stitchkit 0.113.3 → 0.114.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +1 -1
  3. package/dist/browser/client-url.d.ts.map +1 -1
  4. package/dist/entrypoints/agent-runtime/coding-tools.js +1 -1
  5. package/dist/entrypoints/agent-runtime/harness.js +4 -4
  6. package/dist/entrypoints/agent-runtime/realtime.js +2 -2
  7. package/dist/entrypoints/agent-runtime/sandbox.js +3 -3
  8. package/dist/entrypoints/agent-runtime/sqlite/bun.js +3 -3
  9. package/dist/entrypoints/agent-runtime/sqlite/node.js +3 -3
  10. package/dist/entrypoints/agent-runtime/testing.js +4 -4
  11. package/dist/entrypoints/agent-runtime.js +12 -12
  12. package/dist/entrypoints/application/directory-inbox.js +3 -3
  13. package/dist/entrypoints/application.js +4 -4
  14. package/dist/entrypoints/cli/publish.js +1 -1
  15. package/dist/entrypoints/cli.js +4 -4
  16. package/dist/entrypoints/contract.js +2 -2
  17. package/dist/entrypoints/http.js +2 -2
  18. package/dist/entrypoints/index.js +5 -5
  19. package/dist/entrypoints/node.js +3 -3
  20. package/dist/entrypoints/observability.js +3 -3
  21. package/dist/entrypoints/react/keyboard/testing.d.ts +2 -0
  22. package/dist/entrypoints/react/keyboard/testing.d.ts.map +1 -0
  23. package/dist/entrypoints/react/keyboard/testing.js +127 -0
  24. package/dist/entrypoints/react/keyboard.d.ts +4 -2
  25. package/dist/entrypoints/react/keyboard.d.ts.map +1 -1
  26. package/dist/entrypoints/react/keyboard.js +83 -319
  27. package/dist/entrypoints/react.js +15 -3
  28. package/dist/entrypoints/realtime.js +1 -1
  29. package/dist/entrypoints/remote.js +1 -1
  30. package/dist/entrypoints/server.js +18 -18
  31. package/dist/entrypoints/telegram/grammy.d.ts +2 -0
  32. package/dist/entrypoints/telegram/grammy.d.ts.map +1 -0
  33. package/dist/entrypoints/telegram/grammy.js +50 -0
  34. package/dist/entrypoints/telegram/screens.js +1 -1
  35. package/dist/entrypoints/telegram.d.ts +1 -0
  36. package/dist/entrypoints/telegram.d.ts.map +1 -1
  37. package/dist/entrypoints/telegram.js +254 -8
  38. package/dist/entrypoints/testing.js +3 -3
  39. package/dist/entrypoints/tools/connections.js +10 -10
  40. package/dist/entrypoints/tools.js +19 -19
  41. package/dist/entrypoints/tracking.js +1 -1
  42. package/dist/{index-xvj9h8yh.js → index-61hve0ez.js} +15 -28
  43. package/dist/index-6j725ez5.js +25 -0
  44. package/dist/{index-1csp1hwm.js → index-6p3frkv5.js} +1 -1
  45. package/dist/{index-fy4av0hq.js → index-9bwcthxw.js} +1 -1
  46. package/dist/{index-523tq7y4.js → index-a3462dqf.js} +1 -1
  47. package/dist/{index-506kd458.js → index-aw6zahv1.js} +1 -1
  48. package/dist/{index-tpwbct8j.js → index-dc9ere5p.js} +1 -1
  49. package/dist/{index-erv5shjq.js → index-kdnfe0sr.js} +1 -1
  50. package/dist/{index-k7z3j3hr.js → index-km2qw407.js} +4 -4
  51. package/dist/{index-7fc0c2np.js → index-n3v695sb.js} +1 -1
  52. package/dist/index-p02zgk8x.js +351 -0
  53. package/dist/{index-vtann8bz.js → index-p0rf7sq9.js} +8 -8
  54. package/dist/{index-aykhzcnf.js → index-qj1sy4ep.js} +1 -1
  55. package/dist/{index-cxpcdswt.js → index-t65erk4z.js} +1 -1
  56. package/dist/{index-c5at3ykd.js → index-tsngj2b5.js} +22 -10
  57. package/dist/react/entity-cache.d.ts +24 -2
  58. package/dist/react/entity-cache.d.ts.map +1 -1
  59. package/dist/react/keyboard/hooks.d.ts +41 -10
  60. package/dist/react/keyboard/hooks.d.ts.map +1 -1
  61. package/dist/react/keyboard/layers.d.ts +27 -8
  62. package/dist/react/keyboard/layers.d.ts.map +1 -1
  63. package/dist/react/keyboard/modality.d.ts +21 -0
  64. package/dist/react/keyboard/modality.d.ts.map +1 -0
  65. package/dist/react/keyboard/refs.d.ts +13 -0
  66. package/dist/react/keyboard/refs.d.ts.map +1 -0
  67. package/dist/react/keyboard/testing.d.ts +80 -0
  68. package/dist/react/keyboard/testing.d.ts.map +1 -0
  69. package/dist/react/keyboard/zone.d.ts +60 -10
  70. package/dist/react/keyboard/zone.d.ts.map +1 -1
  71. package/dist/telegram/budget-transformer.d.ts +35 -0
  72. package/dist/telegram/budget-transformer.d.ts.map +1 -0
  73. package/dist/telegram/outbound-budget.d.ts +101 -0
  74. package/dist/telegram/outbound-budget.d.ts.map +1 -0
  75. package/dist/telegram/send-failure.d.ts +6 -1
  76. package/dist/telegram/send-failure.d.ts.map +1 -1
  77. package/llms/react-keyboard-testing.txt +24 -0
  78. package/llms/react-keyboard.txt +264 -4
  79. package/llms/react.txt +1 -1
  80. package/llms/server.3.txt +31 -0
  81. package/llms/server.4.txt +5 -0
  82. package/llms/stitchkit.2.txt +118 -0
  83. package/llms/stitchkit.txt +8 -122
  84. package/llms/telegram-grammy.txt +22 -0
  85. package/llms/telegram.txt +76 -2
  86. package/llms/{upgrading-0.103.13-0.113.0.txt → upgrading-0.104.0-0.114.0.txt} +45 -84
  87. package/llms/upgrading-0.37.0-0.44.0.txt +444 -0
  88. package/llms/{upgrading-0.44.0-0.60.0.txt → upgrading-0.44.0-0.61.0.txt} +140 -271
  89. package/llms/{upgrading-0.61.0-0.74.0.txt → upgrading-0.62.0-0.78.0.txt} +137 -140
  90. package/llms/{upgrading-0.75.0-0.103.0.txt → upgrading-0.79.0-0.103.13.txt} +84 -137
  91. package/llms/upgrading.txt +5 -5
  92. package/llms-full.txt +453 -11
  93. package/llms.txt +12 -10
  94. package/package.json +9 -1
  95. package/llms/upgrading-0.37.0.txt +0 -176
  96. package/dist/{index-616xh7f2.js → index-4qnzda76.js} +0 -0
  97. package/dist/{index-fsw1ybf3.js → index-d9hq291n.js} +3 -3
  98. package/dist/{index-bjcjqj46.js → index-m8a44e64.js} +3 -3
  99. package/dist/{index-5efrpfmj.js → index-qndsr7f0.js} +3 -3
  100. package/dist/{index-zktz242w.js → index-s47ac8zt.js} +3 -3
  101. package/dist/{index-56yx8dxp.js → index-tvtacc9a.js} +3 -3
  102. /package/dist/{index-v1tswj0z.js → index-txg1npmg.js} +0 -0
