@plitzi/sdk-server 0.37.8 → 0.37.10

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.
Files changed (127) hide show
  1. package/CHANGELOG.md +825 -0
  2. package/README.md +24 -2
  3. package/dist/actions.d.ts +1 -1
  4. package/dist/adapters/cloudAdapters.d.ts +5 -3
  5. package/dist/adapters/cloudAdapters.js +123 -63
  6. package/dist/adapters/space/types.d.ts +3 -0
  7. package/dist/core/configSeam.d.ts +14 -0
  8. package/dist/core/configSeam.js +114 -0
  9. package/dist/core/configSeam.test.d.ts +1 -0
  10. package/dist/core/createServer.js +11 -5
  11. package/dist/core/createServer.publicData.test.d.ts +1 -0
  12. package/dist/core/freePort.d.ts +10 -0
  13. package/dist/core/freePort.js +36 -0
  14. package/dist/core/freePort.test.d.ts +1 -0
  15. package/dist/core/http/stages/images.d.ts +6 -0
  16. package/dist/core/http/stages/images.js +47 -0
  17. package/dist/core/http/stages/images.test.d.ts +1 -0
  18. package/dist/core/server/fleet/channel.js +1 -1
  19. package/dist/core/server/fleet/relay.js +1 -1
  20. package/dist/core/services/action.js +5 -4
  21. package/dist/core/services/functionRoutes.d.ts +2 -2
  22. package/dist/core/services/functionRoutes.js +2 -2
  23. package/dist/core/services/registry.js +2 -0
  24. package/dist/functions-api.d.ts +23 -2
  25. package/dist/functions.d.ts +1 -1
  26. package/dist/functions.js +2 -2
  27. package/dist/helpers/cache/keys.d.ts +23 -10
  28. package/dist/helpers/cache/keys.js +16 -2
  29. package/dist/helpers/flagOverrides.d.ts +27 -0
  30. package/dist/helpers/flagOverrides.js +25 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.js +2 -1
  33. package/dist/modules/actions/index.d.ts +1 -1
  34. package/dist/modules/actions/index.js +3 -2
  35. package/dist/modules/actions/moduleFor.js +5 -3
  36. package/dist/modules/actions/runtime/flags.d.ts +16 -0
  37. package/dist/modules/actions/runtime/flags.js +49 -0
  38. package/dist/modules/actions/runtime/flags.test.d.ts +1 -0
  39. package/dist/modules/actions/runtime/kvList.js +1 -1
  40. package/dist/modules/actions/runtime/renderResolver.js +30 -4
  41. package/dist/modules/actions/runtime/runAction.js +2 -0
  42. package/dist/modules/actions/taskCatalog.d.ts +3 -1
  43. package/dist/modules/actions/taskCatalog.js +1 -0
  44. package/dist/modules/actions/tasks/registry.js +4 -2
  45. package/dist/modules/actions/transport/callHandler.js +8 -0
  46. package/dist/modules/actions/types.d.ts +20 -1
  47. package/dist/modules/connectors/engine.js +1 -1
  48. package/dist/modules/functions/capabilities.js +1 -1
  49. package/dist/modules/functions/config.d.ts +19 -3
  50. package/dist/modules/functions/config.js +30 -14
  51. package/dist/modules/functions/contract.d.ts +10 -3
  52. package/dist/modules/functions/driver.js +4 -2
  53. package/dist/modules/functions/load.js +2 -2
  54. package/dist/modules/functions/manifest.d.ts +5 -3
  55. package/dist/modules/functions/manifest.js +37 -6
  56. package/dist/modules/functions/routes.d.ts +1 -1
  57. package/dist/modules/functions/routes.js +3 -3
  58. package/dist/modules/functions/runner/messages.js +1 -1
  59. package/dist/modules/functions/space.d.ts +7 -5
  60. package/dist/modules/functions/space.js +17 -10
  61. package/dist/modules/images/allowlist.d.ts +5 -0
  62. package/dist/modules/images/allowlist.js +18 -0
  63. package/dist/modules/images/imageProxy.d.ts +36 -0
  64. package/dist/modules/images/imageProxy.js +273 -0
  65. package/dist/modules/images/imageProxy.test.d.ts +1 -0
  66. package/dist/modules/images/transform.d.ts +10 -0
  67. package/dist/modules/images/transform.js +28 -0
  68. package/dist/modules/images/transform.test.d.ts +1 -0
  69. package/dist/modules/rsc/connectorRscData.d.ts +12 -7
  70. package/dist/modules/rsc/connectorRscData.js +12 -8
  71. package/dist/modules/rsc/handler.js +18 -4
  72. package/dist/modules/rsc/publicFileResolver.d.ts +13 -0
  73. package/dist/modules/rsc/publicFileResolver.js +39 -0
  74. package/dist/modules/rsc/publicFileResolver.test.d.ts +1 -0
  75. package/dist/modules/rsc/resolveRscData.d.ts +4 -2
  76. package/dist/modules/rsc/resolveRscData.js +10 -2
  77. package/dist/modules/runtime/bundle.js +2 -2
  78. package/dist/modules/runtime/contract.d.ts +1 -1
  79. package/dist/modules/runtime/stages.d.ts +1 -1
  80. package/dist/modules/runtime/stages.js +1 -1
  81. package/dist/modules/ssr/Component.d.ts +5 -1
  82. package/dist/modules/ssr/Component.js +3 -1
  83. package/dist/modules/ssr/prepareRender.js +38 -10
  84. package/dist/modules/ssr/registerExternalPlugins.d.ts +5 -1
  85. package/dist/modules/ssr/registerExternalPlugins.js +6 -2
  86. package/package.json +9 -4
  87. package/skills/plitzi-authoring/CHEATSHEET.md +135 -0
  88. package/skills/plitzi-authoring/SKILL.md +77 -74
  89. package/skills/plitzi-authoring/recipes/carousel.ts +114 -0
  90. package/skills/plitzi-authoring/recipes/detail-page.ts +70 -0
  91. package/skills/plitzi-authoring/recipes/embed-and-svg.ts +48 -0
  92. package/skills/plitzi-authoring/recipes/every-few-seconds.ts +119 -0
  93. package/skills/plitzi-authoring/recipes/feature-flag.ts +24 -0
  94. package/skills/plitzi-authoring/recipes/filter-a-list.ts +63 -0
  95. package/skills/plitzi-authoring/recipes/forms-and-modals.ts +64 -0
  96. package/skills/plitzi-authoring/recipes/from-tailwind.ts +52 -0
  97. package/skills/plitzi-authoring/recipes/link-to-a-section.ts +35 -0
  98. package/skills/plitzi-authoring/recipes/link-with-data.ts +40 -0
  99. package/skills/plitzi-authoring/recipes/marquee.ts +57 -0
  100. package/skills/plitzi-authoring/recipes/plugin.ts +53 -0
  101. package/skills/plitzi-authoring/recipes/scroll-a-row.ts +79 -0
  102. package/skills/plitzi-authoring/recipes/show-data.ts +73 -0
  103. package/skills/plitzi-authoring/recipes/style-a-page.ts +45 -0
  104. package/skills/plitzi-authoring/recipes/typed-data.ts +64 -0
  105. package/skills/plitzi-authoring/recipes/usable-without-sight.ts +39 -0
  106. package/skills/plitzi-authoring/reference/accessibility.md +2 -2
  107. package/skills/plitzi-authoring/reference/authoring-errors.md +195 -53
  108. package/skills/plitzi-authoring/reference/colours-and-motion.md +63 -0
  109. package/skills/plitzi-authoring/reference/components.md +83 -0
  110. package/skills/plitzi-authoring/reference/data-and-visibility.md +29 -29
  111. package/skills/plitzi-authoring/reference/efficiency.md +53 -0
  112. package/skills/plitzi-authoring/reference/elements-and-styles.md +39 -24
  113. package/skills/plitzi-authoring/reference/feature-flags.md +78 -0
  114. package/skills/plitzi-authoring/reference/flows.md +26 -37
  115. package/skills/plitzi-authoring/reference/kept-state.md +28 -0
  116. package/skills/plitzi-authoring/reference/layouts.md +20 -3
  117. package/skills/plitzi-authoring/reference/lists.md +45 -16
  118. package/skills/plitzi-authoring/reference/plugins.md +27 -0
  119. package/skills/plitzi-authoring/reference/realtime.md +35 -0
  120. package/skills/plitzi-authoring/reference/review-checklist.md +8 -3
  121. package/skills/plitzi-authoring/reference/{templates-and-export.md → snippets-and-export.md} +15 -8
  122. package/skills/plitzi-authoring/reference/structure.md +29 -20
  123. package/skills/plitzi-authoring/reference/tailwind.md +19 -0
  124. package/skills/plitzi-authoring/reference/templates.md +6 -5
  125. package/skills/plitzi-authoring/reference/testing.md +11 -0
  126. package/skills/plitzi-authoring/reference/typed-sources.md +20 -0
  127. package/skills/plitzi-authoring/reference/validation.md +6 -2
