sygnal 5.4.0 → 6.1.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 (360) hide show
  1. package/CHANGELOG.md +716 -0
  2. package/README.md +24 -9
  3. package/dist/ai.cjs.js +115 -0
  4. package/dist/ai.cjs.js.map +1 -0
  5. package/dist/ai.esm.js +2 -0
  6. package/dist/ai.esm.js.map +1 -0
  7. package/dist/astro/client.cjs.js +16 -9
  8. package/dist/astro/client.cjs.js.map +1 -1
  9. package/dist/astro/client.mjs +16 -9
  10. package/dist/astro/client.mjs.map +1 -1
  11. package/dist/astro/index.cjs.js +958 -92
  12. package/dist/astro/index.cjs.js.map +1 -1
  13. package/dist/astro/index.mjs +958 -93
  14. package/dist/astro/index.mjs.map +1 -1
  15. package/dist/astro/server.cjs.js +3396 -165
  16. package/dist/astro/server.cjs.js.map +1 -1
  17. package/dist/astro/server.mjs +3396 -165
  18. package/dist/astro/server.mjs.map +1 -1
  19. package/dist/devtools.cjs.js +1840 -0
  20. package/dist/devtools.cjs.js.map +1 -0
  21. package/dist/devtools.esm.js +1827 -0
  22. package/dist/devtools.esm.js.map +1 -0
  23. package/dist/diagnostics.cjs.js +3190 -296
  24. package/dist/diagnostics.cjs.js.map +1 -1
  25. package/dist/diagnostics.esm.js +3190 -296
  26. package/dist/diagnostics.esm.js.map +1 -1
  27. package/dist/element.cjs.js +230 -0
  28. package/dist/element.cjs.js.map +1 -0
  29. package/dist/element.esm.js +228 -0
  30. package/dist/element.esm.js.map +1 -0
  31. package/dist/guide/accessibility.md +310 -0
  32. package/dist/guide/adapters.md +221 -0
  33. package/dist/guide/agent.md +588 -0
  34. package/dist/guide/ai-chat.md +693 -0
  35. package/dist/guide/ai-decisions.md +330 -0
  36. package/dist/guide/behaviors.md +312 -0
  37. package/dist/guide/browser-sources.md +203 -0
  38. package/dist/guide/drag-and-drop.md +342 -0
  39. package/dist/guide/element-commands.md +306 -0
  40. package/dist/guide/error-boundaries.md +169 -0
  41. package/dist/guide/forms-reference.md +409 -0
  42. package/dist/guide/forms.md +251 -0
  43. package/dist/guide/http.md +301 -0
  44. package/dist/guide/inputs.md +173 -0
  45. package/dist/guide/mcp-apps.md +272 -0
  46. package/dist/guide/persistence.md +245 -0
  47. package/dist/guide/recipes/ai-form-fill.md +163 -0
  48. package/dist/guide/recipes/ai-summarize.md +155 -0
  49. package/dist/guide/recipes/ai-support-inbox.md +190 -0
  50. package/dist/guide/recipes/carousel.md +155 -0
  51. package/dist/guide/recipes/charts.md +177 -0
  52. package/dist/guide/recipes/code-editor.md +135 -0
  53. package/dist/guide/recipes/data-grid.md +155 -0
  54. package/dist/guide/recipes/data-table.md +196 -0
  55. package/dist/guide/recipes/i18n.md +222 -0
  56. package/dist/guide/recipes/icons.md +115 -0
  57. package/dist/guide/recipes/overview.md +43 -0
  58. package/dist/guide/recipes/rich-text.md +154 -0
  59. package/dist/guide/resources.md +471 -0
  60. package/dist/guide/ssr.md +255 -0
  61. package/dist/guide/timers.md +178 -0
  62. package/dist/guide/ui/accordion.md +93 -0
  63. package/dist/guide/ui/combobox.md +144 -0
  64. package/dist/guide/ui/dialog.md +124 -0
  65. package/dist/guide/ui/disclosure.md +60 -0
  66. package/dist/guide/ui/menu.md +125 -0
  67. package/dist/guide/ui/overview.md +166 -0
  68. package/dist/guide/ui/popover.md +101 -0
  69. package/dist/guide/ui/select.md +114 -0
  70. package/dist/guide/ui/tabs.md +161 -0
  71. package/dist/guide/ui/toaster.md +152 -0
  72. package/dist/guide/ui/tooltip.md +103 -0
  73. package/dist/guide/undo.md +178 -0
  74. package/dist/guide/virtual-collections.md +183 -0
  75. package/dist/guide/web-components.md +179 -0
  76. package/dist/guide/webmcp.md +226 -0
  77. package/dist/guide/widgets.md +217 -0
  78. package/dist/index.cjs.js +19288 -7408
  79. package/dist/index.cjs.js.map +1 -1
  80. package/dist/index.d.ts +4466 -552
  81. package/dist/index.esm.js +19274 -7461
  82. package/dist/index.esm.js.map +1 -1
  83. package/dist/jsx-dev-runtime.cjs.js +26 -334
  84. package/dist/jsx-dev-runtime.cjs.js.map +1 -1
  85. package/dist/jsx-dev-runtime.esm.js +19 -327
  86. package/dist/jsx-dev-runtime.esm.js.map +1 -1
  87. package/dist/jsx-runtime.cjs.js +26 -334
  88. package/dist/jsx-runtime.cjs.js.map +1 -1
  89. package/dist/jsx-runtime.esm.js +19 -327
  90. package/dist/jsx-runtime.esm.js.map +1 -1
  91. package/dist/jsx.cjs.js +10 -314
  92. package/dist/jsx.cjs.js.map +1 -1
  93. package/dist/jsx.esm.js +8 -315
  94. package/dist/jsx.esm.js.map +1 -1
  95. package/dist/react.cjs.js +117 -0
  96. package/dist/react.cjs.js.map +1 -0
  97. package/dist/react.esm.js +115 -0
  98. package/dist/react.esm.js.map +1 -0
  99. package/dist/shims/globalthis.cjs +20 -0
  100. package/dist/sygnal.min.js +1 -1
  101. package/dist/sygnal.min.js.map +1 -1
  102. package/dist/ui-combobox.cjs.js +169 -0
  103. package/dist/ui-combobox.cjs.js.map +1 -0
  104. package/dist/ui-combobox.esm.js +148 -0
  105. package/dist/ui-combobox.esm.js.map +1 -0
  106. package/dist/ui-menu.cjs.js +87 -0
  107. package/dist/ui-menu.cjs.js.map +1 -0
  108. package/dist/ui-menu.esm.js +66 -0
  109. package/dist/ui-menu.esm.js.map +1 -0
  110. package/dist/ui-select.cjs.js +104 -0
  111. package/dist/ui-select.cjs.js.map +1 -0
  112. package/dist/ui-select.esm.js +83 -0
  113. package/dist/ui-select.esm.js.map +1 -0
  114. package/dist/ui.cjs.js +738 -0
  115. package/dist/ui.cjs.js.map +1 -0
  116. package/dist/ui.esm.js +727 -0
  117. package/dist/ui.esm.js.map +1 -0
  118. package/dist/vike/ClientOnly.cjs.js +4 -14
  119. package/dist/vike/ClientOnly.cjs.js.map +1 -1
  120. package/dist/vike/ClientOnly.mjs +4 -14
  121. package/dist/vike/ClientOnly.mjs.map +1 -1
  122. package/dist/vike/{+config.js → config/+config.js} +9 -1
  123. package/dist/vike/config/+config.js.map +1 -0
  124. package/dist/vike/config/package.json +6 -0
  125. package/dist/vike/onRenderClient.cjs.js +165 -95
  126. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  127. package/dist/vike/onRenderClient.mjs +165 -95
  128. package/dist/vike/onRenderClient.mjs.map +1 -1
  129. package/dist/vike/onRenderHtml.cjs.js +59 -24
  130. package/dist/vike/onRenderHtml.cjs.js.map +1 -1
  131. package/dist/vike/onRenderHtml.mjs +60 -25
  132. package/dist/vike/onRenderHtml.mjs.map +1 -1
  133. package/dist/vite/plugin.cjs.js +919 -89
  134. package/dist/vite/plugin.cjs.js.map +1 -1
  135. package/dist/vite/plugin.mjs +919 -90
  136. package/dist/vite/plugin.mjs.map +1 -1
  137. package/dist/zag.cjs.js +408 -0
  138. package/dist/zag.cjs.js.map +1 -0
  139. package/dist/zag.esm.js +405 -0
  140. package/dist/zag.esm.js.map +1 -0
  141. package/llms.txt +172 -103
  142. package/package.json +112 -14
  143. package/src/ai.d.ts +1186 -0
  144. package/src/ai.ts +23 -0
  145. package/src/astro/client.ts +27 -16
  146. package/src/astro/index.d.ts +24 -0
  147. package/src/astro/index.ts +69 -3
  148. package/src/astro/server.ts +8 -2
  149. package/src/collection.ts +6 -93
  150. package/src/core/actions.ts +134 -0
  151. package/src/core/cell.ts +202 -0
  152. package/src/core/debug.ts +23 -0
  153. package/src/core/define.ts +242 -0
  154. package/src/core/hooks.ts +314 -0
  155. package/src/core/hosts/collection.ts +325 -0
  156. package/src/core/hosts/switchable.ts +146 -0
  157. package/src/core/instance.ts +592 -0
  158. package/src/core/markers/clientonly.ts +13 -0
  159. package/src/core/markers/lazy.ts +40 -0
  160. package/src/core/markers/portal.ts +96 -0
  161. package/src/core/markers/suspense.ts +41 -0
  162. package/src/core/markers/transition.ts +70 -0
  163. package/src/core/registry.ts +25 -0
  164. package/src/core/runtime.ts +573 -0
  165. package/src/core/statics.ts +104 -0
  166. package/src/core/teardown.ts +60 -0
  167. package/src/core/view.ts +37 -0
  168. package/src/cycle/dom/DocumentDOMSource.ts +13 -8
  169. package/src/cycle/dom/ElementFinder.ts +7 -9
  170. package/src/cycle/dom/EventDelegator.ts +174 -223
  171. package/src/cycle/dom/IsolateModule.ts +65 -35
  172. package/src/cycle/dom/MainDOMSource.ts +9 -2
  173. package/src/cycle/dom/PriorityQueue.ts +4 -2
  174. package/src/cycle/dom/SymbolTree.ts +18 -47
  175. package/src/cycle/dom/classNameModule.ts +9 -7
  176. package/src/cycle/dom/controlledInputModule.ts +23 -6
  177. package/src/cycle/dom/enrichEventStream.ts +25 -47
  178. package/src/cycle/dom/fragment.ts +7 -0
  179. package/src/cycle/dom/isolate.ts +30 -27
  180. package/src/cycle/dom/makeDOMDriver.ts +115 -56
  181. package/src/cycle/dom/mockDOMSource.ts +21 -1
  182. package/src/cycle/dom/modules.ts +16 -3
  183. package/src/cycle/dom/propsModule.ts +65 -0
  184. package/src/cycle/dom/selectModule.ts +22 -17
  185. package/src/cycle/dom/snabbdom.ts +1 -6
  186. package/src/cycle/dom/thunk.ts +3 -0
  187. package/src/cycle/dom/utils.ts +98 -0
  188. package/src/cycle/dom/viewTransition.ts +43 -0
  189. package/src/cycle/state/StateSource.ts +19 -3
  190. package/src/cycle/state/objIsEqual.ts +50 -0
  191. package/src/cycle/state/types.ts +0 -10
  192. package/src/defineComponent.ts +34 -0
  193. package/src/devtools.d.ts +170 -0
  194. package/src/devtools.ts +37 -0
  195. package/src/element.d.ts +59 -0
  196. package/src/element.ts +235 -0
  197. package/src/extra/ai/agent/index.ts +484 -0
  198. package/src/extra/ai/answers.ts +115 -0
  199. package/src/extra/ai/chat/behavior.ts +336 -0
  200. package/src/extra/ai/chat/driver.ts +271 -0
  201. package/src/extra/ai/chat/memoryTransport.ts +61 -0
  202. package/src/extra/ai/chat/output.ts +37 -0
  203. package/src/extra/ai/commandBar.ts +281 -0
  204. package/src/extra/ai/decide.ts +104 -0
  205. package/src/extra/ai/index.ts +42 -0
  206. package/src/extra/ai/link.ts +90 -0
  207. package/src/extra/ai/mcpApp.ts +314 -0
  208. package/src/extra/ai/messages.ts +51 -0
  209. package/src/extra/ai/schema/index.ts +166 -0
  210. package/src/extra/ai/schema/jsonSchema.ts +72 -0
  211. package/src/extra/ai/schema/strict.ts +152 -0
  212. package/src/extra/ai/transports/agui.ts +177 -0
  213. package/src/extra/ai/transports/anthropicMessages.ts +175 -0
  214. package/src/extra/ai/transports/chatCompletions.ts +105 -0
  215. package/src/extra/ai/transports/chromePrompt.ts +77 -0
  216. package/src/extra/ai/transports/encodeOpenResponses.ts +87 -0
  217. package/src/extra/ai/transports/fromAISDK.ts +126 -0
  218. package/src/extra/ai/transports/openResponses.ts +105 -0
  219. package/src/extra/ai/transports/shared.ts +125 -0
  220. package/src/extra/ai/transports/tools.ts +59 -0
  221. package/src/extra/ai/transports/uiMessageStream.ts +107 -0
  222. package/src/extra/ai/webmcp.ts +260 -0
  223. package/src/extra/backoff.ts +9 -0
  224. package/src/extra/behaviors.ts +169 -0
  225. package/src/extra/browserSignals.ts +23 -0
  226. package/src/extra/browserSources.ts +326 -0
  227. package/src/extra/command.ts +3 -3
  228. package/src/extra/controls.ts +47 -0
  229. package/src/extra/copyAsTest.ts +286 -0
  230. package/src/extra/devMcp.ts +386 -0
  231. package/src/extra/devtools.ts +101 -31
  232. package/src/extra/devtoolsActions.ts +379 -0
  233. package/src/extra/devtoolsHook.ts +9 -0
  234. package/src/extra/devtoolsNext.ts +83 -0
  235. package/src/extra/diagnostics/checks/actionLog.ts +108 -0
  236. package/src/extra/diagnostics/checks/behaviors.ts +49 -0
  237. package/src/extra/diagnostics/checks/browserSources.ts +96 -0
  238. package/src/extra/diagnostics/checks/chat.ts +71 -0
  239. package/src/extra/diagnostics/checks/collections.ts +1 -1
  240. package/src/extra/diagnostics/checks/controls.ts +148 -0
  241. package/src/extra/diagnostics/checks/dataset.ts +57 -0
  242. package/src/extra/diagnostics/checks/dom.ts +20 -11
  243. package/src/extra/diagnostics/checks/elementCommands.ts +175 -0
  244. package/src/extra/diagnostics/checks/events.ts +17 -2
  245. package/src/extra/diagnostics/checks/fetch.ts +135 -0
  246. package/src/extra/diagnostics/checks/forms.ts +171 -0
  247. package/src/extra/diagnostics/checks/index.ts +103 -4
  248. package/src/extra/diagnostics/checks/inspect.ts +140 -32
  249. package/src/extra/diagnostics/checks/next.ts +417 -0
  250. package/src/extra/diagnostics/checks/persist.ts +54 -0
  251. package/src/extra/diagnostics/checks/props.ts +10 -5
  252. package/src/extra/diagnostics/checks/public.d.ts +115 -7
  253. package/src/extra/diagnostics/checks/replies.ts +128 -0
  254. package/src/extra/diagnostics/checks/router.ts +100 -0
  255. package/src/extra/diagnostics/checks/rxjsHints.ts +1 -1
  256. package/src/extra/diagnostics/checks/shared.ts +86 -2
  257. package/src/extra/diagnostics/checks/shorthand.ts +115 -0
  258. package/src/extra/diagnostics/checks/sortable.ts +129 -0
  259. package/src/extra/diagnostics/checks/state.ts +100 -7
  260. package/src/extra/diagnostics/checks/statics.ts +48 -0
  261. package/src/extra/diagnostics/checks/strict.ts +10 -67
  262. package/src/extra/diagnostics/checks/timers.ts +80 -0
  263. package/src/extra/diagnostics/checks/viewTransitions.ts +47 -0
  264. package/src/extra/diagnostics/checks/virtual.ts +73 -0
  265. package/src/extra/diagnostics/checks/widgets.ts +109 -0
  266. package/src/extra/diagnostics/checks/wiring.ts +69 -10
  267. package/src/extra/diagnostics/codes.ts +335 -23
  268. package/src/extra/diagnostics/index.ts +34 -9
  269. package/src/extra/diagnostics/legacy.ts +17 -0
  270. package/src/extra/driverFactories.ts +83 -19
  271. package/src/extra/elementCommands.ts +53 -0
  272. package/src/extra/fetchDriver.ts +519 -0
  273. package/src/extra/focusWithin.ts +27 -0
  274. package/src/extra/form.ts +284 -0
  275. package/src/extra/formHelpers.ts +139 -0
  276. package/src/extra/formTool.ts +150 -0
  277. package/src/extra/head.ts +106 -0
  278. package/src/extra/hmr.ts +2 -3
  279. package/src/extra/owned.ts +24 -0
  280. package/src/extra/pager.ts +50 -0
  281. package/src/extra/persist.ts +133 -0
  282. package/src/extra/pwa.ts +1 -1
  283. package/src/extra/queryCache.ts +107 -0
  284. package/src/extra/reducers.ts +1 -1
  285. package/src/extra/reduxDevtools.ts +92 -0
  286. package/src/extra/replies.ts +54 -0
  287. package/src/extra/router.ts +323 -0
  288. package/src/extra/run.ts +90 -217
  289. package/src/extra/selection.ts +76 -0
  290. package/src/extra/socketDriver.ts +265 -0
  291. package/src/extra/sortable.ts +377 -0
  292. package/src/extra/ssr.ts +427 -160
  293. package/src/extra/standardSchema.ts +27 -0
  294. package/src/extra/testing.ts +2550 -175
  295. package/src/extra/timers.ts +95 -0
  296. package/src/extra/undo.ts +261 -0
  297. package/src/extra/viewTransitions.ts +38 -0
  298. package/src/extra/virtual.ts +513 -0
  299. package/src/extra/widget.ts +201 -0
  300. package/src/index.d.ts +2754 -127
  301. package/src/index.ts +45 -4
  302. package/src/jsx-runtime.ts +15 -1
  303. package/src/jsx.ts +2 -1
  304. package/src/lazy.ts +50 -7
  305. package/src/portal.ts +3 -1
  306. package/src/pragma/index.ts +256 -133
  307. package/src/react-peers.d.ts +5 -0
  308. package/src/react.d.ts +43 -0
  309. package/src/react.ts +110 -0
  310. package/src/shared.ts +57 -0
  311. package/src/slot.ts +1 -1
  312. package/src/suspense.ts +3 -1
  313. package/src/switchable.ts +6 -121
  314. package/src/transition.ts +3 -1
  315. package/src/ui/accordion.ts +64 -0
  316. package/src/ui/dialog.ts +129 -0
  317. package/src/ui/disclosure.ts +35 -0
  318. package/src/ui/popover.ts +45 -0
  319. package/src/ui/shared.ts +94 -0
  320. package/src/ui/tabs.ts +82 -0
  321. package/src/ui/toaster.ts +198 -0
  322. package/src/ui/tooltip.ts +70 -0
  323. package/src/ui/zag/combobox.ts +115 -0
  324. package/src/ui/zag/menu.ts +40 -0
  325. package/src/ui/zag/select.ts +54 -0
  326. package/src/ui/zag/shared.ts +43 -0
  327. package/src/ui-combobox.d.ts +20 -0
  328. package/src/ui-combobox.ts +6 -0
  329. package/src/ui-menu.d.ts +30 -0
  330. package/src/ui-menu.ts +6 -0
  331. package/src/ui-select.d.ts +17 -0
  332. package/src/ui-select.ts +6 -0
  333. package/src/ui-zag-types.d.ts +42 -0
  334. package/src/ui.d.ts +208 -0
  335. package/src/ui.ts +15 -0
  336. package/src/vike/+config.ts +9 -1
  337. package/src/vike/ClientOnly.ts +1 -1
  338. package/src/vike/onRenderClient.ts +144 -96
  339. package/src/vike/onRenderHtml.ts +69 -26
  340. package/src/vike/types.ts +7 -1
  341. package/src/vite/globalthis-shim.ts +18 -0
  342. package/src/vite/globalthis.ts +56 -0
  343. package/src/vite/mcp.ts +568 -0
  344. package/src/vite/plugin.d.ts +47 -3
  345. package/src/vite/plugin.ts +262 -25
  346. package/src/zag.d.ts +64 -0
  347. package/src/zag.ts +238 -0
  348. package/dist/vike/+config.cjs.js +0 -66
  349. package/dist/vike/+config.cjs.js.map +0 -1
  350. package/dist/vike/+config.js.map +0 -1
  351. package/src/component.ts +0 -2301
  352. package/src/cycle/isolate/index.ts +0 -196
  353. package/src/cycle/run/index.ts +0 -151
  354. package/src/cycle/run/internals.ts +0 -143
  355. package/src/cycle/state/Collection.ts +0 -184
  356. package/src/cycle/state/index.ts +0 -5
  357. package/src/cycle/state/pickCombine.ts +0 -201
  358. package/src/cycle/state/pickMerge.ts +0 -122
  359. package/src/cycle/state/withState.ts +0 -47
  360. package/src/pragma/fn.ts +0 -55
@@ -0,0 +1,245 @@
1
+ <!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
2
+ # Persistence
3
+
4
+ `persist()` saves part of the app's state in the browser's storage and puts it back when the app starts again. You declare it once, on the root component: which keys to keep, under which storage key, and in which version of their shape.
5
+
6
+ ```jsx live
7
+ // TodoApp.jsx
8
+ import { ABORT, persist } from 'sygnal'
9
+
10
+ export function TodoApp({ state }) {
11
+ const shown = state.filter === 'open' ? state.todos.filter((todo) => !todo.done) : state.todos
12
+ return (
13
+ <main>
14
+ <label>New todo <input className="draft" value={state.draft} /></label>
15
+ <button className="add">Add</button>
16
+ <button className="show-all">All</button>
17
+ <button className="show-open">Open</button>
18
+ <button className="start-over">Start over</button>
19
+ <ul>{shown.map((todo) => <li>{todo.title}</li>)}</ul>
20
+ </main>
21
+ )
22
+ }
23
+
24
+ TodoApp.initialState = { todos: [], filter: 'all', draft: '' }
25
+
26
+ TodoApp.intent = ({ DOM }) => ({
27
+ DRAFT: DOM.input('.draft').value(),
28
+ ADD: DOM.click('.add'),
29
+ SHOW_ALL: DOM.click('.show-all'),
30
+ SHOW_OPEN: DOM.click('.show-open'),
31
+ START_OVER: DOM.click('.start-over'),
32
+ })
33
+
34
+ TodoApp.model = {
35
+ DRAFT: (state, draft) => ({ ...state, draft }),
36
+ ADD: (state) => state.draft
37
+ ? { ...state, todos: [...state.todos, { title: state.draft, done: false }], draft: '' }
38
+ : ABORT,
39
+ SHOW_ALL: (state) => ({ ...state, filter: 'all' }),
40
+ SHOW_OPEN: (state) => ({ ...state, filter: 'open' }),
41
+ START_OVER: {
42
+ STATE: (state) => ({ ...state, todos: [], filter: 'all' }),
43
+ PERSIST: { clear: true },
44
+ },
45
+ }
46
+
47
+ // Version 1 saved { items: ['milk', ...] }; version 2 saves { todos: [{ title, done }], filter }
48
+ TodoApp.persist = persist({
49
+ key: 'todo-app',
50
+ pick: ['todos', 'filter'],
51
+ version: 2,
52
+ migrate: (old, fromVersion) => fromVersion === 1
53
+ ? { todos: old.items.map((title) => ({ title, done: false })), filter: 'all' }
54
+ : undefined,
55
+ })
56
+ ```
57
+
58
+ Reload the page and the todos and the filter are still there; the half-typed `draft` is not, because it isn't picked. **Start over** empties the list and removes the saved copy.
59
+
60
+ ## What is saved
61
+
62
+ The stored value is JSON, `{ version, state }`, under `key`:
63
+
64
+ ```json
65
+ {"version":2,"state":{"todos":[{"title":"milk","done":false}],"filter":"all"}}
66
+ ```
67
+
68
+ `state` has the top-level keys listed in `pick`, or, with `omit` instead, every key except those. [Calculated fields](https://sygnal.js.org/guide/calculated-fields/) are never saved: they are computed again from the restored state. Pick what has to survive a reload (the user's data, a chosen view) and leave out what doesn't (drafts, loading flags, server data you fetch again, an undo history). A [behavior](./behaviors.md#persisted-state-and-undo-steps) of the root component whose slice is UI state (`persist: false`, as [`sortable`](./drag-and-drop.md)'s drag state) is never saved or restored, even when `pick` names it. A sub-component's or Collection item's behavior slice is part of the data it lives in and is saved with it.
69
+
70
+ Values must survive `JSON.stringify`: plain objects, arrays, strings, numbers, booleans and `null`. A `Date` comes back as a string, and a `Map` or `Set` as `{}`.
71
+
72
+ ### A plain format
73
+
74
+ When something else reads or writes the same entry (an older version of the app, another script, a spec that fixes the format), `format: 'plain'` stores the picked keys themselves, with no envelope:
75
+
76
+ ```jsx
77
+ Note.persist = persist({ key: 'note-draft', pick: ['title', 'body'], format: 'plain' })
78
+ ```
79
+
80
+ ```json
81
+ {"title":"Groceries","body":"milk, eggs"}
82
+ ```
83
+
84
+ A plain entry has no version, so `version` and `migrate` don't apply: TypeScript rejects them with `format: 'plain'`, and they are ignored at runtime. A stored value that isn't an object (an array, a string, a number) is ignored, and the app starts from `initialState`. Everything else (`pick`/`omit`, `sync`, `storage`, clearing, the restore and the debounced writes) works as with the default `format: 'versioned'`.
85
+
86
+ ## When it is restored
87
+
88
+ When the app starts, the root component reads the stored entry synchronously, before its first state, and merges the saved keys into `initialState`. The first render already shows the saved todos, and the `INITIALIZE` action carries them: there is no separate "load" step and no flash of the empty list.
89
+
90
+ `persist` works on the **root component** only, the one passed to `run()`. It saves and restores the app's whole state tree, and a sub-component's state is a slice of it: to save a child's state, pick the key the root keeps it under. On any other component the static is ignored, which is [SYG224](https://sygnal.js.org/reference/errors/#syg224) in development and in `sygnal-check`.
91
+
92
+ :::note[Where persist works]
93
+ On any component that is its app's root:
94
+
95
+ - the component you pass to `run()`
96
+ - the component you pass to [`renderComponent`](#testing) in tests
97
+ - an [Astro](https://sygnal.js.org/integration/astro/) island (each island is its own app)
98
+ - a [Vike](https://sygnal.js.org/integration/vike/) page with no Layout or Wrapper
99
+
100
+ Not (yet) on a Vike page, Layout or Wrapper rendered in a Layout/Wrapper shell: the shell is the root there, so `persist` is ignored and SYG224 says so. Save that state yourself: write it with [`STATE.watch`](https://sygnal.js.org/guide/intent/#reacting-to-state-changes-statewatch) and read it back in `BOOTSTRAP`.
101
+ :::
102
+
103
+ ## When it is saved
104
+
105
+ A write happens after the state stops changing for `debounceMs` (100 ms by default), so typing or a burst of actions makes one write. A pending write is also made at once when the page is hidden for good (the `pagehide` event: closing the tab, navigating away) and when the app is disposed. Nothing is written while the picked keys are unchanged, and the initial state is not written back.
106
+
107
+ ## Versions and migrate
108
+
109
+ `version` (default 1) is saved with the state (not with `format: 'plain'`). When the stored entry has another version, `migrate(old, fromVersion)` turns the old `state` into this version's keys; the result is merged into `initialState` like any restore and saved under the new version on the next write. When `migrate` returns nothing (`undefined` or `null`), or there is no `migrate`, the stored entry is ignored and the app starts from `initialState`.
110
+
111
+ Bump `version` whenever the shape of a picked key changes, and keep the `migrate` branches for the versions your users may still have.
112
+
113
+ ## Clearing
114
+
115
+ `PERSIST: { clear: true }` in a model entry removes the stored copy. Like other sinks it can be a function of `(state, data)` that returns the command, or `ABORT` to do nothing:
116
+
117
+ ```jsx
118
+ Account.model = {
119
+ LOG_OUT: {
120
+ STATE: (state) => ({ ...state, user: null, cart: [] }),
121
+ PERSIST: { clear: true },
122
+ },
123
+ }
124
+ ```
125
+
126
+ The state the same action produces isn't saved; the next change is. `PERSIST` needs no driver.
127
+
128
+ ## Several tabs: sync
129
+
130
+ With `sync: true`, a write made in another tab (or window) of the same site is applied in this one, as a built-in `RESTORE` action whose data is the saved keys. The default `RESTORE` merges them into the state; a `RESTORE` entry in your model replaces it:
131
+
132
+ ```jsx
133
+ TodoApp.persist = persist({ key: 'todo-app', pick: ['todos', 'filter'], version: 2, migrate, sync: true })
134
+ ```
135
+
136
+ Clearing in another tab doesn't reset this one: it keeps its state and saves it again on its next change.
137
+
138
+ ## Storage
139
+
140
+ `storage` is `'local'` (`localStorage`, the default), `'session'` (`sessionStorage`: one tab, until it closes) or an object with synchronous `getItem`, `setItem` and `removeItem`. With `sync`, an object can also have `subscribe(fn)`: call `fn(key, newValue)` when another writer changes a key, and return a function that unsubscribes.
141
+
142
+ ```jsx
143
+ const memory = new Map()
144
+
145
+ export const memoryStorage = {
146
+ getItem: (key) => memory.get(key) ?? null,
147
+ setItem: (key, value) => { memory.set(key, value) },
148
+ removeItem: (key) => { memory.delete(key) },
149
+ }
150
+ ```
151
+
152
+ Asynchronous storages (IndexedDB, a server) don't fit the synchronous restore. Load from those with a request in `BOOTSTRAP` and save with [`STATE.watch`](https://sygnal.js.org/guide/intent/#reacting-to-state-changes-statewatch) instead.
153
+
154
+ During server rendering nothing is read or written: `renderToString` runs views only.
155
+
156
+ ## Server rendering: hydrate
157
+
158
+ When the client starts from server-rendered HTML (the [`hydrateState`](./ssr.md) state), restoring before the first render would make that render differ from the server's markup. So when the app hydrates, the first render uses the server's state, and the saved keys follow in a `RESTORE` action once it is on the page. The app hydrates when:
159
+
160
+ - `run()`'s mount point starts with markup from [`renderToString()`](./ssr.md): its root element carries a `data-sygnal-ssr` attribute, which the first client render removes. Other markup in the mount point, such as a client-only app's loading placeholder, isn't server markup: the saved keys are part of the first render
161
+ - an [Astro](https://sygnal.js.org/integration/astro/) island was rendered on the server (not `client:only`)
162
+ - a [Vike](https://sygnal.js.org/integration/vike/) page hydrates its server HTML (not a client-side navigation)
163
+
164
+ ```jsx
165
+ App.persist = persist({ key: 'app', pick: ['theme'] })
166
+ App.initialState = window.__SYGNAL_STATE__ || App.initialState
167
+
168
+ run(App, {}, { mountPoint: '#app' })
169
+ ```
170
+
171
+ The `hydrate` option overrides the detection: `hydrate: true` always restores after the first render, `hydrate: false` always before it. Use `hydrate: true` when the server's HTML doesn't come from `renderToString()` (another renderer, or markup you post-process without the attribute).
172
+
173
+ ## With undo
174
+
175
+ Keep an [undo history](./undo.md) out of the saved state: pick the document, not `history`. After a reload the document is back and the history starts empty.
176
+
177
+ ## Diagnostics
178
+
179
+ | Code | When |
180
+ |---|---|
181
+ | [SYG642](https://sygnal.js.org/reference/errors/#syg642) (warning) | The stored entry isn't JSON, `migrate` threw, or a write failed (the storage is full or blocked). The app continues: from `initialState` after a failed restore, unsaved after a failed write (retried at the next state change). Printed in production too, once per kind (restore, save, clear): a storage that refuses every write warns once, and again only after a save has worked |
182
+ | [SYG223](https://sygnal.js.org/reference/errors/#syg223) (warning) | A `pick` or `omit` key that isn't a key of `initialState` (a typo) |
183
+ | [SYG224](https://sygnal.js.org/reference/errors/#syg224) (error) | `persist` on a component that isn't the root |
184
+
185
+ ## Testing
186
+
187
+ `renderComponent` gives a persisting root a fake storage. The `storage` option fills it (key to stored entry, or a raw string), `t.storage(key)` reads an entry, and `t.settle()` makes the pending writes:
188
+
189
+ ```jsx
190
+ // TodoApp.test.jsx
191
+ import { it, expect } from 'vitest'
192
+ import { renderComponent } from 'sygnal'
193
+ import { TodoApp } from './TodoApp.jsx'
194
+
195
+ it('restores version 1 todos and saves version 2', async () => {
196
+ const t = renderComponent(TodoApp, {
197
+ storage: { 'todo-app': { version: 1, state: { items: ['milk'] } } },
198
+ })
199
+ await t.ready()
200
+ expect(t.state.todos).toEqual([{ title: 'milk', done: false }])
201
+
202
+ t.simulateAction('DRAFT', 'eggs')
203
+ t.simulateAction('ADD')
204
+ await t.settle()
205
+ expect(t.storage('todo-app')).toEqual({
206
+ version: 2,
207
+ state: { todos: [{ title: 'milk', done: false }, { title: 'eggs', done: false }], filter: 'all' },
208
+ })
209
+ t.dispose()
210
+ })
211
+ ```
212
+
213
+ The object you pass is used as the storage, not copied: writes land in it, and two `renderComponent` calls given the same object share it, so `sync: true` can be tested with two instances. The fake serves `'local'` and `'session'` alike; a component whose `storage` is an object uses that object.
214
+
215
+ With `format: 'plain'` the entries are the stored keys themselves, both in the `storage` option and from `t.storage(key)`:
216
+
217
+ ```jsx
218
+ it('saves the draft as { title, body }', async () => {
219
+ const t = renderComponent(Note, { storage: { 'note-draft': { title: 'Groceries', body: '' } } })
220
+ await t.ready()
221
+ expect(t.state.title).toBe('Groceries')
222
+
223
+ t.simulateAction('BODY', 'milk')
224
+ await t.settle()
225
+ expect(t.storage('note-draft')).toEqual({ title: 'Groceries', body: 'milk' })
226
+ t.dispose()
227
+ })
228
+ ```
229
+
230
+ ## Options
231
+
232
+ | Option | Default | |
233
+ |---|---|---|
234
+ | `key` | (required) | The storage key |
235
+ | `pick` | all keys | The top-level state keys to save |
236
+ | `omit` | `[]` | The top-level state keys not to save (instead of `pick`) |
237
+ | `version` | `1` | Saved with the state; another stored version goes through `migrate` |
238
+ | `migrate` | none | `(old, fromVersion) => keys` for a stored entry of another version; nothing discards it |
239
+ | `format` | `'versioned'` | `'versioned'` stores `{ version, state }`; `'plain'` stores the picked keys themselves (no `version` / `migrate`) |
240
+ | `storage` | `'local'` | `'local'`, `'session'` or a synchronous `{ getItem, setItem, removeItem, subscribe? }` |
241
+ | `sync` | `false` | Apply other tabs' writes (a `RESTORE` action) |
242
+ | `hydrate` | detected | Restore after the first render (in `RESTORE`): `true` / `false` override the detection of server-rendered HTML |
243
+ | `debounceMs` | `100` | Wait this long without a state change before writing |
244
+
245
+ In TypeScript, `pick` and `omit` are checked against the root component's state keys.
@@ -0,0 +1,163 @@
1
+ <!-- Generated from docs/src/content/docs/recipes by scripts/copy-guides.mjs; online: https://sygnal.js.org/recipes/ -->
2
+ # Structured Output into a Form
3
+
4
+ A contact form with a shortcut: paste an email signature, and a [chat model](../ai-chat.md) fills in the fields. The model answers with [structured output](../ai-chat.md#structured-output), validated against a schema, and each field it found goes into the [`form` behavior](../forms.md) as if the user had typed it. Nothing is saved until the user checks the fields and submits, with the form's own validation.
5
+
6
+ The pieces:
7
+
8
+ - **Two schemas.** The extraction schema says what the model may answer: every field can be `null` (not in the text). The form's schema says what the user may submit.
9
+ - **The `ok` action fills the form** with one `form.SET` holding the fields the model found, so those fields are validated and marked as changed like typed ones.
10
+ - **The model never submits.** The user does.
11
+
12
+ ## The form
13
+
14
+ In this demo, the signature is read as soon as the demo shows. Paste another one and fill again; or edit a field and submit.
15
+
16
+ ```js live-server
17
+ import { encodeOpenResponses } from 'sygnal/ai'
18
+
19
+ let nextId = 1
20
+
21
+ export default {
22
+ // the chat model: reads a signature (a real model reads anything)
23
+ 'POST /v1/responses': ({ json }) => {
24
+ const text = String(json.input.at(-1).content)
25
+ const contact = {
26
+ name: text.split('\n')[0].trim() || null,
27
+ email: text.match(/[\w.+-]+@[\w-]+\.[\w.]+/)?.[0] ?? null,
28
+ company: text.split('\n')[1]?.split('|')[1]?.trim() || null,
29
+ phone: text.match(/\+?[\d ()-]{7,}\d/)?.[0].trim() ?? null,
30
+ }
31
+ return { sse: encodeOpenResponses(JSON.stringify(contact).match(/.{1,20}/g)), delayMs: 300, chunkMs: 40 }
32
+ },
33
+ 'POST /api/contacts': ({ json }) => ({ status: 201, json: { id: nextId++, ...json } }),
34
+ }
35
+ ```
36
+
37
+ ```jsx live live-height=420
38
+ import { z } from 'zod'
39
+ import { ABORT, form } from 'sygnal'
40
+
41
+ // what the model may answer: null for "not in the text"
42
+ const Extracted = z.object({
43
+ name: z.string().nullable(),
44
+ email: z.string().nullable(),
45
+ company: z.string().nullable(),
46
+ phone: z.string().nullable(),
47
+ })
48
+
49
+ // what the user may submit
50
+ const contactSchema = z.object({
51
+ name: z.string().trim().min(1, 'Enter a name'),
52
+ email: z.string().trim().email('Enter a valid email address'),
53
+ company: z.string().trim(),
54
+ phone: z.string().trim(),
55
+ })
56
+
57
+ const SIGNATURE = 'Dana Whitfield\nHead of Operations | Northwind Traders\ndana.whitfield@northwind.example\n+1 (555) 014-2290'
58
+
59
+ const read = (text) => ({
60
+ instructions: 'Extract the contact details from the text. Use null for anything that is not there.',
61
+ messages: [{ role: 'user', content: text }],
62
+ output: Extracted,
63
+ ok: 'EXTRACTED',
64
+ error: 'NOT_EXTRACTED',
65
+ })
66
+
67
+ function Field({ field, label, id }) {
68
+ return (
69
+ <p>
70
+ <label for={id}>{label}</label>{' '}
71
+ <input id={id} name={field.name} value={field.value} aria-invalid={field.invalid} aria-describedby={`${id}-error`} />
72
+ <span id={`${id}-error`}> {field.error}</span>
73
+ </p>
74
+ )
75
+ }
76
+
77
+ function NewContact({ state, uid }) {
78
+ const f = state.form.fields
79
+ return (
80
+ <section className="new-contact">
81
+ <form className="paste">
82
+ <label for={uid('paste')}>Paste a signature</label>
83
+ <textarea id={uid('paste')} className="pasted" rows="4" value={state.pasted} />
84
+ <button type="submit" disabled={state.reading}>{state.reading ? 'Reading…' : 'Fill in the form'}</button>
85
+ <span role="status"> {state.note}</span>
86
+ </form>
87
+ <form className="contact" noValidate>
88
+ <Field field={f.name} label="Name" id={uid('name')} />
89
+ <Field field={f.email} label="Email" id={uid('email')} />
90
+ <Field field={f.company} label="Company" id={uid('company')} />
91
+ <Field field={f.phone} label="Phone" id={uid('phone')} />
92
+ <button type="submit" disabled={state.form.submitting}>Save contact</button>
93
+ <span className="saved"> {state.savedId ? `Saved as contact ${state.savedId}.` : ''}</span>
94
+ </form>
95
+ </section>
96
+ )
97
+ }
98
+
99
+ NewContact.initialState = { pasted: SIGNATURE, reading: false, note: '', savedId: null }
100
+
101
+ NewContact.uses = {
102
+ form: form(contactSchema, { form: '.contact', values: { name: '', email: '', company: '', phone: '' }, submit: 'SAVE' }),
103
+ }
104
+
105
+ NewContact.intent = ({ DOM }) => ({
106
+ PASTE: DOM.input('.pasted').value(),
107
+ FILL: DOM.select('.paste').events('submit', { preventDefault: true }),
108
+ })
109
+
110
+ NewContact.model = {
111
+ BOOTSTRAP: {
112
+ STATE: (state) => ({ ...state, reading: true }),
113
+ LLM: (state) => read(state.pasted),
114
+ },
115
+ PASTE: (state, pasted) => ({ ...state, pasted }),
116
+ FILL: {
117
+ STATE: (state) => (state.pasted.trim() ? { ...state, reading: true, note: '' } : ABORT),
118
+ LLM: (state) => (state.pasted.trim() ? read(state.pasted) : ABORT),
119
+ },
120
+ EXTRACTED: {
121
+ STATE: (state, { value }) => ({ ...state, reading: false, note: value.name === null ? 'No name found: add it, then save.' : 'Check the fields, then save.' }),
122
+ // the fields the model found, as if the user had typed them
123
+ EFFECT: (state, { value }, next) => {
124
+ next('form.SET', { values: Object.fromEntries(Object.entries(value).filter(([, text]) => text !== null)) })
125
+ },
126
+ },
127
+ NOT_EXTRACTED: (state) => ({ ...state, reading: false, note: 'Could not read that. Fill in the form by hand.' }),
128
+ SAVE: { HTTP: (state, values) => ({ url: '/api/contacts', method: 'POST', json: values, ok: 'form.DONE', error: 'form.ERRORS' }) },
129
+ 'form.DONE': (state, contact) => ({ ...state, savedId: contact.id }),
130
+ }
131
+ ```
132
+
133
+ ```css live
134
+ .new-contact textarea { width: 100%; }
135
+ .new-contact form { margin-bottom: 1rem; }
136
+ ```
137
+
138
+ - **The extraction schema is loose, the form's is strict.** The model may find no email; the form still requires one. A wrong value from the model (an email without `@`) shows the form's error once the user leaves the field or submits, as for anything typed.
139
+ - **`form.SET`, not `form.RESET`**: a reset makes the values the form's starting point, so they would count as unchanged, and it would clear what the user had already typed in the fields the model didn't find. `form.SET` changes only the fields it names, in one validation.
140
+ - **`null`, not optional.** Providers' strict modes require every key, so "not found" is a `null` value. `.nullable()` also tells the model plainly that it may say so.
141
+ - **Keep the user in the loop.** The model's answer is a suggestion: the form shows it, the user saves it.
142
+
143
+ ## Testing
144
+
145
+ `t.respond('LLM', object)` sends the object as the reply's JSON, so `EXTRACTED` gets it as `value`:
146
+
147
+ ```js
148
+ import { it, expect } from 'vitest'
149
+ import { renderComponent } from 'sygnal'
150
+ import NewContact from './NewContact.jsx'
151
+
152
+ it('fills the fields the model found, and saves only on submit', async () => {
153
+ const t = renderComponent(NewContact)
154
+ await t.respond('LLM', { name: 'Dana Whitfield', email: 'dana@northwind.example', company: null, phone: null })
155
+ expect(t.state.form.values).toEqual({ name: 'Dana Whitfield', email: 'dana@northwind.example', company: '', phone: '' })
156
+ expect(t.requests('HTTP')).toEqual([])
157
+
158
+ t.simulateEvent('.contact', 'submit')
159
+ await t.respond('HTTP', { id: 7 })
160
+ expect(t.state.savedId).toBe(7)
161
+ t.dispose()
162
+ })
163
+ ```
@@ -0,0 +1,155 @@
1
+ <!-- Generated from docs/src/content/docs/recipes by scripts/copy-guides.mjs; online: https://sygnal.js.org/recipes/ -->
2
+ # On-device Summaries
3
+
4
+ Chrome can run a small language model on the user's device, through the [Prompt API](https://developer.chrome.com/docs/ai/prompt-api) (`LanguageModel`). The `chromePrompt()` transport from `sygnal/ai` puts it behind the [chat driver](../ai-chat.md), so a component asks it for a summary exactly as it would ask a hosted model. Nothing leaves the device: no server, no key, no cost per request.
5
+
6
+ Not every visitor has it: other browsers don't, and Chrome needs a capable device and a one-time model download. So the feature must **degrade**: where the model is unavailable, the component shows a plain excerpt instead.
7
+
8
+ ## main.js
9
+
10
+ ```js
11
+ import { run } from 'sygnal'
12
+ import { makeChatDriver, chromePrompt } from 'sygnal/ai'
13
+ import Article from './Article.jsx'
14
+
15
+ run(Article, { LLM: makeChatDriver({ transport: chromePrompt() }) })
16
+ ```
17
+
18
+ Where the browser has no `LanguageModel`, or the device can't run it, every request fails at once with an Error whose `status` is `'unavailable'`. The component's `error` action handles that like any failure, so `main.js` needs no check.
19
+
20
+ To use a hosted model where the on-device one is missing, choose the transport before `run()` with `status()`:
21
+
22
+ ```js
23
+ import { run } from 'sygnal'
24
+ import { makeChatDriver, chromePrompt, uiMessageStream } from 'sygnal/ai'
25
+ import Article from './Article.jsx'
26
+
27
+ const onDevice = chromePrompt()
28
+ const transport = (await onDevice.status()) === 'unavailable' ? uiMessageStream('/api/summarize') : onDevice
29
+
30
+ run(Article, { LLM: makeChatDriver({ transport }) })
31
+ ```
32
+
33
+ `status()` resolves with `'available'`, `'downloadable'`, `'downloading'` or `'unavailable'`. A `'downloadable'` model is downloaded by its first request, which must come from a user action (a click), so summarise on a click rather than on load. `chromePrompt({ monitor })` gets the download's progress events.
34
+
35
+ A component can show the status too, for example to say "Summaries need a one-time download" before the first click. A promise can't be read in a view, so give `status` to [`driverFromAsync`](https://sygnal.js.org/guide/custom-drivers/) and ask for it like any request:
36
+
37
+ ```js
38
+ import { run, driverFromAsync } from 'sygnal'
39
+ import { makeChatDriver, chromePrompt } from 'sygnal/ai'
40
+ import Article from './Article.jsx'
41
+
42
+ const onDevice = chromePrompt()
43
+ run(Article, { LLM: makeChatDriver({ transport: onDevice }), MODEL: driverFromAsync(onDevice.status) })
44
+ ```
45
+
46
+ ```js
47
+ Article.model = {
48
+ BOOTSTRAP: { MODEL: () => ({ ok: 'MODEL_STATUS' }) },
49
+ MODEL_STATUS: (state, status) => ({ ...state, model: status }),
50
+ }
51
+ ```
52
+
53
+ In a test, `await t.respond('MODEL', 'downloadable')` answers it.
54
+
55
+ ## The component
56
+
57
+ The component is the same whatever the transport. In this demo it runs on the page's demo model (as every demo here does), and asks for a summary as soon as it shows. In your app, the same code runs on the device.
58
+
59
+ ```js live-server
60
+ import { encodeOpenResponses } from 'sygnal/ai'
61
+
62
+ export default {
63
+ 'POST /v1/responses': () => ({
64
+ sse: encodeOpenResponses(['Octopuses ', 'have three hearts ', 'and blue blood, ', 'and each arm ', 'can act ', 'on its own.']),
65
+ delayMs: 300,
66
+ chunkMs: 80,
67
+ }),
68
+ }
69
+ ```
70
+
71
+ ```jsx live live-height=260
72
+ import { ABORT } from 'sygnal'
73
+
74
+ const TEXT = 'The octopus has three hearts: two pump blood through the gills, and one through the rest of the body. Its blood is blue, because it carries oxygen with copper instead of iron. Two thirds of its neurons are in its arms, so each arm can taste, touch and react without waiting for the brain. Most octopuses live only a year or two.'
75
+
76
+ // the fallback: the first two sentences
77
+ const excerpt = (text) => text.match(/[^.!?]+[.!?]+/g)?.slice(0, 2).join(' ') ?? text
78
+
79
+ const summarize = (text) => ({
80
+ instructions: 'Summarise the text in one sentence of at most 25 words.',
81
+ messages: [{ role: 'user', content: text }],
82
+ key: 'summary',
83
+ delta: 'SUMMARY',
84
+ ok: 'SUMMARIZED',
85
+ error: 'NO_SUMMARY',
86
+ })
87
+
88
+ function Article({ state }) {
89
+ return (
90
+ <article className="article">
91
+ <p>{state.text}</p>
92
+ <button type="button" className="summarize" disabled={state.status === 'busy'}>Summarise</button>
93
+ <p aria-live="polite">
94
+ {state.status === 'busy' && !state.summary && 'Summarising…'}
95
+ {state.summary && <><b>Summary:</b> {state.summary}</>}
96
+ {state.status === 'fallback' && <><b>Excerpt:</b> {excerpt(state.text)} <i>(summaries need Chrome's built-in model)</i></>}
97
+ </p>
98
+ </article>
99
+ )
100
+ }
101
+
102
+ Article.initialState = { text: TEXT, summary: '', status: 'ready' }
103
+
104
+ Article.intent = ({ DOM }) => ({
105
+ SUMMARIZE: DOM.click('.summarize'),
106
+ })
107
+
108
+ Article.model = {
109
+ BOOTSTRAP: {
110
+ STATE: (state) => ({ ...state, status: 'busy' }),
111
+ LLM: (state) => summarize(state.text),
112
+ },
113
+ SUMMARIZE: {
114
+ STATE: (state) => (state.status === 'busy' ? ABORT : { ...state, summary: '', status: 'busy' }),
115
+ LLM: (state) => (state.status === 'busy' ? ABORT : summarize(state.text)),
116
+ },
117
+ SUMMARY: (state, { text }) => ({ ...state, summary: text }),
118
+ SUMMARIZED: (state, { text }) => ({ ...state, summary: text, status: 'ready' }),
119
+ // no model on this device: show the excerpt; any other failure: let the user try again
120
+ NO_SUMMARY: (state, { error }) => ({ ...state, summary: '', status: error.status === 'unavailable' ? 'fallback' : 'ready' }),
121
+ }
122
+ ```
123
+
124
+ - **The summary streams in** through `SUMMARY`, and `SUMMARIZED` ends the busy state.
125
+ - **Degrade, don't hide.** The excerpt is computed in the view from the text, so it needs no model. Where a summary isn't essential, an excerpt is an honest fallback.
126
+ - **Keep prompts short and in `instructions`.** The on-device model is small and has a small context window. Long articles need splitting, or a hosted model.
127
+
128
+ ## What the on-device model can't do
129
+
130
+ - **No tools.** The Prompt API has no tool calling yet, so `chromePrompt()` doesn't send `tools`. An assistant that operates the app needs a hosted model.
131
+ - **One system prompt.** The Prompt API takes system text only at the start of a session, so `instructions` and every `system` message (wherever it is in the conversation) are joined into that one prompt.
132
+ - **Structured output works.** `output` becomes the Prompt API's `responseConstraint`, and `ok` gets the validated `value`, as with any transport.
133
+
134
+ ## Testing
135
+
136
+ The LLM fake doesn't care which transport `main.js` uses. Fail a request with the transport's error to test the fallback:
137
+
138
+ ```js
139
+ import { it, expect } from 'vitest'
140
+ import { renderComponent } from 'sygnal'
141
+ import Article from './Article.jsx'
142
+
143
+ it('summarises, and falls back to an excerpt without the model', async () => {
144
+ const t = renderComponent(Article)
145
+ await t.respond('LLM', 'Octopuses have three hearts.')
146
+ expect(t.html()).toContain('Summary:')
147
+
148
+ t.simulateEvent('.summarize', 'click')
149
+ await t.fail('LLM', Object.assign(new Error('the on-device model is unavailable'), { status: 'unavailable' }))
150
+ expect(t.html()).toContain('Excerpt:')
151
+ t.dispose()
152
+ })
153
+ ```
154
+
155
+ To test the transport itself, `chromePrompt({ LanguageModel })` takes a stub of the Prompt API instead of the browser's.