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
@@ -0,0 +1,882 @@
1
+ <!-- GENERATED FILE -- do not edit directly. -->
2
+ <!-- Source outline: examples/tutorials/tutorial_content.rb -->
3
+ <!-- Regenerate: ruby examples/tutorials/render_markdown.rb -->
4
+
5
+ # Build My Todos: the StreamWeaver Way
6
+
7
+ *StreamWeaver · Tutorial* · Zero custom JavaScript · Four features, one file · Rebuilt from examples/my_todos
8
+
9
+ A guided rebuild of the four Turbo Frames features from the
10
+ [learnhotwire.com](https://learnhotwire.com) course, in StreamWeaver, with
11
+ no script tag, no inline JavaScript, and no hand-written Alpine anywhere in
12
+ app code. You will write about 120 lines of Ruby and get inline editing,
13
+ submit-as-you-type search, lazy hover cards, and Russian-doll infinite
14
+ scroll.
15
+
16
+ The `streamweaver-way` skill tells you **what** the conventions are. This
17
+ tutorial tells you **why** each one exists, in the order you'd hit them if
18
+ you built the app yourself. Every step names the law it obeys, the gotcha it
19
+ avoids, and — where one was actually measured — the number that makes the
20
+ case.
21
+
22
+ ## Contents
23
+
24
+ - [01. Why this tutorial exists](#why)
25
+ - [02. The six laws, and why they're laws](#laws)
26
+ - [03. Start with the store](#store)
27
+ - [04. Feature 1 — Inline editing](#inline-editing)
28
+ - [05. Feature 2 — Scoped live search](#search)
29
+ - [06. Interlude — the three fragment timings](#timings)
30
+ - [07. Feature 3 — Lazy hover cards](#hover-cards)
31
+ - [08. Feature 4 — Russian-doll infinite scroll](#infinite-scroll)
32
+ - [09. What you built](#scoreboard)
33
+ - [10. Where to go next](#next)
34
+
35
+ <a id="why"></a>
36
+
37
+ ## 01. Why this tutorial exists
38
+
39
+ Most frontend libraries ask nothing of your backend. That is a design
40
+ goal for them and a tax on you: htmx will happily swap any HTML you
41
+ send it, but it has no opinion about what your fragment ids should be,
42
+ so you invent a convention, then you invent the JavaScript that
43
+ upholds it. Hotwire's insight was that a framework which owns *both*
44
+ sides of the wire can just decide — `dom_id` on the server, matching
45
+ `turbo_frame_tag` in the view, and the swap needs no glue code because
46
+ both halves already agree.
47
+
48
+ StreamWeaver owns both sides too. The DSL that renders the page also
49
+ mints the interactive ids, signs the endpoints, and emits the `hx-*`
50
+ attributes. So the conventions in this tutorial are not style advice
51
+ you could reasonably ignore — they are the framework. Follow them and
52
+ you write no JavaScript. Fight them and you write a lot.
53
+
54
+ ### What you'll build
55
+
56
+ The learnhotwire.com course's Turbo Frames chapter builds four
57
+ features against a Rails app. We rebuild all four:
58
+
59
+ 1. **Inline editing** — click Edit and the row becomes a form in place
60
+ 2. **Scoped live search** — results filter as you type, no controller
61
+ 3. **Lazy hover cards** — six cards that cost nothing until hovered
62
+ 4. **Russian-doll infinite scroll** — pages load as you reach them
63
+
64
+ One file. One in-memory store. The finished app is
65
+ `examples/my_todos/` in this repo, which is where every code sample
66
+ below comes from — these are extracts, not illustrations.
67
+
68
+ > [!IMPORTANT]
69
+ > **The rule that makes this interesting**
70
+ >
71
+ > **Zero custom JavaScript in app code.** No `<script>` tags, no inline `onclick`, no hand-written Alpine, no raw `hx-*` attributes, no reaching into `lib/stream_weaver/adapter/*`. Only DSL verbs and CSS. If a feature needs JavaScript, that is a finding about the framework, not a licence to write some. All four features below clear the bar.
72
+
73
+ ### How to read this
74
+
75
+ Each feature section has the same four beats, in this order:
76
+
77
+ **Why** — the problem the feature has, stated before any code.
78
+ **Build it** — the code, with the Rails mechanism it mirrors.
79
+ **The law it obeys** — which of the six laws is doing the work.
80
+ **The gotcha it avoids** — what breaks if you write the tempting shape.
81
+
82
+ Where a number was measured on the real app, it is quoted with its
83
+ source. Nothing here is estimated.
84
+
85
+ **These two documents are twins.** This tutorial exists as a StreamWeaver
86
+ doc-app (`examples/tutorials/streamweaver_way_tutorial.rb`) and as markdown
87
+ (`docs/tutorials/the-streamweaver-way.md`). Both are rendered from one
88
+ outline, `examples/tutorials/tutorial_content.rb`, and a spec fails if the
89
+ checked-in markdown drifts from it. Edit the outline, never a twin.
90
+
91
+ <a id="laws"></a>
92
+
93
+ ## 02. The six laws, and why they're laws
94
+
95
+ Read these once now. They will not mean much yet — that is fine, each
96
+ one gets earned by a feature below. Come back after section 08 and
97
+ they will read as obvious.
98
+
99
+ | # | Law | What it costs you to ignore |
100
+ |---|---|---|
101
+ | 1 | Key by what is unique per *position* on the page, not by what the content is *about*. | Two rows sharing an assignee collide silently — hovering the second shows the first one's data. |
102
+ | 2 | Deferred over hand-rolled timers. | A 1.5s region holds the whole shell hostage, or you write a polling loop and a loading flag. |
103
+ | 3 | Lazy means visible. | Sixty hover cards fetch on page load for a reader who hovers none of them. |
104
+ | 4 | Dev loud, prod self-heals. | Either your users see "Content missing", or you see nothing and debug blind. |
105
+ | 5 | State your intent in DSL verbs. Zero custom JavaScript is the default, not an aspiration. | You hand-roll the glue the framework already emits, and it drifts out of sync with the server. |
106
+ | 6 | Run every feature through three design filters: is it findable and digestible; is it zero-friction with a real perk; is the *default* path the correct path. | You ship something that demos well and changes nothing. |
107
+
108
+ Law 1 is the one that bites hardest, so it is worth a sentence more.
109
+ StreamWeaver derives an id for every interactive element from its label
110
+ plus its block source location, with `-dup-N` auto-disambiguation on
111
+ repeats — so you rarely pass anything. But a derived id is
112
+ *position*-stable, not *content*-stable: insert or delete an earlier
113
+ row and every later suffix shifts. That is fine for a static form and
114
+ fatal for a list you filter, sort, or delete from. Reach for `key:`
115
+ exactly when identity must outlive reordering.
116
+
117
+ Law 6's third filter is the *why* under the other five: **the default
118
+ path must be the correct path.** Design for the developer you have, not
119
+ the one who remembers to pass the right option. Law 1's auto-keying is
120
+ that filter in code — because the default already disambiguates, the
121
+ silent-wrong-callback bug can't happen to someone who never read law 1.
122
+ Every law below is really this one, applied to a different primitive.
123
+
124
+ <a id="store"></a>
125
+
126
+ ## 03. Start with the store
127
+
128
+ Before any feature, the data. A **store** in StreamWeaver is any object
129
+ that answers five methods: `all`, `find(id)`, `create(attrs)`,
130
+ `update(id, attrs)`, and `destroy(id)`. There is no base class to
131
+ inherit and no schema to declare. A plain module over an Array is
132
+ enough to build all four features — which is the point: the framework
133
+ asks for a protocol, not a dependency.
134
+
135
+ Two extra methods show up later (`search` in section 05, `page` in
136
+ section 08); each is introduced by the feature that needs it, so you
137
+ can see exactly what the feature costs.
138
+
139
+ ```ruby
140
+ require 'stream_weaver'
141
+
142
+ module UserStore
143
+ USERS = {
144
+ 'u1' => { id: 'u1', name: 'Ada Lovelace', role: 'Engineering' },
145
+ 'u2' => { id: 'u2', name: 'Alan Turing', role: 'Research' },
146
+ 'u3' => { id: 'u3', name: 'Grace Hopper', role: 'Platform' }
147
+ }.freeze
148
+
149
+ def self.find(id) = USERS[id]
150
+
151
+ # Deliberately slow, opt-in, so "was this fetched lazily or eagerly?"
152
+ # is observable. The course's Rails app uses a literal `sleep 1.5`
153
+ # here for the same reason. Section 07 turns it on.
154
+ def self.delay
155
+ seconds = ENV['SW_HOVERCARD_DELAY'].to_f
156
+ sleep(seconds) if seconds.positive?
157
+ end
158
+ end
159
+
160
+ module TodoStore
161
+ TITLES = [
162
+ 'Buy milk', 'Ship the parity spike', 'Review the fragment docs',
163
+ 'Book the offsite', 'Refactor the store protocol', 'Write release notes',
164
+ 'Answer the support thread', 'Update the changelog', 'Prune stale branches',
165
+ 'Draft the design memo', 'Pair on the routing bug', 'Buy coffee beans'
166
+ ].freeze
167
+
168
+ # Sixty rows: enough to paginate, enough that eager hover cards hurt.
169
+ @todos = 60.times.map do |i|
170
+ { id: (i + 1).to_s,
171
+ title: "#{TITLES[i % TITLES.length]} ##{i + 1}",
172
+ completed: (i % 5).zero?,
173
+ user_id: "u#{(i % 3) + 1}" }
174
+ end
175
+
176
+ class << self
177
+ def all = @todos
178
+ def find(id) = @todos.find { |t| t[:id] == id.to_s }
179
+
180
+ def create(attrs)
181
+ id = ((@todos.map { |t| t[:id].to_i }.max || 0) + 1).to_s
182
+ @todos << { id: id, completed: false, user_id: 'u1', **attrs }
183
+ id
184
+ end
185
+
186
+ def update(id, attrs)
187
+ todo = find(id) or return false
188
+ todo.merge!(attrs)
189
+ true
190
+ end
191
+
192
+ def destroy(id)
193
+ @todos.reject! { |t| t[:id] == id.to_s }
194
+ true
195
+ end
196
+ end
197
+ end
198
+ ```
199
+
200
+ > [!NOTE]
201
+ > **Inside this repo**
202
+ >
203
+ > The samples say `require 'stream_weaver'`, which is what a reader with the gem installed writes. Working inside the StreamWeaver checkout instead? Use `require_relative '../../lib/stream_weaver'` — that is what `examples/my_todos/my_todos.rb` does.
204
+
205
+ <a id="inline-editing"></a>
206
+
207
+ ## 04. Feature 1 — Inline editing
208
+
209
+ ### Why
210
+
211
+ Clicking Edit on a row should turn *that row* into a form, leave the
212
+ other 59 rows alone, and — this is the part that is easy to get wrong —
213
+ make it structurally impossible for the edit form to modify a field it
214
+ doesn't display. The course demos this as "edit safe": the form shows
215
+ only the title, so submitting it must not be able to flip the
216
+ completed checkbox, even for someone who crafts the request by hand.
217
+
218
+ Rails does this with two `turbo_frame_tag` frames sharing a `dom_id`,
219
+ plus a deliberately narrow controller
220
+ (`params.expect(project: [:name])`). Two mechanisms, held in agreement
221
+ by you remembering to keep them in agreement.
222
+
223
+ ### Build it
224
+
225
+ StreamWeaver needs one mechanism for the swap and gets the safety for
226
+ free. `fragment(name) { ... }` marks a region that interactions
227
+ *inside it* swap on their own, rather than swapping the whole app
228
+ container. Everything interactive rendered inside a fragment is
229
+ auto-scoped to it — so the Edit button and the form's submit both
230
+ swap just this row, without either of them naming the row.
231
+
232
+ ```ruby
233
+ # Declare ONLY the editable field. `form_for` coerces exactly the fields
234
+ # listed here and nothing else, so a submit through this form cannot
235
+ # reach :completed. That is the "edit safe" guarantee, enforced by
236
+ # construction rather than by remembering to narrow a controller.
237
+ TITLE_ONLY = [StreamWeaver::Field.new(:title, :string, {})].freeze
238
+
239
+ app 'My Todos' do
240
+ TodoStore.all.first(6).each do |todo|
241
+ fragment("todo-#{todo[:id]}") do
242
+ if state[:editing_id].to_s == todo[:id]
243
+ text "#{todo[:completed] ? '☑' : '☐'} (editing)"
244
+ form_for(
245
+ store: TodoStore,
246
+ fields: TITLE_ONLY,
247
+ name: :"todo_#{todo[:id]}_form",
248
+ record: todo,
249
+ on_success: ->(_id) { state[:editing_id] = nil }
250
+ ) { submit_label 'Save' }
251
+ button('Cancel', style: :secondary, key: "cancel-#{todo[:id]}") do |s|
252
+ s[:editing_id] = nil
253
+ end
254
+ else
255
+ hstack spacing: :sm do
256
+ text "#{todo[:completed] ? '☑' : '☐'} #{todo[:title]}"
257
+ button('Edit', style: :secondary, key: "edit-#{todo[:id]}") do |s|
258
+ s[:editing_id] = todo[:id]
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end.run!
265
+ ```
266
+
267
+ Read the shape rather than the lines. There is one fragment per row,
268
+ named by record id. Inside it, a branch on `state[:editing_id]`: either
269
+ the display row with an Edit button, or the form with a Cancel button.
270
+ Clicking Edit sets `editing_id` and re-renders — but only this
271
+ fragment, because the button was rendered inside it. There is no
272
+ "which frame should this target?" decision to get wrong, because the
273
+ answer is always "the one you're inside".
274
+
275
+ `form_for` is the record-bound form primitive: it seeds fields from the
276
+ record, infers create-vs-update from record identity, coerces and
277
+ validates on submit, calls `store.update`, flashes, and redirects.
278
+ `on_success:` is where the row goes back to display mode.
279
+
280
+ ### The law it obeys
281
+
282
+ **Law 1 — key by position.** Look at the two buttons: both carry
283
+ `key:`. That is not decoration. Without it, their ids are derived from
284
+ label plus source location, which for six identical Edit buttons in one
285
+ loop means `edit`, `edit-dup-1`, `edit-dup-2`, and so on — stable only
286
+ as long as the list is. Delete row 2 and every later suffix shifts by
287
+ one, so the callback fires for the wrong todo. `key: "edit-#{todo[:id]}"`
288
+ ties the id to the record, and the record survives deletion of its
289
+ neighbours.
290
+
291
+ **Law 5 — state your intent in DSL verbs.** There is no JavaScript here
292
+ and there is also no `hx-target`. The scoping is a consequence of where
293
+ the code sits, which is the omakase bargain: give up the ability to
294
+ target an arbitrary frame, get correctness by default.
295
+
296
+ ### The gotcha it avoids
297
+
298
+ > [!WARNING]
299
+ > **Per-row buttons must carry `key:`**
300
+ >
301
+ > This is the single most common way to ship a silently-wrong list. The symptom is not an error — it's clicking Edit on row 3 and watching row 2 open. If you want the framework to shout instead of guess, turn on `strict_ids` (section 09), which promotes a derived-id collision from a warning to a hard failure in development and test.
302
+
303
+ Two smaller ones, both real, both cosmetic-or-worse:
304
+
305
+ - **`on_success:` is required** for a standalone `form_for`. Inside a
306
+ `resource` block there is a post-submit transition to fall back to;
307
+ standalone there isn't, so you must say what happens next.
308
+ - **The flash noun is derived by stripping `_form` from `name:`**, so
309
+ `:todo_1_form` flashes "Todo_1 updated." Cosmetic, known, unfixed.
310
+ Mentioned here so you don't go looking for the bug you introduced.
311
+
312
+ ### The number
313
+
314
+ | Interaction | Bytes on the wire |
315
+ |---|---|
316
+ | Click Edit (row becomes a form) | 1,327 |
317
+ | Submit the form (title renamed, `☑` preserved) | 806 |
318
+ | Full page load of the same view, for comparison | 108,792 |
319
+
320
+ Two orders of magnitude, and the reason is entirely structural: the
321
+ response is one row, because the interaction happened inside one row's
322
+ fragment. Source: `docs/research/streamweaver-way-spike-findings.md`.
323
+
324
+ <a id="search"></a>
325
+
326
+ ## 05. Feature 2 — Scoped live search
327
+
328
+ ### Why
329
+
330
+ Filter-as-you-type is the feature everyone reaches for JavaScript to
331
+ build: an input listener, a debounce, a fetch, a DOM update, and a
332
+ loading state. In the course's Rails app it is a form outside the
333
+ results frame, named with `data: {turbo_frame: :todos}`, plus a
334
+ Stimulus `autosubmit` controller that turns keystrokes into
335
+ `requestSubmit()`.
336
+
337
+ ### Build it
338
+
339
+ `text_field` auto-submits on input. There is no controller, no
340
+ debounce code, and no wiring — that behaviour is the verb. The only
341
+ decision left is **where the field lives**, and unlike most of this
342
+ tutorial, that one is a genuine trade-off rather than a right answer.
343
+
344
+ First, the store gains one method:
345
+
346
+ ```ruby
347
+ # The blank guard is load-bearing -- see the gotcha below.
348
+ def TodoStore.search(query)
349
+ return all if query.to_s.strip.empty?
350
+ needle = query.to_s.downcase
351
+ all.select { |t| t[:title].downcase.include?(needle) }
352
+ end
353
+ ```
354
+
355
+ Then the view. Put the field **inside** the results fragment:
356
+
357
+ ```ruby
358
+ app 'Search' do
359
+ fragment(:search_results) do
360
+ text_field :query, placeholder: 'Filter todos…', label: 'Search'
361
+ results = TodoStore.search(state[:query])
362
+ text "#{results.length} of #{TodoStore.all.length} todos"
363
+ results.first(8).each { |t| text "• #{t[:title]}" }
364
+ end
365
+ end.run!
366
+ ```
367
+
368
+ Five lines, and it filters as you type. The field is inside the
369
+ fragment, so its auto-submit is scoped to the fragment, so the response
370
+ is just the results region — and because StreamWeaver morphs rather
371
+ than replaces, focus and caret position survive the swap.
372
+
373
+ ### The law it obeys
374
+
375
+ **Law 5 — zero custom JavaScript is the default.** The Rails version
376
+ needs a Stimulus controller because a plain form doesn't submit on
377
+ input; the StreamWeaver version needs nothing because `text_field`
378
+ already does. When you find yourself about to write JavaScript in a
379
+ StreamWeaver app, the odds strongly favour a verb you haven't met yet.
380
+
381
+ ### The gotcha it avoids
382
+
383
+ > [!CAUTION]
384
+ > **`text_field :query, updates: :results` is accepted and ignored**
385
+ >
386
+ > This is the natural transliteration of Rails' `data: {turbo_frame: :todos}` and it looks completely right. It is silently a no-op: `updates:` is read only by `button` and `clickable`. There is no `data-turbo-frame` equivalent for inputs yet, so **an input can target only its own enclosing fragment or `#app-container`** — it cannot name a sibling fragment from outside it. A field placed outside the results fragment still filters correctly; it just swaps the entire app body on every keystroke.
387
+
388
+ That constraint is what makes the field's placement a trade-off rather
389
+ than a preference:
390
+
391
+ | Arrangement | Swap target | Response size | Cost |
392
+ |---|---|---|---|
393
+ | A — field outside the results fragment (the Rails shape) | `#app-container` | 2,123 bytes | Whole app body every keystroke; field never re-renders itself |
394
+ | B — field inside the results fragment (shown above) | `#sw-frag-search-results` | 800 bytes | Field re-renders itself every keystroke; focus and caret survive |
395
+
396
+ Prefer B unless you specifically need the input never to re-render.
397
+ Source for both measurements:
398
+ `docs/research/streamweaver-way-spike-findings.md`.
399
+
400
+ Two more, quickly:
401
+
402
+ - **Guard the blank query in the store.** Without `return all if
403
+ query.to_s.strip.empty?`, clearing the box makes the whole list
404
+ vanish. The course demos exactly this as a live bug, because in Rails
405
+ the `LIKE` runs against `nil`.
406
+ - **Search is a GET filter**, so if you want a result set to be
407
+ shareable, put the query in the URL with `route_with`. The finished
408
+ `examples/my_todos/` app does this.
409
+
410
+ <a id="timings"></a>
411
+
412
+ ## 06. Interlude — the three fragment timings
413
+
414
+ You have now used `fragment` twice, both times in its plain form: the
415
+ block runs during the page render, inline, like any other DSL call.
416
+ That is one of three timings, and the other two are what make features
417
+ 3 and 4 possible.
418
+
419
+ | Form | When the block runs | Use for |
420
+ |---|---|---|
421
+ | `fragment(:n) { }` | Inline, with the page | Scoping a swap to one region |
422
+ | `fragment(:n, defer: true) { }` | Right after page load | Slow regions, off the critical path |
423
+ | `fragment(:n, lazy: true) { }` | The first time it is *visible* | Hover cards, infinite scroll, tab panels |
424
+
425
+ `defer: true` is **Law 2 — deferred over hand-rolled timers.** A slow
426
+ region ships a placeholder with the page and fetches its own content
427
+ immediately afterward, over the same signed endpoint every in-fragment
428
+ interaction already uses. No new route, no new authorization path, and
429
+ nothing for you to write. Measured on a page with one `sleep 1.5`
430
+ fragment:
431
+
432
+ | Request | Time |
433
+ |---|---|
434
+ | Initial `GET` (was 1.5s before deferring) | 0.018s |
435
+ | The fragment's own fetch, afterward | 1.508s |
436
+
437
+ The work didn't get cheaper — it got moved off the path the reader is
438
+ waiting on. Source: `CHANGELOG.md`, deferred fragments entry.
439
+
440
+ `lazy: true` is **Law 3 — lazy means visible**, and it implies
441
+ `defer: true`, so you only ever write one option. It holds the fetch
442
+ until the fragment is actually visible: scrolled into the viewport, or
443
+ CSS flipping an ancestor out of `display: none`. Two guarantees follow,
444
+ and both are load-bearing for what comes next.
445
+
446
+ **A hidden fragment never fetches while it stays hidden.** That is what
447
+ makes a hover card cost literally nothing on page load.
448
+
449
+ **It fetches exactly once.** The content lands in the DOM and stays, so
450
+ hiding and re-showing the region doesn't refetch it.
451
+
452
+ Under the hood the placeholder carries `hx-trigger="intersect once"`.
453
+ htmx's other visibility trigger, `revealed`, reads
454
+ `getBoundingClientRect()` and therefore counts a `display: none`
455
+ element as revealed — which would fetch every hidden fragment on load
456
+ and destroy the first guarantee. That choice is documented in
457
+ `docs/research/2026-08-22-lazy-fragments-trigger-decision.md`.
458
+
459
+ > [!WARNING]
460
+ > **Three rules for every deferred or lazy fragment**
461
+ >
462
+ > **The block is skipped on the shell render**, so anything it *registers* — actions, timers — isn't registered until the fetch lands. Keep registration outside deferred blocks.
463
+ >
464
+ > **A named-action button (`button 'X', action: :foo`) inside one does not fire.** Its token is minted outside the session's action manifest. Use a block button (`button('X') { |s| ... }`), which dispatches by id and is unaffected.
465
+ >
466
+ > **Each fetch re-runs its ancestors' blocks.** Nest for staging, not to split one expensive block into cheaper pieces.
467
+
468
+ One practical note that costs people an afternoon: **give the
469
+ placeholder height.** The observer watches the placeholder wrapper, and
470
+ a zero-area element never intersects anything, so a lazy fragment with
471
+ an empty placeholder simply never fires. `placeholder:` takes nothing
472
+ (a small spinner), a String (rendered as text), or a Proc (run as DSL).
473
+
474
+ <a id="hover-cards"></a>
475
+
476
+ ## 07. Feature 3 — Lazy hover cards
477
+
478
+ ### Why
479
+
480
+ Each todo has an assignee. Hovering the assignee's name should reveal a
481
+ card with their details — details expensive enough to fetch that you
482
+ would not want to fetch sixty of them for a reader who hovers none.
483
+
484
+ This is the feature that most clearly separates "it works" from "it is
485
+ right". A version that renders every card eagerly and hides them with
486
+ CSS *looks* identical. The difference is only visible in the network
487
+ tab, and in how long the page took to arrive.
488
+
489
+ ### Build it
490
+
491
+ Three lines of CSS and one option. The two halves interlock: CSS does
492
+ the revealing, and revealing is what does the fetching.
493
+
494
+ ```ruby
495
+ HOVERCARD_CSS = <<~CSS
496
+ .hovercard { position: relative; display: inline-block; cursor: help; }
497
+ .hovercard .hovercard-panel {
498
+ display: none; position: absolute; top: 1.6em; left: 0; z-index: 50;
499
+ min-width: 15rem;
500
+ }
501
+ .hovercard:hover .hovercard-panel { display: block; }
502
+ CSS
503
+
504
+ app 'Hover cards' do
505
+ use_stylesheet HOVERCARD_CSS
506
+
507
+ TodoStore.all.first(6).each do |todo|
508
+ user = UserStore.find(todo[:user_id])
509
+ hstack spacing: :sm do
510
+ text "#{todo[:completed] ? '☑' : '☐'} #{todo[:title]} —"
511
+ div class: 'hovercard' do
512
+ text user[:name]
513
+ div class: 'hovercard-panel' do
514
+ # Keyed by the TODO, not the user. See the gotcha below.
515
+ fragment(:"hovercard_#{todo[:id]}", lazy: true, placeholder: 'Loading…') do
516
+ UserStore.delay
517
+ card do
518
+ header3 user[:name]
519
+ text user[:role]
520
+ text "#{TodoStore.all.count { |t| t[:user_id] == user[:id] }} todos"
521
+ end
522
+ end
523
+ end
524
+ end
525
+ end
526
+ end
527
+ end.run!
528
+ ```
529
+
530
+ Boot it with `SW_HOVERCARD_DELAY=1.5 ruby my_todos.rb` and hover a
531
+ name. The page was already there; the card you hovered pays 1.5
532
+ seconds, once, and the five you didn't hover pay nothing at all — ever,
533
+ if you never hover them.
534
+
535
+ To check laziness yourself, count the fragments that have *not* fetched
536
+ yet, in the browser console:
537
+ `document.querySelectorAll('.sw-fragment-lazy').length`.
538
+
539
+ ### The law it obeys
540
+
541
+ **Law 3 — lazy means visible**, in its purest form. Nothing on this
542
+ page fetches anything until the pointer arrives, and the mechanism that
543
+ hides the card is the same mechanism that gates the fetch. You did not
544
+ write a hover handler, because there is nothing for a hover handler to
545
+ do: the CSS already reacted, and the observer already noticed.
546
+
547
+ **Law 1 — key by position** is here too, and it is the reason the
548
+ fragment is named `hovercard_#{todo[:id]}` and not `hovercard_#{user[:id]}`.
549
+
550
+ ### The gotcha it avoids
551
+
552
+ > [!CAUTION]
553
+ > **Key by the todo, not the user**
554
+ >
555
+ > Sixty todos share three assignees. Key the fragment by the user and twenty todos emit the same fragment name — hovering the second one shows the first one's card, with no error anywhere. The rule states itself once you've been bitten: key by what is unique per *position* on the page (the todo), never by what the row happens to *display* (the assignee). The course demos this exact collision in Rails, which is how we knew to look for it.
556
+
557
+ > [!WARNING]
558
+ > **`display: none`, never `visibility: hidden` or `opacity: 0`**
559
+ >
560
+ > This one is subtle enough to survive review. `visibility: hidden` and `opacity: 0` both leave the element laid out and intersecting, so the IntersectionObserver counts it as visible and **every card fetches eagerly on page load** — the exact cost you added `lazy: true` to avoid. The page looks right. It just does all the work anyway.
561
+
562
+ ### The number
563
+
564
+ | Page load with `SW_HOVERCARD_DELAY=1.5` | Time |
565
+ |---|---|
566
+ | Eager — every card rendered in the list request (6 × 1.5s) | 9.151s |
567
+ | Lazy — shell only, cards not yet fetched | 0.085s |
568
+
569
+ On six rows that is an ugly page. On the full sixty it is ninety
570
+ seconds of work to render a page nobody may hover at all. Source:
571
+ `docs/research/streamweaver-way-spike-findings.md`.
572
+
573
+ <a id="infinite-scroll"></a>
574
+
575
+ ## 08. Feature 4 — Russian-doll infinite scroll
576
+
577
+ ### Why
578
+
579
+ Sixty rows, ten at a time, loading as the reader reaches them — with no
580
+ scroll handler, no page counter in state, and no "Load more" button.
581
+
582
+ Nesting lazy fragments gets you this for free, but only in one
583
+ particular shape, and the wrong shape is the more tempting one. This
584
+ section is the reason the tutorial is worth reading rather than
585
+ skimming.
586
+
587
+ ### Build it
588
+
589
+ The store gains one method, and it returns **one page's slice** — never
590
+ everything-so-far:
591
+
592
+ ```ruby
593
+ module TodoStore
594
+ PER_PAGE = 10
595
+ def self.page(number)
596
+ all.drop(([number.to_i, 1].max - 1) * PER_PAGE).first(PER_PAGE)
597
+ end
598
+ end
599
+ ```
600
+
601
+ Now the recursion. Read this one slowly — the whole feature is four
602
+ lines of logic, and which lines are inside the lazy fragment is the
603
+ entire design:
604
+
605
+ ```ruby
606
+ def scroll_todos_page(number)
607
+ # THIS page renders NOW, inline, in whatever response we are already
608
+ # writing. It is not deferred and it is not lazy.
609
+ TodoStore.page(number).each do |t|
610
+ text "#{t[:completed] ? '☑' : '☐'} #{t[:title]}"
611
+ end
612
+
613
+ # Only the NEXT page is lazy. The guard is load-bearing: without it
614
+ # the chain dead-ends in an empty fragment that fetches nothing.
615
+ nxt = number + 1
616
+ return if TodoStore.page(nxt).empty?
617
+
618
+ fragment(:"todos_page_#{nxt}", lazy: true, placeholder: 'Loading…') do
619
+ scroll_todos_page(nxt) # the recursion lives INSIDE the lazy fragment
620
+ end
621
+ end
622
+
623
+ app('Infinite scroll') { scroll_todos_page(1) }.run!
624
+ ```
625
+
626
+ Trace one scroll. The shell response contains page 1's ten rows, really
627
+ there in the HTML, plus exactly one placeholder for page 2. Page 2 does
628
+ not exist in the DOM. Scroll to the bottom of page 1 and the placeholder
629
+ enters the viewport, which is what fires its fetch. That fetch runs
630
+ `scroll_todos_page(2)`, which writes page 2's ten rows and one
631
+ placeholder for page 3. And so on, until `TodoStore.page(nxt)` comes
632
+ back empty and the chain simply stops.
633
+
634
+ That recursion is the whole of the pagination logic. There is no state
635
+ to keep, because "which page am I on" is answered by which fragment is
636
+ currently executing.
637
+
638
+ ### The gotcha it avoids
639
+
640
+ > [!CAUTION]
641
+ > **Do not wrap page N itself in the lazy fragment**
642
+ >
643
+ > The tempting shape is `fragment(:"page_#{number}", lazy: true) { rows; recurse }`, called with `1` — one uniform function, each page wrapped the same way. It is wrong, and it is wrong in a way that passes a casual test.
644
+ >
645
+ > A deferred block **is skipped on the shell render**. So the served HTML contains no rows at all — just a placeholder. It looks like it works only because page 1's placeholder sits in the initial viewport, and a lazy fragment that is already visible fetches immediately. In a browser you see rows appear and conclude it's fine. `curl` the same URL and you get an empty shell.
646
+
647
+ This is not hypothetical. It is the bug a clean-room agent hit while
648
+ rebuilding these features from the conventions alone: the app rendered
649
+ correctly in the browser, every feature demonstrably worked, and the
650
+ served HTML had zero rows in it. Nothing in the running app said so.
651
+ The failure only surfaced when someone read the response instead of the
652
+ screen.
653
+
654
+ Rendering the current page inline and lazying only the next one is also
655
+ exactly what Turbo does — a frame around the current page's rows, plus a
656
+ separate `loading: :lazy` frame for the next page. It is what keeps the
657
+ shell honest for a crawler, for a reader with JavaScript disabled, and
658
+ for anyone who ever reads the served HTML. The pretty version fails all
659
+ three silently.
660
+
661
+ > [!TIP]
662
+ > **How to check your own version**
663
+ >
664
+ > `curl -s http://127.0.0.1:PORT/infinite-scroll | grep -c 'Buy milk'` — if that returns 0 while the browser shows rows, you wrote the tempting shape. The screen is not the artifact; the response is.
665
+
666
+ Two smaller ones:
667
+
668
+ - **Return one page's slice, not everything-so-far.** Each response
669
+ should be a constant size. If your payloads grow per page, you are
670
+ re-sending rows the reader already has.
671
+ - **Nested fragment ids are `parent--child`.** Page 2's fragment is
672
+ declared beside page 1's rows, so it is `sw-frag-todos-page-2`; page
673
+ 3's is declared inside page 2's block, giving
674
+ `sw-frag-todos-page-2--todos-page-3`. Useful when you are reading the
675
+ DOM to work out what fetched what.
676
+ - **Inside a tab, the lazy fragment goes *inside* a `tab` block**, never
677
+ beside one at the top level of a `tabs` block — a non-tab child there
678
+ shifts every panel index. Route tabs (`tabs :view, url: true`) render
679
+ inactive panels as `display: none`, so a lazy fragment in one
680
+ correctly waits until that tab is opened.
681
+
682
+ ### The number
683
+
684
+ This is the clearest measurement in the whole tutorial, because it
685
+ changes the *shape* of the cost rather than its size. Before nested
686
+ fragments existed, "Load more" re-rendered and re-sent every row loaded
687
+ so far:
688
+
689
+ | Old: click-driven, O(n) per click | Payload | Rows in payload |
690
+ |---|---|---|
691
+ | Click 1 | 1,248 bytes | 20 |
692
+ | Click 2 | 1,603 bytes | 30 |
693
+ | Click 3 | 1,957 bytes | 40 |
694
+
695
+ With nested lazy fragments, each fetch carries only its own page:
696
+
697
+ | New: Russian-doll, O(1) per page | Payload |
698
+ |---|---|
699
+ | Page 2 | 809 bytes |
700
+ | Page 3 | 877 bytes |
701
+ | Page 4 | 934 bytes |
702
+ | Page 5 | 993 bytes |
703
+ | Page 6 | 1,060 bytes |
704
+ | Page 7 (terminal — chain ends) | 371 bytes |
705
+
706
+ The remaining growth is not rows. It is the nested id string getting
707
+ longer — `todos-page-2--todos-page-3--todos-page-4` and so on — which
708
+ is a few bytes per level, not a page of content. O(n) per click and
709
+ O(n²) over a session became O(1) per page. Source:
710
+ `docs/research/streamweaver-way-spike-findings.md`.
711
+
712
+ <a id="scoreboard"></a>
713
+
714
+ ## 09. What you built
715
+
716
+ Four features from a Turbo Frames course, rebuilt with no script tag,
717
+ no inline JavaScript, and no hand-written Alpine. Here is the whole
718
+ thing on one line each:
719
+
720
+ | Feature | The verb that did the work | The law | The measured win |
721
+ |---|---|---|---|
722
+ | Inline editing | `fragment` per row + `form_for` + `key:` | 1, 5 | 806-byte save vs 108,792-byte page |
723
+ | Scoped live search | `text_field` inside a `fragment` | 5 | 800 bytes scoped vs 2,123 whole-body |
724
+ | Lazy hover cards | `fragment(..., lazy: true)` + 3 lines of CSS | 1, 3 | 0.085s shell vs 9.151s eager |
725
+ | Infinite scroll | nested `fragment(..., lazy: true)` | 2, 3 | O(1) per page vs O(n) per click |
726
+
727
+ ### Assembling the four into one app
728
+
729
+ Each section above showed its feature as its own `app` block, so you
730
+ could run it in isolation while building it. Putting all four in one
731
+ file needs one more thing: a way to say which feature the reader is
732
+ looking at. That is `route_with` plus a `case`, and it is the last new
733
+ verb in the tutorial.
734
+
735
+ `route_with` takes a **parser** (URL path in, state out) and a
736
+ **builder** (state in, URL path out). Between them the browser's
737
+ address bar and `state[:feature]` stay in agreement, so every feature
738
+ is a real, bookmarkable URL rather than a tab index.
739
+
740
+ ```ruby
741
+ FEATURE_PATHS = {
742
+ inline_edit: '/', search: '/search',
743
+ hover_cards: '/hover-cards', infinite_scroll: '/infinite-scroll'
744
+ }.freeze
745
+
746
+ # routing.md Pitfall 1: `route_with` MERGES GET state rather than
747
+ # replacing it, so every parser branch merges on top of this. Without
748
+ # it, an `editing_id` set on the inline-edit page leaks into the search
749
+ # page and quietly opens a form there.
750
+ VIEW_RESET = { editing_id: nil }.freeze
751
+
752
+ app 'My Todos', layout: :wide do
753
+ use_stylesheet HOVERCARD_CSS
754
+
755
+ route_with(
756
+ parser: lambda do |path|
757
+ base = path.to_s.split('?').first
758
+ feature = FEATURE_PATHS.key(base.empty? ? '/' : base)
759
+ feature ? VIEW_RESET.merge(feature: feature) : nil
760
+ end,
761
+ builder: lambda do |current_state|
762
+ base = FEATURE_PATHS[current_state[:feature]&.to_sym] || '/'
763
+ # Search is a GET filter, so the query belongs in the URL.
764
+ query = current_state[:query].to_s
765
+ if current_state[:feature]&.to_sym == :search && !query.strip.empty?
766
+ "#{base}?query=#{CGI.escape(query)}"
767
+ else
768
+ base
769
+ end
770
+ end
771
+ )
772
+
773
+ state[:feature] ||= :inline_edit
774
+
775
+ navbar do
776
+ nav_item 'Inline editing', href: '/', active: state[:feature] == :inline_edit
777
+ nav_item 'Search', href: '/search', active: state[:feature] == :search
778
+ nav_item 'Hover cards', href: '/hover-cards', active: state[:feature] == :hover_cards
779
+ nav_item 'Infinite scroll', href: '/infinite-scroll', active: state[:feature] == :infinite_scroll
780
+ end
781
+
782
+ fragment(:flash) { flash_messages }
783
+
784
+ case state[:feature].to_sym
785
+ when :inline_edit then inline_edit_view # section 04's body
786
+ when :search then search_view # section 05's body
787
+ when :hover_cards then hover_cards_view # section 07's body
788
+ when :infinite_scroll then scroll_todos_page(1) # section 08's recursion
789
+ end
790
+ end.run!
791
+ ```
792
+
793
+ > [!WARNING]
794
+ > **An incomplete route table fails silently, on both sides**
795
+ >
796
+ > `route_with` merges GET state rather than replacing it, and a `case` with a missing `when` renders nothing rather than raising. Add a feature and forget its `FEATURE_PATHS` entry and you get a blank page with a 200 status. `docs/routing.md`'s Common Pitfalls section is worth reading before any app with more than a couple of routes — this is Pitfall 1 and it has cost real time.
797
+
798
+ ### Turn on `strict_ids` before you ship
799
+
800
+ Everything in section 04 about keying is enforceable. `strict_ids`
801
+ promotes a derived-id collision from a warning to a hard failure — it
802
+ raises in development and test, and degrades to a warning in production,
803
+ because a live page shouldn't return a 500 over an id the framework
804
+ already disambiguated for it.
805
+
806
+ ```ruby
807
+ StreamWeaver::App.new('My App', strict_ids: true) { ... } # per app
808
+ StreamWeaver.strict_ids = true # global
809
+ SW_STRICT_IDS=1 ruby my_todos.rb # env, e.g. in CI
810
+ ```
811
+
812
+ ### When something is wired wrong
813
+
814
+ **Law 4 — dev loud, prod self-heals.** A stale action token gets a 409
815
+ plus a full-container re-render that is byte-for-byte identical in
816
+ production: the user never sees an error, the page just heals. In
817
+ development only, the same response prepends a dismissible
818
+ `.sw-dev-fallback` overlay naming the stale target and the likely cause.
819
+
820
+ This is a deliberate inversion of Hotwire's "Content missing", which
821
+ shows the failure to the user and tells the developer nothing useful.
822
+ Keep the debugging signal; don't charge the reader for it. **If you see
823
+ that overlay, your wiring is wrong — don't style it away.**
824
+
825
+ ### The anti-pattern list
826
+
827
+ Each of these has bitten someone, which is why it is written down:
828
+
829
+ | Don't | Do |
830
+ |---|---|
831
+ | `defer { ... }` | `fragment(:name, defer: true)` — the bare verb raises and names the real one |
832
+ | `every(...)` to refresh a slow region | `fragment(..., defer: true)` (Law 2) |
833
+ | A named-action button inside a deferred/lazy fragment | A block button — `button('X') { \|s\| ... }` |
834
+ | `key: i` (positional index) | `key: record.id` — index is the identity reordering destroys |
835
+ | `text "**bold**"` | `md "**bold**"` — `text` does not render markdown |
836
+ | `spacer` / `divider` | They don't exist. `div(style: 'height:24px')` |
837
+ | `<button onclick="location.href=...">` | `link_to` / `nav_item` with a real `href` |
838
+ | Assuming port 4567 | Read the URL StreamWeaver prints — it finds a free port |
839
+
840
+ <a id="next"></a>
841
+
842
+ ## 10. Where to go next
843
+
844
+ **Run the finished app.** `examples/my_todos/` is all four features in
845
+ one file, annotated with the Rails mechanism each mirrors:
846
+
847
+ ```
848
+ SW_NO_OPEN=1 ruby examples/my_todos/my_todos.rb
849
+ SW_HOVERCARD_DELAY=1.5 SW_NO_OPEN=1 ruby examples/my_todos/my_todos.rb
850
+ ```
851
+
852
+ The second one is worth the extra keystrokes. Feeling a card take 1.5
853
+ seconds *only when you hover it* is the moment Law 3 stops being a
854
+ sentence.
855
+
856
+ **Get the conventions on tap.** The `streamweaver-way` skill is the
857
+ prescriptive twin of this tutorial: the same six laws and the same four
858
+ recipes, written for an agent that needs the answer rather than the
859
+ reasoning. Install it with `streamweaver setup`.
860
+
861
+ | Where to look | For |
862
+ |---|---|
863
+ | `llms.txt` (aliased `docs/for_llms.md`) | The full DSL reference — every verb, every option |
864
+ | `docs/resource-dsl.md` | `resource` + `form_for`, complete reference |
865
+ | `docs/routing.md` | **Read Common Pitfalls** before any app with more than a couple of routes |
866
+ | `docs/crud-patterns.md` | Create/edit/delete modals, URL-addressable edit pages, store adapters |
867
+ | `docs/components_reference.md` | Every component's API |
868
+
869
+ **Read the why.** These exist for when you are extending a primitive
870
+ rather than using one:
871
+
872
+ | Document | What it answers |
873
+ |---|---|
874
+ | `docs/research/streamweaver-way-spike-findings.md` | What broke building these four features, and how each break was closed — the source of every gotcha above |
875
+ | `docs/research/2026-08-22-lazy-fragments-trigger-decision.md` | Why `intersect once` and not `revealed` |
876
+ | `docs/research/2026-08-22-learnhotwire-syllabus-coverage.md` | Hotwire feature-by-feature coverage: what exists, what's next |
877
+ | `docs/streamweaver-frontend-vision.md` | The strategic thesis — why owning both sides of the wire is the whole bet |
878
+
879
+ > [!NOTE]
880
+ > **One closing thought**
881
+ >
882
+ > Every feature in this tutorial got shorter than its Rails equivalent, and none of them got shorter by being cleverer. They got shorter because the framework already knew something — which fragment you're inside, which record a row is for, whether a region is visible — that the other arrangement makes you say out loud. That is what an omakase layer buys, and it is the only thing it buys. Everything else in this document is a consequence.