Style panel gets a real-viewport iframe preview (390/820/1280px) tied to the breakpoint tab, reloaded after each edit, and spacing/gap controls per breakpoint. The editor does not boot inside the preview frame. Mock and tests mirror the new k6n layout template. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
5.8 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 (done):
$defs/breakpointdeclaresgap,padding(Top|Bottom),margin(Top|Bottom),align,justify; the k6n layout template emitsk6n-t-gap-md/k6n-m-pt-smclasses (tablet ≤900px, mobile ≤620px). Columns are resolved in the template into--k6n-cols/--k6n-cols-t/--k6n-cols-m(defaults: tablet ≤2, mobile 1); cards/quotes/cases/projects/people read the same variables. Device preview: the Style panel's Show … preview opens the page in an iframe at 390/820/1280px (real viewport, so media queries apply), reloaded after each edit; the editor does not boot inside it (window.name). Only the k6n content root has this so far; the shareddata/templates/layout.gohtmlstill uses the old class scheme. - 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.