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.
- package/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/dist/browser/client-url.d.ts.map +1 -1
- package/dist/entrypoints/agent-runtime/coding-tools.js +1 -1
- package/dist/entrypoints/agent-runtime/harness.js +4 -4
- package/dist/entrypoints/agent-runtime/realtime.js +2 -2
- package/dist/entrypoints/agent-runtime/sandbox.js +3 -3
- package/dist/entrypoints/agent-runtime/sqlite/bun.js +3 -3
- package/dist/entrypoints/agent-runtime/sqlite/node.js +3 -3
- package/dist/entrypoints/agent-runtime/testing.js +4 -4
- package/dist/entrypoints/agent-runtime.js +12 -12
- package/dist/entrypoints/application/directory-inbox.js +3 -3
- package/dist/entrypoints/application.js +4 -4
- package/dist/entrypoints/cli/publish.js +1 -1
- package/dist/entrypoints/cli.js +4 -4
- package/dist/entrypoints/contract.js +2 -2
- package/dist/entrypoints/http.js +2 -2
- package/dist/entrypoints/index.js +5 -5
- package/dist/entrypoints/node.js +3 -3
- package/dist/entrypoints/observability.js +3 -3
- package/dist/entrypoints/react/keyboard/testing.d.ts +2 -0
- package/dist/entrypoints/react/keyboard/testing.d.ts.map +1 -0
- package/dist/entrypoints/react/keyboard/testing.js +127 -0
- package/dist/entrypoints/react/keyboard.d.ts +4 -2
- package/dist/entrypoints/react/keyboard.d.ts.map +1 -1
- package/dist/entrypoints/react/keyboard.js +83 -319
- package/dist/entrypoints/react.js +15 -3
- package/dist/entrypoints/realtime.js +1 -1
- package/dist/entrypoints/remote.js +1 -1
- package/dist/entrypoints/server.js +18 -18
- package/dist/entrypoints/telegram/grammy.d.ts +2 -0
- package/dist/entrypoints/telegram/grammy.d.ts.map +1 -0
- package/dist/entrypoints/telegram/grammy.js +50 -0
- package/dist/entrypoints/telegram/screens.js +1 -1
- package/dist/entrypoints/telegram.js +16 -10
- package/dist/entrypoints/testing.js +3 -3
- package/dist/entrypoints/tools/connections.js +10 -10
- package/dist/entrypoints/tools.js +19 -19
- package/dist/entrypoints/tracking.js +1 -1
- package/dist/{index-hs35ss3p.js → index-61hve0ez.js} +4 -25
- package/dist/index-6j725ez5.js +25 -0
- package/dist/{index-1csp1hwm.js → index-6p3frkv5.js} +1 -1
- package/dist/{index-fy4av0hq.js → index-9bwcthxw.js} +1 -1
- package/dist/{index-523tq7y4.js → index-a3462dqf.js} +1 -1
- package/dist/{index-506kd458.js → index-aw6zahv1.js} +1 -1
- package/dist/{index-tpwbct8j.js → index-dc9ere5p.js} +1 -1
- package/dist/{index-erv5shjq.js → index-kdnfe0sr.js} +1 -1
- package/dist/{index-k7z3j3hr.js → index-km2qw407.js} +4 -4
- package/dist/{index-7fc0c2np.js → index-n3v695sb.js} +1 -1
- package/dist/index-p02zgk8x.js +351 -0
- package/dist/{index-vtann8bz.js → index-p0rf7sq9.js} +8 -8
- package/dist/{index-aykhzcnf.js → index-qj1sy4ep.js} +1 -1
- package/dist/{index-cxpcdswt.js → index-t65erk4z.js} +1 -1
- package/dist/{index-c5at3ykd.js → index-tsngj2b5.js} +22 -10
- package/dist/react/entity-cache.d.ts +24 -2
- package/dist/react/entity-cache.d.ts.map +1 -1
- package/dist/react/keyboard/hooks.d.ts +41 -10
- package/dist/react/keyboard/hooks.d.ts.map +1 -1
- package/dist/react/keyboard/layers.d.ts +27 -8
- package/dist/react/keyboard/layers.d.ts.map +1 -1
- package/dist/react/keyboard/modality.d.ts +21 -0
- package/dist/react/keyboard/modality.d.ts.map +1 -0
- package/dist/react/keyboard/refs.d.ts +13 -0
- package/dist/react/keyboard/refs.d.ts.map +1 -0
- package/dist/react/keyboard/testing.d.ts +80 -0
- package/dist/react/keyboard/testing.d.ts.map +1 -0
- package/dist/react/keyboard/zone.d.ts +60 -10
- package/dist/react/keyboard/zone.d.ts.map +1 -1
- package/dist/telegram/budget-transformer.d.ts +35 -0
- package/dist/telegram/budget-transformer.d.ts.map +1 -0
- package/dist/telegram/outbound-budget.d.ts +4 -2
- package/dist/telegram/outbound-budget.d.ts.map +1 -1
- package/llms/react-keyboard-testing.txt +24 -0
- package/llms/react-keyboard.txt +264 -4
- package/llms/react.txt +1 -1
- package/llms/server.3.txt +31 -0
- package/llms/stitchkit.2.txt +118 -0
- package/llms/stitchkit.txt +4 -118
- package/llms/telegram-grammy.txt +22 -0
- package/llms/telegram.txt +19 -7
- package/llms-full.txt +345 -12
- package/llms.txt +7 -5
- package/package.json +9 -1
- package/dist/{index-616xh7f2.js → index-4qnzda76.js} +0 -0
- package/dist/{index-fsw1ybf3.js → index-d9hq291n.js} +3 -3
- package/dist/{index-bjcjqj46.js → index-m8a44e64.js} +3 -3
- package/dist/{index-5efrpfmj.js → index-qndsr7f0.js} +3 -3
- package/dist/{index-zktz242w.js → index-s47ac8zt.js} +3 -3
- package/dist/{index-56yx8dxp.js → index-tvtacc9a.js} +3 -3
- /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((
|
|
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.
|
|
134
|
-
|
|
135
|
-
|
|
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((
|
|
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.
|
|
15581
|
-
|
|
15582
|
-
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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.
|
|
15
|
-
- **stitchkit/react/keyboard**: [llms/react-keyboard.txt](llms/react-keyboard.txt)
|
|
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)
|
|
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)
|
|
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.
|
|
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";
|