stream_weaver 0.3.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 (381) hide show
  1. checksums.yaml +7 -0
  2. data/.beads/.gitignore +77 -0
  3. data/.beads/README.md +81 -0
  4. data/.beads/config.yaml +54 -0
  5. data/.beads/metadata.json +7 -0
  6. data/.rspec +3 -0
  7. data/AGENTS.md +91 -0
  8. data/CHANGELOG.md +294 -0
  9. data/CLAUDE.md +67 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +565 -0
  12. data/Rakefile +13 -0
  13. data/assets/streamweaver-hero.jpg +0 -0
  14. data/bench/baselines/ledger.rb +58 -0
  15. data/bench/baselines/warroom.rb +51 -0
  16. data/bench/fixtures/ledger.rb +76 -0
  17. data/bench/fixtures/warroom.rb +60 -0
  18. data/bench/results/6daf8b8.md +102 -0
  19. data/bench/results/c8d749b.md +102 -0
  20. data/bench/results/eb06ab9.md +102 -0
  21. data/bench/run.rb +151 -0
  22. data/bench/support.rb +84 -0
  23. data/copy_this/dto_run_sample_run_7631.html +220 -0
  24. data/docs/SERVICE_MODE.md +302 -0
  25. data/docs/architecture/how_streamweaver_works.md +772 -0
  26. data/docs/blog/2026-01-16-streamweaver-introduction.md +515 -0
  27. data/docs/bug-2026-08-31-canvas-bridge-socket-collision.md +83 -0
  28. data/docs/canvas-ipc-session-summary.md +127 -0
  29. data/docs/canvas-panel-workflow.md +172 -0
  30. data/docs/canvas-read.md +147 -0
  31. data/docs/canvas-roadmap.md +275 -0
  32. data/docs/case-studies/2026-09-03-didx-canvas-read-shelf.md +147 -0
  33. data/docs/claude-code-companion-skill-spec.md +567 -0
  34. data/docs/components_reference.md +1223 -0
  35. data/docs/crud-patterns.md +265 -0
  36. data/docs/endpoints.md +122 -0
  37. data/docs/for_llms.md +1 -0
  38. data/docs/form-patterns.md +165 -0
  39. data/docs/frontend-only.md +169 -0
  40. data/docs/html-artifact-audit.md +238 -0
  41. data/docs/ideas/2025-01-01-as-a-service.md +203 -0
  42. data/docs/ideas/2026-01-16-charm-tui-exploration.md +542 -0
  43. data/docs/ideas/2026-08-27-static-doc-shelf-export.md +153 -0
  44. data/docs/ideas/dhh-review-future-refactors.md +142 -0
  45. data/docs/opal-jamstack.md +47 -0
  46. data/docs/opal-npm-direction.md +184 -0
  47. data/docs/opal-spike-findings.md +194 -0
  48. data/docs/plans/2025-12-16-form-blocks-design.md +124 -0
  49. data/docs/plans/2025-12-30-examples-browser-design.md +152 -0
  50. data/docs/plans/2026-01-02-charts-design.md +288 -0
  51. data/docs/plans/2026-01-10-canvas-ipc-design.md +247 -0
  52. data/docs/plans/2026-01-19-cabinet-control-components.md +176 -0
  53. data/docs/plans/2026-01-26-gem-release-and-panel.md +140 -0
  54. data/docs/plans/canvas-claude-project-design.md +238 -0
  55. data/docs/plans/canvas-doc-location-and-discovery.md +32 -0
  56. data/docs/plans/org-doc-preview-surfaces.md +293 -0
  57. data/docs/plans/shadcn-polish-plan.md +417 -0
  58. data/docs/porting-artifacts.md +252 -0
  59. data/docs/reference/agent-skills-comparison.md +369 -0
  60. data/docs/reference/travel-state-prd.artifact.html +780 -0
  61. data/docs/research/2026-08-17-hotwire-alike-landscape.md +268 -0
  62. data/docs/research/2026-08-17-hotwire-concept-map.md +404 -0
  63. data/docs/research/2026-08-22-lazy-fragments-trigger-decision.md +246 -0
  64. data/docs/research/2026-08-22-learnhotwire-syllabus-coverage.md +239 -0
  65. data/docs/research/frontend-only-matrix.md +278 -0
  66. data/docs/research/streamweaver-way-spike-findings.md +529 -0
  67. data/docs/resource-dsl.md +342 -0
  68. data/docs/routing.md +302 -0
  69. data/docs/ruby-ui-comparison.md +319 -0
  70. data/docs/shared-dsl-fragments.md +356 -0
  71. data/docs/streamweaver-for-ai-agents.md +401 -0
  72. data/docs/streamweaver-frontend-vision.md +123 -0
  73. data/docs/streamweaver_canvas/glimmer_initial_final_layer.rb +77 -0
  74. data/docs/streamweaver_canvas/save_example.rb +9 -0
  75. data/docs/templates.md +213 -0
  76. data/docs/testing.md +155 -0
  77. data/docs/theming-hooks.md +281 -0
  78. data/docs/tutorials/the-streamweaver-way.md +882 -0
  79. data/docs/university/capability-inventory.md +137 -0
  80. data/docs/university/dependency-survey.md +114 -0
  81. data/docs/university/design-spec.md +217 -0
  82. data/docs/university/mockups/code-block-doc-theme-dark.png +0 -0
  83. data/docs/university/mockups/code-block-doc-theme-light.png +0 -0
  84. data/docs/university/mockups/course-list-dark.png +0 -0
  85. data/docs/university/mockups/course-list-light.png +0 -0
  86. data/docs/university/mockups/course_canvas_mockup.rb +741 -0
  87. data/docs/university/mockups/step-screen-dark.png +0 -0
  88. data/docs/university/mockups/step-screen-light.png +0 -0
  89. data/docs/university/roadmap.md +72 -0
  90. data/docs/university/send-to-coworker.md +77 -0
  91. data/docs/university/worker-session-mining.md +109 -0
  92. data/docs/visual/browser-rendering-spike.md +173 -0
  93. data/docs/visual/builder-visual-plan-analysis.md +378 -0
  94. data/docs/visual/sw-plan-format-exploration.md +511 -0
  95. data/docs/visual/sw-plan-rendering-deep-dive.md +453 -0
  96. data/docs/visual-skills/PROGRESS.md +60 -0
  97. data/docs/visual-skills/SESSION-CONTEXT.md +207 -0
  98. data/docs/visual-skills/analysis/components.md +317 -0
  99. data/docs/visual-skills/analysis/overlap.md +360 -0
  100. data/docs/visual-skills/analysis/pi-design-deck.md +1102 -0
  101. data/docs/visual-skills/analysis/streamweaver-inventory.md +93 -0
  102. data/docs/visual-skills/analysis/unified-specs.feature +1003 -0
  103. data/docs/visual-skills/analysis/visual-explainer.md +964 -0
  104. data/docs/visual-skills/blog/blog-series-outline.md +39 -0
  105. data/docs/visual-skills/blog/token-efficiency.md +73 -0
  106. data/docs/visual-skills/design/architecture.md +1725 -0
  107. data/docs/visual-skills/design/codex-review.md +340 -0
  108. data/docs/visual-skills/design/dhh-review.md +209 -0
  109. data/docs/visual-skills/design/evolution.md +100 -0
  110. data/docs/visual-skills/design/gemini-review.md +55 -0
  111. data/docs/visual-skills/design/review-synthesis.md +149 -0
  112. data/docs/visual-skills/implementation/STATE.md +22 -0
  113. data/docs/visual-skills/implementation/plan.md +347 -0
  114. data/docs/visual-skills/implementation/spike-findings.md +237 -0
  115. data/docs/visual-skills/implementation/tasks.md +702 -0
  116. data/docs/visual-skills/lessons-learned/process.md +145 -0
  117. data/examples/README.md +96 -0
  118. data/examples/advanced/all_components.rb +177 -0
  119. data/examples/advanced/examples_browser.rb +394 -0
  120. data/examples/advanced/teachables_browser.rb +261 -0
  121. data/examples/advanced/theme_tweaker.rb +366 -0
  122. data/examples/advanced/tutorial.rb +1528 -0
  123. data/examples/agentic/agentic_form.rb +30 -0
  124. data/examples/agentic/agentic_form_autoclose.rb +30 -0
  125. data/examples/agentic/cultivation_tracker.rb +69 -0
  126. data/examples/basic/hello_world.rb +22 -0
  127. data/examples/basic/opal_tabs_table.rb +44 -0
  128. data/examples/basic/todo_list.rb +39 -0
  129. data/examples/button_loading_test.rb +36 -0
  130. data/examples/canvas/mermaid_canvas_demo.sh +151 -0
  131. data/examples/charts/bar_chart_demo.rb +57 -0
  132. data/examples/charts/line_chart_demo.rb +73 -0
  133. data/examples/charts/pie_area_demo.rb +87 -0
  134. data/examples/charts/stacked_bar_chart_demo.rb +69 -0
  135. data/examples/claude_code/README.md +85 -0
  136. data/examples/claude_code/codebreaker/.claude/commands/infiltrate.md +632 -0
  137. data/examples/claude_code/codebreaker/.claude/settings.json +11 -0
  138. data/examples/claude_code/codebreaker/.claude/settings.local.json +17 -0
  139. data/examples/claude_code/codebreaker/.claude/skills/infiltrate.md +0 -0
  140. data/examples/claude_code/codebreaker/README.md +213 -0
  141. data/examples/claude_code/tutorial/.claude/commands/learn.md +453 -0
  142. data/examples/claude_code/tutorial/README.md +102 -0
  143. data/examples/claude_code/verification_flow/.claude/commands/verify.md +171 -0
  144. data/examples/claude_code/verification_flow/README.md +126 -0
  145. data/examples/components/annotated_code_demo.rb +54 -0
  146. data/examples/components/callout_demo.rb +33 -0
  147. data/examples/components/checkbox_group_demo.rb +46 -0
  148. data/examples/components/design_review.css +468 -0
  149. data/examples/components/design_review_demo.rb +44 -0
  150. data/examples/components/design_review_dsl.rb +300 -0
  151. data/examples/components/diff_block_demo.rb +112 -0
  152. data/examples/components/events_demo.rb +184 -0
  153. data/examples/components/form_demo.rb +62 -0
  154. data/examples/components/lesson_demo.rb +53 -0
  155. data/examples/components/markdown_demo.rb +110 -0
  156. data/examples/components/mermaid_demo.rb +166 -0
  157. data/examples/components/pareto_set.rb +64 -0
  158. data/examples/components/prd_demo.rb +21 -0
  159. data/examples/components/prd_dsl.rb +385 -0
  160. data/examples/components/quiz_demo.rb +47 -0
  161. data/examples/components/run_viewer_demo.rb +100 -0
  162. data/examples/components/score_and_collapsible_demo.rb +82 -0
  163. data/examples/components/select_stale_value_smoke_test.rb +39 -0
  164. data/examples/components/table_demo.rb +247 -0
  165. data/examples/components/timer_with_state_demo.rb +58 -0
  166. data/examples/components/todo_due_dates.rb +63 -0
  167. data/examples/components/uat_gaps_demo.rb +112 -0
  168. data/examples/dashboard/feed_simulator.rb +60 -0
  169. data/examples/dashboard/live_dashboard.rb +80 -0
  170. data/examples/dashboard_components.rb +108 -0
  171. data/examples/deferred_fragments_demo.rb +62 -0
  172. data/examples/generate_more_spike/README.md +63 -0
  173. data/examples/generate_more_spike/app.rb +441 -0
  174. data/examples/git_health.sh +401 -0
  175. data/examples/layout/layout_components_demo.rb +174 -0
  176. data/examples/layout/modal_demo.rb +215 -0
  177. data/examples/layout/navigation_demo.rb +227 -0
  178. data/examples/layout/route_tabs_demo.rb +52 -0
  179. data/examples/layout/routing_demo.rb +52 -0
  180. data/examples/layout/scroll_box_demo.rb +154 -0
  181. data/examples/lazy_fragments_demo.rb +151 -0
  182. data/examples/my_todos/README.md +29 -0
  183. data/examples/my_todos/my_todos.rb +312 -0
  184. data/examples/my_todos/store.rb +86 -0
  185. data/examples/opal/reactive_demo.rb +62 -0
  186. data/examples/opal/scenarios/s1_counter.rb +15 -0
  187. data/examples/opal/scenarios/s2_search_filter.rb +17 -0
  188. data/examples/opal/scenarios/s3_sibling_tabs.rb +25 -0
  189. data/examples/opal/scenarios/s4_shopping_cart.rb +34 -0
  190. data/examples/opal/scenarios/s5_watch.rb +39 -0
  191. data/examples/opal/scenarios/s6_on_start.rb +20 -0
  192. data/examples/opal/scenarios/s7_wizard.rb +33 -0
  193. data/examples/opal/scenarios/s8_loan_calculator.rb +21 -0
  194. data/examples/opal/scenarios/s9_dashboard.rb +27 -0
  195. data/examples/operations_dashboard_demo.rb +167 -0
  196. data/examples/panel_demo.sh +447 -0
  197. data/examples/parity/assets/tyrion_slice.css +86 -0
  198. data/examples/parity/rivet_people_slice.rb +182 -0
  199. data/examples/parity/tyrion_components.css +777 -0
  200. data/examples/parity/tyrion_warroom_components.rb +414 -0
  201. data/examples/parity/tyrion_warroom_slice.rb +549 -0
  202. data/examples/puma_dev/README.md +124 -0
  203. data/examples/puma_dev/config.ru +34 -0
  204. data/examples/puma_dev/standalone_app.rb +34 -0
  205. data/examples/scaffolding/blog.rb +49 -0
  206. data/examples/scaffolding/utf_lite.rb +105 -0
  207. data/examples/styling/feedback_demo.rb +229 -0
  208. data/examples/styling/style_showcase.rb +172 -0
  209. data/examples/styling/theme_demo.rb +508 -0
  210. data/examples/timer_health_checker.rb +111 -0
  211. data/examples/timer_showcase.rb +131 -0
  212. data/examples/tutorials/render_markdown.rb +112 -0
  213. data/examples/tutorials/streamweaver_way_tutorial.rb +44 -0
  214. data/examples/tutorials/tutorial_content.rb +1082 -0
  215. data/examples/visual_skills/design_deck_demo.rb +314 -0
  216. data/examples/visual_skills/explainer_demo.rb +499 -0
  217. data/exe/streamweaver +9 -0
  218. data/gsd/ROADMAP-1.0.md +167 -0
  219. data/gsd/STATE.md +13 -0
  220. data/gsd/research/market-positioning-research.md +139 -0
  221. data/gsd/research/production-patterns-research.md +270 -0
  222. data/gsd/research/repo-audit-1.0.md +274 -0
  223. data/lib/stream_weaver/action_token.rb +58 -0
  224. data/lib/stream_weaver/adapter/alpinejs.rb +8249 -0
  225. data/lib/stream_weaver/adapter/base.rb +591 -0
  226. data/lib/stream_weaver/adapter/opal.rb +334 -0
  227. data/lib/stream_weaver/adapter/static.rb +1118 -0
  228. data/lib/stream_weaver/admin.rb +176 -0
  229. data/lib/stream_weaver/app.rb +1821 -0
  230. data/lib/stream_weaver/assets/js/sw-copy.js +50 -0
  231. data/lib/stream_weaver/assets/js/sw-heredoc-rewrite.js +68 -0
  232. data/lib/stream_weaver/assets/js/sw-keyboard.js +165 -0
  233. data/lib/stream_weaver/assets/js/sw-mermaid-zoom.js +621 -0
  234. data/lib/stream_weaver/assets/js/sw-route-tabs.js +53 -0
  235. data/lib/stream_weaver/assets/js/sw-sidebar-toc.js +116 -0
  236. data/lib/stream_weaver/assets/js/sw-slide-nav.js +103 -0
  237. data/lib/stream_weaver/canvas/bridge.rb +239 -0
  238. data/lib/stream_weaver/canvas/bridge_server.rb +646 -0
  239. data/lib/stream_weaver/canvas/client.rb +298 -0
  240. data/lib/stream_weaver/canvas/doc_roots.rb +233 -0
  241. data/lib/stream_weaver/canvas/doc_store.rb +252 -0
  242. data/lib/stream_weaver/canvas/gist_publisher.rb +264 -0
  243. data/lib/stream_weaver/canvas/gist_save_handler.rb +89 -0
  244. data/lib/stream_weaver/canvas/gist_store.rb +135 -0
  245. data/lib/stream_weaver/canvas/helpers.rb +109 -0
  246. data/lib/stream_weaver/canvas/history.rb +90 -0
  247. data/lib/stream_weaver/canvas/protocol.rb +86 -0
  248. data/lib/stream_weaver/canvas/reader.rb +853 -0
  249. data/lib/stream_weaver/canvas/save_doc_widget.rb +457 -0
  250. data/lib/stream_weaver/canvas/scroll_top_hint.rb +21 -0
  251. data/lib/stream_weaver/canvas/session.rb +132 -0
  252. data/lib/stream_weaver/cli.rb +3235 -0
  253. data/lib/stream_weaver/component_assets.rb +70 -0
  254. data/lib/stream_weaver/component_registry.rb +67 -0
  255. data/lib/stream_weaver/component_renderer.rb +48 -0
  256. data/lib/stream_weaver/components/annotated_code.rb +53 -0
  257. data/lib/stream_weaver/components/api_endpoint.rb +42 -0
  258. data/lib/stream_weaver/components/callout.rb +59 -0
  259. data/lib/stream_weaver/components/chart.rb +84 -0
  260. data/lib/stream_weaver/components/code_block.rb +77 -0
  261. data/lib/stream_weaver/components/comparison.rb +39 -0
  262. data/lib/stream_weaver/components/decision.rb +38 -0
  263. data/lib/stream_weaver/components/deck/close_overlay.rb +84 -0
  264. data/lib/stream_weaver/components/deck/confirmation_bar.rb +60 -0
  265. data/lib/stream_weaver/components/deck/deck_option.rb +61 -0
  266. data/lib/stream_weaver/components/deck/deck_slide.rb +77 -0
  267. data/lib/stream_weaver/components/deck/deck_state.rb +469 -0
  268. data/lib/stream_weaver/components/deck/deck_summary.rb +73 -0
  269. data/lib/stream_weaver/components/deck/design_deck.rb +60 -0
  270. data/lib/stream_weaver/components/deck/generate_more_controls.rb +84 -0
  271. data/lib/stream_weaver/components/deck/model_selector.rb +84 -0
  272. data/lib/stream_weaver/components/deck/skeleton_placeholder.rb +36 -0
  273. data/lib/stream_weaver/components/diff_block.rb +125 -0
  274. data/lib/stream_weaver/components/doc_header.rb +57 -0
  275. data/lib/stream_weaver/components/image_block.rb +70 -0
  276. data/lib/stream_weaver/components/implementation_map.rb +33 -0
  277. data/lib/stream_weaver/components/keyboard_shortcuts.rb +94 -0
  278. data/lib/stream_weaver/components/kpi_dashboard.rb +78 -0
  279. data/lib/stream_weaver/components/mermaid.rb +79 -0
  280. data/lib/stream_weaver/components/pipeline.rb +63 -0
  281. data/lib/stream_weaver/components/sidebar_toc.rb +46 -0
  282. data/lib/stream_weaver/components/slide_container.rb +141 -0
  283. data/lib/stream_weaver/components/timeline_event.rb +58 -0
  284. data/lib/stream_weaver/components/wireframe.rb +29 -0
  285. data/lib/stream_weaver/components/wireframe_block.rb +34 -0
  286. data/lib/stream_weaver/components.rb +2711 -0
  287. data/lib/stream_weaver/css.rb +230 -0
  288. data/lib/stream_weaver/dev_fallback_overlay.rb +61 -0
  289. data/lib/stream_weaver/display_dsl.rb +1004 -0
  290. data/lib/stream_weaver/export/html_exporter.rb +478 -0
  291. data/lib/stream_weaver/feed.rb +34 -0
  292. data/lib/stream_weaver/feed_builder.rb +31 -0
  293. data/lib/stream_weaver/fonts.rb +33 -0
  294. data/lib/stream_weaver/interaction_runner.rb +487 -0
  295. data/lib/stream_weaver/iterm.rb +460 -0
  296. data/lib/stream_weaver/layout_registry.rb +92 -0
  297. data/lib/stream_weaver/opal/bridge.rb +52 -0
  298. data/lib/stream_weaver/opal/builder.rb +193 -0
  299. data/lib/stream_weaver/opal/env.rb +29 -0
  300. data/lib/stream_weaver/opal/reactive_state.rb +75 -0
  301. data/lib/stream_weaver/opal/regexp_anchor_patch.rb +62 -0
  302. data/lib/stream_weaver/opal/renderer.rb +77 -0
  303. data/lib/stream_weaver/opal/runtime.rb +250 -0
  304. data/lib/stream_weaver/opal/shell.rb +145 -0
  305. data/lib/stream_weaver/opal/string_bridge.rb +50 -0
  306. data/lib/stream_weaver/opal/stubs/diff.min.js +1 -0
  307. data/lib/stream_weaver/opal/stubs/digest.rb +15 -0
  308. data/lib/stream_weaver/opal/stubs/marked.umd.js +79 -0
  309. data/lib/stream_weaver/opal/stubs/md5.rb +3 -0
  310. data/lib/stream_weaver/opal/stubs/morphdom.min.js +775 -0
  311. data/lib/stream_weaver/opal/stubs/prism-tomorrow.min.css +1 -0
  312. data/lib/stream_weaver/opal/stubs/prism.min.js +1967 -0
  313. data/lib/stream_weaver/opal_entry.rb +136 -0
  314. data/lib/stream_weaver/org/inline.rb +95 -0
  315. data/lib/stream_weaver/org/reader.rb +561 -0
  316. data/lib/stream_weaver/org/recording_context.rb +86 -0
  317. data/lib/stream_weaver/org/source_splitter.rb +63 -0
  318. data/lib/stream_weaver/org/writer.rb +300 -0
  319. data/lib/stream_weaver/page_shell.rb +530 -0
  320. data/lib/stream_weaver/portfile.rb +79 -0
  321. data/lib/stream_weaver/pushable.rb +42 -0
  322. data/lib/stream_weaver/resource/default_views.rb +111 -0
  323. data/lib/stream_weaver/resource/field_input.rb +25 -0
  324. data/lib/stream_weaver/resource/state_keys.rb +16 -0
  325. data/lib/stream_weaver/resource/store.rb +18 -0
  326. data/lib/stream_weaver/resource.rb +104 -0
  327. data/lib/stream_weaver/server.rb +1471 -0
  328. data/lib/stream_weaver/service.rb +1240 -0
  329. data/lib/stream_weaver/service_client.rb +104 -0
  330. data/lib/stream_weaver/session_store.rb +186 -0
  331. data/lib/stream_weaver/skills/streamweaver-canvas-safe/SKILL.md +66 -0
  332. data/lib/stream_weaver/skills/streamweaver-canvas-safe/examples/canvas-safe-showcase.rb +98 -0
  333. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/actions-and-buttons.md +47 -0
  334. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/charts-and-diagrams.md +38 -0
  335. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/deck.md +32 -0
  336. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/inputs-and-forms.md +65 -0
  337. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/tabs-and-navigation.md +53 -0
  338. data/lib/stream_weaver/skills/streamweaver-doc-builder/SKILL.md +298 -0
  339. data/lib/stream_weaver/skills/streamweaver-visual-companion/SKILL.md +130 -0
  340. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.css +468 -0
  341. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.rb +39 -0
  342. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example_dsl.rb +300 -0
  343. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example.rb +24 -0
  344. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example_dsl.rb +385 -0
  345. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/checkpoints-and-forms.md +28 -0
  346. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/cleanup-and-panel.md +44 -0
  347. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/example-gallery.md +8 -0
  348. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/persistence.md +58 -0
  349. data/lib/stream_weaver/skills/streamweaver-way/SKILL.md +396 -0
  350. data/lib/stream_weaver/skills/visual-plan/SKILL.md +201 -0
  351. data/lib/stream_weaver/skills/visual-recap/SKILL.md +244 -0
  352. data/lib/stream_weaver/streamer.rb +63 -0
  353. data/lib/stream_weaver/templates/choices.rb +149 -0
  354. data/lib/stream_weaver/templates/code.rb +157 -0
  355. data/lib/stream_weaver/templates/confirm.rb +110 -0
  356. data/lib/stream_weaver/templates/diff.rb +195 -0
  357. data/lib/stream_weaver/templates/info.rb +142 -0
  358. data/lib/stream_weaver/templates/table.rb +169 -0
  359. data/lib/stream_weaver/templates/wizard.rb +271 -0
  360. data/lib/stream_weaver/theme/auto_mode.rb +116 -0
  361. data/lib/stream_weaver/theme/presets.rb +555 -0
  362. data/lib/stream_weaver/theme.rb +639 -0
  363. data/lib/stream_weaver/university/canvas.rb +924 -0
  364. data/lib/stream_weaver/university/course.rb +699 -0
  365. data/lib/stream_weaver/university/demos/counter.rb +33 -0
  366. data/lib/stream_weaver/university/demos/dashboard.rb +147 -0
  367. data/lib/stream_weaver/university/demos/decision_form.rb +143 -0
  368. data/lib/stream_weaver/university/demos.rb +50 -0
  369. data/lib/stream_weaver/university/listener.rb +467 -0
  370. data/lib/stream_weaver/university/progress.rb +201 -0
  371. data/lib/stream_weaver/university/runner.rb +138 -0
  372. data/lib/stream_weaver/university/scripts/growing_doc.rb +609 -0
  373. data/lib/stream_weaver/university/scripts/growing_doc_state.rb +91 -0
  374. data/lib/stream_weaver/utils.rb +31 -0
  375. data/lib/stream_weaver/version.rb +5 -0
  376. data/lib/stream_weaver/views/canvas/reader_layout.erb +800 -0
  377. data/lib/stream_weaver/views.rb +3940 -0
  378. data/lib/stream_weaver.rb +122 -0
  379. data/llms.txt +1269 -0
  380. data/sig/stream_weaver.rbs +4 -0
  381. metadata +598 -0