@@ -46,6 +46,8 @@ key hears it first — a widget's own `onKeyDown`, a text field, a popover that
46
46
  closes on Escape — and keeps it with `preventDefault()` or `stopPropagation()`.
47
47
  A layer only gets what nobody nearer wanted, so third-party components keep
48
48
  working unchanged.
49
+ One kind of layer may ask for the opposite: a menu over a text field takes the
50
+ arrows before the field does — see [A menu over a text field](#a-menu-over-a-text-field).
49
51
 
50
52
  ## Shortcuts and Escape
51
53
 
@@ -125,7 +127,7 @@ function ChatList({ chats, openId, open }) {
125
127
  | Option | Default | Meaning |
126
128
  |---|---|---|
127
129
  | `items`, `value`, `onChange` | — | the ids in order, the chosen one, and the report of a choice; the zone never holds the value |
128
- | `mode` | `'select'` | `'select'`: an arrow chooses (`onChange(id, 'arrow')`). `'highlight'`: an arrow only moves focus, Enter chooses (`onChange(id, 'confirm')`) |
130
+ | `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) |
129
131
  | `orientation` | `'vertical'` | which arrows move; the other axis enters and exits |
130
132
  | `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` |
131
133
  | `loop` | `false` | wrap past the ends instead of calling `onBoundary` |
@@ -134,6 +136,8 @@ function ChatList({ chats, openId, open }) {
134
136
  | `onBoundary(side)` | — | a move past `'start'` or `'end'`: hand focus to the neighbouring zone |
135
137
  | `active` | `true` | whether arrows pressed outside every zone land here |
136
138
  | `label` | — | the zone's accessible name |
139
+ | `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) |
140
+ | `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) |
137
141
  | `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 |
138
142
 
139
143
  The callbacks return `false` to leave the key to the page, anything else takes
@@ -146,6 +150,256 @@ axis puts focus on the active zone's current item. To move between zones
146
150
  (sidebar → table), call `zone.focus(id?)` of the other zone from `onEnter` or
147
151
  `onBoundary`. `zone.element` is the zone's element, for a layer scoped to it.
148
152
 
153
+ ## Links and buttons that choose for themselves
154
+
155
+ In `select` and `highlight` mode the zone reports a choice, and in `highlight` it takes
156
+ Enter for it. A list of `<a>` and `<button>` elements has a choice built in: Enter
157
+ clicks the focused one. Taking Enter would replace that with a handler you must
158
+ write to click the element yourself.
159
+
160
+ `mode: 'focus'` is for those lists — a menu, a toolbar, a row of tabs that are
161
+ links. The arrows move focus; Enter, and Space on a button, stay with the focused
162
+ element; the zone reports nothing, so there is no `onChange`. `value` is optional
163
+ and only marks the page you are on (`aria-current` with `role: 'none'`).
164
+
165
+ ```tsx
166
+ function Toolbar({ actions }) {
167
+ const zone = useNavZone({
168
+ items: actions.map((action) => action.id),
169
+ mode: 'focus',
170
+ role: 'none',
171
+ orientation: 'horizontal',
172
+ label: 'Formatting',
173
+ })
174
+ return (
175
+ <div {...zone.zoneProps}>
176
+ {actions.map((action) => (
177
+ <button key={action.id} type="button" onClick={action.run} {...zone.itemProps(action.id)}>
178
+ {action.label}
179
+ </button>
180
+ ))}
181
+ </div>
182
+ )
183
+ }
184
+ ```
185
+
186
+ In a focus zone Enter is never taken, even when `onEnter` is set: `onEnter` is
187
+ the cross-axis key (→ in a vertical zone).
188
+
189
+ ## Props, refs and other libraries
190
+
191
+ Spread the zone's props **last** on an element another library also configures, so
192
+ the roving `tabIndex` is the one that counts. A prop without a value is absent, not
193
+ `undefined`: `role` is there only for a listbox or a tablist, `aria-selected` only
194
+ outside `role: 'none'`, `aria-label` only with a `label`. A `role="button"` the
195
+ element carries from another library, or an `aria-label` you wrote before the
196
+ spread, survives it.
197
+
198
+ `ref` is the one prop two libraries both need. `mergeRefs` makes one callback of
199
+ several refs; spread the zone's props, then replace `ref`:
200
+
201
+ ```tsx
202
+ import { mergeRefs, useNavZone } from 'stitchkit/react/keyboard'
203
+
204
+ function Row({ id, zone }) {
205
+ const sortable = useSortable({ id })
206
+ const own = zone.itemProps(id)
207
+ return (
208
+ <li
209
+ {...sortable.attributes}
210
+ {...sortable.listeners}
211
+ {...own}
212
+ ref={mergeRefs(own.ref, sortable.setNodeRef)}
213
+ >
214
+ {id}
215
+ </li>
216
+ )
217
+ }
218
+ ```
219
+
220
+ The same goes for `zone.zoneProps.ref` beside a scroll container's or a measuring
221
+ hook's ref. `mergeRefs` returns a new function on every call and React re-attaches a
222
+ callback ref whose identity changed; a component that renders often keeps the
223
+ result in `useMemo`.
224
+
225
+ `useNavZone` also returns a new object on every render. An effect that should run
226
+ when a menu *opens* depends on that, not on `zone`; keep the latest zone in a ref.
227
+
228
+ ## A zone inside a drag-and-drop list
229
+
230
+ A sortable list is a zone whose rows can also be lifted with the keyboard: Space
231
+ lifts a row, the arrows move it, Space drops it. The arrows are then the drag's,
232
+ not the zone's, and a zone that also moves focus on them makes the focus ring jump
233
+ from row to row while the row moves.
234
+
235
+ `ignore` hands the zone a question about every key it would act on. dnd-kit marks
236
+ the lifted activator `aria-pressed="true"` for the length of the drag:
237
+
238
+ ```tsx
239
+ const zone = useNavZone({
240
+ items: order,
241
+ mode: 'focus',
242
+ role: 'none',
243
+ ignore: (event) =>
244
+ event.target instanceof Element && event.target.closest('[aria-pressed="true"]') !== null,
245
+ })
246
+ ```
247
+
248
+ The zone leaves an ignored key entirely: it does not move, does not take the key and
249
+ does not call `preventDefault`, whether the key came from inside the zone or from
250
+ a page whose focus is in no zone. The test is the application's on purpose. A
251
+ toggle button also carries `aria-pressed="true"`, and a toolbar of toggles must
252
+ keep its arrows; only the application knows whether a pressed element is a drag.
253
+
254
+ ## A menu or a popover opened by the pointer
255
+
256
+ A script's `focus()` shows the focus ring when the element that had focus before it
257
+ was showing one — and when nothing had. A menu opened by a right click, with focus
258
+ on `body`, would put a ring on its first item although nobody pressed a key. Decide
259
+ at the moment of opening: in the handler that opens the menu, before anything moves
260
+ focus, ask `openedByKeyboard()`.
261
+
262
+ ```tsx
263
+ const byKeyboard = useRef(false)
264
+ const container = useRef<HTMLDivElement>(null)
265
+
266
+ function show() {
267
+ byKeyboard.current = openedByKeyboard() // before the menu takes focus
268
+ setOpen(true)
269
+ }
270
+
271
+ useLayoutEffect(() => {
272
+ if (!open) return
273
+ if (byKeyboard.current) zone.focus() // the first item, with its ring
274
+ else container.current?.focus() // the menu itself
275
+ }, [open])
276
+ ```
277
+
278
+ `openedByKeyboard()` is whether the element with focus right now matches
279
+ `:focus-visible`; it is `false` on a server and in a browser without that selector.
280
+ Give the container `outline: none`: it only holds focus, and a browser may count a
281
+ focus that starts on `body` as keyboard focus. The first arrow with focus on the
282
+ zone's own element lands on the current item, not one step beyond it, so the menu
283
+ opened by a click is a normal menu from the first key.
284
+
285
+ A focus manager that also puts initial focus on opening fights this for the first
286
+ item. Floating UI's `FloatingFocusManager` documents `initialFocus={-1}` as "ignore
287
+ the initial focus" and `initialFocus={refs.floating}` as focusing the container;
288
+ either leaves the first item to the handler above. (This repository's browser probe
289
+ covers the pattern with a plain element, not Floating UI.)
290
+
291
+ ## The first arrow from outside a zone
292
+
293
+ With focus in no zone — right after a click on the page, or on the empty part of a
294
+ screen — the first arrow on an active zone's axis puts focus on its current item.
295
+ That is the safe answer: the person sees where they are before anything changes,
296
+ and a zone whose arrows choose does not change the choice on a key that only
297
+ announced the list.
298
+
299
+ A list that has always answered the first arrow with a change (a chat list whose
300
+ arrows open the next chat) can say so: `outside: 'move'` acts as if focus were
301
+ already on the current item, so the arrow moves from it and, in `select` mode,
302
+ calls `onChange` for the neighbour. It is opt-in and per zone; `ignore` is asked
303
+ first in both.
304
+
305
+ ## A panel that keeps its keys around a zone
306
+
307
+ `data-local-keys` marks an area whose keys belong to it rather than to the page:
308
+ layers skip keys pressed inside it, so a section switch on ← and → leaves a
309
+ dropdown panel alone. A zone inside such a panel still has its arrows. The zone is
310
+ the part of the area that handles them, so only a field *inside the zone* — an
311
+ input in one of its rows — keeps its keys from the zone; an area that contains the
312
+ zone, or the zone's own element carrying the mark, does not. ← and → are not a
313
+ vertical zone's keys, so they bubble on to the layers, which skip them in that
314
+ panel: the zone moves, the section does not.
315
+
316
+ ## A menu over a text field
317
+
318
+ A layer hears a key after everything closer to it, and a text field is as close as
319
+ it gets: the field acts on an arrow or Enter first. A menu that hangs over the
320
+ field — slash commands, quick replies — keeps focus in the field and needs those
321
+ keys *before* it does, and the field's own Enter (send the message) must not also
322
+ run.
323
+
324
+ `phase: 'capture'` is for that, and for a `local` layer only (any other kind
325
+ refuses it when it mounts): the layer is asked before the element that has focus,
326
+ and a key it takes — `preventDefault` and `stopPropagation` — reaches nothing
327
+ nearer.
328
+
329
+ ```tsx
330
+ function CommandMenu({ open, items, pick }) {
331
+ const menu = useRef<HTMLDivElement>(null)
332
+ const [index, setIndex] = useState(0)
333
+ useKeyLayer({
334
+ kind: 'local',
335
+ phase: 'capture',
336
+ fields: 'include', // the key is pressed in the field
337
+ scope: menu, // heard only while the menu is on the page
338
+ active: open,
339
+ onKey: (event) => {
340
+ if (event.key === 'ArrowDown') setIndex((i) => Math.min(i + 1, items.length - 1))
341
+ else if (event.key === 'ArrowUp') setIndex((i) => Math.max(i - 1, 0))
342
+ else if (event.key === 'Enter') pick(items[index])
343
+ else return false // typing, Backspace, everything else stays the field's
344
+ return true
345
+ },
346
+ })
347
+ return open ? <div ref={menu} role="listbox">…</div> : null
348
+ }
349
+ ```
350
+
351
+ `fields: 'include'` is needed because the default skips keys pressed in a field;
352
+ `scope` and `active` keep the layer from taking keys when there is no menu. An
353
+ `overlay` above still stops it: a live overlay is a barrier for layers of either
354
+ phase. In `createKeyboardTestBed`, `press` runs the capture phase first and
355
+ stops there when a layer took the key (`event.propagationStopped`).
356
+
357
+ ## Moving from a stack of zones of your own
358
+
359
+ A hand-written zone stack and `stitchkit/react/keyboard` name the same ideas
360
+ differently:
361
+
362
+ | Yours | Here |
363
+ |---|---|
364
+ | a flag that arrows choose at once (`instant`) | `mode: 'select'`; `'highlight'` when choosing is expensive |
365
+ | wrapping (often the default) | `loop` — `false` by default; set it on the lists that wrapped |
366
+ | the current section | `active` for which zone takes the first arrow, `scope` for which layer is live |
367
+ | one shared "highlighted id" | each zone's own focused item; read `zone.current` |
368
+ | an attribute that marks areas with their own keys | `data-local-keys` |
369
+ | Escape closing the innermost thing | `useEscapeLayer` — one Escape closes one level |
370
+
371
+ ## Testing without a DOM
372
+
373
+ `stitchkit/react/keyboard/testing` has no React and no DOM in it: a window that
374
+ remembers its listener, elements with a parent, `inert` and visibility, and key
375
+ events with `preventDefault`. A test adds the layers a screen declares, presses
376
+ keys on elements and reads who heard what.
377
+
378
+ ```ts
379
+ import { createKeyboardTestBed } from 'stitchkit/react/keyboard/testing'
380
+
381
+ test('Escape closes the dialog and not the page behind it', () => {
382
+ const bed = createKeyboardTestBed()
383
+ const heard: string[] = []
384
+ const page = bed.element()
385
+ const dialog = bed.element()
386
+ bed.stack.add({ kind: 'route', scope: () => page, onKey: () => (heard.push('page'), true) })
387
+ bed.stack.add({ kind: 'overlay', scope: () => dialog, onKey: () => (heard.push('dialog'), true) })
388
+ bed.press('Escape')
389
+ expect(heard).toEqual(['dialog'])
390
+ page.inert = true // the page behind a dialog
391
+ dialog.hidden = true
392
+ bed.press('Escape')
393
+ expect(heard).toEqual(['dialog'])
394
+ })
395
+ ```
396
+
397
+ `createNavZone` is the zone without React: build it with the items' fake elements
398
+ (`bed.element({ zone: true })` and its `child()` rows count `focus()` and scrolling),
399
+ call `zone.keyDown(bed.key('ArrowDown', { target: row }), zoneElement)`. A real
400
+ focus ring, scrolling and screen readers are a browser's: this is for the order of
401
+ layers, `scope`/`inert` and the edges of a zone.
402
+
149
403
  ## What stays in the application
150
404
 
151
405
  The URL, the router, sections and which screen is open. A layer calls the
@@ -168,13 +422,19 @@ no component, no styles, no router — see the [keyboard guide](../guide/keyboar
168
422
  |--------|------|---------|
169
423
  | `useKeyLayer` | hook | put a layer on the window's one key stack — asked `overlay` → `local` → `route` → `zone` → `global`, innermost first within a kind |
170
424
  | `useEscapeLayer` | hook | an `overlay`, `local` or `route` layer for Escape: one press closes one level |
171
- | `useNavZone` | hook | a list zone — roving tab stop, arrows move DOM focus, select or highlight mode, cross-axis enter/exit, boundaries |
425
+ | `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 |
172
426
  | `nextZoneIndex` | function | where a move lands in a list of a given length: an index, `'before-start'` or `'after-end'` |
173
427
  | `isKeyFieldTarget` | function | whether a key was pressed in a field or a `data-local-keys` area, the test layers use |
174
- | `KeyLayerOptions` | _type_ | `kind`, `onKey`, `active`, `scope`, `fields` |
428
+ | `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 |
429
+ | `KeyLayerPhase` | _type_ | `'bubble' \| 'capture'` |
175
430
  | `KeyLayerKind` | _type_ | `'overlay' \| 'local' \| 'route' \| 'zone' \| 'global'` |
176
431
  | `EscapeLayerOptions` | _type_ | `kind`, `onEscape`, `active`, `scope` |
177
- | `NavZoneOptions` | _type_ | `items`, `value`, `onChange`, `mode`, `orientation`, `role`, `loop`, `onEnter`, `onExit`, `onBoundary`, `active`, `label`, `onReveal` |
432
+ | `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` |
433
+ | `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 |
434
+ | `ZoneOptions` | _type_ | `NavZoneOptions` or `FocusZoneOptions` — what `useNavZone` takes |
435
+ | `ZoneKeyEvent` | _type_ | the key an `ignore` callback is asked about: `key`, modifiers, `target`, `repeat`, `isComposing`, `defaultPrevented` |
436
+ | `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)` |
437
+ | `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 |
178
438
  | `NavZone` | _type_ | what `useNavZone` returns — `zoneProps`, `itemProps(id)`, `element`, `current`, `focus(id?)` |
179
439
  | `NavZoneProps` / `NavZoneItemProps` | _type_ | the props spread on the zone's element and on each item |
180
440
  | `NextZoneIndexInput` | _type_ | `length`, `index`, `move`, `loop` |
package/llms/react.txt CHANGED
@@ -144,7 +144,7 @@ Browser- and server-render-safe React data-layer helpers. Needs the
144
144
  | `EntityCacheHandlers` | _type_ | the `{ created, updated, deleted }` handlers it returns |
145
145
  | `EntityCacheEvent` | _type_ | discriminated created/updated/deleted input for dynamic cache keys |
146
146
  | `EntityCacheKey` | _type_ | static `QueryKey` or event-aware key factory |
147
- | `EntityCacheListConfig` | _type_ | list shape, scoped key, insertion/missing-update policy and comparator |
147
+ | `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) |
148
148
  | `EntityCacheListShape` | _type_ | `array \| paginated \| infinite-array \| infinite-paginated` |
149
149
  | `DeletedPayload` | _type_ | a `deleted` event payload — the entity or a bare `{ id }` |
150
150
  | `CursorQueryConfig` | _type_ | config for `createCursorQuery` |
package/llms/server.3.txt CHANGED
@@ -868,6 +868,37 @@ deleted payload without guessing. The same resolved detail key drives the
868
868
  `isFresh` echo guard. Shape checks also leave neighbouring detail caches alone
869
869
  when a list key is intentionally used as a partial query-key prefix.
870
870
 
871
+ ### When only a new row needs an order
872
+
873
+ `compare` is the order the row itself determines: the array is sorted again after
874
+ every create and update, so a rename that moves a row moves it in the cache too.
875
+ Some lists are ordered by facts the row does not carry — the projects a person
876
+ owns first, then the ones they administer, then the rest by name, where the role
877
+ is not in the row for a platform administrator who belongs to none of them. No
878
+ `compare` can reproduce that, and sorting every row with a wrong one scrambles
879
+ the list the server ordered.
880
+
881
+ `insertCompare` answers a smaller question: where does a row that is *entering*
882
+ the list go?
883
+
884
+ ```ts
885
+ list: {
886
+ key: ['projects'],
887
+ shape: 'array',
888
+ createAt: 'end',
889
+ updateMissing: 'insert',
890
+ insertCompare: (incoming, existing) => incoming.name.localeCompare(existing.name),
891
+ }
892
+ ```
893
+
894
+ A new row goes before the first row it sorts ahead of; a tie goes by `createAt`
895
+ (`'start'`: before equal rows, `'end'`: after them). On a list already in that
896
+ order the result is the one `compare` would give. On a list that is not, no other
897
+ row moves, and a row that is already in the list is replaced where it stands.
898
+ A paged list places the row within the page `createAt` selects, and leaves the
899
+ other pages as they were. `compare` and `insertCompare` are two answers to one
900
+ question: a list takes one of them, and `createEntityCacheHandlers` refuses both.
901
+
871
902
  The helper deliberately does **not** flatten pages, infer filter semantics,
872
903
  derive a sort order or replace arbitrary `setQueryData` logic. Those remain
873
904
  declared application policies; Stitchkit applies their generic cache mechanics.
package/llms/server.4.txt CHANGED
@@ -391,6 +391,11 @@ and our payload is wrong, which is the case a list of substrings quietly counts
391
391
  against the user. An unrecognised refusal leaves the recipient reachable: losing
392
392
  a working subscriber forever costs more than one wasted send.
393
393
 
394
+ `bot-to-bot-disabled` is permanent but deliberately not `recipientUnreachable`:
395
+ Telegram returns it when either bot has not enabled Bot to Bot messaging, so the
396
+ missing setting may be the sender's. Do not retry it, and do not close the
397
+ recipient's row on it.
398
+
394
399
  `TELEGRAM_SEND_FAILURE_REASONS` lists every reason. A table keyed by
395
400
  `TelegramSendFailureReason` — error codes, the text a person reads — fails to
396
401
  compile when a release adds one, instead of falling through to a default:
@@ -16,6 +16,124 @@ Also read:
16
16
  # Guide: Typed client (docs/guide/client.md) (continued)
17
17
  ==============================================================================
18
18
 
19
+ ## Chunked uploads
20
+
21
+ A file larger than one request goes as three calls of the application's own
22
+ contract — open, parts, finish — driven by `uploadInChunks` on the client and
23
+ held by `createChunkSpool` (`stitchkit/files`) on the server. The contract stays
24
+ the application's: its fields, its limits, what happens to the finished file.
25
+
26
+ ```ts
27
+ // shared: one part size for both halves
28
+ export const CHUNK_BYTES = 4 * 1024 * 1024
29
+
30
+ // contract
31
+ init: {
32
+ method: 'POST', path: '/uploads', desc: 'Open an upload',
33
+ input: z.object({ uploadId: z.string(), totalBytes: z.number(), chunkCount: z.number(), name: z.string() }),
34
+ output: z.object({ finished: Accepted.optional() }),
35
+ },
36
+ chunk: {
37
+ method: 'PUT', path: '/uploads/:uploadId/chunks/:index', desc: 'Send one part',
38
+ params: z.object({ uploadId: z.string(), index: z.coerce.number() }),
39
+ multipart: { files: { bytes: {} } },
40
+ output: z.object({ state: z.enum(['stored', 'repeated']) }),
41
+ },
42
+ finalize: {
43
+ method: 'POST', path: '/uploads/:uploadId/finalize', desc: 'Assemble the file',
44
+ params: z.object({ uploadId: z.string() }),
45
+ output: Accepted,
46
+ },
47
+ ```
48
+
49
+ ```ts
50
+ // server
51
+ import { createChunkSpool } from 'stitchkit/files'
52
+
53
+ const spool = createChunkSpool({
54
+ directory: env.UPLOAD_SPOOL_DIR,
55
+ chunkBytes: CHUNK_BYTES,
56
+ maxFileBytes: 1024 * 1024 * 1024,
57
+ meta: z.object({ name: z.string() }),
58
+ })
59
+
60
+ implement(contract, {
61
+ init: async ({ input, ctx }) => {
62
+ const done = await findFinished(input.uploadId) // the app's own record
63
+ if (done) return { finished: done }
64
+ await spool.open({ owner: ctx.user.id, ...input, meta: { name: input.name } })
65
+ return {}
66
+ },
67
+ chunk: async ({ params, files, ctx }) => ({
68
+ state: await spool.put({ owner: ctx.user.id, ...params, bytes: files.bytes }),
69
+ }),
70
+ finalize: async ({ params, ctx }) => {
71
+ const key = { owner: ctx.user.id, uploadId: params.uploadId }
72
+ const file = await spool.assemble(key) // file.stream(), file.chunkPaths, file.meta
73
+ const accepted = await store(file)
74
+ await spool.discard(key)
75
+ return accepted
76
+ },
77
+ })
78
+ ```
79
+
80
+ ```ts
81
+ // client — Bun, Node, a browser or React Native
82
+ import { uploadInChunks } from 'stitchkit'
83
+
84
+ const accepted = await uploadInChunks({
85
+ file,
86
+ chunkBytes: CHUNK_BYTES,
87
+ signal,
88
+ onProgress: ({ sentBytes, totalBytes }) => draw(sentBytes / totalBytes),
89
+ init: async (start, options) => {
90
+ const opened = await api.uploads.init.withOptions({ ...start, name: file.name }, options)
91
+ return opened.finished ? { finished: opened.finished } : undefined
92
+ },
93
+ chunk: (part, options) => api.uploads.chunk.withOptions(part, options),
94
+ finalize: (upload, options) => api.uploads.finalize.withOptions(upload, options),
95
+ })
96
+ ```
97
+
98
+ The rules the two halves share:
99
+
100
+ - **The client mints the upload id** (default `crypto.randomUUID()`, fresh per
101
+ call), so a repeated `init` is the same upload. The spool keys it by `owner`
102
+ too — the same id from another owner is another upload. To resume after the
103
+ app restarts instead of sending the file again, pass a stable `uploadId` —
104
+ the id of the app's own job or attempt.
105
+ - **The file is anything with `size` and `slice(start, end)`** (`ChunkSource`).
106
+ A `Blob` is one; so is a platform file that is not a DOM `Blob`, such as an
107
+ Expo `File`, passed without a cast. A part's `bytes` is what `slice`
108
+ returns, so the part call hands it to the client as the multipart file.
109
+ - **A part is idempotent.** The spool keeps a size+sha256 receipt per index:
110
+ the same bytes again answer `'repeated'`, other bytes `UPLOAD_CONFLICT`
111
+ (409). So the driver repeats `init` and a part on a failure that may pass — no
112
+ answer (network, timeout), `429`, `5xx` — up to `retries` times (default 3)
113
+ with a doubling pause from `retryDelayMs` (default 1 s). A refusal is final.
114
+ - **`finalize` is never repeated.** After it the application owns the file and
115
+ the parts are gone; a lost answer is recovered by `init` returning
116
+ `{ finished }`, which the driver hands back without sending a byte.
117
+ - **Cancellation is checked between parts**, so a cancelled upload never
118
+ reaches `finalize`.
119
+ - **Every part but the last is exactly `chunkBytes`** and `chunkCount` must add
120
+ up; the spool refuses anything else with a 4xx `AppError`
121
+ (`ChunkSpoolErrorCode`), which a handler can let through as the client's
122
+ typed error. An application that maps error codes onto its own must map
123
+ these too — `isChunkSpoolErrorCode` / `CHUNK_SPOOL_ERROR_CODES` from
124
+ `stitchkit/files` — or a refusal reaches its client as an unknown 500.
125
+ - **Progress counts file bytes**, inside a part too — the driver passes
126
+ `onUploadProgress` into the part call, so the [upload
127
+ progress](#upload-progress) route of the client applies. A repeated part
128
+ counts again from its start.
129
+ - The first writer of a part wins across processes: its data file is named by
130
+ its hash and its receipt is published by an exclusive link. `sweep()` removes
131
+ uploads untouched for `staleAfterMs` (default 24 h); run it on a schedule.
132
+
133
+ Part size is a latency decision: every part costs a round trip, so on a
134
+ 200 ms link a smaller part buys smoother progress at the price of a slower
135
+ upload.
136
+
19
137
  ## Contract URL builders
20
138
 
21
139
  Browser-native consumers such as `<img src>`, downloads and navigation need a