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