stitchkit 0.113.3 → 0.114.1

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 (102) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +1 -1
  3. package/dist/browser/client-url.d.ts.map +1 -1
  4. package/dist/entrypoints/agent-runtime/coding-tools.js +1 -1
  5. package/dist/entrypoints/agent-runtime/harness.js +4 -4
  6. package/dist/entrypoints/agent-runtime/realtime.js +2 -2
  7. package/dist/entrypoints/agent-runtime/sandbox.js +3 -3
  8. package/dist/entrypoints/agent-runtime/sqlite/bun.js +3 -3
  9. package/dist/entrypoints/agent-runtime/sqlite/node.js +3 -3
  10. package/dist/entrypoints/agent-runtime/testing.js +4 -4
  11. package/dist/entrypoints/agent-runtime.js +12 -12
  12. package/dist/entrypoints/application/directory-inbox.js +3 -3
  13. package/dist/entrypoints/application.js +4 -4
  14. package/dist/entrypoints/cli/publish.js +1 -1
  15. package/dist/entrypoints/cli.js +4 -4
  16. package/dist/entrypoints/contract.js +2 -2
  17. package/dist/entrypoints/http.js +2 -2
  18. package/dist/entrypoints/index.js +5 -5
  19. package/dist/entrypoints/node.js +3 -3
  20. package/dist/entrypoints/observability.js +3 -3
  21. package/dist/entrypoints/react/keyboard/testing.d.ts +2 -0
  22. package/dist/entrypoints/react/keyboard/testing.d.ts.map +1 -0
  23. package/dist/entrypoints/react/keyboard/testing.js +127 -0
  24. package/dist/entrypoints/react/keyboard.d.ts +4 -2
  25. package/dist/entrypoints/react/keyboard.d.ts.map +1 -1
  26. package/dist/entrypoints/react/keyboard.js +83 -319
  27. package/dist/entrypoints/react.js +15 -3
  28. package/dist/entrypoints/realtime.js +1 -1
  29. package/dist/entrypoints/remote.js +1 -1
  30. package/dist/entrypoints/server.js +18 -18
  31. package/dist/entrypoints/telegram/grammy.d.ts +2 -0
  32. package/dist/entrypoints/telegram/grammy.d.ts.map +1 -0
  33. package/dist/entrypoints/telegram/grammy.js +50 -0
  34. package/dist/entrypoints/telegram/screens.js +1 -1
  35. package/dist/entrypoints/telegram.d.ts +1 -0
  36. package/dist/entrypoints/telegram.d.ts.map +1 -1
  37. package/dist/entrypoints/telegram.js +254 -8
  38. package/dist/entrypoints/testing.js +3 -3
  39. package/dist/entrypoints/tools/connections.js +10 -10
  40. package/dist/entrypoints/tools.js +19 -19
  41. package/dist/entrypoints/tracking.js +1 -1
  42. package/dist/{index-xvj9h8yh.js → index-61hve0ez.js} +15 -28
  43. package/dist/index-6j725ez5.js +25 -0
  44. package/dist/{index-1csp1hwm.js → index-6p3frkv5.js} +1 -1
  45. package/dist/{index-fy4av0hq.js → index-9bwcthxw.js} +1 -1
  46. package/dist/{index-523tq7y4.js → index-a3462dqf.js} +1 -1
  47. package/dist/{index-506kd458.js → index-aw6zahv1.js} +1 -1
  48. package/dist/{index-tpwbct8j.js → index-dc9ere5p.js} +1 -1
  49. package/dist/{index-erv5shjq.js → index-kdnfe0sr.js} +1 -1
  50. package/dist/{index-k7z3j3hr.js → index-km2qw407.js} +4 -4
  51. package/dist/{index-7fc0c2np.js → index-n3v695sb.js} +1 -1
  52. package/dist/index-p02zgk8x.js +351 -0
  53. package/dist/{index-vtann8bz.js → index-p0rf7sq9.js} +8 -8
  54. package/dist/{index-aykhzcnf.js → index-qj1sy4ep.js} +1 -1
  55. package/dist/{index-cxpcdswt.js → index-t65erk4z.js} +1 -1
  56. package/dist/{index-c5at3ykd.js → index-tsngj2b5.js} +22 -10
  57. package/dist/react/entity-cache.d.ts +24 -2
  58. package/dist/react/entity-cache.d.ts.map +1 -1
  59. package/dist/react/keyboard/hooks.d.ts +41 -10
  60. package/dist/react/keyboard/hooks.d.ts.map +1 -1
  61. package/dist/react/keyboard/layers.d.ts +27 -8
  62. package/dist/react/keyboard/layers.d.ts.map +1 -1
  63. package/dist/react/keyboard/modality.d.ts +21 -0
  64. package/dist/react/keyboard/modality.d.ts.map +1 -0
  65. package/dist/react/keyboard/refs.d.ts +13 -0
  66. package/dist/react/keyboard/refs.d.ts.map +1 -0
  67. package/dist/react/keyboard/testing.d.ts +80 -0
  68. package/dist/react/keyboard/testing.d.ts.map +1 -0
  69. package/dist/react/keyboard/zone.d.ts +60 -10
  70. package/dist/react/keyboard/zone.d.ts.map +1 -1
  71. package/dist/telegram/budget-transformer.d.ts +35 -0
  72. package/dist/telegram/budget-transformer.d.ts.map +1 -0
  73. package/dist/telegram/outbound-budget.d.ts +101 -0
  74. package/dist/telegram/outbound-budget.d.ts.map +1 -0
  75. package/dist/telegram/send-failure.d.ts +6 -1
  76. package/dist/telegram/send-failure.d.ts.map +1 -1
  77. package/llms/react-keyboard-testing.txt +24 -0
  78. package/llms/react-keyboard.txt +264 -4
  79. package/llms/react.txt +1 -1
  80. package/llms/server.3.txt +31 -0
  81. package/llms/server.4.txt +5 -0
  82. package/llms/stitchkit.2.txt +118 -0
  83. package/llms/stitchkit.txt +8 -122
  84. package/llms/telegram-grammy.txt +22 -0
  85. package/llms/telegram.txt +76 -2
  86. package/llms/{upgrading-0.103.13-0.113.0.txt → upgrading-0.104.0-0.114.0.txt} +45 -84
  87. package/llms/upgrading-0.37.0-0.44.0.txt +444 -0
  88. package/llms/{upgrading-0.44.0-0.60.0.txt → upgrading-0.44.0-0.61.0.txt} +140 -271
  89. package/llms/{upgrading-0.61.0-0.74.0.txt → upgrading-0.62.0-0.78.0.txt} +137 -140
  90. package/llms/{upgrading-0.75.0-0.103.0.txt → upgrading-0.79.0-0.103.13.txt} +84 -137
  91. package/llms/upgrading.txt +5 -5
  92. package/llms-full.txt +453 -11
  93. package/llms.txt +12 -10
  94. package/package.json +9 -1
  95. package/llms/upgrading-0.37.0.txt +0 -176
  96. package/dist/{index-616xh7f2.js → index-4qnzda76.js} +0 -0
  97. package/dist/{index-fsw1ybf3.js → index-d9hq291n.js} +3 -3
  98. package/dist/{index-bjcjqj46.js → index-m8a44e64.js} +3 -3
  99. package/dist/{index-5efrpfmj.js → index-qndsr7f0.js} +3 -3
  100. package/dist/{index-zktz242w.js → index-s47ac8zt.js} +3 -3
  101. package/dist/{index-56yx8dxp.js → index-tvtacc9a.js} +3 -3
  102. /package/dist/{index-v1tswj0z.js → index-txg1npmg.js} +0 -0
