Files
cms-onpageeditor/src/style/StylePanel.tsx
T
matsandClaude Sonnet 5.5 1af9ec3172 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>
2026-10-31 22:34:48 +01:00

248 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
}