stitchkit 0.114.0 → 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 (90) hide show
  1. package/CHANGELOG.md +29 -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.js +16 -10
  36. package/dist/entrypoints/testing.js +3 -3
  37. package/dist/entrypoints/tools/connections.js +10 -10
  38. package/dist/entrypoints/tools.js +19 -19
  39. package/dist/entrypoints/tracking.js +1 -1
  40. package/dist/{index-hs35ss3p.js → index-61hve0ez.js} +4 -25
  41. package/dist/index-6j725ez5.js +25 -0
  42. package/dist/{index-1csp1hwm.js → index-6p3frkv5.js} +1 -1
  43. package/dist/{index-fy4av0hq.js → index-9bwcthxw.js} +1 -1
  44. package/dist/{index-523tq7y4.js → index-a3462dqf.js} +1 -1
  45. package/dist/{index-506kd458.js → index-aw6zahv1.js} +1 -1
  46. package/dist/{index-tpwbct8j.js → index-dc9ere5p.js} +1 -1
  47. package/dist/{index-erv5shjq.js → index-kdnfe0sr.js} +1 -1
  48. package/dist/{index-k7z3j3hr.js → index-km2qw407.js} +4 -4
  49. package/dist/{index-7fc0c2np.js → index-n3v695sb.js} +1 -1
  50. package/dist/index-p02zgk8x.js +351 -0
  51. package/dist/{index-vtann8bz.js → index-p0rf7sq9.js} +8 -8
  52. package/dist/{index-aykhzcnf.js → index-qj1sy4ep.js} +1 -1
  53. package/dist/{index-cxpcdswt.js → index-t65erk4z.js} +1 -1
  54. package/dist/{index-c5at3ykd.js → index-tsngj2b5.js} +22 -10
  55. package/dist/react/entity-cache.d.ts +24 -2
  56. package/dist/react/entity-cache.d.ts.map +1 -1
  57. package/dist/react/keyboard/hooks.d.ts +41 -10
  58. package/dist/react/keyboard/hooks.d.ts.map +1 -1
  59. package/dist/react/keyboard/layers.d.ts +27 -8
  60. package/dist/react/keyboard/layers.d.ts.map +1 -1
  61. package/dist/react/keyboard/modality.d.ts +21 -0
  62. package/dist/react/keyboard/modality.d.ts.map +1 -0
  63. package/dist/react/keyboard/refs.d.ts +13 -0
  64. package/dist/react/keyboard/refs.d.ts.map +1 -0
  65. package/dist/react/keyboard/testing.d.ts +80 -0
  66. package/dist/react/keyboard/testing.d.ts.map +1 -0
  67. package/dist/react/keyboard/zone.d.ts +60 -10
  68. package/dist/react/keyboard/zone.d.ts.map +1 -1
  69. package/dist/telegram/budget-transformer.d.ts +35 -0
  70. package/dist/telegram/budget-transformer.d.ts.map +1 -0
  71. package/dist/telegram/outbound-budget.d.ts +4 -2
  72. package/dist/telegram/outbound-budget.d.ts.map +1 -1
  73. package/llms/react-keyboard-testing.txt +24 -0
  74. package/llms/react-keyboard.txt +264 -4
  75. package/llms/react.txt +1 -1
  76. package/llms/server.3.txt +31 -0
  77. package/llms/stitchkit.2.txt +118 -0
  78. package/llms/stitchkit.txt +4 -118
  79. package/llms/telegram-grammy.txt +22 -0
  80. package/llms/telegram.txt +19 -7
  81. package/llms-full.txt +345 -12
  82. package/llms.txt +7 -5
  83. package/package.json +9 -1
  84. package/dist/{index-616xh7f2.js → index-4qnzda76.js} +0 -0
  85. package/dist/{index-fsw1ybf3.js → index-d9hq291n.js} +3 -3
  86. package/dist/{index-bjcjqj46.js → index-m8a44e64.js} +3 -3
  87. package/dist/{index-5efrpfmj.js → index-qndsr7f0.js} +3 -3
  88. package/dist/{index-zktz242w.js → index-s47ac8zt.js} +3 -3
  89. package/dist/{index-56yx8dxp.js → index-tvtacc9a.js} +3 -3
  90. /package/dist/{index-v1tswj0z.js → index-txg1npmg.js} +0 -0