package/llms-full.txt CHANGED
@@ -67,7 +67,8 @@ own, recorded as an ADR.
67
67
  | `stitchkit/process/owner-loss` | bundled server entry (Bun, POSIX) | evolving | narrow bootstrap that keeps the process runner lazy while carrying its owner-loss guard in a single-file bundle |
68
68
  | `stitchkit/files/packaging` | build time (Bun or Node) | evolving | installed native asset graph, integrity, Bun packaging plugin and the artifact loader check; no runtime install policy |
69
69
  | `stitchkit/files` | server (Bun or Node) | stable | peer-free managed local-file boundary |
70
- | `stitchkit/telegram` | server (Bun or Node) | evolving | peer-free Telegram platform primitives — Mini App `initData` verification, send-failure classification, resumable broadcasts, the operator channel with dedupe, a Bot API transport that separates a request that never left from an unknown outcome, local Bot API files, webhook ownership and durable webhook update intake |
70
+ | `stitchkit/telegram` | server (Bun or Node) | evolving | peer-free Telegram platform primitives — Mini App `initData` verification, send-failure classification, one outbound budget per bot token with weighted albums and shared 429 pauses, resumable broadcasts, the operator channel with dedupe, a Bot API transport that separates a request that never left from an unknown outcome, local Bot API files, webhook ownership and durable webhook update intake |
71
+ | `stitchkit/telegram/grammy` | server (Bun or Node) | evolving | the token's outbound budget as one grammY client transformer — a 429 that comes back as a response pauses the token, and a cancelled wait is a request that never left |
71
72
  | `stitchkit/telegram/html` | browser and server | evolving | Telegram's HTML as a tree — clean any markup to what Telegram accepts, cut long text into valid parts by what the reader sees, check markup before it is sent |
72
73
  | `stitchkit/telegram/screens` | server (Bun or Node) | evolving | a bot's menus as declared screens over grammY — typed paths, actions and input, message reconciliation, state in any `StorageAdapter`, a test chat |
73
74
  | `stitchkit/tracking` | browser **and** server | evolving | visitor-tracking mechanics — `createTrackingClient`, the tab-shared outbox, the page-leave beacon, attribution, the contract factory; no event vocabulary, no React |
@@ -77,12 +78,13 @@ own, recorded as an ADR.
77
78
  | `stitchkit/geo` | server (Bun or Node) | evolving | managed GeoIP reader generations, last-known-good reload and the optional MaxMind adapter |
78
79
  | `stitchkit/oauth` | browser **and** server | evolving | provider-neutral Authorization Code + PKCE request and one-shot callback transaction mechanics |
79
80
  | `stitchkit/google` | server (Bun or Node) | evolving | optional Google code exchange and verified OIDC identity adapter |
