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