@zerotal/arch 1.7.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,63 @@ All notable changes to this package are documented here. The format is
7
7
  **Maturity: beta.** The API is close to final and breaking changes are rare, called out
8
8
  here with migration steps — but a minor release may still contain one.
9
9
 
10
+ ## [Unreleased]
11
+
12
+ ## [1.7.1] — 2026-08-16
13
+
14
+ ### Fixed
15
+
16
+ Faults that only appear once the surface is installed into a real app, found by installing
17
+ it into this repo's own `apps/docs` and calling every tool.
18
+
19
+ - **`search_docs` ranked a generated page above the right answer.** Scoring was raw term
20
+ frequency weighted by field, with no length normalisation — so `components.md`, one
21
+ generated page covering 53 components and long enough to mention nearly everything, came
22
+ first for both "send an email" and "how do I write a test for a controller". Now BM25 over
23
+ a small inverted index built when the corpus is read: length normalisation, inverse
24
+ document frequency and term saturation, with title/description/heading matches scored as
25
+ separate fields _outside_ the saturation, since folded in BM25 flattens a title hit to
26
+ about twice a passing mention. Plus light stemming, so "test" meets "testing", and
27
+ hyphenated terms indexed whole and in parts, so "soft deletes" finds `soft-delete`.
28
+
29
+ Measured on fourteen questions an agent would actually ask, top-1 relevance went from
30
+ roughly three in ten to twelve in fourteen. The remaining two return related pages rather
31
+ than the best one; tuning further against a list that size fits the list, not the corpus.
32
+
33
+ - **`last_error` dropped the error.** The framework logs an exception's class in `error`,
34
+ its trace in `stack` and the request it belongs to in `requestId`. The parser named the
35
+ six fields it knew about and discarded the rest, so the tool whose entire job is saying
36
+ _why_ something failed returned the generic `"Unhandled error"` that wraps the real one —
37
+ a line that says nothing. Entries now carry every field the logger wrote, and
38
+ `last_error` renders the exception, the request id and the trace. `logs` shows the first
39
+ two but not the trace: a stack on each of two hundred entries buries the sequence the
40
+ caller asked for.
41
+
42
+ - **`baselines` reported a ceiling smaller than the command's count, without saying so.**
43
+ The cast baseline ratchets per file and exempts designated boundary modules, so it reads
44
+ 455 where `cast:check` prints 466. A reader comparing the two saw debt that had appeared
45
+ between them. The reading now carries a `note` naming the exempt modules.
46
+
47
+ - **Packages in a workspace were invisible.** `node_modules/@zerotal/*` is a symlink in
48
+ every workspace — this monorepo, `bun link`, any app developed against a checkout — and
49
+ `Bun.Glob` will not descend into one, `followSymlinks` or not. `installedPackages()`
50
+ returned nothing for an app with seventeen packages, so `app_info` reported an empty list
51
+ and `arch:install` wrote generic guidance with none of the per-package sections that are
52
+ the reason it is composed rather than canned. Listed with `readdir` now, which sees the
53
+ link, and read through `Bun.file`, which follows it.
54
+
55
+ - **The generated Markdown failed the formatter.** `arch:install` wrote files that did not
56
+ pass the `prettier --check .` the project it had just installed into already runs. The
57
+ markers now sit on their own lines with blank lines around them — which is correctness
58
+ before it is formatting, since Markdown parses text pressed against an HTML comment as
59
+ part of that raw-HTML block.
60
+
61
+ - **`arch:update` fought the formatter over `.mcp.json`.** `JSON.stringify(…, 2)` expands a
62
+ one-element array where a formatter collapses it, so the command rewrote a file it had no
63
+ change to make to, and the next `prettier --write` put it back. Idempotence is now
64
+ measured on the parsed data: when the config already says what it should, the file is
65
+ returned exactly as it was found, in whatever shape its owner keeps it.
66
+
10
67
  ## [1.7.0] — 2026-08-16
11
68
 
12
69
  ### Added
@@ -122,7 +122,7 @@ the wire; a Pusher-protocol client surfaces them to your code under the `pusher:
122
122
 
123
123
  > **Note** — The native `ws`/`redis` drivers use the equivalent
124
124
  > `subscription_succeeded` / `presence:member_added` / `presence:member_removed` events; the
125
- > first-party `Socket` client maps them to Echo's `here`/`joining`/`leaving` callbacks for you.
125
+ > first-party `Socket` client maps them to Socket's `here`/`joining`/`leaving` callbacks for you.
126
126
 
127
127
  ## Typed channels
128
128
 
@@ -9,8 +9,20 @@ description: Subscribe from the browser and react to events as they arrive.
9
9
 
10
10
  `@zerotal/client` ships a small, dependency-free `Socket` that speaks the native broadcast
11
11
  protocol and exposes a familiar realtime-client API — no external client library, and it works
12
- with the lightweight `ws` and `redis` drivers (no Pusher credentials needed). It's also a drop-in
13
- for `window.Echo`, so Flow's [`@on('echo:…')`](/docs/flow/events) listeners work against it.
12
+ with the lightweight `ws` and `redis` drivers (no Pusher credentials needed).
13
+
14
+ **Flow apps need none of this.** The client is bundled into `/__flow/runtime.js` and created the
15
+ first time a page declares a [`@on('socket:…')`](/docs/flow/events) listener, so those listeners
16
+ are live with no script of your own. Read on only if you want a configured client — a different
17
+ host, your own auth endpoint — or if you are subscribing from code that is not a Flow component.
18
+
19
+ > **The package root is fine to import in browser code.** It used to be a bundle error: the root
20
+ > also exports `ClientProvider`, which reaches the CLI commands and `await import("bun")`, and a
21
+ > browser bundler rejects that during resolution — before tree-shaking can discard the half you
22
+ > did not want. `@zerotal/client` now resolves to a browser-safe entry under the `browser`
23
+ > condition, so a bundler gets `Socket`, `ApiClient` and `CircuitBreaker` and none of the
24
+ > server-side exports. `@zerotal/client/Socket` still works and is still the leanest import if
25
+ > `Socket` is all you need.
14
26
 
15
27
  ```ts
16
28
  // in your client code
@@ -34,8 +46,9 @@ socket
34
46
  .leaving((m) => removeOnline(m))
35
47
  .listen("Message", (e) => append(e));
36
48
 
37
- // Use it as Echo for Flow @on('echo:…') listeners:
38
- window.Echo = socket;
49
+ // Only if you need Flow's listeners to use *this* client rather than the bundled
50
+ // one — assign before the runtime loads and it is used as-is:
51
+ window.Socket = socket;
39
52
  ```
40
53
 
41
54
  Private and presence channels are authorized with a **per-subscription HMAC signature** (the same
@@ -64,7 +64,7 @@ Broadcast.send(new OrderShipmentStatusUpdated(order));
64
64
  ```
65
65
 
66
66
  `broadcast(event).toOthers()` excludes the connection that triggered the request (read from the
67
- `X-Socket-ID` header your Echo client sends), so the user who just made an optimistic UI update
67
+ `X-Socket-ID` header your Socket client sends), so the user who just made an optimistic UI update
68
68
  doesn't receive a duplicate.
69
69
 
70
70
  `broadcast()` returns a `PendingBroadcast` — a thenable that sends itself on the next
@@ -181,7 +181,7 @@ The wire event name defaults to `${ModelName}${Event}` (e.g. `OrderUpdated`) and
181
181
 
182
182
  ```ts
183
183
  // in your client code
184
- Echo.private(`orders.${id}`).listen("OrderUpdated", (e) => render(e.order));
184
+ Socket.private(`orders.${id}`).listen("OrderUpdated", (e) => render(e.order));
185
185
  ```
186
186
 
187
187
  ## Next steps
@@ -50,7 +50,7 @@ The provider exposes one HTTP route and one WebSocket upgrade path:
50
50
 
51
51
  - `POST /broadcasting/auth` — the private/presence channel auth endpoint (a real `Router` route).
52
52
  - The WebSocket upgrade is served at the configured `path` (default `/app/ws`) via Bun's
53
- WebSocket handler — it is _not_ a separate `Router` route. Pusher/Echo clients connect to
53
+ WebSocket handler — it is _not_ a separate `Router` route. Pusher/Socket clients connect to
54
54
  `ws://host/app/{appKey}`.
55
55
 
56
56
  ## Configuration
package/docs/changelog.md CHANGED
@@ -24,6 +24,52 @@ Each version lists changes under three headings:
24
24
  Patch and minor releases are backward compatible. Before taking a **major** release,
25
25
  read its section here and apply each migration note.
26
26
 
