| 1 |
// ===== ai-shared/kit.js — shared primitives for the BetterDocs AI surfaces ===== |
| 2 |
// Ported from the AI Studio mockup (ai-kit.jsx). Plain-React components styled |
| 3 |
// with inline styles + the CSS custom properties defined in tokens.scss. Every |
| 4 |
// portal root carries `.bd-ai-scope` so those `var(--x)` tokens resolve. |
| 5 |
// |
| 6 |
// Consumed by BOTH the Write-with-AI modal and the restyled Edit-with-AI modal. |
| 7 |
|
| 8 |
import { useState, useEffect, useRef, createPortal } from "@wordpress/element"; |
| 9 |
import Select from "react-select"; |
| 10 |
|
| 11 |
// ── Icons (stroke 1.7, currentColor — same convention as the mockup) ───────── |
| 12 |
export const AIcon = ({ d, size = 16, fill = "none", stroke = "currentColor", sw = 1.7, children, ...p }) => ( |
| 13 |
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill} stroke={stroke} |
| 14 |
strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" {...p}> |
| 15 |
{d && <path d={d} />} |
| 16 |
{children} |
| 17 |
</svg> |
| 18 |
); |
| 19 |
|
| 20 |
export const IcClose = (p) => <AIcon {...p} d="M6 6l12 12M6 18 18 6" />; |
| 21 |
export const IcChevDown = (p) => <AIcon {...p} d="m6 9 6 6 6-6" />; |
| 22 |
export const IcPlus = (p) => <AIcon {...p} d="M12 5v14M5 12h14" />; |
| 23 |
export const IcTrash = (p) => <AIcon {...p}><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" /></AIcon>; |
| 24 |
export const IcGrip = (p) => ( |
| 25 |
<AIcon {...p} fill="currentColor" stroke="none"> |
| 26 |
<circle cx="9" cy="6" r="1.4" /><circle cx="9" cy="12" r="1.4" /><circle cx="9" cy="18" r="1.4" /> |
| 27 |
<circle cx="15" cy="6" r="1.4" /><circle cx="15" cy="12" r="1.4" /><circle cx="15" cy="18" r="1.4" /> |
| 28 |
</AIcon> |
| 29 |
); |
| 30 |
export const IcCheck = (p) => <AIcon {...p} d="M5 12l5 5L20 7" />; |
| 31 |
export const IcArrowR = (p) => <AIcon {...p} d="M5 12h14M13 5l7 7-7 7" />; |
| 32 |
export const IcRefresh = (p) => <AIcon {...p} d="M21 12a9 9 0 1 1-3.51-7.13M21 4v5h-5" />; |
| 33 |
export const IcAlert = (p) => <AIcon {...p}><circle cx="12" cy="12" r="10" /><path d="M12 8v5M12 16.5h.01" /></AIcon>; |
| 34 |
export const IcList = (p) => <AIcon {...p} d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />; |
| 35 |
export const IcSummary = (p) => <AIcon {...p} d="M4 6h16M4 10h10M4 14h16M4 18h7" />; |
| 36 |
|
| 37 |
// BetterDocs AI mark — the accent-colored pencil from the modal header |
| 38 |
export const AIMark = ({ size = 18 }) => ( |
| 39 |
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" |
| 40 |
stroke="var(--ai)" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"> |
| 41 |
<path d="M12 20h9" /> |
| 42 |
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z" /> |
| 43 |
</svg> |
| 44 |
); |
| 45 |
export const AIMarkWhite = ({ size = 16 }) => ( |
| 46 |
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="white" |
| 47 |
strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"> |
| 48 |
<path d="M12 20h9" /> |
| 49 |
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z" /> |
| 50 |
</svg> |
| 51 |
); |
| 52 |
export const AIBadgeIcon = ({ size = 14 }) => ( |
| 53 |
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" stroke="none"> |
| 54 |
<path d="M12 2l1.9 5.1L19 9l-5.1 1.9L12 16l-1.9-5.1L5 9l5.1-1.9z" /> |
| 55 |
<path d="M19 14l.9 2.4L22 17l-2.1.6L19 20l-.9-2.4L16 17l2.1-.6z" /> |
| 56 |
</svg> |
| 57 |
); |
| 58 |
|
| 59 |
// ── AI icon disc (header) ──────────────────────────────────────────────────── |
| 60 |
export const AIDisc = ({ size = 44 }) => ( |
| 61 |
<div style={{ |
| 62 |
width: size, height: size, borderRadius: "50%", |
| 63 |
background: "var(--ai-50)", flexShrink: 0, |
| 64 |
display: "flex", alignItems: "center", justifyContent: "center", |
| 65 |
}}> |
| 66 |
<AIMark size={size * 0.42} /> |
| 67 |
</div> |
| 68 |
); |
| 69 |
|
| 70 |
// ── Buttons ────────────────────────────────────────────────────────────────── |
| 71 |
export const PrimaryBtn = ({ children, icon, onClick, disabled, style }) => ( |
| 72 |
<button type="button" onClick={disabled ? undefined : onClick} disabled={disabled} style={{ |
| 73 |
display: "inline-flex", alignItems: "center", gap: 8, |
| 74 |
background: disabled ? "#cbd5e1" : "var(--green)", color: "white", |
| 75 |
padding: "11px 20px", borderRadius: 8, border: "none", |
| 76 |
fontSize: 14, fontWeight: 600, whiteSpace: "nowrap", |
| 77 |
cursor: disabled ? "not-allowed" : "pointer", |
| 78 |
boxShadow: disabled ? "none" : "0 1px 0 rgba(0,0,0,.04), inset 0 -1px 0 rgba(0,0,0,.06)", |
| 79 |
transition: "background .15s, transform .05s", |
| 80 |
...style, |
| 81 |
}} |
| 82 |
onMouseEnter={e => { if (!disabled) e.currentTarget.style.background = "var(--green-600)"; }} |
| 83 |
onMouseLeave={e => { if (!disabled) e.currentTarget.style.background = "var(--green)"; }}> |
| 84 |
{icon}{children} |
| 85 |
</button> |
| 86 |
); |
| 87 |
|
| 88 |
// Outlined accent button — uses the AI accent color |
| 89 |
export const AccentBtn = ({ children, icon, onClick, disabled, style }) => ( |
| 90 |
<button type="button" onClick={disabled ? undefined : onClick} disabled={disabled} style={{ |
| 91 |
display: "inline-flex", alignItems: "center", gap: 8, |
| 92 |
background: "white", |
| 93 |
color: disabled ? "var(--muted-2)" : "var(--ai)", |
| 94 |
padding: "11px 18px", borderRadius: 8, |
| 95 |
border: `1.5px solid ${disabled ? "var(--border)" : "var(--ai-100)"}`, |
| 96 |
fontSize: 14, fontWeight: 600, whiteSpace: "nowrap", |
| 97 |
cursor: disabled ? "not-allowed" : "pointer", |
| 98 |
transition: "background .15s, border-color .15s, color .15s", |
| 99 |
...style, |
| 100 |
}} |
| 101 |
onMouseEnter={e => { if (!disabled) { e.currentTarget.style.background = "var(--ai-50)"; e.currentTarget.style.borderColor = "var(--ai)"; } }} |
| 102 |
onMouseLeave={e => { if (!disabled) { e.currentTarget.style.background = "white"; e.currentTarget.style.borderColor = "var(--ai-100)"; } }}> |
| 103 |
{icon}{children} |
| 104 |
</button> |
| 105 |
); |
| 106 |
|
| 107 |
export const GhostBtn = ({ children, icon, onClick, style, disabled }) => ( |
| 108 |
<button type="button" onClick={disabled ? undefined : onClick} disabled={disabled} style={{ |
| 109 |
display: "inline-flex", alignItems: "center", gap: 8, |
| 110 |
background: "white", color: disabled ? "var(--muted-2)" : "var(--ink-2)", |
| 111 |
padding: "10px 16px", borderRadius: 8, |
| 112 |
border: "1px solid var(--border)", |
| 113 |
fontSize: 14, fontWeight: 600, whiteSpace: "nowrap", |
| 114 |
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.7 : 1, |
| 115 |
transition: "background .15s, border-color .15s", |
| 116 |
...style, |
| 117 |
}} |
| 118 |
onMouseEnter={e => { if (!disabled) { e.currentTarget.style.background = "#f8fafc"; e.currentTarget.style.borderColor = "#d8dde3"; } }} |
| 119 |
onMouseLeave={e => { if (!disabled) { e.currentTarget.style.background = "white"; e.currentTarget.style.borderColor = "var(--border)"; } }}> |
| 120 |
{icon}{children} |
| 121 |
</button> |
| 122 |
); |
| 123 |
|
| 124 |
// Quiet tertiary text button — used for "Discard" |
| 125 |
export const TextBtn = ({ children, onClick, tone = "muted", style }) => { |
| 126 |
const color = tone === "danger" ? "#dc2626" : "var(--muted)"; |
| 127 |
return ( |
| 128 |
<button type="button" onClick={onClick} style={{ |
| 129 |
display: "inline-flex", alignItems: "center", gap: 6, |
| 130 |
background: "transparent", color, padding: "10px 12px", |
| 131 |
border: "none", borderRadius: 8, fontSize: 13.5, fontWeight: 600, cursor: "pointer", |
| 132 |
transition: "background .12s, color .12s", ...style, |
| 133 |
}} |
| 134 |
onMouseEnter={e => { e.currentTarget.style.background = tone === "danger" ? "#fef2f2" : "#f4f6f8"; e.currentTarget.style.color = tone === "danger" ? "#dc2626" : "var(--ink-2)"; }} |
| 135 |
onMouseLeave={e => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = color; }}> |
| 136 |
{children} |
| 137 |
</button> |
| 138 |
); |
| 139 |
}; |
| 140 |
|
| 141 |
// ── "AI generated" pill ────────────────────────────────────────────────────── |
| 142 |
// Neutral ash pill so the surface isn't flooded with green — the AI identity is |
| 143 |
// carried by the small green sparkle icon alone. |
| 144 |
export const AIPill = ({ children = "AI generated", style }) => ( |
| 145 |
<span style={{ |
| 146 |
display: "inline-flex", alignItems: "center", gap: 6, |
| 147 |
background: "var(--text-color-50, #f9fafb)", color: "var(--text-color-600, #667085)", |
| 148 |
padding: "5px 11px 5px 9px", borderRadius: 999, |
| 149 |
fontSize: 11.5, fontWeight: 700, letterSpacing: 0.1, whiteSpace: "nowrap", flexShrink: 0, |
| 150 |
border: "1px solid var(--text-color-200, #eaecf0)", ...style, |
| 151 |
}}> |
| 152 |
<span style={{ color: "var(--ai)", display: "flex" }}><AIBadgeIcon size={13} /></span>{children} |
| 153 |
</span> |
| 154 |
); |
| 155 |
|
| 156 |
// ── WP-style notice banner (light red, left accent bar) ────────────────────── |
| 157 |
export const Banner = ({ tone = "error", children, style }) => { |
| 158 |
const t = tone === "error" |
| 159 |
? { bg: "var(--wp-red-50)", bar: "var(--wp-red)", fg: "#8a1f20", icon: "var(--wp-red)" } |
| 160 |
: { bg: "#fef6e0", bar: "#d9a406", fg: "#8a6400", icon: "#d9a406" }; |
| 161 |
return ( |
| 162 |
<div style={{ |
| 163 |
display: "flex", alignItems: "center", gap: 10, |
| 164 |
background: t.bg, color: t.fg, |
| 165 |
borderRadius: 8, |
| 166 |
padding: "12px 14px", fontSize: 13, fontWeight: 500, lineHeight: 1.5, |
| 167 |
...style, |
| 168 |
}}> |
| 169 |
<span style={{ color: t.icon, flexShrink: 0, display: "flex" }}><IcAlert size={17} sw={2} /></span> |
| 170 |
<span style={{ flex: 1 }}>{children}</span> |
| 171 |
</div> |
| 172 |
); |
| 173 |
}; |
| 174 |
|
| 175 |
// ── Spinner + inline "working" status ──────────────────────────────────────── |
| 176 |
export const Spinner = ({ size = 18, color = "var(--ai)", sw = 2.4 }) => ( |
| 177 |
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ animation: "bd-ai-spin .8s linear infinite", flexShrink: 0 }}> |
| 178 |
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity=".18" strokeWidth={sw} style={{ color }} /> |
| 179 |
<path d="M21 12a9 9 0 0 0-9-9" stroke={color} strokeWidth={sw} strokeLinecap="round" /> |
| 180 |
</svg> |
| 181 |
); |
| 182 |
export const WorkingDots = ({ color = "var(--ai)" }) => ( |
| 183 |
<span style={{ display: "inline-flex", gap: 3, marginLeft: 1 }}> |
| 184 |
{[0, 1, 2].map(i => ( |
| 185 |
<span key={i} style={{ |
| 186 |
width: 4, height: 4, borderRadius: "50%", background: color, |
| 187 |
animation: "bd-ai-dotPulse 1.1s ease-in-out infinite", animationDelay: `${i * 0.16}s`, |
| 188 |
}} /> |
| 189 |
))} |
| 190 |
</span> |
| 191 |
); |
| 192 |
|
| 193 |
// ── Inputs ─────────────────────────────────────────────────────────────────── |
| 194 |
export const FieldLabel = ({ children, required, style }) => ( |
| 195 |
<div style={{ fontSize: 13, fontWeight: 700, color: "var(--ink-2)", marginBottom: 8, ...style }}> |
| 196 |
{children}{required && <span style={{ color: "#ef4444", marginLeft: 3 }}>*</span>} |
| 197 |
</div> |
| 198 |
); |
| 199 |
|
| 200 |
export const TextInput = ({ value, onChange, placeholder, autoFocus, disabled }) => { |
| 201 |
const [focus, setFocus] = useState(false); |
| 202 |
return ( |
| 203 |
<input |
| 204 |
type="text" autoFocus={autoFocus} value={value} placeholder={placeholder} disabled={disabled} |
| 205 |
onChange={(e) => onChange(e.target.value)} |
| 206 |
onFocus={() => setFocus(true)} onBlur={() => setFocus(false)} |
| 207 |
style={{ |
| 208 |
width: "100%", border: `1px solid ${focus ? "var(--green)" : "var(--border)"}`, |
| 209 |
background: focus ? "white" : "#fcfcfd", |
| 210 |
boxShadow: focus ? "0 0 0 3px rgba(0,184,132,.14)" : "none", |
| 211 |
borderRadius: 10, padding: "12px 14px", fontSize: 14, color: "var(--ink)", |
| 212 |
outline: "none", transition: "border-color .15s, box-shadow .15s, background .15s", |
| 213 |
}} /> |
| 214 |
); |
| 215 |
}; |
| 216 |
|
| 217 |
export const TextArea = ({ value, onChange, placeholder, rows = 4, disabled }) => { |
| 218 |
const [focus, setFocus] = useState(false); |
| 219 |
return ( |
| 220 |
<textarea |
| 221 |
rows={rows} value={value} placeholder={placeholder} disabled={disabled} |
| 222 |
onChange={(e) => onChange(e.target.value)} |
| 223 |
onFocus={() => setFocus(true)} onBlur={() => setFocus(false)} |
| 224 |
style={{ |
| 225 |
width: "100%", resize: "vertical", |
| 226 |
border: `1px solid ${focus ? "var(--green)" : "var(--border)"}`, |
| 227 |
background: focus ? "white" : "#fcfcfd", |
| 228 |
boxShadow: focus ? "0 0 0 3px rgba(0,184,132,.14)" : "none", |
| 229 |
borderRadius: 10, padding: "12px 14px", fontSize: 14, color: "var(--ink-2)", |
| 230 |
outline: "none", fontFamily: "inherit", lineHeight: 1.55, |
| 231 |
transition: "border-color .15s, box-shadow .15s, background .15s", |
| 232 |
}} /> |
| 233 |
); |
| 234 |
}; |
| 235 |
|
| 236 |
// On/off switch styled to match the kit — used for the title / overwrite toggles. |
| 237 |
export const Toggle = ({ checked, onChange, label, disabled }) => ( |
| 238 |
<label style={{ |
| 239 |
display: "inline-flex", alignItems: "center", gap: 9, |
| 240 |
fontSize: 12.5, fontWeight: 600, color: "var(--ink-2)", |
| 241 |
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.6 : 1, |
| 242 |
}}> |
| 243 |
<span |
| 244 |
role="switch" aria-checked={!!checked} |
| 245 |
onClick={() => { if (!disabled) onChange(!checked); }} |
| 246 |
style={{ |
| 247 |
position: "relative", width: 36, height: 20, flexShrink: 0, |
| 248 |
borderRadius: 999, background: checked ? "var(--green)" : "#cbd5e1", |
| 249 |
transition: "background .15s", |
| 250 |
}}> |
| 251 |
<span style={{ |
| 252 |
position: "absolute", top: 2, left: checked ? 18 : 2, |
| 253 |
width: 16, height: 16, borderRadius: "50%", background: "white", |
| 254 |
boxShadow: "0 1px 2px rgba(15,23,42,.25)", transition: "left .15s", |
| 255 |
}} /> |
| 256 |
</span> |
| 257 |
{label} |
| 258 |
</label> |
| 259 |
); |
| 260 |
|
| 261 |
// Native <select> styled to match the BetterDocs dashboard select control via |
| 262 |
// the shared `.bd-ai-select` class (see ai-shared/tokens.scss). Used for the |
| 263 |
// language / source-type / git / tone / length pickers. |
| 264 |
export const Pick = ({ value, onChange, options, style, disabled }) => ( |
| 265 |
<div style={{ position: "relative", ...style }}> |
| 266 |
<select className="bd-ai-select" value={value} disabled={disabled} onChange={(e) => onChange(e.target.value)}> |
| 267 |
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)} |
| 268 |
</select> |
| 269 |
<span style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", pointerEvents: "none", color: "var(--muted)", display: "flex" }}> |
| 270 |
<IcChevDown size={16} /> |
| 271 |
</span> |
| 272 |
</div> |
| 273 |
); |
| 274 |
|
| 275 |
// react-select dropdown that reuses the BetterDocs dashboard "betterdocs-dropdown" |
| 276 |
// (FAQ order / bulk-actions) markup + classNamePrefix, so its control + styled |
| 277 |
// option menu match the admin exactly. Styling lives in ai-shared/tokens.scss |
| 278 |
// (scoped to .bd-ai-scope). Used for the source / git / language pickers. |
| 279 |
export const DropdownSelect = ({ value, onChange, options, style, disabled, isSearchable = false, placeholder, formatOptionLabel }) => ( |
| 280 |
<div className="betterdocs-dropdown-wrapper" style={style}> |
| 281 |
<Select |
| 282 |
className="betterdocs-dropdown-select" |
| 283 |
classNamePrefix="betterdocs-dropdown" |
| 284 |
value={options.find((o) => o.value === value) || null} |
| 285 |
options={options} |
| 286 |
onChange={(opt) => onChange(opt ? opt.value : "")} |
| 287 |
isDisabled={disabled} |
| 288 |
isSearchable={isSearchable} |
| 289 |
isClearable={false} |
| 290 |
placeholder={placeholder} |
| 291 |
formatOptionLabel={formatOptionLabel} |
| 292 |
menuPlacement="auto" |
| 293 |
menuShouldScrollIntoView={false} |
| 294 |
/> |
| 295 |
</div> |
| 296 |
); |
| 297 |
|
| 298 |
// ── Modal shell — rendered through a portal at document.body ────────────────── |
| 299 |
export const Modal = ({ open, onClose, width = 600, children, labelledBy }) => { |
| 300 |
useEffect(() => { |
| 301 |
if (!open) return; |
| 302 |
const onKey = (e) => { if (e.key === "Escape") onClose(); }; |
| 303 |
document.addEventListener("keydown", onKey); |
| 304 |
return () => document.removeEventListener("keydown", onKey); |
| 305 |
}, [open, onClose]); |
| 306 |
if (!open) return null; |
| 307 |
return createPortal( |
| 308 |
<div className="bd-ai-scope" style={{ position: "fixed", inset: 0, zIndex: 100050 }} role="dialog" aria-modal="true" aria-labelledby={labelledBy}> |
| 309 |
<div onClick={onClose} style={{ position: "absolute", inset: 0, background: "rgba(15,23,42,.4)", animation: "bd-ai-fadeIn .15s ease" }} /> |
| 310 |
<div style={{ |
| 311 |
position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", |
| 312 |
width, maxWidth: "calc(100vw - 48px)", maxHeight: "calc(100vh - 48px)", |
| 313 |
background: "white", borderRadius: 16, boxShadow: "var(--shadow-lg)", |
| 314 |
display: "flex", flexDirection: "column", overflow: "hidden", |
| 315 |
animation: "bd-ai-modalIn .2s cubic-bezier(.2,.7,.3,1)", |
| 316 |
}}> |
| 317 |
{children} |
| 318 |
{/* Matches the admin .bd-faq-modal-close-button — 32px ash rounded |
| 319 |
square with the shared X glyph, no shadow. */} |
| 320 |
<button type="button" onClick={onClose} title="Close" aria-label="Close" style={{ |
| 321 |
position: "absolute", top: 16, right: 22, zIndex: 2, |
| 322 |
width: 32, height: 32, borderRadius: 9, border: "none", |
| 323 |
display: "inline-flex", alignItems: "center", justifyContent: "center", |
| 324 |
color: "var(--muted)", background: "#f2f4f7", cursor: "pointer", |
| 325 |
boxShadow: "none", transition: "all .2s ease-in-out", |
| 326 |
}} |
| 327 |
onMouseEnter={(e) => { e.currentTarget.style.background = "#eaecf0"; e.currentTarget.style.color = "var(--ink)"; }} |
| 328 |
onMouseLeave={(e) => { e.currentTarget.style.background = "#f2f4f7"; e.currentTarget.style.color = "var(--muted)"; }}> |
| 329 |
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 330 |
<path d="M1.69687 14L0.296875 12.6L5.89687 7L0.296875 1.4L1.69687 0L7.29688 5.6L12.8969 0L14.2969 1.4L8.69688 7L14.2969 12.6L12.8969 14L7.29688 8.4L1.69687 14Z" fill="currentColor" /> |
| 331 |
</svg> |
| 332 |
</button> |
| 333 |
</div> |
| 334 |
</div>, |
| 335 |
document.body |
| 336 |
); |
| 337 |
}; |
| 338 |
|