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>
5.3 KiB
Styling on the page: flow, padding, margin, gap
Decision
Keep the typed token object on the node; tag its fields with x-style; let on-page editors key off the tag.
Not a free-form style object on every node, and not per-node CSS: the k6n design system (data/k6n/design.md) already says
blocks own no outer margin, spacing comes from the parent layout, spacing values are tokens (none xs sm md lg xl) and never raw
lengths. The layout node is that primitive: flow, columns, gap, padding(Top|Bottom), margin(Top|Bottom), align,
justify, tone, border, reveal, and responsive.{mobile,tablet,desktop}. The template turns those into .k6n-gap-md /
.k6n-p-lg helper classes. So:
| Option | Verdict |
|---|---|
Generic style: {…} on every node |
No. Every template would have to consume it, it re-opens raw CSS in class attributes, and it contradicts "blocks own no margin". |
style-class nodes (named CSS class, linked by edge) |
Keep, but not for on-page tweaks. Shared by design (blast radius = every layout that links it) and emitted into <style> in the head, which a per-node patch cannot refresh. Good for reusable surfaces (tone, border); edit them in the dialog. |
| Per-type editor code ("if layout then …") | No. Every new block type would need editor changes. |
x-style on schema properties |
Yes. The schema already is the source of truth (x-graph, x-editor); one set op per change (undo/redo, coalescing, 409 all free); any type opts in by annotating a field, zero editor code. |
Blocks that have no style fields (hero, text, …) get spacing by being wrapped in a layout — one atomic batch
(insert layout + move block into it), undone in one step. The wrapper type is the schema tagged x-style.wrapper.
x-style (schema contract)
On a property:
"paddingTop": {
"type": "string", "enum": ["none","xs","sm","md","lg","xl"],
"x-style": { "kind": "spacing", "box": "padding", "side": "top", "overrides": "padding", "token": "--k6n-sp-{value}" }
}
| key | meaning |
|---|---|
kind |
what the field is: spacing gap flow columns align justify tone border reveal choice |
box, side |
spacing only: padding/margin × all top bottom left right x y |
overrides |
sibling this field wins over (paddingTop over padding); drives the "inherited" display and which field a handle edits |
token |
CSS custom property template that resolves an enum value to a length. The editor measures real pixels from the page's own CSS (so fluid clamp() tokens are right) and snaps drags to the nearest token |
On the schema root: "x-style": { "wrapper": true } marks the type blocks can be wrapped in.
responsive.<bp>.<field> (the $defs/breakpoint properties) are matched to base fields by name: those fields get a breakpoint tab
and write to responsive.<bp>.<field>. Today only flow and columns are declared there - the template has classes for nothing else.
Fallback. For schemas without x-style (e.g. production before the annotated schemas deploy) the editor infers the same
descriptors from the property name + enum (gap, padding|margin(Top|Bottom|Left|Right|X|Y) with a none…xl scale, flow,
columns, align, justify, tone, border, reveal) and says so in the panel. The wrapper tag is never guessed.
Raw-length fields (grid.gap: "1rem") are intentionally not inferred.
Annotated in data/k6n/schemas: layout (all fields, $defs/breakpoint, wrapper), consulting-band (tone, align, width),
and every block with a numeric columns.
What the editor does with it
- Handles on the page (
src/style/BoxHandles.tsx): padding (green), margin (orange) and gap (purple) bands from computed style, each with a grip. Drag → live inline preview → snaps to a token on release → one coalescedset. Only sides the schema can express get a grip (left/rightfall back to theallfield and say "(all sides)"). - Style dialog (
src/style/StylePanel.tsx, toolbar ◐): flow / align / justify chips, columns stepper, a margin ⟶ padding box diagram with one select per expressible side (override fields show what they inherit), gap chips with px, tone / border / reveal, breakpoint tabs. Registered withmatch: (info) => describeStyle(info.schema).length > 0: by schema, not by type. - Wrap in layout (toolbar ▣) for blocks without style fields.
- Pure, tested pieces:
model.ts(descriptors, effective values, patches),tokens.ts(measure/snap),handles.ts(geometry, drag maths).
Open points / suggestions for the CMS side
- Responsive spacing is not possible today: no
gap/padding/marginclasses per breakpoint. If wanted: addx-stylespacing fields to$defs/breakpointand emit e.g.k6n-gap-md@tclasses fromlayout.gohtml; the editor picks them up unchanged. - Left/right padding and margin have no fields (
paddingTop/Bottomonly), so those grips edit the all-sides field. AddingpaddingX/marginX(side: "x") gives them their own handles with no editor change. InsertOpin@k6n/cms-editorlacks the optionalidthe server accepts (the editor casts for the wrap batch).- A layout linked to a
style-classinheritstone/borderfrom it; the panel shows the layout's own value only.