27
+ ## 1.7.2 — 2026-08-18
28
+
29
+ Realtime that works without being wired up, and three ways a socket could go quiet without
30
+ saying so.
31
+
32
+ ### Changed
33
+
34
+ - **BREAKING — Flow's `@on` broadcast listeners use a `socket:` prefix.** `echo:`,
35
+ `echo-private:` and `echo-presence:` are now `socket:`, `socket-private:` and
36
+ `socket-presence:`; the browser global is `window.Socket`, not `window.Echo`. There is no
37
+ alias — an unrenamed listener never matches, and never subscribes.
38
+
39
+ ```diff
40
+ - @on("echo-private:issues.5,CommentPosted")
41
+ + @on("socket-private:issues.5,CommentPosted")
42
+ ```
43
+
44
+ Shipped in a patch release deliberately, on the judgement that the old prefix has no
45
+ meaningful use in the wild. If you are on it, the upgrade is a find-and-replace of `echo:`
46
+ → `socket:` in your `@on` listeners and `window.Echo` → `window.Socket` in any client code.
47
+
48
+ ### Added
49
+
50
+ - **Flow bundles the socket client into its runtime.** A page that declares a `socket:`
51
+ listener is live with no script of your own. Flow apps own no bundle entry, so the contract
52
+ used to be "publish `window.Socket` yourself" — and when you didn't, the listeners were
53
+ *silently inert*: no error, no warning, no subscription, so a live feature with no script
54
+ looked exactly like a live feature that was never written. An app that needs a configured
55
+ client still assigns `window.Socket` before the runtime loads and that one is used as-is; a
56
+ page with no listeners opens no connection at all.
57
+
58
+ ### Fixed
59
+
60
+ - **A patch no longer writes back into a file input.** A file input's `value` belongs to the
61
+ user agent, and assigning anything but `""` throws `InvalidStateError`. The write was legal
62
+ while the bound property was empty and threw on the very patch carrying an upload's result
63
+ — and the throw escaped the frame handler, so the DOM never updated *and* the action's ack
64
+ never resolved. Since frames are chained per component, every later action queued behind a
65
+ promise that would never settle: the page rendered correctly and ignored every click for
66
+ the rest of its life.
67
+
68
+ - **WebSocket connections get an explicit 120s `idleTimeout`.** Bun closes an idle socket
69
+ after 10 seconds; the client pings every 30. A connection that was merely quiet got cut
70
+ before it had reason to speak, taking its channel subscriptions with it — so anyone who had
71
+ been reading a page for more than ten seconds silently stopped receiving broadcasts.
72
+
27
73
  ## 1.7.0 — 2026-08-16
28
74
 
29
75
  The agent surface, a DevTools panel that shows the framework and not just the last request,
package/docs/commands.md CHANGED
@@ -210,23 +210,32 @@ running, restarting, or has given up:
210
210
  ─────────────────────────────────────────────────
211
211
  GET / 200 4ms
212
212
  GET /posts 200 11ms
213
- 1-9 tab · ←/→ cycle · r restart · c clear · / search · t time · s stream · q quit
213
+ 1-9 tab · ←/→ cycle · ↑/↓ scroll · r restart · c clear · / search · t time · s stream · q quit
214
214
  ```
215
215
 
216
- | Key | Does |
217
- | ------------- | ---------------------------------------------------------------- |
218
- | `1`–`9` | Select that tab |
219
- | `←` `→` `Tab` | Cycle through tabs |
220
- | `r` | Restart the focused process |
221
- | `c` | Clear the focused tab's output |
222
- | `/` | Search within the focused tab (`Enter` keeps it, `Esc` drops it) |
223
- | `t` | Toggle per-line timestamps |
224
- | `s` | Switch to stream mode |
225
- | `PgUp` `PgDn` | Scroll the focused tab |
226
- | `q` | Quit stops every process and restores your shell |
216
+ | Key | Does |
217
+ | ------------- | ----------------------------------------------------------------- |
218
+ | `1`–`9` | Select that tab |
219
+ | `←` `→` `Tab` | Cycle through tabs |
220
+ | `↑` `↓` | Scroll the focused tab a line at a time — as does the mouse wheel |
221
+ | `PgUp` `PgDn` | Scroll it a screen at a time |
222
+ | `Home` `End` | Jump to the oldest line, or back to the newest |
223
+ | `r` | Restart the focused process |
224
+ | `c` | Clear the focused tab's output |
225
+ | `/` | Search within the focused tab (`Enter` keeps it, `Esc` drops it) |
226
+ | `t` | Toggle per-line timestamps |
227
+ | `s` | Switch to stream mode |
228
+ | `q` | Quit — stops every process and restores your shell |
227
229
 
228
230
  Scrollback belongs to the deck rather than to your terminal, which is what makes
229
231
  per-tab history and search possible. It keeps the last 5,000 lines per process.
232
+ Your terminal's own scrollbar does nothing while the deck is up — it has no
233
+ history to move, because the deck holds it all. Use the keys above (or the
234
+ wheel, which the terminal sends the deck as `↑`/`↓`).
235
+
236
+ Scrolling up parks the view where you left it: the process behind the tab keeps
237
+ printing, but what you stopped to read stays on screen until you scroll back
238
+ down to the newest line.
230
239
 
231
240
  **A process that dies never takes the server with it.** It restarts on its own —
232
241
  three times, backing off between attempts — and if it still will not start, that
@@ -29,6 +29,13 @@ import { Link } from "@zerotal/flow";
29
29
  Posts
30
30
  </Link>;
31
31
 
32
+ {
33
+ /* Prefetch on pointer-down instead — for a row in a long list */
34
+ }
35
+ <Link href="/posts/42" down>
36
+ One post
37
+ </Link>;
38
+
32
39
  {
33
40
  /* Disable automatic data-current (e.g. always-active home links) */
34
41
  }
@@ -37,6 +44,14 @@ import { Link } from "@zerotal/flow";
37
44
  </Link>;
38
45
  ```
39
46
 
47
+ Choose between `hover` and `down` by how many of the link there are. `hover` prefetches after a
48
+ short dwell, which is free speed on a handful of stable links — a navigation rail, a breadcrumb.
49
+ On a dense list it inverts: the pointer crosses every row between where it is and where it is
50
+ going, so scrolling a hundred-row table asks the server for a hundred pages nobody chose. `down`
51
+ fires on `pointerdown` instead — once, on the link the reader has committed to, and still ahead
52
+ of the click by however long the button is held. Both may be set; the target is fetched once and
53
+ cached either way.
54
+
40
55
  `data-current` matches by **prefix** — a link to `/posts` stays active on `/posts/42` — which is what you want for a section parent. For an index link that should be active only on its own exact URL (an "Overview" tab that shouldn't light up on the section's sub-pages), add `exact`:
41
56
 
42
57
  ```tsx
@@ -456,6 +471,14 @@ import { Tabs } from "@zerotal/flow";
456
471
 
457
472
  `<Tabs>` emits `role="tablist"` / `"tab"` / `"tabpanel"` with roving arrow-key navigation.
458
473
 
474
+ > **Note** — selection is client-only and not addressable. The active tab lives in Alpine state,
475
+ > always starts on the first item, and there is no prop to bind or read it, so `?tab=settings`
476
+ > cannot be made to work and the back button does not step between panels. The tab strip's own
477
+ > classes are fixed (`border-gray-800`, `text-white`, `border-indigo-500`); only the outer wrapper
478
+ > takes `class`, so on a light surface the selected tab is white on white. For a tab set that has
479
+ > to be linkable or has to match a design system, drive the panels from a `@url` property and
480
+ > write the strip out — it is a dozen lines, and you keep both.
481
+
459
482
  ## File upload component
460
483
 
461
484
  ### FileUpload + FileUploads mixin
@@ -133,9 +133,13 @@ You can also pass rules directly to `this.validate()` — they override the deco
133
133
 
134
134
  ### Real-time validation
135
135
 
136
- When a `@validate` field is bound with `flow:model.live` (or `.blur`), each change is validated on
136
+ When a `@validate` field is bound `live` (or `blur`), each change is validated on
137
137
  the server as it arrives. The field's error appears (and clears)
138
- as the user edits, with no action call and without affecting any other field:
138
+ as the user edits, with no action call and without affecting any other field.
139
+
140
+ > **Note** — `live` is the prop you write; `flow:model.live` is what it compiles to. The compiled
141
+ > form is not writable in TSX: the `.` in an attribute name is a parse error (`TS1003`), so
142
+ > copying it out of the emitted HTML into a component will not build.
139
143
 
140
144
  ```tsx
141
145
  @expose @validate((rule) => rule.required().email()) email = "";
