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 const LEGACY_FLOW = ['vertical', 'horizontal', 'grid'] const ICON: Record> = { 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): 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..*`. */ export function StylePanel({ node, schema, ctrl }: EditorProps) { const fields = useMemo(() => describeStyle(schema), [schema]) const [data, setData] = useState(node.data ?? {}) const [bp, setBp] = useState() 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 (
{hasResponsive && (
{[undefined, ...BREAKPOINTS].map((b) => ( ))}
)} {bp &&

Overrides for {bp === 'mobile' ? 'small screens' : bp === 'tablet' ? 'tablets' : 'desktops'}; unset values inherit from “All sizes”.

} {of('flow', 'columns', 'align', 'justify').length > 0 && (
{of('flow', 'columns', 'align', 'justify') .filter((f) => f.kind !== 'columns' || showColumns) .map((f) => ( set(f, v)} /> ))}
)} {!bp && fields.some((f) => f.kind === 'spacing' || f.kind === 'gap') && (
{fields.filter((f) => f.kind === 'gap').map((f) => ( set(f, v)} scale={f.options.length ? ctrl.scaleFor(f) : undefined} /> ))}
)} {of('tone', 'border').length > 0 && (
{of('tone', 'border').map((f) => ( set(f, v)} /> ))}
)} {of('reveal', 'choice').length > 0 && (
{of('reveal', 'choice').map((f) => ( set(f, v)} /> ))}
)} {fields.some((f) => f.inferred) &&

Controls detected from field names. Add x-style to the schema to make this explicit.

}
) } function Section({ title, children }: { title: string; children: ReactNode }) { return (

{title}

{children}
) } // --- controls ------------------------------------------------------------------------ function Control({ f, value, onChange, scale }: { f: StyleField; value: unknown; onChange(v: unknown): void; scale?: Scale }) { const set = value !== undefined && value !== null return (
{f.label} {set && ( )}
) } function Widget({ f, value, onChange, scale }: { f: StyleField; value: unknown; onChange(v: unknown): void; scale?: Scale }) { if (f.kind === 'columns') return 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 ( ) } return } function Chips({ options, value, onChange, icons = {}, scale }: { options: string[]; value?: string; onChange(v: string): void; icons?: Record; scale?: Scale }) { return (
{options.map((o) => { const px = scale ? pxOf(scale, o) : null return ( ) })}
) } function Columns({ f, value, onChange }: { f: StyleField; value: unknown; onChange(v: unknown): void }) { if (f.options.length) return onChange(Number(v))} /> const min = f.min ?? 1 const max = f.max ?? 12 const n = typeof value === 'number' ? value : undefined return (
onChange(e.target.value === '' ? undefined : Math.min(max, Math.max(min, Number(e.target.value))))} />
) } // --- 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) => { const f = fieldForSide(fields, box, edge) if (!f) return – 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 {f.side === 'all' ? 'all' : f.side} seenAll.add(f.name) } const { value, from } = effective(data, fields, f) const own = data[f.name] const scale = ctrl.scaleFor(f) return ( ) } const render = (i: number): ReactNode => { const box = boxes[i] const seen = new Set() return (
{box}
{cell(box, 'top', seen)}
{cell(box, 'left', seen)}
{i + 1 < boxes.length ? render(i + 1) : content}
{cell(box, 'right', seen)}
{cell(box, 'bottom', seen)}
) } return
{render(0)}
}