data/llms.txt ADDED
@@ -0,0 +1,1269 @@
1
+ # StreamWeaver - LLM Quick Reference
2
+
3
+ StreamWeaver is a Ruby DSL for building reactive web UIs with minimal code. Think "Streamlit for Ruby".
4
+
5
+ ## CRITICAL: Auto-Port and Auto-Browser
6
+
7
+ **StreamWeaver automatically:**
8
+ 1. **Finds a free port** - Does NOT use a fixed port. Scans from 4567 upward.
9
+ 2. **Opens the browser** - You do NOT need to open it manually.
10
+ 3. **Prints the URL to stdout** - Read terminal output to get the actual port.
11
+
12
+ **NEVER assume port 4567.** The actual port is printed to stdout:
13
+ ```
14
+ ╔═══════════════════════════════════════════════════════════════╗
15
+ ║ StreamWeaver App Running ║
16
+ ╚═══════════════════════════════════════════════════════════════╝
17
+
18
+ 🌐 http://localhost:4572 ← READ THIS, don't assume 4567!
19
+ 📱 My App
20
+
21
+ Press Ctrl+C to stop
22
+ ```
23
+
24
+ **For AI agents:** Parse the terminal output after `ruby app.rb` to find the actual URL.
25
+
26
+ **Finding the port an agent is already driving (do this, not a second boot):** Run the app as a
27
+ background task, then read its own stdout for the `http://127.0.0.1:<port>` line the startup
28
+ banner prints — that line IS the port, there is nothing to discover beyond reading it. **NEVER
29
+ boot a second instance of the same app to "find" a port** — StreamWeaver's own port-scan means a
30
+ second launch just finds the NEXT free port and starts a duplicate, orphaned server; it never
31
+ tells you what the first one is running on. If the banner didn't reach your captured output
32
+ (e.g. you weren't backgrounding the process yet), find the live port with
33
+ `lsof -i :4567-4620 -sTCP:LISTEN` against the ALREADY-running process rather than sleeping on
34
+ the log or launching another one.
35
+
36
+ ## THE KEY INSIGHT
37
+
38
+ **Your Ruby DSL block RE-EXECUTES on every user interaction.**
39
+
40
+ - User types in a field → entire block runs again with updated `state[:field]`
41
+ - User clicks a button → callback runs, then entire block runs again
42
+ - This creates reactive UI without you writing any event handling code
43
+
44
+ ```ruby
45
+ app "Counter" do
46
+ # This ENTIRE block runs on EVERY interaction
47
+ state[:count] ||= 0
48
+
49
+ text "Count: #{state[:count]}"
50
+
51
+ button "+" do |s|
52
+ s[:count] += 1 # Modifies state, triggers re-run
53
+ end
54
+ end.run!
55
+ ```
56
+
57
+ ## The StreamWeaver Way
58
+
59
+ StreamWeaver is an omakase layer, not a widget kit — backend and frontend were designed as one system, so these conventions are the framework, not style advice. Follow them and you write no JavaScript. Six laws, each linked to the section that details it:
60
+
61
+ 1. **Key by what is unique per position on the page, not by what the content is about.** Auto-derived ids are position-stable, not content-stable — reach for `key: record_id` when identity must outlive reordering, filtering, or deletion. → [Interactive IDs and keying](#interactive-ids-and-keying)
62
+ 2. **Deferred over hand-rolled timers.** A slow region gets `fragment(..., defer: true)`, never an `every()` poll. → [Deferred Fragments](#deferred-fragments-fragment--defer-true)
63
+ 3. **Lazy means visible.** Hidden content costs nothing until CSS or scroll reveals it, and it fetches exactly once. → [Lazy Fragments](#lazy-fragments-fragment--lazy-true)
64
+ 4. **Dev loud, prod self-heals.** The deliberate inversion of Hotwire's "content missing": keep the debugging signal, don't charge the user for it. → [Dev Loud, Prod Self-Heal](#dev-loud-prod-self-heal-stale-action-fallback)
65
+ 5. **State your intent in DSL verbs.** Zero custom JavaScript is the default, not an aspiration — no script tags, no raw `hx-*` from app code, no adapter internals. → [Repo Conventions](#repo-conventions-anti-patterns-that-cause-churn)
66
+ 6. **Trilaws as design filters.** Matt (findable/digestible), Forrest (zero friction + real perks), Gloria (the default path must be the correct path). Law 1's auto-keying is Gloria's Law in code: the default already disambiguates, so the silent-wrong-callback bug cannot happen.
67
+
68
+ The `streamweaver-way` skill ([Agent Skills](#agent-skills-install-these-before-hand-rolling-the-above)) teaches these with the four benchmark recipes — inline editing, scoped live search, lazy hover cards, Russian-doll infinite scroll. `examples/my_todos/` runs all four in one app with zero custom JavaScript.
69
+
70
+ **Tutorial — `docs/tutorials/the-streamweaver-way.md`.** "Build My Todos: the StreamWeaver Way" rebuilds those same four features step by step, why-first: each step names the law it obeys, the gotcha it avoids, and the measured number where one exists (deferring a 1.5s region: 0.018s shell vs 1.508s fragment fetch; hover cards: 0.085s shell vs 9.151s eager; search: 800-byte scoped morph vs 2,123-byte whole-body swap; infinite scroll: O(1) per page vs O(n) per click). The skill *prescribes*; the tutorial *narrates* — read the tutorial when you want the reasoning, the skill when you want the answer. Runs as a `:doc`-theme app too: `SW_NO_OPEN=1 ruby examples/tutorials/streamweaver_way_tutorial.rb`.
71
+
72
+ ## Minimal Example
73
+
74
+ ```ruby
75
+ require 'stream_weaver'
76
+
77
+ app "Hello" do
78
+ text_field :name, placeholder: "Your name"
79
+
80
+ if state[:name].to_s.strip != ""
81
+ text "Hello, #{state[:name]}!"
82
+ end
83
+ end.run!
84
+ ```
85
+
86
+ Run: `ruby hello.rb` → browser opens automatically.
87
+
88
+ ## State
89
+
90
+ State is a hash. Access with `state[:key]`. Components auto-bind to keys.
91
+
92
+ ```ruby
93
+ text_field :email # state[:email] = user input
94
+ checkbox :agree, "I agree" # state[:agree] = true/false
95
+ select :color, ["Red", "Blue"] # state[:color] = selected value
96
+
97
+ # Read state anywhere
98
+ if state[:agree]
99
+ text "You agreed!"
100
+ end
101
+ ```
102
+
103
+ ## Essential Components
104
+
105
+ ### Input
106
+ ```ruby
107
+ text_field :name, placeholder: "Name"
108
+ text_area :bio, rows: 5
109
+ text_area :paste, transient: true # excluded from session cookie (for large paste areas)
110
+ checkbox :key, "Label" # state[:key] = true/false
111
+ select :role, ["Admin", "User"], default: "User"
112
+ radio_group :choice, ["A", "B", "C"]
113
+ date_field :due_on, label: "Due date" # native <input type=date>; state[:due_on] = "YYYY-MM-DD" (ISO 8601)
114
+ ```
115
+
116
+ ### IMPORTANT: Form Input Auto-Submit Behavior
117
+
118
+ **By default, ALL input components auto-submit on change.** Every keystroke, checkbox click, or selection triggers a server round-trip and page re-render. This is intentional for reactive UIs.
119
+
120
+ **ANTI-PATTERN — text_area / text_field / select paired with a button:**
121
+ ```ruby
122
+ # WRONG — every keystroke redaws the UI, textarea resize is lost, select jumps
123
+ text_area :prompt, rows: 4
124
+ select :choice, options
125
+ button "Submit" do |s| ... end
126
+
127
+ # CORRECT — no round-trips until button click
128
+ text_area :prompt, rows: 4, submit: false
129
+ select :choice, options, submit: false
130
+ button "Submit" do |s| ... end
131
+ ```
132
+ Any `text_field`, `text_area`, or `select` that feeds into a button action **must** use `submit: false`. Without it, typing causes full page re-renders, textarea resize resets, and the UI flickers on every keystroke.
133
+
134
+ **For "form-like" behavior** (collect multiple inputs, then act):
135
+
136
+ Option 1: Use `submit: false` on individual inputs:
137
+ ```ruby
138
+ # Checkboxes that don't auto-submit - user can check multiple before action
139
+ items.each do |item|
140
+ checkbox :"select_#{item.id}", item.name, submit: false
141
+ end
142
+
143
+ button "Process Selected" do |state|
144
+ items.each do |item|
145
+ if state[:"select_#{item.id}"]
146
+ # act on selected items
147
+ end
148
+ end
149
+ end
150
+ ```
151
+
152
+ Option 2: Use `form` block for deferred submission:
153
+ ```ruby
154
+ form :my_form do
155
+ text_field :name
156
+ select :status, %w[active paused]
157
+ submit "Save" do |form_values|
158
+ # All values submitted together
159
+ end
160
+ cancel "Reset"
161
+ end
162
+ ```
163
+
164
+ **When to use which:**
165
+ - Default (auto-submit): Filters, toggles, instant feedback
166
+ - `submit: false`: Multi-select lists with action buttons
167
+ - `form` block: Traditional forms with Save/Cancel
168
+
169
+ ### Checkbox Group (Multi-Select with Select All/None)
170
+ ```ruby
171
+ # For batch selection (emails, files, items to process)
172
+ checkbox_group :selected_items, select_all: "Select All", select_none: "Clear" do
173
+ items.each do |item|
174
+ item item.id do # Use `item value do`, NOT `checkbox`
175
+ text item.name
176
+ # Any components can be nested here
177
+ end
178
+ end
179
+ end
180
+ # state[:selected_items] = ["id1", "id3", ...] (array of selected IDs)
181
+ ```
182
+
183
+ **CRITICAL:** Inside `checkbox_group`, ONLY `item` elements are allowed at the top level. You CANNOT use `text`, `header`, `div`, or other components directly inside `checkbox_group` - they must be nested inside an `item` block. If you need section headers, put them OUTSIDE the checkbox_group.
184
+
185
+ ### Display
186
+ ```ruby
187
+ text "Plain text with #{state[:value]}"
188
+ md "**Bold** and *italic* markdown"
189
+ header "Section Title" # h2
190
+ header1 "Page Title" # h1
191
+ header3 "Subsection" # h3
192
+
193
+ # Mermaid diagrams — Mermaid.js loaded lazily from CDN
194
+ mermaid "graph LR; A-->B" # basic
195
+ mermaid diagram_code, zoom: true # zoom/pan controls
196
+ mermaid diagram_code, compact: true # reduced padding (use inside card)
197
+ mermaid diagram_code, layout: :elk # ELK layout engine for dense graphs
198
+ mermaid diagram_code, theme_vars: { primaryColor: "#6366f1" } # custom colors
199
+ ```
200
+
201
+ Every diagram, regardless of `zoom:`, also gets an "expand to full screen" button (no opt-in) — a wide diagram's fixed-px labels shrink to fit whatever doc column width it renders in no matter how the layout is tuned, so this is the actual fix for an illegible complex diagram, not just a bigger version of `zoom: true`.
202
+
203
+ ### CRITICAL: `text` Does NOT Render Markdown
204
+
205
+ **`text` is literal — markdown syntax appears as raw characters.**
206
+
207
+ ```ruby
208
+ # WRONG — renders as: **Select the article PDF:** (asterisks visible)
209
+ text "**Select the article PDF:**"
210
+
211
+ # CORRECT — renders as bold text
212
+ md "**Select the article PDF:**"
213
+
214
+ # BETTER — use semantic headers for section labels
215
+ header3 "Select the article PDF:"
216
+ header4 "What do you want to know?"
217
+ ```
218
+
219
+ **Rule:** Never put `**`, `*`, `#`, or other markdown in a `text` call. Use `md` if you need inline formatting, or `header1`–`header6` for structural labels.
220
+
221
+ ### Tables
222
+ ```ruby
223
+ # Basic - explicit headers/rows
224
+ table headers: ["Name", "Size"], rows: [["app.rb", "12kb"]]
225
+
226
+ # Array of hashes - auto-infer headers
227
+ table [{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }]
228
+
229
+ # Column DSL with formatters
230
+ table users do
231
+ column :name
232
+ column :balance, format: :currency, align: :right
233
+ column :joined, format: :date
234
+ column(:active) { |u| u.active? ? "Yes" : "No" }
235
+ end
236
+
237
+ # Interactive features
238
+ table data, sortable: true, sticky_header: true, striped: true
239
+
240
+ # Markdown links in cells
241
+ table [{ issue: "[JIRA-123](https://jira.example.com)" }], markdown: true
242
+
243
+ # Data sources
244
+ table data: :users # From state
245
+ table file: "data.yaml" # From file
246
+
247
+ # Cell style escape hatches: column style: (String|Proc), id_style: true/false, table-level id_column:
248
+ table headers: [...], rows: [...], id_column: false # disable the default first-column accent
249
+ ```
250
+
251
+ Formatters: `:date`, `:datetime`, `:currency`, `:number`, `:percent`
252
+
253
+ ### Buttons
254
+ ```ruby
255
+ button "Click Me" do |s|
256
+ s[:clicked] = true
257
+ s[:count] = (s[:count] || 0) + 1
258
+ end
259
+
260
+ button "Secondary", style: :secondary do |s|
261
+ # ...
262
+ end
263
+
264
+ # Client-side clipboard copy -- no server round-trip
265
+ copy_button "Copy summary", text: report_text
266
+
267
+ # Syntax-highlighted code, with an optional copy affordance (off by default)
268
+ code_block source, lang: "ruby", copy: true
269
+ ```
270
+
271
+ ### Interactive IDs and keying
272
+
273
+ Every interactive component needs an id the server can dispatch on. StreamWeaver
274
+ derives one for you; you only reach for `key:`/`id:` when you want identity to
275
+ survive something the derivation can't see.
276
+
277
+ **Precedence: `id:` > `key:` > auto-derivation.**
278
+
279
+ ```ruby
280
+ button "Delete" do |s| ... end # auto: label + block source location
281
+ button "Delete", key: todo[:id] do |s| ... end # content-stable: bound to the record
282
+ button "Delete", id: "delete-todo-#{todo[:id]}" # verbatim override -- you own the id
283
+ ```
284
+
285
+ - **auto-derivation** hashes the label plus the block's source location. Two loop
286
+ iterations render the same label at the same line, so they derive the same id;
287
+ StreamWeaver auto-disambiguates each repeat occurrence with a `-dup-N` suffix
288
+ (and warns once, pointing at `key:`). Every button stays independently
289
+ dispatchable with zero author intervention -- the old silent wrong-callback
290
+ bug cannot happen.
291
+ - **`key:`** accepts stable scalars ONLY -- String, Symbol, or Integer. Arrays,
292
+ hashes, and arbitrary objects raise `ArgumentError`, because their
293
+ stringification is not a stable identity. Never pass a positional index.
294
+ - **`id:`** is the full explicit override: the emitted DOM id is
295
+ `btn_<label>_<your id>` verbatim, which is what you want when other code
296
+ (SSE pushes, CSS, tests) targets the element by selector.
297
+
298
+ **Key by what is unique per position, not by content.** Auto-derived ids
299
+ (including the `-dup-N` suffixes) are POSITION-stable, not CONTENT-stable: they
300
+ hold steady across rerenders of the same list, but inserting or deleting an
301
+ earlier item shifts every later occurrence's suffix. If a row's identity must
302
+ outlive reordering, filtering, or deletion -- per-row Delete/Edit buttons, hover
303
+ cards, anything whose payload is "this record" -- pass `key: record_id`. The
304
+ classic failure is two cards sharing one derived id: hovering the second shows
305
+ the first one's data, and nothing errors.
306
+
307
+ **`strict_ids`** turns a derived-id collision from a warning into a hard failure,
308
+ so a loop that relies on position-stability can't ship unnoticed:
309
+
310
+ ```ruby
311
+ StreamWeaver::App.new("My App", strict_ids: true) { ... } # per app
312
+ StreamWeaver.strict_ids = true # global (boot file)
313
+ SW_STRICT_IDS=1 ruby my_app.rb # env, e.g. in CI
314
+ ```
315
+
316
+ It raises in development (and test), and degrades to a warning when
317
+ `RACK_ENV=production` -- a live page should not 500 over an id the framework
318
+ already disambiguated. The same rules apply to `clickable(action:, key:)`,
319
+ `fragment` names, and feed/canvas-pushed buttons.
320
+
321
+ ### Layout
322
+ ```ruby
323
+ card do
324
+ header3 "Card Title"
325
+ text "Content"
326
+ end
327
+
328
+ vstack spacing: :md do
329
+ text "Vertical"
330
+ text "Stack"
331
+ end
332
+
333
+ hstack spacing: :sm do
334
+ button "Left"
335
+ button "Right"
336
+ end
337
+
338
+ columns widths: ['30%', '70%'] do
339
+ column { text "Sidebar" }
340
+ column { text "Main" }
341
+ end
342
+ ```
343
+
344
+ ### Modal
345
+ ```ruby
346
+ button "Open" do |s|
347
+ s[:my_modal_open] = true
348
+ end
349
+
350
+ modal :my_modal, title: "Dialog", size: :md do
351
+ text "Modal content"
352
+ modal_footer do
353
+ button "Close" do |s|
354
+ s[:my_modal_open] = false
355
+ end
356
+ end
357
+ end
358
+ ```
359
+
360
+ ### Navigation
361
+ ```ruby
362
+ # Horizontal navbar — active item is bold/non-clickable, others are links
363
+ navbar do
364
+ nav_item "Dashboard", href: "/", active: true
365
+ nav_item "Settings", href: "/settings"
366
+ end
367
+
368
+ # Inline link
369
+ link_to "View docs", href: "https://example.com"
370
+ ```
371
+
372
+ ### URL Routing (Deep Links & Bookmarks)
373
+
374
+ Use `route_by` to map a state key's values to URL paths. This enables:
375
+ - Direct URL access (e.g. visiting `/settings` loads the settings page)
376
+ - Browser back/forward navigation
377
+ - Bookmarkable URLs
378
+ - External deep-linking
379
+
380
+ ```ruby
381
+ app "My App" do
382
+ route_by :page, home: "/", dashboard: "/dashboard", settings: "/settings"
383
+
384
+ state[:page] ||= :home
385
+
386
+ navbar do
387
+ nav_item "Home", href: "/", active: state[:page] == :home
388
+ nav_item "Dashboard", href: "/dashboard", active: state[:page] == :dashboard
389
+ nav_item "Settings", href: "/settings", active: state[:page] == :settings
390
+ end
391
+
392
+ case state[:page]
393
+ when :home then # ...
394
+ when :dashboard then # ...
395
+ when :settings then # ...
396
+ end
397
+ end.run!
398
+ ```
399
+
400
+ **How it works:** When `state[:page]` changes (via button callback or nav click), StreamWeaver emits an `HX-Push-Url` header that updates the browser URL. Visiting `/dashboard` directly seeds `state[:page] = :dashboard` before render. Back/forward uses HTMX to re-fetch the page at the popped URL.
401
+
402
+ **Important:** `route_by` is opt-in — apps without it are unaffected.
403
+
404
+ ### Route Tabs (`tabs url: true`)
405
+
406
+ Puts a tab group's active index in a query param, so a tab is bookmarkable:
407
+
408
+ ```ruby
409
+ tabs :view, url: true do # active tab lives in ?view=<index>
410
+ tab("Overview") { text "..." }
411
+ tab("Findings") { text "..." }
412
+ end
413
+
414
+ # Read state BELOW the declaration — above it, state[:view] is still the raw
415
+ # pre-authority value (param string or stale session index), not the resolved Integer.
416
+ md "Active view: #{state[:view]}"
417
+ ```
418
+
419
+ Tab switching is client-side (History API), so no request is made; back/forward and
420
+ bookmarks work. Multiple groups compose, one param per key (`/?view=2&panel=1`).
421
+
422
+ **URL authority:** on a full GET the param wins — present and valid → that index,
423
+ absent or invalid → tab 0, never the session value. Same URL, same tabs, always.
424
+ `?view=999`, `?view=abc`, `?view[]=` all return 200 on tab 0; `?view=2` is a valid deep
425
+ link. A route-tab group therefore can't be server-side preset (`state[:view] ||= 1`
426
+ above the declaration is overwritten) — link to `/?view=1` instead.
427
+
428
+ Raises at build time: reserved keys (`app_id`, `splat`, `captures`, `button_id`),
429
+ duplicate `url:` keys, or `url: true` combined with `lazy: true`. On canvas, `url:` is
430
+ ignored (plain client tabs + one logged warning; the page URL is untouched).
431
+
432
+ Plain tabs also clamp: an out-of-range index renders tab 0 instead of blank panels.
433
+
434
+ ### Resource Scaffolding
435
+
436
+ Use `resource` for convention-over-configuration CRUD. One block replaces ~40 lines of route/state/form boilerplate:
437
+
438
+ ```ruby
439
+ resource :post, store: PostStore do
440
+ field :title, :string
441
+ field :body, :text
442
+ field :status, :enum, values: %w[draft published]
443
+ end
444
+ ```
445
+
446
+ Generates index / show / new / edit / destroy with deep-linkable URLs, named-route helpers (`posts_path`, `post_path(rec)`, etc.), store validation at startup, and override blocks for any action. Declare `page` / `route` calls before `resource` blocks (first-registered-wins routing chain).
447
+
448
+ See `docs/resource-dsl.md` for full reference: field types, `only`/`except`, store protocol, `_sw_` state schema, override blocks.
449
+
450
+ ### Record-Bound Forms (`form_for`)
451
+
452
+ `form_for` is the primitive `resource`'s own `new`/`edit` views build on: seeds fields from a record, infers create vs. update from record identity, coerces + validates on submit, then `store.create`/`update` → flash notice → PRG to the resource's `show` action. Use it inside `resource` override blocks (or standalone) whenever you need more control than the default form gives you:
453
+
454
+ ```ruby
455
+ resource :person, store: PersonStore do
456
+ field :name, :string
457
+ field :role, :enum, values: %w[lead champion decision_maker]
458
+
459
+ edit do |person|
460
+ header1 "Edit #{person[:name]}"
461
+ form_for :person, record: person do
462
+ submit_label "Save"
463
+ cancel_label "Cancel"
464
+ end
465
+ end
466
+ end
467
+ ```
468
+
469
+ - `record:` present with an `:id` → update mode (`"Save"`); `nil`/absent → create mode (`"Create"`).
470
+ - Seeds the form scope from `record:` only on first render for that id — an unrelated re-render never clobbers an in-progress edit; switching to a different id re-seeds fresh.
471
+ - Validation failures (coercion or a `validate { |values| ... }` block) populate an error `Alert` above the fields and re-render same-request, keeping the user's typed values — never a redirect.
472
+ - `on_success:` overrides the default show/index transition; required for standalone (non-`resource`) usage.
473
+
474
+ Full reference (all options, in-block DSL, standalone usage): `docs/resource-dsl.md#form_for`.
475
+
476
+ ### Turbo Frame-Style Inline Editing with `form_for`
477
+
478
+ The learnhotwire.com course's canonical Turbo Frames demo: click a todo's title, it turns into an edit field in place, submit swaps back to the display — no full navigation. Rails does this with two matching-`dom_id` frames; StreamWeaver gets the same *result* (deep-linkable edit state, no hand-written JS, no full page reload — HTMX swaps the container) from `resource` + `form_for`, using the resource's own routed `edit` action instead of a client-side frame swap:
479
+
480
+ ```ruby
481
+ require 'stream_weaver'
482
+ require 'securerandom'
483
+
484
+ module TodoStore
485
+ @todos = [{ id: '1', title: 'Buy milk', completed: false }]
486
+ def self.all; @todos; end
487
+ def self.find(id); @todos.find { |t| t[:id] == id }; end
488
+ def self.create(attrs); id = SecureRandom.hex(4); @todos << { id: id, **attrs }; id; end
489
+ def self.update(id, attrs) t = find(id); t&.merge!(attrs); !!t; end
490
+ def self.destroy(id); @todos.reject! { |t| t[:id] == id }; true; end
491
+ end
492
+
493
+ app 'My Todos' do
494
+ resource :todo, store: TodoStore do
495
+ field :title, :string
496
+ field :completed, :boolean
497
+
498
+ # Row shows the title as plain text with an Edit link — click it to swap
499
+ # this row's spot for an inline form_for, submit swaps back to the row.
500
+ index do |todos|
501
+ header1 'My Todos'
502
+ todos.each do |todo|
503
+ hstack do
504
+ text todo[:title]
505
+ link_to 'Edit', href: edit_todo_path(todo)
506
+ end
507
+ end
508
+ end
509
+
510
+ # /todo/:id/edit — form_for seeds :title/:completed from the record,
511
+ # submit coerces + validates, calls TodoStore.update, flashes, and
512
+ # PRGs back to :show (URL pushed, deep-linkable either way).
513
+ edit do |todo|
514
+ header1 "Edit #{todo[:title]}"
515
+ form_for :todo, record: todo do
516
+ submit_label 'Save'
517
+ cancel_label 'Cancel'
518
+ end
519
+ end
520
+ end
521
+ end.run!
522
+ ```
523
+
524
+ **Key mapping** (Rails → StreamWeaver): matching `dom_id` frames that swap in place →
525
+ `resource`'s routed `edit` action (`edit_todo_path(todo)`) whose default/override view
526
+ calls `form_for :todo, record: todo`; Rails' `form_with model: @todo` field/value
527
+ seeding + strong-params allowlist → `form_for`'s record seeding + `field` declarations
528
+ (only declared fields are read from submitted state); Rails' controller-driven
529
+ create/update + redirect-back-to-show → `form_for`'s built-in coerce → validate →
530
+ `store.update` → flash → PRG to `show`, all for free. What StreamWeaver does not yet do
531
+ is a same-DOM-fragment swap scoped to just the one row — today the edit view is its own
532
+ routed, deep-linkable page/fragment, not a client-side frame replacement. For the other
533
+ half of the Turbo Frames story — rendering the shell now and letting a slow region land
534
+ later — see [Deferred Fragments](#deferred-fragments-fragment--defer-true) below.
535
+
536
+ ### Deferred Fragments (`fragment ..., defer: true`)
537
+
538
+ `fragment(:name) { ... }` marks a region of the page that interactions inside it can swap on
539
+ its own, instead of swapping the whole app container. Adding `defer: true` makes it the
540
+ equivalent of Turbo's `turbo_frame_tag ..., src:`: **the block does not run on the initial
541
+ render at all.** The page ships immediately with a placeholder, and the fragment fetches its
542
+ own content right after — no custom JavaScript in your app, just the option:
543
+
544
+ ```ruby
545
+ app 'Dashboard' do
546
+ header1 'Dashboard' # renders instantly
547
+ text "#{Order.count} orders" # renders instantly
548
+
549
+ # This block never runs during the page render. The shell responds, the
550
+ # browser fetches this fragment, and the content swaps in when ready.
551
+ fragment :revenue_report, defer: true do
552
+ text expensive_quarterly_rollup # 1.5s of work, off the critical path
553
+ end
554
+ end.run!
555
+ ```
556
+
557
+ **Placeholder** — what stands in until the content lands. Three forms:
558
+
559
+ ```ruby
560
+ fragment(:stats, defer: true) { ... } # default: small spinner
561
+ fragment(:stats, defer: true, placeholder: 'Loading stats…') { ... } # a String renders as text
562
+ fragment(:stats, defer: true, placeholder: -> { skeleton_rows(5) }) { ... } # a Proc runs as DSL
563
+ ```
564
+
565
+ **How it works**: the deferred fragment renders its placeholder wrapped in an element carrying
566
+ `hx-trigger="load"` pointing at the same signed fragment-update endpoint every in-fragment
567
+ interaction already uses. That fetch re-runs the DSL with this fragment marked as the one to
568
+ materialize, so its block executes and only its content comes back over the wire.
569
+
570
+ **Things to know**:
571
+
572
+ - The block is skipped on the shell render, so anything it registers as a side effect (actions,
573
+ timers) is not registered until the fetch. Keep registration outside deferred blocks.
574
+ - **A named-action button (`button 'X', action: :foo`) inside a deferred fragment does not fire.**
575
+ The fetch mints its token outside the session's action manifest, so clicking it re-renders the
576
+ fragment without running the action. Put such buttons in a non-deferred fragment, or use a block
577
+ button (`button('X') { |s| ... }`), which dispatches by id and is unaffected.
578
+ - Current input values are posted with the fetch, so a deferred fragment can read `state` set by
579
+ fields elsewhere on the page.
580
+ - Nesting works and chains: a `defer:` fragment inside a plain fragment loads in place; a
581
+ `defer:` fragment inside another `defer:` fragment loads after its parent does. Because
582
+ every fetch re-runs the DSL, a nested fetch also runs its ancestors' blocks — two nested
583
+ 1.5s panels cost 1.5s for the outer fetch and 3s for the inner one. Nest deferred
584
+ fragments for staging, not to divide one expensive block into cheaper pieces.
585
+ - An interaction that swaps the whole app container re-renders the deferred fragment as its
586
+ placeholder, and it fetches again. That keeps the shell fast on every render rather than
587
+ paying the slow cost inline.
588
+ - `defer:` is an option on `fragment`. The bare `defer { ... }` display verb is an unimplemented
589
+ stub that silently drops its block — do not use it.
590
+
591
+ ### Lazy Fragments (`fragment ..., lazy: true`)
592
+
593
+ `defer: true` fetches the moment the page loads. Adding `lazy: true` holds that fetch until the
594
+ fragment is **visible** — Turbo's `loading="lazy"`. Visible means visible, whatever caused it:
595
+ scrolled into the viewport, or a CSS rule flipping an ancestor out of `display: none`. It is not
596
+ "scrolled near" and it is not "hovered"; a hover only counts because the hover changed `display`.
597
+
598
+ ```ruby
599
+ fragment :revenue_report, lazy: true do
600
+ text expensive_quarterly_rollup # runs the first time this region is seen, never before
601
+ end
602
+ ```
603
+
604
+ **`lazy: true` implies `defer: true`** — write both if it reads better, but `lazy:` alone is
605
+ enough. `placeholder:` works the same as on any deferred fragment.
606
+
607
+ **The two guarantees**:
608
+
609
+ - **A CSS-hidden fragment never fetches.** `display: none` means no fetch, for as long as it
610
+ lasts. This is what makes the hover-card recipe below cost nothing on page load.
611
+ - **It fetches exactly once.** The content lands in the DOM and stays there; hiding and
612
+ re-showing the region does not fetch again.
613
+
614
+ **How it works**: the placeholder wrapper carries `hx-trigger="intersect once"` instead of
615
+ `hx-trigger="load"`, which htmx backs with an `IntersectionObserver` — the same browser API Turbo
616
+ uses. (`revealed`, the other htmx visibility trigger, reads `getBoundingClientRect()`, which
617
+ reports an all-zero rect for a hidden element and so counts it as revealed; it would fetch every
618
+ hidden fragment on page load. See `docs/research/2026-08-22-lazy-fragments-trigger-decision.md`.)
619
+ The wrapper also gets a `sw-fragment-lazy` class, which disappears with the wrapper once the
620
+ content lands — so `document.querySelectorAll('.sw-fragment-lazy').length` counts what has not
621
+ fetched yet, which is how you verify a hidden fragment stayed hidden.
622
+
623
+ #### Recipe: hover cards, with no JavaScript
624
+
625
+ CSS reveals the card; revealing it is what fires the fetch. Nothing on the page has fetched
626
+ anything until the pointer arrives.
627
+
628
+ ```ruby
629
+ app 'Directory' do
630
+ use_stylesheet <<~CSS
631
+ .hovercard-host { position: relative; display: inline-block; }
632
+ .hovercard-pop { display: none; position: absolute; top: 1.6rem; left: 0; z-index: 20; }
633
+ .hovercard-host:hover .hovercard-pop { display: block; }
634
+ CSS
635
+
636
+ div(class: 'hovercard-host') do
637
+ text 'Ada Lovelace'
638
+ div(class: 'hovercard-pop') do
639
+ fragment :ada_card, lazy: true, placeholder: 'Loading…' do
640
+ text expensive_profile_lookup('ada')
641
+ end
642
+ end
643
+ end
644
+ end.run!
645
+ ```
646
+
647
+ #### Recipe: infinite scroll, as nested fragments
648
+
649
+ **The page the reader is on renders inline; only the *next* page is lazy.** Each response
650
+ carries its own rows plus one placeholder for the page after it, so page N+1 does not exist in
651
+ the DOM until page N has been scrolled past. That is the whole of the pagination logic — no
652
+ scroll handler, no page counter in state.
653
+
654
+ ```ruby
655
+ def scroll_page(number)
656
+ # THIS page renders now, in whatever response we're already writing.
657
+ Post.page(number).each { |post| card { text post.title } }
658
+
659
+ # Only the NEXT page is deferred.
660
+ nxt = number + 1
661
+ if Post.page?(nxt)
662
+ fragment :"page_#{nxt}", lazy: true, placeholder: 'Loading…' do
663
+ scroll_page(nxt) # recursion lives INSIDE the lazy fragment
664
+ end
665
+ end
666
+ end
667
+
668
+ app('Feed') { scroll_page(1) }.run!
669
+ ```
670
+
671
+ **Do not wrap page `number` itself in the lazy fragment.** A deferred block is skipped on the
672
+ shell render, so `fragment(:"page_#{number}", lazy: true) { rows; recurse }` called with `1`
673
+ serves HTML containing no rows at all — just a placeholder. It *looks* correct only because
674
+ page 1's placeholder sits in the initial viewport, so the IntersectionObserver fires
675
+ immediately; a lazy fragment that is already visible fetches at once, which is precisely what
676
+ hides the mistake. Rendering the current page inline is also what Turbo does, and it is what
677
+ keeps the served HTML honest for crawlers and for a reader with no JavaScript.
678
+
679
+ Nested fragment ids are `parent--child`: page 2's fragment is declared beside page 1's rows so
680
+ its id is `sw-frag-page-2`, and page 3's is declared inside page 2's block, giving
681
+ `sw-frag-page-2--page-3`. Each fetch re-runs its ancestors' blocks (see the deferred nesting
682
+ note above), so keep the per-page work cheap.
683
+
684
+ #### Recipe: a tab panel that loads when you open it
685
+
686
+ Route tabs (`tabs :view, url: true`) render inactive panels as `display: none`, so a lazy
687
+ fragment inside one waits until that tab is selected — and then fetches just itself, without the
688
+ whole-container morph the deprecated `tabs ..., lazy: true` mode used. Put the fragment **inside**
689
+ a `tab` block, never beside one at the top level of the `tabs` block (a non-tab child there shifts
690
+ every panel index):
691
+
692
+ ```ruby
693
+ tabs :view, url: true do
694
+ tab('Summary') { text summary_line }
695
+ tab('Revenue') do
696
+ fragment :revenue, lazy: true, placeholder: -> { spinner(label: 'Loading…') } do
697
+ revenue_table
698
+ end
699
+ end
700
+ end
701
+ ```
702
+
703
+ **Things to know**:
704
+
705
+ - Everything true of `defer:` is true here: the block is skipped on the shell render, current
706
+ input values ride along with the fetch, a named-action button inside it does not fire (use a
707
+ block button), and a static export runs the block inline.
708
+ - A full-container swap re-renders a lazy fragment back to its placeholder and **re-arms** it —
709
+ but lazily. If it is off-screen or hidden when that happens, it waits, where a plain `defer:`
710
+ fragment would refetch immediately.
711
+ - The wrapper is what the observer watches, so give it something with height. The default spinner
712
+ placeholder has it; a placeholder that renders nothing gives the observer a zero-area target.
713
+
714
+ ### Feedback
715
+ ```ruby
716
+ alert(variant: :success) { text "Saved!" }
717
+ alert(variant: :error) { text "Failed!" }
718
+ # variants: :info, :success, :warning, :error
719
+ ```
720
+
721
+ ## Running Modes
722
+
723
+ ### Interactive (default)
724
+ ```ruby
725
+ app "My App" do
726
+ # ...
727
+ end.run!
728
+ ```
729
+ Server runs until Ctrl+C. State persists in session cookies (~4KB limit).
730
+
731
+ **Reset corrupted state:** `ruby my_app.rb --reset` clears the session on first page load.
732
+
733
+ **Debug logging:** `SW_DEBUG=1 ruby my_app.rb` logs every request with cookie size, state size, and SSE connection counts to stderr.
734
+
735
+ ### Remote/Mobile Access (Tailscale, LAN)
736
+
737
+ By default, StreamWeaver binds to `127.0.0.1` (localhost only). For access from phones, tablets, or other machines via Tailscale or LAN:
738
+
739
+ ```bash
740
+ STREAMWEAVER_HOST=0.0.0.0 STREAMWEAVER_PORT=4580 ruby my_app.rb
741
+ ```
742
+
743
+ Or in code:
744
+ ```ruby
745
+ app "Dashboard" do
746
+ # ...
747
+ end.run!(host: '0.0.0.0', port: 4580)
748
+ ```
749
+
750
+ **Environment variables** (overridden by code options):
751
+ - `STREAMWEAVER_HOST` — bind address (default: `127.0.0.1`)
752
+ - `STREAMWEAVER_PORT` — fixed port (default: auto-detect)
753
+
754
+ **Why fixed port matters for mobile:** Auto-port detection finds a different port each restart, breaking bookmarks. Set a fixed port when you need a stable URL.
755
+
756
+ **Host authorization:** When bound to `0.0.0.0`, StreamWeaver automatically disables Sinatra's host authorization so Tailscale DNS names, LAN IPs, etc. are accepted.
757
+
758
+ ### Agentic (one-shot)
759
+ ```ruby
760
+ result = app "Survey" do
761
+ text_field :name
762
+ select :priority, ["Low", "High"]
763
+ end.run_once!(auto_close_window: true)
764
+
765
+ puts result # => { "name" => "Alice", "priority" => "High" }
766
+ ```
767
+ Blocks until user submits, returns form data as hash, exits.
768
+
769
+ **Important for agentic mode:** Only input component values are returned. State set by button callbacks is NOT included.
770
+
771
+ ## Common Patterns
772
+
773
+ ### Conditional Display
774
+ ```ruby
775
+ app "Login" do
776
+ if state[:logged_in]
777
+ text "Welcome!"
778
+ button "Logout" do |s| s[:logged_in] = false end
779
+ else
780
+ text_field :username
781
+ button "Login" do |s| s[:logged_in] = true end
782
+ end
783
+ end
784
+ ```
785
+
786
+ ### List with Add/Remove
787
+ ```ruby
788
+ app "Todo" do
789
+ text_field :new_item
790
+
791
+ button "Add" do |s|
792
+ s[:items] ||= []
793
+ s[:items] << s[:new_item] if s[:new_item].to_s.strip != ""
794
+ s[:new_item] = ""
795
+ end
796
+
797
+ state[:items]&.each_with_index do |item, i|
798
+ hstack do
799
+ text item
800
+ button "X" do |s| s[:items].delete_at(i) end
801
+ end
802
+ end
803
+ end
804
+ ```
805
+
806
+ ### Multi-Step Wizard
807
+ ```ruby
808
+ app "Wizard" do
809
+ state[:step] ||= 1
810
+
811
+ case state[:step]
812
+ when 1
813
+ text_field :name
814
+ button "Next" do |s| s[:step] = 2 end
815
+ when 2
816
+ select :plan, ["Free", "Pro"]
817
+ button "Back" do |s| s[:step] = 1 end
818
+ button "Submit" do |s| s[:done] = true end
819
+ end
820
+
821
+ text "Done!" if state[:done]
822
+ end
823
+ ```
824
+
825
+ ## App Options
826
+
827
+ ```ruby
828
+ app "Title",
829
+ layout: :wide, # :default, :wide, :full, :fluid
830
+ theme: :dashboard, # :default, :dashboard, :document, :doc (artifact-exact editorial; supports dark via data-sw-theme="dark" on <html>)
831
+ strict_ids: true # raise (dev) / warn (prod) on derived-id collisions -- see "Interactive IDs and keying"
832
+ do
833
+ # ...
834
+ end
835
+ ```
836
+
837
+ ## Session Persistence
838
+
839
+ State persists in browser cookies across server restarts. To reset:
840
+ - Clear browser cookies
841
+ - Use incognito mode
842
+ - Add: `button "Reset" do |s| s.clear end`
843
+
844
+ ---
845
+
846
+ ## Canvas Mode (Side-by-Side Panels)
847
+
848
+ Canvas mode lets scripts display rich interactive UI in a browser panel alongside the terminal.
849
+ Useful for presenting forms, selections, or analysis results that are hard to show in terminal.
850
+
851
+ ### Quick Example (Shell Script)
852
+
853
+ ```bash
854
+ #!/bin/bash
855
+ SESSION="my-panel-$$"
856
+
857
+ # Open panel in iTerm2 split pane
858
+ streamweaver panel "$SESSION"
859
+
860
+ # Push UI content
861
+ streamweaver canvas-push "$SESSION" <<'RUBY'
862
+ header1 "Select Options"
863
+ checkbox :option_a, "Enable feature A"
864
+ checkbox :option_b, "Enable feature B"
865
+ radio_group :mode, ["Fast", "Safe", "Balanced"]
866
+ button "Apply"
867
+ RUBY
868
+
869
+ # Wait for button click, get JSON result
870
+ result=$(streamweaver canvas-wait "$SESSION")
871
+ echo "User selected: $result"
872
+
873
+ # Cleanup
874
+ streamweaver canvas-close "$SESSION"
875
+ ```
876
+
877
+ ### Key Commands
878
+
879
+ | Command | Purpose |
880
+ |---------|---------|
881
+ | `streamweaver panel SESSION [--layout=LAYOUT] [--theme=THEME] [--fresh]` | Open canvas in iTerm2 split pane |
882
+ | `streamweaver canvas-push SESSION` | Push DSL content (stdin or `<<'RUBY'`) |
883
+ | `streamweaver canvas-wait SESSION [--event TYPE\|--any]` | Wait for a control to dispatch, return JSON (see the control table below) |
884
+ | `streamweaver canvas-close SESSION` | Close the canvas session |
885
+ | `streamweaver canvas-read FILE [FILE\|DIR ...]` | Browse saved canvas DSL files in a local viewer (sidebar + prev/next nav) |
886
+ | `streamweaver export FILE.rb [-o OUT.html] [--inline-images] [--offline]` | Write a canvas DSL doc out as standalone HTML |
887
+
888
+ `panel` flags: `--layout=` is one of `default`/`wide`/`full`/`fluid` (default `fluid`). `--theme=` selects any registered theme for the session (default `:default`); e.g. `--theme=doc` renders the canvas in the `:doc` theme, including its dark-mode variant. `--fresh` closes any existing session with the same name before creating a new one.
889
+
890
+ `export` flags: `--inline-images` embeds local images as base64 data URIs so the file is fully portable. `--offline` fetches and inlines mermaid's own library (instead of referencing its CDN) so diagrams render in a viewer whose CSP blocks external scripts entirely — SharePoint's built-in HTML preview is the motivating case; needs network access once, at export time, not at view time. Same capability from `canvas-read`'s nav rail: "⬇ Export HTML" on every file, plus a second "⬇ Export HTML (offline)" link shown only on docs with a mermaid diagram.
891
+
892
+ ### Which canvas controls wake `canvas-wait`
893
+
894
+ | Control | Canvas event | Payload |
895
+ |---|---|---|
896
+ | `button` | `action` | `button`, `state` |
897
+ | `clickable(action:)` | `action` | `button` (the action token), `state` |
898
+ | `menu_item` with a block | `action` | `button` (the item id), `state` |
899
+ | `form` submit | `action` | `button`, `form`, `values` (the form scope), `state` |
900
+ | `radio_group`, `tag_buttons`, `chip_group` | `change` | `field`, `value`, `state` |
901
+
902
+ Text, date, select and checkbox fields dispatch no canvas event of their own -- their values ride along in the `state` of whichever `action` follows. `tag_buttons` is the exception among the `change` controls: it has no `x-model`, so its choice reaches the agent in its own `change` event but is *not* in a later `action`'s `state`.
903
+
904
+ ### Important Behavior
905
+
906
+ - **canvas-wait returns on `action` events by default** - see the control table above; the `change` events from `radio_group`, `tag_buttons` and `chip_group` return only with `--event change` or `--any`
907
+ - **Form state is captured** - JSON includes all checkbox, radio, text field values
908
+ - **Use heredoc for DSL** - `<<'RUBY'` prevents shell interpolation issues
909
+
910
+ ### Canvas DSL
911
+
912
+ Same components as regular StreamWeaver apps:
913
+ ```ruby
914
+ header1 "Title"
915
+ md "**Bold** and `code` work"
916
+ card do
917
+ checkbox :agree, "I agree to **terms**" # Markdown in labels
918
+ end
919
+ columns widths: ['50%', '50%'] do
920
+ column { radio_group :choice, ["A", "B"] }
921
+ column { checkbox :extra, "Extra option" }
922
+ end
923
+ button "Submit" # Returns JSON when clicked
924
+
925
+ # Mermaid diagrams work in canvas-push too
926
+ mermaid "graph LR; A-->B", zoom: true
927
+ ```
928
+
929
+ ---
930
+
931
+ ## Canvas Persistence (Two-Tier)
932
+
933
+ Every `canvas-push` is auto-saved. The user can promote a snapshot to a permanent doc with one click. Both tiers Just Work — you don't need to ask, you don't need to drive a save.
934
+
935
+ | Tier | Path | Origin | Git? |
936
+ |---|---|---|---|
937
+ | 1 (history) | `~/.streamweaver/history/<session>/<YYYYMMDD_HHMMSS>.rb` | every `canvas-push`, automatic, 7-day cleanup | NO |
938
+ | 2 (docs) | `<git_root>/docs/streamweaver_canvas/<name>.rb` | floating 💾 button in canvas, or curl POST | YES (intentional) |
939
+
940
+ **For Claude:** if the user says "save this canvas as X", point at the floating Save-as-doc button (bottom-right of the canvas) — they click it, name it, done. Don't reinvent the save flow. As a fallback for non-canvas contexts:
941
+
942
+ ```bash
943
+ curl -sX POST "http://localhost:<bridge-port>/canvas/<session>/save-doc" \
944
+ -H 'Content-Type: application/json' \
945
+ -d '{"name":"<doc-name>"}'
946
+ ```
947
+
948
+ **Browsing saved docs:** `streamweaver canvas-read` with no arguments opens the project's `docs/streamweaver_canvas/` directory in a viewer with two sidebar sections:
949
+ - **Docs** (expanded) — explicit saves
950
+ - **History** (collapsed by default) — every auto-saved snapshot, browseable retroactively
951
+
952
+ History entries also show a 💾 button so the user can promote any past snapshot to a doc without re-pushing.
953
+
954
+ The reader also discovers docs roots across the whole machine (scan of `$STREAMWEAVER_DOCS_SCAN_ROOTS`, default `~/work`, unioned with an auto-written registry at `~/.streamweaver/docs_roots.log`), grouped by repo with a repo filter — and it renders StreamWeaver-flavored `.org` docs (`#+STREAMWEAVER_DSL:` marker) everywhere `.rb` docs work.
955
+
956
+ **`.rb` vs `.org` when saving** (`"format":"org"` in the POST body, or the dialog's Org toggle): `.rb` is the full-fidelity format and keeps everything. `.org` is a document — it leaves out live controls (`button`, `radio_group`, `text_field`, `form`, ...), one `#+STREAMWEAVER_OMITTED: <call>` keyword line each, reported as `omitted` in the response's `coverage` alongside `recognized`/`passthrough_verbatim`/`passthrough_lossy`. Saving a session that carries a form is therefore fine in both formats; only `.rb` brings the form back.
957
+
958
+ ## Agent Skills (Install These Before Hand-Rolling the Above)
959
+
960
+ If you are Claude Code, Codex CLI, Gemini CLI, GitHub Copilot, or another agent that uses the `SKILL.md` convention, StreamWeaver ships three skills that already encode the workflows in this doc as trained procedure — check whether they're loaded before building UI from this doc's raw examples:
961
+
962
+ - **streamweaver-way** — for building or changing an *interactive* app or feature: inline editing, live search, per-row buttons, hover cards, infinite scroll, or any region slow enough to hold up the page. Encodes the six laws above plus the four benchmark recipes, with the gotchas that actually bite.
963
+ - **streamweaver-visual-companion** — the default choice for mockups, diagrams, layout comparisons, and brainstorming, any time you'd otherwise reach for a chrome-based visual companion or a claude.ai Artifact. Canvas-push is 5-7x cheaper in tokens and never leaves the terminal loop.
964
+ - **streamweaver-doc-builder** — for long-form editorial documents (PRDs, reports, explainers) using the `:doc` theme, proven to reach 1:1 visual parity with a real claude.ai Artifact.
965
+
966
+ **If those skills don't appear in your available-skills list, ask the user whether to install them** — it's one idempotent command that installs to both Claude Code's own path and the `.agents/skills/` cross-tool alias that Codex CLI, Gemini CLI, and GitHub Copilot all read natively (see `docs/reference/agent-skills-comparison.md` for how each tool discovers skills):
967
+
968
+ ```bash
969
+ streamweaver setup # global: ~/.claude/skills/ + ~/.agents/skills/ + bash permission (recommended)
970
+ streamweaver install-skill # project-local instead: .claude/skills/ + .agents/skills/ (no --global)
971
+ ```
972
+
973
+ ## Hotwire-Style Navigation Pattern
974
+
975
+ Layouts with sidebars (or any persistent client-side UI) MUST preserve state across navigation. Full page reloads collapse accordions, scroll-jump, and reset client state. The pattern, on the canvas-read layout, is the reference implementation:
976
+
977
+ ```erb
978
+ <body hx-boost="true"
979
+ hx-target="#content"
980
+ hx-select="#content"
981
+ hx-select-oob="#nav"
982
+ hx-swap="innerHTML">
983
+ <nav id="sidebar"> <!-- preserved across navigations --> </nav>
984
+ <div id="main">
985
+ <div id="content"> <%= @content_html %> </div>
986
+ <div id="nav"> <!-- prev/next/counter, OOB-swapped --> </div>
987
+ </div>
988
+ </body>
989
+ ```
990
+
991
+ Rules for any new layout following this pattern:
992
+
993
+ 1. **Navigation links must be `<a href>`** — `hx-boost` only intercepts anchor clicks. NEVER `<button onclick="location.href=...">`.
994
+ 2. **Disabled state renders as `<span>`**, not `<a>` — anchors don't have a real disabled state.
995
+ 3. **Keyboard shortcuts call `.click()` on the existing anchor** rather than mutating `location.href`.
996
+ 4. **Read current state from the URL at click time**, not from server-baked JS variables — after htmx swaps the page hasn't reloaded; baked variables are stale.
997
+ 5. **Update active-link highlights via `htmx:afterSwap` listener** that reads the URL and toggles `.active` (the sidebar isn't re-rendered, so the server-rendered class is stale).
998
+
999
+ Reference: `lib/stream_weaver/views/canvas/reader_layout.erb`.
1000
+
1001
+ **Future direction:** once Idiomorph (bd `stream_weaver-2ds`) lands, the OOB and active-class boilerplate goes away — morph diffs the rendered HTML and surgically updates only what changed, including class attributes. See `docs/streamweaver-frontend-vision.md` for the full thesis.
1002
+
1003
+ ## Dev Loud, Prod Self-Heal (Stale Action Fallback)
1004
+
1005
+ Every named action's token embeds a digest of the app's registered actions
1006
+ plus a per-session generation. When a request arrives whose token no longer
1007
+ matches either (`lib/stream_weaver/interaction_runner.rb`'s
1008
+ `StaleActionDefinition`) — most commonly because the dev server hot-reloaded
1009
+ code and the browser is still holding a token minted before the reload —
1010
+ `lib/stream_weaver/server.rb`'s `/action/:button_id` route responds `409` +
1011
+ `HX-Retarget: #app-container` and re-renders the whole app. **This
1012
+ self-healing full-container swap is unconditional and stays byte-for-byte
1013
+ identical in production**: a user never sees an error, the app just
1014
+ silently recovers.
1015
+
1016
+ This is a **deliberate inversion of Hotwire's philosophy**. Turbo Frames
1017
+ treat a missing/stale frame target as "content missing" and surface it loud
1018
+ by design — Rails' own course material calls this Turbo's single best
1019
+ debugging aid, because a silent frame swap during development hides the
1020
+ exact wiring bug the author needs to see. StreamWeaver keeps Hotwire's
1021
+ signal without inheriting its user-facing cost: **in development only**
1022
+ (`RACK_ENV` not `production`/`test` — see `sw_dev_mode?` in server.rb), the
1023
+ same 409 response additionally prepends a dismissible `.sw-dev-fallback`
1024
+ overlay (`lib/stream_weaver/dev_fallback_overlay.rb`) naming:
1025
+
1026
+ - the stale target — the scoped fragment id the token declared (or the
1027
+ action name, when no fragment was declared)
1028
+ - the likely cause — action token stale after a code reload (definitions
1029
+ changed) or a server restart/session reset (generation changed)
1030
+
1031
+ The overlay is framework-emitted HTML only — a checkbox+label CSS pattern
1032
+ dismisses it without a page reload, no inline `onclick`, no script tag, so
1033
+ app code stays zero-JS. Default styling comes from `--sw-dev-fallback-*`
1034
+ custom properties (`CSS.dev_fallback_css`); restyle via the `.sw-dev-fallback`
1035
+ class hook the same way any other framework CSS is overridden.
1036
+
1037
+ **Decision note — only one loud-failure cause is reachable through this
1038
+ path.** The 409 route only ever raises `StaleActionDefinition` for a
1039
+ digest/generation mismatch (a stale token). A separately-missing fragment
1040
+ target (e.g. `find_fragment` returning nil after a conditional re-render
1041
+ removed it) is a *different*, non-exceptional code path inside
1042
+ `InteractionRunner#scoped_response` — it already falls back to the same
1043
+ `HX-Retarget: #app-container` swap, but at `200`, not `409`, and isn't
1044
+ raised as an exception at all. That branch is out of scope for this
1045
+ overlay; only "stale action token" is distinguishable at the 409 rescue
1046
+ site today.
1047
+
1048
+ ## Repo Conventions (Anti-Patterns That Cause Churn)
1049
+
1050
+ Common failure modes when LLMs build StreamWeaver apps. Avoid these.
1051
+
1052
+ - **Don't launch new servers per visual question.** Use `streamweaver canvas-push` to update a single persistent canvas. Multiple `streamweaver run app.rb` invocations create orphaned processes and port conflicts.
1053
+ - **Don't write Python helper scripts.** `streamweaver panel <session>` already detects iTerm vs. other terminals and opens the right thing. Ruby-first; iTerm2 control via `~/work/iterm2_ruby` if needed at all.
1054
+ - **Don't search `~`.** It's 2/3 of the home disk. Target `~/work/<project>` or use `uregistry locate <name>`.
1055
+ - **Don't `text "**markdown**"`.** `text` does not render markdown — use `md`.
1056
+ - **Don't use `spacer` or `divider`.** They don't exist — use `div(style: "height:Npx")`.
1057
+ - **Don't `<button onclick="location.href=...">` for navigation.** Bypasses htmx, full page reload, loses sidebar state. Use `<a href>` so `hx-boost` intercepts.
1058
+ - **Don't use positional URL indexes** like `?file=2` for shareable resources. Use Rails-style resource URLs (`/docs/:name`, `/history/:session/:timestamp`) — see `route_by` and `resource` (above) and bd `stream_weaver-9ei` for the canvas-read migration.
1059
+ - **Don't pass `theme: :light`.** Unrecognized — defaults to `:default`, just omit.
1060
+ - **Don't assume port 4567.** StreamWeaver finds a free port, may be 4568+. Read the actual URL from stdout or `streamweaver canvas-list`.
1061
+
1062
+ ## Detailed Documentation
1063
+
1064
+ For comprehensive reference, see these docs:
1065
+
1066
+ | Document | Contents |
1067
+ |----------|----------|
1068
+ | `docs/components_reference.md` | Full component API (all options, variants) |
1069
+ | `docs/frontend-only.md` | What survives `canvas-read` and `streamweaver export` (no backend) vs. the live canvas — read before building a doc meant to be exported or browsed read-only |
1070
+ | `docs/SERVICE_MODE.md` | Multi-app service mode, CLI commands |
1071
+ | `docs/testing.md` | Unit and integration testing |
1072
+ | `docs/architecture/how_streamweaver_works.md` | Internal architecture, event flow |
1073
+ | `docs/routing.md` | URL routing: `route_by`, `route_with`, parameterized paths, edit routes — **read the Common Pitfalls section before building any app with more than a couple of routes**: `route_with` merges GET state rather than replacing it, and an incomplete `case`/`when` route table fails silently on both the parse and build sides |
1074
+ | `docs/crud-patterns.md` | CRUD: create/edit/delete modals, URL-addressable edit pages, store adapters |
1075
+ | `docs/streamweaver-frontend-vision.md` | Strategic direction: htmx + Alpine + Idiomorph as a Hotwire-equivalent stack for Ruby devs |
1076
+ | `docs/porting-artifacts.md` | Porting a claude.ai Artifact into StreamWeaver at visual parity: dissect, gap-assess, port, verify, promote |
1077
+ | `docs/shared-dsl-fragments.md` | Two or more docs for different audiences sharing ONE canonical copy of a table via an `instance_eval`'d fragment file, so they cannot drift — includes the per-mode `__dir__` truth table (export and canvas-read pass a filename to `instance_eval`; org-export and the canvas-push bridge do not) |
1078
+ | `docs/tutorials/the-streamweaver-way.md` | **Tutorial** — "Build My Todos: the StreamWeaver Way": the four benchmark features rebuilt step by step, each naming its law, its gotcha, and its measured number. The narrated twin of the `streamweaver-way` skill |
1079
+
1080
+ ## Live Streaming with `every` Timers
1081
+
1082
+ Push live updates to the browser via SSE (Server-Sent Events). No polling, no WebSockets — just `every(seconds)`.
1083
+
1084
+ ### Periodic Timers
1085
+
1086
+ ```ruby
1087
+ app "Monitor", theme: :dark do
1088
+ div id: "status" do
1089
+ text "Waiting..."
1090
+ end
1091
+
1092
+ every(3) do |streamer|
1093
+ streamer.replace("#status") do
1094
+ div id: "status" do
1095
+ text "Updated at #{Time.now.strftime('%H:%M:%S')}"
1096
+ end
1097
+ end
1098
+ end
1099
+ end.run!
1100
+ ```
1101
+
1102
+ The `every` block receives a `streamer` object with these SSE actions:
1103
+
1104
+ | Method | Purpose |
1105
+ |--------|---------|
1106
+ | `streamer.replace(selector) { ... }` | Replace element innerHTML |
1107
+ | `streamer.append(selector) { ... }` | Append to element |
1108
+ | `streamer.prepend(selector) { ... }` | Prepend to element |
1109
+ | `streamer.remove(selector)` | Remove element from DOM |
1110
+ | `streamer.add_class(selector, class)` | Add CSS class |
1111
+ | `streamer.remove_class(selector, class)` | Remove CSS class |
1112
+
1113
+ All methods accept either a block (renders DSL components) or a raw HTML string.
1114
+
1115
+ ### Multiple Timers with Shared State
1116
+
1117
+ Closure variables let timers share state naturally:
1118
+
1119
+ ```ruby
1120
+ app "Dashboard", theme: :dark do
1121
+ div id: "metric" do
1122
+ stat_display value: "\u2014", label: "CPU", color: :blue
1123
+ end
1124
+ div id: "alert-css", style: "display:none"
1125
+
1126
+ latest = { cpu: 0.0 }
1127
+
1128
+ every(3) do |streamer|
1129
+ latest[:cpu] = `sysctl -n vm.loadavg`.scan(/[\d.]+/)[0].to_f
1130
+
1131
+ streamer.replace("#metric") do
1132
+ div id: "metric" do
1133
+ stat_display value: latest[:cpu].to_s, label: "CPU", color: :green
1134
+ end
1135
+ end
1136
+ end
1137
+
1138
+ every(5) do |streamer|
1139
+ # Idempotent CSS injection via replace (not append — avoids duplicates)
1140
+ streamer.replace("#alert-css", "<style>.alert-ring{box-shadow:0 0 0 3px red}</style>")
1141
+ streamer.add_class("#card-cpu", "alert-ring") if latest[:cpu] > 4.0
1142
+ end
1143
+ end.run!
1144
+ ```
1145
+
1146
+ ### Key Patterns
1147
+
1148
+ - **Target elements by `id:`** — use `div id: "my-target"` in initial render, then `streamer.replace("#my-target")`
1149
+ - **Re-include `id:` inside replace blocks** — the id must survive replacement
1150
+ - **CSS injection via `replace`** — use a hidden placeholder div, not `append("head")` which duplicates on every tick
1151
+ - **Always re-apply `add_class`** — new SSE connections miss past class changes, so re-apply each tick
1152
+ - **`expandable_card key: :foo` generates `id="card-foo"`** — target it with `#card-foo` in SSE actions
1153
+
1154
+ ### CRITICAL: Accessing `state` Inside `streamer.replace` Blocks
1155
+
1156
+ **The problem:** `streamer.replace("#target") { ... }` evaluates its block in a `FeedBuilder` context, NOT in the App context. This means `state` — which is a method on the App — is **not accessible** inside the block. This will raise `NameError: undefined local variable or method 'state'` at runtime (not at startup).
1157
+
1158
+ ```ruby
1159
+ # WRONG — `state` is not available inside replace blocks
1160
+ every(30) do |streamer|
1161
+ streamer.replace("#my-panel") do
1162
+ text "Filter: #{state[:filter]}" # NameError: undefined method `state'
1163
+ end
1164
+ end
1165
+
1166
+ # CORRECT — pass state: explicitly
1167
+ every(30) do |streamer|
1168
+ streamer.replace("#my-panel", state: state) do
1169
+ text "Filter: #{state[:filter]}" # works
1170
+ end
1171
+ end
1172
+ ```
1173
+
1174
+ The same applies to `streamer.append` and `streamer.prepend`.
1175
+
1176
+ ### CRITICAL: Helper Methods Inside `streamer.replace` Blocks
1177
+
1178
+ Top-level `def` helper methods work inside `streamer.replace` blocks (they're globally accessible). However, if those helpers call `state`, the `state:` keyword must be passed to `replace` — not to the helper itself.
1179
+
1180
+ ```ruby
1181
+ def render_summary(data:, state:)
1182
+ card do
1183
+ text "Mode: #{state[:mode]}" # state passed as arg — fine
1184
+ stat_display value: data[:count], label: "Items"
1185
+ end
1186
+ end
1187
+
1188
+ app "Dashboard" do
1189
+ div id: "summary" do
1190
+ render_summary(data: load_data, state: state)
1191
+ end
1192
+
1193
+ every(10) do |streamer|
1194
+ # Pass state: to replace so FeedBuilder exposes it
1195
+ streamer.replace("#summary", state: state) do
1196
+ render_summary(data: load_data, state: state)
1197
+ end
1198
+ end
1199
+ end.run!
1200
+ ```
1201
+
1202
+ **Why this matters:** The error appears in the timer thread log (`[StreamWeaver] Timer error: NameError`), not on page load. It's easy to miss during development if the timer interval is long.
1203
+
1204
+ ### Timer Examples
1205
+
1206
+ | File | Description |
1207
+ |------|-------------|
1208
+ | `examples/timer_showcase.rb` | Live CPU/Memory/Disk with spotlight effects and alert feed |
1209
+ | `examples/timer_health_checker.rb` | HTTP endpoint health monitoring with SLA badges |
1210
+
1211
+ ## Architecture Summary
1212
+
1213
+ - **Backend**: Sinatra server, Phlex for HTML
1214
+ - **Frontend**: Alpine.js (reactivity) + HTMX (server communication)
1215
+ - **State**: Server-side, persisted in session cookies
1216
+ - **Updates**: HTMX swaps `#app-container` innerHTML on state changes; SSE pushes for `every` timers
1217
+
1218
+ ## Common Mistakes
1219
+
1220
+ 1. **Forgetting state is a hash** - Use `state[:key]`, not `state.key`
1221
+ 2. **Assuming port 4567, or booting a second instance to "find" the port** - Read the already-running process's stdout for the actual port; a second launch just starts an orphaned duplicate on the next free one
1222
+ 3. **Missing `.run!`** - App won't start without it
1223
+ 4. **Agentic mode button state** - Only input values returned, not button-set state
1224
+ 5. **Wrong checkbox signature** - `checkbox :key, "label"` (key first, then label). NOT `checkbox "label", value: x`
1225
+ 6. **Using checkbox inside checkbox_group** - Use `item value do...end`, NOT `checkbox`. checkbox_group has its own DSL.
1226
+ 7. **Putting text/headers inside checkbox_group** - ONLY `item` elements allowed directly inside `checkbox_group`. Put section headers OUTSIDE the checkbox_group block.
1227
+ 8. **Expecting form-like behavior by default** - ALL inputs auto-submit on change. For multi-select lists where user picks several items then clicks an action button, use `submit: false` on each checkbox. For traditional forms, use the `form` block.
1228
+ 9. **Using buttons to simulate selection** - Don't set `state[:x] = index` in button callbacks to track which option is selected. Use `select :key, choices, submit: false` or `radio_group :key, choices` instead. Buttons are for actions, not selection state.
1229
+ 10. **Storing array indices in state** - Indices break when lists change. Store the string name/label and resolve to index at action time.
1230
+ 11. **Long lists without scroll** - Use `scroll_box(max_height: "400px") do...end` to constrain list height. See `docs/form-patterns.md` for all canonical patterns.
1231
+
1232
+ ---
1233
+
1234
+ ## Opal Mode — Static Browser Apps
1235
+
1236
+ StreamWeaver apps can be compiled to static JavaScript and deployed without a server via `streamweaver opal-build`.
1237
+
1238
+ ### Build command
1239
+
1240
+ ```bash
1241
+ streamweaver opal-build app.rb # outputs to dist/
1242
+ streamweaver opal-build app.rb --output /my/dir
1243
+ ```
1244
+
1245
+ Produces `dist/index.html` + `dist/app.js` + `dist/morphdom.min.js`. Deploy `dist/` to GitHub Pages or any static host.
1246
+
1247
+ ### What works in Opal mode
1248
+
1249
+ - `app`, `header`, `text_field`, `checkbox`, `button`, `md`, `div` — all Phase 1 components
1250
+ - State is fully in-browser (no server round-trips)
1251
+ - Button callbacks via data attributes + delegated event listeners
1252
+ - Reactivity: whole-block re-execution + morphdom DOM patching
1253
+
1254
+ ### What doesn't work in Opal mode (Phase 1)
1255
+
1256
+ - `feed`, `streamer`, `service_client` — require a server by definition
1257
+ - `form_block` — deferred
1258
+ - `chartjs`, `tabs`, `table` — not yet implemented in Adapter::Opal
1259
+
1260
+ ### Key files
1261
+
1262
+ | File | Role |
1263
+ |---|---|
1264
+ | `lib/stream_weaver/opal_entry.rb` | Browser-only require tree |
1265
+ | `lib/stream_weaver/opal/renderer.rb` | String-accumulating view (replaces Phlex) |
1266
+ | `lib/stream_weaver/opal/runtime.rb` | State + callback registry |
1267
+ | `lib/stream_weaver/opal/bridge.rb` | Wires Ruby to window.SWRuntime |
1268
+ | `lib/stream_weaver/opal/builder.rb` | Opal compilation pipeline |
1269
+ | `lib/stream_weaver/adapter/opal.rb` | Render methods for browser |