@plitzi/sdk-schema 0.37.2 → 0.37.4

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 CHANGED
@@ -1,5 +1,1175 @@
1
1
  # @plitzi/sdk-schema
2
2
 
3
+ ## 0.37.4
4
+
5
+ ### Patch Changes
6
+
7
+ - cadd1b9: ## `@plitzi/sdk-shared` loads without Apollo
8
+
9
+ - **The root entry no longer needs `@apollo/client`.** It re-exported every builder query, mutation and subscription,
10
+ written with `gql` from `@apollo/client/core` — a package it never declared. Inside the monorepo the builder's copy
11
+ answered for it; a server installing the published packages (`@plitzi/sdk-server` and anything else that imports
12
+ `@plitzi/sdk-shared`) failed at boot with `ERR_MODULE_NOT_FOUND: @apollo/client`. The documents are now written with
13
+ `graphql-tag`, which Apollo's `gql` already was, and `graphql` is a dependency.
14
+ - **The two helpers that do run Apollo leave the barrels**: `createAuthFailureLink` is imported from
15
+ `@plitzi/sdk-shared/auth/authFailureLink` (no longer from `@plitzi/sdk-shared/auth` or the root) and
16
+ `createStripTypenameLink` from `@plitzi/sdk-shared/helpers/stripTypename`. `@apollo/client` is an optional peer, for
17
+ those two alone.
18
+
19
+ - Updated dependencies [cadd1b9]
20
+ - @plitzi/sdk-shared@0.37.4
21
+ - @plitzi/sdk-style@0.37.4
22
+
23
+ ## 0.37.3
24
+
25
+ ### Patch Changes
26
+
27
+ - cadd1b9: ## Authoring: nothing renders wrong in silence
28
+
29
+ - `authorSpace` reads every template with the runtime's own parser and refuses what it would read past: an operator it
30
+ does not implement (`matches`), an unknown filter, function or tag, a stray character, an unclosed bracket.
31
+ - Binding templates and attribute tokens are held to what is in scope: a name nothing answers to, a short source name,
32
+ or a source read from outside the element that publishes it is refused with the name it should have been.
33
+ - Children on a type that holds none (`heading`, `text`, `paragraph`, `image`, `formControl`…) are refused.
34
+ - A text template feeding an attribute that holds a list (a list's `items`) is refused; `returnMode: 'value'` fixes it.
35
+ - Warning `default-content-beside-children`: a `button` whose placeholder "Button" would print beside its children.
36
+ - New: `bindTemplate(to, source, template, { category, returns })`; `visible: { source, template }`; a `compact`
37
+ breakpoint key (tablet and mobile at once); handles flag `repeated` (inside a list row) and `boxless` (a provider
38
+ with no tag); catalogues `elementLeafTypes` and `elementDefaultAttributes`.
39
+ - Defensive by default: every field of a space, page, layout, element, binding and step is checked against what its
40
+ type declares — an unknown key, an enumerated attribute outside its values (`subType: 'h7'`, declared per element with
41
+ `valuesOf`), text where a flag or a list is read, an unknown binding category or transformer, a step or transformer
42
+ param its catalog does not take (or of the wrong type), a flow with no trigger, a link or `navigate` to a page id that
43
+ does not exist, a URL/`mailto:`/`tel:` in page mode, a controlled list with nothing to render, two pages at one
44
+ address, an empty id, and a CSS value that is empty or breaks out of its declaration are all refused. An attribute a
45
+ built-in element never reads is now refused (was a warning). New warnings: `unknown-element-type` (name plugin types
46
+ with `authorSpace(space, { pluginTypes })`), `overlay-starts-open`, `provider-without-source`, `colour-without-dark`.
47
+ - `computed` on a space: values declared once and read anywhere as `{{ computed.<name> }}` (published as the global
48
+ source `computed`). `notifications` on a space: the toast colours and radius.
49
+ - `didYouMean` also suggests a candidate that contains what was written (`template` → `twigTemplate`).
50
+ - The decompiler drops a step param its action does not take, moves `settings.computed` to `computed`, and repairs a
51
+ full URL in a page-mode link to `mode: 'external'`, each reported as a correction.
52
+ - The skill gains `lists.md`, `plugins.md`, `authoring-errors.md` and a Recipes section (held by a test), and corrects
53
+ the attribute template scope, the Twig subset, the filter list, trigger payloads, offline data, `isEmpty`,
54
+ `loadStrategy` and `keepState`.
55
+
56
+ ## One linter for every writer
57
+ - `lintSpace({ schema, style }, catalogs)` is the document linter everything is held to: `authorSpace` (through
58
+ `validateSpace`), `validateTemplate`, the MCP and the server's publish gate read a space with the same rules and the
59
+ same messages. The per-rule checks that `authorSpace` and the MCP each kept are gone.
60
+ - New rule `binding-target-unknown`: a binding onto an attribute its element never reads (`data-*`, a misspelt name)
61
+ is refused — the value arrived and nothing showed it. `className` stays bindable.
62
+ - The MCP lints the draft of every batch (`lintDraft`) and holds each element it touches to it; an issue already there
63
+ is labelled pre-existing. A binding onto an element that does not exist is now refused there too (the structural pass
64
+ runs with the source catalogue). Its own attribute and `{{ variable }}` checks for built-in types are gone — the lint
65
+ knows every attribute — so an unknown prop or an unknown variable is an error, not a warning.
66
+ - New rule `callback-key-unknown`: an element `setState`/`toggleState` writing a field its target never reads (or a
67
+ state other than `visibility` / `styleSelectors.<selector>`) is refused. The params of the callbacks every element
68
+ answers to are held to their spec (`vocabulary.sharedCallbacks`), and step params are read with their defaults
69
+ filled in, as the runtime reads them — `autoDismissTimeout: 'soon'` is caught though `autoDismiss` was left out.
70
+ - `lintSpace` has one test per code, and a test that fails when a rule is added without one.
71
+ - `validateTemplate` tells a binding onto a provider left behind once (`TEMPLATE_BINDING_OUT_OF_SCOPE`), no longer also
72
+ as `UNRESOLVED_BINDING_SOURCE`.
73
+ - `@plitzi/sdk-schema`: `REFERENCE_ERROR_CODES` and `isIntegrityError` tell a reference left dangling from a broken tree.
74
+ - MCP: `plitzi_validate`, `plitzi_apply` and `plitzi_render` share one pipeline (`draftBatch`), so validate answers
75
+ exactly what apply would. A structural error a batch introduces blocks it wherever it lands; one already in an
76
+ untouched element no longer blocks every edit. Its own checks that the lint now makes (first node a trigger, param
77
+ types, a missing step target, a setState key on a built-in type) are gone.
78
+ - Builder: a write the server refuses is now put back in the editor (the check never matched, so a refused change
79
+ stayed on screen and the next save built on it); only a write the server never answered is retried. The unused
80
+ `urgent` queue — which nothing ever processed — `count` and `getIsProcessing` are gone.
81
+ - Builder: a problems button in the header lists what is wrong with the saved space (re-read whenever the save queue
82
+ drains); each issue selects its element. Snapshot opens that list instead of publishing while there are errors, and
83
+ shows it when the server refuses a publish with `SPACE_INVALID`. New builder query `SpaceIssues` (`TSpaceIssue`,
84
+ `TSpaceIssues`).
85
+
86
+ - `fixSpace(documents, catalogs?, codes?)` and `FIXABLE_CODES`: the issues with a single reading are fixed on a copy
87
+ — a URL in a page-mode link or `navigate`, an attribute or step param that is a typo (renamed) or is never read
88
+ (dropped), `'true'`/`'false'` where a flag is read, a global callback on the wrong module, a utility on an element,
89
+ a visibility binding in `attributes`, a binding onto an attribute nothing reads, a misspelt transformer, an overlay
90
+ that starts open, a state key with `state.` in it — each reported as a line. Held by a test per code. The builder's
91
+ problems list offers "Fix N automatically" (server mutation `SpaceFixIssues`; each issue says whether it is
92
+ `fixable`).
93
+ - The linter's source scope is what the runtime walks: an element sees the providers around it and, past its page,
94
+ the layout around the slot it renders in — no longer any provider anywhere in a layout.
95
+ - Builder: a template that cannot be read is said under the field while it is typed, in the binding transformer and
96
+ flow step editors.
97
+
98
+ ## One tree walk, one tree writer
99
+ - `@plitzi/sdk-schema/helpers/elementTree`: `parentChain`, `renderContext` and `descendants`, cycle-safe. The runtime's
100
+ data-source visibility, the builder's reveal, `authorSpace` and the linter all walk the tree through it.
101
+ - `FlatMap` loses what nobody called: `validate`/`isValid`/`assertValid` (the validation lives in `validateSchema`),
102
+ `getElement`, `elementIdConflict`, `takenIds`, `renameConflict`, `parentTree`, `childTree` and the unused statics.
103
+ `moveElement` refuses a move into the element's own subtree without walking forever on a cyclic document, and a move
104
+ into another page or layout carries the subtree's `rootId`.
105
+ - The MCP writes the tree through `FlatMap` (create, move, delete) like every other writer; a move into the element's
106
+ own descendant is refused with the reason.
107
+ - `schemaToWire`/`schemaFromWire` (sdk-shared `network/spaceEvents`): a whole schema on the live channel has `flat` as
108
+ a list; the builder reads `SPACE_UPDATED` through `schemaFromWire`.
109
+
110
+ - `FlatMap` no longer goes through lodash `get`/`set` with string paths: every read and write is typed. `addElement`
111
+ and `moveElement` share one placement step and validate before they write — an insert that is refused leaves no
112
+ element behind, and an anchor its parent does not list is refused rather than landing the element before the last
113
+ sibling. A template's base element has no `parentId` (it was `null`, which the type does not allow).
114
+ - Removed what nothing used: the MCP's `isActionOp`/`isConnectorOp`/`pageStylesUri`; in the builder the empty module
115
+ barrels, the pending `Integrations` stub, `PluginSettingsForm` and the code commented out around it,
116
+ `useInfiniteGraphQL`, a second `formatTime`, `ToggleItem`, `ButtonVoice`, and `SpaceContext`/its provider.
117
+ - Builder resources: one preview (`ResourceContent`) for a resource on its way up, in the list and in its details — the
118
+ list's own copy of the plugin card is gone — and one card for images and videos (`ResourceMedia`). A video was
119
+ dragged as an image and kept its remove button under the cursor while dragged, and so did any other file; both fixed.
120
+ - `elementsByRoot` (and `RootElements`) in `@plitzi/sdk-schema/helpers/elementTree`: the element count grouped by the
121
+ page or layout that holds it. The builder's quota panel and the server's usage API each carried a copy.
122
+ - Builder: a plugin removed from the resources leaves the elements panel while it is open, and one installed shows up in
123
+ it (the panel read the component registry once); removing a plugin no longer takes every other plugin's stylesheet
124
+ with it. Asking to remove a plugin that is still placed says on which pages, and how many of its elements will show
125
+ as not found.
126
+ - `rootName` in `@plitzi/sdk-schema/helpers/elementTree`, and `elementsByRoot` takes an optional filter: the same
127
+ per-page grouping, narrowed to the elements asked about.
128
+ - Builder: the model picker's ↑/↓ and ↵ do what its footer says (they were swallowed). Gone: the space setting `head`,
129
+ which was never wired, a connectivity listener that only logged, and commented-out code across the builder, the SDK
130
+ app and the packages.
131
+ - The plugin marketplace is removed. Nothing in the builder opened it any more; a space's plugins are still installed
132
+ from its resources. Gone with it: the builder's `Marketplace` module and the `integrations` placeholder panel,
133
+ `PluginsContextValue.fetch`, and the builder query `Plugins` (`@plitzi/sdk-shared/network/graphql/builder/Queries/PluginsQuery`).
134
+ The server drops the catalog behind it — the `Plugins`/`Plugin` queries, `/api/plugins` and its six tables.
135
+ - No debug logging left in shipped code: `stringToArray` logged every call and the transition editor every change;
136
+ `BlockJsx` and plugin loading report their failures as errors.
137
+
138
+ ## Runtime
139
+ - Attribute `{{ tokens }}` resolve against every source around the element — a list row, a provider, `state`, `auth`,
140
+ `navigation.currentPageId` — not only variables and route params. Only authored values are interpolated: a value a
141
+ binding wrote is data and is never evaluated.
142
+ - The `twigTemplate` binding transformer takes `returnMode: 'value'`, answering a single `{{ expression }}` with its
143
+ value (`processTwigValue`): a filtered array, a number, a boolean.
144
+ - `keepState` no longer deletes what it kept on the next load: before the `auth` source is published the owner is
145
+ unknown, not "the browser".
146
+ - A `formControl` outside a `form` renders and works on its own (own value, `onChange`, follows `defaultValue`), and
147
+ its hooks no longer run conditionally.
148
+ - Modals and dialogs are fixed to the viewport, as tall as their content up to the screen; their close control is a
149
+ button. `openModal` metadata that parses as a non-object (`'42'`) is kept as `content`.
150
+ - `button` no longer takes its accessible name from `content`.
151
+ - `apiContainer.isEmpty` reads a plain `query` answer by what arrived.
152
+ - `container` accepts `h1`–`h6` tags, for a heading made of parts.
153
+ - `setState` keeps decimals for `number` and has a `json` type for objects and lists; `runServerAction.input` and
154
+ `webHook.body` are declared as the objects they take (param type `json`).
155
+ - `addNotification`'s `appearance` is spelled correctly (was `appeareance`; existing flows need the key renamed).
156
+ Notifications follow the space's theme and font.
157
+ - An `image` accepts `loadMode: 'auto'`, which the builder offers.
158
+ - `getStateManager()` gains `subscribe`.
159
+ - Routes: a slug with more than one `{{param}}` matches (only the first was converted).
160
+ - A plugin with several elements (`plitzi pack plugin` with more than one folder) draws each of them: every element but
161
+ the main one rendered the main element's component, since all of them load from the one module.
162
+ - `createServer({ allowPrivatePluginHosts })`: a schema plugin may be read from a private address — a development
163
+ machine's bucket on localhost. Off by default: a plugin's address is typed by whoever edits a space.
164
+ - Dev tools: with the panel collapsed, the page scrolls the document as it does in production.
165
+ - The base stylesheet has no invalid declarations: `markdown` fills its box (`height`/`width: 100%` were quoted
166
+ strings the browser dropped), and `text` no longer declares a size it never applied — it still inherits its own.
167
+
168
+ ## Twig
169
+ - `starts with`, `ends with`, `//`, `**`, `?:`, `a ? b`, subscripts on array and hash literals.
170
+ - `format` is sprintf: flags, width, padding and precision (`'%02d'`, `'%.2f'`, `'%-8s'`).
171
+ - `date` tokens `D`, `y`, `h`, `g`, `A`, `a`, and `\` to print a character as it is.
172
+ - `inspectTemplate(template)` reports what a template would read past and the names it reads.
173
+
174
+ ## CLI
175
+ - With nobody at the terminal (an agent, CI), `create` stops and prints each missing choice — package manager, mode,
176
+ source, and the key for a cloud project — as a question the agent must put to the user, and offers no way around
177
+ it: it used to end with "or with --yes to take the defaults", and agents took that exit instead of asking. `--yes`
178
+ now only answers for a person at a terminal; a script passes the flags.
179
+ - Every project gets `tsx`, so `npm run author` works on a fresh checkout, and `npm run shot -- /path --width 390
180
+ --scheme dark` takes a full-page screenshot.
181
+ - The generated visual test skips list rows and providers with no tag.
182
+ - The example plugin takes its props as attributes; in a client project its numbers come from `public/data/stats.json`
183
+ through a provider — the offline-data pattern.
184
+
185
+ ## Examples: `browser` and `self-hosting`
186
+ - `examples/` is two folders: `browser` (a space on your page, no server) and `self-hosting` (a server of your own,
187
+ from a server-rendered page to a space's runtime). What a space on the platform does — Ceniza, Tremor, Fieldnotes,
188
+ Pizarra, the server and render actions, a template — is a seeded space on the platform rather than an example here.
189
+
190
+ ## A space's visitors: signing in, and what they may do
191
+ - `settings.visitorRoles`: a space declares its visitor roles and what each gives (`{ author: ['postPublish'] }`),
192
+ published and exported with it. `authorSpace` refuses a malformed one; `checkVisitorRoles` / `visitorAccess`
193
+ (`@plitzi/sdk-shared/auth/visitorRoles`) are the one reading of them.
194
+ - `userProvider: 'server'` (`ServerAuthProvider`): a space whose people sign in THROUGH its page server, by redirect —
195
+ the session is a cookie on the space's host; `login` goes out, `logout` asks the server.
196
+ - `createServer({ signIn })`: `GET /auth/sign-in` and its callback — register this host with an OAuth 2.1 authorization
197
+ server, PKCE, state in a `__Host-` cookie, the code redeemed server to server and handed to `exchangeCredential`.
198
+ - OAuth: `issueToken` is told the grant's `redirectUri`; a server mounted under a prefix sends people back to, and posts
199
+ its grant screen to, its own `/authorize` (it resolved `/authorize` against the issuer and dropped the prefix).
200
+ - `safeRedirectTarget` refuses `/\host`, which a browser reads as `//host`.
201
+ - `spaceKvPatterns(spaceId)` (`@plitzi/sdk-server/actions`): every key a space's `kv` holds, to let them all go.
202
+ - Builder: a **Visitors** panel — the space's roles, and who holds them, given by email.
203
+
204
+ ## MCP: what the agent is told
205
+ - The guide explains every operation `plitzi_apply` takes — moving and deleting elements, schema variables, design
206
+ tokens and fonts had no word in it — and names every resource the server registers, `plitzi://fonts/{env}` (now
207
+ registered, so it can be discovered), the layouts, the server tasks and the render guide among them. A test fails when
208
+ an operation, a resource or a global source is missing from it.
209
+ - The global binding sources, the transformer names and the data-sources scope note are generated from
210
+ `@plitzi/sdk-authoring`, the catalogs the linter checks a save against. They listed `collection` and `space`, which
211
+ no longer exist, and missed `variables`, `host`, `theme` and `computed`.
212
+ - The guide says what an `apiContainer` publishes in each mode: through a connector `.records`/`.record`/`.pageInfo`,
213
+ with a browser `query` the body under `.data` and the HTTP `.status`; both `.isLoading`, `.isEmpty`, `.hasError`.
214
+ - Server actions have their own section; the tool list names `plitzi_preview`, `plitzi_screenshot` and
215
+ `plitzi_render`; the co-worker prompt lists the layouts, fonts, connectors and actions.
216
+ - The guide's connector examples read a record's fields under `values` (`list_posts.item.values.title`,
217
+ `.record.values.<field>`), which is where the engine puts them; they bound to nothing as written.
218
+ - The `plitzi-render` skill's first example no longer sets the `min-width: 0` its own rules say is unneeded; the
219
+ authoring skill lists `host` and `computed` among the globals.
220
+ - New guides: `docs/en/mcp.md` — connecting an agent, how it works a space, what the server guarantees, what is
221
+ deliberately left open — and `docs/en/connectors.md`, which only existed in Spanish. RFC 0002 is removed now that it
222
+ shipped. The repository READMEs, `claude.md`, onboarding and repository-structure list `apps/mcp`, `apps/cli`,
223
+ `apps/desktop` and `sdk-authoring`, and no longer `sdk-collections`.
224
+
225
+ ## Preview a published revision
226
+ - The draft preview takes an optional `revision` (`PreviewRequestBody.revision`, carried by `PreviewClient.render`):
227
+ a published revision of `env` rendered instead of its latest, so a capture can show one exact version — the one a
228
+ reviewer approved rather than whatever was published after. Ignored for `main`; a revision that is not a positive
229
+ integer is a 400.
230
+
231
+ ## Builder: the server's reason, not "network not available"
232
+ - A query the server refused shows the server's own message in its toast — "the storage provider refused this CDN's
233
+ credential", "there is no bucket …" — where it used to say "Query … Failed" and, wrongly, "Network Not Available".
234
+ Only a request that got no answer is reported as a network problem.
235
+ - A CDN that cannot be listed says so in its panel, with the reason and a way to choose or fix its credential, instead
236
+ of an empty list that read as "nothing uploaded".
237
+ - Every builder preview renders again — element templates from a CDN, directory items, transformer and AI previews.
238
+ The preview's render-settings scope inherited nothing (a nexus scope is isolated unless `inherit="live"`), so it held
239
+ `render` alone and each element failed with "Element … not found". `useRenderOverride` now says the scope must be
240
+ live, and a test holds it.
241
+
242
+ ## Interactions: "Propagate Event" does what it says
243
+ - A click, hover or focus trigger with **Propagate Event** off — the default — now answers the event for the elements
244
+ around it too: a button inside a clickable card runs the button's flow and not the card's as well. It used to decide
245
+ only `preventDefault`, so every clickable ancestor ran its flow after the inner one.
246
+ - The DOM event itself is not stopped: a component's own handler (a dropdown opening from a click inside it), the dev
247
+ tools' element picker and anything listening above the space still receive it. `preventDefault` is unchanged.
248
+ - To keep the old behaviour on one element, turn Propagate Event on for the inner trigger.
249
+
250
+ ## Change history
251
+ - Every save of a space's schema and style is recorded by the server — who made it (a person, an agent, the co-worker,
252
+ the autofix), from where, and each element, class, token or font it touched, before and after. Read-only; kept per
253
+ plan. See `docs/en/history.md`.
254
+ - Builder: a **History** panel — the timeline, newest first, with saves folded into rows, each unfolding into a
255
+ field-by-field diff that links to its element; filters by who made it, the selected element, and since the last
256
+ snapshot; each row numbered (`#50`), and each published revision marked right above the last change it includes
257
+ ("Revision 4 · includes up to #50") — `upToSeq` on the snapshot markers `SpaceChanges` returns.
258
+ - `@plitzi/sdk-shared/history`: `diffSchema`, `diffStyle`, `describeChange` (a save as lines: "Added text “hero” to
259
+ page “test”", with the parent's bookkeeping left out), `fieldChanges`, `sameValue`, `jsonCopy` and the
260
+ `SpaceChange` vocabulary; builder query `SpaceChanges` (`TSpaceChanges`, `TSnapshotMarker`).
261
+ - `@plitzi/sdk-mcp`: `saveSchema`/`saveStyle` receive an `SSRWriteContext` (the member, one batch per tool call), and an
262
+ optional `getChanges` adapter serves `plitzi://changes/{env}` and `plitzi://changes/{env}/{id}`.
263
+
264
+ ## Kept state: what is never kept, and where keeping is decided
265
+ - New space setting **`transientState`**: top-level `runtime.state` keys that are never kept, even with `keepState` on.
266
+ They are not written, not brought back — an entry kept before a key was declared transient does not restore it —
267
+ and a value one of them holds survives the restore, which lands late (after hydration, once auth settles) and used
268
+ to undo anything set before it. Built on `@plitzi/nexus` 1.3.0's `partializePath`/`mergePath`; every `@plitzi/nexus`
269
+ range here is `^1.3.0`. Covered by
270
+ `e2e/tests/sdk/keptState.spec.ts` across a real reload. For demos, open panels, walkthrough steps: state that must start fresh every visit.
271
+ `authorSpace` refuses a list that is not one, an empty key and a dotted one (naming the top-level key to write), and
272
+ warns `transient-state-without-keep-state` when `keepState` is off. The builder's State Settings has the field, and
273
+ the MCP's `patchSettings` takes it.
274
+ - **A page no longer takes `keepState` / `stateStorage`.** The runtime only ever read them from the space's settings,
275
+ so on a page they promised something nothing did. `authorSpace` refuses them with where they go; reading an older
276
+ document back drops them and reports it.
277
+ - The authoring skill no longer suggests resetting kept state from `onPageLoad` — the restore lands in the middle of
278
+ that flow — and the MCP guide describes `keepState` as what it is: `runtime.state`, not element state.
279
+
280
+ ## A step's params keep their type, and a failure can say why
281
+ - **A param is its value's own type, never one guessed from its text.** Step params — the browser's flows and a
282
+ server action's steps alike — were rendered to text and read back as JSON, so a text field holding `1234` reached
283
+ the next step as the number 1234: a password field declared `text` then saw no password at all, and a board's lock
284
+ was REMOVED where one was being set. One resolver now serves both sides (`processTwigParam` in
285
+ `@plitzi/sdk-shared/helpers/twigWrapper`): a param that is one `{{ expression }}` is that expression's value as it
286
+ is; a JSON filter alone (`{{ saved|json_encode }}`) is the value it encodes; text around the tokens is JSON only
287
+ when it makes an object or array document, and otherwise text. Converting is left to whoever declares a type —
288
+ `setState`'s `type`, an action's input fields — which already did. **Behaviour change:** a template that renders a
289
+ number-looking string now hands on the string (`'{{ flag ? "1" : "" }}'` is `'1'`, not `1`).
290
+ - **A failed server action can tell the page why.** A step's own error message still never leaves the server — it
291
+ can hold a query or a credential's name — but a task may now throw **`ActionRefusal`** (from
292
+ `@plitzi/sdk-server/actions`) with a message written for the person on the page, and `flow.fail` takes
293
+ **`tellCaller`**. The run answers `status: 'failed'` with that message as `error` (in a stream's last frame too),
294
+ and `runServerAction` hands it to the flow as `{{ step.error }}` — as it now also does for a refusal before the run
295
+ began. The step's preview lists `reason` and `error`, so the builder offers them.
296
+ - **One resolver for a step's params, in the browser and on the server** (`resolveStepParam`). The server only
297
+ resolved what `hasValidToken` calls a token, so a param written as a condition or an object literal
298
+ (`{{ { "id": run.id }|json_encode }}`) reached its task as the template text — an action's `output` built that way
299
+ failed with "not valid JSON". Both sides now run any template syntax, keep the value's type, and read a value that
300
+ is itself a template again up to the same ceiling.
301
+ - **Fixed: a value with a quote or a line break broke a param written as a JSON document.**
302
+ `{ "city": "{{ values.city }}" }` gave the step its raw text instead of the document when a visitor typed `Say "hi"`
303
+ or pressed Enter. A value printed inside one of the document's string literals is now escaped for it; a value
304
+ printed outside them is printed as before — it IS the JSON value there.
305
+ - **`when` around a step that already has a `when` adds to it.** It used to replace the inner condition, so the step
306
+ ran whenever the outer one held and nothing said so. Two `and` groups become one; otherwise both are kept, nested.
307
+ - The schedules example's first-paint test assumed the digest was less than a day away, and failed at weekends.
308
+
309
+ ## A page with many elements keeps its frame rate
310
+ - **`useEventBridge` stopped re-subscribing on every render.** Its `callbacks` and `params` defaulted to `= {}` in the
311
+ signature — a new object each render, and both are the effect's dependencies — so every element on a page (each
312
+ subscribes through `withElement`) took its subscription off and put it back on every render it went through.
313
+ - **`EventBridge.off` is constant time.** Whether a module had events left was a `for…in` over its keys, which still
314
+ enumerates all of them on an object that constant deletions have turned into a dictionary: N elements
315
+ re-subscribing cost N × N. A count per module replaces it. `EventBridgeProps.events` is typed as the bridge holds
316
+ it — only the modules and events that have a listener — and the package has tests now.
317
+ - **A flow reads a space's computed values once per change, not once per step.** `liveSources` evaluated every
318
+ computed value before every step and every `when`; it now keeps the last evaluation and reuses it over the same
319
+ snapshots of the state and the global sources.
320
+ - **An element subscribes to the paths it reads, not to the sources they are in.** A binding on `computed.tool`
321
+ rendered its element again whenever any computed value changed — on a board, the tool in hand changing was the
322
+ whole page drawn again. Bindings, `when` rules and attribute templates now subscribe to each path they name
323
+ (`templatePaths` in `@plitzi/sdk-shared/helpers/twigWrapper`). **Fixed** on the way: a binding whose template also
324
+ read another source (`{{ theme.resolved }}` beside the bound value) and a binding shown while a `when` held were
325
+ never told those changed.
326
+ - **A computed value that comes out the same keeps its object.** Every computed value is evaluated again whenever the
327
+ state changes, and a list or a record came out a new object each time, so every element reading one rendered again
328
+ for a change to something else: on Pizarra, the elements library's hundred and sixty stars reading the favourites.
329
+ `evaluateComputed` takes the previous evaluation and keeps each value that is deep-equal to it, and the whole when
330
+ none changed. Switching tools there rendered 1,375 elements; it renders 19. Drawing a shape: 2,833 → 129.
331
+ - **A step that finishes synchronously hands over to the next at once.** Every step used to wait a microtask, so a
332
+ flow of synchronous steps rendered once per step; React now batches them into one.
333
+ - Measured on Pizarra with 300 notes and a marquee over all of them: from 27 frames over 50 ms (the worst 330 ms,
334
+ React's development build) to 60 fps with one 88 ms frame when the selection panels mount (production build).
335
+ - **A test can count what an interaction renders.** `inspectRenders(page, act, { max })` in
336
+ `@plitzi/sdk-authoring` answers every element that rendered, how often, what changed for it and which store paths
337
+ were written; over `max`, `problems` names the elements that rendered most. It reads the render tracing the SDK
338
+ already keeps under `debugMode`, now published as `window.plitziTracing` while it is on (`TracingReader` in
339
+ `@plitzi/sdk-shared/store/tracing`: `lastCommitId`, `commitsSince`). The skill's new `performance` reference says
340
+ what makes an element render, what a flow costs and where to look when something is slow.
341
+ - **`pressShortcut(page, 'mod+z')`** presses a shortcut written as `onKey` writes it, with the keys the PAGE listens
342
+ for: `mod` from its user agent. A driver's "Control or Meta" asks the machine running the suite, so a Mac driving an
343
+ emulated desktop Chrome pressed ⌘ at a page listening for Ctrl.
344
+
345
+ ## Conditions, and what authoring catches
346
+ - **Behaviour change: a visibility, once its data answers, is a yes or a no.** A value written to `visibility` by a
347
+ binding is now read the way `not` reads it — `false`, `0`, an empty text, an empty list are a no — and a no is
348
+ written too. Before, only a truthy value was written, so an element shown once stayed shown when its condition came
349
+ back empty, and a template that printed `0` showed it. A condition's template no longer needs `? 'true' : 'false'`:
350
+ `{{ source is defined and source is empty }}` is enough. A source that has not answered yet still leaves the element
351
+ as it starts, so a flag nobody has set keeps what it controls on screen, as spaces rely on.
352
+ - **New warning `form-value-compared-to-blank`.** A `when` asking whether a submitted field (`….values.x`) `=` or
353
+ `!=` `""`: a field nobody typed in is not in `values` at all, so it never matches. The warning names
354
+ `operator: 'empty'` / `'notEmpty'`, which take a missing value and `""` alike.
355
+ - A `when` with `isBinding` comparing a computed value with a path from the trigger is pinned by a test.
356
+ - The examples write their conditions without `? 'true' : 'false'` — a flag is `visible: 'computed.presenter'`, its
357
+ inverse `'!computed.hasFrames'`, a condition its own expression — and author with no warning: Pizarra's chat no
358
+ longer sends an empty line (`notEmpty`, which the new warning found), and the blog's sidebar stops sticking on
359
+ phones (`tablet-rule-skips-mobile`).
360
+
361
+ ## Builds and caches
362
+ - **`apps/sdk`'s production build no longer deletes the vendor bundles.** `emptyOutDir` emptied `dist`, where
363
+ `vite.vendor.config.ts` builds React and its kin: a production build left pages whose script 302'd to HTML. The build
364
+ now empties what it made and leaves the vendor files.
365
+ - **A cached plugin is rebuilt when its source changed, whatever its version says.** The plugin manager records a
366
+ content digest of every file a bundle was built from and compares it when a process first finds the bundle — in
367
+ production as in development. A deployment rebuilt from new source under the same version used to serve the
368
+ previous bundle. Bundles cached before this are built once more.
369
+ - **A space's plugins are kept by what they are, not by their name and version.** The page server kept every plugin a
370
+ render named — a space's external plugins, a deployment's `pluginSources` — under `name@version`, and the first
371
+ source to arrive held that key while the process ran: a plugin published again under the same version was served
372
+ from its old, missing URLs until a restart, and two spaces each with a `board@1.0.0` of their own were both served
373
+ whichever the process had met first. The key now carries the source's identity (`name@version+<digest>` of where
374
+ its files are, how they are served and its props); invalidating a release covers those keys too, and a plugin named
375
+ by a render never answers for a bare name, which stays the plugins the server was set up with.
376
+ - **The examples are linted** with the packages' rules (`examples/eslint.config.mjs`, a `lint` script in each), and
377
+ what the rules found is fixed — among it, index reads the types called defined, a hook-named step helper, and the
378
+ Permissions API assumed present. `docs/` and the skills' markdown are hand-wrapped and listed in `.prettierignore`.
379
+
380
+ ## Kept state the first paint shows is drawn by the server
381
+ - Kept state lives in web storage, which only the browser reads, and is restored after hydration — so anything kept
382
+ that changes what is DRAWN (the tool a toolbar shows as last picked, a name in an avatar, a panel left off) was
383
+ painted with the space's defaults and swapped a moment later. New space setting **`paintedState`**: the kept keys the
384
+ first paint shows. They are written to a cookie as well (`plitzi_<webId>_painted`, with the port in the name as the
385
+ debug cookie has it); `prepareRender` reads it, renders with the declared keys only and hands the page the same
386
+ values as its starting `runtime.state` (the SDK's `state`), so it hydrates onto them and nothing is swapped. The HTML
387
+ cache is keyed by that cookie. `e2e/tests/server/ssr/paintedState.spec.ts` checks the raw HTML and the hydration.
388
+ - The cookie carries its owner, like the kept entry in web storage: the server cannot tell whose it is for a space with
389
+ its own sign-in, so once auth has settled the page drops what it rendered with from somebody else's cookie, and an
390
+ account change no longer returns to them. Over 3 KB it is not written (the dev tools say so), and the stale one is
391
+ removed.
392
+ - `authorSpace` checks it like `transientState` — a list of top-level keys — refuses a key that is both painted and
393
+ transient, and warns `painted-state-without-keep-state`. The builder's State Settings, the MCP's `patchSettings`
394
+ (validated the same way), its guide, the authoring skill and `docs/en/authoring-spaces.md` describe it.
395
+ - One cookie reader for both halves of the SDK (`cookieFromHeader` in `@plitzi/sdk-shared/helpers/cookies`), which the
396
+ theme cookie now uses as well.
397
+
398
+ ## Testing an authored space: one call, every problem
399
+ - **`inspectPage(page, handles, options?)`** (`@plitzi/sdk-authoring`): the open page, checked whole — every element it
400
+ owes present and visible (its own and those of the layouts around it), images arrived, nothing scrolling sideways, no
401
+ text in the colour painted behind it. Returns every problem at once, each naming the element and why
402
+ (`display:none on "panel"`), and retries like an assertion. Driver-agnostic (anything with `evaluate`), no new
403
+ dependency. `inspectDocument(page)` runs the page half for a page whose space is not in hand.
404
+ - `onScreen(handles, page, { elements, ignore })`: what a freshly opened page owes. Page handles carry their `layout`,
405
+ layout handles theirs.
406
+ - `singlePageSpace(body, space?)` and `withElement(spec, id, patch)`: a one-page space, and the same space with one
407
+ element changed — on the SPEC, so a variant is validated like any space.
408
+ - `authorSpace(spec, { allow: [{ code, element, why }] })`: a fixture that breaks a check on purpose names that break.
409
+ It comes back in `warnings` with the reason; an entry that matches nothing is refused.
410
+ - Fixed: `defineAction` with several triggers chained them one after another, so a run through the first executed the
411
+ second as a step. Every way in now heads the same chain.
412
+ - Fixed: `provider-without-source` warned on a provider that names a `connector`.
413
+ - The image element says when it drew its fallback: `data-plitzi-failed="<src>"`. The fallback loads, so to a browser
414
+ a broken image looked loaded. The fallback is now state, keyed by the source: a new `src` gets its own attempt.
415
+ - The CLI's generated visual test uses `inspectPage`.
416
+ - The sample space (`examples/shared-space`) no longer sizes itself by the window: embedded beside a host's sidebar
417
+ (`03-react-component`) it overflowed by the sidebar's width. Its RSC section is named `rsc-section`.
418
+
419
+ ## A page server that starts in less memory
420
+ - `sdk-shared` imports date-fns one function per subpath, and its locales one by one: `from 'date-fns'` loaded all
421
+ 826 of its modules (and `date-fns/locale` every language) wherever the date helpers were imported, which is on every
422
+ page server — the largest single cost of starting at all.
423
+ - `isDate(value, format)` moved to `@plitzi/sdk-shared/helpers/isDate` (still exported from `@plitzi/sdk-shared/helpers`):
424
+ it needs date-fns `parse`, which alone costs about 100 MB, and nothing that only formats dates should load it. It is
425
+ no longer exported from `helpers/formatDate`.
426
+ - Needs `@plitzi/plitzi-ui` with the same fix in its `formatDate` (the QueryBuilder evaluator a server loads imported
427
+ date-fns whole, and `parse` for one fixed format). Measured on the SSR example: resident memory at rest 308 → 177 MB.
428
+ - Everything a page runs is in `plitzi-sdk.js` again, with no `withElement-<hash>.js` or `rolldown-runtime-<hash>.js`
429
+ beside it. The plugin loader's dynamic imports split a chunk off, which every host serving the SDK by name had to
430
+ know about. The vendor build has `codeSplitting: false`; the SDK's keeps every module its entry reaches statically in
431
+ the entry (see "A lighter SDK" for the one chunk it does split).
432
+
433
+ ## A page server that fits in half a CPU and 256 MB
434
+ - **Fixed: a page server leaked on every request until it ran out of memory.** The auth/deployment middleware chain
435
+ was rebuilt per request, and `basicAuthMiddleware` starts a credential cache with a sweep timer — each request left
436
+ one behind that its timer kept alive for good (about a kilobyte a request; a 256 MB container died after a few
437
+ hundred thousand). The chain is built once per server, and the credential cache finally caches.
438
+ - **A cached page is compressed once.** The render cache keeps the Brotli and gzip bodies beside the HTML, so a hit is
439
+ a copy instead of a compression — which was 87% of a hit's CPU. `res.send(body, { compressed })` takes the store to
440
+ fill; `CompressedBodies` and the cache's `CachedPage` are exported.
441
+ - **Log levels: `logLevel` (`silent` < `error` < `warn` < `info` < `debug`).** One threshold for everything a server
442
+ says, process-wide. Default `error` — in production only what went wrong is written — and `info` with `devMode`.
443
+ - Requests below the threshold are not turned into events; a 5xx or a request that threw is an `error`, the rest
444
+ `info`. A refused action is a `warn`, a run that did not complete an `error`.
445
+ - The server's own lines (`listening on`, plugin builds, manifest fetches, failures in adapters, RSC, actions, auth
446
+ flows) are `kind: 'message'` events on the same `logger`, where they used to go straight to the console. With
447
+ no `logger` they still go to the console.
448
+ - The dev metrics line is `debug`.
449
+ - `serverLog` (`error`/`warn`/`info`/`debug`/`enabled`/`emit`), `logLevelOf` and `isLogged` are exported.
450
+ `createRunLogger`/`createRejectLogger` are held to the same threshold.
451
+ - **`createJsonAdapters` parses a file once per version of it**, not once per request (a tenth of a render's CPU on a
452
+ real space). A file edited by hand is read again by its mtime and size; a save drops the copy it replaced. Every
453
+ request shares the parsed space, as they already did with `createCloudAdapters`.
454
+ - A server with `devMode` off that runs without `NODE_ENV=production` says so once, at `error`: React chose its
455
+ development build from `NODE_ENV` when it was imported, and renders about 40% fewer pages a second with it. The
456
+ README no longer claims `devMode` defaults from `NODE_ENV` — it defaults to off.
457
+ - `@plitzi/sdk-server` and `@plitzi/sdk-mcp` no longer bake `process.env.NODE_ENV` in at build time. The published
458
+ build always read `"production"`, whatever the process was started with; a server reads it from its process now.
459
+
460
+ ## Static files compressed, and compressed once
461
+ - **Fixed: static files went out uncompressed.** Every body sent as a `Buffer` skips compression (the rule that
462
+ keeps fonts and images intact), and static files were read as Buffers — so the SDK bundle, its vendor and its
463
+ stylesheet travelled whole: 2.7 MB, 1 MB and 220 KB on a first visit. Text files (scripts, stylesheets, JSON, SVG,
464
+ plain text) are compressed now; images and fonts still go out as the bytes on disk.
465
+ - Each is compressed once per version of the file and kept, within 16 MB, least recently served first out — and read
466
+ from disk only when an encoding it has not been compressed to yet is asked for. `res.send` takes a function for such a
467
+ body: `send(() => read(), { compressed })` reads it only when the stored form is missing.
468
+ - **Two Brotli qualities.** `compression.brotliQuality` (default 2, was 4) is for a body compressed on every request;
469
+ `compression.keptBrotliQuality` (default 6) for one compressed once and kept — a cached page, a static file. Measured
470
+ on a quarter core: 4 cost a tenth of the pages a second to save half a kilobyte each; 6 makes the SDK bundle 10%
471
+ smaller than 4 for the same memory, where 9 needs ~40 MB more than a 128 MB server has.
472
+ - The space embedded in a rendered page is serialized once per space object instead of on every render — a tenth of a
473
+ render's CPU spent producing the same string.
474
+ - `sdk-shared`: the dev-tools console no longer keeps logs on a server, where nobody could ever receive them and they
475
+ held other visitors' state; and stamps a log by hand instead of through date-fns. A page renders ~25% faster on a
476
+ quarter core with both.
477
+ - Examples: the servers take `HOST` (still loopback by default) and derive `devMode` from `NODE_ENV` — a copy of an
478
+ example run in production no longer runs in development mode.
479
+ - `bench/` (private): load and footprint benchmarks of the self-hosted servers under hardware profiles, with
480
+ baselines — `yarn bench`, see its README.
481
+
482
+ ## Self-hosted servers run compiled, without a transpiler
483
+ - **`plitzi create` (server mode) runs on Node alone.** `start` is `node src/main.ts` — Node 22.18+ strips the types
484
+ itself — and `tsx` is gone: its loader thread cost a page server more memory than the server, ~270 MB to start where
485
+ the same server starts in ~90. For production, `build` (`tsc -p tsconfig.build.json`) emits `dist/` and `start:prod`
486
+ runs `node dist/main.js`: stripping types keeps a TypeScript transformer in the process for its whole life (~10 MB),
487
+ compiled JavaScript does not. The project's `tsconfig` holds the code to what stripping needs
488
+ (`allowImportingTsExtensions`, `verbatimModuleSyntax`, `erasableSyntaxOnly`), `engines` says `>=22.18`, relative
489
+ imports name their `.ts` file, the plugin path resolves from the project root (the same from `src/` and `dist/`),
490
+ and the server listens on `HOST` (loopback by default).
491
+ - The examples run the same way: `node src/main.ts`, `node --watch-path=./src` while editing, no `tsx`.
492
+ - `@plitzi/nexus` 1.3.1, now required (`^1.3.1`) by every package that uses it: `useStore` runs one set of hooks for a
493
+ single path and a list of paths, instead of both side by side with the idle one disabled (a page allocates about a
494
+ quarter less to render); `useStoreGetter` reads a function entry from the latest render instead of an earlier
495
+ closure with the same source; and the path caches no longer evict every path of a page of a few hundred elements
496
+ just before reading it again.
497
+ - `bench/`: `cli-server` measures a project as `plitzi create` writes it, built and run with `start:prod`; portals are
498
+ mounted into the container and recorded in every result; `edge-96` and `edge-64` probe the floor.
499
+ - `sdk-elements`: an element that binds nothing no longer resolves bindings on every render, and one whose attributes
500
+ hold no template no longer builds template data and copies its attributes to interpolate none — the work every
501
+ element of a page did on each render, for nothing, however few of them bind or template.
502
+ - `bench/`: the memory probe is compiled to JavaScript before a run (loaded as TypeScript, it put Node's type stripper
503
+ into every server measured, ~10 MB counted as the server's); `--repeat N` starts a target cold N times and keeps
504
+ each phase's median run, since on Apple silicon a container runs on a fast or a slow core for its whole life.
505
+
506
+ ## A page server on every core
507
+ - **`workers`: one process per core.** Node renders on one thread, so a server used one core however many the
508
+ machine had. `workers: 'auto' | <n> | false` (and `SDK_SERVER_WORKERS`) runs the server in that many processes on
509
+ one port; `'auto'` is the default under `NODE_ENV=production`, one process anywhere else. The count follows the
510
+ cores the process may use — a container's CPU quota included — and a number above them is lowered, with a warning.
511
+ One process (workers off, one asked for, one core) is the server exactly as before: nothing forked, nothing wrapped.
512
+ - **The workers are one server.** The in-memory defaults — an action's `kv`, the job queue, draft previews, the
513
+ sign-in rate limit — are kept once, by the process that was started, and every worker reaches the same copy over
514
+ the cluster channel; a store the deployment supplies is used as it is. The scheduler and the job consumers run in
515
+ one worker. `server.cache.invalidate()`, `server.plugins.register()` and `server.plugins.invalidate()` reach every
516
+ worker. Plugins are built once, before the workers start, and their files are written atomically (a temporary file
517
+ renamed over the target), so no process reads a half-written one.
518
+ - **A worker that dies is replaced**, and takes its jobs with it to the replacement. Past one death per worker a minute
519
+ the replacements wait longer each time (half a second, doubling, up to thirty), so a crash on every request is not a
520
+ fork loop; a replacement that cannot start is retried while the others keep serving. A worker that dies before any
521
+ has served stops the server with a non-zero exit, and a server killed outright takes its workers with it.
522
+ - **Fixed: `server.cache.invalidate({ spaceId | environment | hostname })` matched nothing.** It read the page cache's
523
+ key by positions it no longer had once the key gained the visitor's token, theme and dev-tools choice in front; a
524
+ publish webhook that invalidated a space cleared no page. The key is now read by the same list that writes it.
525
+ - `PluginManager.forget(name?, version?)` drops what a process remembers of a plugin and leaves the files: what an
526
+ invalidation in another worker does.
527
+
528
+ ## Fewer silent failures, less friction for an agent
529
+ - **New lint warnings**, each with the fix in its message, held by `authorSpace`, the builder's problems list, the
530
+ publish gate and the MCP server alike:
531
+ - `server-data-without-rsc`: a `runtime: 'server'` provider with a `connector` or `action` in a space that does not
532
+ turn server data on — it rendered its mock data and nothing said why. Add `rsc: { enabled: true }`.
533
+ - `route-param-undeclared`: `navigation.routeParams.x` read on a page whose slug has no `:x` — always empty. Prose
534
+ elements that only mention one are not read.
535
+ - `form-control-unnamed` / `form-control-name-taken`: a control in a form with no `name` never reached the form's
536
+ values, and two with one name wrote over each other.
537
+ - `overlay-never-opened`: a modal or dialog that starts hidden and that no step opens.
538
+ - **New lint error `list-items-ignored`** (fixable): a `list` with items (bound or written) and a `source` other than
539
+ `'controlled'` rendered its children once and never read them — one empty row where the rows should be. `fixSpace`
540
+ sets `source: 'controlled'`. It found the Feature Lab seed's catalogue rendering one blank card.
541
+ - **The page server logs a plugin it has nothing for**: a `custom` element whose `renderType` has neither a component
542
+ in the render nor a bundle for the browser is reported at `error`, once per space and type, naming the element and
543
+ `plugins` + the deployment's `pluginNames`. It used to render "Custom Component … Not Found" and say nothing.
544
+ - **A `webHook` that writes with an empty body sends `{}`**, not the JSON text `""` a JSON endpoint refuses with a 400.
545
+ - **`webHook` takes `headers`** (by name; a template per value): an API key, an `Accept`, an idempotency key.
546
+ `Authorization` stays `authorizationToken`'s and the content type follows the body, so neither can be named twice.
547
+ Headers are part of what a cached read is keyed by.
548
+ - **Fixed: a `webHook` sending a file could not be read by any server.** It set `multipart/form-data` by hand, without
549
+ the boundary the parts are split by; `fetch` writes the type itself now.
550
+ - `sdk-authoring`: **`setFieldValue(target, name, value)`**, the step that fills one field of a form — clearing a code
551
+ field after a failed attempt, prefilling one from a binding.
552
+ - **Fixed: a step parameter like `"012345"` reached the step as the number `12345`.** A value is read as a number only
553
+ when it reads back as the same text; a code, a postcode or an id with leading zeros stays text.
554
+ - `fixSpace(space, catalogs, codes, elements)`: `elements` narrows the fixes to some elements. It now clones only the
555
+ elements it changes, and with nothing to fix answers the schema it was handed.
556
+ - **MCP `plitzi_apply` / `plitzi_validate`:**
557
+ - What was already wrong with an element a batch changes is fixed on the way, where it has one reading, and every
558
+ fix is said in `warnings`. The fixes run on the space before the batch, so the batch's own mistakes are still
559
+ refused.
560
+ - An old issue is told from a new one by its code on its element, not only by its message, so a page rename or a
561
+ changed suggestion no longer makes an old issue on an untouched element look new and block the batch. One more
562
+ of an issue than before is still the batch's.
563
+ - The space as it was is read only when the result has something to classify: ~15% less time a batch on the
564
+ largest spaces.
565
+
566
+ ## A server-rendered page carries its stylesheet once, and its data as JSON
567
+ - **The compiled stylesheet no longer travels twice.** The runtime `<style>` a page renders already holds
568
+ `style.cache`, verbatim, and the hydration payload carried it again. The server now leaves it out
569
+ (`styleCacheInDocument: true` in the payload) whenever the page gives it back byte for byte — no `{{ token }}`, no
570
+ `<`, no `\r` or `\0` — and `render()` reads it back from that `<style>` before hydrating, so the browser draws the
571
+ same stylesheet and the store holds the same cache. The cache sits between two CSS comments in the stylesheet it was
572
+ always in: the element, its place in the tree and the cascade are unchanged. `plitzi.com`'s home: 2.82 → 2.53 MB,
573
+ 350 → 313 KB with per-request Brotli.
574
+ - **The payload is a `<script type="application/json">` block**, parsed with `JSON.parse` and removed once read,
575
+ instead of a JavaScript literal inside the bootstrap module. On a first load of that page Chromium parses it in
576
+ 6.7 ms instead of 15.4 ms; the page no longer holds the space twice.
577
+ - `sdk-shared/style`: `markStyleCache`, `styleCacheTravelsInDocument`, `styleCacheFromDocument`, `RUNTIME_STYLE_ID`.
578
+
579
+ ## The account console, and telling devices apart
580
+ - **`auth.plitzi.*/account` is an account console**, no longer a column beside the sign-in's brand pane: identity along
581
+ the top, sections down the side (tabs on a phone) — **Profile** (username, and email changed through a link to the
582
+ new address), **Security** (password, and closing the account), **Devices**, **Connections**.
583
+ - **Devices are listed by device, not by session.** `GET /devices/sessions` groups sessions by what they were created
584
+ from and answers this device apart; a browser that signed in forty times is one row saying "40 sessions". The list
585
+ had shipped and drawn every session — 1,218 for one account a test suite signs in as — inside a box that scrolled.
586
+ - **Every device is named for what it is**: the application as it registered over OAuth (`Plitzi CLI on carlos-mbp`,
587
+ `Plitzi Desktop on studio`), a browser and its system, an automated client (Playwright, headless Chrome) — never a
588
+ raw user agent. Sessions record their address and **when they were last used** (at most every five minutes, on the
589
+ lookup a request already makes). A sign-in deletes the account's dead sessions.
590
+ - **AI connectors are one per application**: Claude and ChatGPT granted the same space are two connectors, each named
591
+ and disconnected on its own (`GET /devices/connectors`).
592
+ - `sdk-server`: **`issueToken(user, target, context)`** — the OAuth layer tells a deployment which application a
593
+ credential is for (`client_name`, `software_id` kept from registration), the request it is issued on, and on a
594
+ renewal the credential it **replaces**, so a native client renewing daily stays one session. **`revokeToken`**, new
595
+ and optional: `/revoke` now ends what the grant issued, not only its renewal — signing the CLI or the desktop app out
596
+ removes it from the device list at once. Sessions carry `app` and `lastActiveAt` (`SessionApp`,
597
+ `SESSION_ACTIVITY_RESOLUTION_SECONDS`, `activityDue`); the MySQL store migrates to schema step 4. The session's
598
+ address is recorded (Express `req.ip`, or the proxies' headers); `SSRRequest.ip`.
599
+ - `sdk-server`: **`deletionBlockers(userId)`** — an account is refused deletion (409, with the list) while it owns
600
+ something that would be lost with it. `plitzi-sdk-server` refuses while it is the only owner of a workspace holding
601
+ spaces, other members or a live plan; an empty personal workspace is deleted with the account.
602
+ - **Fixed: a flow's `navigate` to a page in a folder went to the wrong address** — `/security` for `/account/security`,
603
+ and the home page for a folder's index page. It resolves the page's full path now, as a link does
604
+ (`navigationTarget` in `sdk-shared/navigation`).
605
+ - **Fixed: changing your email sent a mail with no body** (the `email-change` template did not exist) and a link to no
606
+ page. It has both now (`/confirm-email` in the auth space).
607
+ - **Fixed: account emails interpolated values unescaped** — a username with markup in it arrived as markup in a mail
608
+ sent from Plitzi.
609
+ - The visual suite signs every test's session out when it ends; it had left over a thousand on one account a day.
610
+
611
+ ## Two-step sign-in
612
+ - **An account can turn on a second step** (an authenticator app, TOTP) from Security in the account console: a QR
613
+ code drawn by the server, the key to type by hand, one code to confirm it works, and ten recovery codes shown once.
614
+ Turning it off asks for the password. `GET /account/mfa/setup` answers the QR of an enrolment in progress, never of
615
+ one already confirmed, and is never cached. The secret is encrypted at rest (`user_mfa`) and recovery codes are kept
616
+ as digests, each good once.
617
+ - **Signing in then asks for the code** on its own page (`/two-factor` in the auth space), which takes a recovery code
618
+ as well. `sdk-auth`: a login answered with `mfaRequired` is a **`MfaChallenge`** (`{ ok: false, reason: 'mfa',
619
+ mfaToken }`), not a session — the provider read it as one and ended signed out. The space names where the code is
620
+ sent with **`mfaUrl`**; the `auth.login` step's mode **`'mfa'`** sends `{ mfaToken, code }` there.
621
+ - **Fixed: an AI connector ended from the account console came back.** Ending it — one connector, "sign out everywhere
622
+ else", or the space's own credentials — deleted its row, and the host's next renewal put the row back. A renewal of
623
+ a connector that has been ended now ends too (`invalid_grant`).
624
+ - **Fixed: a new account made by signing in with GitHub or Google could not use its session.** It was created active
625
+ but not verified — the column's default — and an unverified account holds no session, so every request after the
626
+ sign-up answered 401. The provider verified the address, so the account is created verified; a migration verifies
627
+ the accounts already created that way. The credential exchange made accounts the same way and is fixed with it.
628
+ - **Fixed: a code could sign in twice.** A TOTP code is valid for its whole window, and nothing remembered that one had
629
+ been used: seen over a shoulder or lifted by a phishing page, it opened a second session within that window. The
630
+ step of the last code accepted is kept (`MfaRecord.lastUsedStep`; `totpStep(secret, code)` in `sdk-server/auth`
631
+ says which step a code matched) and nothing up to it is taken again — the code that confirms the enrolment
632
+ included (RFC 6238 §5.2). The MySQL store migrates to schema step 5 (`account_mfa.last_used_step`).
633
+ - **Fixed: an account with a second factor was locked out by signing in often.** A right password answered with a
634
+ challenge was not reported as a success, so the sign-in limit counted it as a failure (ten in five minutes).
635
+ - `sdk-server`: a numeric field in an `/auth` body is read as its text (a code typed into a number field was dropped
636
+ as missing).
637
+ - **Fixed: signing out of the auth space could loop between two pages** (over a thousand navigations in six seconds):
638
+ for a moment the session published to the page was still the one the provider had just ended. Once the provider says
639
+ nobody is signed in, the page is told nobody is (`publishedSession`).
640
+ - Builder: the space's provider settings take **`mfaUrl`** and **`sessionExchangeUrl`**; both could only be set from
641
+ code.
642
+
643
+ ## Requests from other sites
644
+ - **Fixed: any website could act as a signed-in person against the api and server roles.** CORS answered every origin
645
+ with `Access-Control-Allow-Credentials`, and the session cookie is `SameSite=None`: a page the person visited could
646
+ read their account and write to it. Credentialed CORS is now for `PLATFORM_ORIGINS` only; every other origin is
647
+ still answered, without the person's cookies. A published site calling its own space is unaffected — it presents
648
+ its space credential, and a customer domain's sign-in exchange is served by its own page server, same-origin.
649
+ - **A write carried by a session cookie from another site is refused** (403, `reason: 'foreign'`), judged by Fetch
650
+ Metadata and the exact `Origin`: CORS keeps another site from reading, but a plain form POST needs no preflight.
651
+ Platform origins pass, and so do the origins the request's space credential declares. Bearer requests carry no
652
+ victim's cookie and are never asked. `sdk-server`: **`createOriginGuardMiddleware(csrf, { allowedFor, exempt,
653
+ errorKey })`** and `csrf.crossSite(carrier, alsoAllowed?)`; `plitzi-sdk-server` runs it on both roles and refuses
654
+ to start with CSRF switched off.
655
+ - **Analytics beacons are sent as text** (`text/plain`), which the collector reads as JSON. A beacon always goes with
656
+ credentials, and a JSON one is preflighted: from a customer's domain it would have been refused with the rule above.
657
+ As text it needs no preflight. **Fixed:** the `fetch` fallback (a batch over the beacon's size) arrived empty — in
658
+ `no-cors` the browser sends text whatever the header says, and the collector did not read it.
659
+ - **Fixed: the CSRF middleware answered 500 on Node 24.** It built its carrier by spreading the request, and `headers`
660
+ there is a getter on the prototype that a spread does not copy. The carrier is built field by field (`carrierOf`).
661
+
662
+ ## Plugins from the CLI
663
+ - **`plitzi add plugin [names...]`** adds elements of your own to the project you are in — one, several at once, or one
664
+ at a time as the need comes. It asks what to call each, what the builder shows and what it is for, checks every folder
665
+ is free before writing any, and writes a folder per element the way `@plitzi/sdk-elements` writes its own:
666
+ the component, `declaration.ts` (its `type`, the `triggers` it fires, the `callbacks` it answers to and the element
667
+ the builder adds — data only), `Settings.tsx` (its builder panel) and `index.ts`
668
+ (`Object.assign(Component, declaration, { pluginSettings: Settings })`). Each kind of project is answered as itself:
669
+ - a project `plitzi create` wrote gets it in `src/plugins`, registered by itself (`start:dev` restarts onto it); when
670
+ its space lives in Plitzi, it is told to place it in the builder;
671
+ - a project from before plugins were found by folder is told the exact line its `src/main.ts` list needs;
672
+ - a plugin package gets it in `src/` and in `src/elements.ts` / `src/declarations.ts` — rewritten only while they are
673
+ still the lists the CLI wrote;
674
+ - any other project is asked for the folder (`--dir`) and told how to register it for `render()`,
675
+ `<PlitziSdk.Plugin>` and a page server.
676
+ A name that would make a built-in element's type (`button`, `form`) is refused.
677
+ - **`plitzi create [directory] --plugin`** writes a plugin package: its elements, a Vite preview that renders them
678
+ inside a space, and a visual test of each. A package holds as many elements as it needs (`--elements
679
+ legend,price-tag`, or asked): the first is published as the plugin, the rest as its `plugins`. It builds nothing
680
+ itself — no bundler config, no build dependency — since `plitzi pack plugin` is the one place a plugin is built.
681
+ It ships its source (a page server compiles an element from it) and exports `elements` for a project registering
682
+ them itself. `--name`, `--title`, `--description` and `--owner` answer what it otherwise asks; inside a repository it
683
+ offers the folders that repository keeps its packages in, installs with its package manager and leaves its install
684
+ settings alone. It replaces the `plitzi-plugin-template` repository, which is deprecated.
685
+ - **`plitzi pack plugin [folders...]`** builds a plugin — a package's elements, or element folders of any project, a
686
+ self-hosted one included: one ES module (esbuild; React and the SDK kept out for the page to provide, images and
687
+ fonts kept in, since a page imports it from a blob URL), `plugin-manifest.json` written from the elements'
688
+ declarations with each file's integrity hash, and the zip the builder takes under Resources — checked against how
689
+ the upload and the builder read it. A package also gets its type declarations, written with its own TypeScript. A
690
+ declaration missing what the manifest needs is refused by name.
691
+ - **`plitzi login`, `logout`, `whoami`, `space` and `upload plugin`.** The CLI signs in the way the desktop app does:
692
+ in the browser, through the platform's native OAuth (loopback redirect, PKCE), keeping the session and a refresh
693
+ token in `~/.config/plitzi/connection.json` (0600), renewed on its own and revoked on `logout`. It is connected to
694
+ **one space at a time**, chosen on the grant screen (`plitzi space`, the `space` scope) — choosing again replaces the
695
+ connection and revokes the one before, and no command takes a space of its own. `upload plugin` puts the zip
696
+ `pack plugin` left on one of that space's CDNs and installs it, signing in or choosing the space in the browser
697
+ first when either is missing. Every builder open on the space loads the new version on the spot.
698
+ - `sdk-server`: `grantTargets(user, { scope })` — the grant screen offers what the scope a client asked with chooses
699
+ among — and the token response carries the chosen `target` (RFC 6749 §5.1), on renewal too, so a native client knows
700
+ what it was granted.
701
+ - `plitzi-sdk-server`: the native sign-in offers the person's spaces to a client asking with the `space` scope, and
702
+ re-checks access to the chosen one whenever it issues a session — a refresh after losing access ends the grant.
703
+ `GET /spaces/:id/cdns` lists a space's CDNs (never their credentials) and `POST /spaces/:id/cdns/:identifier/plugins`
704
+ takes a plugin's zip, uploads it the way the builder does (one `uploadResource` now serves both) and installs it on
705
+ `main`, keeping the settings of a plugin already there; the change is recorded in the space's history as the person's.
706
+ - **Plugins change live in every builder open on the space.** Adding, updating or removing one — from a builder, or
707
+ from `plitzi upload plugin` with none open — is announced on the space's one channel (Redis pub/sub and the GraphQL
708
+ subscription every other edit travels on), as `SPACE_ADD_PLUGIN`, `SPACE_UPDATE_PLUGIN` and `SPACE_REMOVE_PLUGIN`.
709
+ The other builders load, swap or drop it, its sub-plugins and its stylesheet included; the builder that made the
710
+ change does not apply it twice.
711
+ - **Fixed: a plugin's settings could not be saved.** `SpaceUpdatePlugin` required the plugin's address and wrote the
712
+ settings empty every time. Both are optional now and what is not sent is kept; settings that are not an object, or
713
+ a plugin the space does not have, are refused.
714
+ - **Fixed: a plugin uploaded from Windows was refused** ("Type file not supported"). Chrome and Edge on Windows send a
715
+ zip as `application/x-zip-compressed`, and both the builder and the upload accepted only `application/zip`.
716
+ - **A project `plitzi create` writes registers every folder of `src/plugins` by itself**, under its name in camelCase
717
+ — `readdirSync` in server mode, `import.meta.glob` in client mode — so a new element needs no line of `src/main.ts`.
718
+ - **A new project is formatted by its own Prettier** once installed, so the first commit is already in its style. Only
719
+ into a folder that was empty: `--force` never reformats work that was there.
720
+ - `sdk-shared` / `plitzi-sdk`: **`PluginDeclaration`**, the declaration type of an element of somebody's own — what
721
+ `ElementDeclarationData` says of any element, plus the `content` a manifest publishes. Exported as a type from
722
+ `@plitzi/plitzi-sdk`.
723
+ - **Fixed: a plugin on its own host rendered "Not Found".** `fetchManifest` sent `Content-Type` on a GET, which made the
724
+ browser ask the host's permission first; a host that allows plain cross-origin reads — the usual CORS setting of a
725
+ bucket — refused, and the element never loaded. It asks with `Accept` now, and answers nothing for a 404 rather than
726
+ for a body that failed to parse.
727
+ - `sdk-authoring`: `blankSpaceSource({ plugin: { as: 'element' } })` hosts a plugin as an element of its own type — how
728
+ the builder adds one and how a space loading it from its manifest renders it, and takes a list to host several.
729
+ Strings in the copy are quoted the way
730
+ Prettier quotes them (`"Today's"`, not `'Today\'s'`), and a name with a backslash no longer breaks the file.
731
+ - e2e: `plugin-server` generates a package with the CLI, builds it, publishes it on a host of its own and checks a page
732
+ loads it from its manifest.
733
+
734
+ ## Export as code: spaces from an older builder
735
+ - `sdk-authoring`: **`specFromSpace` reads a document an older builder keyed by ObjectId.** Before an element's id was
736
+ its name, `flat` was keyed by a Mongo ObjectId and the name lived in `idRef`; reading one kept the ObjectId, and
737
+ `authorSpace` refused it ("not one a binding, a template or a test can name"), so the builder's Export failed on
738
+ every such space. Each element takes its `idRef` back as its id — a positional `<type>-<n>` where it has none and
739
+ its key is not a valid id — with every reference repointed, and the rename is reported as `legacy-element-id`.
740
+ `withNamedIds` is exported so a caller comparing what it read compares it under the same names.
741
+ - `sdk-authoring`: a space with no pages is refused up front with a reason a person can act on, instead of the
742
+ authoring error about writing one.
743
+ - Builder: Export sends the space's plugin types, so an element a plugin provides is no longer reported as unknown.
744
+
745
+ ## Outbound requests stay outside the cluster
746
+ - `sdk-server`: the `http.request` task and the connector engine refuse private destinations however they are written.
747
+ An address is judged by range, so private IPv4 written as IPv6 (`[::ffff:127.0.0.1]`, `[::]`) is refused, and so
748
+ are the multicast, CGNAT and reserved ranges. NAT64 and 6to4 addresses are judged by the IPv4 address they carry,
749
+ so an IPv6-only cluster still reaches public IPv4 APIs. A DNS name is no longer refused just because it starts like
750
+ an IPv6 prefix (`fcbarcelona.com`).
751
+ - Redirects are followed one hop at a time, and each destination is checked before anything is sent to it. A public
752
+ URL that redirects into the cluster is refused. A redirect that leaves the origin drops `Authorization` and `Cookie`.
753
+
754
+ ## The grant screen says who is asking
755
+ - `sdk-server`: the OAuth grant screen names the client and the host the grant is sent back to ("an app on this
756
+ computer" for a loopback client). Anybody can register a client under any name, so the host is the part a person
757
+ can check. `OAuthConsentView` carries it as `client: { name, redirectHost, loopback }`.
758
+ - New `OAuthConfig.loopbackRedirectsOnly`: only `http://127.0.0.1` / `localhost` redirects are accepted, when a client
759
+ registers and again at `/authorize`. Turn it on when every client is a native app, above all with `directTokens`,
760
+ where the grant is the person's session.
761
+
762
+ ## Draft previews: a secret, and one space
763
+ - `sdk-mcp`: the `/__preview` endpoint refuses every request when preview is enabled without a `secret`, and compares
764
+ the secret in constant time. It lives on the page server the public reaches, so "no secret" used to mean "no check".
765
+ **A deployment with preview on must set `preview.secret`**, and every caller must send it as `x-preview-secret`.
766
+ - `sdk-server`: a draft is only rendered for the space it was made from. `DraftPutOptions` and `DraftEntry` carry
767
+ `spaceId`, and a token presented under another space's host is ignored. A custom `DraftStore` has to keep it.
768
+
769
+ ## One outbound rule, everywhere
770
+ - `sdk-server`: `@plitzi/sdk-server/kernel` exports `isBlockedHost` and `assertOutboundAllowed`, the rule the
771
+ `http.request` task and the connector engine follow.
772
+ - `sdk-mcp`: the widget proxy judges addresses with that rule instead of its own copy, which let IPv4 written as IPv6
773
+ (`[::ffff:127.0.0.1]`) through.
774
+ - `sdk-server`: a space's external plugin manifest is fetched through the same rule, redirects included.
775
+
776
+ ## A signed-in visitor is rendered signed in, the day after too
777
+ - `sdk-server`: a page asked for once the access cookie has expired but renewal is still possible (the session hint
778
+ says so) is sent to renew first and comes back signed in, so the server renders the visitor the browser will end up
779
+ with. The HTML used to be the guest page, swapped for the signed-in one after boot, and a guest-only page was shown
780
+ and then redirected away from instead of answered with a 302.
781
+ - New `createServer({ sessionRenewal: { url } | false })`. On by default with `auth` (its own `/refresh`); without it,
782
+ name the endpoint — an absolute URL when another host serves `/auth`.
783
+ - The endpoint is `GET <basePath>/refresh?redirect=<page>`, served by `createServer({ auth })` and now also by
784
+ `mountAuthRoutes` (34 routes). It renews with the refresh cookie, writes the new session or ends a dead one, and
785
+ always sends the browser back — to a path, or to a host sharing the session's cookie domain (`/` otherwise).
786
+ - Only whole-tab navigations (`Sec-Fetch-Dest: document`) take part, on both halves: a renewal rotates the session,
787
+ and an `<img>` or a frame on another site must not be able to. Anything else on `GET /refresh` is a 405. A short
788
+ `<cookie>_renewing` cookie keeps a renewal that failed without ending the session from sending the visitor round
789
+ again.
790
+ - New in `@plitzi/sdk-server/auth`: `parseSessionHint`, `readSessionHint`, `sessionReturnTarget`,
791
+ `isDocumentNavigation`, `renewForNavigation`.
792
+
793
+ ## Scheduled jobs: a store hiccup is not an incident
794
+ - `sdk-server`: a schedule sweep, reconcile, job claim or heartbeat that fails is reported by how long it has been
795
+ failing. The first failure is a one-line warning with its reason (it runs again by itself, and every pass is safe
796
+ to repeat); a pass still failing a minute later is the error, with the cause; the pass that ends such a streak says
797
+ so. A Mongo driver resetting its pool while the host was busy used to print an error with a stack on every pass it
798
+ caught. A deployment's own `onError` still receives every failure.
799
+ - Tests that ran slow under a busy machine: the CLI's type-declarations test (a real compile) has a timeout of its own,
800
+ and the workers test waits for every worker to listen rather than a fixed number of requests.
801
+
802
+ ## Plugins held to their declarations, and fixes a full example found
803
+ - `authorSpace(space, { plugins: [declaration] })` (also `validateSpace`, `lintSpace`, `fixSpace`): a plugin handed over
804
+ as its declaration is checked like a built-in element — authored as its own type or hosted by
805
+ `custom({ renderType })`. A flow on an event it never fires, a step sent to an action it does not answer and an
806
+ attribute it does not read are refused, naming what it declares. `pluginTypes` stays as the lighter form.
807
+ - New step builders typed from a declaration: `declaredTrigger(declaration, 'onPick')` and
808
+ `declaredCallback(declaration, 'reset', { on: 'seats' })` — a name the declaration lacks is a compile error.
809
+ - A `custom` host whose component was not declared is no longer refused for flows on the component's own events: the
810
+ plugin template `plitzi add plugin` writes (`onCount`) could not be used in a flow as generated.
811
+ - `setState` accepts `type: 'json'` in authoring, as the runtime and the docs always did; a test holds the two lists
812
+ together.
813
+ - The plugin cache rebuilds when a plugin's entry moved (`Widget.ts` → `Widget/index.ts`) or a file it was built from
814
+ was deleted — a missing file used to count as unchanged, and a versioned plugin served the old bundle for good in dev.
815
+ - A plugin handed to the SDK after mount — one the server could not import, passed once hydration is done, or a
816
+ `<PlitziSdk.Plugin>` added later — now renders: the component registry was built once and never learned of it.
817
+ - `dropdown`: a click inside the popup no longer reaches the trigger and closes the menu (`closeOnClickPopup: false`
818
+ now keeps it open), and `closeOnClickBackground` closes on a click outside even without the blocking background.
819
+ - A space's `style.theme.default` is applied: the server paints a first visit in it, and the SDK starts there. The
820
+ theme cookie now records only a visitor's CHOICE — the theme a surface merely started in is not written — so a space
821
+ that changes its default reaches everybody who never chose.
822
+ - `button`, `text`, `markdown` and `heading` default line heights are ratios that land on the same pixels at their
823
+ default sizes, so a class that resizes the text keeps the proportion instead of a fixed 24px line.
824
+ - Skills: `plitzi-authoring` gains `reference/validation.md` (how `authorSpace` checks — first refusals one at a time,
825
+ then the linter's list at once — a one-file author script, and what it cannot see) and plugin guidance; `@plitzi/cli`
826
+ ships a `plitzi-cli` skill, copied into every project `plitzi create` writes and every plugin package.
827
+
828
+ ## A flow's steps read the page as it is when they run
829
+ - **Behaviour change.** Every step of a flow reads the sources again when it runs, instead of the page as it was when
830
+ the trigger fired. A `when` or a `{{ state.x }}` after a `setState` sees the new value; one after a `delay`, a
831
+ server action or anything else that waits sees what changed meanwhile, including what the person did. `computed`
832
+ is evaluated again for each step over the state as it is then — read from the store, not from the copy the last
833
+ render left in `runtime.sources`.
834
+ - What this breaks: a toggle written as two `setState` steps under opposite `when` guards on the same key now flips
835
+ and flips back. `authorSpace` warns about it (`state-toggled-in-branches`) and names `toggleState`. The Plitzi
836
+ website's sidebar toggle was the one stored flow of that shape, and is one step now.
837
+ - `liveSources(sources, state, computedDefinitions)` (`@plitzi/sdk-shared/dataSource`) is what an element hands a
838
+ running flow.
839
+
840
+ ## Keyboard shortcuts: `onKey`
841
+ - A trigger every element has, `onKey`, with one param: `keys` — one shortcut or several with commas (`'f'`,
842
+ `'shift+f'`, `'mod+k'`, `'plus, ='`, `'escape'`). Heard on the window while the element is mounted; ignored while
843
+ somebody types in a field unless Ctrl/⌘/Alt is held or the key is Escape; a matching press does not also do the
844
+ browser's default. The flow reads `{{ <step>.key }}`, the key pressed.
845
+ - Authoring: `onKey(keys)` refuses a shortcut that cannot fire where it is written; `lintSpace` reports one written in
846
+ the builder (`trigger-keys`).
847
+ - `@plitzi/sdk-shared/helpers/keys`: `parseKeys` and `keyPressCombo`, the two halves of matching.
848
+
849
+ ## `plitzi create` projects check their own plugins
850
+ - A local project keeps `src/plugins/declarations.ts`, and every place it authors the space — the server or the
851
+ browser entry, `npm run author`, the visual test — passes it to `authorSpace(space, { plugins: declarations })`.
852
+ `plitzi add plugin` adds each new plugin's declaration to it (or, when the list was changed by hand, says what to
853
+ add). A flow on an event a project's plugin never fires is refused at authoring, as it is for a built-in element.
854
+
855
+ ## Templates: `same as`, `divisible by`, and `null`
856
+ - `x is same as(y)` and `x is divisible by(n)` (with their `is not` forms) are Twig tests the interpreter now reads.
857
+ Before, `same` was read as a variable nobody set, so `x is same as(false)` held exactly when `x` was UNSET — silently
858
+ the opposite of what it says.
859
+ - `null` and `none` are literals, as in Twig, instead of names that resolved to nothing. After `is` they are still the
860
+ test (`x is null` holds for an unset value too).
861
+
862
+ ## `whileRunning`: what a trigger fired again while its flow runs does
863
+ - A trigger's `whileRunning` is `skip` (the default and what always happened: the firing is ignored — no double
864
+ submit), `queue` (it runs after the one in progress, in order) or `parallel` (it runs at once). Authored with
865
+ `whileRunning('queue', onClick())`, offered on the trigger in the builder, carried by the MCP and by the export to code.
866
+ - The guard is now per FLOW rather than per event: one flow on a click still running no longer holds back another flow
867
+ on the same click.
868
+ - `lintSpace` refuses `whileRunning` on a step that is not the trigger, or an unknown value (`while-running`).
869
+
870
+ ## A trigger fired while the page mounts runs its flow
871
+ - A flow starts one microtask after its trigger fires, once the commit that fired it has run all its effects. The
872
+ page's sources (`state`, `navigation`, the actions) register from effects React runs after those of the elements
873
+ under them, so a plugin firing an event from its first effect used to run a flow whose steps found nothing
874
+ registered — and did nothing, silently. `onLoad` and `onPageLoad` had each worked around it on their own.
875
+ - An element unmounted — or mounted again, as React does twice in development — before its flow starts does not run
876
+ it for the subscription that is gone. An element that only re-rendered keeps its subscription: `useInteractions`
877
+ subscribes once per mount and hands new callbacks to `InteractionsManager.update`, so a form marking itself
878
+ submitted as it fires `onSubmit` still runs the flow.
879
+
880
+ ## Realtime channels
881
+ - A space declares `channels` — topic patterns (`board:{id}`) with an access rule, who may send (`clients` or only
882
+ the `server`), `presence`, `maxMessageBytes` and `messagesPerSecond`. An undeclared topic is refused by the server,
883
+ by `authorSpace` and by `lintSpace` (`channel-topic`).
884
+ - `sdk-server` serves `/_realtime`: one Server-Sent Events connection per page for every topic it listens to, and a
885
+ `POST` to publish. Every message is authorised, size- and rate-checked, and stamped by the server (`from`, `user`,
886
+ `at`); presence (`$presence`, `$join`, `$leave`) is kept by the connections, nothing stored.
887
+ - Transport is a `PubSubAdapter` the deployment picks: `createServer({ realtime: { pubsub } })`. `createMemoryPubSub`
888
+ (the default, across a server's workers) and `createRedisPubSub({ publisher, subscriber })` ship; anything else is
889
+ one object of two methods. `realtime: false` turns the endpoint off.
890
+ - A server action announces what it did with the `realtime.publish` task (`ctx.publish` for a deployment's own task).
891
+ - On the page: the `channel` element (source `channel_<id>`: `connected`, `me`, `members`, `messages`, `last`;
892
+ `onMessage`, `onJoin`, `onLeave`; `publish`, `setPresence`), and `useChannel` for a plugin that moves at the speed
893
+ of a cursor. `onJoin` is somebody who came after the page, once they announced who they are, and `onLeave` somebody
894
+ who went — both with `from`, `user` and the `state` they announced, so a flow says who (`useChannel`'s `onJoin` /
895
+ `onLeave`, `trackPresence`'s `onArrive` / `onDepart`). Authoring: `channel(...)`, `publishOn`, `announceOn`, and `channels` on the space.
896
+ - A page is one connection and one member per topic, however many elements and plugins listen; a publish waits for
897
+ the connection that includes its topic, so one made right after a navigation is not refused.
898
+ - Two transports at the same address: Server-Sent Events plus a `POST` per publish (`sse`, the default), or one
899
+ WebSocket both ways (`realtime: { transport: 'websocket' }`), where a publish is a frame answered by an `ack`. A
900
+ page falls back to the stream on its own where a socket cannot open (HTTP/2, a proxy that drops upgrades). A socket
901
+ from another origin is refused unless listed in `realtime.allowedOrigins` — CORS does not protect a WebSocket.
902
+ - Upgrades go through the same pipeline as any request (space, auth, then the realtime stage); an upgrade on any other
903
+ path is a `404`. `makeHandler(label, buildContext, stages, { compression, upgrades })` takes an options object.
904
+ - Channel declarations are checked in one place, `channelProblems` (`@plitzi/sdk-shared/realtime`): authoring refuses,
905
+ `lintSpace` reports `channel-declaration`, and the MCP's `patchSettings` takes `channels` (merged per pattern, `null`
906
+ removes one) and answers with the same sentence. The agent's guide has a "Realtime channels" section.
907
+ - Pizarra, a collaborative whiteboard built on all of the above — channels, server actions, a canvas plugin, an agent at
908
+ `/mcp` — is a seeded space on the platform (`pizarra.plitzi.app`) whose server code is its runtime, not an example
909
+ here. See `docs/en/realtime.md`.
910
+ - `lintSpace`'s `channel-topic` skips an element whose `topic` is bound: its topic is only known on the page.
911
+
912
+ ## A page on its way out keeps what it showed
913
+ - A server-driven section (`apiContainer` with `runtime: 'server'`, anything reading `useRscData`) on the page being
914
+ left kept rendering its last answer. A navigation asks for the destination's payload before it goes, and the page it
915
+ leaves is still drawn over that payload for a moment — first while it lands, then while the next page renders — with
916
+ no slice for anything on it: every section drew itself empty on the way out (a list turned into its empty state on
917
+ the click that opened one of its items). An element on a layout, which is on every page, is never held back.
918
+
919
+ ## Links open where they are asked to
920
+ - A `link` to a page of the site with a `target` of its own (`blank`…) opened in the same tab — the click was always
921
+ taken over for in-place navigation. So was a click held with ⌘, Ctrl or Shift, or with the middle button. Only a
922
+ plain click to the same tab navigates in place now; the rest is the browser's.
923
+
924
+ ## `navigation.href`: the page's whole address, from the first paint
925
+ - The navigation store (and the `navigation` global source) carries `href` — origin, path and query — beside
926
+ `origin`, on the server as in the browser. A text or a code built from "this page's link" is right in the first
927
+ paint, where one filled in by the browser once mounted showed its placeholder first. The builder answers it for the
928
+ host being tested.
929
+
930
+ ## A server provider pages, and searches, where it is
931
+ - What a refresh of a server-driven provider asks for besides its page — the next page of a "load more", the input it
932
+ was reloaded with — reaches what resolves it. `/_rsc` read only the query of the page's `location` and dropped the
933
+ rest, so a provider paged in place (`pagination: 'append'`, `goToPage`) was answered its first page every time.
934
+ - `performQuery` (authoring: `reloadApi(id, input)`) takes an `input` for a server-driven provider — a search, a
935
+ filter, how many to show — and the provider keeps it for the pages and refreshes after it: "load more" of a search
936
+ is more of the search, and a live refresh does not lose it.
937
+
938
+ ## The server names a page's origin with its port
939
+ - The origin a page is rendered with (`navigation.origin`, `location.origin`) lacked the port on the server: a site on
940
+ `:4016` was `http://127.0.0.1` in the first paint and `http://127.0.0.1:4016` once hydrated — a text built from it
941
+ did not hydrate, and a link built from it pointed nowhere until then. It is taken from the request's authority,
942
+ guarded as before against a forged Host.
943
+
944
+ ## `onPointerDown`: the press, before it is a click
945
+ - Every element fires `onPointerDown` (authoring: `onPointerDown()`), beside `onClick`, `onHover` and the rest: the
946
+ press itself, where a drag away from the element starts — a tile taken to a canvas, a handle pulled. Let go where it
947
+ went down, it is a click too and `onClick` follows.
948
+
949
+ ## `formControl` takes the focus as it appears
950
+ - `autoFocus: true` focuses a control each time it is shown — as it mounts, and again whenever it or anything around
951
+ it goes from hidden to shown, so a search box a shortcut opens is typed into at once however many times it opens.
952
+ Text inputs and textareas alike; never in the builder.
953
+
954
+ ## Elements move as they show and hide: the `hidden` style state
955
+ - A class's `states` take `hidden`: how an element looks while its `visible` says no — where it goes as it hides and,
956
+ written as its `@starting-style` too, where it comes from as it shows. With a transition that includes
957
+ `display … allow-discrete`, a panel fades or slides instead of blinking, with a pace of its own each way (the base's
958
+ transition is the way in, the one in `hidden` the way out). `ancestors` take it too, for what is inside something
959
+ that hides. The builder's style editor has it as a tab, like `hover`. The SDK's hidden class is exported as
960
+ `HIDDEN_CLASS` (`@plitzi/sdk-shared/style/styleStates`).
961
+ - `transition-behavior` is in the style vocabulary, and `transition` reads `allow-discrete` into it.
962
+ - A layered `transition`, `animation` or `background` whose layers do not all say the same things expanded the missing
963
+ ones to `initial` — which is not allowed inside a list, so the browser dropped the whole declaration (a second layer
964
+ without a delay voided the first one's). They are now filled with each longhand's initial value, and
965
+ `background-color` is taken from the last layer only.
966
+
967
+ ## `formControl` of `subType: 'color'`
968
+ - A form control can be the browser's own colour picker: `subType: 'color'`, its value `#rrggbb`. Its `onChange` fires
969
+ as the colour is picked, like any other control's.
970
+
971
+ ## A render reads what a call wrote
972
+ - `createServer` built the actions module for `render` elements on a different config object than the one the
973
+ endpoint used — two modules, so two in-memory `kv` stores and two sets of single-flight guards. With no `kv`
974
+ configured, what a call saved was missing from every render. Both now share one module.
975
+ - The runner, the guards and the module's own `kv` share one default store instead of a Map apiece.
976
+
977
+ ## `json_encode` prints JSON for every value
978
+ - `json_encode` and `to_json` encoded objects only: `null` printed as nothing and a string printed bare, so a JSON
979
+ document built around them — a server action's `output`, a `realtime.publish` step's `data` — stopped being JSON the
980
+ moment a value was `null` or text, and the step failed at the end with the work already done. They now do what Twig
981
+ does: a string is quoted and escaped, `null` and an unset value are `null`. `object_as_json` is unchanged.
982
+
983
+ ## Keyboard shortcuts leave a text field its own editing
984
+ - With ⌘/Ctrl held a press in a field still reaches `onKey` (so `mod+k` opens a palette from a search box), but the
985
+ field keeps ⌘A, ⌘Z/⌘⇧Z/⌘Y, ⌘C/⌘X/⌘V and moving or deleting by word and line (`isFieldEditing`): a space binding
986
+ `mod+a` to "select all shapes" no longer steals "select this text".
987
+
988
+ ## Usable without sight: screen readers and browser agents
989
+
990
+ Screen readers and browser agents (Claude in Chrome) find a page's controls in its accessibility tree. The elements
991
+ now put the right things there, authors can say the rest, and the linter says when they have not. See
992
+ `docs/en/accessibility.md`.
993
+
994
+ - `modalContainer` / `dialogContainer` are a `dialog` / `alertdialog` with `aria-modal`, named by their title. They
995
+ take the focus as they open, keep Tab inside, close on Escape (a dialog is turned down, never accepted) and give the
996
+ focus back as they close. Their footer buttons are `type="button"`.
997
+ - `tabContainer` is a `tablist` of `tab`s (`aria-selected`, `aria-controls`) and `tabpanel`s, with one tab in the Tab
998
+ order; the arrow keys, Home and End move between tabs, Enter and Space select.
999
+ - `formControl`: a control breaking a rule is `aria-invalid` and described by its message (`aria-describedby`), which
1000
+ is an `alert`. The password eye is a real button, "Show password", with `aria-pressed`. New `hideLabel`: the label
1001
+ stays out of sight and still names the field.
1002
+ - `fontAwesome` is `aria-hidden` unless its new `label` gives it a meaning (`role="img"`).
1003
+ - `image` takes `decorative`: `alt=""` whatever `alt` says. The builder's settings can now write `alt` at all.
1004
+ - `container` takes `label`: a landmark's name, a `section` becomes a region, a `div` a named group
1005
+ (`NAMEABLE_CONTAINER_TAGS`).
1006
+ - `pagination` is a `nav` named by its new `label` ("Pagination"); the current page is `aria-current="page"`.
1007
+ - The segmented `themeToggle` is a named `group` whose options carry `aria-pressed`.
1008
+ - `link`'s `label` can be written in the builder. `button` takes a `label` too (`aria-label`), for a button whose words
1009
+ do not say what it does — a key hint, a count.
1010
+ - `dropdown` marks the control that opens it (`aria-haspopup`, `aria-expanded`); opened from the keyboard the focus
1011
+ moves to the popup's first control, and it goes back to the trigger when the popup closes with the focus inside.
1012
+ - `container` takes `decorative`: an illustration built from elements, `aria-hidden` whatever it holds.
1013
+ - The SDK's button base no longer removes the focus ring for everyone: only for a pointer
1014
+ (`:focus:not(:focus-visible)`).
1015
+ - New warnings in `lintSpace`: `control-without-name`, `image-without-alt`, `click-on-static-element`,
1016
+ `dropdown-without-control`, `heading-level-skipped`, `label-ignored`, `control-in-decorative`. Nothing inside a
1017
+ `decorative` container is held to them. The blank space and the examples author with none.
1018
+ - `plitzi_screenshot` takes `view: 'accessibility' | 'both'`: the page's accessibility tree as an outline, and every
1019
+ control or picture with no name (`unnamed`). `ScreenshotInput.views`, `ScreenshotResult.accessibility`; the HTTP
1020
+ client reads the screenshot service's Puppeteer tree (service ≥ 0.1.9), the local client Playwright's or Puppeteer's.
1021
+ `outlineOfTree`, `outlineOfSnapshot` and `unnamedControls` are exported.
1022
+ - The MCP guide, its quickstart, the server instructions and the co-worker prompt teach it; so does the authoring skill
1023
+ (`reference/accessibility.md`, a recipe, the review checklist).
1024
+
1025
+ ## A server with pages open shuts down
1026
+ - `close()` — and so `closeOnSignals` — waited for every open connection to end, and a realtime WebSocket, an event
1027
+ stream or an agent's listening MCP stream never does: with a page open, the first Ctrl+C or SIGTERM hung until a
1028
+ second one, or a SIGKILL, cut it. Now the server stops taking connections, the realtime hub closes its connections
1029
+ (a socket with 1001, going away), any other event stream is ended, requests being answered finish, and what is
1030
+ still open after `SHUTDOWN_GRACE_MS` (10 s) is cut. `HttpServerParts.onClosing`; `RealtimeConnection.end`,
1031
+ `hub.closeAll()`, `hub.connectionCount`.
1032
+
1033
+ ## A lighter SDK
1034
+
1035
+ `plitzi-sdk.js` (production) goes from 1141 KB to 766 KB minified, 342 KB to 239 KB gzipped. Nothing a page does
1036
+ changed.
1037
+
1038
+ - No Apollo in the SDK. It sends three queries, always to the network, and carried a GraphQL client with a normalized
1039
+ cache, `graphql`'s parser, rxjs and optimism for them — a quarter of the bundle. A `fetch` sends them now
1040
+ (`createGraphqlClient`, `GraphqlRequestError` with `failure: 'network' | 'http' | 'graphql'`), a 401 still reaches
1041
+ the auth-failure channel, and a page that cannot load says what it said before ("Access not authorized", "Service
1042
+ not available"). `SdkQueries` in `sdk-shared` are strings rather than `gql` documents, typed
1043
+ `Record<keyof SdkQueriesMap, string>`; the builder's documents are unchanged. `@apollo/client` and `graphql` are no
1044
+ longer dependencies of `@plitzi/plitzi-sdk`.
1045
+ - The dev-tools panel is a chunk of its own, `plitzi-sdk-devtools-<hash>.js`, beside `plitzi-sdk.js`: a page loads it
1046
+ when it is allowed to debug and shows the tools, and no other page does. `DevToolsContainer` loads its badge and panel
1047
+ lazily (they were never drawn before hydration), so the builder splits them off too. The chunk imports the SDK as
1048
+ `@plitzi/plitzi-sdk` — the name every page's import map already gives it for plugins — so the panel inspects the
1049
+ page's own stores, not a second copy the `?v=` cache-buster would have loaded. The build fails if anything else ever
1050
+ splits off.
1051
+ - `date-fns-tz` is gone from `sdk-shared`: `formatDateUTC` and `formatUTCToLocal` need no time-zone library.
1052
+ `formatUTCToLocal` printed the hour that repeats when daylight saving time ends an hour off; it no longer does.
1053
+ - The SDK's demo page and the static deployment template map `react/compiler-runtime`, which the bundle imports and
1054
+ only the page server's template mapped: a statically deployed space failed to start with "Failed to resolve module
1055
+ specifier".
1056
+
1057
+ ## Two writers at once, and private channels
1058
+
1059
+ What an app with more than one person in it had to build for itself, now the platform's — Pizarra, the whiteboard
1060
+ example, was built on the lack of them and is simpler for it.
1061
+
1062
+ - **`kv.setIf`**: writes only if the key still holds the value the flow read, and answers `written: false` when somebody
1063
+ got there first; empty `expected` = only if nothing is there yet (claim a seat, a username). `ActionKvStore.swap` for
1064
+ a deployment's own tasks.
1065
+ - **Lists**: `list.put` (one entry per id, highest score first, `keep` the top N — answering what it `dropped` — and
1066
+ `higherOnly` to keep a higher score already there), `list.range`, `list.remove`. At most 500 entries and 128 KB.
1067
+ `ActionKvStore.listPut` / `listRange` / `listRemove`.
1068
+ - **`flow.rateLimit`**: at most N runs every so many seconds, per person or for everyone, refused with its message.
1069
+ - **The `kv` adapter has a sixth operation, `swap`** (compare-and-set) — a breaking change for a deployment with its
1070
+ own adapter. Memory, the worker fleet's shared copy, MySQL, Mongo and the examples implement it; **`createRedisKv`**
1071
+ ships in `@plitzi/sdk-server/actions`, so a deployment on Redis writes no adapter at all (plitzi-sdk-server uses it).
1072
+ All of them pass one contract, racing writers included.
1073
+ - **Private channels**: a channel declared `grant: true` opens a topic only for a page that brings a grant for it —
1074
+ handed out by the `realtime.grant` task (or `ctx.grant`) after the flow decided the visitor may be there. Refused
1075
+ otherwise (`ungranted`), however well the topic's name is known. Grants live in the store the actions use, so they
1076
+ work across replicas with nothing new to configure; a day by default, thirty at most. The `channel` element and
1077
+ `useChannel` take a `grant`, the realtime client's `grant(topic, grant)` sends it; `lintSpace` reports a private
1078
+ topic opened with none (`channel-grant`).
1079
+ - **Revoking**: `realtime.revoke { topic, grant }` (or `ctx.revoke`) takes one grant back — or, naming none, every grant
1080
+ for the topic, the ones not used yet too. Whoever is on it with one, on any replica, is let go of it at once: their
1081
+ page hears `$revoked`, the others hear them leave, `useChannel` says `connected: false`; a new grant opens it again.
1082
+ - **MySQL `kv`**: a key is bytes (`VARBINARY(764)`) — `Board` and `board` were one key here and two in Redis and in
1083
+ memory — and a value a `MEDIUMTEXT`, where a `TEXT` refused, or cut short, anything past 64 KB. An existing table is
1084
+ brought up to it on first use, and only when it needs it; `mysqlJobSchemaUpgrades()` for a deployment that migrates
1085
+ its own tables (`createTables: false`).
1086
+ - Pizarra: its gallery is two of these lists — the featured boards and the rest, 200 kept, the one dropped forgotten;
1087
+ every board is written on its own with `swap` instead of one lock for all of them across every replica;
1088
+ its rate limits are `flow.rateLimit` steps in its actions; its board and room channels are private, opened with the
1089
+ grant `board-load`/`board-open` answer — a locked board's topic no longer carries a secret, only its password's
1090
+ version.
1091
+
1092
+ ## Functions: a space's own server code
1093
+
1094
+ A space can have its own server code: TypeScript whose **tasks** are steps in its actions and whose
1095
+ **routes** answer under `/api/` on its host, run by the platform in a sandbox. See `docs/en/functions.md`.
1096
+
1097
+ - **The contract**, `@plitzi/sdk-server/functions`: `defineFunctions({ allow: { hosts }, tasks, routes })`, `ctx`
1098
+ (`kv`, `fetch` to declared hosts only — a credential NAMED and written in by the platform, its value never in the code
1099
+ —, `publish`/`grant`/`revoke`, `user` without its session, `log`, `emit`, `signal`), web-standard only.
1100
+ `dist/functions-api.d.ts` is the contract rolled up in one file, for editors (`@plitzi/sdk-server/functions-api.d.ts`).
1101
+ - **Breaking: `action.tasks` is gone.** A deployment's own tasks are functions loaded natively:
1102
+ `createServer({ functions: { native: [defineFunctions({ tasks })] } })` — the same shape a space's are. Their routes
1103
+ are served too.
1104
+ - **`loadFunctions(dir)`** (`@plitzi/sdk-server`): a `functions/` folder built as the platform builds a space's and
1105
+ loaded natively — what a self-hosted server passes to `functions.native`, and what a `plitzi create` server project
1106
+ now does with its own `functions/`. Which files are the source is one rule, `readFunctionsSource` /
1107
+ `isFunctionsSourcePath` in `@plitzi/sdk-shared/actions`, used by it, the build and the CLI.
1108
+ - **The runner**, `@plitzi/sdk-server/functions-runner` (`isolated-vm` and `core-js@3` are optional peers):
1109
+ `startFunctionsRunnerService` — its own process, one V8 isolate per invocation from a snapshot (~2 ms), CPU / wall /
1110
+ memory / output / calls limits that hold, behind a shared secret, warmed before it listens so no request pays the
1111
+ first isolate; `createRemoteRunner` — the page server's client, one WebSocket per invocation with the code's calls
1112
+ answered on it, abandoned past the invocation's wall time plus `graceMs` (5 s) even when the runner never answers;
1113
+ `createIsolateRunner({ concurrency, cacheBytes })` — compiled bundles kept by size (64 MB), least recently used out;
1114
+ `createLocalFunctions`. Isolates need Node started with `--no-node-snapshot` (isolated-vm crashes beside Node's
1115
+ startup snapshot): without it they refuse to start, naming the flag; `plitzi functions dev` re-runs itself with it.
1116
+ - **A run carries the bundle by reference**: `FunctionsBundleRef { id, load }` in `FunctionInvokeRequest` and
1117
+ `SpaceFunctions` — a runner asks for the code (`needBundle`) only when it does not keep that bundle, so a lookup never
1118
+ reads it on the way to one. `functionsInHand` makes one from a bundle already in memory.
1119
+ - **Wired into actions**: `lookups.getFunctions(spaceId, at)`; `registryFor(spaceId, at)` — the catalog, the check and
1120
+ the runs of a space see its tasks; `prepareFunctions(source)` builds, reads and checks a source before it is stored;
1121
+ `functions.limits`, `functions.admit`, `functions.onUsage` for a deployment's ceilings and budget. A run only asks for
1122
+ a space's functions when a step names a task the deployment does not have.
1123
+ - **`ctx.log`** for every task: a step's lines are kept on it (`ActionRunStep.logs`, redacted, at most 100), shown by
1124
+ a Try and in the run history. Builder test runs return `steps`.
1125
+ - **Routes** under `/api/`: the visitor's `cookie`/`authorization` never reach the code, `Set-Cookie` is dropped, a
1126
+ failure answers 500/503 with its reason in the server log only. `lintSpace` refuses a page under `/api`
1127
+ (`page-route-reserved`); the prefix is `FUNCTION_ROUTES_PREFIX` in `@plitzi/sdk-shared/actions`.
1128
+ - **Builder**: a Functions panel — the files, TypeScript that knows `ctx` in a worker of its own
1129
+ (`dist/plitzi-functions-worker.js`, loaded only when the panel opens; the host passes `functionsWorkerUrl`), Save with
1130
+ the problems where they are, what the code declares, and Try. Needs `@plitzi/plitzi-ui` 1.6.24 (`CodeMirror`
1131
+ `mode="ts"` and `extensions`).
1132
+ - **CLI**: `plitzi functions pull | push | try | dev` — `functions/` as a working copy of the space's, refused rather
1133
+ than overwritten in either direction; `dev` runs it on the machine with the project's own `@plitzi/sdk-server`.
1134
+ - **MCP**: the `upsertFunctionFile` / `deleteFunctionFile` operations (saved first in a batch, so a problem refuses
1135
+ it all), `plitzi://functions/{env}` and `/{+path}`, and `plitzi_try_function`.
1136
+ - **`crypto.subtle` derives keys**: PBKDF2 (`importKey('raw', password, 'PBKDF2')`, `deriveBits`, `deriveKey` to an
1137
+ HMAC key) beside digests and HMAC, so a space can keep a password. The runner derives, at most 1,000,000 iterations
1138
+ and 1024 bits a call, and charges what it took to the run's CPU. A key is held to the usages it was imported for.
1139
+ - **`ActionRefusal` from `@plitzi/sdk-server/functions`**: a function refuses with a reason for whoever asked — the
1140
+ page reads it as `{{ step.error }}`, a route answers `400 { error }` — natively and in the sandbox alike (the bundle
1141
+ prints the platform's class; a refusal crosses the runner as `FunctionFailure` reason `refused`). Anything else a
1142
+ function throws still stays in the run's record.
1143
+ - **`ctx.kv.change(key, change, lifetime)`**: read, change and write back, again when somebody wrote first — the loop
1144
+ every concurrent edit needs, now one (lists stand on it too; the sandbox runs the same function). **`ctx.rateLimit`**
1145
+ counts with `flow.rateLimit`'s counter and answers `{ allowed, count, remaining }`. **`ctx.sign` / `ctx.verify`**:
1146
+ HMAC with a per-space, per-environment key derived from the new `action.signingSecret`, which the code never holds.
1147
+ - **Space runtimes**, `@plitzi/sdk-server/runtime` ([docs](../docs/en/runtimes.md)): a space's own server code run as a
1148
+ process of its own beside the platform — `defineRuntime({ start })` answers its `functions` (run with the platform's
1149
+ `ctx` over the runners' protocol) and `endpoints` (web handlers, streamed). `startSpaceRuntime` hosts one,
1150
+ `createRuntimeProxyStage` forwards a space's endpoints to it, `serveRuntime` loads one into a server of its own;
1151
+ `packRuntime` / `inspectRuntime` / `loadRuntime`; `SpaceFunctions.runner` sends a space's tasks to its runtime. One
1152
+ driver for the sandbox and runtimes alike (`createFunctionsDriver`, printed into the guest). CLI: `plitzi runtime
1153
+ push | status | vars`. Builder: a Runtime panel — each environment's state, and write-only variables.
1154
+ `examples/self-hosting/10-runtime` is the smallest one — a task, a route and a stream held open — served by its own
1155
+ `main.ts`; Pizarra, on the platform, is a whole product built this way. A runtime runs at a size — small, medium or large, each a plan feature
1156
+ — shown and chosen per environment in the Runtime panel and with `plitzi runtime size`. One nobody uses for a
1157
+ week — on the platform — stops by itself until started again (Start in the panel, `plitzi runtime start | stop`, a
1158
+ push or a publish) — its status says `starting` or `stopping` meanwhile; the builder's header warns a day before,
1159
+ with a way to keep it running. `createRuntimeProxyStage` takes `onForward`, told of
1160
+ each request it forwards — what counts as a runtime used. `reachSpaceInside` (the host's `insideUrl`)
1161
+ sends a runtime's `fetch` and `WebSocket` to its own space's address to an inside one — a cluster's ingress — instead
1162
+ of out through the edge and back.
1163
+ - **Authoring writes one form of each**: a trigger that `whileRunning('skip', …)` is written as the default it is, and
1164
+ `bind: []` writes no `bindings` — a document read back into code is the one written.
1165
+ - **Shared types**: `FunctionsManifest`, `FunctionsDraft`, `FunctionsProblem`, `FunctionsSaveResult`; the builder's
1166
+ `SpaceFunctions`, `SpaceSaveFunctions`, `SpaceRemoveFunctions`, `SpaceTryFunction`; `ChangeDocument` `functions`
1167
+ with entries of kind `file`; `SSRAdapters.getFunctions` / `saveFunctions` / `tryFunction`.
1168
+
1169
+ - Updated dependencies [cadd1b9]
1170
+ - @plitzi/sdk-shared@0.37.3
1171
+ - @plitzi/sdk-style@0.37.3
1172
+
3
1173
  ## 0.37.2
4
1174
 
5
1175
  ### Patch Changes