@@ -0,0 +1,53 @@
1
+ # Efficiency: the same page with fewer elements
2
+
3
+ Every element is a node the page renders, the builder lists, the document stores and the next reader has to read. A
4
+ space written the long way still renders — nothing in it is wrong — but it is two or three times the size it needs,
5
+ and every copy is a place the next edit has to be made again. The platform has a short way for each of the long ones
6
+ below. Use it from the start; `authorSpace` points out the ones you missed.
7
+
8
+ ## Read the suggestions
9
+
10
+ `authorSpace` returns `suggestions` beside `warnings` — the ones that save the most elements first, each with its code,
11
+ what to write instead, the elements it is about and how many it would save:
12
+
13
+ ```ts
14
+ const { warnings, suggestions } = authorSpace(space);
15
+ // [{ code: 'repeated-on-pages', saves: 57, at: 'src/pages/docs.ts:291', elementIds: [...], message: '4 pages carry…' }]
16
+ ```
17
+
18
+ `npm run author` prints them under the warnings (`[suggest]`), and `npm run author -- --json` carries them. A warning
19
+ is a bug to fix; a suggestion is a shorter way to the same page — take it unless you have a reason not to, and the
20
+ reason is usually that the copies are about to diverge. `npx @plitzi/cli explain repeated-on-pages` explains any code.
21
+
22
+ ## The long way, and the short one
23
+
24
+ | Written the long way | The short way | Suggestion |
25
+ | --- | --- | --- |
26
+ | The same header, footer or sidebar in every page | A **layout** holding it once; each page names it (`layout: { id, slot }`) — [layouts](layouts.md) | `repeated-on-pages` |
27
+ | A header copied per page so one link can be styled "active" | The link marks its own page: `states: { current: { … } }` on its class; `activeOn` for an entry lit on several pages | `repeated-on-pages` |
28
+ | The same card, row or tile written again with other words | A **component** with props, placed with `component(id, { props })` — [components](components.md) | `repeated-shape` |
29
+ | Rows of data written one by one, side by side | One `list` over the rows — `items: [ … ]` fixed, or bound to a source — with its row written once — [lists](lists.md). Data is the test: three cards a person rewords on the canvas read better as three cards | `repeated-shape` |
30
+ | `button({ children: [text('Save')] })`, `link({ children: [text('Docs'), fontAwesome({ icon })] })` | `button({ content: 'Save' })`, `link({ href, content: 'Docs', icon, iconPlacement: 'after' })` — the icon's class on the `icon` slot; what the text's class adds (`whiteSpace: 'nowrap'`) moves to the box's class — never the class itself, whose `inherit` would then point past the box and whose `pointerEvents: 'none'` would switch the box off | `content-attribute` |
31
+ | `.card:hover { … }`, `.card .icon { … }` in `customCss` | The class's own `states` and `ancestors` | `custom-css-class` |
32
+ | A reduced-motion reset, or rules showing one icon of the theme toggle, in `customCss` | Nothing: the SDK does both for every space | `custom-css-sdk-default` |
33
+ | `.Toastify__toast { font-family: …; border: … }` in `customCss` | `notifications: { font, fontSize, border, shadow, padding, … }` | `custom-css-notifications` |
34
+ | Keyframes animating `width`, `top`, `filter: blur()`, `box-shadow`, a colour in a loop | `opacity` and `transform`; decoration held until `[data-hydrated]` — [motion](colours-and-motion.md) | `heavy-animation` |
35
+
36
+ And a few the suggestions do not count, because they cost styles rather than elements:
37
+
38
+ - **A look used twice is a class**, written once with `styles()`; every element of a TYPE dressed the same way is the
39
+ space's `elements` defaults (`elements: { heading: { color: 'var(--fg)' } }`), not a class on each.
40
+ - **Inner parts are slots.** A form control's input, a modal's backdrop, a theme toggle's icons: style them with
41
+ `slots: { input: inputClass }` on the element, or the type's `slots` in `elements` — never a `customCss` rule reaching
42
+ into the element.
43
+ - **A container with one child and nothing of its own** — no class, no flow, no condition — is an element for nothing:
44
+ put what it was for on the child.
45
+ - **A wrapper that only marks where something goes** inside a parent that lays its children out takes
46
+ `css: { display: 'contents' }` (a layout's slot, a layout itself in a page that is a column): no box, so the layout
47
+ around it is what it was.
48
+
49
+ ## Measured
50
+
51
+ On the seeded spaces, taking these suggestions removed a third of a restaurant site (2,533 → 1,697 elements: header
52
+ and footer into a layout), two fifths of a blog (353 → 210: the per-page header provider into the layout) and of a
53
+ sign-in space (848 → 530: the split screen into a layout) — and rendered every page pixel for pixel the same.
@@ -7,6 +7,7 @@ One factory per element, named after it. Attributes and the authoring fields go
7
7
  ```ts
8
8
  heading({ content: 'Fieldnotes', subType: 'h2', class: title })
9
9
  image({ src: '/fox.jpg', alt: 'A fox', css: { 'aspect-ratio': '3/2' } })
10
+ image({ src: 'https://cdn.shop.com/p.jpg', alt: '…', sizes: '360px', width: 1200, height: 800 }) // resized by the page server
10
11
  container({ class: card, children: [ … ] })
11
12
  text('Wildlife, close up') // a string is the content
12
13
  container([hero, grid]) // an array is the children
@@ -17,7 +18,7 @@ container([hero, grid]) // an array is the children
17
18
  | `id` | the ONE name this element answers to — its key in the document, a binding's source, a step's target. Unique across the WHOLE space |
18
19
  | `class` | a shared class: a `styles()` declaration or a name from the space's `classes`; a list for several. Exclusive with `css` |
19
20
  | `css` | rules of this element's own: one set, or one per breakpoint (`{ desktop, tablet, mobile }`) |
20
- | `states` | `hover`, `focus-visible`, `hidden`… beside the element's own `css` |
21
+ | `states` | `hover`, `focus-visible`, `current` (a link to the page shown), `hidden`… beside the element's own `css` |
21
22
  | `variant` | a variant of the class it wears (when the class declares it) or of its type |
22
23
  | `slots` | a class for one of the element's OTHER selectors — a form control's `input` |
23
24
  | `bind` | where a value comes from |
@@ -31,15 +32,32 @@ container([hero, grid]) // an array is the children
31
32
  Anything else in the object is an **attribute** (`authorSpace` warns `unknown-attribute` for one the element never
32
33
  reads).
33
34
 
34
- **A link's `href` is a page id.** `link({ href: 'reports' })` finds the page by id, folder included. `mode: 'internal'`
35
- for a path within the space carrying `{{tokens}}` — a row's own slug (`'/games/{{ list_games.item.slug }}'`), a route
36
- param, state; `mode: 'external'` for a full URL passed through untouched. A link is an `<a>`: never put a link inside
35
+ **A link says where by its `href`:** a page id (`link({ href: 'reports' })`, folder included), a path within the space
36
+ — with `{{tokens}}` if it needs them, a row's own slug (`'/games/{{ list_games.item.slug }}'`) — or a full URL
37
+ (`https:`, `mailto:`, `tel:`), passed through untouched. `mode` follows from it; write it only to say otherwise. A link
38
+ that opens another tab (`target: 'blank'`) gets `rel="noopener noreferrer"`. A link is an `<a>`: never put a link inside
37
39
  another link — make the card the link, or the button, not both.
38
40
 
41
+ **A link's words are its `content`** and an icon its `icon`, as a button's (`link({ href: 'pricing', content:
42
+ 'Pricing', icon: 'fa-solid fa-arrow-right', iconPlacement: 'after' })`, its class on the `icon` slot) — never a
43
+ `text` or `fontAwesome` inside. The
44
+ link to the page being shown is `aria-current="page"`; its class's `current` state styles it, so one header in a
45
+ layout lights the right item everywhere.
46
+
47
+ **An element's `id` is not its HTML id.** It reaches the DOM as `data-id`. For a section a URL can land on, give it
48
+ an `anchor` (`anchor: 'plans'` → `id="plans"`: lowercase, digits, `-`) and link to it with `hash`:
49
+ `link({ href: 'home', hash: 'plans' })` goes to `/#plans` and scrolls there, waiting for a section that renders once
50
+ its data arrives. One anchor per page, layouts included; never inside a list row or a component, which would repeat
51
+ it — `authorSpace` refuses each, and a `hash` no element on that page carries (`anchor-missing`). Under a fixed
52
+ header, give the section `scroll-margin-top` (the header's height) so it does not land beneath it.
53
+
39
54
  **Some types hold no children.** A `heading`, `text`, `paragraph`, `image`, `formControl`… renders its own attributes
40
55
  and nothing nested in it — `authorSpace` refuses children there. A heading made of parts (a word in another colour, an
41
56
  icon, a badge) is a `container` with the heading's tag: `container({ subType: 'h1', class: title, children: [text('Space
42
57
  '), text('Gamer', { class: accent })] })` — give the texts `display: inline` in their class.
58
+ A piece that sits in a line of text without breaking it — a dot before a title, a badge — is
59
+ `container({ subType: 'span', … })`: inline by default. It holds words and inline pieces, never a heading or a
60
+ paragraph (`span-holds-block`).
43
61
 
44
62
  **A `button` shows its `content` AND its children.** `content` defaults to "Button": a button whose words are children
45
63
  says `content: ''` (`default-content-beside-children` warns otherwise). Its accessible name is everything it shows.
@@ -69,14 +87,17 @@ node --input-type=module -e "import * as a from '@plitzi/sdk-authoring'; const t
69
87
 
70
88
  ## CSS
71
89
 
72
- Write CSS as you write CSS, shorthands included — they are expanded to the longhands Plitzi's editor reads. A property
73
- outside the vocabulary is refused with the key it should have been (`paddingTop` → `padding-top`).
90
+ Write CSS as you write CSS, shorthands included — they are expanded to the longhands Plitzi's editor reads. Keys in
91
+ kebab-case or camelCase (`paddingTop`, as a React style object), and a bare number on a length is pixels (`gap: 16`;
92
+ `fontWeight: 800`, `opacity: 0.5` stay numbers). A property outside the vocabulary is refused.
74
93
 
75
94
  - `column(gap, extra?)`, `row(gap, extra?)`, `grid(columns, gap, extra?)` for the three layouts every space repeats.
76
95
  - **Breakpoints are ranges, not a cascade.** `tablet` is 48–64rem, `mobile` below 48rem, and each inherits only from
77
96
  `desktop` — a rule for `tablet` never reaches a phone (`authorSpace` warns `tablet-rule-skips-mobile`). A rule for
78
97
  everything narrower than a desktop is `compact`, written to both: `css: { desktop: { … }, compact: { 'grid-template-columns':
79
98
  '1fr' }, mobile: { gap: '8px' } }` — what `tablet` or `mobile` say for themselves wins.
99
+ - **One property that changes, in place:** `{ fontSize: { desktop: '24px', mobile: '18px' }, fontWeight: 800 }` — the
100
+ rest of the rule stays desktop-only, without splitting it by breakpoint. Both forms mix.
80
101
  - **`backdrop-filter`, `filter` and `transform` make an element the containing block of its `position: fixed`
81
102
  children.** A glass header with a fixed phone dock inside puts the dock at the top of the header, not the screen.
82
103
  - **Fonts are declared.** A `font-family` loads only if the space lists the face in `fonts`
@@ -125,6 +146,9 @@ container({ class: card, children: [ … ] });
125
146
  What is inside follows its container with `ancestors: { [panel.name]: { states: { hidden: { … } } } }` — a
126
147
  different `transition-delay` per item staggers them. A container whose children mount only while it shows
127
148
  (`loadStrategy: 'visible'`) empties before it can leave: use `'lazy'` on one that animates out.
149
+ - **The navigation of a site goes in a layout, once.** A link to the page being shown marks itself, and its class
150
+ dresses it with `states: { current: { … } }` — never a class picked per page, which forces a copy of the header into
151
+ every page.
128
152
  - **`:hover` is part of the selector, not a rule in `customCss`.** `customCss` is for what no class can say:
129
153
  keyframes, pseudo-elements, a rule across two unrelated elements, the inside of a third-party widget.
130
154
  - Per element TYPE defaults go in `elements: { heading: { base, states, variants, slots } }` — a whole type at once.
@@ -137,25 +161,16 @@ container({ class: card, children: [ … ] });
137
161
 
138
162
  **A variant chosen by the data:** `variantFrom(pill, 'jobs.item.status')`, or with a template when the data does not
139
163
  already speak in variant names — `variantFrom(pill, 'runs.item.status', { template: "{{ source == 'completed' ?
140
- 'ok' : 'failed' }}" })`. The binding's key names the CLASS; never write it by hand. For "the current page", see
141
- `activeOn` in [layouts.md](layouts.md).
164
+ 'ok' : 'failed' }}" })`. The binding's key names the CLASS; never write it by hand. A variant while a condition holds
165
+ — the dot of the slide on screen — is `activeWhen(dot, '{{ list_dots.index == state.slide }}')` (`active`, else
166
+ `idle`); for "the current page", `activeOn` in [layouts.md](layouts.md).
142
167
 