package/llms/telegram.txt CHANGED
@@ -95,6 +95,7 @@ through it spends its weight. → [ADR 0274](../decisions/0274-a-telegram-token-
95
95
 
96
96
  ```ts
97
97
  import { telegramOutboundBudget } from 'stitchkit/telegram'
98
+ import { telegramBudgetTransformer } from 'stitchkit/telegram/grammy'
98
99
 
99
100
  const budget = telegramOutboundBudget({
100
101
  token: env.BOT_TOKEN,
@@ -103,11 +104,16 @@ const budget = telegramOutboundBudget({
103
104
 
104
105
  // The one transformer of the bot's client. No second limiter, no second
105
106
  // scheduler: campaigns, replies and every other send of this token share it.
106
- api.config.use((previous, method, payload, signal) =>
107
- budget.run(method, payload, signal, () => previous(method, payload, signal)),
108
- )
107
+ bot.api.config.use(telegramBudgetTransformer(budget))
109
108
  ```
110
109
 
110
+ `telegramBudgetTransformer` is the whole wiring for grammY. Without it the
111
+ adapter is `budget.run(method, payload, signal, () => previous(method, payload, signal))`
112
+ inside your own `config.use` closure; that works too, but grammY types its signal
113
+ as the `abort-controller` polyfill's, so the closure has to pass `undefined` or
114
+ cast, and a send cancelled in the queue then waits for its slot and fails as
115
+ `unknown`. The transformer takes grammY's signal as it is.
116
+
111
117
  - **The token is the identity, not the sender.** Two bot objects built from one
112
118
  token share the budget; two different tokens do not. The registry keeps a
113
119
  128-bit identity, never the credential, and `stats()` contains no token.
@@ -124,15 +130,21 @@ api.config.use((previous, method, payload, signal) =>
124
130
  - **It paces; it does not retry.** `run` calls the send once. A 429 pauses the
125
131
  token for Telegram's `retry_after` (or `retryAfterFallbackMs` when Telegram
126
132
  gave none) and rethrows; the caller's retry policy decides what happens next.
133
+ The refusal may be thrown or returned: a client transformer sits below the
134
+ place where grammY turns Telegram's `{ ok: false, error_code: 429, … }` body into
135
+ a `GrammyError`, so `run` reads that body too and hands it back unchanged.
127
136
  A 429 from an edit or a read is still a fact about the token and pauses later
128
137
  sends too. A network failure is rethrown unchanged and stays `unknown`; a 5xx
129
138
  is rethrown unchanged and stays `server-error`. Neither is retried by the
130
139
  budget or pauses the token — repeating a send whose outcome is unknown is a
131
140
  decision, not a primitive's silent guess.
132
141
  - **A wait can be cancelled.** `run`/`acquire` take an `AbortSignal`; a
133
- cancelled waiter spends no slot and calls no provider. A granted send that is
134
- then aborted is not evidence that Telegram did not accept it — keep the
135
- outcome unknown rather than retrying it as a definite failure.
142
+ cancelled waiter spends no slot and calls no provider. Through
143
+ `telegramBudgetTransformer` a send cancelled before it was handed to the client
144
+ throws `TelegramNotDispatchedError` (`classifyTelegramSendFailure` says
145
+ `not-dispatched`, safe to repeat); a send cancelled after it was handed on is
146
+ the client's own error — not evidence that Telegram did not accept it — so
147
+ keep the outcome unknown rather than retrying it as a definite failure.
136
148
  - **Non-send retries wait without spending.** An edit or delete retry that got
137
149
  a 429 first records the pause with `budget.pauseAfter(error)` (the `run` path
138
150
  does this itself), then uses `await budget.waitUntilResumed(signal)`; it shares
@@ -596,7 +608,7 @@ injected grammY bot. → ADR 0143, ADR 0201, ADR 0274 — [guide](../guide/teleg
596
608
  | `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) |
597
609
  | `TelegramNotDispatchedError` / `TelegramNotDispatchedStage` | class / _type_ | a request that provably never left (`lookup`, `connect` or `request`), so repeating it cannot duplicate a message |
598
610
  | `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 |
599
- | `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, `pause`/`pauseAfter` for 429, `waitUntilResumed` for non-send retries and `stats`; it paces and never retries, and no public value carries the token |
611
+ | `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 |
600
612
  | `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 |
601
613
  | `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` |
602
614
  | `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 |
package/llms-full.txt CHANGED
@@ -68,6 +68,7 @@ own, recorded as an ADR.
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
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 |
@@ -82,6 +83,7 @@ own, recorded as an ADR.
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) |
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 |
85
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 |
@@ -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.
@@ -15264,6 +15299,8 @@ key hears it first — a widget's own `onKeyDown`, a text field, a popover that
15264
15299
  closes on Escape — and keeps it with `preventDefault()` or `stopPropagation()`.
15265
15300
  A layer only gets what nobody nearer wanted, so third-party components keep
15266
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).
15267
15304
 
