Add on-page editor for the go-graph-cms

Injectable React editor that is side-loaded onto the statically rendered
page and edits through the CMS HTTP API (POST /api/edit).

- Shadow-root UI over server HTML; patches morphed in, undo/redo from
  server inverses (reuses @k6n/cms-editor EditClient/morph/markdown editor)
- Sign-in via go-auth device grant as the CMS client (aud matches), token
  refresh + 401 retry; live preview swapped in on load and on tab focus
- Schema-driven property form (arrays, maps, variants, $ref, validation)
- Every string property editable where it is rendered (value-bound inline
  fields and attribute chips), markdown WYSIWYG for marked fields
- Block palette + drag & drop with ghost drop targets, insertion slots,
  sort snapping between siblings (Alt to nest)
- x-style annotations: on-page padding/margin/gap handles, Style panel,
  wrap-in-layout; x-graph label/group for titles and grouping
- Dev workflow: Vite proxy to the live site (token injected server-side)
  or an offline in-memory mock CMS; jsdom test suite

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-31 22:34:48 +01:00
commit 1af9ec3172
67 files changed
+12322

No files matched your search

+247
View File
@@ -0,0 +1,247 @@
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { EditorProps } from '../registry'
import { useEditorState } from '../ui/hooks'
import { BREAKPOINTS, effective, patchFor, readField } from './model'
import type { Breakpoint, StyleField } from './model'
import { pxOf } from './tokens'
import type { Scale } from './tokens'
import { Icon } from '../ui/Icon'
import { describeStyle, fieldForSide } from './model'
type Data = Record<string, any>
const LEGACY_FLOW = ['vertical', 'horizontal', 'grid']
const ICON: Record<string, Record<string, string>> = {
flow: { row: 'flow-row', stack: 'flow-stack', actions: 'flow-actions', vertical: 'flow-stack', horizontal: 'flow-row', grid: 'flow-grid' },
align: { start: 'align-start', center: 'align-center', end: 'align-end', left: 'text-left', right: 'text-right' },
justify: { start: 'align-start', center: 'align-center', end: 'align-end' },
}
const cap = (o: string) => o[0].toUpperCase() + o.slice(1)
/** Apply a `set` payload to local data the way the server will (null deletes). */
const applyLocal = (data: Data, patch: Record<string, unknown>): Data => {
const next = { ...data }
for (const [k, v] of Object.entries(patch)) v === null ? delete next[k] : (next[k] = v)
return next
}
/**
* The "Style" dialog: controls for every schema field tagged `x-style` (or inferred from naming),
* grouped by what they do. Nothing here knows a node type - add an `x-style` field to any schema
* and it shows up. Spacing is a box-model diagram; breakpoints edit `responsive.<bp>.*`.
*/
export function StylePanel({ node, schema, ctrl }: EditorProps) {
const fields = useMemo(() => describeStyle(schema), [schema])
const [data, setData] = useState<Data>(node.data ?? {})
const [bp, setBp] = useState<Breakpoint | undefined>()
const rev = useEditorState((s) => s.editRev)
useEffect(() => {
if (rev) void ctrl.node(node.id, true).then((n) => setData(n.data ?? {})).catch(() => {})
}, [rev, ctrl, node.id])
const hasResponsive = fields.some((f) => f.responsive.length)
const shown = bp ? fields.filter((f) => f.responsive.includes(bp)) : fields
const set = (f: StyleField, v: unknown) => {
setData((d) => applyLocal(d, patchFor(d, f, v, bp)))
void ctrl.setStyle(node.id, f, v, { bp })
}
const get = (f: StyleField) => readField(data, f, bp)
const of = (...kinds: StyleField['kind'][]) => shown.filter((f) => kinds.includes(f.kind))
const flow = fields.find((f) => f.kind === 'flow')
const flowValue = flow ? String(readField(data, flow, bp) ?? data[flow.name] ?? '') : ''
const showColumns = !flow || !flowValue || ['row', 'grid', 'horizontal'].includes(flowValue)
return (
<div className="cms-style">
{hasResponsive && (
<div className="cms-tabs" role="tablist" aria-label="Breakpoint">
{[undefined, ...BREAKPOINTS].map((b) => (
<button key={b ?? 'base'} role="tab" aria-selected={bp === b} className={bp === b ? 'is-on' : ''} onClick={() => setBp(b)}>
{b ? b[0].toUpperCase() + b.slice(1) : 'All sizes'}
</button>
))}
</div>
)}
{bp && <p className="cms-muted">Overrides for {bp === 'mobile' ? 'small screens' : bp === 'tablet' ? 'tablets' : 'desktops'}; unset values inherit from “All sizes”.</p>}
{of('flow', 'columns', 'align', 'justify').length > 0 && (
<Section title="Layout">
{of('flow', 'columns', 'align', 'justify')
.filter((f) => f.kind !== 'columns' || showColumns)
.map((f) => (
<Control key={f.name} f={f} value={get(f)} onChange={(v) => set(f, v)} />
))}
</Section>
)}
{!bp && fields.some((f) => f.kind === 'spacing' || f.kind === 'gap') && (
<Section title="Spacing">
<BoxModel fields={fields} data={data} onChange={set} ctrl={ctrl} />
{fields.filter((f) => f.kind === 'gap').map((f) => (
<Control key={f.name} f={f} value={data[f.name]} onChange={(v) => set(f, v)} scale={f.options.length ? ctrl.scaleFor(f) : undefined} />
))}
</Section>
)}
{of('tone', 'border').length > 0 && (
<Section title="Surface">
{of('tone', 'border').map((f) => (
<Control key={f.name} f={f} value={get(f)} onChange={(v) => set(f, v)} />
))}
</Section>
)}
{of('reveal', 'choice').length > 0 && (
<Section title="More">
{of('reveal', 'choice').map((f) => (
<Control key={f.name} f={f} value={get(f)} onChange={(v) => set(f, v)} />
))}
</Section>
)}
{fields.some((f) => f.inferred) && <p className="cms-muted cms-style__note">Controls detected from field names. Add <code>x-style</code> to the schema to make this explicit.</p>}
</div>
)
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="cms-style__section">
<h4>{title}</h4>
{children}
</section>
)
}
// --- controls ------------------------------------------------------------------------
function Control({ f, value, onChange, scale }: { f: StyleField; value: unknown; onChange(v: unknown): void; scale?: Scale }) {
const set = value !== undefined && value !== null
return (
<div className="cms-ctl" data-style-field={f.name}>
<div className="cms-ctl__head">
<span className="cms-ctl__label" title={f.description}>
{f.label}
</span>
{set && (
<button type="button" className="cms-ctl__clear" aria-label={`Reset ${f.label}`} title="Reset (inherit)" onClick={() => onChange(undefined)}>
<Icon name="refresh" size={14} />
</button>
)}
</div>
<Widget f={f} value={value} onChange={onChange} scale={scale} />
</div>
)
}
function Widget({ f, value, onChange, scale }: { f: StyleField; value: unknown; onChange(v: unknown): void; scale?: Scale }) {
if (f.kind === 'columns') return <Columns f={f} value={value} onChange={onChange} />
const icons = ICON[f.kind] ?? {}
let options = f.options.map(String)
if (f.kind === 'flow' && !LEGACY_FLOW.includes(String(value))) options = options.filter((o) => !LEGACY_FLOW.includes(o))
if (options.length > 6) {
return (
<select className="cms-input" value={value === undefined ? '' : String(value)} onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}>
<option value="">—</option>
{options.map((o) => (
<option key={o}>{o}</option>
))}
</select>
)
}
return <Chips options={options} value={value as string | undefined} onChange={onChange} icons={icons} scale={scale} />
}
function Chips({ options, value, onChange, icons = {}, scale }: { options: string[]; value?: string; onChange(v: string): void; icons?: Record<string, string>; scale?: Scale }) {
return (
<div className="cms-chips" role="radiogroup">
{options.map((o) => {
const px = scale ? pxOf(scale, o) : null
return (
<button key={o} type="button" role="radio" aria-checked={value === o} className={`cms-chip${value === o ? ' is-on' : ''}`} title={px !== null ? `${Math.round(px)}px` : undefined} onClick={() => onChange(o)}>
{icons[o] && <Icon name={icons[o]} size={15} />}
{icons[o] ? cap(o) : o}
</button>
)
})}
</div>
)
}
function Columns({ f, value, onChange }: { f: StyleField; value: unknown; onChange(v: unknown): void }) {
if (f.options.length) return <Chips options={f.options.map(String)} value={value === undefined ? undefined : String(value)} onChange={(v) => onChange(Number(v))} />
const min = f.min ?? 1
const max = f.max ?? 12
const n = typeof value === 'number' ? value : undefined
return (
<div className="cms-stepper">
<button type="button" className="cms-ico" aria-label="Fewer columns" disabled={(n ?? min) <= min} onClick={() => onChange(Math.max(min, (n ?? min + 1) - 1))}>
−
</button>
<input className="cms-input" type="number" aria-label="Columns" min={min} max={max} value={n ?? ''} placeholder="auto" onChange={(e) => onChange(e.target.value === '' ? undefined : Math.min(max, Math.max(min, Number(e.target.value))))} />
<button type="button" className="cms-ico" aria-label="More columns" disabled={(n ?? 0) >= max} onClick={() => onChange(Math.min(max, (n ?? min - 1) + 1))}>
+
</button>
</div>
)
}
// --- box model ---------------------------------------------------------------------------
type Edge = 'top' | 'right' | 'bottom' | 'left'
/** margin ⟶ padding diagram: one token select per side the schema can express. */
function BoxModel({ fields, data, onChange, ctrl }: { fields: StyleField[]; data: Data; onChange(f: StyleField, v: unknown): void; ctrl: EditorProps['ctrl'] }) {
const boxes = (['margin', 'padding'] as const).filter((b) => fields.some((f) => f.kind === 'spacing' && f.box === b && f.options.length))
if (!boxes.length) return null
const cell = (box: 'margin' | 'padding', edge: Edge, seenAll: Set<string>) => {
const f = fieldForSide(fields, box, edge)
if (!f) return <span className="cms-box__none">–</span>
if (f.side === 'all' || f.side === 'x' || f.side === 'y') {
// One field covers several edges: put its select on the first, mark the rest as linked.
if (seenAll.has(f.name)) return <span className="cms-box__link" title={`${f.label} (shared)`}>
<Icon name="link" size={12} /> {f.side === 'all' ? 'all' : f.side}
</span>
seenAll.add(f.name)
}
const { value, from } = effective(data, fields, f)
const own = data[f.name]
const scale = ctrl.scaleFor(f)
return (
<select
className={`cms-box__sel${own === undefined || own === null ? ' is-inherited' : ''}`}
aria-label={`${box} ${f.side === 'all' ? 'all sides' : edge}`}
value={own === undefined || own === null ? '' : String(own)}
onChange={(e) => onChange(f, e.target.value === '' ? undefined : e.target.value)}
title={f.description}
>
<option value="">{value !== undefined && from !== f ? `${String(value)} (${from.name})` : '—'}</option>
{f.options.map((o) => {
const px = pxOf(scale, String(o))
return (
<option key={String(o)} value={String(o)}>
{String(o)}
{px !== null ? ` · ${Math.round(px)}` : ''}
</option>
)
})}
</select>
)
}
const render = (i: number): ReactNode => {
const box = boxes[i]
const seen = new Set<string>()
return (
<div className={`cms-box cms-box--${box}`} key={box}>
<span className="cms-box__title">{box}</span>
<div className="cms-box__top">{cell(box, 'top', seen)}</div>
<div className="cms-box__left">{cell(box, 'left', seen)}</div>
<div className="cms-box__mid">{i + 1 < boxes.length ? render(i + 1) : <span className="cms-box__content">content</span>}</div>
<div className="cms-box__right">{cell(box, 'right', seen)}</div>
<div className="cms-box__bottom">{cell(box, 'bottom', seen)}</div>
</div>
)
}
return <div className="cms-boxmodel">{render(0)}</div>
}