143
- ## Colours and themes
168
+ ## From Tailwind classes
144
169
 
145
- Every colour is a **variable** with both values, and elements say `var(--name)`:
170
+ A design written in Tailwind classes becomes classes the builder edits with `styles('pill', tw('…'))` — see
171
+ [tailwind.md](tailwind.md).
146
172
 
147
- ```ts
148
- variables: {
149
- color: {
150
- foreground: { light: '#0c0c14', dark: '#ededf3', default: '#0c0c14' },
151
- card: { light: '#ffffff', dark: '#101019', default: '#ffffff' },
152
- primary: { light: '#5b3df5', dark: '#6e52f7', default: '#5b3df5' }
153
- }
154
- }
155
- ```
173
+ ## Colours, themes and motion
156
174
 
157
- - Choose each value against its own background: a colour picked on white is not the same colour on near-black.
158
- - Fixed colours are only for surfaces that are fixed in both themes (a brand panel that is always dark) — and then
159
- the text on them is fixed too. Theme-following text on a fixed background is the bug.
160
- - A tint of a token is `color-mix(in srgb, var(--primary) 12%, transparent)`, which follows the theme for free.
161
- - `themeToggle()` is the switch; `theme.resolved` (`light`/`dark`) is a global source for anything else that needs it.
175
+ Every colour is a token with a light and a dark value, read as `var(--name)` (or `tokens(variables)`); keyframes go in
176
+ `customCss` — see [colours-and-motion.md](colours-and-motion.md).
@@ -0,0 +1,78 @@
1
+ # Feature flags
2
+
3
+ A flag turns a part of the space on or off without editing it: a feature still being built, a version for beta users,
4
+ the old checkout kept until the new one ships. It is **not a visibility**. A hidden element is still rendered — its
5
+ markup is in the page, its subtree mounted. An element gated off by a flag is not rendered at all: not on the server,
6
+ not in the browser, not its children, and its server data is not resolved. Use `visible` for what the page's own state
7
+ shows and hides; use a flag for what a person decides is switched on.
8
+
9
+ **A flag is not a secret.** The space's document — gated elements included — still reaches the browser, as it always
10
+ does, so the other side can appear without a reload when a flag changes. Content a visitor must never receive belongs
11
+ behind a server action or a connector.
12
+
13
+ ## Declaring one
14
+
15
+ ```ts
16
+ authorSpace({
17
+ name: 'Shop',
18
+ permanentUrl: 'shop',
19
+ flags: {
20
+ newCheckout: {
21
+ description: 'The one-step checkout',
22
+ value: false, // the answer when no rule matches
23
+ rules: [ // read top to bottom; the first that matches decides
24
+ { when: { combinator: 'and', rules: [{ field: 'environment', operator: '=', value: 'staging' }] }, value: true },
25
+ { when: { combinator: 'and', rules: [{ field: 'user.roles', operator: 'contains', value: 'beta' }] }, value: true }
26
+ ]
27
+ }
28
+ },
29
+ pages: [ … ]
30
+ });
31
+ ```
32
+
33
+ A rule's `when` sees `environment` (`main`, `development`, `staging`, `production`), `hostname`, `routeParams.<name>`,
34
+ `queryParams.<name>` and the visitor: `user.authenticated`, `user.email`, `user.username`, `user.roles` (a list — use
35
+ `contains`). A group with no rules is skipped, never read as "always" (`flag-rule-empty` warns).
36
+
37
+ The name is what a template reads, so it is a template key: letters, digits and `_`, starting with a letter or `_`.
38
+
39
+ ## Gating an element or a page
40
+
41
+ ```ts
42
+ container({ id: 'checkout', flag: 'newCheckout', children: [ … ] }) // only while the flag is on
43
+ container({ id: 'legacyCheckout', flag: '!newCheckout', children: [ … ] }) // only while it is off
44
+ { name: 'Labs', slug: 'labs', flag: 'labs', body: [ … ] } // a page: 404 while the flag is off
45
+ ```
46
+
47
+ Put the two sides of a rollout next to each other — the new one on `flag`, the old one on `!flag` — so exactly one is
48
+ there whatever the flag says. A gate on a flag the space does not declare is an error (`flag-undeclared`): an
49
+ undeclared flag is off, so the element would never render.
50
+
51
+ ## Reading one
52
+
53
+ `flags` is a global source like `state` or `theme`: `{{ flags.newCheckout }}` is `true` or `false` in a binding, a
54
+ step's `when`, a computed value — and in a server action's steps, resolved on the server for the visitor who started
55
+ it. A plugin reads one with `useFlag('newCheckout')` from `@plitzi/plitzi-sdk`. Reading a flag the space does not
56
+ declare is an error (`flag-unknown`).
57
+
58
+ Prefer a gate to `visible: 'flags.x'`: a visibility bound to a flag still renders the hidden half and ships it in the
59
+ HTML. Reach for the source only for what a gate cannot say — a label that changes, a step that branches.
60
+
61
+ ## Who decides
62
+
63
+ The space's declaration answers first. Above it, in order, each may override a flag — only one the space declares:
64
+
65
+ 1. the server rendering the page — `createServer({ flags })` in a self-hosted deployment;
66
+ 2. the SDK embedding the space — `<PlitziSdk flags={{ newCheckout: true }} />` or `render(…, { flags })`;
67
+ 3. a tester with the dev tools — the **Flags** tab forces one for that browser, only where debugging is authorized.
68
+
69
+ The draft (`main`) applies its flags as they are. Flags are **not served from a snapshot**: each environment has one set,
70
+ shared by every snapshot it serves. Publishing a snapshot sends the draft's flags with it; the builder's *Publish flags*
71
+ sends only them, with no new snapshot; rolling a snapshot back keeps the environment's flags. Each snapshot keeps a copy
72
+ of the flags it was published with, used only when the environment's own cannot be read (after a Redis copy of them),
73
+ and a self-hosted server keeps the last ones it fetched — so a published site keeps its flags with Plitzi unreachable.
74
+
75
+ ## When the feature ships
76
+
77
+ Remove the gate from what stays, delete what it replaced, and remove the flag. A declared flag nothing reads is a
78
+ warning (`flag-unused`): it is a switch nobody can see the effect of.
@@ -16,7 +16,9 @@ button({
16
16
  ```
17
17
 
18
18
  A flow is a list: a trigger, then steps in order. "Only if" is on the step (`when`, `whenSucceeded`, `whenFailed`),
19
- never a nested tree. Use the step builders — they fill in where a step runs and what it takes:
19
+ never a nested tree. A step that belongs to a feature still behind a [feature flag](feature-flags.md) reads it like any
20
+ source — `when({ field: 'flags.newCheckout', operator: '=', value: true }, …)` — and a server action's steps see the
21
+ same flags, decided on the server. Use the step builders — they fill in where a step runs and what it takes:
20
22
 
21
23
  - **Where it runs.** A global callback registers under its source MODULE (`state`, `auth`, `actions`), an element
22
24
  callback under an element's id, a utility under nothing. Naming either half wrong is a control that does nothing,
@@ -39,7 +41,7 @@ never a nested tree. Use the step builders — they fill in where a step runs an
39
41
 
40
42
  ## What a trigger hands the flow
41
43
 
42
- Name the trigger (`named('changed', onChange())`) and read its payload as `{{ changed.<field> }}`:
44
+ Name the trigger (`named('changed', on('onChange'))`) and read its payload as `{{ changed.<field> }}`:
43
45
 
44
46
  | Trigger | Payload |
45
47
  | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- |
@@ -89,6 +91,22 @@ on the page. A press while typing in a field is the field's, unless Ctrl/⌘/Alt
89
91
  `{{ <step>.key }}` is the key pressed (`shift+f`). A shortcut that cannot fire (`'ctrl+shift'`, `'arrowupp'`) is refused
90
92
  where it is written.
91
93
 
94
+ **Something every few seconds** — an autoplay, a clock — is `onInterval(ms)`, a trigger every element has:
95
+ `[[onInterval(5000), cycleState({ key: 'slide', length: 4 })]]`. It
96
+ ticks while the element is mounted and the tab is in view, never in the builder outside preview, at least every 250 ms
97
+ (`trigger-interval` otherwise). To pause it, give its steps a condition — a state that `on('onMouseEnter')` sets and
98
+ `on('onMouseLeave')` clears. `{{ <step>.count }}` is how many times it has ticked. No plugin needed.
99
+
100
+ **A number that goes round, or stops:** `cycleState({ key: 'slide', length: 4 })` is "next" — after the last the first
101
+ — and `by: -1` "previous"; `stepState({ key: 'shown', by: 40, max: 'apiContainer_site.data.total' })` adds and stops
102
+ at the bound. Each is the `setState` it stands for, with the arithmetic written once.
103
+
104
+ **Scrolling** is a step on the element whose box scrolls (`overflow-x: auto` on a row of cards): `scrollBy('cards',
105
+ { x: '80%' })` moves it by most of what it shows (`'-80%'` back, `'240'` pixels), `scrollTo('cards', { x: 'end' })`
106
+ to an end or a place, and `scrollIntoView('answer', { block: 'center' })` brings an element into view. `onScroll()`
107
+ fires as the box moves and once on mount, with `{ x, y, atStart, atEnd }` — what an arrow that hides at the end reads
108
+ (recipes/scroll-a-row.ts). A link to a section needs none of this: an `anchor` and a link's `hash`.
109
+
92
110
  **A form's flow goes on the `form`**, which hands its submit over with `managedByInteractions: true`
93
111
  (`FORM_SUBMIT_UNMANAGED` otherwise — the browser submits it natively and `onSubmit` never fires):
94
112
 
@@ -112,8 +130,11 @@ without a word.
112
130
  ## Notifications
113
131
 
114
132
  `addNotification({ content: 'Saved', appearance: 'success' })` — `appearance` is `success`, `danger`, `warning` or
115
- `info`. They follow the page's theme and font; their colours are the space's `notifications`:
116
- `notifications: { background: 'var(--card)', text: 'var(--foreground)', success: 'var(--accent)', radius: '12px' }`.
133
+ `info`. They follow the page's theme; how they look is the space's `notifications` — colours (`background`, `text`,
134
+ `success`, `danger`, `warning`, `info`) and shape (`radius`, `font`, `fontSize`, `border`, `shadow`, `padding`):
135
+ `notifications: { background: 'var(--card)', text: 'var(--foreground)', success: 'var(--accent)', radius: '12px',
136
+ font: 'var(--font-sans)', border: '1px solid var(--border)', shadow: 'var(--shadow-lg)' }`. The toast carries no class
137
+ of the space's, so this — not a `.Toastify__toast` rule in `customCss` — is where it is dressed.
117
138
 
118
139
  ## Modals, dropdowns, tabs
119
140
 
@@ -126,39 +147,7 @@ without a word.
126
147
 
127
148
  ## Realtime channels
128
149
 
129
- Pages that see each other — cursors, presence, a shared board — need a channel. Declare its topic pattern on the space,
130
- then subscribe with a `channel` element; a topic no pattern matches is refused, naming the patterns:
131
-
132
- ```ts
133
- channels: {
134
- 'board:{id}': { access: { mode: 'public' }, publish: 'server' }, // only `realtime.publish` speaks
135
- 'room:{id}': { access: { mode: 'public' }, presence: true } // pages speak directly
136
- }
137
-
138
- channel({ id: 'room', topic: 'room:{{ id }}', keep: 0, bind: { presence: 'computed.me' }, children: [...] })
139
- ```
140
-
141
- Its descendants bind `room.members`, `room.connected`, `room.last`; flows use `on('onMessage')` (`type`, `data`,
142
- `from`), `onJoin`/`onLeave` (`from`, `user` and the `state` the member announced — `onJoin` only for who came after
143
- this page), `publishOn('room', 'reaction', data)` and `announceOn('room', state)`. State everyone must
144
- agree on goes through a server action whose last step is `realtime.publish` — validated and saved first, announced
145
- after. `docs/en/realtime.md` is the whole of it.
146
-
147
- A topic only some visitors may hear — a room behind a password, a customer's order — goes on a channel declared
148
- `grant: true`. The action that decides they may be there ends with `realtime.grant` and answers the grant; the
149
- element binds it, or the server refuses the topic every time (`channel-grant`). `realtime.revoke { topic, grant }`
150
- takes one back — or every grant for the topic, naming none — and lets go whoever is on it with it:
151
-
152
- ```ts
153
- channels: { 'order:{id}': { access: { mode: 'session' }, grant: true } }
154
-
155
- // steps: [ …decide…, { id: 'let', task: 'realtime.grant', params: { topic: 'order:{{ input.order }}' } } ]
156
- channel({ id: 'order', topic: 'order:{{ id }}', bind: { grant: 'state.follow.grant' }, children: [...] })
157
- ```
158
-
159
- Two people writing the same thing at once: `kv.setIf` writes only if the value is still the one the flow read (empty
160
- `expected` = only if nothing is there yet — claim a seat, a username), `list.put`/`list.range` keep an ordered list
161
- (latest first, a leaderboard), and `flow.rateLimit` first in a public action that writes.
150
+ Pages that see each other — cursors, presence, a shared board — talk over a channel: see [realtime](realtime.md).
162
151
 
163
152
  ## Lists as state
164
153
 
@@ -0,0 +1,28 @@
1
+ # State that outlives a reload
2
+
3
+ `render(…, { state })` seeds `runtime.state` when the page starts — the way a host hands a space what it already knows.
4
+ `settings: { keepState: true, stateStorage: 'localStorage' }` keeps `runtime.state` across reloads, filed under
5
+ whoever is signed in (a guest's state is the browser's; another account never sees it). Keep only what a person would
6
+ expect back — favourites, a chosen theme. Everything else goes in `transientState`, which is never written and never
7
+ brought back:
8
+
9
+ ```ts
10
+ settings: { keepState: true, transientState: ['filter', 'tourStep', 'panelOpen'] }
11
+ ```
12
+
13
+ Do not reset kept state from `onPageLoad` instead: what was kept is restored late — after hydration, once auth knows
14
+ who this is — and lands in the middle of that flow, so half of it is undone. A filter restored on the next visit is a
15
+ page that looks broken. Keeping state is the SPACE's setting; a page does not take `keepState`.
16
+
17
+ That lateness shows. Kept state is restored after the server's first paint, so anything kept that changes what is
18
+ DRAWN — the tool a toolbar shows as last picked, a name in an avatar, a panel left off — is drawn with its default and
19
+ then swapped. List those keys in `paintedState`: they are kept in a cookie too, the server renders with them, and the
20
+ page starts from the same values.
21
+
22
+ ```ts
23
+ settings: { keepState: true, paintedState: ['shapesPick', 'name', 'color'], transientState: ['panelOpen'] }
24
+ ```
25
+
26
+ Only what the first paint shows, and small values: the cookie travels with every request and holds a few kilobytes
27
+ (over that, the page falls back to the defaults and the dev tools say so). Never a secret — a key, a token — and never
28
+ a key that is also in `transientState` (`authorSpace` refuses it).
@@ -36,6 +36,13 @@ pages: [{ name: 'Quickstart', slug: 'quickstart', folder: 'docs', layout: { id:
36
36
  sidebar) inside the application shell. The page resolves the chain from the outside in.
37
37
  - **A provider in a layout serves every page** — the plan in a sidebar, a star count in a top bar. A server provider
38
38
  (`runtime: 'server'`) in a layout is resolved with the page like one in the page.
39
+ - **The layout is a box between the page and its shell.** The page's own class styles the page's root, which holds the
40
+ layout, which holds your shell — so a page that is a column stretching its content to the window's height
41
+ (`min-height: 100vh` with a `flex-grow: 1` child) no longer reaches the shell: the footer lifts off the bottom. Give
42
+ the layout `css: { display: 'contents' }` and the chain is what it was. A slot that only marks where the page goes,
43
+ inside a parent that lays its children out, takes the same.
44
+ - **The slot can be a role, not only a place.** A split screen's form half is the slot itself (`class: formPane`), so
45
+ each page is just its card.
39
46
  - **Ids are shared** between a layout and all its pages. Name layout elements after the layout (`docs-topbar`), and
40
47
  let pages prefix theirs with the page id.
41
48
 
@@ -90,9 +97,19 @@ const navLink = styles('nav-link', {
90
97
  link({ href: 'spaces', class: navLink, bind: [activeOn(navLink, ['spaces', 'space-record'])] });
91
98
  ```
92
99
 
93
- `activeOn(class, pageIds, { variant?, slot? })` binds the class's variant to `navigation.currentPageId`: the `active`
94
- variant on those pages, `idle` everywhere else. Several ids for one entry — a section and the pages under it. Do not
95
- write the binding by hand with a `when` rule per page; that is the long form this replaces.
100
+ A link to ONE page needs nothing of this: it marks itself — `aria-current="page"`, which a screen reader announces — and
101
+ its class dresses it with the `current` state:
102
+
103
+ ```ts
104
+ const navLink = styles('nav-link', { css: { color: 'var(--muted)' }, states: { current: { color: 'var(--foreground)' } } });
105
+
106
+ link({ href: '/pricing', mode: 'internal', class: navLink });
107
+ ```
108
+
109
+ `activeOn(class, pageIds, { variant?, slot? })` is for an entry lit on SEVERAL pages — a section and the pages under it,
110
+ a journal and its articles: it binds the class's variant to `navigation.currentPageId`, the `active` variant on those
111
+ pages and `idle` everywhere else. Do not write the binding by hand with a `when` rule per page; that is the long form
112
+ this replaces.
96
113
 
97
114
  ## Pages from a factory
98
115
 
@@ -7,36 +7,44 @@ its children once as a plain `<ul>` and never looks at `items`, so a list with i
7
7
  list bound to its records (see the provider in data-and-visibility).
8
8
 
9
9
  ```ts
10
- list({
11
- id: 'games',
12
- source: 'controlled',
13
- class: gameGrid,
14
- bind: { items: 'catalog.data.games' }, // an array from the provider around it
15
- children: [gameCard()] // rendered once per game
16
- })
10
+ list({ id: 'games', class: gameGrid, items: 'catalog.data.games', row: 'game-card' }) // a component per game
11
+ list({ id: 'games', items: 'catalog.data.games', row: r => text({ from: `${r.item}.title` }) }) // or a tree of its own
12
+ list({ id: 'plans', items: ['Free', 'Pro'], children: [text({ from: 'plans.item' })] }) // fixed items
17
13
  ```
18
14
 
19
- - Each row publishes **`list_<id>.item`** (the row) and **`list_<id>.index`** (its position, as text). Inside the row,
15
+ `items` makes a list controlled: an array of its own, or the source its rows come from. `row` is what it renders once
16
+ per item — a component's id (placed with the row bound to its `item` prop, or its only prop), or a function handed the
17
+ row's names: `r.item` and `r.index` for a binding or `from`, `r.inTemplate.item` for a template
18
+ (`` `/games/{{ ${r.inTemplate.item}.slug }}` ``). A function needs the list's `id`, which names those sources. With
19
+ `items` a typed source's path (`items: site.data.games`, see typed-sources.md), the row is typed too:
20
+ `row: g => text({ from: g.item.title })`, and a field the sample's games lack is a type error.
21
+
22
+ - Each row publishes **`list_<id>.item`** (the row) and **`list_<id>.index`** (its position, a number from 0 —
23
+ `index + 1` counts, and `==` compares it with text or a number alike). Inside the row,
20
24
  bindings read the short form (`'games.item.title'`), templates and attribute tokens the full one
21
25
  (`{{ list_games.item.slug }}`).
22
26
  - A **nested list** sees the outer row: inside `list_features`, `list_games.item` is still the game.
23
27
  - The list is a `<ul>` and its `class` styles that root; each child is rendered straight into it, with no wrapper.
24
28
  A card grid needs `{ margin: '0px', padding: '0px', 'list-style-type': 'none' }` in its class.
25
- - Fixed data is `items: [ … ]` on the list itself — no provider needed.
29
+ - Fixed data is `items: [ … ]` on the list itself — no provider needed. Worth it when the rows ARE data — written from
30
+ an array, or rows that come and go (`repeated-shape` points at sibling copies). Three cards a person rewords on the
31
+ canvas are easier to edit as three cards than as a JSON array.
26
32
  - Everything inside a row is `repeated` in the handles: a test addresses one copy with `.first()` / `.nth()`.
33
+ - **A row is its item's by its `id`** when every item has a different one, else by its position: filtered or
34
+ reordered, a row's state (an open detail, a field's text) follows its item. Items named by another field say so —
35
+ `itemKey: 'slug'` (`list-item-key-missing` warns when fixed items lack it). A row whose item changes mounts again,
36
+ so a one-row list showing "the current slide" replays the slide's entrance animation.
27
37
 
28
38
  ## Filtering and sorting
29
39
 
30
- Bind `items` through a template that hands over its **value**:
40
+ Feed the list through a template — `from` with `as` hands a list its template's **value**:
31
41
 
32
42
  ```ts
33
43
  list({
34
44
  id: 'shown',
35
- source: 'controlled',
36
- bind: [bindTemplate('items', 'catalog.data.games',
37
- "{{ source|filter(g => (state.genre ?? '') == '' or g.genre == state.genre)|sort((a, b) => b.score - a.score) }}",
38
- { returns: 'value' })],
39
- children: [gameCard()]
45
+ from: 'catalog.data.games',
46
+ as: "{{ source|filter(g => (state.genre ?? '') == '' or g.genre == state.genre)|sort((a, b) => b.score - a.score) }}",
47
+ row: 'game-card'
40
48
  })
41
49
  ```
42
50
 
@@ -71,4 +79,25 @@ apiContainer({ id: 'catalog', query: '/data/games.json', cache: true, children:
71
79
  ```
72
80
 
73
81
  A link to it from a card is an attribute token read inside the card's row:
74
- `link({ mode: 'internal', href: '/games/{{ list_games.item.slug }}' })`.
82
+ `link({ href: '/games/{{ list_games.item.slug }}' })`.
83
+
84
+ ## Carousels
85
+
86
+ ```ts
87
+ carousel({ id: 'hero', label: 'Featured', items: 'site.data.slides', autoplay: 5000, row: 'slide-card', children: [
88
+ button({ content: '›', title: 'Next slide', flows: [[onClick(), carouselNext('hero')]] })
89
+ ] })
90
+ ```
91
+
92
+ A `carousel` takes `items` and a `row` as a list does, and writes the row into a `carouselTrack`; its other children are
93
+ its controls, which read `carousel_hero.index`, `.count`, `.item` and `.items` (`list({ id: 'dots', items: 'hero.items',
94
+ … })` for dots, `activeWhen(dot, '{{ list_dots.index == carousel_hero.index }}')` for the current one). Steps:
95
+ `carouselNext`, `carouselPrevious`, `carouselGoTo(id, index)`, `carouselPlay`, `carouselPause`; it fires `onChange`.
96
+
97
+ - `mode: 'slide'` (one at a time, `transition` `slide`/`fade`/`none`, back enters from the left), `'marquee'` (`speed` px/s,
98
+ no seam), `'scroll'` (a row that snaps and swipes; next moves a slide). Several slides at once: the carousel's class
99
+ sets `--plitzi-carousel-slide-width` and `--plitzi-carousel-gap`.
100
+ - Autoplay holds still under the pointer (`pauseOnHover`), with keyboard focus inside, in a hidden tab and for reduced
101
+ motion. In the builder it stands on its first slide. `label` names it for a screen reader.
102
+
103
+ See recipes/carousel.ts.
@@ -92,6 +92,21 @@ Take the topic as a prop (`bindTemplate('roomTopic', 'board.id', 'room:{{ source
92
92
  channels, and trust a message you act on only when `message.from === 'server'`. The page has one connection and is one
93
93
  member per topic, however many elements and plugins listen.
94
94
 
95
+ ## Feature flags
96
+
97
+ A plugin that ships a feature behind one of the space's [feature flags](feature-flags.md) asks with `useFlag` — the
98
+ answer every layer gave (the space's rules, the server, the SDK, a tester forcing it), and it renders again when it
99
+ changes:
100
+
101
+ ```tsx
102
+ import { useFlag } from '@plitzi/plitzi-sdk';
103
+
104
+ const newChart = useFlag('newChart'); // false for a flag the space does not declare
105
+ ```
106
+
107
+ Gating the whole plugin needs no code: give its element `flag: 'newChart'`. `useFlag` is for a plugin that changes
108
+ inside — a new mode, an extra panel.
109
+
95
110
  ## Registering
96
111
 
97
112
  A project `plitzi create` wrote registers every folder of `src/plugins` by itself, under the folder's name in
@@ -134,6 +149,18 @@ A plugin says what HAPPENED through its events (`onPick`, with the seat in the p
134
149
  decide what that means — write `state`, open a modal, call a server action. Prefer that to writing `state` from inside
135
150
  the component: the flow is visible in the space, the builder shows it, and the same act can come from a button too.
136
151
 
152
+ ## Behaving in the builder
153
+
154
+ The builder draws the element on its canvas while somebody edits the page: a click selects it, a drag moves it, a key
155
+ belongs to the editor.
156
+
157
+ - **Outside preview, do nothing on your own.** `usePlitziServiceContext().settings.previewMode` is `false` while the
158
+ page is edited. Declared interactions are already held back then; your own click handlers, timers, global listeners,
159
+ permission prompts and map gestures are not — gate them on `previewMode`, and still render something to select.
160
+ - **Never the global `window` or `document`.** The canvas is a frame of its own and the code runs in the builder's
161
+ window: listen, measure and go full screen through the node's own page (`ref.current.ownerDocument`, its
162
+ `defaultView`) or `usePlitziServiceContext().utils.getWindow()`. An `instanceof` check takes its class from there too.
163
+
137
164
  ## Components that draw into DOM they do not render
138
165
 
139
166
  A map, a chart library, anything that positions its own markers or popups: its roots are the library's to place. Never
@@ -0,0 +1,35 @@
1
+ # Realtime channels
2
+
3
+ Pages that see each other — cursors, presence, a shared board — need a channel. Declare its topic pattern on the space,
4
+ then subscribe with a `channel` element; a topic no pattern matches is refused, naming the patterns:
5
+
6
+ ```ts
7
+ channels: {
8
+ 'board:{id}': { access: { mode: 'public' }, publish: 'server' }, // only `realtime.publish` speaks
9
+ 'room:{id}': { access: { mode: 'public' }, presence: true } // pages speak directly
10
+ }
11
+
12
+ channel({ id: 'room', topic: 'room:{{ id }}', keep: 0, bind: { presence: 'computed.me' }, children: [...] })
13
+ ```
14
+
15
+ Its descendants bind `room.members`, `room.connected`, `room.last`; flows use `on('onMessage')` (`type`, `data`,
16
+ `from`), `onJoin`/`onLeave` (`from`, `user` and the `state` the member announced — `onJoin` only for who came after
17
+ this page), `publishOn('room', 'reaction', data)` and `announceOn('room', state)`. State everyone must
18
+ agree on goes through a server action whose last step is `realtime.publish` — validated and saved first, announced
19
+ after.
20
+
21
+ A topic only some visitors may hear — a room behind a password, a customer's order — goes on a channel declared
22
+ `grant: true`. The action that decides they may be there ends with `realtime.grant` and answers the grant; the
23
+ element binds it, or the server refuses the topic every time (`channel-grant`). `realtime.revoke { topic, grant }`
24
+ takes one back — or every grant for the topic, naming none — and lets go whoever is on it with it:
25
+
26
+ ```ts
27
+ channels: { 'order:{id}': { access: { mode: 'session' }, grant: true } }
28
+
29
+ // steps: [ …decide…, { id: 'let', task: 'realtime.grant', params: { topic: 'order:{{ input.order }}' } } ]
30
+ channel({ id: 'order', topic: 'order:{{ id }}', bind: { grant: 'state.follow.grant' }, children: [...] })
31
+ ```
32
+
33
+ Two people writing the same thing at once: `kv.setIf` writes only if the value is still the one the flow read (empty
34
+ `expected` = only if nothing is there yet — claim a seat, a username), `list.put`/`list.range` keep an ordered list
35
+ (latest first, a leaderboard), and `flow.rateLimit` first in a public action that writes.
@@ -6,6 +6,8 @@ of it; each line is something that has shipped broken before.
6
6
  ## It renders, and says so
7
7
 
8
8
  - [ ] `authorSpace` returns **zero warnings**. Each one is written code that will not do what it says.
9
+ - [ ] Every **suggestion** is taken, or left for a reason you can say (the copies are about to diverge; three cards a
10
+ person rewords on the canvas). The ones your change opened up first — [efficiency.md](efficiency.md).
9
11
  - [ ] No console errors on any page you touched.
10
12
 
11
13
  ## Nothing flashes, nothing jumps
@@ -15,6 +17,8 @@ of it; each line is something that has shipped broken before.
15
17
  - [ ] Nothing that is on screen by default disappears because a flag has not been set yet (a sidebar until it is
16
18
  folded). Checked on a fresh visit, with no state kept from before.
17
19
  - [ ] Empty states show only when the answer arrived and is empty — never while loading.
20
+ - [ ] A feature still being built is gated by a [feature flag](feature-flags.md) (`flag: 'x'` / `'!x'`), not hidden
21
+ with `visible` — and checked with the flag on AND off. A flag whose feature shipped is removed, gate and all.
18
22
  - [ ] No hidden element leaves a hole: the element itself is hidden, not a wrapper around it.
19
23
  - [ ] Nothing shifts when the data lands: a loading area keeps its size, numbers use tabular figures.
20
24
 
@@ -23,7 +27,8 @@ of it; each line is something that has shipped broken before.
23
27
  - [ ] Desktop, tablet and mobile — and nothing scrolls sideways on a phone. A rule for tablets and phones alike is
24
28
  written under `compact`.
25
29
  - [ ] Light AND dark. Every colour is a token with both values; no theme-following text on a fixed background.
26
- - [ ] Focus is visible on everything clickable (`focus-visible`), and `prefers-reduced-motion` stops what moves.
30
+ - [ ] Focus is visible on everything clickable (`focus-visible`). Animations and transitions stop by themselves for
31
+ `prefers-reduced-motion`; a hover that moves something is turned off there too (`transform: none`).
27
32
 
28
33
  ## Usable without sight
29
34
 
@@ -44,8 +49,8 @@ Screen readers and browser agents (Claude in Chrome) work a page through its acc
44
49
  - [ ] Chrome shared by pages is a layout; the current menu entry comes from `activeOn`, not per-page styling.
45
50
  - [ ] A look used twice is a class; a tree used twice is a function or a `map` over data; a list of pages, links or
46
51
  plans is ONE array everything reads.
47
- - [ ] No positional ids (`container-45`) in what you wrote; every referenced element is named, prefixed when a
48
- helper runs more than once.
52
+ - [ ] No positional ids (`container-45`) in what you wrote; every referenced element is named, and a helper that
53
+ runs more than once builds inside `scope()`.
49
54
 
50
55
  ## True, and in the right place
51
56