stitchkit 0.100.2 → 0.101.0

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 (242) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +2 -1
  3. package/dist/entrypoints/agent-runtime/browser.js +119 -119
  4. package/dist/entrypoints/agent-runtime/coding-tools.js +15 -20
  5. package/dist/entrypoints/agent-runtime/harness.js +55 -63
  6. package/dist/entrypoints/agent-runtime/openrouter.js +6 -6
  7. package/dist/entrypoints/agent-runtime/sandbox.js +20 -25
  8. package/dist/entrypoints/agent-runtime/sqlite/bun.js +11 -13
  9. package/dist/entrypoints/agent-runtime/sqlite/node.js +11 -13
  10. package/dist/entrypoints/agent-runtime/testing.js +32 -34
  11. package/dist/entrypoints/agent-runtime.js +395 -402
  12. package/dist/entrypoints/application/diagnostic-journal.js +26 -27
  13. package/dist/entrypoints/application/directory-inbox.js +16 -17
  14. package/dist/entrypoints/application/grammy.js +13 -13
  15. package/dist/entrypoints/application/opentelemetry.js +13 -13
  16. package/dist/entrypoints/application/schemas.js +24 -24
  17. package/dist/entrypoints/application.js +209 -210
  18. package/dist/entrypoints/bin/upgrade-cli.js +2 -2
  19. package/dist/entrypoints/cli.js +81 -84
  20. package/dist/entrypoints/contract.js +54 -57
  21. package/dist/entrypoints/declaration.js +22 -22
  22. package/dist/entrypoints/files.js +18 -21
  23. package/dist/entrypoints/geo.js +7 -8
  24. package/dist/entrypoints/google.js +2 -2
  25. package/dist/entrypoints/index.js +150 -154
  26. package/dist/entrypoints/live.js +45 -47
  27. package/dist/entrypoints/node.js +68 -74
  28. package/dist/entrypoints/oauth.js +6 -6
  29. package/dist/entrypoints/observability.d.ts +1 -0
  30. package/dist/entrypoints/observability.d.ts.map +1 -1
  31. package/dist/entrypoints/observability.js +116 -87
  32. package/dist/entrypoints/primitives.js +35 -35
  33. package/dist/entrypoints/react/keyboard.d.ts +4 -0
  34. package/dist/entrypoints/react/keyboard.d.ts.map +1 -0
  35. package/dist/entrypoints/react/keyboard.js +397 -0
  36. package/dist/entrypoints/react.js +10 -10
  37. package/dist/entrypoints/release.js +7 -7
  38. package/dist/entrypoints/remote.js +15 -17
  39. package/dist/entrypoints/server.js +198 -200
  40. package/dist/entrypoints/telegram/html.d.ts +5 -0
  41. package/dist/entrypoints/telegram/html.d.ts.map +1 -0
  42. package/dist/entrypoints/telegram/html.js +26 -0
  43. package/dist/entrypoints/telegram/screens.d.ts +14 -0
  44. package/dist/entrypoints/telegram/screens.d.ts.map +1 -0
  45. package/dist/entrypoints/telegram/screens.js +2028 -0
  46. package/dist/entrypoints/telegram.d.ts +11 -3
  47. package/dist/entrypoints/telegram.d.ts.map +1 -1
  48. package/dist/entrypoints/telegram.js +779 -159
  49. package/dist/entrypoints/testing.js +75 -79
  50. package/dist/entrypoints/tools/connections.js +30 -32
  51. package/dist/entrypoints/tools/contract.js +18 -20
  52. package/dist/entrypoints/tools/invoker.js +12 -17
  53. package/dist/entrypoints/tools.js +224 -229
  54. package/dist/entrypoints/tracking/server.js +6 -6
  55. package/dist/entrypoints/tracking.js +40 -42
  56. package/dist/entrypoints/voice.js +3 -3
  57. package/dist/{index-eaysmdtq.js → index-0ndk4555.js} +56 -56
  58. package/dist/{index-2bxbyab0.js → index-1bx1xv1k.js} +1 -1
  59. package/dist/{index-p5r7qpdd.js → index-1eeqpadm.js} +4 -4
  60. package/dist/{index-hadmp193.js → index-3ct7hhq6.js} +210 -210
  61. package/dist/{index-e01r054k.js → index-45tdvqa3.js} +26 -26
  62. package/dist/{index-dce5wxcq.js → index-4s0fep5q.js} +8 -8
  63. package/dist/index-50ejfz75.js +9 -0
  64. package/dist/{index-m9zn6cpq.js → index-5107q189.js} +12 -12
  65. package/dist/{index-sbdmyz75.js → index-5cc6r990.js} +3 -3
  66. package/dist/{index-c6bxbsnc.js → index-7kxw1awn.js} +2 -2
  67. package/dist/{index-0r0fbk7z.js → index-7z9ym7t8.js} +28 -28
  68. package/dist/{index-p7hgdyhb.js → index-814xpr6d.js} +2 -2
  69. package/dist/{index-gtj3zc53.js → index-8kzct1d6.js} +99 -99
  70. package/dist/{index-1zbbzgfh.js → index-9pw0a51a.js} +4 -4
  71. package/dist/{index-79z7m7dt.js → index-a9pd417t.js} +35 -35
  72. package/dist/{index-ham5sz8m.js → index-aaj97r4y.js} +45 -45
  73. package/dist/{index-s812ycat.js → index-aan6hqb9.js} +3 -3
  74. package/dist/{index-10x2hbx0.js → index-adhgd4pe.js} +44 -44
  75. package/dist/{index-2gxzjvyr.js → index-afjvjyck.js} +23 -23
  76. package/dist/{index-qmy6zq75.js → index-ahvsksw5.js} +16 -16
  77. package/dist/{index-hr4tzbwq.js → index-ap3gqe56.js} +3 -3
  78. package/dist/{index-znfs20ss.js → index-b2bz0kdm.js} +16 -16
  79. package/dist/{index-r066jx76.js → index-c6aagrbd.js} +148 -120
  80. package/dist/{index-k30h66yd.js → index-ck71tbcf.js} +9 -9
  81. package/dist/{index-eqqvgdb3.js → index-ct75c3at.js} +2 -2
  82. package/dist/{index-gw8bmyaj.js → index-djjhhgjk.js} +41 -41
  83. package/dist/{index-8qt2m66q.js → index-dvwj1f43.js} +43 -43
  84. package/dist/index-e05cjgtf.js +144 -0
  85. package/dist/{index-yw4fzmdz.js → index-e6c2spsd.js} +79 -30
  86. package/dist/{index-fhwqwxe0.js → index-ej331hah.js} +1 -1
  87. package/dist/{index-9t3dffv2.js → index-ewbaywk5.js} +5 -5
  88. package/dist/{index-7p1f0vp2.js → index-fyphpknh.js} +25 -25
  89. package/dist/{index-83bdg97s.js → index-g149e9pk.js} +1 -1
  90. package/dist/{index-ngfhvfpd.js → index-g6a3na27.js} +4 -4
  91. package/dist/{index-qwhynv6x.js → index-h124epcf.js} +20 -20
  92. package/dist/{index-h6eeys1v.js → index-h97tqqp1.js} +9 -9
  93. package/dist/{index-zcgf3gqf.js → index-hby4qhax.js} +10 -10
  94. package/dist/{index-5jrd0yng.js → index-j8aw2c0e.js} +4 -4
  95. package/dist/{index-d8m8zpd8.js → index-jzczzcsb.js} +14 -14
  96. package/dist/index-kqnd6a85.js +26 -0
  97. package/dist/{index-35mh5bhj.js → index-kw1ws0cs.js} +12 -12
  98. package/dist/{index-4gcnakhx.js → index-m38ej7e5.js} +2 -2
  99. package/dist/{index-ndddxtyx.js → index-m79yr938.js} +19 -19
  100. package/dist/{index-5p2m7we1.js → index-m9fndta6.js} +11 -11
  101. package/dist/index-mnkk1ffz.js +150 -0
  102. package/dist/index-n15jny5b.js +4 -0
  103. package/dist/{index-2vtcmrj9.js → index-n53mswt3.js} +20 -20
  104. package/dist/{index-bt14acb5.js → index-n5yqxd3d.js} +21 -21
  105. package/dist/{index-bm5rx7n6.js → index-n9tyz8z3.js} +12 -12
  106. package/dist/index-nmaqxdx7.js +463 -0
  107. package/dist/{index-mqv3b5rh.js → index-nx6eazt0.js} +30 -30
  108. package/dist/{index-3z73fh2c.js → index-q0th8q0z.js} +9 -9
  109. package/dist/{index-v260df89.js → index-qjeggp8w.js} +20 -20
  110. package/dist/{index-zfrhfn82.js → index-r1z0rd9c.js} +20 -20
  111. package/dist/{index-wqgjfstn.js → index-r5c4fedv.js} +3 -3
  112. package/dist/{index-cyktc4bp.js → index-r5hsgkz5.js} +118 -118
  113. package/dist/{index-0b1ess5e.js → index-r7a7r7ha.js} +1 -1
  114. package/dist/{index-1j8pm5t0.js → index-rfkh44ss.js} +15 -15
  115. package/dist/index-rz3z13e9.js +47 -0
  116. package/dist/{index-k6gpggb9.js → index-s80awwsw.js} +35 -35
  117. package/dist/{index-vqr7f7v8.js → index-ssmd41kh.js} +124 -124
  118. package/dist/index-sznqf8fx.js +20 -0
  119. package/dist/{index-ensd0r74.js → index-v14qqmys.js} +41 -41
  120. package/dist/{index-paz6sgdp.js → index-v4a2fjtk.js} +36 -36
  121. package/dist/{index-d9pk3td4.js → index-w0eqdhyd.js} +14 -14
  122. package/dist/{index-5qn1kx34.js → index-w575evhq.js} +29 -29
  123. package/dist/{index-3gye9wzb.js → index-w5s3xftz.js} +26 -26
  124. package/dist/{index-tm88avnj.js → index-wykebpn9.js} +3 -3
  125. package/dist/{index-sh3s86na.js → index-xkhk5d6g.js} +17 -17
  126. package/dist/{index-6k1937bx.js → index-xqhede6x.js} +4 -4
  127. package/dist/{index-f4fx2tza.js → index-zs4ms3th.js} +181 -181
  128. package/dist/{index-k5hsfc3j.js → index-zsvzhjtk.js} +1 -1
  129. package/dist/internal/secret-shapes.d.ts +21 -0
  130. package/dist/internal/secret-shapes.d.ts.map +1 -0
  131. package/dist/observability/bounded-logger.d.ts +2 -0
  132. package/dist/observability/bounded-logger.d.ts.map +1 -1
  133. package/dist/observability/sanitize.d.ts +6 -0
  134. package/dist/observability/sanitize.d.ts.map +1 -1
  135. package/dist/observability/secret-values.d.ts +25 -0
  136. package/dist/observability/secret-values.d.ts.map +1 -0
  137. package/dist/react/keyboard/hooks.d.ts +71 -0
  138. package/dist/react/keyboard/hooks.d.ts.map +1 -0
  139. package/dist/react/keyboard/layers.d.ts +98 -0
  140. package/dist/react/keyboard/layers.d.ts.map +1 -0
  141. package/dist/react/keyboard/zone.d.ts +139 -0
  142. package/dist/react/keyboard/zone.d.ts.map +1 -0
  143. package/dist/telegram/bot-api.d.ts +7 -4
  144. package/dist/telegram/bot-api.d.ts.map +1 -1
  145. package/dist/telegram/crypto.d.ts +16 -0
  146. package/dist/telegram/crypto.d.ts.map +1 -0
  147. package/dist/telegram/html/check.d.ts +26 -0
  148. package/dist/telegram/html/check.d.ts.map +1 -0
  149. package/dist/telegram/html/nodes.d.ts +70 -0
  150. package/dist/telegram/html/nodes.d.ts.map +1 -0
  151. package/dist/telegram/html/parse.d.ts +35 -0
  152. package/dist/telegram/html/parse.d.ts.map +1 -0
  153. package/dist/telegram/html/render.d.ts +39 -0
  154. package/dist/telegram/html/render.d.ts.map +1 -0
  155. package/dist/telegram/init-data.d.ts +3 -44
  156. package/dist/telegram/init-data.d.ts.map +1 -1
  157. package/dist/telegram/operator-channel.d.ts +19 -3
  158. package/dist/telegram/operator-channel.d.ts.map +1 -1
  159. package/dist/telegram/operator-dedupe.d.ts +44 -0
  160. package/dist/telegram/operator-dedupe.d.ts.map +1 -0
  161. package/dist/telegram/screens/callback-codec.d.ts +34 -0
  162. package/dist/telegram/screens/callback-codec.d.ts.map +1 -0
  163. package/dist/telegram/screens/dispatch.d.ts +22 -0
  164. package/dist/telegram/screens/dispatch.d.ts.map +1 -0
  165. package/dist/telegram/screens/display.d.ts +70 -0
  166. package/dist/telegram/screens/display.d.ts.map +1 -0
  167. package/dist/telegram/screens/execute.d.ts +57 -0
  168. package/dist/telegram/screens/execute.d.ts.map +1 -0
  169. package/dist/telegram/screens/html.d.ts +36 -0
  170. package/dist/telegram/screens/html.d.ts.map +1 -0
  171. package/dist/telegram/screens/outcome.d.ts +74 -0
  172. package/dist/telegram/screens/outcome.d.ts.map +1 -0
  173. package/dist/telegram/screens/path.d.ts +29 -0
  174. package/dist/telegram/screens/path.d.ts.map +1 -0
  175. package/dist/telegram/screens/reconcile.d.ts +67 -0
  176. package/dist/telegram/screens/reconcile.d.ts.map +1 -0
  177. package/dist/telegram/screens/registry.d.ts +23 -0
  178. package/dist/telegram/screens/registry.d.ts.map +1 -0
  179. package/dist/telegram/screens/render.d.ts +70 -0
  180. package/dist/telegram/screens/render.d.ts.map +1 -0
  181. package/dist/telegram/screens/runtime.d.ts +133 -0
  182. package/dist/telegram/screens/runtime.d.ts.map +1 -0
  183. package/dist/telegram/screens/screen-types.d.ts +130 -0
  184. package/dist/telegram/screens/screen-types.d.ts.map +1 -0
  185. package/dist/telegram/screens/screen.d.ts +138 -0
  186. package/dist/telegram/screens/screen.d.ts.map +1 -0
  187. package/dist/telegram/screens/state.d.ts +115 -0
  188. package/dist/telegram/screens/state.d.ts.map +1 -0
  189. package/dist/telegram/screens/telegram-screens.d.ts +18 -0
  190. package/dist/telegram/screens/telegram-screens.d.ts.map +1 -0
  191. package/dist/telegram/screens/test-chat-telegram.d.ts +47 -0
  192. package/dist/telegram/screens/test-chat-telegram.d.ts.map +1 -0
  193. package/dist/telegram/screens/test-chat.d.ts +66 -0
  194. package/dist/telegram/screens/test-chat.d.ts.map +1 -0
  195. package/dist/telegram/screens/view.d.ts +89 -0
  196. package/dist/telegram/screens/view.d.ts.map +1 -0
  197. package/dist/telegram/send-failure.d.ts +25 -0
  198. package/dist/telegram/send-failure.d.ts.map +1 -1
  199. package/dist/telegram/update-intake.d.ts +82 -0
  200. package/dist/telegram/update-intake.d.ts.map +1 -0
  201. package/dist/telegram/update-store-check.d.ts +22 -0
  202. package/dist/telegram/update-store-check.d.ts.map +1 -0
  203. package/dist/telegram/update-store-postgres.d.ts +33 -0
  204. package/dist/telegram/update-store-postgres.d.ts.map +1 -0
  205. package/dist/telegram/update-store-sqlite.d.ts +26 -0
  206. package/dist/telegram/update-store-sqlite.d.ts.map +1 -0
  207. package/dist/telegram/update-store.d.ts +67 -0
  208. package/dist/telegram/update-store.d.ts.map +1 -0
  209. package/dist/telegram/user.d.ts +51 -0
  210. package/dist/telegram/user.d.ts.map +1 -0
  211. package/dist/telegram/webhook.d.ts +105 -0
  212. package/dist/telegram/webhook.d.ts.map +1 -0
  213. package/dist/tools/cli/args-fields.d.ts +8 -0
  214. package/dist/tools/cli/args-fields.d.ts.map +1 -1
  215. package/dist/tools/cli/args-route.d.ts.map +1 -1
  216. package/dist/tools/cli/args.d.ts.map +1 -1
  217. package/dist/tools/cli/help.d.ts.map +1 -1
  218. package/llms/cli.txt +8 -2
  219. package/llms/observability.txt +15 -2
  220. package/llms/react-keyboard.txt +193 -0
  221. package/llms/server.4.txt +16 -0
  222. package/llms/stitchkit.txt +10 -4
  223. package/llms/telegram-html.txt +35 -0
  224. package/llms/telegram-screens.txt +365 -0
  225. package/llms/telegram.txt +245 -11
  226. package/llms/{upgrading-0.74.0-0.100.0.txt → upgrading-0.74.0-0.101.0.txt} +39 -3
  227. package/llms/upgrading.txt +1 -1
  228. package/llms-full.txt +889 -19
  229. package/llms.txt +12 -7
  230. package/package.json +17 -2
  231. package/dist/index-2k4yrqkc.js +0 -9
  232. package/dist/index-crykmqkr.js +0 -26
  233. package/dist/index-y64k3s0h.js +0 -150
  234. package/dist/index-y91ry9h2.js +0 -4
  235. /package/dist/{index-hffjpmq9.js → index-9jfdm5tg.js} +0 -0
  236. /package/dist/{index-zjjyynm3.js → index-d4pa2bgs.js} +0 -0
  237. /package/dist/{index-rxfy4cq7.js → index-dwfv9a85.js} +0 -0
  238. /package/dist/{index-5jm36zv1.js → index-gfk7nrht.js} +0 -0
  239. /package/dist/{index-b9aefrqd.js → index-n1dqr7gf.js} +0 -0
  240. /package/dist/{index-q3pyb9n0.js → index-sykb5ag7.js} +0 -0
  241. /package/dist/{index-48ffdxgk.js → index-xhzq2qvj.js} +0 -0
  242. /package/dist/{index-kzfs85xp.js → index-zsarb5xp.js} +0 -0