80
- | `stitchkit/observability` | server | stable<br>_redefined in 2 of the 58 minors since 0.56.2, most recently 0.92.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
81
+ | `stitchkit/observability` | server | stable<br>_redefined in 2 of the 59 minors since 0.56.2, most recently 0.92.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
81
82
  | `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, the managed-resource conformance kit and manual time for schedules and retries |
82
83
  | `stitchkit/declaration` | browser + build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
83
84
  | `stitchkit/react` | browser + server rendering | stable | `createCursorQuery`, `createCacheBridge`, QueryClient and `ApiError` retry policy |
84
85
  | `stitchkit/react/keyboard` | browser | evolving | which part of a screen gets a key — ordered layers, one Escape per level, list zones with real focus; see [keyboard guide](keyboard.md) |
85
- | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 24 of the 58 minors since 0.56.2, most recently 0.113.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
86
+ | `stitchkit/react/keyboard/testing` | server (Bun or Node) | evolving | the keyboard layers and zones without a DOM — fakes for the window, elements and key events beside the two engines, to test the order of layers, `scope`/`inert` and the edges of a zone in milliseconds |
87
+ | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 24 of the 59 minors since 0.56.2, most recently 0.113.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
86
88
  | `stitchkit/agent-runtime/sandbox` | server | evolving | optional Linux Bubblewrap sessions, durable workspaces and a host HTTP credential gateway; see [sandbox guide](sandbox.md) |
87
89
  | `stitchkit/agent-runtime/testing` | tests on Bun or Node | evolving | credential-free replay, scripted provider faults, deterministic race controls and the agent-store conformance kit |
88
90
  | `stitchkit/agent-runtime/harness` | server | evolving | resource-aware process-local facade over the canonical Agent runtime; supervision stays outside |
@@ -94,7 +96,7 @@ own, recorded as an ADR.
94
96
  | `stitchkit/agent-runtime/sqlite/bun` | server (Bun) | evolving | durable built-in SQLite store for the agent runtime |
95
97
  | `stitchkit/agent-runtime/sqlite/node` | server (Node ≥ 22.5) | evolving | durable built-in SQLite store for the agent runtime |
96
98
  | `stitchkit-tui` | terminal (Bun) | evolving | optional official OpenTUI host over a caller-composed headless runtime |
97
- | `stitchkit/application` | browser + server | evolving<br>_redefined in 14 of the 58 minors since 0.56.2, most recently 0.110.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
99
+ | `stitchkit/application` | browser + server | evolving<br>_redefined in 14 of the 59 minors since 0.56.2, most recently 0.110.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
98
100
  | `stitchkit/application/grammy` | server | evolving | a grammY bot as application resources — configuration, then polling with updates admitted by batch — and the webhook adapter |
99
101
  | `stitchkit/application/opentelemetry` | server | evolving | maps application snapshots onto an injected OpenTelemetry `Meter` |
100
102
  | `stitchkit/application/schemas` | browser + server | evolving | the application's snapshot, health and shutdown schemas alone, without the kernel |
@@ -207,6 +209,7 @@ map — feature → packages:
207
209
  | MCP Apps UI widgets | `@modelcontextprotocol/ext-apps` |
208
210
  | React data layer (`stitchkit/react`) | `@tanstack/react-query` `react-query-kit` |
209
211
  | Keyboard layers and list zones (`stitchkit/react/keyboard`) | `react` |
212
+ | Keyboard test bed (`stitchkit/react/keyboard/testing`) | — (peer-free) |
210
213
  | MaxMind GeoIP (`stitchkit/geo`) | `maxmind` |
211
214
  | Browser OAuth client (`stitchkit/oauth`) | — |
212
215
  | Google OIDC verifier (`stitchkit/google`) | `google-auth-library` |
@@ -216,6 +219,7 @@ map — feature → packages:
216
219
  | grammY lifecycle adapters (`stitchkit/application/grammy`) | `grammy` |
217
220
  | Telegram platform primitives (`stitchkit/telegram`) | — (peer-free) |
218
221
  | Telegram message markup (`stitchkit/telegram/html`) | — (peer-free) |
222
+ | Telegram grammY client transformer (`stitchkit/telegram/grammy`) | `grammy` (types only; the bot brings it) |
219
223
  | Telegram screens (`stitchkit/telegram/screens`) | `grammy` (types only; the bot brings it) |
220
224
  | OpenTelemetry gauges (`stitchkit/application/opentelemetry`) | `@opentelemetry/api` |
221
225
 
@@ -11928,6 +11932,37 @@ deleted payload without guessing. The same resolved detail key drives the
11928
11932
  `isFresh` echo guard. Shape checks also leave neighbouring detail caches alone
11929
11933
  when a list key is intentionally used as a partial query-key prefix.
11930
11934
 
11935
+ ### When only a new row needs an order
11936
+
11937
+ `compare` is the order the row itself determines: the array is sorted again after
11938
+ every create and update, so a rename that moves a row moves it in the cache too.
11939
+ Some lists are ordered by facts the row does not carry — the projects a person
11940
+ owns first, then the ones they administer, then the rest by name, where the role
11941
+ is not in the row for a platform administrator who belongs to none of them. No
11942
+ `compare` can reproduce that, and sorting every row with a wrong one scrambles
11943
+ the list the server ordered.
11944
+
11945
+ `insertCompare` answers a smaller question: where does a row that is *entering*
11946
+ the list go?
11947
+
11948
+ ```ts
11949
+ list: {
11950
+ key: ['projects'],
11951
+ shape: 'array',
11952
+ createAt: 'end',
11953
+ updateMissing: 'insert',
11954
+ insertCompare: (incoming, existing) => incoming.name.localeCompare(existing.name),
11955
+ }
11956
+ ```
11957
+
11958
+ A new row goes before the first row it sorts ahead of; a tie goes by `createAt`
11959
+ (`'start'`: before equal rows, `'end'`: after them). On a list already in that
11960
+ order the result is the one `compare` would give. On a list that is not, no other
11961
+ row moves, and a row that is already in the list is replaced where it stands.
11962
+ A paged list places the row within the page `createAt` selects, and leaves the
11963
+ other pages as they were. `compare` and `insertCompare` are two answers to one
11964
+ question: a list takes one of them, and `createEntityCacheHandlers` refuses both.
11965
+
11931
11966
  The helper deliberately does **not** flatten pages, infer filter semantics,
11932
11967
  derive a sort order or replace arbitrary `setQueryData` logic. Those remain
11933
11968
  declared application policies; Stitchkit applies their generic cache mechanics.
@@ -12949,6 +12984,11 @@ and our payload is wrong, which is the case a list of substrings quietly counts
12949
12984
  against the user. An unrecognised refusal leaves the recipient reachable: losing
12950
12985
  a working subscriber forever costs more than one wasted send.
12951
12986
 
12987
+ `bot-to-bot-disabled` is permanent but deliberately not `recipientUnreachable`:
12988
+ Telegram returns it when either bot has not enabled Bot to Bot messaging, so the
12989
+ missing setting may be the sender's. Do not retry it, and do not close the
12990
+ recipient's row on it.
12991
+
12952
12992
  `TELEGRAM_SEND_FAILURE_REASONS` lists every reason. A table keyed by
12953
12993
  `TelegramSendFailureReason` — error codes, the text a person reads — fails to
12954
12994
  compile when a release adds one, instead of falling through to a default:
@@ -15259,6 +15299,8 @@ key hears it first — a widget's own `onKeyDown`, a text field, a popover that
15259
15299
  closes on Escape — and keeps it with `preventDefault()` or `stopPropagation()`.
15260
15300
  A layer only gets what nobody nearer wanted, so third-party components keep
15261
15301
  working unchanged.
15302
+ One kind of layer may ask for the opposite: a menu over a text field takes the
15303
+ arrows before the field does — see [A menu over a text field](#a-menu-over-a-text-field).
15262
15304
 
15263
15305
  ## Shortcuts and Escape
15264
15306
 
@@ -15338,7 +15380,7 @@ function ChatList({ chats, openId, open }) {
15338
15380
  | Option | Default | Meaning |
15339
15381
  |---|---|---|
15340
15382
  | `items`, `value`, `onChange` | — | the ids in order, the chosen one, and the report of a choice; the zone never holds the value |
15341
- | `mode` | `'select'` | `'select'`: an arrow chooses (`onChange(id, 'arrow')`). `'highlight'`: an arrow only moves focus, Enter chooses (`onChange(id, 'confirm')`) |
15383
+ | `mode` | `'select'` | `'select'`: an arrow chooses (`onChange(id, 'arrow')`). `'highlight'`: an arrow only moves focus, Enter chooses (`onChange(id, 'confirm')`). `'focus'`: an arrow only moves focus and the items choose for themselves — see [below](#links-and-buttons-that-choose-for-themselves) |
15342
15384
  | `orientation` | `'vertical'` | which arrows move; the other axis enters and exits |
15343
15385
  | `role` | `'listbox'` | `'listbox'` of options or `'tablist'` of tabs, marked `aria-selected`; `'none'` keeps the items' own semantics (links, buttons) and marks the chosen one `aria-current` |
15344
15386
  | `loop` | `false` | wrap past the ends instead of calling `onBoundary` |
@@ -15347,6 +15389,8 @@ function ChatList({ chats, openId, open }) {
15347
15389
  | `onBoundary(side)` | — | a move past `'start'` or `'end'`: hand focus to the neighbouring zone |
15348
15390
  | `active` | `true` | whether arrows pressed outside every zone land here |
15349
15391
  | `label` | — | the zone's accessible name |
15392
+ | `outside` | `'focus'` | what the first arrow does when focus is in no zone: `'focus'` puts focus on the current item and nothing else; `'move'` moves from it and, in select mode, chooses the neighbour — see [below](#the-first-arrow-from-outside-a-zone) |
15393
+ | `ignore(event)` | — | a key that is the application's own right now — return `true` and the zone leaves it whole; see [dragging](#a-zone-inside-a-drag-and-drop-list) |
15350
15394
  | `onReveal(id)` | — | focus went to an item with no element yet — a row of a virtualized list outside the rendered range; scroll it in, and focus lands on it when it mounts |
15351
15395
 
15352
15396
  The callbacks return `false` to leave the key to the page, anything else takes
@@ -15359,6 +15403,256 @@ axis puts focus on the active zone's current item. To move between zones
15359
15403
  (sidebar → table), call `zone.focus(id?)` of the other zone from `onEnter` or
15360
15404
  `onBoundary`. `zone.element` is the zone's element, for a layer scoped to it.
15361
15405
 
15406
+ ## Links and buttons that choose for themselves
15407
+
15408
+ In `select` and `highlight` mode the zone reports a choice, and in `highlight` it takes
15409
+ Enter for it. A list of `<a>` and `<button>` elements has a choice built in: Enter
15410
+ clicks the focused one. Taking Enter would replace that with a handler you must
15411
+ write to click the element yourself.
15412
+
15413
+ `mode: 'focus'` is for those lists — a menu, a toolbar, a row of tabs that are
15414
+ links. The arrows move focus; Enter, and Space on a button, stay with the focused
15415
+ element; the zone reports nothing, so there is no `onChange`. `value` is optional
15416
+ and only marks the page you are on (`aria-current` with `role: 'none'`).
15417
+
15418
+ ```tsx
15419
+ function Toolbar({ actions }) {
15420
+ const zone = useNavZone({
15421
+ items: actions.map((action) => action.id),
15422
+ mode: 'focus',
15423
+ role: 'none',
15424
+ orientation: 'horizontal',
15425
+ label: 'Formatting',
15426
+ })
15427
+ return (
15428
+ <div {...zone.zoneProps}>
15429
+ {actions.map((action) => (
15430
+ <button key={action.id} type="button" onClick={action.run} {...zone.itemProps(action.id)}>
15431
+ {action.label}
15432
+ </button>
15433
+ ))}
15434
+ </div>
15435
+ )
15436
+ }
15437
+ ```
15438
+
15439
+ In a focus zone Enter is never taken, even when `onEnter` is set: `onEnter` is
15440
+ the cross-axis key (→ in a vertical zone).
15441
+
15442
+ ## Props, refs and other libraries
15443
+
15444
+ Spread the zone's props **last** on an element another library also configures, so
15445
+ the roving `tabIndex` is the one that counts. A prop without a value is absent, not
15446
+ `undefined`: `role` is there only for a listbox or a tablist, `aria-selected` only
15447
+ outside `role: 'none'`, `aria-label` only with a `label`. A `role="button"` the
15448
+ element carries from another library, or an `aria-label` you wrote before the
15449
+ spread, survives it.
15450
+
15451
+ `ref` is the one prop two libraries both need. `mergeRefs` makes one callback of
15452
+ several refs; spread the zone's props, then replace `ref`:
15453
+
15454
+ ```tsx
15455
+ import { mergeRefs, useNavZone } from 'stitchkit/react/keyboard'
15456
+
15457
+ function Row({ id, zone }) {
15458
+ const sortable = useSortable({ id })
15459
+ const own = zone.itemProps(id)
15460
+ return (
15461
+ <li
15462
+ {...sortable.attributes}
15463
+ {...sortable.listeners}
15464
+ {...own}
15465
+ ref={mergeRefs(own.ref, sortable.setNodeRef)}
15466
+ >
15467
+ {id}
15468
+ </li>
15469
+ )
15470
+ }
15471
+ ```
15472
+
15473
+ The same goes for `zone.zoneProps.ref` beside a scroll container's or a measuring
15474
+ hook's ref. `mergeRefs` returns a new function on every call and React re-attaches a
15475
+ callback ref whose identity changed; a component that renders often keeps the
15476
+ result in `useMemo`.
15477
+
15478
+ `useNavZone` also returns a new object on every render. An effect that should run
15479
+ when a menu *opens* depends on that, not on `zone`; keep the latest zone in a ref.
15480
+
15481
+ ## A zone inside a drag-and-drop list
15482
+
15483
+ A sortable list is a zone whose rows can also be lifted with the keyboard: Space
15484
+ lifts a row, the arrows move it, Space drops it. The arrows are then the drag's,
15485
+ not the zone's, and a zone that also moves focus on them makes the focus ring jump
15486
+ from row to row while the row moves.
15487
+
15488
+ `ignore` hands the zone a question about every key it would act on. dnd-kit marks
15489
+ the lifted activator `aria-pressed="true"` for the length of the drag:
15490
+
15491
+ ```tsx
15492
+ const zone = useNavZone({
15493
+ items: order,
15494
+ mode: 'focus',
15495
+ role: 'none',
15496
+ ignore: (event) =>
15497
+ event.target instanceof Element && event.target.closest('[aria-pressed="true"]') !== null,
15498
+ })
15499
+ ```
15500
+
15501
+ The zone leaves an ignored key entirely: it does not move, does not take the key and
15502
+ does not call `preventDefault`, whether the key came from inside the zone or from
15503
+ a page whose focus is in no zone. The test is the application's on purpose. A
15504
+ toggle button also carries `aria-pressed="true"`, and a toolbar of toggles must
15505
+ keep its arrows; only the application knows whether a pressed element is a drag.
15506
+
15507
+ ## A menu or a popover opened by the pointer
15508
+
15509
+ A script's `focus()` shows the focus ring when the element that had focus before it
15510
+ was showing one — and when nothing had. A menu opened by a right click, with focus
15511
+ on `body`, would put a ring on its first item although nobody pressed a key. Decide
15512
+ at the moment of opening: in the handler that opens the menu, before anything moves
15513
+ focus, ask `openedByKeyboard()`.
15514
+
15515
+ ```tsx
15516
+ const byKeyboard = useRef(false)
15517
+ const container = useRef<HTMLDivElement>(null)
15518
+
15519
+ function show() {
15520
+ byKeyboard.current = openedByKeyboard() // before the menu takes focus
15521
+ setOpen(true)
15522
+ }
15523
+
15524
+ useLayoutEffect(() => {
15525
+ if (!open) return
15526
+ if (byKeyboard.current) zone.focus() // the first item, with its ring
15527
+ else container.current?.focus() // the menu itself
15528
+ }, [open])
15529
+ ```
15530
+
15531
+ `openedByKeyboard()` is whether the element with focus right now matches
15532
+ `:focus-visible`; it is `false` on a server and in a browser without that selector.
15533
+ Give the container `outline: none`: it only holds focus, and a browser may count a
15534
+ focus that starts on `body` as keyboard focus. The first arrow with focus on the
15535
+ zone's own element lands on the current item, not one step beyond it, so the menu
15536
+ opened by a click is a normal menu from the first key.
15537
+
15538
+ A focus manager that also puts initial focus on opening fights this for the first
15539
+ item. Floating UI's `FloatingFocusManager` documents `initialFocus={-1}` as "ignore
15540
+ the initial focus" and `initialFocus={refs.floating}` as focusing the container;
15541
+ either leaves the first item to the handler above. (This repository's browser probe
15542
+ covers the pattern with a plain element, not Floating UI.)
15543
+
15544
+ ## The first arrow from outside a zone
15545
+
15546
+ With focus in no zone — right after a click on the page, or on the empty part of a
15547
+ screen — the first arrow on an active zone's axis puts focus on its current item.
15548
+ That is the safe answer: the person sees where they are before anything changes,
15549
+ and a zone whose arrows choose does not change the choice on a key that only
15550
+ announced the list.
15551
+
15552
+ A list that has always answered the first arrow with a change (a chat list whose
15553
+ arrows open the next chat) can say so: `outside: 'move'` acts as if focus were
15554
+ already on the current item, so the arrow moves from it and, in `select` mode,
15555
+ calls `onChange` for the neighbour. It is opt-in and per zone; `ignore` is asked
15556
+ first in both.
15557
+
15558
+ ## A panel that keeps its keys around a zone
15559
+
15560
+ `data-local-keys` marks an area whose keys belong to it rather than to the page:
15561
+ layers skip keys pressed inside it, so a section switch on ← and → leaves a
15562
+ dropdown panel alone. A zone inside such a panel still has its arrows. The zone is
15563
+ the part of the area that handles them, so only a field *inside the zone* — an
15564
+ input in one of its rows — keeps its keys from the zone; an area that contains the
15565
+ zone, or the zone's own element carrying the mark, does not. ← and → are not a
15566
+ vertical zone's keys, so they bubble on to the layers, which skip them in that
15567
+ panel: the zone moves, the section does not.
15568
+
15569
+ ## A menu over a text field
15570
+
15571
+ A layer hears a key after everything closer to it, and a text field is as close as
15572
+ it gets: the field acts on an arrow or Enter first. A menu that hangs over the
15573
+ field — slash commands, quick replies — keeps focus in the field and needs those
15574
+ keys *before* it does, and the field's own Enter (send the message) must not also
15575
+ run.
15576
+
15577
+ `phase: 'capture'` is for that, and for a `local` layer only (any other kind
15578
+ refuses it when it mounts): the layer is asked before the element that has focus,
15579
+ and a key it takes — `preventDefault` and `stopPropagation` — reaches nothing
15580
+ nearer.
15581
+
15582
+ ```tsx
15583
+ function CommandMenu({ open, items, pick }) {
15584
+ const menu = useRef<HTMLDivElement>(null)
15585
+ const [index, setIndex] = useState(0)
15586
+ useKeyLayer({
15587
+ kind: 'local',
15588
+ phase: 'capture',
15589
+ fields: 'include', // the key is pressed in the field
15590
+ scope: menu, // heard only while the menu is on the page
15591
+ active: open,
15592
+ onKey: (event) => {
15593
+ if (event.key === 'ArrowDown') setIndex((i) => Math.min(i + 1, items.length - 1))
15594
+ else if (event.key === 'ArrowUp') setIndex((i) => Math.max(i - 1, 0))
15595
+ else if (event.key === 'Enter') pick(items[index])
15596
+ else return false // typing, Backspace, everything else stays the field's
15597
+ return true
15598
+ },
15599
+ })
15600
+ return open ? <div ref={menu} role="listbox">…</div> : null
15601
+ }
15602
+ ```
15603
+
15604
+ `fields: 'include'` is needed because the default skips keys pressed in a field;
15605
+ `scope` and `active` keep the layer from taking keys when there is no menu. An
15606
+ `overlay` above still stops it: a live overlay is a barrier for layers of either
15607
+ phase. In `createKeyboardTestBed`, `press` runs the capture phase first and
15608
+ stops there when a layer took the key (`event.propagationStopped`).
15609
+
15610
+ ## Moving from a stack of zones of your own
15611
+
15612
+ A hand-written zone stack and `stitchkit/react/keyboard` name the same ideas
15613
+ differently:
15614
+
15615
+ | Yours | Here |
15616
+ |---|---|
15617
+ | a flag that arrows choose at once (`instant`) | `mode: 'select'`; `'highlight'` when choosing is expensive |
15618
+ | wrapping (often the default) | `loop` — `false` by default; set it on the lists that wrapped |
15619
+ | the current section | `active` for which zone takes the first arrow, `scope` for which layer is live |
15620
+ | one shared "highlighted id" | each zone's own focused item; read `zone.current` |
15621
+ | an attribute that marks areas with their own keys | `data-local-keys` |
15622
+ | Escape closing the innermost thing | `useEscapeLayer` — one Escape closes one level |
15623
+
15624
+ ## Testing without a DOM
15625
+
15626
+ `stitchkit/react/keyboard/testing` has no React and no DOM in it: a window that
15627
+ remembers its listener, elements with a parent, `inert` and visibility, and key
15628
+ events with `preventDefault`. A test adds the layers a screen declares, presses
15629
+ keys on elements and reads who heard what.
15630
+
15631
+ ```ts
15632
+ import { createKeyboardTestBed } from 'stitchkit/react/keyboard/testing'
15633
+
15634
+ test('Escape closes the dialog and not the page behind it', () => {
15635
+ const bed = createKeyboardTestBed()
15636
+ const heard: string[] = []
15637
+ const page = bed.element()
15638
+ const dialog = bed.element()
15639
+ bed.stack.add({ kind: 'route', scope: () => page, onKey: () => (heard.push('page'), true) })
15640
+ bed.stack.add({ kind: 'overlay', scope: () => dialog, onKey: () => (heard.push('dialog'), true) })
15641
+ bed.press('Escape')
15642
+ expect(heard).toEqual(['dialog'])
15643
+ page.inert = true // the page behind a dialog
15644
+ dialog.hidden = true
15645
+ bed.press('Escape')
15646
+ expect(heard).toEqual(['dialog'])
15647
+ })
15648
+ ```
15649
+
15650
+ `createNavZone` is the zone without React: build it with the items' fake elements
15651
+ (`bed.element({ zone: true })` and its `child()` rows count `focus()` and scrolling),
15652
+ call `zone.keyDown(bed.key('ArrowDown', { target: row }), zoneElement)`. A real
15653
+ focus ring, scrolling and screen readers are a browser's: this is for the order of
15654
+ layers, `scope`/`inert` and the edges of a zone.
15655
+
15362
15656
  ## What stays in the application
15363
15657
 
15364
15658
  The URL, the router, sections and which screen is open. A layer calls the
@@ -15528,6 +15822,79 @@ The error line contract, for whatever recognises a failure from the journal: a
15528
15822
  line whose `level` is `50` or more is an error. There is no separate `fatal`;
15529
15823
  an unrecoverable end is an `error` line followed by a non-zero exit.
15530
15824
 
15825
+ ## One token, one outbound budget
15826
+
15827
+ Telegram counts a bot's bulk messages per token, not per campaign, handler or
15828
+ module. `telegramOutboundBudget` is that budget once: every alias of one token
15829
+ in the process gets the same object, another token gets another, and every send
15830
+ through it spends its weight. → [ADR 0274](../decisions/0274-a-telegram-token-has-one-outbound-budget.md).
15831
+
15832
+ ```ts
15833
+ import { telegramOutboundBudget } from 'stitchkit/telegram'
15834
+ import { telegramBudgetTransformer } from 'stitchkit/telegram/grammy'
15835
+
15836
+ const budget = telegramOutboundBudget({
15837
+ token: env.BOT_TOKEN,
15838
+ messagesPerSecond: 24,
15839
+ })
15840
+
15841
+ // The one transformer of the bot's client. No second limiter, no second
15842
+ // scheduler: campaigns, replies and every other send of this token share it.
15843
+ bot.api.config.use(telegramBudgetTransformer(budget))
15844
+ ```
15845
+
15846
+ `telegramBudgetTransformer` is the whole wiring for grammY. Without it the
15847
+ adapter is `budget.run(method, payload, signal, () => previous(method, payload, signal))`
15848
+ inside your own `config.use` closure; that works too, but grammY types its signal
15849
+ as the `abort-controller` polyfill's, so the closure has to pass `undefined` or
15850
+ cast, and a send cancelled in the queue then waits for its slot and fails as
15851
+ `unknown`. The transformer takes grammY's signal as it is.
15852
+
15853
+ - **The token is the identity, not the sender.** Two bot objects built from one
15854
+ token share the budget; two different tokens do not. The registry keeps a
15855
+ 128-bit identity, never the credential, and `stats()` contains no token.
15856
+ - **A message is the unit.** `sendMediaGroup` and `sendPaidMedia` cost their
15857
+ media length, `copyMessages` and `forwardMessages` cost their message ids, and
15858
+ a follow-up call to action after an album costs one more. Reads and edits
15859
+ (`getMe`, `getFile`, `editMessage*`, `deleteMessage`, `sendChatAction`) do not
15860
+ spend the budget.
15861
+ - **The first send after idle is immediate; the rest are spaced.** This is a
15862
+ strict budget, not a bucket that saves a burst: at 24/s the next send is due
15863
+ one interval later. A 429 pauses the whole token; when it resumes, one send
15864
+ is released at the deadline and the queue continues one interval at a time,
15865
+ never as a stampede.
15866
+ - **It paces; it does not retry.** `run` calls the send once. A 429 pauses the
15867
+ token for Telegram's `retry_after` (or `retryAfterFallbackMs` when Telegram
15868
+ gave none) and rethrows; the caller's retry policy decides what happens next.
15869
+ The refusal may be thrown or returned: a client transformer sits below the
15870
+ place where grammY turns Telegram's `{ ok: false, error_code: 429, … }` body into
15871
+ a `GrammyError`, so `run` reads that body too and hands it back unchanged.
15872
+ A 429 from an edit or a read is still a fact about the token and pauses later
15873
+ sends too. A network failure is rethrown unchanged and stays `unknown`; a 5xx
15874
+ is rethrown unchanged and stays `server-error`. Neither is retried by the
15875
+ budget or pauses the token — repeating a send whose outcome is unknown is a
15876
+ decision, not a primitive's silent guess.
15877
+ - **A wait can be cancelled.** `run`/`acquire` take an `AbortSignal`; a
15878
+ cancelled waiter spends no slot and calls no provider. Through
15879
+ `telegramBudgetTransformer` a send cancelled before it was handed to the client
15880
+ throws `TelegramNotDispatchedError` (`classifyTelegramSendFailure` says
15881
+ `not-dispatched`, safe to repeat); a send cancelled after it was handed on is
15882
+ the client's own error — not evidence that Telegram did not accept it — so
15883
+ keep the outcome unknown rather than retrying it as a definite failure.
15884
+ - **Non-send retries wait without spending.** An edit or delete retry that got
15885
+ a 429 first records the pause with `budget.pauseAfter(error)` (the `run` path
15886
+ does this itself), then uses `await budget.waitUntilResumed(signal)`; it shares
15887
+ the token's pause but does not take a message slot.
15888
+
15889
+ `telegramBroadcastSender` and `telegramOperatorSender` still send through
15890
+ `callTelegramBotApi`; they are not a second budget. Their returned function does
15891
+ not expose the method and payload, so a product that uses one wraps the concrete
15892
+ `sendMessage`/`copyMessage` call in `budget.run` where it knows both, or passes
15893
+ its own sender. There is no generic wrapper around the opaque sender.
15894
+ `runTelegramBroadcast` keeps its per-run `ratePerSecond` and the operator
15895
+ channel keeps its per-chat `minIntervalMs`; those are caps of their own jobs,
15896
+ not another token quota. The token budget is the binding shared cap.
15897
+
15531
15898
  ## The operator channel
15532
15899
 
15533
15900
  The chat where people read about new users, payments and failures is not the
@@ -16361,6 +16728,48 @@ analytics policy belong to the application.
16361
16728
 
16362
16729
  # Upgrading stitchkit
16363
16730
 
16731
+ ## Released migration: 0.114.0
16732
+
16733
+ **Who must act:** code with an exhaustive `switch` or `Record` over
16734
+ `TelegramSendFailureReason` or `TELEGRAM_SEND_FAILURE_REASONS`. Code that only
16735
+ reads `retryable` and `recipientUnreachable` needs no change.
16736
+
16737
+ `classifyTelegramSendFailure` now returns `reason: 'bot-to-bot-disabled'` for a
16738
+ `400 USER_BOT_TO_BOT_DISABLED` ([ADR 0274](../decisions/0274-a-telegram-token-has-one-outbound-budget.md)).
16739
+ It is permanent for that send, so it is not retried; it is **not**
16740
+ `recipientUnreachable`, because Telegram disables bot-to-bot messaging when
16741
+ either bot has not enabled its Bot to Bot setting, and the missing setting may
16742
+ be the sender's.
16743
+
16744
+ 1. Add the reason to every exhaustive mapping:
16745
+
16746
+ ```ts
16747
+ // before
16748
+ const code: Record<TelegramSendFailureReason, string> = { /* … */ unknown: 'E_UNKNOWN' }
16749
+ // after
16750
+ const code: Record<TelegramSendFailureReason, string> = {
16751
+ /* … */ 'bot-to-bot-disabled': 'E_BOT_TO_BOT', unknown: 'E_UNKNOWN',
16752
+ }
16753
+ ```
16754
+
16755
+ Search pattern: `TelegramSendFailureReason`, `TELEGRAM_SEND_FAILURE_REASONS`
16756
+ and every `switch` over a send-failure reason.
16757
+ 2. Type-check: the exhaustive table without the new member fails to compile.
16758
+ 3. If the application wants one outbound budget per bot token, opt in at the
16759
+ one transformer that already owns its Bot API calls:
16760
+
16761
+ ```ts
16762
+ import { telegramOutboundBudget } from 'stitchkit/telegram'
16763
+
16764
+ const budget = telegramOutboundBudget({ token: env.BOT_TOKEN })
16765
+ api.config.use((previous, method, payload, signal) =>
16766
+ budget.run(method, payload, signal, () => previous(method, payload, signal)),
16767
+ )
16768
+ ```
16769
+
16770
+ Remove the old limiter rather than running it beside this one. See the
16771
+ [Telegram guide](telegram.md#one-token-one-outbound-budget).
16772
+
16364
16773
  ## Released migration: 0.113.0
16365
16774
 
16366
16775
  **Who must act:** custom `AgentRuntimeStoreDriver` adapters and custom `AgentRuntimeStore` implementations of `seedConversationInput`. The built-in memory and SQLite stores already implement the contract.
@@ -23286,7 +23695,7 @@ available from `stitchkit/contract`.
23286
23695
  Peer-free server-only Telegram platform primitives — the Bot API's own
23287
23696
  protocols and the jobs every bot rewrote around them. Importing this resolves no
23288
23697
  bot library; `stitchkit/application/grammy` remains the lifecycle adapter for an
23289
- injected grammY bot. → ADR 0143, ADR 0201 — [guide](../guide/telegram.md)
23698
+ injected grammY bot. → ADR 0143, ADR 0201, ADR 0274 — [guide](../guide/telegram.md)
23290
23699
 
23291
23700
  | Export | Kind | Summary |
23292
23701
  |--------|------|---------|
@@ -23301,7 +23710,8 @@ injected grammY bot. → ADR 0143, ADR 0201 — [guide](../guide/telegram.md)
23301
23710
  | `createTelegramBotTransport` / `TelegramBotTransportOptions` | function / _type_ | a `fetch` for the Bot API senders that connects before it writes, so a failure before the first byte is `TelegramNotDispatchedError`; connection attempt and budget, answer timeout and size, injectable resolver and socket factory — [guide](../guide/telegram.md#a-transport-that-knows-whether-a-request-left) |
23302
23711
  | `TelegramFetch` / `TelegramBotTransportOpen` / `TelegramBotTransportResolve` / `TelegramBotTransportSocket` | _types_ | the `(url, init) => Promise<Response>` the senders take; the transport's socket factory, resolver and the structural socket it needs (a Node `net.Socket` or `tls.TLSSocket` fits) |
23303
23712
  | `TelegramNotDispatchedError` / `TelegramNotDispatchedStage` | class / _type_ | a request that provably never left (`lookup`, `connect` or `request`), so repeating it cannot duplicate a message |
23304
- | `TelegramSendFailureReason` / `TELEGRAM_SEND_FAILURE_REASONS` | _type_ / constant | `blocked-by-user` / `user-deactivated` / `chat-not-found` / `not-started` / `rate-limited` / `message-invalid` / `server-error` / `not-dispatched` / `unknown`, and the same as a list; a `Record<TelegramSendFailureReason, …>` over them fails to compile when a reason is added |
23713
+ | `TelegramSendFailureReason` / `TELEGRAM_SEND_FAILURE_REASONS` | _type_ / constant | `blocked-by-user` / `user-deactivated` / `chat-not-found` / `not-started` / `bot-to-bot-disabled` / `rate-limited` / `message-invalid` / `server-error` / `not-dispatched` / `unknown`, and the same as a list; a `Record<TelegramSendFailureReason, …>` over them fails to compile when a reason is added |
23714
+ | `telegramOutboundBudget` / `TelegramOutboundBudget` / `TelegramOutboundBudgetConfig` / `TelegramOutboundBudgetStats` | function / _type_ | one token-wide outbound budget: one object per token in the process, strict 24 messages/s by default, weighted albums and multi-message copies, `run(method, payload, signal, send)` as the transformer adapter (a 429 pauses the token whether `send` throws it or resolves with the Bot API's `{ ok: false, … }` body), `pause`/`pauseAfter` for 429, `waitUntilResumed` for non-send retries and `stats`; it paces and never retries, and no public value carries the token |
23305
23715
  | `runTelegramBroadcast` | function | a resumable broadcast by `name` under a state `directory`: audience and journal durably recorded, pacing at `ratePerSecond` (25), exact retry delays within the finite declared budget, injected classifier, uncertain send outcomes never replayed, `dryRun`, one fenced runner per name |
23306
23716
  | `TelegramBroadcastConfig` / `TelegramBroadcastSend` / `TelegramBroadcastReport` / `TelegramBroadcastRunOutcome` / `TelegramBroadcastOutcome` / `TelegramBroadcastRecipient` | _type_ | `name`, `directory`, `recipients()`, `send({ recipient, attempt })`, `maxAttempts`, `signal`, `onProgress`; counts `delivered` / `unreachable` / `failed` / `uncertain` / `pending` and `finished` / `stopped` / `halted` / `dry-run` |
23307
23717
  | `TelegramBroadcastFailureSchema` / `TelegramBroadcastFailure` | schema / _type_ | injected `classify` answers retry-after/transient/permanent/ambiguous; exact provider delay is bounded by `maxRetryDelayMs` without shortening, unknown outcome remains uncertain and is never replayed |
@@ -23335,6 +23745,18 @@ injected grammY bot. → ADR 0143, ADR 0201 — [guide](../guide/telegram.md)
23335
23745
 
23336
23746
  ---
23337
23747
 
23748
+ ## `stitchkit/telegram/grammy`
23749
+
23750
+ The token's outbound budget as one grammY client transformer. Server-only, Bun
23751
+ or Node; `grammy` appears in the declarations and is never imported at run time —
23752
+ the bot brings it. → ADR 0274 — [guide](../guide/telegram.md#one-token-one-outbound-budget)
23753
+
23754
+ | Export | Kind | Summary |
23755
+ |--------|------|---------|
23756
+ | `telegramBudgetTransformer` | function | `bot.api.config.use(telegramBudgetTransformer(budget))`: every Bot API call runs under the token's `telegramOutboundBudget`; a 429 that comes back as a response pauses the token, a cancellation before the request was handed on throws `TelegramNotDispatchedError`, a cancellation in flight stays the client's own error; it never retries |
23757
+
23758
+ ---
23759
+
23338
23760
  ## `stitchkit/telegram/html`
23339
23761
 
23340
23762
  Telegram's HTML parse mode as a tree: any markup in, markup Telegram accepts
@@ -23734,7 +24156,7 @@ Browser- and server-render-safe React data-layer helpers. Needs the
23734
24156
  | `EntityCacheHandlers` | _type_ | the `{ created, updated, deleted }` handlers it returns |
23735
24157
  | `EntityCacheEvent` | _type_ | discriminated created/updated/deleted input for dynamic cache keys |
23736
24158
  | `EntityCacheKey` | _type_ | static `QueryKey` or event-aware key factory |
23737
- | `EntityCacheListConfig` | _type_ | list shape, scoped key, insertion/missing-update policy and comparator |
24159
+ | `EntityCacheListConfig` | _type_ | list shape, scoped key, insertion/missing-update policy and one ordering: `compare` (the whole array is sorted again after a create or update) or `insertCompare` (only a row entering the list is placed; every other row stays, and an updated row keeps its place) |
23738
24160
  | `EntityCacheListShape` | _type_ | `array \| paginated \| infinite-array \| infinite-paginated` |
23739
24161
  | `DeletedPayload` | _type_ | a `deleted` event payload — the entity or a bare `{ id }` |
23740
24162
  | `CursorQueryConfig` | _type_ | config for `createCursorQuery` |
@@ -23773,13 +24195,19 @@ no component, no styles, no router — see the [keyboard guide](../guide/keyboar
23773
24195
  |--------|------|---------|
23774
24196
  | `useKeyLayer` | hook | put a layer on the window's one key stack — asked `overlay` → `local` → `route` → `zone` → `global`, innermost first within a kind |
23775
24197
  | `useEscapeLayer` | hook | an `overlay`, `local` or `route` layer for Escape: one press closes one level |
23776
- | `useNavZone` | hook | a list zone — roving tab stop, arrows move DOM focus, select or highlight mode, cross-axis enter/exit, boundaries |
24198
+ | `useNavZone` | hook | a list zone — roving tab stop, arrows move DOM focus, select, highlight or focus mode, cross-axis enter/exit, boundaries, `ignore` for a key the application is using; props carry no key without a value |
23777
24199
  | `nextZoneIndex` | function | where a move lands in a list of a given length: an index, `'before-start'` or `'after-end'` |
23778
24200
  | `isKeyFieldTarget` | function | whether a key was pressed in a field or a `data-local-keys` area, the test layers use |
23779
- | `KeyLayerOptions` | _type_ | `kind`, `onKey`, `active`, `scope`, `fields` |
24201
+ | `KeyLayerOptions` | _type_ | `kind`, `onKey`, `active`, `scope`, `fields`, `phase` — `'capture'` is for a `local` layer only: it hears a key before the element that has focus does, as a menu over a text field must, and a key it takes goes no further |
24202
+ | `KeyLayerPhase` | _type_ | `'bubble' \| 'capture'` |
23780
24203
  | `KeyLayerKind` | _type_ | `'overlay' \| 'local' \| 'route' \| 'zone' \| 'global'` |
23781
24204
  | `EscapeLayerOptions` | _type_ | `kind`, `onEscape`, `active`, `scope` |
23782
- | `NavZoneOptions` | _type_ | `items`, `value`, `onChange`, `mode`, `orientation`, `role`, `loop`, `onEnter`, `onExit`, `onBoundary`, `active`, `label`, `onReveal` |
24205
+ | `NavZoneOptions` | _type_ | `items`, `value`, `onChange`, `mode` (`select` or `highlight`), `orientation`, `role`, `loop`, `onEnter`, `onExit`, `onBoundary`, `active`, `outside` (`'focus'` or `'move'`: what the first arrow does when focus is in no zone), `label`, `onReveal`, `ignore` |
24206
+ | `FocusZoneOptions` | _type_ | the options of a zone whose items choose for themselves: `mode: 'focus'`, no `onChange`, an optional `value`; the arrows move focus and Enter stays with the focused element |
24207
+ | `ZoneOptions` | _type_ | `NavZoneOptions` or `FocusZoneOptions` — what `useNavZone` takes |
24208
+ | `ZoneKeyEvent` | _type_ | the key an `ignore` callback is asked about: `key`, modifiers, `target`, `repeat`, `isComposing`, `defaultPrevented` |
24209
+ | `mergeRefs` | function | one ref callback for several refs — the zone's `ref` beside a drag handle's or your own; spread the zone's props, then set `ref: mergeRefs(props.ref, other)` |
24210
+ | `openedByKeyboard` | function | whether the element with focus right now shows a focus ring — call it in the handler that opens a menu, before focus moves, to decide whether the first item takes focus |
23783
24211
  | `NavZone` | _type_ | what `useNavZone` returns — `zoneProps`, `itemProps(id)`, `element`, `current`, `focus(id?)` |
23784
24212
  | `NavZoneProps` / `NavZoneItemProps` | _type_ | the props spread on the zone's element and on each item |
23785
24213
  | `NextZoneIndexInput` | _type_ | `length`, `index`, `move`, `loop` |
@@ -23788,6 +24216,20 @@ no component, no styles, no router — see the [keyboard guide](../guide/keyboar
23788
24216
  | `ZoneMove` | _type_ | `'next' \| 'previous' \| 'first' \| 'last'` |
23789
24217
  | `ZoneOrientation` | _type_ | `'vertical' \| 'horizontal'` |
23790
24218
 
24219
+ ## `stitchkit/react/keyboard/testing`
24220
+
24221
+ Keyboard layers and zones without a DOM, a browser or React: fakes for the
24222
+ window, elements and key events, and the two engines the hooks are made of.
24223
+ Peer-free. A test builds the layers a screen declares, presses keys on elements
24224
+ and reads who heard what — see the [keyboard guide](../guide/keyboard.md#testing-without-a-dom).
24225
+
24226
+ | Export | Kind | Summary |
24227
+ |--------|------|---------|
24228
+ | `createKeyboardTestBed` | function | a fresh stack, window, element factory and key factory: `stack.add(layer)`, `press(key, { target })`, `element({ parent, field, zone, inert, hidden })`, `key(name)` for a zone, and `listening` |
24229
+ | `KeyboardTestBed` / `TestElement` / `TestElementInit` / `TestKeyEvent` / `TestKeyInit` | _type_ | the test bed, an element (also a zone item: it counts `focus()` and scrolling), what builds one, and a key event with `defaultPrevented` |
24230
+ | `createKeyLayerStack` / `KeyLayerStack` / `KeyLayerHandle` / `KeyLayerState` / `KeyListenerTarget` / `LayerKeyEvent` / `LayerScope` | function / _type_ | the layer stack the hooks put on the window, over any listener target and elements that have `contains`, `closest`, `isConnected` |
24231
+ | `createNavZone` / `NavZoneController` / `ZoneItemElement` | function / _type_ | a zone controller without React: `keyDown`, `keyFromOutside`, `focused`, `focus`, `update`, `current`, `pointedItem`, `subscribe` |
24232
+
23791
24233
  ## `stitchkit/agent-runtime/sandbox`
23792
24234
 
23793
24235
  Optional server-only namespace sandbox. Exports: