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>
73 lines
5.3 KiB
Markdown
73 lines
5.3 KiB
Markdown
# 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:
|
||
|
||
```json
|
||
"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 coalesced `set`. Only sides the schema can express
|
||
get a grip (`left/right` fall back to the `all` field 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 with `match: (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
|
||
|
||
1. **Responsive spacing** is not possible today: no `gap/padding/margin` classes per breakpoint. If wanted: add `x-style` spacing
|
||
fields to `$defs/breakpoint` and emit e.g. `k6n-gap-md@t` classes from `layout.gohtml`; the editor picks them up unchanged.
|
||
2. **Left/right padding and margin** have no fields (`paddingTop/Bottom` only), so those grips edit the all-sides field. Adding
|
||
`paddingX/marginX` (`side: "x"`) gives them their own handles with no editor change.
|
||
3. `InsertOp` in `@k6n/cms-editor` lacks the optional `id` the server accepts (the editor casts for the wrap batch).
|
||
4. A layout linked to a `style-class` inherits `tone`/`border` from it; the panel shows the layout's own value only.
|