package/llms-full.txt CHANGED
@@ -60,7 +60,9 @@ own, recorded as an ADR.
60
60
  | `stitchkit/cli` | server | stable | `createCli` — the CLI transport, light (no MCP SDK / `ai`) |
61
61
  | `stitchkit/remote` | browser **and** server | stable | peer-free `implementRemote` for thin HTTP proxy processes |
62
62
  | `stitchkit/files` | server (Bun or Node) | stable | peer-free managed local-file boundary |
63
- | `stitchkit/telegram` | server (Bun or Node) | evolving | peer-free Telegram platform primitives — Mini App `initData` verification, send-failure classification, resumable broadcasts, the operator channel and local Bot API files |
63
+ | `stitchkit/telegram` | server (Bun or Node) | evolving | peer-free Telegram platform primitives — Mini App `initData` verification, send-failure classification, resumable broadcasts, the operator channel with dedupe, local Bot API files, webhook ownership and durable webhook update intake |
64
+ | `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 |
65
+ | `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 |
64
66
  | `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 |
65
67
  | `stitchkit/tracking/server` | server (Bun or Node) | evolving | the decisions a tracking backend makes — dispositions, visit lease over an application-owned store, active intervals, presence; no database |
66
68
  | `stitchkit/release` | browser **and** server | evolving | a page follows the release it was built for — `createReleaseMarker` on the server, `createReleaseWatcher` in the browser, the `X-Build-Id` header and a socket event between them |
@@ -68,11 +70,12 @@ own, recorded as an ADR.
68
70
  | `stitchkit/geo` | server (Bun or Node) | evolving | managed GeoIP reader generations, last-known-good reload and the optional MaxMind adapter |
69
71
  | `stitchkit/oauth` | browser **and** server | evolving | provider-neutral Authorization Code + PKCE request and one-shot callback transaction mechanics |
70
72
  | `stitchkit/google` | server (Bun or Node) | evolving | optional Google code exchange and verified OIDC identity adapter |
71
- | `stitchkit/observability` | server | stable<br>_redefined in 2 of the 45 minors since 0.56.2, most recently 0.92.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
73
+ | `stitchkit/observability` | server | stable<br>_redefined in 2 of the 46 minors since 0.56.2, most recently 0.92.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
72
74
  | `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, plus the managed-resource conformance kit |
73
75
  | `stitchkit/declaration` | browser + build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
74
76
  | `stitchkit/react` | browser + server rendering | stable | `createCursorQuery`, `createCacheBridge`, QueryClient and `ApiError` retry policy |
75
- | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 22 of the 45 minors since 0.56.2, most recently 0.97.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
77
+ | `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) |
78
+ | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 22 of the 46 minors since 0.56.2, most recently 0.97.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
76
79
  | `stitchkit/agent-runtime/sandbox` | server | evolving | optional Linux Bubblewrap sessions, durable workspaces and a host HTTP credential gateway; see [sandbox guide](sandbox.md) |
77
80
  | `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 |
78
81
  | `stitchkit/agent-runtime/harness` | server | evolving | resource-aware process-local facade over the canonical Agent runtime; supervision stays outside |
@@ -82,7 +85,7 @@ own, recorded as an ADR.
82
85
  | `stitchkit/agent-runtime/sqlite/bun` | server (Bun) | evolving | durable built-in SQLite store for the agent runtime |
83
86
  | `stitchkit/agent-runtime/sqlite/node` | server (Node ≥ 22.5) | evolving | durable built-in SQLite store for the agent runtime |
84
87
  | `stitchkit-tui` | terminal (Bun) | evolving | optional official OpenTUI host over a caller-composed headless runtime |
85
- | `stitchkit/application` | browser + server | evolving<br>_redefined in 10 of the 45 minors since 0.56.2, most recently 0.100.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
88
+ | `stitchkit/application` | browser + server | evolving<br>_redefined in 10 of the 46 minors since 0.56.2, most recently 0.100.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
86
89
  | `stitchkit/application/grammy` | server | evolving | a grammY bot as application resources — configuration, then polling with updates admitted by batch — and the webhook adapter |
87
90
  | `stitchkit/application/opentelemetry` | server | evolving | maps application snapshots onto an injected OpenTelemetry `Meter` |
88
91
  | `stitchkit/application/schemas` | browser + server | evolving | the application's snapshot, health and shutdown schemas alone, without the kernel |
@@ -194,6 +197,7 @@ map — feature → packages:
194
197
  | MCP host/client tests | `@modelcontextprotocol/client` |
195
198
  | MCP Apps UI widgets | `@modelcontextprotocol/ext-apps` |
196
199
  | React data layer (`stitchkit/react`) | `@tanstack/react-query` `react-query-kit` |
200
+ | Keyboard layers and list zones (`stitchkit/react/keyboard`) | `react` |
197
201
  | MaxMind GeoIP (`stitchkit/geo`) | `maxmind` |
198
202
  | Browser OAuth client (`stitchkit/oauth`) | — |
199
203
  | Google OIDC verifier (`stitchkit/google`) | `google-auth-library` |
@@ -202,6 +206,8 @@ map — feature → packages:
202
206
  | Socket.IO client | `socket.io-client` (runtime peer; unrelated root declarations remain peer-free) |
203
207
  | grammY lifecycle adapters (`stitchkit/application/grammy`) | `grammy` |
204
208
  | Telegram platform primitives (`stitchkit/telegram`) | — (peer-free) |
209
+ | Telegram message markup (`stitchkit/telegram/html`) | — (peer-free) |
210
+ | Telegram screens (`stitchkit/telegram/screens`) | `grammy` (types only; the bot brings it) |
205
211
  | OpenTelemetry gauges (`stitchkit/application/opentelemetry`) | `@opentelemetry/api` |
206
212
 
207
213
  ```bash
@@ -8168,12 +8174,18 @@ string, the schema says what it should be:
8168
8174
  | -------------------- | ---------------------------------------------- |
8169
8175
  | `z.string()` | `--name "box"` or a positional |
8170
8176
  | `z.number()` | `--count 3` → `3` |
8171
- | `z.boolean()` | `--active` (presence) / `--no-active` |
8177
+ | `z.boolean()` | `--active` (presence) / `--active false` / `--no-active` |
8172
8178
  | `z.enum([...])` | `--size large` |
8173
8179
  | `z.array(z.string())`| `--tag a --tag b` → `["a","b"]` |
8174
8180
  | `z.object({...})` | `--opts '{"k":"v"}'` (JSON) or `--opts.k v` |
8175
8181
  | `.optional()` / `.default()` | not required |
8176
8182
 
8183
+ After a bare boolean flag, the next token is its value when it is exactly
8184
+ `true` or `false` — `--active false` turns it off, as it reads. Any other word
8185
+ there stays a positional, and `--` makes a literal `false` one too. The same
8186
+ holds for a short alias, a framework flag such as `--json false` and an
8187
+ application option.
8188
+
8177
8189
  Without presentation configuration, positional arguments fill non-boolean
8178
8190
  fields in declaration order, so `myapp generate "a fox"` is
8179
8191
  `--prompt "a fox"`. A piped value fills the first required unset field:
@@ -8232,7 +8244,7 @@ framework globals may precede that explicit command. Top-level `--help`, `-h`
8232
8244
  and `--version` never execute the default, and top-level help marks it.
8233
8245
 
8234
8246
  Aliases are command-local, one ASCII letter and validated against the resolved
8235
- command schema. `-f` / `-f=false` are boolean forms; values accept `-n 100` and
8247
+ command schema. `-f` / `-f=false` / `-f false` are boolean forms; values accept `-n 100` and
8236
8248
  `-n=100`. Arrays accumulate across short and long forms. `-h` is reserved,
8237
8249
  bundles such as `-fn`, attached values such as `-n100`, `--no-f` and unknown
8238
8250
  short flags are rejected. Canonical `--no-follow` remains available.
@@ -10787,6 +10799,11 @@ Three things it does that a hand-written check usually does not:
10787
10799
  - `raw` keeps every signed pair, so a field Telegram adds after this release
10788
10800
  does not need a release to reach.
10789
10801
 
10802
+ The user arrives `camelCase` — `firstName`, `languageCode` — as `TelegramUser`.
10803
+ A bot meets the same person as grammY's `ctx.from`, in Telegram's `snake_case`;
10804
+ `parseTelegramUser(ctx.from)` gives the same `TelegramUser`, so one
10805
+ `ensureAccount(user)` serves the Mini App and the chat.
10806
+
10790
10807
  `maxAgeSeconds` is optional and there is no default: only the application knows
10791
10808
  how long its own session is worth. Omitting it means the string never expires,
10792
10809
  which is a decision rather than an oversight.
@@ -10816,6 +10833,17 @@ and our payload is wrong, which is the case a list of substrings quietly counts
10816
10833
  against the user. An unrecognised refusal leaves the recipient reachable: losing
10817
10834
  a working subscriber forever costs more than one wasted send.
10818
10835
 
10836
+ `TELEGRAM_SEND_FAILURE_REASONS` lists every reason. A table keyed by
10837
+ `TelegramSendFailureReason` — error codes, the text a person reads — fails to
10838
+ compile when a release adds one, instead of falling through to a default:
10839
+
10840
+ ```ts
10841
+ const codes: Record<TelegramSendFailureReason, string> = {
10842
+ 'blocked-by-user': 'E_BLOCKED',
10843
+ /* … every reason … */
10844
+ }
10845
+ ```
10846
+
10819
10847
  The rest of what a bot is assembled from — its resources, journal, operator
10820
10848
  channel, broadcasts and local Bot API files — is in [Telegram bots](telegram.md).
10821
10849
 
@@ -11882,6 +11910,18 @@ A payload goes into an audit row only after `sanitizePayload`:
11882
11910
  `tokenizer`) survive. This applies wherever a key exists — an object field and
11883
11911
  a `Map` entry alike, at any depth. A `Set` member has no key, so nothing there
11884
11912
  is masked by name;
11913
+ - **secrets are masked by their shape** in every string, whatever key holds
11914
+ it — the secret half of a Telegram bot token (`123456:[redacted]`: the bot's
11915
+ id stays, it tells two bots apart), the password in
11916
+ `postgresql://user:password@host` (user, host and path stay), and the value
11917
+ of a query parameter named as a secret (`?access_token=`, `?api-key=`,
11918
+ `?key=`, `?sig=`). A URL in an error message or a request log line is the
11919
+ usual way a token reaches a journal;
11920
+ - **an error keeps what it says about itself** — its own fields (`error_code`,
11921
+ `parameters`, a provider's `status`) are written beside `name`, `message`,
11922
+ `stack` and `cause`, masked by the same rules; a field holding a class
11923
+ instance other than an error (grammY's `BotError.ctx`) is named, not written,
11924
+ so one error cannot push its line past the bound and lose the stack;
11885
11925
  - **binary blobs** (`Uint8Array`, `Blob`, `FormData`) collapse to metadata —
11886
11926
  never the bytes;
11887
11927
  - the result is **capped** — anything over the byte limit becomes a preview.
@@ -12990,6 +13030,165 @@ session query with `cache`; the generic starter does not invent a `getSession`
12990
13030
  endpoint or cookie vocabulary.
12991
13031
 
12992
13032
 
13033
+ ==============================================================================
13034
+ # Guide: Keyboard layers and list zones (docs/guide/keyboard.md)
13035
+ ==============================================================================
13036
+
13037
+ ---
13038
+ title: Keyboard layers and list zones
13039
+ description: One listener decides which part of a screen gets a key; lists move real focus with the arrows
13040
+ type: guide
13041
+ status: active
13042
+ created: 2026-09-29
13043
+ updated: 2026-09-29
13044
+ ---
13045
+
13046
+ # Keyboard layers and list zones
13047
+
13048
+ `stitchkit/react/keyboard` answers one question for a React screen: **who gets
13049
+ this key?** It is headless — three hooks and no component, no styles, no
13050
+ router. The application keeps its URL, its sections and its markup; the hooks
13051
+ decide which part of the screen hears a key and move focus through lists.
13052
+
13053
+ ```bash
13054
+ bun add react # the only peer
13055
+ ```
13056
+
13057
+ ## Why layers
13058
+
13059
+ A screen with a menu, a dialog, a list and section shortcuts usually grows a
13060
+ `keydown` listener per concern, and they run in the order they mounted:
13061
+ Escape closes the dialog *and* the page behind it, an arrow in a dialog also
13062
+ switches the section underneath, a hidden panel still reacts. Here every
13063
+ concern is a layer on one stack, and the stack asks them in a fixed order until
13064
+ one takes the key:
13065
+
13066
+ | Kind | What it is | Example |
13067
+ |---|---|---|
13068
+ | `overlay` | something modal; while one is live nothing below hears any key | a dialog |
13069
+ | `local` | a component with the user's attention, not modal | an open command menu |
13070
+ | `route` | the page's "up one level" | Escape closes the open entity |
13071
+ | `zone` | a list the arrows move through (see below) | the chat list |
13072
+ | `global` | shortcuts of the whole screen | ← / → switch sections |
13073
+
13074
+ Within a kind the innermost layer wins: of two layers whose elements nest, the
13075
+ inner one, otherwise the one activated last. One press of Escape closes one
13076
+ level, and holding Escape does not close several.
13077
+
13078
+ The stack listens on the window, in the bubbling phase. Everything closer to the
13079
+ key hears it first — a widget's own `onKeyDown`, a text field, a popover that
13080
+ closes on Escape — and keeps it with `preventDefault()` or `stopPropagation()`.
13081
+ A layer only gets what nobody nearer wanted, so third-party components keep
13082
+ working unchanged.
13083
+
13084
+ ## Shortcuts and Escape
13085
+
13086
+ ```tsx
13087
+ import { useRef } from 'react'
13088
+ import { useEscapeLayer, useKeyLayer } from 'stitchkit/react/keyboard'
13089
+
13090
+ function Workspace({ next, previous, children }) {
13091
+ useKeyLayer({
13092
+ kind: 'global',
13093
+ onKey: (event) => {
13094
+ if (event.key === 'ArrowRight') next()
13095
+ else if (event.key === 'ArrowLeft') previous()
13096
+ else return false // not taken: the key keeps its default
13097
+ return true
13098
+ },
13099
+ })
13100
+ return children
13101
+ }
13102
+
13103
+ function EntityPanel({ close }) {
13104
+ const panel = useRef<HTMLElement>(null)
13105
+ useEscapeLayer({ kind: 'route', scope: panel, onEscape: close })
13106
+ return <section ref={panel}>…</section>
13107
+ }
13108
+ ```
13109
+
13110
+ `onKey` returns `true` when it took the key: its default is prevented and no
13111
+ layer below hears it. `onEscape` returns `false` when there was nothing to close,
13112
+ and the next layer is asked.
13113
+
13114
+ **`scope`** ties a layer to an element. A scoped layer is heard only while the
13115
+ element is mounted, visible and outside any `inert` subtree — a panel kept
13116
+ mounted in the background stops hearing keys without an `active` flag — and it
13117
+ outranks layers of its kind whose elements contain it. React runs a child's
13118
+ effects before its parent's, so when a page and the panel inside it mount
13119
+ together, only `scope` tells the stack which one is inside.
13120
+
13121
+ **`active`** (default `true`) switches a layer off without unmounting it;
13122
+ becoming active again puts it on top of its kind.
13123
+
13124
+ **Fields keep their keys.** Keys pressed in an input, a text area, a select, an
13125
+ editable region, a combobox, a slider or any area marked `data-local-keys` do not
13126
+ reach layers — except Escape, which a field that wants it keeps with
13127
+ `preventDefault()`. `fields: 'include'` lets a layer hear them anyway.
13128
+ `isKeyFieldTarget(event.target)` is the same test, for a handler of your own.
13129
+
13130
+ ## List zones
13131
+
13132
+ A zone is a list the arrows move through with **real focus**: one item is the
13133
+ tab stop (roving `tabIndex`), Tab enters the list at it and leaves the list, the
13134
+ arrows move DOM focus between items. Screen readers follow it, `:focus-visible`
13135
+ draws it, the browser scrolls to it.
13136
+
13137
+ ```tsx
13138
+ import { useNavZone } from 'stitchkit/react/keyboard'
13139
+
13140
+ function ChatList({ chats, openId, open }) {
13141
+ const zone = useNavZone({
13142
+ items: chats.map((chat) => chat.id),
13143
+ value: openId,
13144
+ onChange: (id) => open(id),
13145
+ label: 'Chats',
13146
+ })
13147
+ return (
13148
+ <ul {...zone.zoneProps}>
13149
+ {chats.map((chat) => (
13150
+ <li key={chat.id} {...zone.itemProps(chat.id)}>
13151
+ {chat.title}
13152
+ </li>
13153
+ ))}
13154
+ </ul>
13155
+ )
13156
+ }
13157
+ ```
13158
+
13159
+ | Option | Default | Meaning |
13160
+ |---|---|---|
13161
+ | `items`, `value`, `onChange` | — | the ids in order, the chosen one, and the report of a choice; the zone never holds the value |
13162
+ | `mode` | `'select'` | `'select'`: an arrow chooses (`onChange(id, 'arrow')`). `'highlight'`: an arrow only moves focus, Enter chooses (`onChange(id, 'confirm')`) |
13163
+ | `orientation` | `'vertical'` | which arrows move; the other axis enters and exits |
13164
+ | `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` |
13165
+ | `loop` | `false` | wrap past the ends instead of calling `onBoundary` |
13166
+ | `onEnter(id)` | — | → in a vertical zone (↓ in a horizontal one), and Enter in select mode: go into what the item opens |
13167
+ | `onExit()` | — | ← in a vertical zone (↑ in a horizontal one) |
13168
+ | `onBoundary(side)` | — | a move past `'start'` or `'end'`: hand focus to the neighbouring zone |
13169
+ | `active` | `true` | whether arrows pressed outside every zone land here |
13170
+ | `label` | — | the zone's accessible name |
13171
+ | `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 |
13172
+
13173
+ The callbacks return `false` to leave the key to the page, anything else takes
13174
+ it. A key the zone does not take bubbles on to the layers — in a vertical list
13175
+ ← and → are not the list's keys, so the section switch above hears them.
13176
+
13177
+ Each zone keeps its own focused item; two zones never share one. When focus is
13178
+ in no zone at all — right after the page loads — the first arrow on the zone's
13179
+ axis puts focus on the active zone's current item. To move between zones
13180
+ (sidebar → table), call `zone.focus(id?)` of the other zone from `onEnter` or
13181
+ `onBoundary`. `zone.element` is the zone's element, for a layer scoped to it.
13182
+
13183
+ ## What stays in the application
13184
+
13185
+ The URL, the router, sections and which screen is open. A layer calls the
13186
+ application's own functions — `router.push`, `close()` — and nothing here reads
13187
+ or writes a location. Keep entity coordinates in the path and filters in the
13188
+ query, parse and build them in one pure module with tests, and use explicit
13189
+ `push` / `replace` rather than a positional flag.
13190
+
13191
+
12993
13192
  ==============================================================================
12994
13193
  # Guide: Voice (docs/guide/voice.md)
12995
13194
  ==============================================================================
@@ -13088,6 +13287,8 @@ way: [ADR 0201](../decisions/0201-a-telegram-bot-is-assembled-from-primitives.md
13088
13287
  | Job | Where |
13089
13288
  |---|---|
13090
13289
  | the bot as resources: command menu, then long polling with updates admitted by batch | `grammyBotResources` — [application kernel](application-kernel.md#optional-grammy-adapter) |
13290
+ | a bot on a webhook: set only where it is this process's, updates recorded before Telegram is answered | `claimTelegramWebhook`, `createTelegramUpdateIntake`, `receiveTelegramWebhook` — [below](#a-bot-on-a-webhook) |
13291
+ | text for Telegram's HTML: cleaned, cut to the limit, checked | `stitchkit/telegram/html` — [below](#message-markup) |
13091
13292
  | the process journal | `createJsonLogger` from `stitchkit/observability` |
13092
13293
  | the operator's chat: product events, apart from the journal | `createTelegramOperatorChannel` |
13093
13294
  | a broadcast that survives crashes, deploys and Ctrl-C | `runTelegramBroadcast` |
@@ -13095,6 +13296,7 @@ way: [ADR 0201](../decisions/0201-a-telegram-bot-is-assembled-from-primitives.md
13095
13296
  | why a send was refused | `classifyTelegramSendFailure` — [auth and errors](auth-and-errors.md#telegram-mini-apps) |
13096
13297
  | Mini App `initData` | `verifyTelegramInitData` — [auth and errors](auth-and-errors.md#telegram-mini-apps) |
13097
13298
  | one Bot API call without a bot library | `callTelegramBotApi` |
13299
+ | menus: screens, buttons, actions, input and message editing | `stitchkit/telegram/screens` — [Telegram screens](telegram-screens.md) |
13098
13300
 
13099
13301
  ## The journal
13100
13302
 
@@ -13103,21 +13305,46 @@ output, in pino's shape — numeric `level` (`debug` 20, `info` 30, `warn` 40,
13103
13305
  `error` 50), `time` in epoch milliseconds, `msg` — so `pino-pretty` reads it on
13104
13306
  a terminal and a supervisor matching `"level":50` sees errors. Unlike pino, an
13105
13307
  `Error` under *any* key is written with its name, message, stack and cause; an
13106
- error logged as `{ error }` does not become `"error":{}`.
13308
+ error logged as `{ error }` does not become `"error":{}`, and its own fields —
13309
+ a `TelegramBotApiError`'s `error_code` and `parameters.retry_after` — are on the
13310
+ line beside them.
13311
+
13312
+ A bot token is masked wherever it appears — a request URL in an error message,
13313
+ a local Bot API file path — with no configuration: `123456:[redacted]` keeps the
13314
+ bot's id, which is public and is what tells two bots apart in one journal. So is
13315
+ the password in an address and a query parameter named as a secret (`?token=`).
13107
13316
 
13108
13317
  ```ts
13109
13318
  import { createJsonLogger } from 'stitchkit/observability'
13110
- import { TELEGRAM_BOT_TOKEN_PATTERN } from 'stitchkit/telegram'
13111
13319
 
13112
13320
  export const logger = createJsonLogger({
13113
13321
  level: env.LOG_LEVEL,
13114
13322
  fields: { service: 'my-bot' },
13115
- sensitiveUrlPatterns: [TELEGRAM_BOT_TOKEN_PATTERN],
13116
13323
  })
13117
13324
 
13118
13325
  logger.error('Application startup failed', { error })
13119
13326
  ```
13120
13327
 
13328
+ A provider's key, a webhook secret or a database password has no shape a
13329
+ pattern could guess, so the journal also masks exact values — and the
13330
+ environment already names them. `secretValuesFromEnv` collects every variable
13331
+ named as a token, secret, key, password or credential, the password and secret
13332
+ query values inside any URL variable (`DATABASE_URL`), and each URL-encoded:
13333
+
13334
+ ```ts
13335
+ import { createJsonLogger, secretValuesFromEnv } from 'stitchkit/observability'
13336
+
13337
+ export const logger = createJsonLogger({
13338
+ sensitiveValues: secretValuesFromEnv(process.env),
13339
+ })
13340
+ ```
13341
+
13342
+ A string of your own — a message for a person, a report — masks bot tokens with
13343
+ `redactTelegramBotToken(text)` from `stitchkit/telegram`.
13344
+
13345
+ A key added to the environment is masked from its first line; a URL's user name
13346
+ is left alone, since it names a role that appears in ordinary text.
13347
+
13121
13348
  The error line contract, for whatever recognises a failure from the journal: a
13122
13349
  line whose `level` is `50` or more is an error. There is no separate `fatal`;
13123
13350
  an unrecoverable end is an `error` line followed by a non-zero exit.
@@ -13146,8 +13373,46 @@ operators.post(`New user ${user.id}`, 'users')
13146
13373
 
13147
13374
  Every message is masked before it leaves: a bot token always — bots post their
13148
13375
  errors here, and an error about a local Bot API file carries the token in its
13149
- path — plus any `sensitivePatterns` the bot adds; `telegramOperatorSender` also
13150
- masks its own exact token. What to post stays the bot's. On the way down, drain it within the grace period
13376
+ path — plus any `sensitivePatterns` and exact `sensitiveValues` the bot adds;
13377
+ `telegramOperatorSender` also masks its own exact token, and with `parseMode:
13378
+ 'HTML'` repairs the markup — a text cut at Telegram's limit never leaves a tag
13379
+ open.
13380
+
13381
+ A failure that repeats a hundred times, or a storm of a hundred different ones
13382
+ when a database goes down, is a line with a count for the person reading, not a
13383
+ hundred messages. `dedupe` sends one message per fingerprint per window — the
13384
+ topic and the text with numbers, UUIDs and long identifiers blanked — and the
13385
+ next one of that kind carries how many were held back; past `maxPerWindow`
13386
+ messages in a window the rest are counted onto the next message sent:
13387
+
13388
+ ```ts
13389
+ const operators = createTelegramOperatorChannel<'errors'>({
13390
+ chatId: env.OPERATOR_CHAT_ID,
13391
+ send: telegramOperatorSender({ token: env.BOT_TOKEN }),
13392
+ sensitiveValues: secretValuesFromEnv(process.env),
13393
+ dedupe: {
13394
+ windowMs: 10 * 60_000,
13395
+ maxPerWindow: 20,
13396
+ repeatedLine: (count) => `Same again: ${count}`,
13397
+ },
13398
+ })
13399
+ ```
13400
+
13401
+ Held-back messages reach `onDropped` as `repeated` or `over-budget`.
13402
+
13403
+ A bot that already sends its alerts itself — its own bot instance, its own retry
13404
+ — takes the window alone instead of switching the channel off with parameters:
13405
+
13406
+ ```ts
13407
+ import { createTelegramOperatorDedupe } from 'stitchkit/telegram'
13408
+
13409
+ const admit = createTelegramOperatorDedupe<'errors'>({ windowMs: 10 * 60_000 })
13410
+
13411
+ const verdict = admit(text, 'errors')
13412
+ if (verdict.send) await sendAlert(verdict.text) // the text carries the held-back counts
13413
+ ```
13414
+
13415
+ What to post stays the bot's. On the way down, drain it within the grace period
13151
13416
  and close it: `drain: (context) => operators.drain(context.signal)`,
13152
13417
  `close: () => operators.close()` in a resource.
13153
13418
 
@@ -13191,6 +13456,155 @@ application (the application's signal, with admission held by the caller).
13191
13456
  The recipient state contains Telegram ids: keep it in the state directory, out
13192
13457
  of git.
13193
13458
 
13459
+ ## Message markup
13460
+
13461
+ `stitchkit/telegram/html` is Telegram's HTML parse mode as a tree, with no peer
13462
+ and no DOM, so a server sending a message and a browser previewing it share one
13463
+ parser. Text written by someone else — a model, an editor, an owner's greeting —
13464
+ becomes markup Telegram accepts: synonyms renamed (`strong` → `b`), block tags
13465
+ turned into line breaks, a link kept only with an `href` Telegram opens,
13466
+ anything Telegram forbids (a tag it does not know, formatting inside `code`, a
13467
+ quote inside a quote) left as its words.
13468
+
13469
+ ```ts
13470
+ import { sanitizeTelegramHtml, splitTelegramHtml } from 'stitchkit/telegram/html'
13471
+
13472
+ for (const part of splitTelegramHtml(summary)) {
13473
+ await ctx.reply(part, { parse_mode: 'HTML' })
13474
+ }
13475
+ ```
13476
+
13477
+ Telegram's limits — 4096 for a message, 1024 for a caption (`{ limit:
13478
+ TELEGRAM_CAPTION_LIMIT }`) — count the text after the markup is parsed, so a
13479
+ text is cut by what the reader sees, not by the length of its tags. A cut
13480
+ prefers a paragraph, then a line, then a space; it never splits a character or
13481
+ a custom emoji, and an element open across it is closed in one part and opened
13482
+ again, attributes and all, in the next. `truncateTelegramHtml` keeps one part
13483
+ with an ellipsis; `telegramHtmlText` is what the reader sees; `parseTelegramHtml`
13484
+ gives the nodes to render a preview or an email from. `checkTelegramHtml` says,
13485
+ without repairing anything, what Telegram would refuse — for an editor that
13486
+ should warn a person rather than change their text.
13487
+
13488
+ ## A bot on a webhook
13489
+
13490
+ A webhook is one place in all of Telegram, and `setWebhook` takes it without
13491
+ asking whose it was: a staging host or a developer's machine started with the
13492
+ production token takes the production bot with one call. `claimTelegramWebhook`
13493
+ sets it only where it already points here — address *and* secret — or where the
13494
+ bot names the current owner's host as the one it takes over from (`'none'` when
13495
+ there is no webhook: a bot on long polling is somebody's bot too). Telegram never
13496
+ returns the secret, so the address carries an `owner` tag derived from it; a
13497
+ copy of the production address with another secret is refused as
13498
+ `other-secret` instead of quietly installing its own.
13499
+
13500
+ ```ts
13501
+ import {
13502
+ checkTelegramWebhook,
13503
+ claimTelegramWebhook,
13504
+ createTelegramUpdateIntake,
13505
+ receiveTelegramWebhook,
13506
+ sqliteTelegramUpdateStore,
13507
+ } from 'stitchkit/telegram'
13508
+ import { Database } from 'bun:sqlite'
13509
+ import type { Update } from 'grammy/types'
13510
+
13511
+ const webhook = { token: env.BOT_TOKEN, url: env.WEBHOOK_URL, secret: env.WEBHOOK_SECRET }
13512
+
13513
+ const intake = createTelegramUpdateIntake<Update>({
13514
+ store: sqliteTelegramUpdateStore({ database: new Database(env.UPDATES_DB) }),
13515
+ handle: (update) => bot.handleUpdate(update),
13516
+ onFailure: (failure) => logger.warn('Update failed', { ...failure }),
13517
+ })
13518
+
13519
+ // start: set the webhook, then handle what was recorded before the restart
13520
+ await bot.init()
13521
+ await claimTelegramWebhook({ ...webhook, takeoverFrom: env.WEBHOOK_TAKEOVER_FROM })
13522
+ await intake.start()
13523
+
13524
+ // the route
13525
+ const route = (request: Request) =>
13526
+ receiveTelegramWebhook(request, { secret: webhook.secret, accept: intake.accept })
13527
+
13528
+ // stop admission: `await intake.close()` waits for the handlers in flight
13529
+ ```
13530
+
13531
+ A refusal names the owner's host, never its path — a foreign webhook's path may
13532
+ itself be a secret — and says what to pass: set the takeover for one start and
13533
+ remove it. `checkTelegramWebhook` on a timer notices a webhook that moved
13534
+ elsewhere, which this process cannot see any other way.
13535
+
13536
+ **Moving to it from a claim of your own.** A bot that tagged its webhook itself
13537
+ holds an address this one does not recognise, so the first claim is refused —
13538
+ `other-secret` when the address is the same, `owned-elsewhere` otherwise — and
13539
+ names its own host. Start once with `takeoverFrom` set to that host, then remove
13540
+ it. The tag is derived from the secret with a fixed context, so it is the same
13541
+ across stitchkit releases: that takeover is needed once per bot, not per
13542
+ upgrade. A secret rotation is a new owner, and takes a takeover too.
13543
+
13544
+ **The update is recorded before Telegram is answered.** A handler that runs
13545
+ longer than Telegram waits — a large file from a local Bot API server — gets the
13546
+ same update again while it still works, and a process that answers and then
13547
+ dies loses the update for good. `receiveTelegramWebhook` answers 200 once the
13548
+ intake recorded the body; the handler runs after, outside the request. Updates
13549
+ of one chat run in order, other chats alongside (`maxConcurrent`, 32). An
13550
+ attempt holds a lease it renews while the handler lives; a sweep takes over what
13551
+ was left — `pending` whose process never got to it (`pendingGraceMs`), `failed`
13552
+ whose retry is due, `processing` whose process died. A failure is retried with
13553
+ backoff or after Telegram's `retry_after`; one Telegram said repeating cannot
13554
+ fix (a refused message, a blocked user) is abandoned; `maxAttempts` (5) ends
13555
+ the rest. Delivery is at least once: a handler that finished but could not be
13556
+ recorded as finished runs again.
13557
+
13558
+ With grammY the handler is `bot.handleUpdate`, which wraps a middleware's error
13559
+ in a `BotError`; the intake unwraps it, so `retry`, `onFailure` and the error
13560
+ stored with the update are what the middleware threw — a `retry` that returns
13561
+ `false` for the bot's own terminal error sees that error.
13562
+
13563
+ The store is an interface of six atomic steps (`TelegramUpdateStore`), so the
13564
+ bot's own database holds it:
13565
+
13566
+ | Store | Takes |
13567
+ |---|---|
13568
+ | `sqliteTelegramUpdateStore` | a `bun:sqlite` `Database` or `node:sqlite` `DatabaseSync` as it is |
13569
+ | `postgresTelegramUpdateStore` | one `query(text, parameters)` over the client the bot already holds |
13570
+ | `memoryTelegramUpdateStore` | nothing — tests, or a bot that accepts losing unhandled updates to a restart |
13571
+
13572
+ ```ts
13573
+ import { postgresTelegramUpdateStore } from 'stitchkit/telegram'
13574
+
13575
+ // Bun
13576
+ postgresTelegramUpdateStore({ query: (text, parameters) => sql.unsafe(text, [...parameters]) })
13577
+ // Prisma
13578
+ postgresTelegramUpdateStore({
13579
+ query: (text, parameters) => prisma.$queryRawUnsafe(text, ...parameters),
13580
+ createTable: false, // the migration below owns the table
13581
+ })
13582
+ // pg
13583
+ postgresTelegramUpdateStore({
13584
+ query: (text, parameters) => pool.query(text, [...parameters]).then((result) => result.rows),
13585
+ })
13586
+ ```
13587
+
13588
+ Each rule is one conditional statement, so a claim is atomic across every
13589
+ process sharing the table. By default the table is created on first use; where
13590
+ migrations own the schema, `postgresTelegramUpdateStoreSchema(table)` is the
13591
+ statement to put in one.
13592
+
13593
+ A store of your own — over an ORM, another database — is put through the same
13594
+ rules the shipped ones are, concurrent claims included:
13595
+
13596
+ ```ts
13597
+ import { checkTelegramUpdateStore } from 'stitchkit/telegram'
13598
+
13599
+ test('our update store keeps the rules', async () => {
13600
+ expect(await checkTelegramUpdateStore(() => ourStore(freshTable()))).toEqual([])
13601
+ })
13602
+ ```
13603
+
13604
+ A store that reads and then writes in two statements passes every test with one
13605
+ process and runs one update twice in production; the check races eight claims
13606
+ against each other to catch it.
13607
+
13194
13608
  ## Files from a local Bot API server
13195
13609
 
13196
13610
  A local `telegram-bot-api` keeps downloaded files under `<dir>/<bot token>/`,
@@ -13221,6 +13635,327 @@ throws on `{ ready: false }` in `start` keeps the bot from answering before it
13221
13635
  can read its media.
13222
13636
 
13223
13637
 
13638
+ ==============================================================================
13639
+ # Guide: Telegram screens (docs/guide/telegram-screens.md)
13640
+ ==============================================================================
13641
+
13642
+ # Telegram screens
13643
+
13644
+ `stitchkit/telegram/screens` turns a bot's menus into declared screens. A screen
13645
+ says what the chat should show — a message or several, with their buttons — and
13646
+ the runtime decides what to send, what to edit in place and what to delete. It
13647
+ is grammY middleware: it answers the presses and messages that belong to a
13648
+ screen and hands every other update to the next handler. Why it is shaped this
13649
+ way: [ADR 0211](../decisions/0211-telegram-screens-are-declared-views-over-grammy.md).
13650
+
13651
+ ```bash
13652
+ bun add grammy zod
13653
+ ```
13654
+
13655
+ grammY is the bot's own; screens use its `ctx.api` and name its types, and
13656
+ nothing imports grammY at run time.
13657
+
13658
+ ## A menu, end to end
13659
+
13660
+ A bot that lets its owner manage projects: a list, a project's page, a
13661
+ two-message greeting screen and a screen that waits for the new greeting.
13662
+
13663
+ ```ts
13664
+ import { back, html, link, telegramScreens } from 'stitchkit/telegram/screens'
13665
+ import { z } from 'zod'
13666
+
13667
+ export const tg = telegramScreens<BotContext>()
13668
+
13669
+ export const home = tg.screen('/')
13670
+ .load((c) => listProjects(c.ctx.from?.id))
13671
+ .view(({ data: projects }) => ({
13672
+ text: html`<b>Your projects</b>`,
13673
+ keyboard: [
13674
+ ...projects.map((project) => [link(project.name, projectPage, { projectId: project.id })]),
13675
+ [link('➕ New project', newProject)],
13676
+ ],
13677
+ }))
13678
+
13679
+ // A group shares a path prefix, a params schema and a load. The load runs
13680
+ // before every screen under it — its load, its actions, its input — and is
13681
+ // where access is checked: params arrive from a button, so they are input.
13682
+ const owned = tg.group('/project/:projectId', { params: z.object({ projectId: z.uuid() }) })
13683
+ .load(async (c) => {
13684
+ const project = await findProject(c.params.projectId)
13685
+ return project?.ownerId === c.ctx.from?.id ? project : c.go(home).toast('Project not found')
13686
+ })
13687
+
13688
+ export const projectPage = owned.screen('/')
13689
+ .action('archive', async (c) => {
13690
+ await archiveProject(c.data.id)
13691
+ return c.toast('Archived').go(home)
13692
+ })
13693
+ .view(({ data: project, params, act }) => ({
13694
+ text: html`📁 <b>${project.name}</b>\n\nStatus: ${project.status}`,
13695
+ keyboard: [
13696
+ [link('👋 Greeting', greeting, params)],
13697
+ project.status === 'active' && [act.archive('🗄 Archive')],
13698
+ [back('« Back')],
13699
+ ],
13700
+ }))
13701
+
13702
+ export const greeting = owned.screen('/greeting')
13703
+ .action('clear', (c) => clearGreeting(c.data.id)) // nothing returned: stay, redrawn
13704
+ .action('rule', z.object({ every: z.boolean() }), (c) => setRule(c.data.id, c.input.every))
13705
+ .view(({ data: project, params, act }) => project.greeting
13706
+ ? [
13707
+ { key: 'header', text: html`<b>New members receive ↓</b>` },
13708
+ {
13709
+ text: project.greeting, // a plain string is escaped
13710
+ keyboard: [
13711
+ [link('✏️ Edit', greetingEdit, params), act.clear('🗑 Clear')],
13712
+ [act.rule(project.every ? 'Every message' : 'First message only', { every: !project.every })],
13713
+ [back('« Back')],
13714
+ ],
13715
+ },
13716
+ ]
13717
+ : { text: html`🗑 <b>No greeting.</b>`, keyboard: [[back('« Back')]] })
13718
+
13719
+ export const greetingEdit = owned.screen('/greeting/edit')
13720
+ .on('text', async (c) => {
13721
+ if (c.text.length < 3) return c.notice(html`❌ Too short`, { expiresInMs: 5_000 })
13722
+ await c.pending('⏳')
13723
+ await saveGreeting(c.data.id, c.text)
13724
+ return c.notice(html`<blockquote>✅ Greeting saved</blockquote>`).back()
13725
+ })
13726
+ .view(() => ({ text: html`📝 Send the new greeting ↓`, keyboard: [[back('« Back')]] }))
13727
+ ```
13728
+
13729
+ ```ts
13730
+ // bot.ts
13731
+ const screens = tg.create({
13732
+ screens: [home, projectPage, greeting, greetingEdit],
13733
+ storage: myStorageAdapter, // any grammY StorageAdapter
13734
+ })
13735
+ bot.use(screens)
13736
+ bot.command('start', (ctx) => screens.open(ctx, home))
13737
+ bot.catch((error) => logger.error('update failed', { error }))
13738
+ ```
13739
+
13740
+ ## The model
13741
+
13742
+ - **A screen** is a path, an optional `load`, any number of `action` and `on`,
13743
+ and a `view`. `load` comes first or not at all — actions and input read what
13744
+ it returned — and `view` ends the declaration.
13745
+ - **Params** come from the path literal: `/project/:projectId` gives
13746
+ `params.projectId`. A group's or screen's `params` schema parses them (a
13747
+ `z.coerce.number()` makes a number). The schema must accept what it produces,
13748
+ because a button carries the parsed value and the schema parses it again when
13749
+ it is pressed: `z.coerce.number()` does, `z.string().transform(Number)` does
13750
+ not and is a compile error. A `link` to a screen whose path has params must
13751
+ carry them; a link to a screen without params takes none — both are compile
13752
+ errors.
13753
+ - **The parent** of a screen is the nearest declared screen whose path is a
13754
+ prefix of its own, by segments. `back()` and the `back('« Back')` button go
13755
+ there. There is no history stack: navigation is a tree of paths, so it
13756
+ survives a restart and never grows.
13757
+ - **`load`** returns data, or an outcome instead of data — `c.go(home).toast('…')`
13758
+ shows another screen. A chain of such redirects stops after four steps.
13759
+ - **`view`** is a pure function of `ctx`, `params`, `data` and `act`. It returns
13760
+ one message or several. A message has exactly one content — `text`, `rich`
13761
+ (a Bot API rich message), or a media file id or URL (`photo`, `video`,
13762
+ `animation`, `document`, `audio`) with an optional `caption` — plus
13763
+ `keyboard`, `key` and, for text, `linkPreview`. Rows and buttons may be
13764
+ `false`, `null` or `undefined`, so a conditional button is `cond && [...]`.
13765
+ - **Text** is a string, which is always escaped, or a `TelegramHtml` from the
13766
+ `html` tag, whose interpolations are escaped. `html.raw(markup)` marks markup
13767
+ that is already safe and `html.join(parts, separator)` joins pieces. Every
13768
+ message is sent with `parse_mode: 'HTML'` set explicitly, so an application's
13769
+ own `parse_mode` transformer changes nothing.
13770
+
13771
+ ## Outcomes
13772
+
13773
+ A load, an action and an input handler answer with an outcome, built by
13774
+ chaining; returning nothing is `stay()`.
13775
+
13776
+ | Builder | Does |
13777
+ |---|---|
13778
+ | `c.stay()` | show this screen again, with fresh data |
13779
+ | `c.go(screen, params?)` | show another screen |
13780
+ | `c.back()` | show the parent screen |
13781
+ | `c.toast(text, { alert? })` | answer the press — up to 200 characters; the last toast wins |
13782
+ | `c.notice(content)` | a receipt that stays: posted, and the screen moves below it |
13783
+ | `c.notice(content, { expiresInMs })` | a remark under the screen that removes itself — `expiresInMs` up to 2³¹ − 1, a timer's limit |
13784
+
13785
+ A remark is removed when its time comes, on the chat's next update after that,
13786
+ before anything new is sent into the chat and when the chat leaves its screen —
13787
+ so a screen is never split by a remark. A chat keeps at most 100 remarks; the
13788
+ oldest is removed to make room for the next. After a message there is no press
13789
+ to answer, so an input handler has no `toast`.
13790
+
13791
+ ## What the chat shows: reconciliation
13792
+
13793
+ Each message of a view has a key: `key` when given, otherwise `'main'` for the
13794
+ last message and `s0`, `s1`, … for the ones before it. Between two views,
13795
+ messages with the same key are the same message, and only what changed is
13796
+ edited:
13797
+
13798
+ | From → to | Call |
13799
+ |---|---|
13800
+ | nothing changed | none |
13801
+ | only the keyboard | `editMessageReplyMarkup` |
13802
+ | text → text, rich → rich | `editMessageText` |
13803
+ | text or rich → media, media → media | `editMessageMedia` |
13804
+ | a caption | `editMessageCaption` |
13805
+ | media → text or rich, text ↔ rich | delete and send |
13806
+
13807
+ Order holds: once a message has to be sent, every message after it is sent
13808
+ too, and a message is deleted only after its replacement is in the chat. So
13809
+ "header + body" → "body" edits the body in place and deletes the header, and
13810
+ the reverse sends both and deletes the old body.
13811
+
13812
+ When Telegram refuses a step:
13813
+
13814
+ - **"message is not modified"** is success.
13815
+ - **A message gone or not editable** — deleted by the user, too old — is sent
13816
+ again with every message after it.
13817
+ - **A deletion refused** (Telegram deletes only messages younger than 48 hours)
13818
+ strips the message's buttons instead and reports it as `message-kept`. A
13819
+ message the user already deleted counts as deleted.
13820
+ - **Anything else** — a rate limit, a network failure, HTML Telegram cannot
13821
+ parse — stops the update, and the transition does not happen: the record stays
13822
+ on the screen the chat was on, so its input still answers, and lists every
13823
+ message the chat now shows, in the chat's order, with the buttons of both
13824
+ renders. The next update repairs the rest. A receipt or a remark Telegram
13825
+ refuses stops the update the same way, with the view already drawn recorded.
13826
+
13827
+ A view is checked before anything is sent: at most 100 messages, keys of 1 to
13828
+ 64 characters, at most 100 buttons whose address is kept in the record. A view
13829
+ past these fails in the handler's place, with the chat untouched.
13830
+
13831
+ ## Buttons and presses
13832
+
13833
+ A screen button's `callback_data` is
13834
+ `‹prefix›‹screen id›[.‹action›](:‹param›)*(:‹key›=‹value›)*`. Values are typed,
13835
+ a UUID takes 24 bytes, and data that would pass Telegram's 64 bytes is kept in
13836
+ the chat's record behind a token derived from it — the same button renders the
13837
+ same token, so an unchanged keyboard is never edited. A screen's id is a
13838
+ short hash of its path, or its explicit `id` — give one to a screen whose path
13839
+ may change, so buttons already in chats keep working.
13840
+
13841
+ A press is answered exactly once, after the outcome is known and before the
13842
+ chat is edited. It is trusted only as far as Telegram vouches for it: the
13843
+ `callback_data` must be on the keyboard of the message it came with, otherwise
13844
+ the press was forged (a client can send any bytes) and goes to `onStale`.
13845
+
13846
+ A press on a message the record does not know — an older menu higher up — is
13847
+ answered in place: a one-message view takes that message over, a longer view is
13848
+ sent below it and the pressed message is deleted. Either way the previously
13849
+ recorded view is removed: one live menu per chat.
13850
+
13851
+ For a message the screens do not own — a notification from the backend —
13852
+ `screens.button(link('🔑 Refresh token', refreshToken, { botId }))` makes a
13853
+ button that opens the screen below and leaves the notification as it is.
13854
+
13855
+ ## Input
13856
+
13857
+ `.on(kinds, handler, { keepMessage? })` takes a message while the screen is
13858
+ shown. Kinds: `text`, `rich`, `photo`, `video`, `animation`, `document`,
13859
+ `audio`, `voice`, `video_note`, `sticker`, `location`, `contact`. A screen may
13860
+ have several `.on`; a message of a kind no `.on` takes, and every command
13861
+ (`/start`), go to the next handler.
13862
+
13863
+ The user's message is deleted **before** any load or the handler runs — input
13864
+ can be a secret, such as a bot token, and a load may redirect or fail — unless
13865
+ `keepMessage: true`; then the view is sent below the message. A message for a
13866
+ screen whose record cannot be read is not passed on to the next handler: the
13867
+ update fails, and `onError` decides. `c.pending(text)` shows a "working on it" message that is
13868
+ removed when the handler ends, however it ends. `c.message` is the message as
13869
+ Telegram sent it; it is never stored.
13870
+
13871
+ ## Opening a screen
13872
+
13873
+ `screens.open(ctx, screen, params?, { previous })` shows a screen as a new
13874
+ message at the bottom of the chat, from a command, a deep link or a handler.
13875
+ The previous view stays (`'keep'`, the default) or is deleted (`'delete'`).
13876
+ Called from inside a handler of the same chat, it runs at once rather than
13877
+ waiting behind the update that called it, and the screen it opened stands: the
13878
+ handler may still answer the press with a toast, and returning a navigation or
13879
+ a notice as well is an error — drawn after it, the handler's own outcome would
13880
+ put the record back on the screen the chat left.
13881
+
13882
+ ## State and concurrency
13883
+
13884
+ One record per chat, in any grammY `StorageAdapter<ScreenChatState>`, under
13885
+ `screens:<chat id>` by default (`chatKey` changes it; it never collides with a
13886
+ grammY session). It holds the shown screen, its params, message ids with
13887
+ fingerprints of their content, overflow tokens and expiring remarks — never
13888
+ message text or input. A record from another schema version is set aside
13889
+ (`state-discarded`); a storage failure is an update failure.
13890
+
13891
+ Updates of one chat are handled one at a time within a process. Two processes
13892
+ serving one chat — releases overlapping, a webhook fan-out — are not
13893
+ serialised: the last write wins. The record is written after Telegram answers,
13894
+ so it never names a message that was not sent — and it is written when Telegram
13895
+ refused halfway too.
13896
+
13897
+ ## Configuration
13898
+
13899
+ | Option | Default | Does |
13900
+ |---|---|---|
13901
+ | `screens` | — | the declared screens; ids, paths and access are checked at `create` |
13902
+ | `storage` | — | where each chat's record survives a restart |
13903
+ | `chatKey` | `screens:<chat id>` | the storage key of a chat's record |
13904
+ | `callbackPrefix` | `~` | what every screen button's `callback_data` starts with |
13905
+ | `chats` | `'private'` | `'all'` answers in groups too, where any member can press or type |
13906
+ | `onStale(c)` | answered silently | a press nothing can answer, with its `reason`; its outcome is shown |
13907
+ | `onError(error, c)` | none | a load, action or input that threw; its outcome is shown |
13908
+ | `onEvent(event)` | none | `transition`, `state-discarded`, `message-kept`, `sweep-failed` (a remark's timer could not delete it) — never message text |
13909
+ | `clock` | `Date.now` | the time expiring remarks are measured by |
13910
+
13911
+ Without `onError` the press is answered silently and the error reaches
13912
+ `bot.catch` — which a bot must set. `screens.close()` cancels pending expiry
13913
+ timers on the way down; a remark left behind is removed on the chat's next
13914
+ update.
13915
+
13916
+ ## Security
13917
+
13918
+ - **A group's `load` checks access** for every screen, action and input under
13919
+ it. A param added below the last load is unchecked until a load of its own
13920
+ runs: `create` refuses a screen whose params no load sees.
13921
+ - **Never `html.raw(c.text)`** — or anything else a user wrote. `html` escapes
13922
+ interpolations; `raw` is for markup the application built.
13923
+ - Private chats only by default: in a group anyone could press an owner's menu.
13924
+ - A forged `callback_data` never reaches a handler; params and action input
13925
+ still go through their schemas.
13926
+
13927
+ ## Testing
13928
+
13929
+ `createScreenTestChat(bot)` takes the application's own `Bot` — its middleware,
13930
+ commands and error boundary in their real order — and answers its Bot API calls
13931
+ from memory. Updates go through `bot.handleUpdate`, and an error reaches
13932
+ `bot.catch` as it does under long polling.
13933
+
13934
+ ```ts
13935
+ import { Bot, MemorySessionStorage } from 'grammy'
13936
+ import { createScreenTestChat } from 'stitchkit/telegram/screens'
13937
+
13938
+ const chat = createScreenTestChat(makeBot(new MemorySessionStorage()))
13939
+ await chat.send('/start')
13940
+ await chat.press('Alpha')
13941
+ expect(chat.messages.at(-1)?.text).toBe('📁 <b>Alpha</b>')
13942
+ expect(chat.calls.map((call) => call.method)).toEqual(['sendMessage', 'answerCallbackQuery', 'editMessageText'])
13943
+ ```
13944
+
13945
+ It keeps the Telegram rules screens depend on — an edit that changes nothing is
13946
+ "not modified", a deleted message cannot be edited, HTML it cannot parse, text
13947
+ past 4096 characters, a caption past 1024 and `callback_data` past 64 bytes are
13948
+ refused — and offers `failNext` to
13949
+ refuse a call, `deleteByUser`, `pressData` for a forged press and
13950
+ `restart(bot)` for a new process over the same storage.
13951
+
13952
+ ## Not included
13953
+
13954
+ Reply keyboards, inline mode, media uploads (`InputFile`) in a view, showing a
13955
+ screen without an update (use `screens.button` in a notification), converting
13956
+ an incoming message to HTML, and a history stack.
13957
+
13958
+
13224
13959
  ==============================================================================
13225
13960
  # Guide: GeoIP (docs/guide/geo.md)
13226
13961
  ==============================================================================
@@ -13317,6 +14052,42 @@ analytics policy belong to the application.
13317
14052
 
13318
14053
  # Upgrading stitchkit
13319
14054
 
14055
+ ## Released migration: 0.101.0
14056
+
14057
+ ### `TelegramInitDataUser` is `TelegramUser`
14058
+
14059
+ **Who must act:** code that names the type `TelegramInitDataUser` from
14060
+ `stitchkit/telegram`. The record is the same; its name now says it is any
14061
+ Telegram user, since `parseTelegramUser(ctx.from)` returns it for a Bot API user
14062
+ too.
14063
+
14064
+ ```ts
14065
+ // Before
14066
+ import type { TelegramInitDataUser } from 'stitchkit/telegram'
14067
+ function ensureAccount(user: TelegramInitDataUser) {}
14068
+ // After
14069
+ import type { TelegramUser } from 'stitchkit/telegram'
14070
+ function ensureAccount(user: TelegramUser) {}
14071
+ ```
14072
+
14073
+ ### `TELEGRAM_BOT_TOKEN_PATTERN` matches the secret half only
14074
+
14075
+ **Who must act:** code that uses `TELEGRAM_BOT_TOKEN_PATTERN` to find or cut a
14076
+ whole token. It now matches what follows `<bot id>:`, so a masking replace keeps
14077
+ the bot's id — `123456:[redacted]`. Masking needs nothing any more: `redact` and
14078
+ every logger on it mask bot tokens by shape; remove the pattern from
14079
+ `sensitiveUrlPatterns`, and use `redactTelegramBotToken(text)` for a string of
14080
+ your own.
14081
+
14082
+ ```ts
14083
+ // Before
14084
+ createJsonLogger({ sensitiveUrlPatterns: [TELEGRAM_BOT_TOKEN_PATTERN] })
14085
+ text.replace(new RegExp(TELEGRAM_BOT_TOKEN_PATTERN.source, 'g'), '[redacted]')
14086
+ // After
14087
+ createJsonLogger({})
14088
+ redactTelegramBotToken(text)
14089
+ ```
14090
+
13320
14091
  ## Released migration: 0.100.0
13321
14092
 
13322
14093
  ### watch capacity and session/live/delivery adoption
@@ -18641,7 +19412,8 @@ audit event. See the [Observability guide](../guide/observability.md).
18641
19412
  | `createBoundedLogger` | function | decorate a `StitchLogger` with request context, shared sanitisation, redaction and total bounds |
18642
19413
  | `DEFAULT_REDACT_PATHS` | constant | baseline credential/token paths added to the sanitizer's sensitive-key policy |
18643
19414
  | `createJsonLogger` | function | the process journal: one JSON line per call in pino's shape (numeric `level`, epoch `time`, `msg`) on standard output, through `createBoundedLogger` — an `Error` in any field keeps name, message, stack and cause; secrets masked; `level` threshold, static `fields`, injectable `write` |
18644
- | `JsonLoggerOptions` / `JsonLogLevel` / `JSON_LOG_LEVELS` | _type_ / constant | the bounded-logger options plus `level`, `fields`, `write`, `now`; pino's numbers `debug` 20 … `error` 50, which a line reader matches on |
19415
+ | `JsonLoggerOptions` / `JsonLogLevel` / `JSON_LOG_LEVELS` | _type_ / constant | the bounded-logger options plus `level`, `fields`, `write`, `now`; pino's numbers `debug` 20 … `error` 50, which a line reader matches on; `sensitiveValues` masks exact secrets in every string |
19416
+ | `secretValuesFromEnv` / `SecretValuesOptions` | function / _type_ | every secret an environment holds, for `sensitiveValues`: variables named as a token, secret, key, password or credential, the password and secret query values in any URL variable, each URL-encoded too, longest first; values under `minLength` (8) skipped |
18645
19417
  | `BoundedLoggerBounds` / `BoundedLoggerOptions` | _type_ | per-value and total record ceilings (`stringLengthByKey` raises the string bound for named keys such as `stack`) plus sink/redaction configuration |
18646
19418
  | `RequestEvent` | _type_ | the normalised audit event handed to the sink; opt-in HTTP cancellation rows carry `outcome: 'cancelled'` |
18647
19419
  | `runUnitOfWork` / `RunUnitOfWorkOptions` | function / _type_ | run work that did not arrive over a transport — an agent loop, a scheduled broadcast — inside one context, with the same completion record a request gets |
@@ -18694,7 +19466,7 @@ audit event. See the [Observability guide](../guide/observability.md).
18694
19466
  | Export | Kind | Summary |
18695
19467
  |--------|------|---------|
18696
19468
  | `sanitizePayload` | function | redact secrets and cap size — [guide](../guide/observability.md#sanitisation) |
18697
- | `redact` | function | mask secret-named keys, drop binary blobs |
19469
+ | `redact` | function | mask secret-named keys and secrets recognised by shape in every string — the secret half of a bot token, a password in an address, a query parameter named as a secret; drop binary blobs; an error keeps its own fields (`error_code`, `parameters`), a class instance on one is named, not written |
18698
19470
  | `truncatePreview` | function | cap a value by serialised size |
18699
19471
  | `measureSize` | function | item count + byte size of a result |
18700
19472
  | `JsonValue` | _type_ | a JSON-serialisable value |
@@ -19124,22 +19896,95 @@ injected grammY bot. → ADR 0143, ADR 0201 — [guide](../guide/telegram.md)
19124
19896
  | `verifyTelegramInitData` | function | verify a Mini App `initData` signature against the bot token in constant time, before reading anything out of it |
19125
19897
  | `VerifyTelegramInitDataOptions` | _type_ | raw `initData`, bot token, optional `maxAgeSeconds` bound and injected clock |
19126
19898
  | `TelegramInitDataVerification` | _type_ | `{ valid: true, data }` or a refusal carrying its reason |
19127
- | `TelegramInitData` | _type_ | verified `user`/`receiver`, `authDate`, `ageSeconds`, `queryId`, `startParam`, `chatType`, `chatInstance` and every signed pair in `raw` |
19128
- | `TelegramInitDataUser` | _type_ | camelCase user record inferred from Telegram's signed `user` payload |
19899
+ | `TelegramInitData` | _type_ | verified `user`/`receiver` as `TelegramUser`, `authDate`, `ageSeconds`, `queryId`, `startParam`, `chatType`, `chatInstance` and every signed pair in `raw` |
19900
+ | `parseTelegramUser` / `TelegramUser` | function / _type_ | Telegram's `snake_case` user — grammY's `ctx.from`, a Bot API `User`, a Mini App `user` — as the one camelCase record initData returns too; `undefined` when it is not a user |
19129
19901
  | `TelegramInitDataRefusal` | _type_ | `missing-hash` / `signature-mismatch` / `malformed` / `expired` — an expired string is not a forged one |
19130
19902
  | `classifyTelegramSendFailure` | function | name a refused Bot API send and separate "retry this send" from "stop addressing this recipient" |
19131
19903
  | `TelegramSendFailure` | _type_ | reason, `status`, Telegram-stated `retryAfterSeconds`, `retryable`, `recipientUnreachable` and which evidence produced the answer |
19132
- | `TelegramSendFailureReason` | _type_ | `blocked-by-user` / `user-deactivated` / `chat-not-found` / `not-started` / `rate-limited` / `message-invalid` / `server-error` / `unknown` |
19904
+ | `TelegramSendFailureReason` / `TELEGRAM_SEND_FAILURE_REASONS` | _type_ / constant | `blocked-by-user` / `user-deactivated` / `chat-not-found` / `not-started` / `rate-limited` / `message-invalid` / `server-error` / `unknown`, and the same as a list; a `Record<TelegramSendFailureReason, …>` over them fails to compile when a reason is added |
19133
19905
  | `runTelegramBroadcast` | function | a resumable broadcast by `name` under a state `directory`: audience written once, an append-only journal, pacing at `ratePerSecond` (25), 429 waits `retry_after`, unreachable recipients never addressed again, a `message-invalid` or unreachable Telegram halts without charging the recipient, a send in flight at a crash is `uncertain` and not repeated, `dryRun`, one runner per name |
19134
19906
  | `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` |
19135
19907
  | `telegramBroadcastSender` / `TelegramBroadcastSenderConfig` / `TelegramBroadcastMessage` | function / _type_ | the standard `send`: `sendMessage` with a text or `copyMessage` of a prepared message, through the Bot API |
19136
19908
  | `createTelegramOperatorChannel` | function | the operator chat, apart from the journal: `post(text, topic?)` returns at once and never throws; paced sends into forum topics, 429 honoured, oldest dropped on overflow, `drain(signal)` and `close()` for the way down |
19137
- | `TelegramOperatorChannelConfig` / `TelegramOperatorChannel` / `TelegramOperatorMessage` / `TelegramOperatorDrop` / `TelegramChatId` | _type_ | `chatId`, `topics`, `send`, `maxQueued`, `minIntervalMs`, `maxAttempts`, `onDropped`, `sensitivePatterns` (a bot token is always masked); a drop is `overflow` / `refused` / `attempts` / `closed` with its classification |
19138
- | `telegramOperatorSender` / `TelegramOperatorSenderConfig` | function / _type_ | the standard `send`: `sendMessage` into the chat and topic, plain text unless `parseMode` |
19909
+ | `TelegramOperatorChannelConfig` / `TelegramOperatorChannel` / `TelegramOperatorMessage` / `TelegramOperatorDrop` / `TelegramChatId` | _type_ | `chatId`, `topics`, `send`, `maxQueued`, `minIntervalMs`, `maxAttempts`, `onDropped`, `sensitivePatterns` and `sensitiveValues` (a bot token is always masked), `dedupe`; a drop is `overflow` / `refused` / `attempts` / `closed` / `repeated` / `over-budget` with its classification |
19910
+ | `createTelegramOperatorDedupe` / `TelegramOperatorDedupeVerdict` | function / _type_ | the channel's dedupe window alone, for a sender with its own bot and retry: `(text, topic?)` → `{ send: true, text }` with the held-back counts, or `{ send: false, reason: 'repeated' \| 'over-budget' }` |
19911
+ | `TelegramOperatorDedupe` | _type_ | `windowMs` (10 min): one message per `fingerprint` per window, the next one sent carries `repeatedLine(count)`; `maxPerWindow` (20) across fingerprints, the overflow counted onto the next one sent with `overBudgetLine(count)`; `now` |
19912
+ | `telegramOperatorSender` / `TelegramOperatorSenderConfig` | function / _type_ | the standard `send`: `sendMessage` into the chat and topic, plain text unless `parseMode`; with `'HTML'` the markup is cleaned and cut by what it shows, so the channel's cut never leaves a tag open |
19139
19913
  | `createTelegramLocalFiles` | function | files the local Bot API server wrote under `<root>/<token>/`: `resolve(file_path)` and `remove(file_path)` only inside the bot's directory, judged on real paths so a link is judged by where it leads; `check()` for readiness. Recommended root variable: `BOT_API_FILES_ROOT` |
19140
19914
  | `TelegramLocalFilesConfig` / `TelegramLocalFiles` / `TelegramLocalFilesCheck` / `TelegramLocalFileError` / `TelegramLocalFileRefusal` | _type_ / class | `root`, `token`; refusals `root-not-absolute` / `bot-directory-unavailable` / `outside-bot-directory` / `missing` / `not-a-file`, never carrying the path or the token |
19141
19915
  | `callTelegramBotApi` / `TelegramBotApiCall` / `TelegramBotApiError` | function / _type_ / class | one Bot API method over `fetch`; a refusal keeps `error_code`, `description` and `parameters` for the classifier, and no error names the URL |
19142
- | `TELEGRAM_BOT_TOKEN_PATTERN` | constant | the shape of a bot token, for a logger's `sensitiveUrlPatterns` |
19916
+ | `TELEGRAM_BOT_TOKEN_PATTERN` / `redactTelegramBotToken` | constant / function | the secret half of a bot token — the bot's id before the colon is not matched; `redact` and every logger on it mask it already, `redactTelegramBotToken(text)` masks every token in a string to `123456:[redacted]` |
19917
+ | `claimTelegramWebhook` | function | set the webhook only where it already points here, or by naming the host it is taken from; confirm Telegram holds it; pending updates kept unless `dropPendingUpdates` |
19918
+ | `ClaimTelegramWebhookConfig` / `TelegramWebhookConfig` / `TelegramWebhookClaim` | _type_ | `token`, `url`, `secret`, `apiRoot`, `fetch`, `signal`; `takeoverFrom` (a host, or `'none'`), `allowedUpdates`, `maxConnections`, `dropPendingUpdates`; the claim's tagged `url`, `takenFrom`, `pendingUpdates` |
19919
+ | `TelegramWebhookClaimError` / `TelegramWebhookRefusal` / `TELEGRAM_WEBHOOK_NONE` | class / _type_ / constant | `owned-elsewhere` / `other-secret` / `not-confirmed` with `ownerHost` — a host, never a path; `'none'` names no webhook |
19920
+ | `telegramWebhookUrl` | function | the address with an `owner` tag derived from the secret: equal for one secret, different for another, the path untouched |
19921
+ | `checkTelegramWebhook` / `TelegramWebhookState` | function / _type_ | whether the webhook still points here: `owned`, `ownerHost`, `pendingUpdates`, Telegram's `lastError` |
19922
+ | `receiveTelegramWebhook` / `ReceiveTelegramWebhookOptions` / `TelegramUpdateAcceptance` | function / _type_ | one webhook `Request`: 403 without the secret (compared in constant time), 405 for another method, 400 for no update, 200 once `accept` recorded it |
19923
+ | `createTelegramUpdateIntake` | function | webhook updates recorded before Telegram is answered and handled after: chat order kept, other chats alongside up to `maxConcurrent`, a renewed lease per attempt, a sweep that takes over what a restart or a dead process left, retries, `maxAttempts`, finished updates remembered `retainMs` to refuse repeats; at least once |
19924
+ | `TelegramUpdateIntakeConfig` / `TelegramUpdateIntake` / `TelegramUpdateEnvelope` / `TelegramUpdateFailure` / `TelegramUpdateStoreStep` | _type_ | `store`, `handle` (a grammY `BotError` is unwrapped: `retry`, `onFailure` and the stored error see what the middleware threw), `retry`, `maxAttempts` (5), `leaseMs` (120 000), `pendingGraceMs` (60 000), `sweepEveryMs` (30 000), `retainMs` (24 h), `maxConcurrent` (32), `onFailure`, `onStoreError`, `now`; `accept(body)`, `start()`, `sweep()`, `idle()`, `close()` |
19925
+ | `TelegramUpdateStore` / `StoredTelegramUpdate` / `TelegramUpdateState` / `TelegramUpdateSettlement` / `TelegramUpdateClaimOptions` / `TelegramUpdateDueQuery` | _type_ | the store an intake runs on: `add`, `claim`, `renew`, `settle`, `due`, `prune`, each atomic on one record — a claim takes `pending`, a due `failed` or a lapsed `processing`; renew and settle only by the attempt holding it |
19926
+ | `memoryTelegramUpdateStore` | function | the store in this process's memory — tests, or a bot that accepts losing unhandled updates to a restart |
19927
+ | `sqliteTelegramUpdateStore` / `SqliteTelegramUpdateStoreConfig` / `TelegramSqliteDatabase` | function / _type_ | the store in SQLite over the handle the application opened — `bun:sqlite` `Database` or `node:sqlite` `DatabaseSync`; `table` (default `telegram_updates`) created when missing |
19928
+ | `postgresTelegramUpdateStore` / `PostgresTelegramUpdateStoreConfig` / `TelegramPostgresQuery` | function / _type_ | the store in Postgres over the client the application holds, as one `query(text, parameters)` returning rows — Bun `sql.unsafe`, Prisma `$queryRawUnsafe`, `pg` `pool.query`; each rule one conditional statement; `table`, `createTable` (default `true`) |
19929
+ | `postgresTelegramUpdateStoreSchema` | function | the table and its index as a statement for a migration, safe to run twice |
19930
+ | `checkTelegramUpdateStore` | function | put any store through every rule, each on a fresh store from `make`, concurrent claims included; resolves with the violations, empty when it keeps them |
19931
+
19932
+ ---
19933
+
19934
+ ## `stitchkit/telegram/html`
19935
+
19936
+ Telegram's HTML parse mode as a tree: any markup in, markup Telegram accepts
19937
+ out, cut to its limits by what the reader sees. No peer and no DOM — the same
19938
+ parse serves a server send and a browser preview. → ADR 0213 —
19939
+ [guide](../guide/telegram.md#message-markup)
19940
+
19941
+ | Export | Kind | Summary |
19942
+ |--------|------|---------|
19943
+ | `parseTelegramHtml` | function | markup into nodes Telegram accepts: synonyms renamed (`strong` → `b`), block tags into line breaks, a link only with an `http`/`https`/`tg`/`mailto` `href`, an element where Telegram forbids it kept as text, a closing tag closing what is open inside it; never throws |
19944
+ | `TelegramHtmlNode` / `TelegramHtmlElement` / `TelegramHtmlTextNode` / `TelegramHtmlTag` / `TelegramHtmlStyle` | _type_ | text nodes hold decoded text; elements `b` `i` `u` `s` `tg-spoiler` `code`, `a` with `href`, `pre` with `language`, `blockquote` with `expandable`, `tg-emoji` with `emojiId`, `tg-time` with `unix` and `format` |
19945
+ | `renderTelegramHtml` | function | nodes back to markup Telegram parses into exactly them |
19946
+ | `sanitizeTelegramHtml` | function | any markup as markup Telegram accepts — `renderTelegramHtml(parseTelegramHtml(markup))` |
19947
+ | `splitTelegramHtml` / `TelegramHtmlLimitOptions` | function / _type_ | parts within `limit` visible characters (default 4096), cut at a paragraph, a line, a space, else anywhere but inside a character or a custom emoji; an element across a cut is closed and reopened with its attributes; nothing visible is no parts |
19948
+ | `truncateTelegramHtml` / `TelegramHtmlTruncateOptions` | function / _type_ | one part: cut to `limit` with `ellipsis` (`…`) inside it, still valid |
19949
+ | `telegramHtmlText` | function | the text a reader sees, entities decoded |
19950
+ | `checkTelegramHtml` / `TelegramHtmlCheck` / `TelegramHtmlProblem` | function / _type_ | the first thing Telegram would refuse, unrepaired — `bare-character` / `unsupported-tag` / `unmatched-end-tag` / `unclosed-tag` — and the visible text before it |
19951
+ | `escapeTelegramHtml` | function | `&`, `<`, `>` and `"` escaped for Telegram's HTML |
19952
+ | `TELEGRAM_TEXT_LIMIT` / `TELEGRAM_CAPTION_LIMIT` | constant | 4096 and 1024, counted after the markup is parsed, in UTF-16 code units |
19953
+
19954
+ ---
19955
+
19956
+ ## `stitchkit/telegram/screens`
19957
+
19958
+ A bot's menus as declared screens over grammY: a screen says what the chat
19959
+ should show, and the runtime decides what to send, edit and delete. Server-only,
19960
+ Bun or Node; `grammy` and `zod` appear in the declarations, and nothing imports
19961
+ grammY at run time — the bot's own `ctx.api` and a grammY `StorageAdapter` are
19962
+ the whole dependency. → ADR 0211 — [guide](../guide/telegram-screens.md)
19963
+
19964
+ | Export | Kind | Summary |
19965
+ |--------|------|---------|
19966
+ | `telegramScreens` | function | `telegramScreens<Ctx>()` — the root the bot's screens are declared from; the context type is named once |
19967
+ | `TelegramScreensRoot` / `ScreenScope` | class | `screen(path, options?)`, `group(prefix, { params?, id? })`, and on the root `create(config)` |
19968
+ | `ScreenGroupBuilder` / `ScreenBuilder` / `ScreenBodyBuilder` | class | the chain: `load` (first or never) → `action` / `on` → `view`, which ends the declaration |
19969
+ | `ScopeOptions` / `ScreenOptions` / `ParamsSchemaFor` / `ActionInputSchema` | _type_ | a group's `params` schema and `id`; a screen's explicit `id`; the schema a path's params accept; an action's flat input schema |
19970
+ | `TelegramScreen` / `AnyTelegramScreen` | class / _type_ | a declared screen, typed by its context, params, data and actions |
19971
+ | `ScreenLoadContext` / `ScreenActionContext` / `ScreenInputContext` / `ScreenViewContext` | _type_ | `ctx`, `params`, `data`; an action's `input`; an input's `message`, `text` and `pending(text)`; a view's `act` buttons |
19972
+ | `ScreenParams` / `ScreenParamValue` / `ScreenPathParams` / `ScreenLinkArgs` | _type_ | params parsed from the path literal (or its schema); the argument a `link` or `go` takes — required exactly when the path has params |
19973
+ | `ScreenActions` / `ScreenActionButtons` / `NoActionInput` | _type_ | action name → input; `act.<name>(label, input?)` typed by the action's schema |
19974
+ | `ScreenInputKind` / `ScreenInputOptions` | _type_ | `text` `rich` `photo` `video` `animation` `document` `audio` `voice` `video_note` `sticker` `location` `contact`; `keepMessage` leaves the user's message (default: deleted before the handler) |
19975
+ | `ScreenHandlerResult` / `ScreenOutcome` / `ScreenOutcomeBuilders` / `ScreenInputOutcomeBuilders` | _type_ / class | what a load, action or input answers: `stay()`, `go(screen, params?)`, `back()`, `toast(text, { alert? })` (≤ 200 characters), `notice(content, { expiresInMs? })`; `void` is `stay()`; after input there is no press, so no `toast` |
19976
+ | `ScreenNavigation` / `ScreenToast` / `ScreenNotice` / `NoticeOptions` | _type_ | the parts of an outcome; a notice without `expiresInMs` is a receipt the screen moves below, with it a remark under the screen that removes itself |
19977
+ | `ViewMessage` / `ScreenViewResult` / `TextViewMessage` / `RichViewMessage` / `PhotoViewMessage` / `VideoViewMessage` / `AnimationViewMessage` / `DocumentViewMessage` / `AudioViewMessage` / `ScreenMediaKind` | _type_ | one message or several; exactly one content (`text`, `rich`, or a media file id or URL with `caption`), plus `keyboard`, `key` and `linkPreview` |
19978
+ | `link` / `back` / `ScreenButton` | function / class | a button to a screen with its params; a button to the parent screen |
19979
+ | `Keyboard` / `KeyboardRow` / `KeyboardButton` / `PlainKeyboardButton` / `ButtonLabel` | _type_ | rows of screen buttons and plain Telegram buttons without `callback_data`; `false`, `null` and `undefined` are skipped; a label is text or `{ text, style?, icon_custom_emoji_id? }` |
19980
+ | `html` / `TelegramHtml` / `HtmlTag` / `HtmlValue` / `TelegramText` | function / class / _type_ | Telegram HTML with every interpolation escaped; `html.raw()` for markup already safe, `html.join()`; a plain string is always escaped (`escapeTelegramHtml` is `stitchkit/telegram/html`'s) |
19981
+ | `ActionValue` | _type_ | what a button can carry: a string, a finite number, a boolean or `null` |
19982
+ | `TelegramScreensConfig` | _type_ | `screens`, `storage`, `chatKey` (default `screens:<chat id>`), `callbackPrefix` (default `~`), `chats` (`'private'` by default, or `'all'`), `onStale`, `onError`, `onEvent`, `clock` |
19983
+ | `TelegramScreens` / `OpenArgs` / `OpenOptions` | _type_ | `bot.use(screens)`; `open(ctx, screen, params?, { previous: 'keep' \| 'delete' })`; `button(link(…))` for a message the screens do not own; `close()` cancels expiry timers |
19984
+ | `ScreenStaleContext` / `ScreenStaleReason` / `ScreenErrorContext` / `ScreenTrigger` | _type_ | a press nothing can answer — `malformed` / `forged` / `inaccessible` / `unknown-token` / `unknown-screen` / `invalid-params` / `unknown-action` / `invalid-input`; an error with its trigger `open` / `press` / `input` |
19985
+ | `ScreenEvent` / `ExecutionReport` | _type_ | `transition` (trigger, path templates, duration, messages sent / edited / kept / deleted), `state-discarded`, `message-kept`, `sweep-failed`; never message text |
19986
+ | `ScreenChatState` / `ScreenChatStateSchema` / `TelegramScreenStorage` | _type_ / schema | one record per chat — the shown view and expiring remarks, no message text; any grammY `StorageAdapter<ScreenChatState>` |
19987
+ | `createScreenTestChat` / `ScreenTestChat` / `TestChatOptions` / `TestChatMessage` / `TestChatCall` / `TestMessageKind` | function / _type_ | a chat with the application's own `Bot` and no Telegram: `send`, `sendPhoto`, `press(label)`, `pressData`, `failNext`, `deleteByUser`, `restart(bot)`; `messages`, `calls`, `answers` |
19143
19988
 
19144
19989
  ---
19145
19990
 
@@ -19495,6 +20340,31 @@ two context types, why thin wrappers — see the
19495
20340
  ends. Watch loss marks a query stale without triggering an independent refetch;
19496
20341
  recovery belongs to the watched read.
19497
20342
 
20343
+ ## `stitchkit/react/keyboard`
20344
+
20345
+ Browser-only, headless keyboard handling for React: which part of a screen gets a
20346
+ key, and lists the arrows move through with real focus. Needs the `react` peer;
20347
+ no component, no styles, no router — see the [keyboard guide](../guide/keyboard.md).
20348
+
20349
+ | Export | Kind | Summary |
20350
+ |--------|------|---------|
20351
+ | `useKeyLayer` | hook | put a layer on the window's one key stack — asked `overlay` → `local` → `route` → `zone` → `global`, innermost first within a kind |
20352
+ | `useEscapeLayer` | hook | an `overlay`, `local` or `route` layer for Escape: one press closes one level |
20353
+ | `useNavZone` | hook | a list zone — roving tab stop, arrows move DOM focus, select or highlight mode, cross-axis enter/exit, boundaries |
20354
+ | `nextZoneIndex` | function | where a move lands in a list of a given length: an index, `'before-start'` or `'after-end'` |
20355
+ | `isKeyFieldTarget` | function | whether a key was pressed in a field or a `data-local-keys` area, the test layers use |
20356
+ | `KeyLayerOptions` | _type_ | `kind`, `onKey`, `active`, `scope`, `fields` |
20357
+ | `KeyLayerKind` | _type_ | `'overlay' \| 'local' \| 'route' \| 'zone' \| 'global'` |
20358
+ | `EscapeLayerOptions` | _type_ | `kind`, `onEscape`, `active`, `scope` |
20359
+ | `NavZoneOptions` | _type_ | `items`, `value`, `onChange`, `mode`, `orientation`, `role`, `loop`, `onEnter`, `onExit`, `onBoundary`, `active`, `label`, `onReveal` |
20360
+ | `NavZone` | _type_ | what `useNavZone` returns — `zoneProps`, `itemProps(id)`, `element`, `current`, `focus(id?)` |
20361
+ | `NavZoneProps` / `NavZoneItemProps` | _type_ | the props spread on the zone's element and on each item |
20362
+ | `NextZoneIndexInput` | _type_ | `length`, `index`, `move`, `loop` |
20363
+ | `ZoneItemId` | _type_ | an item id — `string \| number` |
20364
+ | `ZoneChangeCause` | _type_ | `'arrow' \| 'confirm'` |
20365
+ | `ZoneMove` | _type_ | `'next' \| 'previous' \| 'first' \| 'last'` |
20366
+ | `ZoneOrientation` | _type_ | `'vertical' \| 'horizontal'` |
20367
+
19498
20368
  ## `stitchkit/agent-runtime/sandbox`
19499
20369
 
19500
20370
  Optional server-only namespace sandbox. Exports: