@particle-academy/fancy-cms-ui 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,395 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ > **Pre-1.0:** breaking changes land in MINOR releases. Read the entry, not the
9
+ > version number.
10
+
11
+ > **0.2.0 and 0.3.0 are missing below.** This file was last updated at 0.1.1 and
12
+ > the two releases after it were never written up; `git log` is the record for
13
+ > those. Noted rather than quietly back-filled from memory.
14
+
15
+ ## [Unreleased]
16
+
17
+ ## [0.8.0] - 2026-10-23
18
+
19
+ What a professional landing page needs from the renderer, found by mapping
20
+ the stack for the lab's website-builder agent.
21
+
22
+ ### Changed
23
+
24
+ - **BREAKING (markup): the `[data-cms]` element is now the semantic element.**
25
+ `heading` renders `<h1>`-`<h6>` (`props.level`, default 2), `text` renders
26
+ `<p>`, `section` renders `<section>`, `button` and `link` with a safe `href`
27
+ render `<a>`, and `list` renders `<ul>`/`<ol>`. Previously every node was a
28
+ `<div>`. **What to do:** nothing, unless your own CSS targets
29
+ `div[data-cms]` or `[data-cms] > a` (the inner `<a>` of a `link` is gone;
30
+ the link IS the element now). Select by `[data-cms="<id>"]`, which is
31
+ unchanged.
32
+ - **BREAKING (security, CSS): a style value that could escape its declaration
33
+ is dropped.** A value containing `{`, `}` or `<`, or a `;` outside
34
+ parentheses or quotes, emits nothing. **What to do:** nothing for any real
35
+ colour, gradient, font stack or `url(data:...;base64,...)`, which all still
36
+ pass. Only a value that was trying to close the rule changes.
37
+ - `direction`, `columns` and the new container knobs are read **per
38
+ breakpoint**. A grid can now go 1 column -> 2 -> 3, and a stack column ->
39
+ row. Base output is unchanged.
40
+
41
+ ### Added
42
+
43
+ - Style properties: `paddingX` / `paddingY`, `marginX` (`"auto"` centres) /
44
+ `marginY`, `maxWidth`, `minHeight`, `aspectRatio`, `sticky`, `zIndex`,
45
+ `overflow`, `hidden` (per breakpoint), `textTransform`, `backgroundSize`,
46
+ `backgroundPosition`, `wrap`, and `columnMin` for an auto-fit grid that is
47
+ responsive with no breakpoints at all.
48
+ - **Theme tokens are emitted** as `:root { --cms-* }` custom properties
49
+ (`theme.color.primary` -> `--cms-color-primary`), so styles can reference
50
+ `var(--cms-color-primary)`.
51
+ - `props.as` on containers: `section`, `header`, `nav`, `main`, `footer`,
52
+ `article`, `aside`, `div`. Anything else is ignored, never used as a tag.
53
+ - `list` element (`props.items`, `props.ordered`), and List in the editor's add menu.
54
+ - `props.anchor` on any node renders as its `id`, so an in-page link
55
+ (`href="#pricing"`) has somewhere to land. Only a lowercase slug of up to 64
56
+ characters is rendered (`ANCHOR_PATTERN`, `anchorFor` exported); anything
57
+ else is ignored. The PHP renderer follows in fancy-cms 0.5.0.
58
+ - `image`: `props.fit` (`cover` default, `contain`, `fill`, `none`,
59
+ `scale-down`) and `props.loading` (lazy by default).
60
+ - **`agent-guide/professional-pages.md`**, shipped in the tarball: how an agent
61
+ builds a professional page with these primitives (the bar, scales, batching
62
+ ops, deferring to a person's edits). It is what the lab's website-builder
63
+ agent is measured with, so its numbers are tuned by lab passes, not by taste.
64
+ - `safeHref`, `elementFor`, `LANDMARK_TAGS` and `safeCssValue` are exported.
65
+ Hrefs pass for relative URLs and `http`/`https`/`mailto`/`tel`; every other
66
+ scheme (`javascript:`, `data:` ...) is dropped. External http(s) links get
67
+ `rel="noopener noreferrer"`.
68
+ - **Security: nothing a document holds can escape the inline stylesheet.**
69
+ Two routes carried raw strings into `<style>`. A node id went into
70
+ `[data-cms="<id>"]` unescaped, so an id containing `</style><script>` ended
71
+ the stylesheet and injected a script; ids now pass through `cssString`
72
+ (`\` and `"` escaped, control characters and `<` as hex escapes), and the
73
+ selector still matches the attribute. And `align`, `justify`, `direction`,
74
+ `textAlign`, a Length's unit and the numeric fields were written without a
75
+ check; now a Length needs a finite number and a known unit, a keyword must be
76
+ on its allowlist, and a number must be finite, or the declaration is dropped.
77
+ **Nothing to do:** every valid document emits the same CSS as before.
78
+ `parity/canonical-doc-hostile.json` holds the PHP emitter to the same rules.
79
+ Found by the PHP port's source review.
80
+ - **Security / Fixed: node ids and breakpoint names that are also
81
+ `Object.prototype` names.** Ids arrive in ops, which an agent writes. The node
82
+ map was read as a plain object, so `constructor`, `toString` and friends
83
+ resolved to built-ins: inserting a node called `constructor` was refused as a
84
+ duplicate, removing an absent `toString` "succeeded" and bumped `seq`, and a
85
+ node called `__proto__` replaced the map's prototype instead of being added.
86
+ Every lookup and write in the reducer, renderer, CSS emitter and migration now
87
+ uses own properties only (`src/document/own.ts`). **Nothing to do:** a page
88
+ whose ids are ordinary strings behaves exactly as before. Found by the PHP
89
+ port, which has no prototype to see.
90
+ - **Fixed:** `reorder_roots` accepted a list that repeated a root as long as
91
+ its length matched (`["s1","s1"]` for roots s1, s2), rewrote that root's key
92
+ twice and left the other's alone, so the page order became arbitrary. It is
93
+ now refused as not a permutation, like a list with an unknown id. Found while
94
+ porting the reducer to PHP.
95
+ - Parity: `parity/run.mjs` checks every `parity/canonical*.json`;
96
+ `canonical-doc-0.8.json` covers the new properties and the values that must
97
+ be dropped.
98
+
99
+ ### Fixed
100
+
101
+ - **`CHANGELOG.md` is now in the published tarball.** `files` did not whitelist it, so npm never shipped it — and this package puts breaking changes in MINOR releases and tells you in the README to read the entry before taking one. The instruction existed for the author, who has the file, and not for the consumer, who is the only one being instructed. Nothing for you to do; the file simply arrives from this release on.
102
+ - Dropped `docs` from `files`: it was declared and never existed, so the package claimed to ship it and did not.
103
+
104
+ ### Security
105
+
106
+ - `source-map-js` is pinned forward to `^1.2.2` via `overrides`. Versions up to
107
+ 1.2.1 allow an event-loop denial of service through indexed source-map section
108
+ offsets, and it arrives here transitively through the build toolchain.
109
+ **Nothing for a consumer to do, and no runtime change**: an npm package does
110
+ not ship a lockfile, so this governs builds OF this repo, not anything
111
+ installed FROM it. Recorded rather than left silent because the override it
112
+ sits beside — `shell-quote` `^1.9.0`, added for an earlier advisory — was
113
+ carried with no note of why, and had drifted back inside the vulnerable range
114
+ before anyone looked.
115
+ - **Dev toolchain only — no runtime change, nothing for consumers to do.** The
116
+ lockfile now resolves `vite` 8.3.5 (was 8.3.0), whose bundled
117
+ `launch-editor` fixes GHSA-587h-7wmj-h5pm (HIGH, `fs.existsSync()` check
118
+ bypassable on Windows), GHSA-698v-4r3x-3vcv (`escapeCmdArgs()` omits `%`
119
+ escaping on Windows) and GHSA-r825-w8mm-5pxw (editor CLI flag
120
+ misinterpretation). `vite` is only reached through the test/dev toolchain
121
+ and the published package does not ship our lockfile, so the exposure was
122
+ this repo's own dev server.
123
+
124
+ ## [0.7.1] — 2026-09-17
125
+
126
+ ### Fixed
127
+
128
+ - **Malformed authored documents now render empty instead of throwing during
129
+ React render.** `CmsPage` normalizes a missing, null, array, or otherwise
130
+ invalid `nodes` map at its migration boundary, while direct `emitDocCss()`
131
+ and `rootIds()` calls defensively return empty results. A bad agent-authored
132
+ value can no longer unmount the host subtree around the CMS page.
133
+
134
+ **What you must do:** nothing. Well-formed documents render unchanged; the
135
+ fallback only applies when `nodes` is not a non-null object.
136
+
137
+ ## [0.7.0] — 2026-08-09
138
+
139
+ ### Added
140
+
141
+ - **`$source` on a binding — where its data comes from, in the Live Contract's
142
+ vocabulary.** Story #171, AC2.
143
+
144
+ ```ts
145
+ { $bind: "products.0.name", $source: ["catalog", "products"] }
146
+ ```
147
+
148
+ `{ $bind: "profile.coins" }` is a path into whatever blob the host passes as
149
+ `data`. It says nothing about which query produced that blob, so nothing could
150
+ know what to re-render when the underlying data changed — a host wiring live
151
+ data had to work it out per page, by hand.
152
+
153
+ `$source` names it in the same `[namespace, resource, …]` shape
154
+ `fancy-query`'s `liveKey()` produces and every backend twin declares its
155
+ broadcast events against. This is exactly why the work was gated behind the
156
+ Live Contract: had the substrate invented its own name for "where this data
157
+ lives", that vocabulary would have been baked into every **saved document**.
158
+
159
+ - `bindingSources(doc)` answers *what live queries does this page read?* —
160
+ walking props and a repeater's `each`, which is the densest data dependency
161
+ in the model.
162
+ - A malformed `$source` reads as absent rather than throwing. Documents arrive
163
+ from databases and from agents; a renderer that throws takes the page down.
164
+ - Deliberately a plain array — structurally a TanStack `QueryKey` without the
165
+ dependency, and it survives JSON.
166
+
167
+ **What you must do:** nothing. `$source` is optional, and every document saved
168
+ before this has none.
169
+
170
+ - **Asserts `fancy-doc-commons`' `CANONICAL_WALKS`** — the shared fixture
171
+ `fancy-screens` asserts too, so both surfaces are checked against one tree
172
+ rather than each being inspected separately (AC1).
173
+
174
+ ### Changed
175
+
176
+ - `@particle-academy/fancy-doc-commons` to `>=0.4 <2.0`.
177
+
178
+ ## [0.6.0] — 2026-08-09
179
+
180
+ ### Changed
181
+
182
+ - **BREAKING — the `set_props` op is now `set_node_props`.**
183
+
184
+ `@particle-academy/fancy-doc-commons`' `TreeOp` uses the same discriminator
185
+ `t` and also had a `set_props` variant, with a byte-identical shape. So a
186
+ stored `{ t: "set_props", id, patch }` was **valid under two vocabularies and
187
+ reduced by two different reducers**, with nothing in the document able to say
188
+ which one it meant. Now that `PageDoc` IS a `DocTree`, both reducers can
189
+ plausibly be handed the same document.
190
+
191
+ **What you must do:** if you emit this op directly, rename the tag. If you
192
+ only use `Editor`, `EditablePage`, `Inspector` or `useEditor`, nothing — they
193
+ emit it for you. Stored documents are unaffected: this is an op vocabulary,
194
+ not a document field, so nothing persisted contains it.
195
+
196
+ Every other variant was already disjoint (`insert_node` vs `insert`,
197
+ `move_node` vs `move`), so closing the collision cost exactly one rename. The
198
+ alternative on the table was a versioned envelope (`{v:"tree/1", t:…}`) on
199
+ every op across the kit; that would have been the right answer if the overlap
200
+ were wide, and it is one variant.
201
+
202
+ ### Added
203
+
204
+ - A test asserting the two vocabularies stay **disjoint**, so the next
205
+ collision fails the build rather than shipping. Keeping tag spaces disjoint
206
+ is the cheap version of a versioned envelope — but only while something
207
+ checks.
208
+
209
+
210
+ ### Added
211
+
212
+ - The migration is now tested against a **real saved document** — the showcase's
213
+ own CMS home page as it was actually persisted before 0.5.0, 62 nodes and
214
+ seven sections, extracted from `px-ui-sandbox` at the commit before the
215
+ migration landed. A hand-built fixture only contains the cases its author
216
+ already thought of.
217
+
218
+ That document's `sections` happens to agree with its order keys, because
219
+ nobody ever reordered it — so a further test permutes `sections` and leaves
220
+ the keys alone, which is exactly what the old `reorder_sections` op did, and
221
+ asserts the migration recovers the dragged order.
222
+
223
+ ## [0.5.1] — 2026-08-09
224
+
225
+ ### Fixed
226
+
227
+ - **`NodeTransform` is exported from the `/editor` entry.** It appears in two of
228
+ `EditablePage`'s props — `transforms?: Record<string, NodeTransform>` and
229
+ `onNodeTransform?: (id, transform: NodeTransform) => void` — but had never
230
+ been exported, so a consumer writing an `onNodeTransform` handler had no way
231
+ to name its second argument. The showcase imported it anyway and carried a
232
+ permanent type error in the app that exists to demonstrate this package.
233
+
234
+ **What you must do:** nothing. This only adds an export.
235
+
236
+ The gap was invisible from inside the package: it built and tested clean, and
237
+ only a CONSUMER could discover it. There is now a type-only fixture
238
+ (`tests/editor-exports.types.ts`) that imports every public prop type through
239
+ the same barrel a consumer uses, so the next one fails the build here rather
240
+ than in someone else's app.
241
+
242
+ ### Added
243
+
244
+ - `tsconfig.test.json`, wired into `npm run lint`. `tests/` was excluded from
245
+ type checking entirely, so test files were unchecked TypeScript — turning it
246
+ on surfaced four real type errors that had been sitting in the suite.
247
+
248
+ ## [0.5.0] — 2026-08-09
249
+
250
+ ### Added
251
+
252
+ - `migrateDoc(doc)` / `migrateDocVerbose(doc)` / `needsMigration(doc)` — forward
253
+ migration for documents saved before this release. Idempotent, and returns an
254
+ already-migrated document by reference, so it is safe to call unconditionally
255
+ on load. `migrateDocVerbose` additionally reports `danglingIds` (ids listed in
256
+ `sections[]` with no matching node) and `unlistedRootIds` (roots the array
257
+ never mentioned, which previously did not render at all).
258
+ - `rootIds(doc)` and `lastRootId(doc)` on `document/reduce` — the two things
259
+ callers actually wanted from `doc.sections`.
260
+
261
+ ### Changed
262
+
263
+ - **BREAKING — `PageDoc.sections[]` is gone; roots are ordered by their `order`
264
+ key like every other sibling group.** `PageDoc` is now a `DocTree` from
265
+ `@particle-academy/fancy-doc-commons`, so `childrenOf`, `roots` and
266
+ `descendantsOf` from that package work on a CMS document directly.
267
+
268
+ **What you must do:**
269
+
270
+ - **Reading `doc.sections`** → use `rootIds(doc)`.
271
+ - **Emitting the `reorder_sections` op** → it is now `reorder_roots`, and it
272
+ rewrites the roots' `order` keys rather than permuting an array. The payload
273
+ shape (`{ t, order: NodeId[] }`) is unchanged.
274
+ - **Loading documents you persisted before this release** → nothing, if you
275
+ go through `CmsPage` or `initEditor`; both migrate on the way in. If you
276
+ read a stored document yourself — to index it, diff it, or render it on a
277
+ server — call `migrateDoc` first.
278
+
279
+ This one genuinely bites, so it is worth being precise about why: the old
280
+ `reorder_sections` permuted `sections[]` and left every node's `order` key
281
+ untouched. On any page whose sections were ever dragged into a new order, the
282
+ keys are stale and the array is the **only** record of the real order. So a
283
+ consumer who drops the array and falls back to the keys does not lose a
284
+ redundant field — that page silently reverts to the order it was first
285
+ authored in. Nothing throws, nothing logs, and the layout is wrong. That is
286
+ the case `migrateDoc` exists for, and the reason both entry points call it
287
+ for you.
288
+
289
+ - Top-level order used to live in its own array while every other level used
290
+ fractional keys — two ordering mechanisms that could disagree. A node could be
291
+ a root by `parent === null` and absent from `sections[]`, and then simply not
292
+ render. That is now unrepresentable.
293
+
294
+ ### Removed
295
+
296
+ - **BREAKING — `src/document/fractional.ts` is deleted.** `keyBetween` is still
297
+ exported from the package root, now re-exported from
298
+ `@particle-academy/fancy-doc-commons` (which also exports it as
299
+ `fractionalKey`).
300
+
301
+ **What you must do:** if you imported `keyBetween` from the package root,
302
+ nothing — it is the same function and produces byte-identical keys, which the
303
+ test suite pins against golden values captured from the deleted
304
+ implementation. If you deep-imported `@particle-academy/fancy-cms-ui/dist/document/fractional`,
305
+ import from the package root instead.
306
+
307
+ ## [0.4.0] — 2026-08-07
308
+
309
+ ### Changed
310
+
311
+ - **BREAKING — Node 18 is no longer supported.** `engines.node` moves from `>=18` to `>=22`.
312
+
313
+ **What you must do:** on Node 22 or newer, nothing. Note npm only *warns* on an `engines` mismatch while **pnpm fails the install**, so this surfaces differently depending on your package manager. Node 18 is end-of-life and 20 is maintenance-only.
314
+
315
+ - **BREAKING — React 18 is no longer supported.** `peerDependencies.react` / `react-dom` are now `^19.0.0`.
316
+
317
+ **What you must do:** on React 19, nothing. On React 18, stay on the previous release, or upgrade your app to 19 first.
318
+
319
+ React 18 support was a claim nothing tested — every build and test in this package ran against 19, so the 18 half of the old range was never executed. An untested compatibility claim is worse than an absent one, because it reads as support.
320
+
321
+ ### Why
322
+
323
+ These are the kit 0.5 platform floors, applied across every package at once so a consumer never has to resolve a mix. **No API changed, nothing was removed, nothing was renamed** — only what the package requires.
324
+
325
+
326
+ ## [0.3.1] — 2026-07-27
327
+
328
+ ### Fixed
329
+
330
+ - **`Inspector`'s labels were attached to nothing.** Every one was a plain
331
+ sibling of its control — `<label>Background</label><input/>` — so clicking a
332
+ label focused nothing and a screen reader announced the style panel as a
333
+ column of unlabelled boxes.
334
+
335
+ Worth distinguishing: `NodeInspector`'s labels **wrap** their inputs, which
336
+ associates them implicitly and was always correct. This was seven fields in
337
+ one file, not a package-wide defect.
338
+
339
+ Each control now has an `id` its label points at, plus a `data-cms-field`
340
+ handle keyed by the field — the stable identity the Human+ contract asks for,
341
+ so an agent addresses a field by name instead of guessing at the DOM.
342
+
343
+ - **The element palette was hardcoded dark inside a light-themed host.** The
344
+ editor ships a `--fcms-*` light/dark token layer and then set
345
+ `background: "#0b1220"`, `color: "#e2e8f0"` and a slate border directly, so a
346
+ host on the light theme got a permanently dark flyout. The overlay's selection
347
+ outlines, the drop hint and several `NodeInspector` colours bypassed the layer
348
+ the same way.
349
+
350
+ All of them now resolve through `--fcms-*` **with the previous values as
351
+ fallbacks**, so default rendering is unchanged and only a host that themes the
352
+ editor sees a difference. `--fcms-drop` and `--fcms-danger` were added to the
353
+ light and dark blocks, since a token nothing defines cannot be themed from one
354
+ place.
355
+
356
+ **Nothing to do.** No prop or markup changed.
357
+
358
+ ### Changed
359
+
360
+ - **A stale note in `Editor.tsx` said the chrome "graduates to react-fancy
361
+ next".** It dates from the Phase 0+1 commit and the `--fcms-*` token layer
362
+ landed three weeks after it, superseding it — react-fancy's primitives are
363
+ hardcoded Tailwind palette classes that read no custom properties, so adopting
364
+ them would ignore `--fcms-accent` and strand a themed host with a half-themed
365
+ editor. The comment now records why the chrome stays plain markup, and that
366
+ the old plan is no longer the plan. It is the kind of note that gets acted on
367
+ long after it stopped being true.
368
+
369
+ ### Added
370
+
371
+ - **11 tests** covering label association, the field handles, per-instance ids,
372
+ the select → deselect → reselect transition, and that every `--fcms-*` token
373
+ referenced anywhere is actually defined. Ten of them fail against the previous
374
+ code.
375
+
376
+ ## 0.1.1 — 2026-07-06
377
+
378
+ ### Fixed
379
+ - **`Editor`: the notify effect no longer depends on the `onChange` prop's
380
+ identity** (#1). The latest `onChange` is held in a ref (synced in an effect)
381
+ and the notify effect depends only on the document — so a consumer passing an
382
+ inline closure that also sets state (the most natural usage) can no longer
383
+ loop the notify (notify → setState → render → new closure identity → effect
384
+ re-fires → notify → …; 91k fires observed for one op). Each op now notifies
385
+ exactly once, guarded by a regression test.
386
+ - The same hygiene applied to `EditablePage`'s `onSelect` notify and
387
+ `onProgress` scroll-reporting effects — the identical latent loop class.
388
+
389
+ ## 0.1.0 — 2026-07-05
390
+
391
+ First beta. The editor (`Editor`, `EditablePage`, `Canvas`, `LayersPanel`,
392
+ inspectors, `useEditor` + snapshot undo/redo) and the isomorphic renderer
393
+ (`CmsPage`, `CmsRegion`) exported from the main entry, over the Stages document
394
+ model, the `PageOp` op-spine, and the JS style→CSS emitter (byte-parity with the
395
+ PHP emitter in `particle-academy/fancy-cms`).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Particle Academy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,107 @@
1
+ # Building professional pages with fancy-cms
2
+
3
+ You are building one page as a fancy-cms document, edited through ops. A person
4
+ may be watching it take shape, and may edit it while you work. The bar is a page
5
+ a design studio would ship: clear, specific, consistent, and good on a phone.
6
+
7
+ ## The bar
8
+
9
+ 1. **The brief's facts, all of them, exactly.** Every name, price, time, figure
10
+ and link the brief gives appears on the page as given. Nothing is invented:
11
+ no extra features, customers, awards, quotes or statistics. If a section
12
+ would need a fact the brief does not give, leave the section out.
13
+ 2. **One message per section.** A visitor can say what each section is for in
14
+ five words.
15
+ 3. **The call to action is obvious.** The primary CTA appears in the hero, uses
16
+ the brief's exact label and link, and is visible without scrolling at 375 px.
17
+ 4. **Good on a phone first.** Design at 375 px, then widen. Nothing scrolls
18
+ sideways. Tap targets are at least 44 px tall.
19
+ 5. **Consistent.** One type scale, one spacing scale, two or three colours from
20
+ the theme. No one-off values.
21
+
22
+ ## Never ship
23
+
24
+ - A fact that is not in the brief, or a brief fact that is missing or changed.
25
+ - Lorem ipsum, "Feature 1", placeholder prices or `#` links where the brief gave
26
+ a link.
27
+ - Body text over a busy image, or text that fails contrast (body 4.5:1, large
28
+ text 3:1).
29
+ - More than one `h1`, or heading levels that skip (h2 straight to h4).
30
+ - An image with empty `alt` unless it is purely decorative.
31
+ - Absolute positioning for page layout. Use stack and grid; reserve `free` for
32
+ genuine overlays.
33
+
34
+ ## Before you build
35
+
36
+ 1. Call `cms_describe` once. It lists every element type, op and style property.
37
+ Do not guess names.
38
+ 2. Read the brief and list its facts. Sort them into sections, in this order
39
+ unless the brief says otherwise:
40
+ header, hero, proof or features, the details (menu, pricing, timetable,
41
+ work), trust (quote, numbers, disclaimer), final CTA, footer.
42
+ 3. Set the theme first, in one `set_theme` op, so every later style can
43
+ reference it.
44
+
45
+ ## The theme
46
+
47
+ Set tokens once and reference them as `var(--cms-<group>-<name>)`:
48
+
49
+ ```json
50
+ { "t": "set_theme", "patch": {
51
+ "color": { "primary": "#0f766e", "ink": "#0f172a", "muted": "#475569", "surface": "#fbfaf7", "line": "#e5e7eb" },
52
+ "font": { "body": "Inter, system-ui, sans-serif", "display": "Inter, system-ui, sans-serif" }
53
+ } }
54
+ ```
55
+
56
+ Use the brand colour from the brief as `primary`. Ink on surface must clear 4.5:1.
57
+
58
+ ## Scales (use these values, not others)
59
+
60
+ - **Type** (px, base / md and up): display 40 / 56, h2 28 / 36, h3 20 / 22,
61
+ body 17 / 18, small 14. Line height 1.15 for display and h2, 1.6 for body.
62
+ Weight 700 for display and h2, 600 for h3, 400 for body.
63
+ - **Spacing** (px): 4, 8, 12, 16, 24, 32, 48, 64, 96. Section padding: `paddingY`
64
+ 64 at base, 96 at `md`; `paddingX` 20 at base, 32 at `md`.
65
+ - **Content width**: wrap each section's content in a stack with `maxWidth` 1120,
66
+ `marginX: "auto"` and `paddingX` as above. Text columns no wider than 680.
67
+ - **Radius**: 8 for buttons, 12 for cards.
68
+
69
+ ## Building the page
70
+
71
+ - **Batch your ops.** Send one `cms_apply` per section (10-30 ops), not one op
72
+ per call. A person watching sees the page build section by section, and you
73
+ spend a fraction of the tokens.
74
+ - **Structure**: each section is a root `section` node (`props.as: "header"` for
75
+ the header, `"footer"` for the footer). Inside it, one container `stack`, then
76
+ content.
77
+ - **Grids that respond**: for cards, use a `grid` with `columnMin` 260 px; it
78
+ goes from one column on a phone to three on a desktop with no breakpoints. For
79
+ an exact count per width, set `columns` 1 at base, 2 at `md`, 3 at `lg`.
80
+ - **Rows that collapse**: a stack with `direction: "column"` at base and
81
+ `"row"` at `md`, plus `wrap: true` when items may not fit.
82
+ - **Headings**: `heading` with `props.level` 1 for the page title, 2 for each
83
+ section title, 3 inside cards.
84
+ - **Buttons**: `button` with `props.label` and `props.href`; `variant` primary
85
+ for the main CTA, `outline` for the secondary. At least 44 px tall: `paddingY`
86
+ 12 and `fontSize` 16.
87
+ - **Lists**: a `list` node with `props.items` for features, inclusions and
88
+ hours, not one text node per line.
89
+ - **Contact**: phone as `tel:+15550104477`, email as `mailto:...`.
90
+ - **Images**: `image` with `props.src`, a descriptive `props.alt`, and
91
+ `aspectRatio` (for example `"4 / 3"`) so it never stretches.
92
+ - **Meta**: `set_meta` with a title under 60 characters and a description under
93
+ 155 characters, both from the brief's own words.
94
+
95
+ ## When a person edits the page
96
+
97
+ Every result carries a `revision`. If you get `surface_changed_under_you`, a
98
+ person changed the page. Call `cms_changes_since`, read what they did, and keep
99
+ it: their edit is the new intent. Never put back something a person changed. If
100
+ their change conflicts with the brief, build around it and say so in your final
101
+ message.
102
+
103
+ ## Finishing
104
+
105
+ Before `cms_finish`, call `cms_get` once and check against the bar: every brief
106
+ fact present and exact, one h1, the CTA in the hero, nothing invented. Fix what
107
+ fails in one more `cms_apply`, then finish with a two-sentence summary.
@@ -1,4 +1,4 @@
1
- import { migrateDoc, reduce, CmsPage, rootIds, childrenOf, lastRootId, emptyDoc } from './chunk-IWQQINCF.js';
1
+ import { migrateDoc, reduce, CmsPage, rootIds, childrenOf, lastRootId, emptyDoc } from './chunk-SJYKJ6SG.js';
2
2
  import { useReducer, useCallback, useMemo, useRef, useState, useLayoutEffect, Fragment, useId, useEffect } from 'react';
3
3
  import { jsxs, jsx, Fragment as Fragment$1 } from 'react/jsx-runtime';
4
4
  import { fractionalKey } from '@particle-academy/fancy-doc-commons';
@@ -371,7 +371,8 @@ var ADD_DEFAULTS = {
371
371
  divider: { type: "divider", props: {}, style: {} },
372
372
  code: { type: "code", props: { content: "npm install @particle-academy/react-fancy", lang: "bash" }, style: {} },
373
373
  richtext: { type: "richtext", props: { html: "<p>Rich <strong>text</strong> with <em>inline</em> formatting.</p>" }, style: {} },
374
- repeater: { type: "repeater", props: { items: "" }, style: { gap: { value: 12, unit: "px" } }, layout: "stack" }
374
+ repeater: { type: "repeater", props: { items: "" }, style: { gap: { value: 12, unit: "px" } }, layout: "stack" },
375
+ list: { type: "list", props: { items: ["First point", "Second point"], ordered: false }, style: {} }
375
376
  };
376
377
  var ADD_MENU = [
377
378
  { kind: "heading", label: "Heading" },
@@ -387,6 +388,7 @@ var ADD_MENU = [
387
388
  { kind: "divider", label: "Divider" },
388
389
  { kind: "code", label: "Code" },
389
390
  { kind: "richtext", label: "Rich text" },
391
+ { kind: "list", label: "List" },
390
392
  { kind: "repeater", label: "Repeater" }
391
393
  ];
392
394
  function resolveInsertParent(doc, selectedId) {
@@ -574,5 +576,5 @@ var LIGHT_VARS = "color-scheme:light;--fcms-bg:#ffffff;--fcms-fg:#0f172a;--fcms-
574
576
  var CHROME_CSS = `.fancy-cms-editor{--fcms-accent:#8b5cf6;${LIGHT_VARS}}@media (prefers-color-scheme:dark){.fancy-cms-editor{${DARK_VARS}}}:where(.light,[data-theme="light"]) .fancy-cms-editor{${LIGHT_VARS}}:where(.dark,[data-theme="dark"]) .fancy-cms-editor{${DARK_VARS}}`;
575
577
 
576
578
  export { ADD_DEFAULTS, ADD_MENU, CONTAINER_TYPES, Canvas, Editor, Inspector, LayersPanel, buildInsertOp, editorReduce, initEditor, resolveInsertParent, useEditor, useLatestRef };
577
- //# sourceMappingURL=chunk-YOAZRQKN.js.map
578
- //# sourceMappingURL=chunk-YOAZRQKN.js.map
579
+ //# sourceMappingURL=chunk-IOMIYI45.js.map
580
+ //# sourceMappingURL=chunk-IOMIYI45.js.map