15268
15305
  ## Shortcuts and Escape
15269
15306
 
@@ -15343,7 +15380,7 @@ function ChatList({ chats, openId, open }) {
15343
15380
  | Option | Default | Meaning |
15344
15381
  |---|---|---|
15345
15382
  | `items`, `value`, `onChange` | — | the ids in order, the chosen one, and the report of a choice; the zone never holds the value |
15346
- | `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) |
15347
15384
  | `orientation` | `'vertical'` | which arrows move; the other axis enters and exits |
15348
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` |
15349
15386
  | `loop` | `false` | wrap past the ends instead of calling `onBoundary` |
@@ -15352,6 +15389,8 @@ function ChatList({ chats, openId, open }) {
15352
15389
  | `onBoundary(side)` | — | a move past `'start'` or `'end'`: hand focus to the neighbouring zone |
15353
15390
  | `active` | `true` | whether arrows pressed outside every zone land here |
15354
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) |
15355
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 |
15356
15395
 
15357
15396
  The callbacks return `false` to leave the key to the page, anything else takes
@@ -15364,6 +15403,256 @@ axis puts focus on the active zone's current item. To move between zones
15364
15403
  (sidebar → table), call `zone.focus(id?)` of the other zone from `onEnter` or
15365
15404
  `onBoundary`. `zone.element` is the zone's element, for a layer scoped to it.
15366
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
+
15367
15656
  ## What stays in the application
15368
15657
 
15369
15658
  The URL, the router, sections and which screen is open. A layer calls the
@@ -15542,6 +15831,7 @@ through it spends its weight. → [ADR 0274](../decisions/0274-a-telegram-token-
15542
15831
 
15543
15832
  ```ts
15544
15833
  import { telegramOutboundBudget } from 'stitchkit/telegram'
15834
+ import { telegramBudgetTransformer } from 'stitchkit/telegram/grammy'
15545
15835
 
15546
15836
  const budget = telegramOutboundBudget({
15547
15837
  token: env.BOT_TOKEN,
@@ -15550,11 +15840,16 @@ const budget = telegramOutboundBudget({
15550
15840
 
15551
15841
  // The one transformer of the bot's client. No second limiter, no second
15552
15842
  // scheduler: campaigns, replies and every other send of this token share it.
15553
- api.config.use((previous, method, payload, signal) =>
15554
- budget.run(method, payload, signal, () => previous(method, payload, signal)),
15555
- )
15843
+ bot.api.config.use(telegramBudgetTransformer(budget))
15556
15844
  ```
15557
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
+
15558
15853
  - **The token is the identity, not the sender.** Two bot objects built from one
15559
15854
  token share the budget; two different tokens do not. The registry keeps a
15560
15855
  128-bit identity, never the credential, and `stats()` contains no token.
@@ -15571,15 +15866,21 @@ api.config.use((previous, method, payload, signal) =>
15571
15866
  - **It paces; it does not retry.** `run` calls the send once. A 429 pauses the
15572
15867
  token for Telegram's `retry_after` (or `retryAfterFallbackMs` when Telegram
15573
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.
15574
15872
  A 429 from an edit or a read is still a fact about the token and pauses later
15575
15873
  sends too. A network failure is rethrown unchanged and stays `unknown`; a 5xx
15576
15874
  is rethrown unchanged and stays `server-error`. Neither is retried by the
15577
15875
  budget or pauses the token — repeating a send whose outcome is unknown is a
15578
15876
  decision, not a primitive's silent guess.
15579
15877
  - **A wait can be cancelled.** `run`/`acquire` take an `AbortSignal`; a
15580
- cancelled waiter spends no slot and calls no provider. A granted send that is
15581
- then aborted is not evidence that Telegram did not accept it — keep the
15582
- outcome unknown rather than retrying it as a definite failure.
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.
15583
15884
  - **Non-send retries wait without spending.** An edit or delete retry that got
15584
15885
  a 429 first records the pause with `budget.pauseAfter(error)` (the `run` path
15585
15886
  does this itself), then uses `await budget.waitUntilResumed(signal)`; it shares
@@ -23410,7 +23711,7 @@ injected grammY bot. → ADR 0143, ADR 0201, ADR 0274 — [guide](../guide/teleg
23410
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) |
23411
23712
  | `TelegramNotDispatchedError` / `TelegramNotDispatchedStage` | class / _type_ | a request that provably never left (`lookup`, `connect` or `request`), so repeating it cannot duplicate a message |
23412
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 |
23413
- | `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, `pause`/`pauseAfter` for 429, `waitUntilResumed` for non-send retries and `stats`; it paces and never retries, and no public value carries the token |
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 |
23414
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 |
23415
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` |
23416
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 |
@@ -23444,6 +23745,18 @@ injected grammY bot. → ADR 0143, ADR 0201, ADR 0274 — [guide](../guide/teleg
23444
23745
 
23445
23746
  ---
23446
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
+
23447
23760
  ## `stitchkit/telegram/html`
23448
23761
 
23449
23762
  Telegram's HTML parse mode as a tree: any markup in, markup Telegram accepts
@@ -23843,7 +24156,7 @@ Browser- and server-render-safe React data-layer helpers. Needs the
23843
24156
  | `EntityCacheHandlers` | _type_ | the `{ created, updated, deleted }` handlers it returns |
23844
24157
  | `EntityCacheEvent` | _type_ | discriminated created/updated/deleted input for dynamic cache keys |
23845
24158
  | `EntityCacheKey` | _type_ | static `QueryKey` or event-aware key factory |
23846
- | `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) |
23847
24160
  | `EntityCacheListShape` | _type_ | `array \| paginated \| infinite-array \| infinite-paginated` |
23848
24161
  | `DeletedPayload` | _type_ | a `deleted` event payload — the entity or a bare `{ id }` |
23849
24162
  | `CursorQueryConfig` | _type_ | config for `createCursorQuery` |
@@ -23882,13 +24195,19 @@ no component, no styles, no router — see the [keyboard guide](../guide/keyboar
23882
24195
  |--------|------|---------|
23883
24196
  | `useKeyLayer` | hook | put a layer on the window's one key stack — asked `overlay` → `local` → `route` → `zone` → `global`, innermost first within a kind |
23884
24197
  | `useEscapeLayer` | hook | an `overlay`, `local` or `route` layer for Escape: one press closes one level |
23885
- | `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 |
23886
24199
  | `nextZoneIndex` | function | where a move lands in a list of a given length: an index, `'before-start'` or `'after-end'` |
23887
24200
  | `isKeyFieldTarget` | function | whether a key was pressed in a field or a `data-local-keys` area, the test layers use |
23888
- | `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'` |
23889
24203
  | `KeyLayerKind` | _type_ | `'overlay' \| 'local' \| 'route' \| 'zone' \| 'global'` |
23890
24204
  | `EscapeLayerOptions` | _type_ | `kind`, `onEscape`, `active`, `scope` |
23891
- | `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 |
23892
24211
  | `NavZone` | _type_ | what `useNavZone` returns — `zoneProps`, `itemProps(id)`, `element`, `current`, `focus(id?)` |
23893
24212
  | `NavZoneProps` / `NavZoneItemProps` | _type_ | the props spread on the zone's element and on each item |
23894
24213
  | `NextZoneIndexInput` | _type_ | `length`, `index`, `move`, `loop` |
@@ -23897,6 +24216,20 @@ no component, no styles, no router — see the [keyboard guide](../guide/keyboar
23897
24216
  | `ZoneMove` | _type_ | `'next' \| 'previous' \| 'first' \| 'last'` |
23898
24217
  | `ZoneOrientation` | _type_ | `'vertical' \| 'horizontal'` |
23899
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
+
23900
24233
  ## `stitchkit/agent-runtime/sandbox`
23901
24234
 
23902
24235
  Optional server-only namespace sandbox. Exports:
package/llms.txt CHANGED
@@ -9,10 +9,11 @@ Build with stitchkit: define a contract once, then `implement` it and serve it (
9
9
  Load the slice for each entrypoint your code imports — `import … from 'stitchkit/server'` → `llms/server.txt`; the root `stitchkit` import → `llms/stitchkit.txt`. A slice holds that entrypoint's API reference section and the guide pages whose home it is; every guide lives in exactly one slice, and a slice that needs another's guide names it under "Also read". Each file is at most 50.0 KB; a longer one continues in numbered parts, each naming the next at its top. Moving across versions: load `llms/upgrading.txt` and the range slices it lists between your version and the target. `llms-full.txt` inlines everything; it is far larger than a context window, so do not read it whole.
10
10
 
11
11
  ## Entrypoint slices
12
- - **stitchkit**: [llms/stitchkit.txt](llms/stitchkit.txt) 47.6 KB → [llms/stitchkit.2.txt](llms/stitchkit.2.txt) 38.1 KB
12
+ - **stitchkit**: [llms/stitchkit.txt](llms/stitchkit.txt) 43.2 KB → [llms/stitchkit.2.txt](llms/stitchkit.2.txt) 43.1 KB
13
13
  - **stitchkit/live**: [llms/live.txt](llms/live.txt) 21.2 KB
14
- - **stitchkit/react**: [llms/react.txt](llms/react.txt) 8.6 KB
15
- - **stitchkit/react/keyboard**: [llms/react-keyboard.txt](llms/react-keyboard.txt) 9.2 KB
14
+ - **stitchkit/react**: [llms/react.txt](llms/react.txt) 8.7 KB
15
+ - **stitchkit/react/keyboard**: [llms/react-keyboard.txt](llms/react-keyboard.txt) 21.5 KB
16
+ - **stitchkit/react/keyboard/testing**: [llms/react-keyboard-testing.txt](llms/react-keyboard-testing.txt) 1.9 KB
16
17
  - **stitchkit/tools**: [llms/tools.txt](llms/tools.txt) 46.7 KB → [llms/tools.2.txt](llms/tools.2.txt) 30.4 KB → [llms/tools.3.txt](llms/tools.3.txt) 26.3 KB
17
18
  - **stitchkit/tools/mcp**: [llms/tools-mcp.txt](llms/tools-mcp.txt) 1.8 KB
18
19
  - **stitchkit/tools/contract**: [llms/tools-contract.txt](llms/tools-contract.txt) 0.2 KB
@@ -23,7 +24,7 @@ Load the slice for each entrypoint your code imports — `import … from 'stitc
23
24
  - **stitchkit/remote**: [llms/remote.txt](llms/remote.txt) 1.0 KB
24
25
  - **stitchkit/contract**: [llms/contract.txt](llms/contract.txt) 31.9 KB
25
26
  - **stitchkit/primitives**: [llms/primitives.txt](llms/primitives.txt) 11.7 KB
26
- - **stitchkit/server**: [llms/server.txt](llms/server.txt) 44.2 KB → [llms/server.2.txt](llms/server.2.txt) 20.5 KB → [llms/server.3.txt](llms/server.3.txt) 45.6 KB → [llms/server.4.txt](llms/server.4.txt) 38.6 KB → [llms/server.5.txt](llms/server.5.txt) 22.9 KB
27
+ - **stitchkit/server**: [llms/server.txt](llms/server.txt) 44.2 KB → [llms/server.2.txt](llms/server.2.txt) 20.5 KB → [llms/server.3.txt](llms/server.3.txt) 47.0 KB → [llms/server.4.txt](llms/server.4.txt) 38.6 KB → [llms/server.5.txt](llms/server.5.txt) 22.9 KB
27
28
  - **stitchkit/observability**: [llms/observability.txt](llms/observability.txt) 46.9 KB
28
29
  - **stitchkit/agent-runtime**: [llms/agent-runtime.txt](llms/agent-runtime.txt) 43.2 KB → [llms/agent-runtime.2.txt](llms/agent-runtime.2.txt) 45.9 KB → [llms/agent-runtime.3.txt](llms/agent-runtime.3.txt) 42.2 KB
29
30
  - **stitchkit/agent-runtime/testing**: [llms/agent-runtime-testing.txt](llms/agent-runtime-testing.txt) 2.6 KB
@@ -42,7 +43,8 @@ Load the slice for each entrypoint your code imports — `import … from 'stitc
42
43
  - **stitchkit/testing**: [llms/testing.txt](llms/testing.txt) 33.6 KB
43
44
  - **stitchkit/agent-runtime/sandbox**: [llms/agent-runtime-sandbox.txt](llms/agent-runtime-sandbox.txt) 8.3 KB
44
45
  - **stitchkit/files**: [llms/files.txt](llms/files.txt) 5.7 KB
45
- - **stitchkit/telegram**: [llms/telegram.txt](llms/telegram.txt) 40.6 KB
46
+ - **stitchkit/telegram**: [llms/telegram.txt](llms/telegram.txt) 41.6 KB
47
+ - **stitchkit/telegram/grammy**: [llms/telegram-grammy.txt](llms/telegram-grammy.txt) 1.2 KB
46
48
  - **stitchkit/telegram/html**: [llms/telegram-html.txt](llms/telegram-html.txt) 2.7 KB
47
49
  - **stitchkit/telegram/screens**: [llms/telegram-screens.txt](llms/telegram-screens.txt) 21.2 KB
48
50
  - **stitchkit/tracking**: [llms/tracking.txt](llms/tracking.txt) 18.1 KB
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.114.0",
3
+ "version": "0.114.1",
4
4
  "description": "Contract-first backend framework — one defineContract() into an HTTP API, MCP tools, AI-agent tools and a typed client. Bun and Node.",
5
5
  "keywords": [
6
6
  "bun",
@@ -73,6 +73,10 @@
73
73
  "types": "./dist/entrypoints/react/keyboard.d.ts",
74
74
  "import": "./dist/entrypoints/react/keyboard.js"
75
75
  },
76
+ "./react/keyboard/testing": {
77
+ "types": "./dist/entrypoints/react/keyboard/testing.d.ts",
78
+ "import": "./dist/entrypoints/react/keyboard/testing.js"
79
+ },
76
80
  "./tools": {
77
81
  "types": "./dist/entrypoints/tools.d.ts",
78
82
  "import": "./dist/entrypoints/tools.js"
@@ -193,6 +197,10 @@
193
197
  "types": "./dist/entrypoints/telegram.d.ts",
194
198
  "import": "./dist/entrypoints/telegram.js"
195
199
  },
200
+ "./telegram/grammy": {
201
+ "types": "./dist/entrypoints/telegram/grammy.d.ts",
202
+ "import": "./dist/entrypoints/telegram/grammy.js"
203
+ },
196
204
  "./telegram/html": {
197
205
  "types": "./dist/entrypoints/telegram/html.d.ts",
198
206
  "import": "./dist/entrypoints/telegram/html.js"
File without changes
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  resolveRouteParamsSchema
3
3
  } from "./index-m38ej7e5.js";
4
- import {
5
- assertPositiveSafeInteger
6
- } from "./index-0s4xebbg.js";
7
4
  import {
8
5
  isUnsafeKey
9
6
  } from "./index-zsarb5xp.js";
7
+ import {
8
+ assertPositiveSafeInteger
9
+ } from "./index-0s4xebbg.js";
10
10
  import {
11
11
  isRecord
12
12
  } from "./index-9jfdm5tg.js";