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>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-31 22:34:48 +01:00
commit 1af9ec3172
67 files changed
+12322

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
# 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.