@particle-academy/fancy-cms-ui 0.7.1 → 0.9.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,448 @@
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.9.0] - 2026-10-23
18
+
19
+ Released together with `particle-academy/fancy-cms` 0.5.0, whose PHP renderer
20
+ emits the same markup and CSS: all five canonical documents are byte-identical
21
+ in both `parity/run.mjs` (CSS) and `parity/html.mjs` (HTML).
22
+
23
+ Found by rendering the lab's reference page server-side and capturing it at
24
+ phone and desktop widths: browser-default margins stacked on top of every
25
+ layout's `gap`, buttons were underlined links, and the JS and PHP renderers
26
+ produced different markup because the JS one used inline style objects.
27
+
28
+ ### Changed
29
+
30
+ - **BREAKING (markup + CSS): one fixed base stylesheet replaces inline styles.**
31
+ `emitDocCss` now emits `BASE_CSS` first: it resets the margins of headings,
32
+ paragraphs and lists, sets `box-sizing`, and styles buttons, callouts, code,
33
+ dividers and images. Every rule is wrapped in `:where()`, is never more
34
+ specific than a node's own `[data-cms="<id>"]` rule and comes before it, so
35
+ whatever a node sets wins. Elements carry data attributes instead:
36
+ - a `button` is ONE element, the `<a>` (or `<div>` without an href), with
37
+ `data-cms-button="primary|outline|ghost"`; there is no inner `<span>`;
38
+ - a `callout` is `data-cms-callout="info|success|warning|danger"` with its
39
+ text directly inside;
40
+ - an `image` keeps `<img>` and carries `data-cms-fit` when not `cover`;
41
+ - `divider` and `code` render bare `<hr>` and `<pre><code>`.
42
+ Buttons and callouts take `var(--cms-color-primary)` when the theme sets it.
43
+ **What to do:** nothing, unless your CSS targeted the old inner
44
+ `span[data-cms-button]` or your design relied on browser-default margins;
45
+ set the margin on the node instead. Unknown variants fall back to the
46
+ default rather than being written into the attribute.
47
+ - `emitDocCss` emits the base whenever a document has nodes or theme tokens,
48
+ even if no node sets a style; an empty document still emits nothing.
49
+
50
+ ### Fixed
51
+
52
+ - A node with no `props` or no `style` (a stored or legacy node) crashed both
53
+ the renderer and the emitter, taking the whole page with it. Both now read
54
+ nodes through `withNodeDefaults` (exported).
55
+ - A breakpoint named after an `Object.prototype` member (`constructor`, ...)
56
+ made the emitter print a function into a media query; breakpoint and
57
+ per-breakpoint lookups are now own-property only.
58
+ - An image with no `src` rendered `src=""`, which makes a browser request the
59
+ page again; the attribute is now omitted.
60
+
61
+ ### Added
62
+
63
+ - The parity runners call PHP without a shell (`FANCY_CMS_PHP` overrides the
64
+ binary), so a fixture's filename can never be read as shell text.
65
+ - `parity/html.mjs`: the markup half of the parity gate. It renders every
66
+ canonical document with `CmsPage` and with fancy-cms's PHP renderer and
67
+ requires byte-identical HTML. `canonical-page-ledgerline.json` is the lab's
68
+ premium reference page, so a real page is in the corpus.
69
+
70
+ ## [0.8.0] - 2026-10-23
71
+
72
+ What a professional landing page needs from the renderer, found by mapping
73
+ the stack for the lab's website-builder agent.
74
+
75
+ ### Changed
76
+
77
+ - **BREAKING (markup): the `[data-cms]` element is now the semantic element.**
78
+ `heading` renders `<h1>`-`<h6>` (`props.level`, default 2), `text` renders
79
+ `<p>`, `section` renders `<section>`, `button` and `link` with a safe `href`
80
+ render `<a>`, and `list` renders `<ul>`/`<ol>`. Previously every node was a
81
+ `<div>`. **What to do:** nothing, unless your own CSS targets
82
+ `div[data-cms]` or `[data-cms] > a` (the inner `<a>` of a `link` is gone;
83
+ the link IS the element now). Select by `[data-cms="<id>"]`, which is
84
+ unchanged.
85
+ - **BREAKING (security, CSS): a style value that could escape its declaration
86
+ is dropped.** A value containing `{`, `}` or `<`, or a `;` outside
87
+ parentheses or quotes, emits nothing. **What to do:** nothing for any real
88
+ colour, gradient, font stack or `url(data:...;base64,...)`, which all still
89
+ pass. Only a value that was trying to close the rule changes.
90
+ - `direction`, `columns` and the new container knobs are read **per
91
+ breakpoint**. A grid can now go 1 column -> 2 -> 3, and a stack column ->
92
+ row. Base output is unchanged.
93
+
94
+ ### Added
95
+
96
+ - Style properties: `paddingX` / `paddingY`, `marginX` (`"auto"` centres) /
97
+ `marginY`, `maxWidth`, `minHeight`, `aspectRatio`, `sticky`, `zIndex`,
98
+ `overflow`, `hidden` (per breakpoint), `textTransform`, `backgroundSize`,
99
+ `backgroundPosition`, `wrap`, and `columnMin` for an auto-fit grid that is
100
+ responsive with no breakpoints at all.
101
+ - **Theme tokens are emitted** as `:root { --cms-* }` custom properties
102
+ (`theme.color.primary` -> `--cms-color-primary`), so styles can reference
103
+ `var(--cms-color-primary)`.
104
+ - `props.as` on containers: `section`, `header`, `nav`, `main`, `footer`,
105
+ `article`, `aside`, `div`. Anything else is ignored, never used as a tag.
106
+ - `list` element (`props.items`, `props.ordered`), and List in the editor's add menu.
107
+ - `props.anchor` on any node renders as its `id`, so an in-page link
108
+ (`href="#pricing"`) has somewhere to land. Only a lowercase slug of up to 64
109
+ characters is rendered (`ANCHOR_PATTERN`, `anchorFor` exported); anything
110
+ else is ignored. The PHP renderer follows in fancy-cms 0.5.0.
111
+ - `image`: `props.fit` (`cover` default, `contain`, `fill`, `none`,
112
+ `scale-down`) and `props.loading` (lazy by default).
113
+ - **`agent-guide/professional-pages.md`**, shipped in the tarball: how an agent
114
+ builds a professional page with these primitives (the bar, scales, batching
115
+ ops, deferring to a person's edits). It is what the lab's website-builder
116
+ agent is measured with, so its numbers are tuned by lab passes, not by taste.
117
+ - `safeHref`, `elementFor`, `LANDMARK_TAGS` and `safeCssValue` are exported.
118
+ Hrefs pass for relative URLs and `http`/`https`/`mailto`/`tel`; every other
119
+ scheme (`javascript:`, `data:` ...) is dropped. External http(s) links get
120
+ `rel="noopener noreferrer"`.
121
+ - **Security: nothing a document holds can escape the inline stylesheet.**
122
+ Two routes carried raw strings into `<style>`. A node id went into
123
+ `[data-cms="<id>"]` unescaped, so an id containing `</style><script>` ended
124
+ the stylesheet and injected a script; ids now pass through `cssString`
125
+ (`\` and `"` escaped, control characters and `<` as hex escapes), and the
126
+ selector still matches the attribute. And `align`, `justify`, `direction`,
127
+ `textAlign`, a Length's unit and the numeric fields were written without a
128
+ check; now a Length needs a finite number and a known unit, a keyword must be
129
+ on its allowlist, and a number must be finite, or the declaration is dropped.
130
+ **Nothing to do:** every valid document emits the same CSS as before.
131
+ `parity/canonical-doc-hostile.json` holds the PHP emitter to the same rules.
132
+ Found by the PHP port's source review.
133
+ - **Security / Fixed: node ids and breakpoint names that are also
134
+ `Object.prototype` names.** Ids arrive in ops, which an agent writes. The node
135
+ map was read as a plain object, so `constructor`, `toString` and friends
136
+ resolved to built-ins: inserting a node called `constructor` was refused as a
137
+ duplicate, removing an absent `toString` "succeeded" and bumped `seq`, and a
138
+ node called `__proto__` replaced the map's prototype instead of being added.
139
+ Every lookup and write in the reducer, renderer, CSS emitter and migration now
140
+ uses own properties only (`src/document/own.ts`). **Nothing to do:** a page
141
+ whose ids are ordinary strings behaves exactly as before. Found by the PHP
142
+ port, which has no prototype to see.
143
+ - **Fixed:** `reorder_roots` accepted a list that repeated a root as long as
144
+ its length matched (`["s1","s1"]` for roots s1, s2), rewrote that root's key
145
+ twice and left the other's alone, so the page order became arbitrary. It is
146
+ now refused as not a permutation, like a list with an unknown id. Found while
147
+ porting the reducer to PHP.
148
+ - Parity: `parity/run.mjs` checks every `parity/canonical*.json`;
149
+ `canonical-doc-0.8.json` covers the new properties and the values that must
150
+ be dropped.
151
+
152
+ ### Fixed
153
+
154
+ - **`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.
155
+ - Dropped `docs` from `files`: it was declared and never existed, so the package claimed to ship it and did not.
156
+
157
+ ### Security
158
+
159
+ - `source-map-js` is pinned forward to `^1.2.2` via `overrides`. Versions up to
160
+ 1.2.1 allow an event-loop denial of service through indexed source-map section
161
+ offsets, and it arrives here transitively through the build toolchain.
162
+ **Nothing for a consumer to do, and no runtime change**: an npm package does
163
+ not ship a lockfile, so this governs builds OF this repo, not anything
164
+ installed FROM it. Recorded rather than left silent because the override it
165
+ sits beside — `shell-quote` `^1.9.0`, added for an earlier advisory — was
166
+ carried with no note of why, and had drifted back inside the vulnerable range
167
+ before anyone looked.
168
+ - **Dev toolchain only — no runtime change, nothing for consumers to do.** The
169
+ lockfile now resolves `vite` 8.3.5 (was 8.3.0), whose bundled
170
+ `launch-editor` fixes GHSA-587h-7wmj-h5pm (HIGH, `fs.existsSync()` check
171
+ bypassable on Windows), GHSA-698v-4r3x-3vcv (`escapeCmdArgs()` omits `%`
172
+ escaping on Windows) and GHSA-r825-w8mm-5pxw (editor CLI flag
173
+ misinterpretation). `vite` is only reached through the test/dev toolchain
174
+ and the published package does not ship our lockfile, so the exposure was
175
+ this repo's own dev server.
176
+
177
+ ## [0.7.1] — 2026-09-17
178
+
179
+ ### Fixed
180
+
181
+ - **Malformed authored documents now render empty instead of throwing during
182
+ React render.** `CmsPage` normalizes a missing, null, array, or otherwise
183
+ invalid `nodes` map at its migration boundary, while direct `emitDocCss()`
184
+ and `rootIds()` calls defensively return empty results. A bad agent-authored
185
+ value can no longer unmount the host subtree around the CMS page.
186
+
187
+ **What you must do:** nothing. Well-formed documents render unchanged; the
188
+ fallback only applies when `nodes` is not a non-null object.
189
+
190
+ ## [0.7.0] — 2026-08-09
191
+
192
+ ### Added
193
+
194
+ - **`$source` on a binding — where its data comes from, in the Live Contract's
195
+ vocabulary.** Story #171, AC2.
196
+
197
+ ```ts
198
+ { $bind: "products.0.name", $source: ["catalog", "products"] }
199
+ ```
200
+
201
+ `{ $bind: "profile.coins" }` is a path into whatever blob the host passes as
202
+ `data`. It says nothing about which query produced that blob, so nothing could
203
+ know what to re-render when the underlying data changed — a host wiring live
204
+ data had to work it out per page, by hand.
205
+
206
+ `$source` names it in the same `[namespace, resource, …]` shape
207
+ `fancy-query`'s `liveKey()` produces and every backend twin declares its
208
+ broadcast events against. This is exactly why the work was gated behind the
209
+ Live Contract: had the substrate invented its own name for "where this data
210
+ lives", that vocabulary would have been baked into every **saved document**.
211
+
212
+ - `bindingSources(doc)` answers *what live queries does this page read?* —
213
+ walking props and a repeater's `each`, which is the densest data dependency
214
+ in the model.
215
+ - A malformed `$source` reads as absent rather than throwing. Documents arrive
216
+ from databases and from agents; a renderer that throws takes the page down.
217
+ - Deliberately a plain array — structurally a TanStack `QueryKey` without the
218
+ dependency, and it survives JSON.
219
+
220
+ **What you must do:** nothing. `$source` is optional, and every document saved
221
+ before this has none.
222
+
223
+ - **Asserts `fancy-doc-commons`' `CANONICAL_WALKS`** — the shared fixture
224
+ `fancy-screens` asserts too, so both surfaces are checked against one tree
225
+ rather than each being inspected separately (AC1).
226
+
227
+ ### Changed
228
+
229
+ - `@particle-academy/fancy-doc-commons` to `>=0.4 <2.0`.
230
+
231
+ ## [0.6.0] — 2026-08-09
232
+
233
+ ### Changed
234
+
235
+ - **BREAKING — the `set_props` op is now `set_node_props`.**
236
+
237
+ `@particle-academy/fancy-doc-commons`' `TreeOp` uses the same discriminator
238
+ `t` and also had a `set_props` variant, with a byte-identical shape. So a
239
+ stored `{ t: "set_props", id, patch }` was **valid under two vocabularies and
240
+ reduced by two different reducers**, with nothing in the document able to say
241
+ which one it meant. Now that `PageDoc` IS a `DocTree`, both reducers can
242
+ plausibly be handed the same document.
243
+
244
+ **What you must do:** if you emit this op directly, rename the tag. If you
245
+ only use `Editor`, `EditablePage`, `Inspector` or `useEditor`, nothing — they
246
+ emit it for you. Stored documents are unaffected: this is an op vocabulary,
247
+ not a document field, so nothing persisted contains it.
248
+
249
+ Every other variant was already disjoint (`insert_node` vs `insert`,
250
+ `move_node` vs `move`), so closing the collision cost exactly one rename. The
251
+ alternative on the table was a versioned envelope (`{v:"tree/1", t:…}`) on
252
+ every op across the kit; that would have been the right answer if the overlap
253
+ were wide, and it is one variant.
254
+
255
+ ### Added
256
+
257
+ - A test asserting the two vocabularies stay **disjoint**, so the next
258
+ collision fails the build rather than shipping. Keeping tag spaces disjoint
259
+ is the cheap version of a versioned envelope — but only while something
260
+ checks.
261
+
262
+
263
+ ### Added
264
+
265
+ - The migration is now tested against a **real saved document** — the showcase's
266
+ own CMS home page as it was actually persisted before 0.5.0, 62 nodes and
267
+ seven sections, extracted from `px-ui-sandbox` at the commit before the
268
+ migration landed. A hand-built fixture only contains the cases its author
269
+ already thought of.
270
+
271
+ That document's `sections` happens to agree with its order keys, because
272
+ nobody ever reordered it — so a further test permutes `sections` and leaves
273
+ the keys alone, which is exactly what the old `reorder_sections` op did, and
274
+ asserts the migration recovers the dragged order.
275
+
276
+ ## [0.5.1] — 2026-08-09
277
+
278
+ ### Fixed
279
+
280
+ - **`NodeTransform` is exported from the `/editor` entry.** It appears in two of
281
+ `EditablePage`'s props — `transforms?: Record<string, NodeTransform>` and
282
+ `onNodeTransform?: (id, transform: NodeTransform) => void` — but had never
283
+ been exported, so a consumer writing an `onNodeTransform` handler had no way
284
+ to name its second argument. The showcase imported it anyway and carried a
285
+ permanent type error in the app that exists to demonstrate this package.
286
+
287
+ **What you must do:** nothing. This only adds an export.
288
+
289
+ The gap was invisible from inside the package: it built and tested clean, and
290
+ only a CONSUMER could discover it. There is now a type-only fixture
291
+ (`tests/editor-exports.types.ts`) that imports every public prop type through
292
+ the same barrel a consumer uses, so the next one fails the build here rather
293
+ than in someone else's app.
294
+
295
+ ### Added
296
+
297
+ - `tsconfig.test.json`, wired into `npm run lint`. `tests/` was excluded from
298
+ type checking entirely, so test files were unchecked TypeScript — turning it
299
+ on surfaced four real type errors that had been sitting in the suite.
300
+
301
+ ## [0.5.0] — 2026-08-09
302
+
303
+ ### Added
304
+
305
+ - `migrateDoc(doc)` / `migrateDocVerbose(doc)` / `needsMigration(doc)` — forward
306
+ migration for documents saved before this release. Idempotent, and returns an
307
+ already-migrated document by reference, so it is safe to call unconditionally
308
+ on load. `migrateDocVerbose` additionally reports `danglingIds` (ids listed in
309
+ `sections[]` with no matching node) and `unlistedRootIds` (roots the array
310
+ never mentioned, which previously did not render at all).
311
+ - `rootIds(doc)` and `lastRootId(doc)` on `document/reduce` — the two things
312
+ callers actually wanted from `doc.sections`.
313
+
314
+ ### Changed
315
+
316
+ - **BREAKING — `PageDoc.sections[]` is gone; roots are ordered by their `order`
317
+ key like every other sibling group.** `PageDoc` is now a `DocTree` from
318
+ `@particle-academy/fancy-doc-commons`, so `childrenOf`, `roots` and
319
+ `descendantsOf` from that package work on a CMS document directly.
320
+
321
+ **What you must do:**
322
+
323
+ - **Reading `doc.sections`** → use `rootIds(doc)`.
324
+ - **Emitting the `reorder_sections` op** → it is now `reorder_roots`, and it
325
+ rewrites the roots' `order` keys rather than permuting an array. The payload
326
+ shape (`{ t, order: NodeId[] }`) is unchanged.
327
+ - **Loading documents you persisted before this release** → nothing, if you
328
+ go through `CmsPage` or `initEditor`; both migrate on the way in. If you
329
+ read a stored document yourself — to index it, diff it, or render it on a
330
+ server — call `migrateDoc` first.
331
+
332
+ This one genuinely bites, so it is worth being precise about why: the old
333
+ `reorder_sections` permuted `sections[]` and left every node's `order` key
334
+ untouched. On any page whose sections were ever dragged into a new order, the
335
+ keys are stale and the array is the **only** record of the real order. So a
336
+ consumer who drops the array and falls back to the keys does not lose a
337
+ redundant field — that page silently reverts to the order it was first
338
+ authored in. Nothing throws, nothing logs, and the layout is wrong. That is
339
+ the case `migrateDoc` exists for, and the reason both entry points call it
340
+ for you.
341
+
342
+ - Top-level order used to live in its own array while every other level used
343
+ fractional keys — two ordering mechanisms that could disagree. A node could be
344
+ a root by `parent === null` and absent from `sections[]`, and then simply not
345
+ render. That is now unrepresentable.
346
+
347
+ ### Removed
348
+
349
+ - **BREAKING — `src/document/fractional.ts` is deleted.** `keyBetween` is still
350
+ exported from the package root, now re-exported from
351
+ `@particle-academy/fancy-doc-commons` (which also exports it as
352
+ `fractionalKey`).
353
+
354
+ **What you must do:** if you imported `keyBetween` from the package root,
355
+ nothing — it is the same function and produces byte-identical keys, which the
356
+ test suite pins against golden values captured from the deleted
357
+ implementation. If you deep-imported `@particle-academy/fancy-cms-ui/dist/document/fractional`,
358
+ import from the package root instead.
359
+
360
+ ## [0.4.0] — 2026-08-07
361
+
362
+ ### Changed
363
+
364
+ - **BREAKING — Node 18 is no longer supported.** `engines.node` moves from `>=18` to `>=22`.
365
+
366
+ **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.
367
+
368
+ - **BREAKING — React 18 is no longer supported.** `peerDependencies.react` / `react-dom` are now `^19.0.0`.
369
+
370
+ **What you must do:** on React 19, nothing. On React 18, stay on the previous release, or upgrade your app to 19 first.
371
+
372
+ 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.
373
+
374
+ ### Why
375
+
376
+ 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.
377
+
378
+
379
+ ## [0.3.1] — 2026-07-27
380
+
381
+ ### Fixed
382
+
383
+ - **`Inspector`'s labels were attached to nothing.** Every one was a plain
384
+ sibling of its control — `<label>Background</label><input/>` — so clicking a
385
+ label focused nothing and a screen reader announced the style panel as a
386
+ column of unlabelled boxes.
387
+
388
+ Worth distinguishing: `NodeInspector`'s labels **wrap** their inputs, which
389
+ associates them implicitly and was always correct. This was seven fields in
390
+ one file, not a package-wide defect.
391
+
392
+ Each control now has an `id` its label points at, plus a `data-cms-field`
393
+ handle keyed by the field — the stable identity the Human+ contract asks for,
394
+ so an agent addresses a field by name instead of guessing at the DOM.
395
+
396
+ - **The element palette was hardcoded dark inside a light-themed host.** The
397
+ editor ships a `--fcms-*` light/dark token layer and then set
398
+ `background: "#0b1220"`, `color: "#e2e8f0"` and a slate border directly, so a
399
+ host on the light theme got a permanently dark flyout. The overlay's selection
400
+ outlines, the drop hint and several `NodeInspector` colours bypassed the layer
401
+ the same way.
402
+
403
+ All of them now resolve through `--fcms-*` **with the previous values as
404
+ fallbacks**, so default rendering is unchanged and only a host that themes the
405
+ editor sees a difference. `--fcms-drop` and `--fcms-danger` were added to the
406
+ light and dark blocks, since a token nothing defines cannot be themed from one
407
+ place.
408
+
409
+ **Nothing to do.** No prop or markup changed.
410
+
411
+ ### Changed
412
+
413
+ - **A stale note in `Editor.tsx` said the chrome "graduates to react-fancy
414
+ next".** It dates from the Phase 0+1 commit and the `--fcms-*` token layer
415
+ landed three weeks after it, superseding it — react-fancy's primitives are
416
+ hardcoded Tailwind palette classes that read no custom properties, so adopting
417
+ them would ignore `--fcms-accent` and strand a themed host with a half-themed
418
+ editor. The comment now records why the chrome stays plain markup, and that
419
+ the old plan is no longer the plan. It is the kind of note that gets acted on
420
+ long after it stopped being true.
421
+
422
+ ### Added
423
+
424
+ - **11 tests** covering label association, the field handles, per-instance ids,
425
+ the select → deselect → reselect transition, and that every `--fcms-*` token
426
+ referenced anywhere is actually defined. Ten of them fail against the previous
427
+ code.
428
+
429
+ ## 0.1.1 — 2026-07-06
430
+
431
+ ### Fixed
432
+ - **`Editor`: the notify effect no longer depends on the `onChange` prop's
433
+ identity** (#1). The latest `onChange` is held in a ref (synced in an effect)
434
+ and the notify effect depends only on the document — so a consumer passing an
435
+ inline closure that also sets state (the most natural usage) can no longer
436
+ loop the notify (notify → setState → render → new closure identity → effect
437
+ re-fires → notify → …; 91k fires observed for one op). Each op now notifies
438
+ exactly once, guarded by a regression test.
439
+ - The same hygiene applied to `EditablePage`'s `onSelect` notify and
440
+ `onProgress` scroll-reporting effects — the identical latent loop class.
441
+
442
+ ## 0.1.0 — 2026-07-05
443
+
444
+ First beta. The editor (`Editor`, `EditablePage`, `Canvas`, `LayersPanel`,
445
+ inspectors, `useEditor` + snapshot undo/redo) and the isomorphic renderer
446
+ (`CmsPage`, `CmsRegion`) exported from the main entry, over the Stages document
447
+ model, the `PageOp` op-spine, and the JS style→CSS emitter (byte-parity with the
448
+ 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-LY6ID2J2.js';
1
+ import { migrateDoc, reduce, CmsPage, rootIds, childrenOf, lastRootId, emptyDoc } from './chunk-IRG5IKKO.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-SYRU2X45.js.map
578
- //# sourceMappingURL=chunk-SYRU2X45.js.map
579
+ //# sourceMappingURL=chunk-BVMSHUQF.js.map
580
+ //# sourceMappingURL=chunk-BVMSHUQF.js.map