@@ -143,7 +147,7 @@ as the user edits, with no action call and without affecting any other field:
143
147
  async render() {
144
148
  return (
145
149
  <div>
146
- <input type="email" value={this.email} flow:model.live />
150
+ <input type="email" value={this.email} live />
147
151
  <span error={this.errors.email} />
148
152
  </div>
149
153
  );
@@ -289,11 +293,18 @@ async refreshUser(data: { userId: number }): Promise<void> {
289
293
  }
290
294
 
291
295
  // Listen for real-time WebSocket broadcasts (see Events doc for channel formats)
292
- @on("echo:orders,OrderPlaced")
296
+ @on("socket:orders,OrderPlaced")
293
297
  async onOrderPlaced(payload: { id: number }): Promise<void> {
294
298
  this.orderCount++;
295
299
  this.flash("New order received!", "success");
296
300
  }
301
+
302
+ // A channel naming a record needs a resolver — the string form is read off the
303
+ // class, so `"socket:orders.${this.id},…"` would subscribe to that literal text.
304
+ @on((self) => `socket-private:orders.${self.orderId},OrderCancelled`)
305
+ async onOrderCancelled(payload: { id: number }): Promise<void> {
306
+ this.orderCount--;
307
+ }
297
308
  ```
298
309
 
299
310
  See [Events & Broadcasting](/docs/flow/events) for dispatch methods, targeting, broadcasting, and native event integration.
@@ -385,7 +396,8 @@ See [Layouts & Composition](/docs/flow/layouts#two-way-props) for the full `@mod
385
396
  | `@expose method` | Callable from the browser via WebSocket |
386
397
  | `@expose @renderless method` | Callable from browser; skips re-render cycle |
387
398
  | `@on("event") method` | Listens for cross-component events (auto-exposed) |
388
- | `@on("echo:channel,Event") method` | Listens for real-time server broadcasts |
399
+ | `@on("socket:channel,Event") method` | Listens for real-time server broadcasts |
400
+ | `@on((self) => "socket:…") method` | Same, with the channel resolved per instance (record ids) |
389
401
  | `@reactive prop` | Child prop; parent re-pushes on change, child re-renders |
390
402
  | `@modelable prop` | Two-way child prop; writes from child flow back to parent |
391
403
 
@@ -178,7 +178,7 @@ async onPostCreated(data: EventPayload<"post-created">) {
178
178
  }
179
179
  ```
180
180
 
181
- Adoption is gradual and non-breaking: any event name **not** in the contract stays untyped (a plain optional-payload call), so existing events and `@on("echo:…")` broadcasts keep compiling — you type the ones you care about, when you care about them.
181
+ Adoption is gradual and non-breaking: any event name **not** in the contract stays untyped (a plain optional-payload call), so existing events and `@on("socket:…")` broadcasts keep compiling — you type the ones you care about, when you care about them.
182
182
 
183
183
  **Runtime guard (optional).** The types cover your own dispatch sites at compile time. For a payload that arrives from an untrusted source — a client-originated dispatch — register a runtime guard; a violating payload then throws from `dispatch` instead of reaching listeners:
184
184
 
@@ -231,7 +231,9 @@ When a child component just needs to invoke a parent action, use `$flow.parent`
231
231
 
232
232
  ## Real-time broadcasting
233
233
 
234
- Listen for server-broadcast events over WebSockets with `@on("echo:…")`. When a matching broadcast arrives, the listener method runs server-side exactly like any other action — the component re-renders live.
234
+ Listen for server-broadcast events over WebSockets with `@on("socket:…")`. When a matching broadcast arrives, the listener method runs server-side exactly like any other action — the component re-renders live.
235
+
236
+ The socket client is bundled into the Flow runtime and created the first time a page declares one of these listeners, so there is no script to add and nothing to publish on `window`. An app that needs a configured client — a different host, its own auth endpoint — assigns `window.Socket` before the runtime loads and that one is used instead. Pages with no such listener open no broadcast connection at all.
235
237
 
236
238
  ```typescript
237
239
  export class OrderDashboard extends Component {
@@ -243,7 +245,7 @@ export class OrderDashboard extends Component {
243
245
  this.recentOrders = await Order.query().orderBy("created_at", "desc").limit(5).get();
244
246
  }
245
247
 
246
- @on("echo:orders,OrderPlaced")
248
+ @on("socket:orders,OrderPlaced")
247
249
  async onOrderPlaced(payload: { id: number; total: number }): Promise<void> {
248
250
  this.orderCount++;
249
251
  const order = await Order.findOrFail(payload.id);
@@ -251,7 +253,7 @@ export class OrderDashboard extends Component {
251
253
  this.flash(`New order — $${payload.total}`, "success");
252
254
  }
253
255
 
254
- @on("echo-private:orders.${this.branchId},OrderCancelled")
256
+ @on((self) => `socket-private:orders.${self.branchId},OrderCancelled`)
255
257
  async onOrderCancelled(payload: { id: number }): Promise<void> {
256
258
  this.recentOrders = this.recentOrders.filter((o) => o.id !== payload.id);
257
259
  this.orderCount = Math.max(0, this.orderCount - 1);
@@ -276,29 +278,61 @@ export class OrderDashboard extends Component {
276
278
 
277
279
  | Format | Channel type |
278
280
  | ---------------------------------- | --------------------------------------- |
279
- | `echo:channel,Event` | Public channel |
280
- | `echo-private:channel,Event` | Private channel (requires auth) |
281
- | `echo-presence:room,joining` | Presence channel — member joined |
282
- | `echo-presence:room,leaving` | Presence channel — member left |
283
- | `echo-presence:room,here` | Presence channel — initial member list |
284
- | `echo:teams.1.threads,MessageSent` | Dot-separated dynamic/nested channel |
285
- | `echo:scores,.score.submitted` | Custom `broadcastAs` name (leading dot) |
281
+ | `socket:channel,Event` | Public channel |
282
+ | `socket-private:channel,Event` | Private channel (requires auth) |
283
+ | `socket-presence:room,joining` | Presence channel — member joined |
284
+ | `socket-presence:room,leaving` | Presence channel — member left |
285
+ | `socket-presence:room,here` | Presence channel — initial member list |
286
+ | `socket:teams.1.threads,MessageSent` | Dot-separated dynamic/nested channel |
287
+ | `socket:scores,.score.submitted` | Custom `broadcastAs` name (leading dot) |
286
288
 
287
289
  The part before the comma is the channel name; the part after is the event name. For presence channels, `joining`, `leaving`, and `here` are the built-in presence event names.
288
290
 
291
+ ### Per-instance channels
292
+
293
+ A channel that names a record — `issues.417`, `orders.8` — cannot be written as a string. The
294
+ decorator's argument is read off the **class**, before any instance exists, so a template literal
295
+ inside a plain string is not interpolated: `@on("socket-private:issues.${this.issueId},CommentPosted")`
296
+ subscribes to a channel whose name contains those characters, and receives nothing.
297
+
298
+ Pass a resolver instead. It is called with the component when the snapshot is built, exactly as
299
+ [`@presence`](#presence--whos-here-multiplayer) and [`@shared`](#shared-state--everyone-converges-multiplayer)
300
+ resolve theirs:
301
+
302
+ ```typescript
303
+ export class IssuePage extends Component {
304
+ @locked issue!: Issue;
305
+ @locked comments: Comment[] = [];
306
+
307
+ @on((self) => `socket-private:issues.${self.issue.id},CommentPosted`)
308
+ async onCommentPosted(payload: { comment: Comment }): Promise<void> {
309
+ this.comments = [...this.comments, payload.comment];
310
+ }
311
+ }
312
+ ```
313
+
314
+ The resolver runs once per render, after `onMount()`, so it can read anything the component has
315
+ loaded. If it throws — a field it reads is still null, say — that one listener is dropped and the
316
+ page renders without it, rather than the render failing.
317
+
318
+ Resolve the *narrowest* channel the reader is entitled to. A static `issues` channel with an
319
+ `if (payload.issueId !== this.issue.id) return` in the handler looks equivalent and is not: the
320
+ broadcast still reaches every subscriber's browser, so every reader receives every issue's
321
+ comment bodies and discards them after the fact.
322
+
289
323
  ### Requirements
290
324
 
291
- Broadcasting requires a global `window.Echo` client configured by your application — the first-party `@zerotal/client` `Socket`, or any compatible realtime client. Flow subscribes through it on component mount and unsubscribes on teardown.
325
+ Broadcasting requires a global `window.Socket` client configured by your application — the first-party `@zerotal/client` `Socket`, or any compatible realtime client. Flow subscribes through it on component mount and unsubscribes on teardown.
292
326
 
293
- If `window.Echo` is not present, all `echo:` listeners are silently inert — no errors, no subscriptions attempted.
327
+ If `window.Socket` is not present, all `socket:` listeners are silently inert — no errors, no subscriptions attempted.
294
328
 
295
329
  ```typescript
296
330
  // In your frontend bootstrap (app.ts or similar):
297
331
  import { Socket } from "@zerotal/client";
298
332
 
299
333
  // The first-party Socket speaks Zerotal's native broadcast protocol and is a
300
- // drop-in for `window.Echo` — no external client library or Pusher credentials.
301
- window.Echo = new Socket();
334
+ // drop-in for `window.Socket` — no external client library or Pusher credentials.
335
+ window.Socket = new Socket();
302
336
  ```
303
337
 
304
338
  ## Presence — who's here (multiplayer)
@@ -345,7 +379,7 @@ Broadcast.channel("board.[boardId]", (user, boardId) =>
345
379
 
346
380
  Whispers are client-only (they ride the presence channel directly), so they're instant and don't count as component round-trips.
347
381
 
348
- Like all `echo:` features, presence needs a `window.Echo` client configured (above). Without it, `@presence` props stay empty and whispers are inert — no errors.
382
+ Like all `socket:` features, presence needs a `window.Socket` client configured (above). Without it, `@presence` props stay empty and whispers are inert — no errors.
349
383
 
350
384
  ## Shared state — everyone converges (multiplayer)
351
385
 
@@ -370,7 +404,7 @@ The mental model: a `@shared` prop is a **cache of a server-side room value**, n
370
404
 
371
405
  Like `@presence`, the channel is resolved on the server (signed in the snapshot, unforgeable) and the prop is server-controlled (`@locked`): clients render it but change it only through `@expose` actions. Authorize the channel in `routes/channels.ts` exactly as for presence.
372
406
 
373
- Broadcasting is an **optional peer**. With `window.Echo` and `BroadcastProvider` configured, changes fan out to every open window; without them, `@shared` still converges within a single window's own round-trips, because the room store is server-side either way. For multi-instance deployments, swap the in-process store for a shared backend with `setSharedStore(store)` (any `{ get, set, has }`), e.g. Redis-backed — the convergence logic is unchanged.
407
+ Broadcasting is an **optional peer**. With `window.Socket` and `BroadcastProvider` configured, changes fan out to every open window; without them, `@shared` still converges within a single window's own round-trips, because the room store is server-side either way. For multi-instance deployments, swap the in-process store for a shared backend with `setSharedStore(store)` (any `{ get, set, has }`), e.g. Redis-backed — the convergence logic is unchanged.
374
408
 
375
409
  > v1 semantics are last-write-wins and server-authoritative; `@shared` props should hold plain, serializable data (arrays/objects), like snapshot state generally. The originating window also receives its own change broadcast as an idempotent no-op re-read (self-exclusion is a planned refinement).
376
410
 
@@ -135,6 +135,13 @@ component in the project is covered — the Flow scaffold already writes this:
135
135
  > runtime need a `/** @jsxImportSource … */` comment of their own — which is why
136
136
  > `make:flow` writes one into every class it generates.
137
137
 
138
+ > **Note** — Flow components and `zerotal/view` components do not interoperate, and the failure
139
+ > is a type error rather than a wrong render. The two JSX runtimes produce different element
140
+ > types: a view `FC` returns `SafeHtml` (`{ value }`) and Flow's JSX expects `HtmlNode`
141
+ > (`{ html }`), so using one inside the other is `TS2786: 'Box' cannot be used as a JSX
142
+ > component`. A shared component library has to target one runtime; share class-name constants
143
+ > or plain strings across the two instead of components.
144
+
138
145
  ### Your first component
139
146
 
140
147
  ```tsx
@@ -563,6 +570,6 @@ Flow is a large surface. Each section below is its own page.
563
570
  - [Validator](/docs/validator) — the full rule chain behind `@validate` and `this.validate()`.
564
571
  - [Middleware](/docs/middleware) — write the guards you attach to Flow routes.
565
572
  - [Session](/docs/session) — the store behind `@session` and `SessionMiddleware`.
566
- - [Broadcasting](/docs/broadcasting) — drive `@on("echo:…")` real-time updates from the server.
573
+ - [Broadcasting](/docs/broadcasting) — drive `@on("socket:…")` real-time updates from the server.
567
574
  - [Storage](/docs/storage) — configure the disks that file uploads write to.
568
575
  - [Testing](/docs/testing/index) — patterns for the `FlowTest` harness and the rest of the suite.
@@ -143,6 +143,22 @@ override async render() {
143
143
  }
144
144
  ```
145
145
 
146
+ ### The document's `lang` is fixed
147
+
148
+ Flow assembles the outer document itself and emits `<html lang="en">`, and a `Layout` has no hook to
149
+ change it. `head` injects into `<head>`; the `<html>` attributes are not reachable from a layout.
150
+
151
+ For a localised app, put `lang` (and any locale-dependent class) on the layout's own wrapper element.
152
+ Both are valid on a `div` and apply to every descendant, so the *content* is correctly marked up —
153
+ but the document still declares English to anything reading the root element, which is wrong for a
154
+ screen reader announcing the page in the wrong voice:
155
+
156
+ ```tsx
157
+ override render(slot: HtmlNode) {
158
+ return <div lang={activeLocale()}>{slot}</div>;
159
+ }
160
+ ```
161
+
146
162
  ## Sections
147
163
 
148
164
  A layout owns regions a page cannot reach. When a page needs to put something _there_ — a toolbar
@@ -33,11 +33,37 @@ It's a graceful degrade, not a mode you configure. WebSocket reconnection keeps
33
33
 
34
34
  - Each action is a request/response, with no server-pushed frames — so `@task`
35
35
  streaming arrives as one batched update rather than token by token.
36
- - Real-time `@on("echo:…")`, `@presence`, and `@shared` broadcasts are not
36
+ - Real-time `@on("socket:…")`, `@presence`, and `@shared` broadcasts are not
37
37
  delivered, because those ride the separate broadcasting socket.
38
38
 
39
39
  Everything driven by your own actions still works. Nothing is sent over HTTP until the socket has actually failed; the happy path is unchanged.
40
40
 
41
+ ## What forces the runtime fallback
42
+
43
+ Every page is AOT-compiled at boot where it can be. A page the compiler cannot handle still works —
44
+ it renders through the standard runtime instead — and Flow logs a count at startup:
45
+
46
+ ```
47
+ N of M Flow pages (75%) render through the runtime fallback instead of compiled output.
48
+ ```
49
+
50
+ Set `ZT_FLOW_COMPILE_LOG=1` to print what blocks each page. The common causes:
51
+
52
+ | Blocker | Fix |
53
+ | --- | --- |
54
+ | `render()` with more than one `return` | Build the branches into a variable and return once |
55
+ | A function call in a text child — including **`__()`** | See below |
56
+ | An imported child component in `render()` (`<Header/>`) | Inline it, or accept the fallback |
57
+ | `class={someLocalConst}` or a numeric-literal attribute (`rows={3}`) | Use a literal string |
58
+
59
+ **`__()` is the one that matters most.** A translated template is a function call in a text child,
60
+ so a page that translates a single string falls off the fast path — which in an app where `__()`
61
+ is the house style means every page. The cost is normally just speed, but it is not only speed
62
+ under [`cspSafe`](/docs/flow/components#csp-safe-mode): there, every page **must** compile or the
63
+ build fails, so an app using `__()` in its templates cannot run in CSP-safe mode today. If you need
64
+ `cspSafe`, keep translation out of the template — resolve strings in the action or `onMount()` into
65
+ `@locked` properties and render those.
66
+
41
67
  ## Interaction polish
42
68
 
43
69
  The perceived speed of a server-driven app comes from three things: never showing
@@ -87,6 +87,7 @@ Visual loading indicators (`showOnLoading`, `hideOnLoading`, `loadingClass`) wai
87
87
  | ----------------- | --------------------------------------------------------- | ----------------------------------- |
88
88
  | `navigate` | SPA navigation to `href`, layout stays mounted | `flow:navigate` |
89
89
  | `navigate hover` | Prefetch page on hover (~60ms debounce) | `flow:navigate flow:navigate.hover` |
90
+ | `navigate down` | Prefetch page on pointer-down (no dwell; dense lists) | `flow:navigate flow:navigate.down` |
90
91
  | `current={false}` | Disable automatic `data-current` attribute | — |
91
92
  | `exact` | `data-current` only on an exact URL match (not sub-pages) | `flow:current.exact` |
92
93
 
@@ -177,6 +178,20 @@ override async render() {
177
178
 
178
179
  > A dynamic `sortItem={String(it.id)}` inside a `.map()` renders through the standard runtime (not the AOT fast path) — the drag behaviour is identical either way.
179
180
 
181
+ > **The payload does not say which container took the drop.** The client reads `flow:sort` off the
182
+ > container a child was dropped **into** and calls it `(key, index)` — so the destination is
183
+ > encoded in *which method runs*, and nowhere else. For a single sortable list that is invisible.
184
+ > For dragging **between** containers under one `sortGroup` it means one action per container:
185
+ >
186
+ > ```tsx
187
+ > <ul onSort={this.dropInTodo} sortGroup="tasks">…</ul>
188
+ > <ul onSort={this.dropInDone} sortGroup="tasks">…</ul>
189
+ > ```
190
+ >
191
+ > An arrow (`onSort={(k, i) => this.move("todo", k, i)}`) cannot stand in, because the attribute's
192
+ > value is used as a method *name* rather than evaluated. `onSort` accepts the name as a string,
193
+ > so the handlers can come from a lookup table keyed by column, but they must be declared members.
194
+
180
195
  ### DOM utilities
181
196
 
182
197
  | You write | Behaviour | Compiles to |
@@ -177,6 +177,38 @@ expect(page.user?.email).toBe("alice@example.com");
177
177
  expect(page.totalRevenue).toBe(450.0);
178
178
  ```
179
179
 
180
+ ## No request scope
181
+
182
+ `FlowTest` drives the server-side pipeline but does **not** open a request context. There is no
183
+ `RequestContext.run` inside it, so anything reaching for the request throws rather than
184
+ returning empty:
185
+
186
+ - `Auth.user()` → `E_UNAUTHORIZED`
187
+ - `Auth.attempt()` / `Auth.login()` → `E_CONTEXT_OUTSIDE_REQUEST`
188
+ - request-scoped pagination, and any facade that reads `RequestContext`
189
+
190
+ That covers most actions on any page behind a sign-in, so open the scope yourself. `HttpContext.fake()`
191
+ rather than an object literal cast to the type — it carries a real `Request`, which matters as soon
192
+ as anything downstream reads a header (an audited model records the actor's IP, for one):
193
+
194
+ ```typescript
195
+ import { RequestContext, HttpContext } from "@zerotal/core";
196
+
197
+ function asUser<T>(user: User | null, fn: () => Promise<T>): Promise<T> {
198
+ const ctx = HttpContext.fake("http://localhost/");
199
+ if (user) ctx.user = user;
200
+ return RequestContext.run(ctx, fn);
201
+ }
202
+
203
+ await asUser(alice, async () => {
204
+ const t = await FlowTest.mount(IssuePage, { issue });
205
+ await t.call("postComment");
206
+ });
207
+ ```
208
+
209
+ `app.actingAs()` does not help here: it encodes a session cookie for `app.get()`, and `FlowTest`
210
+ never makes a request to send it on.
211
+
180
212
  ## Testing with a database
181
213
 
182
214
  `FlowTest` does not set up or tear down a database — use your test suite's standard database helpers. With Bun, wrap tests in a transaction that rolls back after each test for full isolation: