sygnal 7.0.0 → 7.2.0-preview.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 (343) hide show
  1. package/CHANGELOG.md +632 -0
  2. package/dist/agent-ui.cjs.js +592 -0
  3. package/dist/agent-ui.cjs.js.map +1 -0
  4. package/dist/agent-ui.esm.js +582 -0
  5. package/dist/agent-ui.esm.js.map +1 -0
  6. package/dist/api.cjs.js +63 -0
  7. package/dist/api.cjs.js.map +1 -0
  8. package/dist/api.esm.js +2 -0
  9. package/dist/api.esm.js.map +1 -0
  10. package/dist/astro/index.cjs.js +968 -4
  11. package/dist/astro/index.cjs.js.map +1 -1
  12. package/dist/astro/index.mjs +968 -4
  13. package/dist/astro/index.mjs.map +1 -1
  14. package/dist/astro/server.cjs.js +73 -45
  15. package/dist/astro/server.cjs.js.map +1 -1
  16. package/dist/astro/server.mjs +73 -45
  17. package/dist/astro/server.mjs.map +1 -1
  18. package/dist/canvas.cjs.js +2919 -0
  19. package/dist/canvas.cjs.js.map +1 -0
  20. package/dist/canvas.esm.js +2866 -0
  21. package/dist/canvas.esm.js.map +1 -0
  22. package/dist/devtools.cjs.js +38 -6
  23. package/dist/devtools.cjs.js.map +1 -1
  24. package/dist/devtools.esm.js +38 -6
  25. package/dist/devtools.esm.js.map +1 -1
  26. package/dist/diagnostics.cjs.js +224 -20
  27. package/dist/diagnostics.cjs.js.map +1 -1
  28. package/dist/diagnostics.esm.js +224 -20
  29. package/dist/diagnostics.esm.js.map +1 -1
  30. package/dist/guide/accessibility.md +1 -1
  31. package/dist/guide/ai-compose.md +150 -0
  32. package/dist/guide/behaviors.md +2 -1
  33. package/dist/guide/collaborative-state.md +265 -0
  34. package/dist/guide/element-commands.md +36 -2
  35. package/dist/guide/forms-reference.md +5 -5
  36. package/dist/guide/forms.md +3 -2
  37. package/dist/guide/http.md +28 -1
  38. package/dist/guide/islands.md +240 -0
  39. package/dist/guide/recipes/code-editor.md +1 -1
  40. package/dist/guide/renderers/agent-ui.md +138 -0
  41. package/dist/guide/renderers/custom-targets.md +126 -0
  42. package/dist/guide/renderers/overview.md +120 -0
  43. package/dist/guide/renderers/react-native.md +128 -0
  44. package/dist/guide/renderers/terminal.md +306 -0
  45. package/dist/guide/renderers/three.md +332 -0
  46. package/dist/guide/renderers/write-once.md +137 -0
  47. package/dist/guide/resources.md +2 -0
  48. package/dist/guide/sandboxed-components.md +223 -0
  49. package/dist/guide/timers.md +32 -5
  50. package/dist/guide/typed-apis.md +442 -0
  51. package/dist/guide/ui/accordion.md +11 -0
  52. package/dist/guide/ui/combobox.md +112 -8
  53. package/dist/guide/ui/dialog.md +96 -20
  54. package/dist/guide/ui/menu.md +57 -5
  55. package/dist/guide/ui/overview.md +8 -4
  56. package/dist/guide/ui/popover.md +69 -9
  57. package/dist/guide/ui/select.md +54 -4
  58. package/dist/guide/ui/tabs.md +1 -1
  59. package/dist/guide/ui/tooltip.md +7 -5
  60. package/dist/guide/virtual-collections.md +3 -0
  61. package/dist/guide/widgets.md +90 -1
  62. package/dist/host.cjs.js +2181 -0
  63. package/dist/host.cjs.js.map +1 -0
  64. package/dist/host.esm.js +2146 -0
  65. package/dist/host.esm.js.map +1 -0
  66. package/dist/index.cjs.js +3948 -586
  67. package/dist/index.cjs.js.map +1 -1
  68. package/dist/index.d.ts +584 -44
  69. package/dist/index.esm.js +3918 -586
  70. package/dist/index.esm.js.map +1 -1
  71. package/dist/primitives.cjs.js +143 -0
  72. package/dist/primitives.cjs.js.map +1 -0
  73. package/dist/primitives.esm.js +133 -0
  74. package/dist/primitives.esm.js.map +1 -0
  75. package/dist/react-native.cjs.js +248 -0
  76. package/dist/react-native.cjs.js.map +1 -0
  77. package/dist/react-native.esm.js +244 -0
  78. package/dist/react-native.esm.js.map +1 -0
  79. package/dist/replay.cjs.js +39 -0
  80. package/dist/replay.cjs.js.map +1 -0
  81. package/dist/replay.esm.js +2 -0
  82. package/dist/replay.esm.js.map +1 -0
  83. package/dist/sandbox-build.cjs.js +140 -0
  84. package/dist/sandbox-build.cjs.js.map +1 -0
  85. package/dist/sandbox-build.esm.js +137 -0
  86. package/dist/sandbox-build.esm.js.map +1 -0
  87. package/dist/sandbox-kernel.esm.js +212 -0
  88. package/dist/sandbox-kernel.esm.js.map +1 -0
  89. package/dist/sandbox-worker.cjs.js +857 -0
  90. package/dist/sandbox-worker.cjs.js.map +1 -0
  91. package/dist/sandbox-worker.esm.js +854 -0
  92. package/dist/sandbox-worker.esm.js.map +1 -0
  93. package/dist/sandbox.cjs.js +1425 -0
  94. package/dist/sandbox.cjs.js.map +1 -0
  95. package/dist/sandbox.esm.js +1406 -0
  96. package/dist/sandbox.esm.js.map +1 -0
  97. package/dist/sygnal.min.js +1 -1
  98. package/dist/sygnal.min.js.map +1 -1
  99. package/dist/terminal.cjs.js +1314 -0
  100. package/dist/terminal.cjs.js.map +1 -0
  101. package/dist/terminal.esm.js +1304 -0
  102. package/dist/terminal.esm.js.map +1 -0
  103. package/dist/three.cjs.js +1111 -0
  104. package/dist/three.cjs.js.map +1 -0
  105. package/dist/three.esm.js +1079 -0
  106. package/dist/three.esm.js.map +1 -0
  107. package/dist/ui-combobox.cjs.js +256 -18
  108. package/dist/ui-combobox.cjs.js.map +1 -1
  109. package/dist/ui-combobox.esm.js +256 -18
  110. package/dist/ui-combobox.esm.js.map +1 -1
  111. package/dist/ui-menu.cjs.js +150 -16
  112. package/dist/ui-menu.cjs.js.map +1 -1
  113. package/dist/ui-menu.esm.js +151 -17
  114. package/dist/ui-menu.esm.js.map +1 -1
  115. package/dist/ui-select.cjs.js +95 -9
  116. package/dist/ui-select.cjs.js.map +1 -1
  117. package/dist/ui-select.esm.js +95 -9
  118. package/dist/ui-select.esm.js.map +1 -1
  119. package/dist/ui.cjs.js +679 -173
  120. package/dist/ui.cjs.js.map +1 -1
  121. package/dist/ui.esm.js +679 -175
  122. package/dist/ui.esm.js.map +1 -1
  123. package/dist/vike/config/package.json +1 -1
  124. package/dist/vite/plugin.cjs.js +968 -4
  125. package/dist/vite/plugin.cjs.js.map +1 -1
  126. package/dist/vite/plugin.mjs +968 -4
  127. package/dist/vite/plugin.mjs.map +1 -1
  128. package/dist/worker-page.cjs.js +669 -0
  129. package/dist/worker-page.cjs.js.map +1 -0
  130. package/dist/worker-page.esm.js +665 -0
  131. package/dist/worker-page.esm.js.map +1 -0
  132. package/dist/worker.cjs.js +377 -0
  133. package/dist/worker.cjs.js.map +1 -0
  134. package/dist/worker.esm.js +374 -0
  135. package/dist/worker.esm.js.map +1 -0
  136. package/dist/yjs-testing.cjs.js +98 -0
  137. package/dist/yjs-testing.cjs.js.map +1 -0
  138. package/dist/yjs-testing.esm.js +77 -0
  139. package/dist/yjs-testing.esm.js.map +1 -0
  140. package/dist/yjs.cjs.js +923 -0
  141. package/dist/yjs.cjs.js.map +1 -0
  142. package/dist/yjs.esm.js +900 -0
  143. package/dist/yjs.esm.js.map +1 -0
  144. package/dist/zag.cjs.js +11 -1
  145. package/dist/zag.cjs.js.map +1 -1
  146. package/dist/zag.esm.js +11 -1
  147. package/dist/zag.esm.js.map +1 -1
  148. package/llms/ai.md +65 -0
  149. package/llms/collaboration.md +5 -0
  150. package/llms/graphics.md +10 -0
  151. package/llms/renderers.md +7 -0
  152. package/llms/replay.md +5 -0
  153. package/llms/sandbox.md +5 -0
  154. package/llms/typed-apis.md +5 -0
  155. package/llms/ui.md +10 -0
  156. package/llms-full.txt +444 -0
  157. package/llms.txt +37 -35
  158. package/package.json +139 -3
  159. package/src/agent-ui/mcp.ts +152 -0
  160. package/src/agent-ui/snapshot.ts +212 -0
  161. package/src/agent-ui.d.ts +106 -0
  162. package/src/agent-ui.ts +249 -0
  163. package/src/ai.d.ts +95 -2
  164. package/src/api.d.ts +303 -0
  165. package/src/api.ts +9 -0
  166. package/src/canvas/Canvas.ts +585 -0
  167. package/src/canvas/backend.ts +627 -0
  168. package/src/canvas/geometry.ts +187 -0
  169. package/src/canvas/gesture.ts +23 -0
  170. package/src/canvas/image.ts +197 -0
  171. package/src/canvas/kit.ts +130 -0
  172. package/src/canvas/paint.ts +260 -0
  173. package/src/canvas/pointer.ts +25 -0
  174. package/src/canvas/snap.ts +68 -0
  175. package/src/canvas/svg.ts +99 -0
  176. package/src/canvas.d.ts +373 -0
  177. package/src/canvas.ts +46 -0
  178. package/src/core/actions.ts +14 -3
  179. package/src/core/cell.ts +12 -4
  180. package/src/core/debug.ts +8 -3
  181. package/src/core/hooks.ts +16 -2
  182. package/src/core/hosts/collection.ts +1 -1
  183. package/src/core/hosts/switchable.ts +26 -8
  184. package/src/core/instance.ts +98 -45
  185. package/src/core/markers/portal.ts +4 -0
  186. package/src/core/registry.ts +4 -0
  187. package/src/core/runtime.ts +34 -25
  188. package/src/core/statics.ts +5 -5
  189. package/src/cycle/dom/EventDelegator.ts +37 -33
  190. package/src/cycle/dom/MainDOMSource.ts +16 -14
  191. package/src/cycle/dom/enrichEventStream.ts +13 -9
  192. package/src/cycle/dom/fromEvent.ts +6 -10
  193. package/src/cycle/dom/index.ts +11 -7
  194. package/src/cycle/dom/isolate.ts +13 -6
  195. package/src/cycle/dom/makeDOMDriver.ts +6 -2
  196. package/src/cycle/dom/utils.ts +9 -2
  197. package/src/cycle/run/adapt.ts +4 -12
  198. package/src/devtools.d.ts +9 -0
  199. package/src/devtools.ts +2 -1
  200. package/src/extra/ai/compose/catalog.ts +2 -1
  201. package/src/extra/ai/compose/decisions.ts +1 -2
  202. package/src/extra/ai/compose/generated.ts +234 -0
  203. package/src/extra/ai/compose/index.ts +6 -2
  204. package/src/extra/ai/compose/layout.ts +3 -1
  205. package/src/extra/ai/compose/llm.ts +7 -3
  206. package/src/extra/ai/compose/types.ts +2 -2
  207. package/src/extra/ai/transports/shared.ts +3 -1
  208. package/src/extra/api/contract.ts +485 -0
  209. package/src/extra/api/index.ts +26 -0
  210. package/src/extra/behaviors.ts +3 -3
  211. package/src/extra/controlled.ts +44 -0
  212. package/src/extra/copyAsTest.ts +22 -0
  213. package/src/extra/devtools.ts +5 -3
  214. package/src/extra/devtoolsActions.ts +8 -0
  215. package/src/extra/devtoolsNext.ts +1 -1
  216. package/src/extra/diagnostics/checks/actionLog.ts +8 -3
  217. package/src/extra/diagnostics/checks/behaviors.ts +25 -0
  218. package/src/extra/diagnostics/checks/elementCommands.ts +17 -5
  219. package/src/extra/diagnostics/checks/index.ts +1 -1
  220. package/src/extra/diagnostics/checks/next.ts +78 -4
  221. package/src/extra/diagnostics/checks/props.ts +6 -1
  222. package/src/extra/diagnostics/checks/replies.ts +11 -7
  223. package/src/extra/diagnostics/checks/timers.ts +8 -2
  224. package/src/extra/diagnostics/checks/virtual.ts +11 -0
  225. package/src/extra/diagnostics/codes.ts +51 -1
  226. package/src/extra/elementCommands.ts +20 -4
  227. package/src/extra/fetchDriver.ts +4 -1
  228. package/src/extra/fetchEventSource.ts +6 -2
  229. package/src/extra/field.ts +29 -0
  230. package/src/extra/form.ts +42 -16
  231. package/src/extra/frame.ts +54 -0
  232. package/src/extra/keymap.ts +41 -0
  233. package/src/extra/pager.ts +2 -0
  234. package/src/extra/persist.ts +2 -1
  235. package/src/extra/pointer.ts +262 -0
  236. package/src/extra/processForm.ts +34 -1
  237. package/src/extra/replay/address.ts +49 -0
  238. package/src/extra/replay/entropy.ts +99 -0
  239. package/src/extra/replay/format.ts +263 -0
  240. package/src/extra/replay/index.ts +8 -0
  241. package/src/extra/replay/inert.ts +22 -0
  242. package/src/extra/replay/recorder.ts +247 -0
  243. package/src/extra/replay/replayer.ts +234 -0
  244. package/src/extra/replay/report.ts +126 -0
  245. package/src/extra/replay/schema.ts +52 -0
  246. package/src/extra/replay/version.ts +3 -0
  247. package/src/extra/router.ts +7 -3
  248. package/src/extra/selection.ts +2 -0
  249. package/src/extra/socketDriver.ts +51 -6
  250. package/src/extra/ssr.ts +128 -42
  251. package/src/extra/testing.ts +295 -39
  252. package/src/extra/timers.ts +96 -7
  253. package/src/extra/undo.ts +2 -0
  254. package/src/extra/virtual.ts +25 -2
  255. package/src/extra/widget.ts +106 -17
  256. package/src/host/actions.ts +383 -0
  257. package/src/host/delegation.ts +101 -0
  258. package/src/host/dom.ts +553 -0
  259. package/src/host/driver.ts +185 -0
  260. package/src/host/selector.ts +119 -0
  261. package/src/host/style.ts +51 -0
  262. package/src/host.d.ts +298 -0
  263. package/src/host.ts +46 -0
  264. package/src/index.d.ts +302 -38
  265. package/src/index.ts +15 -3
  266. package/src/pragma/blocks.ts +211 -0
  267. package/src/pragma/index.ts +44 -10
  268. package/src/primitives.d.ts +54 -0
  269. package/src/primitives.ts +136 -0
  270. package/src/react-native.d.ts +57 -0
  271. package/src/react-native.ts +285 -0
  272. package/src/react-peers.d.ts +21 -3
  273. package/src/replay.d.ts +185 -0
  274. package/src/replay.ts +6 -0
  275. package/src/sandbox/build.ts +119 -0
  276. package/src/sandbox/create.ts +187 -0
  277. package/src/sandbox/engine.ts +267 -0
  278. package/src/sandbox/generated.ts +195 -0
  279. package/src/sandbox/kernel.ts +180 -0
  280. package/src/sandbox/mirror.ts +269 -0
  281. package/src/sandbox/protocol.ts +128 -0
  282. package/src/sandbox/quickjs.ts +74 -0
  283. package/src/sandbox/ssr.ts +41 -0
  284. package/src/sandbox/widget.ts +60 -0
  285. package/src/sandbox/worker-entry.ts +59 -0
  286. package/src/sandbox/worker.ts +137 -0
  287. package/src/sandbox-build.d.ts +12 -0
  288. package/src/sandbox-build.ts +6 -0
  289. package/src/sandbox.d.ts +299 -0
  290. package/src/sandbox.ts +25 -0
  291. package/src/shared.ts +12 -0
  292. package/src/terminal/paint.ts +712 -0
  293. package/src/terminal.d.ts +167 -0
  294. package/src/terminal.ts +530 -0
  295. package/src/three/assets.ts +170 -0
  296. package/src/three/host.ts +36 -0
  297. package/src/three/scene.ts +217 -0
  298. package/src/three/target.ts +252 -0
  299. package/src/three.d.ts +221 -0
  300. package/src/three.ts +30 -0
  301. package/src/ui/accordion.ts +21 -7
  302. package/src/ui/dialog.ts +192 -90
  303. package/src/ui/disclosure.ts +2 -0
  304. package/src/ui/dom.ts +30 -0
  305. package/src/ui/layers.ts +132 -0
  306. package/src/ui/popover.ts +120 -21
  307. package/src/ui/shared.ts +98 -12
  308. package/src/ui/stack.ts +18 -0
  309. package/src/ui/tabs.ts +2 -0
  310. package/src/ui/toaster.ts +16 -15
  311. package/src/ui/tooltip.ts +24 -8
  312. package/src/ui/tooltips.ts +26 -14
  313. package/src/ui/zag/combobox.ts +180 -13
  314. package/src/ui/zag/menu.ts +97 -17
  315. package/src/ui/zag/select.ts +15 -4
  316. package/src/ui/zag/shared.ts +60 -8
  317. package/src/ui-combobox.d.ts +16 -1
  318. package/src/ui-menu.d.ts +22 -7
  319. package/src/ui-zag-types.d.ts +13 -5
  320. package/src/ui.d.ts +62 -7
  321. package/src/ui.ts +2 -2
  322. package/src/vite/api.ts +186 -0
  323. package/src/vite/compile.ts +280 -0
  324. package/src/vite/plugin.d.ts +34 -0
  325. package/src/vite/plugin.ts +78 -4
  326. package/src/vite/sandbox.ts +16 -0
  327. package/src/worker/page.ts +396 -0
  328. package/src/worker/protocol.ts +48 -0
  329. package/src/worker/target.ts +282 -0
  330. package/src/worker-page.d.ts +42 -0
  331. package/src/worker-page.ts +12 -0
  332. package/src/worker.d.ts +65 -0
  333. package/src/worker.ts +16 -0
  334. package/src/yjs/driver.ts +253 -0
  335. package/src/yjs/mapping.ts +389 -0
  336. package/src/yjs/shared.ts +138 -0
  337. package/src/yjs/testing.ts +91 -0
  338. package/src/yjs-testing.d.ts +34 -0
  339. package/src/yjs-testing.ts +7 -0
  340. package/src/yjs.d.ts +109 -0
  341. package/src/yjs.ts +18 -0
  342. package/src/zag.d.ts +2 -0
  343. package/src/zag.ts +8 -1
@@ -0,0 +1,128 @@
1
+ <!-- Generated from docs/src/content/docs/renderers by scripts/copy-guides.mjs; online: https://sygnal.js.org/renderers/ -->
2
+ # React Native
3
+
4
+ `sygnal/react-native` runs a Sygnal app on React Native. Your components, intents and models stay as they are. The view renders into a [host tree](./custom-targets.md), and the bridge shows that tree as React Native components. Native callbacks arrive as the DOM events your intents already listen for: `onPress` is a `click`, `onChangeText` an `input`.
5
+
6
+ ```js
7
+ // index.js
8
+ import { createElement } from 'react'
9
+ import { registerRootComponent } from 'expo'
10
+ import { SygnalView } from 'sygnal/react-native'
11
+ import Tasks from './src/Tasks.jsx'
12
+
13
+ registerRootComponent(() => createElement(SygnalView, { app: Tasks }))
14
+ ```
15
+
16
+ `react` and `react-native` are optional peer dependencies: install them in the app, as Expo does. In a Sygnal component file, set the JSX source with a pragma comment (`/** @jsxImportSource sygnal */`), because the React Native Babel preset compiles JSX for React by default. The [write-once example](https://github.com/tpresley/sygnal/tree/main/examples/write-once) is an Expo app with its Metro config, run in the iOS simulator.
17
+
18
+ ## SygnalView
19
+
20
+ | Prop | |
21
+ |---|---|
22
+ | `app` | the root component |
23
+ | `drivers` | the app's other drivers (`DOM` is the bridge's) |
24
+ | `runOptions` | [`run()`](https://sygnal.js.org/reference/api/) options |
25
+ | `styles` | the native stylesheet (below) |
26
+ | `defaultStyles` | `false`: drop `defaultNativeStyles` (the look of plain HTML tags) |
27
+ | `components` | your own React Native components, by tag or primitive name |
28
+ | `onNavigate(href, el)` | a link's default action: route with your router driver |
29
+ | `style` | the root View's style (default `{ flex: 1 }`) |
30
+
31
+ The app starts when the view mounts, and is disposed when it unmounts. For lower-level control, `createNativeHost(options)` returns `{ driver, Root, doc, stop }`: run the app with `run(App, { DOM: host.driver })`, render `<host.Root />` wherever you like, and call `host.stop()` before disposing.
32
+
33
+ ## What renders as what
34
+
35
+ | Host element | React Native | Events it makes |
36
+ |---|---|---|
37
+ | `View`, and `div` / `section` / `ul` / `form` … | `View` | |
38
+ | `Text`, and `span` / `p` / `label` / `h1` … | `Text` | `click` (`onPress`) when an intent listens for it |
39
+ | `Pressable`, `button` | `Pressable` (direct text wrapped in `Text`) | `click` |
40
+ | `TextInput`, `input`, `textarea` | `TextInput` | `input`, `keydown` Enter (submit key), `focus`, `blur`, `change` |
41
+ | `Switch`, `input type="checkbox"` | `Switch` | `click`, `input`, `change` |
42
+ | `Image`, `img` | `Image` | |
43
+ | `ScrollView` | `ScrollView` | `scroll` |
44
+ | `List` | `FlatList` over its children | `scroll` |
45
+ | `select` | a `Pressable` that steps to the next option | `input`, `change` |
46
+ | `a href` | `Text` | `click`, then `onNavigate(href)` |
47
+
48
+ An HTML view runs too. A container whose children are all inline (a `<li>` holding a checkbox, a label and a button) becomes a row, the way a browser lays it out, and `defaultNativeStyles` gives headings, links, buttons and fields their usual look. The [primitives](./write-once.md) give you exact control instead.
49
+
50
+ A target's own field tags, registered with [`registerFieldTags`](./custom-targets.md#your-own-tags), map by kind: `'text'` to `TextInput`, and `{ tag, kind: 'checkbox' }` to `Switch`.
51
+
52
+ ## Controlled fields
53
+
54
+ A field's value comes from the app's state: `<Draft value={state.draft} />`. The bridge reads the value from the host element at the moment React renders the field, not before. React Native's own `TextInput` protocol (the native event count) then keeps the user's typing ahead of a render that hasn't seen it yet. The app's value wins whenever it differs, for example when the model clears the field after Add. You don't need anything extra for fast typing.
55
+
56
+ A `Switch` is a real checkbox: a press toggles `checked`, then fires `click`, `input` and `change`. When an intent prevents the click (`preventDefault()`), the switch springs back.
57
+
58
+ ## Styles
59
+
60
+ Inline styles are React Native style objects. The `styles` prop adds a stylesheet of `[selector, style]` rules for anything that depends on classes or structure. Its rules are compiled with `StyleSheet.create`:
61
+
62
+ ```js
63
+ // nativeStyles.js
64
+ export const nativeStyles = [
65
+ ['.task.done', { opacity: 0.5 }],
66
+ ['.toolbar', { flexDirection: 'row', justifyContent: 'space-between' }],
67
+ ['.toolbar .title', { fontSize: 20, fontWeight: '600' }],
68
+ ]
69
+ ```
70
+
71
+ An element's style is its matching rules in order (after `defaultNativeStyles`), then its inline style. Flex layout keys work as in React Native. React Native doesn't inherit text styles from a `View`, so a View-like element's text keys (`color`, `fontSize`, …) style the `Text` that wraps its own direct text, and nested `Text` inherits as usual. Selectors support what the [host selectors](./custom-targets.md#painting) do: tags, classes, attributes, `:not()`, and descendant and child combinators.
72
+
73
+ ## Lists
74
+
75
+ Put a `Collection` in a `List`:
76
+
77
+ ```jsx
78
+ // Feed.jsx
79
+ import { Collection } from 'sygnal'
80
+ import { List } from 'sygnal/primitives'
81
+ import Post from './Post.jsx'
82
+
83
+ export default function Feed() {
84
+ return <List><Collection of={Post} from="posts" /></List>
85
+ }
86
+ ```
87
+
88
+ On native, the `List` is a `FlatList` over the Collection's items, so only the visible rows have native views. The limits:
89
+
90
+ - **Every item stays mounted in Sygnal.** Each item component runs and keeps its state, as on the web. FlatList windows the native views only. Lists of a few thousand simple items are fine; for far more, page the data in your model (listen for `scroll` and its `detail.end`).
91
+ - **`VirtualCollection` is web-only:** it measures DOM elements, which a host tree doesn't have.
92
+
93
+ ## Incremental rendering
94
+
95
+ Every host element is a memoized React component, keyed by its host node and that node's revision. A mutation moves the revision of the node and its ancestors, so a change re-renders only the path to it:
96
+
97
+ | A list of 100 items (303 elements) | Elements rendered | React commits |
98
+ |---|---|---|
99
+ | tap a counter at the top: full re-render | 303 | 1 |
100
+ | tap a counter at the top: `sygnal/react-native` | 2 | 1 |
101
+ | toggle item 51: full re-render | 303 | 1 |
102
+ | toggle item 51: `sygnal/react-native` | 4 | 1 |
103
+
104
+ These counts come from the library's tests, which use `onNodeRender` and `incremental: false` to compare. A descendant selector in the stylesheet (`.done .title`) makes a class change re-render that element's subtree too, since its descendants may now match differently.
105
+
106
+ ## Your own components
107
+
108
+ `components` maps a tag (or a primitive name) to a function that returns a React element:
109
+
110
+ ```js
111
+ // components.js
112
+ import { createElement as h } from 'react'
113
+ import { ActivityIndicator } from 'react-native'
114
+
115
+ export const components = {
116
+ // <spinner size="large" /> in a Sygnal view
117
+ spinner: (el, _children, style) => h(ActivityIndicator, { size: el.size || 'small', style: style.view }),
118
+ }
119
+ ```
120
+
121
+ The function gets the host element, its children (already React elements), its computed style split into `{ view, text, all }`, and a context: `ctx.fire(el, type, init)` sends a DOM-shaped event, and `ctx.press(el)` sends a click with its default action. The tag renders as `<spinner>` on the web, so give the web a fallback or keep it in a native-only view.
122
+
123
+ ## Limits
124
+
125
+ - **Expo Go, simulators and devices:** the bridge is plain JavaScript. A native build (`expo run:ios`) needs CocoaPods, as for any React Native app.
126
+ - **Web-only features:** `Portal`, the [UI parts](../ui/overview.md), `VirtualCollection`, view transitions and the browser sources need the DOM.
127
+ - **Gestures:** a press is a `click`; double clicks, hover and long-press aren't mapped. Use a `components` entry with your gesture library for more.
128
+ - **Pickers:** React Native's core has no picker, so a `<select>` steps through its options on each press. Map `select` to a picker component through `components` for a real one.
@@ -0,0 +1,306 @@
1
+ <!-- Generated from docs/src/content/docs/renderers by scripts/copy-guides.mjs; online: https://sygnal.js.org/renderers/ -->
2
+ # Terminal Apps
3
+
4
+ `sygnal/terminal` runs a Sygnal app in a terminal. Your components, intents and models stay the same. The view's HTML is painted as text, and key presses become the DOM events your intents already listen for.
5
+
6
+ ```jsx
7
+ // terminal.jsx
8
+ import { runTerminal } from 'sygnal/terminal'
9
+ import Groceries from './Groceries.jsx'
10
+
11
+ runTerminal(Groceries, {
12
+ styles: [
13
+ ['h1', { color: 'green', upper: true }],
14
+ ['.item.done label', { dim: true, after: ' ✓' }],
15
+ ],
16
+ })
17
+ ```
18
+
19
+ ```
20
+ GROCERIES
21
+ Item [Milk, bread, …] Aisle [produce ▾] [Add]
22
+ • [ ] Apples (produce) [×]
23
+ • [x] Milk ✓ (dairy) [×]
24
+ — 1 to buy · Show [all ▾] [Clear the cart]
25
+
26
+ Tab move · Ctrl+C quit
27
+ ```
28
+
29
+ `runTerminal` takes over the TTY. It switches to the alternate screen, reads raw keys, and redraws in place after every change. It keeps the focused line in view and follows resizes. Ctrl+C disposes the app and restores the terminal, and so does a crash or a `SIGTERM`. It runs in Node. Bundle your JSX first (the [examples](https://github.com/tpresley/sygnal/tree/main/examples/tui-tracker) use a Vite SSR build), or use any runner that compiles JSX with `jsxImportSource: 'sygnal'`.
30
+
31
+ | Option | |
32
+ |---|---|
33
+ | `styles` | the terminal stylesheet (below) |
34
+ | `drivers` | the app's other drivers (`DOM` and `TERMINAL` are the terminal's) |
35
+ | `runOptions` | [`run()`](https://sygnal.js.org/reference/api/) options: `onError`, `diagnostics`, … |
36
+ | `onNavigate(href, el)` | a link's default action: route with your router driver |
37
+ | `footer` | the line under the app. Default: what the keys do on the focused element. A string, a function of the terminal, or `false` for none |
38
+ | `mouse` | clicks, double clicks and the wheel (default `false`, so the terminal keeps its own text selection) |
39
+ | `emojiWidth` | how many cells the terminal draws an emoji in. `'auto'` (default) asks the terminal at start: xterm.js-based terminals (VS Code's) draw 🐛 in one cell, most others in two |
40
+ | `console` | `'buffer'` (default on a TTY: `console.log` output is kept, counted in the footer and written after the terminal is restored) or `'pass'` |
41
+ | `color`, `alternateScreen` | default: on when stdout is a TTY |
42
+ | `stdin`, `stdout` | default `process.stdin` / `process.stdout` |
43
+ | `onExit(code)` | after Ctrl+C, a `TERMINAL` exit or `stop()` |
44
+
45
+ It returns `{ app, terminal, stop }`.
46
+
47
+ ### Defaults
48
+
49
+ | Default | Why | To change it |
50
+ |---|---|---|
51
+ | The mouse is off | the terminal keeps its own text selection (copying from the screen) | `mouse: true` |
52
+ | Ctrl+D deletes forward and Ctrl+A goes to the line start in a text field | the readline keys of shells, Textual and prompt_toolkit, not EOF or select all | the `keydown` goes out first: a [`keymap()`](../inputs.md) shortcut (or a `keydown` intent that prevents the default) takes the key instead |
53
+ | Cell widths come from Sygnal's own tables | no dependency: CJK and fullwidth text take two cells, combining marks and zero-width joiners none | `emojiWidth: 1` or `2` instead of asking the terminal |
54
+ | `console` output is buffered on a TTY | a `console.log` drawn over the screen would scroll it | `console: 'pass'` |
55
+
56
+ ## Keys
57
+
58
+ | Key | What it does |
59
+ |---|---|
60
+ | Tab / Shift+Tab, ↓ / ↑ | move focus: form fields, buttons, links, `[tabindex]`, and **any element your intents listen to clicks on**. A radio group is one stop, and so is a tab list with a roving `tabindex` |
61
+ | typing | edits the focused text field at its caret (`beforeinput` and `input` events) |
62
+ | ← / →, Home / End | move the caret (Shift selects; Alt or Ctrl moves by word); in a `<textarea>`, ↑ / ↓ move between lines |
63
+ | Backspace, Delete | delete (with Alt or Ctrl: a word) |
64
+ | Ctrl+A E B F D K U W, Alt+B F D | the readline keys in a text field: line start / end, back / forward, delete forward, kill to the end / start, delete a word |
65
+ | Enter | in an `<input>`: submit its form; on a button, link, or clicked element: click it |
66
+ | Space | toggle a checkbox, click a button |
67
+ | Enter / Space on a `[tabindex]` element | click it, unless a component listens to `keydown` on it or an ancestor: an app that handles those keys itself (as it must in a browser, where such an element has no key action) gets one action, not two. App-wide listeners (`DOM.select('document')`, `keymap()`) don't count |
68
+ | ← / → on a radio | check the next radio of its group |
69
+ | F2 | a double click (a terminal has none) |
70
+ | Escape | close the modal dialog, or the open popover |
71
+ | any key | `keydown` / `keyup` on the focused element, or on the app's root |
72
+
73
+ The footer always shows the keys for the focused element, for example `Enter open list · ←/→ change` on a `<select>`.
74
+
75
+ Two consequences matter for app code. First, an element like todomvc's `<label>`, which only has a `DOM.dblclick('.todo label')` intent, is still reachable: the terminal knows your intents' selectors, so it adds that label to the focus order. Second, app-wide shortcuts work as on the web, through [`keymap()`](../inputs.md) or `DOM.select('document').events('keydown')`. A shortcut that takes a key prevents its default, so the focus doesn't also move.
76
+
77
+ ### Dropdowns
78
+
79
+ A native `<select>` works as in a TUI toolkit's select widget:
80
+
81
+ | Key | Closed | Open |
82
+ |---|---|---|
83
+ | Enter, Space, Alt+↓, F4 | open the option list | Enter / Space: choose the highlighted option (`input`, `change`) and close |
84
+ | ↑ / ↓, Home / End, PageUp / PageDown | move the focus | move the highlight (disabled options are skipped) |
85
+ | ← / → | the previous / next option, at once | |
86
+ | a letter | the next option starting with it | highlight it |
87
+ | Escape, Tab | | close without a change (Tab then moves on) |
88
+
89
+ The list opens under the select (or above it near the bottom), over what is there. The chosen option has a `✓`:
90
+
91
+ ```
92
+ Show ▸[open ▴]
93
+ ┌─────────┐
94
+ │▸open ✓│
95
+ │ closed │
96
+ │ all │
97
+ └─────────┘
98
+ ```
99
+
100
+ ### Dialogs, popovers and details
101
+
102
+ `<dialog>`, `[popover]` and `<details>` behave as in a browser. A closed dialog, a hidden popover and a closed `<details>`' content aren't drawn. `showModal()` (an `ELEMENT` command) draws the dialog centered over the page, with a border and its `aria-label` as the title, and dims the page under it. The focus moves into the dialog and Tab stays there. Escape sends `cancel` and then `close` (so `DOM.close(Confirm)` hears it), and the focus goes back to what opened the dialog. A `popovertarget` button toggles its popover, which is drawn under the button. `commandfor` / `command` buttons work too.
103
+
104
+ ```jsx
105
+ // Issue.jsx
106
+ import { controls } from 'sygnal'
107
+
108
+ export const { Confirm, Delete } = controls({ Confirm: 'dialog', Delete: 'button' })
109
+
110
+ export default function Issue({ state }) {
111
+ return (
112
+ <article>
113
+ <h2>{state.title}</h2>
114
+ {state.deleted ? <p>Deleted.</p> : <Delete>Delete</Delete>}
115
+ <Confirm aria-label="Confirm">
116
+ <p>Delete “{state.title}”?</p>
117
+ <button className="confirm-ok">Delete</button> <button className="confirm-cancel">Cancel</button>
118
+ </Confirm>
119
+ </article>
120
+ )
121
+ }
122
+
123
+ Issue.initialState = { title: 'Typo in the README', deleted: false }
124
+
125
+ Issue.intent = ({ DOM }) => ({
126
+ ASK: DOM.click(Delete),
127
+ DELETE: DOM.click('.confirm-ok'),
128
+ CANCEL: DOM.click('.confirm-cancel'),
129
+ })
130
+
131
+ Issue.model = {
132
+ ASK: { ELEMENT: { showModal: Confirm } },
133
+ DELETE: { STATE: (state) => ({ ...state, deleted: true }), ELEMENT: { close: Confirm } },
134
+ CANCEL: { ELEMENT: { close: Confirm } },
135
+ }
136
+ ```
137
+
138
+ Escape closes the dialog without a model entry; listen with `DOM.close(Confirm)` when the app needs to know.
139
+
140
+ ### Mouse and paste
141
+
142
+ With `mouse: true`, a click focuses and clicks what it lands on. It toggles a checkbox, opens a select or picks an option, and puts the caret where you clicked in a text field. A double click is a `dblclick`, and the wheel scrolls the box under the pointer. A paste (the terminal's bracketed paste) is one `paste` event and one insertion at the caret; an `<input>` gets its line breaks as spaces. Without bracketed paste, a pasted line break would submit the form.
143
+
144
+ ## Quitting
145
+
146
+ Ctrl+C always quits. To quit from the app (a `q` key, a menu item), send `'exit'` to the `TERMINAL` sink, which `runTerminal` adds:
147
+
148
+ ```jsx
149
+ // Tracker.jsx
150
+ Tracker.intent = ({ DOM }) => ({ ...keymap(DOM, { q: 'QUIT' }) })
151
+ Tracker.model = { QUIT: { TERMINAL: () => 'exit' } }
152
+ ```
153
+
154
+ It also takes `{ exit: code }` (passed to `onExit`), `{ title: 'Tracker' }` (the window title) and `'bell'`.
155
+
156
+ ## The terminal stylesheet
157
+
158
+ CSS doesn't reach a terminal, so each app gives the terminal a short list of `[selector, style]` rules. They're applied in order after a default sheet (bold headings, underlined links, a border on dialogs), and an inline `style={{ display: 'none' }}` or `hidden` attribute wins, as in a browser.
159
+
160
+ | Key | |
161
+ |---|---|
162
+ | `display` | `'none'` hides; `'block'` / `'inline'` override the tag's default; `'flex'` with `flexDirection` |
163
+ | `before`, `after` | text around the element's content |
164
+ | `bold`, `dim`, `italic`, `underline`, `inverse` | text attributes |
165
+ | `color`, `background` | `black red green yellow blue magenta cyan white gray`, or `brightRed`, … |
166
+ | `upper` | the element's text in upper case |
167
+ | `width`, `height` | a box's size in cells (columns / rows), or a percentage: `'40%'` of the space it is in, `height: '100%'` of the screen for the root |
168
+ | `flex` | in a flex row or column: its share of the space left |
169
+ | `border`, `borderColor` | `true` / `'single'`, `'round'`, `'double'`, `'heavy'`; the element's `aria-label` (or `title`) is drawn in the top border |
170
+ | `padding` | cells inside the border: `1`, or `[vertical, horizontal]` |
171
+ | `overflow` | with a height: `'auto'` / `'scroll'` scroll, with a scrollbar, keeping the focused element (and the caret) in view; `'hidden'` crops |
172
+ | `gap` | cells between a flex row's items (default 1) |
173
+
174
+ Use it for the parts of your CSS that change **what is shown**. todomvc hides the edit field unless its item is being edited:
175
+
176
+ ```js
177
+ // terminal-styles.js
178
+ export const todoStyles = [
179
+ ['.todo .edit', { display: 'none' }],
180
+ ['.todo.editing .edit', { display: 'inline' }],
181
+ ['.todo.editing .view', { display: 'none' }],
182
+ ['.todo.completed label', { dim: true, after: ' ✓' }],
183
+ ['.filters li', { display: 'inline' }],
184
+ ]
185
+ ```
186
+
187
+ Selectors support what intents use: tag, `#id`, `.class`, `[attr]`, `[attr="v"]` (and `^=` `$=` `*=` `~=`), `:not(…)`, descendant and `>` combinators, and `a, b` lists. The state pseudo-classes are `:focus`, `:focus-within`, `:checked`, `:disabled`, `:open`, `:modal` and `:popover-open`.
188
+
189
+ The layout is a line flow. Block elements start lines, inline content flows and wraps at the terminal's width, lists indent with bullets or numbers, and `<pre>` keeps its spaces. Form controls are drawn as `[text_]`, `[x]`, `(•)`, `[option ▾]`, `[Button]` and `[████░░] 60%` (a `<progress>`). A `<textarea>` is a box of its `rows`. The focused element shows inverse (or with `▸` in plain text), and a focused box's border turns heavy. Widths count terminal cells: CJK text takes two, combining marks none, and an emoji what the terminal uses (`emojiWidth`).
190
+
191
+ ### Full-screen layouts
192
+
193
+ An element with a `width`, `height`, `border`, `padding` or scrolling `overflow` is a **box**, laid out in a rectangle of its own. A flex row whose items have a `width`, a `flex` share or a border puts them side by side, all as tall as the row. A flex column with a height gives its `flex` items the rows its other items leave. So a list beside a detail pane, with a status bar on the last row, is:
194
+
195
+ ```js
196
+ // styles.js
197
+ export const terminalStyles = [
198
+ ['main', { display: 'flex', flexDirection: 'column', height: '100%' }],
199
+ ['.split', { flex: 1, display: 'flex', flexDirection: 'row' }],
200
+ ['.issues', { width: '45%', border: 'round', overflow: 'auto' }],
201
+ ['.issues:focus-within', { borderColor: 'cyan' }],
202
+ ['.detail', { flex: 1, border: 'round', padding: [0, 1] }],
203
+ ['.status-bar', { inverse: true }],
204
+ ]
205
+ ```
206
+
207
+ ```
208
+ TRACKER
209
+ [Issues] [New issue] [Help]
210
+ Filter [title, label, person] Show [open ▾]
211
+ ╭─ Issues ────────────────────────╮ ╭──────────────────────────────────────────╮
212
+ │• #12 H Dark mode for the ││ │ #14 Search ignores accents (2) │
213
+ │ settings page (2) █│ │ open · medium · @dara · feature │
214
+ │• #13 L Typo in the README (2) ││ │ [Show description] │
215
+ │• ▸#14 M Search ignores accents ││ │ [Close] [Delete] │
216
+ │ (2) ││ │ │
217
+ ╰─────────────────────────────────╯ ╰──────────────────────────────────────────╯
218
+ Synced 32 shown / 40 ↑↓ issues · → detail · 3 help
219
+ ```
220
+
221
+ The box keys come from the terminal stylesheet only, in cells: an inline web style (`style={{ flex: 1, padding: 8 }}`) doesn't make a box. `scrollIntoView()` (an `ELEMENT` command) scrolls an element's boxes to it.
222
+
223
+ ## Isolation still applies
224
+
225
+ The terminal changes nothing about how components see the DOM. A `Collection` item's elements belong to the item, so the parent can't listen to a click on them or focus them by selector, in a terminal as in a browser. Listen in the item and send it up with `PARENT`, and focus an item's element from the parent with [`focusWithin`](../element-commands.md):
226
+
227
+ ```jsx
228
+ // IssueRow.jsx
229
+ IssueRow.intent = ({ DOM }) => ({ PICK: DOM.focus('.issue') })
230
+ IssueRow.model = { PICK: { PARENT: (state) => state.id } }
231
+ // the parent: SELECT: CHILD.select(IssueRow); ELEMENT: { focus: focusWithin('.issue[data-id="3"]') }
232
+ ```
233
+
234
+ ## sygnal/ui in a terminal
235
+
236
+ Every [`sygnal/ui`](../ui/overview.md) part on native HTML works in a terminal: [dialog](../ui/dialog.md), [popover](../ui/popover.md), [tooltip](../ui/tooltip.md) (shown on focus under its trigger), [tabs](../ui/tabs.md), [accordion](../ui/accordion.md), [disclosure](../ui/disclosure.md) and the [Toaster](../ui/toaster.md). They read the document of the element they act on, so the same code runs on the terminal's tree, in a browser, and in a shadow root. The arrow keys move between tabs and between accordion triggers.
237
+
238
+ The `dialog` behavior gives the confirm above its state, its trigger's ARIA and a refusable close:
239
+
240
+ ```jsx
241
+ // Item.jsx
242
+ import { dialog, dialogAttrs } from 'sygnal/ui'
243
+
244
+ export function Item({ state, uid }) {
245
+ const a = dialogAttrs(state.ask, uid)
246
+ return (
247
+ <div>
248
+ <button className="delete" {...a.trigger}>Delete</button>
249
+ <dialog className="ask" {...a.dialog} role="alertdialog" aria-label="Confirm" aria-labelledby={undefined}>
250
+ <p {...a.description}>Delete it? This can't be undone.</p>
251
+ <button className="ok">Delete</button> <button className="cancel">Cancel</button>
252
+ </dialog>
253
+ </div>
254
+ )
255
+ }
256
+
257
+ Item.initialState = { deleted: false }
258
+ Item.uses = { ask: dialog({ dialog: '.ask', trigger: '.delete', close: '.cancel' }) }
259
+ Item.intent = ({ DOM }) => ({ DELETE: DOM.click('.ok') })
260
+ Item.model = { DELETE: (state) => ({ ...state, deleted: true, ask: { ...state.ask, open: false } }) }
261
+ ```
262
+
263
+ The box's title comes from `aria-label`, so the dialog keeps one here instead of a title element. Escape is the dialog's `CANCEL`, and the focus goes back to the trigger.
264
+
265
+ Render `<Toaster />` once and pass `makeTimerDriver()` in `drivers` (`runTerminal(App, { drivers: { TIMER: makeTimerDriver() } })`). A toast is drawn at the top right and times out. The terminal has no `MutationObserver`, so the toast region doesn't move into an open modal dialog as it does in a browser: a toast that comes while a modal is open is drawn over it and still times out, but its Dismiss button can't take the focus until the dialog closes.
266
+
267
+ ## Testing
268
+
269
+ `createTerminal()` is the same target without a TTY. Run the app on its driver, press keys, and compare frames as plain text:
270
+
271
+ ```js
272
+ // Groceries.terminal.test.js
273
+ import { it, expect } from 'vitest'
274
+ import { createTerminal } from 'sygnal/terminal'
275
+ import Groceries, { Name } from './Groceries.jsx'
276
+
277
+ it('adds an item from the keyboard', async () => {
278
+ const term = createTerminal({ width: 60 })
279
+ const app = term.run(Groceries)
280
+ await term.settle()
281
+ term.focus(Name)
282
+ term.type('Eggs')
283
+ term.press('Enter')
284
+ await term.settle()
285
+ expect(term.frame()).toContain('[ ] Eggs')
286
+ app.dispose()
287
+ })
288
+ ```
289
+
290
+ `term.run(App, drivers, runOptions)` is `run()` with the terminal's `DOM` and `TERMINAL` drivers added; it returns the app. `await term.settle()` waits until the app is quiet: nothing scheduled in its runtime and no change to the tree for 20 ms, so element commands (`ELEMENT: { focus }`), delayed actions and a fake server with a short latency are done. It rejects after 2 s with the frame in the message; `term.settle({ timeout, quiet })` changes both. Under `vi.useFakeTimers()` it advances the clock.
291
+
292
+ | | |
293
+ |---|---|
294
+ | `term.press(key, { shiftKey, ctrlKey, altKey })` | a key, by DOM key name (`'Enter'`, `'Tab'`, `'ArrowDown'`, `'Escape'`, `'F2'`, `' '`, `'a'`) |
295
+ | `term.type(text)`, `term.paste(text)` | typing (a press per character), a paste |
296
+ | `term.click(line, col, { detail })`, `term.wheel(line, col, rows)` | the mouse, at a cell of the current frame |
297
+ | `term.frame()`, `term.render()` | the frame as text; `{ plain, ansi, focusLine, focus, caret, hits }` |
298
+ | `term.focusables()`, `term.keyHint()` | the Tab order; the footer's key hint |
299
+ | `term.run(App, drivers)`, `await term.settle()` | run the app; wait for it to go quiet |
300
+ | `term.commands` | what the app sent its `TERMINAL` sink |
301
+
302
+ `createTerminal({ width: 80, height: 24 })` gives the frame a screen's height, for full-screen layouts. Test the logic itself once with [`renderComponent`](https://sygnal.js.org/integration/testing/): it covers every target.
303
+
304
+ ## Lower-level pieces
305
+
306
+ `createTerminal({ onFrame })` calls you with each new frame, if you want to draw it yourself (in a pane of a larger TUI, or over a socket). `paint(doc, { rules, width, height })` paints any [host document](./custom-targets.md), and `hitTest(frame, line, col)` finds the element at a cell. `parseKeys(chunk)` turns raw TTY input into key names, pastes and mouse events, and `stringWidth(text)` counts terminal cells.