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,151 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Manual-verification harness for LAZY fragments
5
+ # (`fragment ..., defer: true, lazy: true`) -- Turbo's `loading="lazy"`.
6
+ #
7
+ # A lazy fragment is a deferred fragment whose fetch waits until the element is
8
+ # VISIBLE, whatever made it visible: scrolling to it, or a CSS rule flipping an
9
+ # ancestor from display:none to display:block. It fetches exactly once. There is
10
+ # no JavaScript in this file -- the trigger is `hx-trigger="intersect once"`,
11
+ # backed by the browser's own IntersectionObserver.
12
+ #
13
+ # SW_NO_OPEN=1 STREAMWEAVER_PORT=4641 ruby examples/lazy_fragments_demo.rb
14
+ #
15
+ # Then, with devtools' Network tab open and filtered to `update`:
16
+ #
17
+ # 1. On load, ZERO fetches fire. Every panel below is either off-screen or
18
+ # CSS-hidden.
19
+ # 2. Hover the card in section 1 and HOLD -> exactly one fetch, and the card
20
+ # fills in. Move away and hover again -> no second fetch.
21
+ # 3. Scroll to section 2 -> one fetch when the panel enters the viewport.
22
+ # 4. Keep scrolling through section 3 -> one fetch per page, in order, each
23
+ # page's tail carrying the next page's placeholder. Each fetch re-runs its
24
+ # ancestors' blocks, so page N takes N * SW_LAZY_DELAY -- the pages getting
25
+ # progressively slower is the nesting cost, not a stall.
26
+ # 5. Click "Re-render the page" -> every panel returns to its placeholder and
27
+ # re-arms, but still waits for visibility rather than refetching at once.
28
+ #
29
+ # SW_LAZY_DELAY overrides the per-fragment sleep (default 1.0s), so the
30
+ # placeholder is on screen long enough to see.
31
+
32
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
33
+ require "stream_weaver"
34
+
35
+ DELAY = (ENV["SW_LAZY_DELAY"] || "1.0").to_f
36
+ PAGES = 4
37
+
38
+ def slow_work(label)
39
+ sleep DELAY
40
+ "#{label} rendered by the server at #{Time.now.strftime('%H:%M:%S.%L')}"
41
+ end
42
+
43
+ # The Russian doll: each page's content ends with the NEXT page's lazy
44
+ # placeholder, so the pages materialize one at a time as the reader scrolls and
45
+ # the app never has to know how many there are up front.
46
+ #
47
+ # NOTE: page 1 is deliberately lazy here TOO, so this demo's "zero fetches on
48
+ # load" check holds — that exercises the primitive, not the Turbo parity shape.
49
+ # For a real feed, render the current page's rows in the shell and lazy-wrap
50
+ # only the NEXT page — see the streamweaver-way skill's infinite-scroll recipe.
51
+ def scroll_page(number)
52
+ fragment :"page_#{number}", lazy: true, placeholder: -> { spinner(size: :sm, label: "Loading page #{number}…") } do
53
+ text slow_work("Page #{number}")
54
+ div(class: "lazy-demo-filler") { text "(page #{number} content, scroll on)" }
55
+ scroll_page(number + 1) if number < PAGES
56
+ end
57
+ end
58
+
59
+ app "Lazy Fragments" do
60
+ use_stylesheet <<~CSS
61
+ /* Section 1 -- the zero-JS hover card. CSS alone flips the popup from
62
+ display:none to display:block; that is what fires the fetch. */
63
+ .lazy-demo-hover-host { position: relative; display: inline-block; }
64
+ .lazy-demo-hover-label {
65
+ border-bottom: 2px dotted var(--sw-accent, #0d9488);
66
+ cursor: help;
67
+ font-weight: 600;
68
+ }
69
+ .lazy-demo-hover-card {
70
+ display: none;
71
+ position: absolute;
72
+ top: 1.6rem;
73
+ left: 0;
74
+ z-index: 20;
75
+ min-width: 22rem;
76
+ padding: 0.75rem 1rem;
77
+ background: var(--sw-surface, #ffffff);
78
+ color: var(--sw-text, #111111);
79
+ border: 1px solid var(--sw-border, #e0e0e0);
80
+ border-radius: 6px;
81
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
82
+ }
83
+ .lazy-demo-hover-host:hover .lazy-demo-hover-card { display: block; }
84
+
85
+ /* Pushes the later sections below the fold so scrolling is what reveals
86
+ them. */
87
+ .lazy-demo-spacer {
88
+ height: 90vh;
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: center;
92
+ color: var(--sw-text-dim, #444444);
93
+ border: 1px dashed var(--sw-border, #e0e0e0);
94
+ border-radius: 6px;
95
+ margin: 1.5rem 0;
96
+ }
97
+ .lazy-demo-filler {
98
+ height: 55vh;
99
+ display: flex;
100
+ align-items: center;
101
+ justify-content: center;
102
+ color: var(--sw-text-dim, #444444);
103
+ background: var(--sw-surface-elevated, #f3f3f3);
104
+ border-radius: 6px;
105
+ margin: 0.75rem 0;
106
+ }
107
+ CSS
108
+
109
+ header1 "Lazy fragments"
110
+ md "Every panel on this page sleeps #{DELAY}s inside its block. **None of them " \
111
+ "has fetched yet.** A lazy fragment holds its fetch until the element is " \
112
+ "visible. Open devtools' Network tab, filter to `update`, and watch the " \
113
+ "count stay at zero until you hover or scroll."
114
+ text "Shell rendered at #{Time.now.strftime('%H:%M:%S.%L')}"
115
+
116
+ header3 "1. Hover card: CSS-hidden, so it never fetches until it is shown"
117
+ md "The popup below is `display: none` until `:hover` flips it. No JavaScript " \
118
+ "is involved on either side: CSS makes it visible, the IntersectionObserver " \
119
+ "notices, htmx fetches once."
120
+ div(class: "lazy-demo-hover-host") do
121
+ div(class: "lazy-demo-hover-label") { text "Hover me for the account summary" }
122
+ div(class: "lazy-demo-hover-card") do
123
+ fragment :hovercard, lazy: true, placeholder: "Fetching summary…" do
124
+ text slow_work("Hover card")
125
+ text "Hover away and back: this does not fetch again."
126
+ end
127
+ end
128
+ end
129
+
130
+ header3 "2. Below the fold: fetches when scrolled into the viewport"
131
+ div(class: "lazy-demo-spacer") { text "↓ keep scrolling ↓" }
132
+ fragment :below_fold, lazy: true, placeholder: -> { spinner(size: :sm, label: "Waiting to be seen…") } do
133
+ text slow_work("Below-the-fold panel")
134
+ end
135
+
136
+ header3 "3. Infinite scroll: #{PAGES} pages of Russian dolls"
137
+ md "Each page's block ends by declaring the next page as its own lazy " \
138
+ "fragment, so page N+1 does not exist in the DOM until page N has landed " \
139
+ "and been scrolled past. This is the whole of the pagination logic."
140
+ scroll_page(1)
141
+
142
+ header3 "4. Full re-render: re-arms, but stays lazy"
143
+ md "This button swaps the whole app container. Every panel above returns to " \
144
+ "its placeholder and re-arms; the ones still off-screen or hidden wait " \
145
+ "rather than fetching immediately, which is what makes this different from " \
146
+ "a plain `defer: true`."
147
+ button "Re-render the page" do |s|
148
+ s[:renders] = s[:renders].to_i + 1
149
+ end
150
+ text "Full re-renders so far: #{state[:renders].to_i}"
151
+ end.run!
@@ -0,0 +1,29 @@
1
+ # My Todos — Turbo Frames parity proof
2
+
3
+ A StreamWeaver mirror of the learnhotwire.com course's Rails "My Todos" app
4
+ (`github.com/learnhotwire/rails`), building all four Turbo Frames chapter features under one
5
+ rule: **zero custom JavaScript in app code**. No script tags, no inline JS, no hand-written
6
+ Alpine — DSL verbs and CSS only.
7
+
8
+ This started as a discovery spike (two of the four features didn't work yet); it's now the
9
+ parity proof — all four work end to end on the primitives the spike's findings motivated
10
+ (deferred/lazy fragments, strict-ids keying).
11
+
12
+ ```bash
13
+ SW_NO_OPEN=1 STREAMWEAVER_PORT=4599 ruby examples/my_todos/my_todos.rb
14
+ ```
15
+
16
+ | Route | Feature | How |
17
+ |---|---|---|
18
+ | `/` | Inline editing | Per-row `fragment` + title-only `form_for`, keyed by record id |
19
+ | `/search` | Search | `text_field` auto-submit, in two arrangements (field outside vs. inside the results fragment) — both filter correctly; see the app for the remaining cost trade-off |
20
+ | `/hover-cards` | Hover cards | `fragment(..., lazy: true)` keyed by todo, inside the CSS `:hover` reveal — fetches once, only when hovered |
21
+ | `/infinite-scroll` | Infinite scroll | Russian-doll nested `fragment(..., lazy: true)` — each page declares the next as its own lazy fragment; scrolling triggers the fetch |
22
+
23
+ Boot with `SW_HOVERCARD_DELAY=1.5` to feel the difference: the shell renders in well under a
24
+ second, and each card pays its 1.5s only once, only if hovered (the equivalent of the chapter's
25
+ `sleep 1.5`).
26
+
27
+ **Full write-up**: [`docs/research/streamweaver-way-spike-findings.md`](../../docs/research/streamweaver-way-spike-findings.md)
28
+ — per feature, the Rails mechanism with its key elements, the StreamWeaver equivalent, and the
29
+ resolution history (what was missing, what shipped to close it).
@@ -0,0 +1,312 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # My Todos -- StreamWeaver parity PROOF against the learnhotwire.com course's
5
+ # Rails app (github.com/learnhotwire/rails), Turbo Frames chapter.
6
+ #
7
+ # Rule: ZERO custom JavaScript in app code. No script tags, no inline JS, no
8
+ # hand-written Alpine. Only DSL verbs and CSS. All four chapter features --
9
+ # inline editing, search, hover cards, infinite scroll -- work end to end
10
+ # under that rule, on the primitives shipped by this epic (strict-ids keying,
11
+ # deferred/lazy fragments). This app started as a spike (SDRD: build to
12
+ # discover); docs/research/streamweaver-way-spike-findings.md is the full
13
+ # history, now with every recorded stumble resolved or re-filed.
14
+ #
15
+ # SW_NO_OPEN=1 ruby examples/my_todos/my_todos.rb
16
+ #
17
+ # Findings doc: docs/research/streamweaver-way-spike-findings.md
18
+
19
+ require_relative '../../lib/stream_weaver'
20
+ require_relative 'store'
21
+
22
+ # The chapter's hover card is "three lines of CSS, no JavaScript": the frame is
23
+ # display:none until :hover flips it to display:block, and Turbo's
24
+ # loading="lazy" IntersectionObserver fires the fetch at exactly that moment.
25
+ # StreamWeaver gets both halves -- the CSS reveal, plus `fragment(...,
26
+ # lazy: true)`'s own visibility-triggered fetch. See feature 3 below.
27
+ HOVERCARD_CSS = <<~CSS
28
+ .hovercard { position: relative; display: inline-block; cursor: help; }
29
+ .hovercard .hovercard-panel {
30
+ display: none; position: absolute; top: 1.6em; left: 0; z-index: 50;
31
+ min-width: 15rem;
32
+ }
33
+ .hovercard:hover .hovercard-panel { display: block; }
34
+ .todo-row { padding: 0.15rem 0; }
35
+ .spike-gap { border-left: 3px solid #d97706; padding-left: 0.75rem; }
36
+ CSS
37
+
38
+ # Only `:title` -- the StreamWeaver equivalent of the course's narrow
39
+ # `Projects::NamesController` with `params.expect(project: [:name])`. `form_for`
40
+ # coerces exactly the fields listed here and nothing else, so a submit through
41
+ # this form cannot reach `:completed` (app.rb#form_for_coerce). That is the
42
+ # "edit safe" half of the chapter's demo, enforced by construction rather than
43
+ # by remembering to pass the right `url:`.
44
+ TITLE_ONLY = [StreamWeaver::Field.new(:title, :string, {})].freeze
45
+
46
+ FEATURE_PATHS = {
47
+ inline_edit: '/',
48
+ search: '/search',
49
+ hover_cards: '/hover-cards',
50
+ infinite_scroll: '/infinite-scroll'
51
+ }.freeze
52
+
53
+ # routing.md Pitfall 1: every parser branch merges on top of this so a
54
+ # feature-local key set by an earlier request cannot leak into a later view.
55
+ VIEW_RESET = { editing_id: nil }.freeze
56
+
57
+ # The Russian doll, in Turbo's actual shape: the page the reader is ON renders
58
+ # inline in whatever response we are already writing, and only the NEXT page is
59
+ # wrapped in a lazy fragment. Rails does exactly this -- a frame around the
60
+ # current page's rows, plus a separate `loading: :lazy` frame for `@pagy.next`.
61
+ # Page N+1 does not exist in the DOM -- and never fetches -- until page N has
62
+ # been scrolled past. That recursion is the whole of the pagination logic: no
63
+ # scroll handler, no page counter in state (llms.txt "Recipe: infinite scroll,
64
+ # as nested fragments").
65
+ #
66
+ # Do NOT wrap page `number` itself in the lazy fragment. A deferred block is
67
+ # skipped on the shell render, so that shape serves HTML with zero rows in it;
68
+ # it only LOOKS right because page 1's placeholder lands in the initial viewport
69
+ # and the observer fires immediately.
70
+ def scroll_todos_page(number)
71
+ TodoStore.page(number).each { |t| text "#{t[:completed] ? '☑' : '☐'} #{t[:title]}" }
72
+
73
+ # The guard is the chapter's `@pagy.next` check: without it Rails renders a
74
+ # frame with a blank id and the chain dead-ends in "Content missing".
75
+ nxt = number + 1
76
+ return if TodoStore.page(nxt).empty?
77
+
78
+ fragment(:"todos_page_#{nxt}", lazy: true, placeholder: 'Loading…') do
79
+ scroll_todos_page(nxt)
80
+ end
81
+ end
82
+
83
+ app 'My Todos', layout: :wide do
84
+ use_stylesheet HOVERCARD_CSS
85
+
86
+ route_with(
87
+ parser: lambda do |path|
88
+ base = path.to_s.split('?').first
89
+ feature = FEATURE_PATHS.key(base.empty? ? '/' : base)
90
+ feature ? VIEW_RESET.merge(feature: feature) : nil
91
+ end,
92
+ builder: lambda do |current_state|
93
+ base = FEATURE_PATHS[current_state[:feature]&.to_sym] || '/'
94
+ # Search is a GET filter, so the query belongs in the URL -- the same
95
+ # reason the chapter uses `method: :get` for its search form.
96
+ query = current_state[:query].to_s
97
+ if current_state[:feature]&.to_sym == :search && !query.strip.empty?
98
+ "#{base}?query=#{CGI.escape(query)}"
99
+ else
100
+ base
101
+ end
102
+ end
103
+ )
104
+
105
+ state[:feature] ||= :inline_edit
106
+
107
+ navbar do
108
+ nav_item 'Inline editing', href: '/', active: state[:feature] == :inline_edit
109
+ nav_item 'Search', href: '/search', active: state[:feature] == :search
110
+ nav_item 'Hover cards', href: '/hover-cards', active: state[:feature] == :hover_cards
111
+ nav_item 'Infinite scroll', href: '/infinite-scroll', active: state[:feature] == :infinite_scroll
112
+ end
113
+
114
+ fragment(:flash) { flash_messages }
115
+
116
+ case state[:feature].to_sym
117
+ # ==========================================================================
118
+ # 1. Inline editing
119
+ #
120
+ # Rails: two `turbo_frame_tag @project, :edit_name` frames -- one wrapping the
121
+ # display <h1>, one wrapping the edit form -- that swap by matching dom_id.
122
+ # StreamWeaver: one `fragment` per row, keyed by record id, whose body
123
+ # branches on `state[:editing_id]`. Every interactive element rendered inside
124
+ # a fragment is auto-scoped to it (adapter/alpinejs.rb#htmx_attrs), so the
125
+ # Edit button and the form submit both swap just this row.
126
+ # ==========================================================================
127
+ when :inline_edit
128
+ header1 'My Todos'
129
+ md 'Click **Edit** on a row: the row becomes a title-only form in place. ' \
130
+ 'The completed checkbox is rendered *outside* the form -- watch that it ' \
131
+ 'survives the save.'
132
+
133
+ TodoStore.all.first(6).each do |todo|
134
+ fragment("todo-#{todo[:id]}") do
135
+ div class: 'todo-row' do
136
+ if state[:editing_id].to_s == todo[:id]
137
+ text "#{todo[:completed] ? '☑' : '☐'} (editing)"
138
+ form_for(
139
+ store: TodoStore,
140
+ fields: TITLE_ONLY,
141
+ name: :"todo_#{todo[:id]}_form",
142
+ record: todo,
143
+ on_success: ->(_id) { state[:editing_id] = nil }
144
+ ) { submit_label 'Save' }
145
+ button('Cancel', style: :secondary, key: "cancel-#{todo[:id]}") do |s|
146
+ s[:editing_id] = nil
147
+ end
148
+ else
149
+ hstack spacing: :sm do
150
+ text "#{todo[:completed] ? '☑' : '☐'} #{todo[:title]}"
151
+ button('Edit', style: :secondary, key: "edit-#{todo[:id]}") do |s|
152
+ s[:editing_id] = todo[:id]
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+
160
+ # ==========================================================================
161
+ # 2. Search
162
+ #
163
+ # Rails: the form sits OUTSIDE the results frame and names it with
164
+ # `data: {turbo_frame: :todos}`; a Stimulus `autosubmit` controller turns
165
+ # keystrokes into `requestSubmit()`.
166
+ # StreamWeaver: `text_field` auto-submits on input with no controller at all.
167
+ # Both arrangements below now filter correctly -- the fragment-scoped param
168
+ # merge bug the spike found is fixed (InteractionRunner merges posted state
169
+ # before every scoped rebuild; see spec/live_input_fragment_wiring_spec.rb
170
+ # and spec/deferred_fragments_spec.rb's regression coverage). What's left is
171
+ # a genuine trade-off, not a break: shown in both arrangements below.
172
+ # ==========================================================================
173
+ when :search
174
+ header1 'Search'
175
+ alert(variant: :success) do
176
+ text 'WORKS -- submit-as-you-type is free in both arrangements below, ' \
177
+ 'with no custom JavaScript.'
178
+ text 'A is the Rails layout (field outside the results): swaps the whole ' \
179
+ 'app body, so it costs more bytes but the field never re-renders ' \
180
+ 'itself. B moves the field inside the fragment for a scoped morph ' \
181
+ '-- fewer bytes, and focus/caret survive the morph -- at the cost ' \
182
+ 'of the field re-rendering itself on every keystroke.'
183
+ end
184
+
185
+ header3 'A. Rails arrangement -- field outside the results region'
186
+ text_field :query, placeholder: 'Filter todos…', label: 'Search (outside)'
187
+ fragment(:search_results_outside) do
188
+ results = TodoStore.search(state[:query])
189
+ text "#{results.length} of #{TodoStore.all.length} todos"
190
+ results.first(8).each { |t| text "• #{t[:title]}" }
191
+ end
192
+
193
+ div(style: 'height:24px') { }
194
+
195
+ header3 'B. StreamWeaver arrangement -- field inside the results fragment'
196
+ fragment(:search_results_inside) do
197
+ text_field :query_inside, placeholder: 'Filter todos…', label: 'Search (inside)'
198
+ results = TodoStore.search(state[:query_inside])
199
+ text "#{results.length} of #{TodoStore.all.length} todos"
200
+ results.first(8).each { |t| text "• #{t[:title]}" }
201
+ end
202
+
203
+ div class: 'spike-gap' do
204
+ header3 'The remaining trade-off'
205
+ md 'Both arrangements filter correctly now. What StreamWeaver still ' \
206
+ 'lacks is a `data-turbo-frame` equivalent: `text_field` never passes ' \
207
+ '`sw_updates:` to `htmx_attrs`, so an input can only target its own ' \
208
+ 'enclosing fragment (B) or `#app-container` (A) -- it cannot name a ' \
209
+ 'sibling fragment from outside it. `text_field :query, updates: ' \
210
+ ':results` is accepted and silently ignored (a real gap, tracked, ' \
211
+ 'not fixed by this story). Until that lands, A pays the whole-body ' \
212
+ 'swap and B pays the self-re-render -- pick per feature which cost ' \
213
+ 'you\'d rather carry.'
214
+ end
215
+
216
+ # ==========================================================================
217
+ # 3. Hover cards
218
+ #
219
+ # Rails: `turbo_frame_tag todo.user, :hovercard, src: ..., loading: :lazy`
220
+ # inside a `div.hovercard`. CSS reveals it; `loading=lazy` means the fetch
221
+ # only happens because the reveal made it visible.
222
+ # StreamWeaver: `fragment(..., lazy: true)` is the same two-part interlock --
223
+ # CSS does the revealing, `hx-trigger="intersect once"` does the fetching,
224
+ # and it never fires while the card is `display: none`.
225
+ # ==========================================================================
226
+ when :hover_cards
227
+ header1 'Hover cards'
228
+ alert(variant: :success) do
229
+ text 'WORKS -- CSS reveals the card, and revealing it is what fires the ' \
230
+ 'fetch. Nothing has fetched until the pointer arrives.'
231
+ text 'Boot with SW_HOVERCARD_DELAY=1.5 and hover a name: the shell above ' \
232
+ 'already rendered; only the hovered card pays the 1.5s, once.'
233
+ end
234
+
235
+ TodoStore.all.first(6).each do |todo|
236
+ user = UserStore.find(todo[:user_id])
237
+ div class: 'todo-row' do
238
+ hstack spacing: :sm do
239
+ text "#{todo[:completed] ? '☑' : '☐'} #{todo[:title]} —"
240
+ # Keyed by the todo, not the user: the chapter (38:52) demos two todos
241
+ # sharing an assignee emitting the same frame id twice. The fragment
242
+ # name below carries that same rule -- key by what is unique per
243
+ # position on the page (the todo), not by what the content is about
244
+ # (the user) -- per llms.txt "Interactive IDs and keying".
245
+ div class: 'hovercard' do
246
+ text user[:name]
247
+ div class: 'hovercard-panel' do
248
+ fragment(:"hovercard_#{todo[:id]}", lazy: true, placeholder: 'Loading…') do
249
+ UserStore.delay
250
+ card do
251
+ header3 user[:name]
252
+ text user[:role]
253
+ text "#{TodoStore.all.count { |t| t[:user_id] == user[:id] }} todos"
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
261
+
262
+ div class: 'spike-gap' do
263
+ header3 'How it works now'
264
+ md 'Each card is its own `fragment(:"hovercard_#{todo[:id]}", lazy: ' \
265
+ 'true)`. The CSS wrapper hides it (`display: none`), so the fragment' \
266
+ '\'s IntersectionObserver never fires -- no request goes out. Hovering ' \
267
+ 'flips the CSS to `display: block`, which is what makes the fragment ' \
268
+ 'visible, which is what fires the fetch. It fetches exactly once: ' \
269
+ 'hover away and back, and the content that already landed stays put, ' \
270
+ 'no second request. Six cards, zero eager cost.'
271
+ end
272
+
273
+ # ==========================================================================
274
+ # 4. Infinite scroll
275
+ #
276
+ # Rails: nested "Russian doll" frames -- each response renders the CURRENT
277
+ # page's rows inline and appends page N+1's placeholder frame, already
278
+ # `loading: :lazy`, so scrolling to the bottom fetches the next page and
279
+ # nothing is ever removed from the DOM.
280
+ # StreamWeaver: the same shape, with `fragment(..., lazy: true)` around the
281
+ # NEXT page only -- see `scroll_todos_page` above. No button, no scroll
282
+ # listener, no page counter in state: scrolling to page N's bottom is what
283
+ # makes page N+1's fragment visible, which is what fetches it.
284
+ # ==========================================================================
285
+ when :infinite_scroll
286
+ header1 'Infinite scroll'
287
+ alert(variant: :success) do
288
+ text 'WORKS -- scroll-to-load-more, nested "Russian doll" fragments, ' \
289
+ 'each fetch sends exactly its own page.'
290
+ end
291
+
292
+ scroll_todos_page(1)
293
+
294
+ div class: 'spike-gap' do
295
+ header3 'How it works now'
296
+ md 'The page you are ON renders inline; only the NEXT page is wrapped in ' \
297
+ '`fragment(:"todos_page_N+1", lazy: true)`. So this shell already ' \
298
+ 'carries page 1\'s ten rows plus exactly one placeholder — view source ' \
299
+ 'and the rows are really there, which is the same progressive-' \
300
+ 'enhancement guarantee Rails gives you. Page N+1 is not in the DOM -- ' \
301
+ 'and has not fetched -- until scrolling reveals its placeholder. Each ' \
302
+ 'response carries only that page\'s rows (`TodoStore.page(n)`), not ' \
303
+ 'everything loaded so far: a constant per-page payload where the old ' \
304
+ 'click-driven version grew with every click. Wrapping page N itself in ' \
305
+ 'the lazy fragment is the tempting shape and it is wrong -- the block ' \
306
+ 'is skipped on the shell render, so the served HTML has no rows at ' \
307
+ 'all, and it only looks right because page 1\'s placeholder starts in ' \
308
+ 'the viewport. See findings doc, feature 4 resolution for the measured ' \
309
+ 'byte counts.'
310
+ end
311
+ end
312
+ end.run!
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ # In-memory stores for the My Todos parity spike.
4
+ #
5
+ # Mirrors the learnhotwire.com course app's `Todo` / `User` models closely
6
+ # enough to exercise the same four Turbo Frames chapter features:
7
+ # `Todo.search(query)` (including the blank guard the chapter demos as a live
8
+ # bug), a `user_id` assignee for hover cards, and enough rows to paginate.
9
+
10
+ module UserStore
11
+ USERS = {
12
+ 'u1' => { id: 'u1', name: 'Ada Lovelace', role: 'Engineering', joined: '2024-03-11' },
13
+ 'u2' => { id: 'u2', name: 'Alan Turing', role: 'Research', joined: '2023-11-02' },
14
+ 'u3' => { id: 'u3', name: 'Grace Hopper', role: 'Platform', joined: '2025-01-20' }
15
+ }.freeze
16
+
17
+ def self.all = USERS.values
18
+ def self.find(id) = USERS[id]
19
+
20
+ # Deliberately slow, to make "was this fetched lazily or eagerly?" observable
21
+ # the way the chapter's `sleep 1.5` does in the Rails app. Off by default so
22
+ # the spike app stays usable; set SW_HOVERCARD_DELAY=1.5 to feel the cost of
23
+ # rendering every card eagerly.
24
+ def self.delay
25
+ seconds = ENV['SW_HOVERCARD_DELAY'].to_f
26
+ sleep(seconds) if seconds.positive?
27
+ end
28
+ end
29
+
30
+ module TodoStore
31
+ PER_PAGE = 10
32
+
33
+ TITLES = [
34
+ 'Buy milk', 'Ship the parity spike', 'Review the fragment docs',
35
+ 'Book the offsite', 'Refactor the store protocol', 'Write release notes',
36
+ 'Answer the support thread', 'Update the changelog', 'Prune stale branches',
37
+ 'Draft the design memo', 'Pair on the routing bug', 'Buy coffee beans'
38
+ ].freeze
39
+
40
+ @todos = 60.times.map do |i|
41
+ {
42
+ id: (i + 1).to_s,
43
+ title: "#{TITLES[i % TITLES.length]} ##{i + 1}",
44
+ completed: (i % 5).zero?,
45
+ user_id: "u#{(i % 3) + 1}"
46
+ }
47
+ end
48
+
49
+ class << self
50
+ def all = @todos
51
+ def find(id) = @todos.find { |t| t[:id] == id.to_s }
52
+
53
+ def create(attrs)
54
+ id = ((@todos.map { |t| t[:id].to_i }.max || 0) + 1).to_s
55
+ @todos << { id: id, completed: false, user_id: 'u1', **attrs }
56
+ id
57
+ end
58
+
59
+ def update(id, attrs)
60
+ todo = find(id) or return false
61
+ todo.merge!(attrs)
62
+ true
63
+ end
64
+
65
+ def destroy(id)
66
+ @todos.reject! { |t| t[:id] == id.to_s }
67
+ true
68
+ end
69
+
70
+ # Mirrors the course's `Todo.search`, blank guard included. The chapter
71
+ # demos the missing-guard version as a live bug: clear the box and the
72
+ # whole list vanishes because the LIKE runs against nil.
73
+ def search(query)
74
+ return all if query.to_s.strip.empty?
75
+ needle = query.to_s.downcase
76
+ all.select { |t| t[:title].downcase.include?(needle) }
77
+ end
78
+
79
+ # A single page's slice -- the Russian-doll infinite-scroll recipe fetches
80
+ # one page per request, not everything-so-far. See
81
+ # docs/research/streamweaver-way-spike-findings.md, feature 4 resolution.
82
+ # An empty return is the recursion's own termination signal -- see
83
+ # scroll_todos_page in my_todos.rb.
84
+ def page(number) = all.drop(([number.to_i, 1].max - 1) * PER_PAGE).first(PER_PAGE)
85
+ end
86
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+ # Demo: ReactiveState — watch, on_start, and computed/derived state
3
+ # Build: bundle exec streamweaver opal-build examples/opal/reactive_demo.rb --output dist/reactive_demo
4
+ # Open: open dist/reactive_demo/index.html
5
+
6
+ app "Reactive Demo" do
7
+ # ── S8: Loan Calculator (derived state, no useMemo needed) ──────────
8
+ card do
9
+ header2 "Loan Calculator"
10
+ text_field :principal, label: "Loan Amount ($)", type: :number
11
+ text_field :rate, label: "Annual Rate (%)", type: :number
12
+ text_field :term, label: "Term (months)", type: :number
13
+
14
+ p = state[:principal].to_f
15
+ r = state[:rate].to_f / 100.0 / 12.0
16
+ n = state[:term].to_f
17
+ payment = (r > 0 && n > 0) ? (p * r * (1 + r)**n) / ((1 + r)**n - 1) : 0.0
18
+
19
+ card do
20
+ header3 "Monthly Payment"
21
+ text "$#{"%.2f" % payment}"
22
+ end
23
+ end
24
+
25
+ div(style: "height: 32px")
26
+
27
+ # ── S5: Watch — filter list reacts to search ──────────────────────
28
+ card do
29
+ header2 "Search Filter (watch)"
30
+ watch(:search_query) do |query|
31
+ all = %w[Apple Banana Blueberry Cherry Cranberry Date Elderberry Fig Grape]
32
+ state[:filtered] = query.to_s.empty? ? all : all.select { |f| f.downcase.include?(query.downcase) }
33
+ end
34
+
35
+ text_field :search_query, placeholder: "Type to filter fruit..."
36
+
37
+ div do
38
+ (state[:filtered] || %w[Apple Banana Blueberry Cherry Cranberry Date Elderberry Fig Grape]).each do |fruit|
39
+ text fruit
40
+ end
41
+ end
42
+ end
43
+
44
+ div(style: "height: 32px")
45
+
46
+ # ── S6: on_start — runs once after first render ───────────────────
47
+ card do
48
+ header2 "on_start Demo"
49
+ md "**on_start** fires exactly once after the first render — like `useEffect(fn, [])` in React. Watch for the 'Loading...' state to briefly appear, then swap to real data after 800ms."
50
+ state[:items] = state[:items] || []
51
+ on_start do
52
+ after(0.8) { state[:items] = ["Loaded Item A", "Loaded Item B", "Loaded Item C"] }
53
+ end
54
+
55
+ if state[:items].empty?
56
+ text "Loading..."
57
+ else
58
+ badge "Loaded via on_start", color: :green
59
+ state[:items].each { |item| text item }
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+ # S1: Counter with Derived Display
3
+ # React equivalent: useState + useMemo
4
+ # StreamWeaver: inline computation, zero annotation
5
+
6
+ app "S1 — Counter with Derived Display" do
7
+ button("Increment") { state[:count] = state[:count].to_i + 1 }
8
+ button("Reset") { state[:count] = 0 }
9
+
10
+ card do
11
+ text "Count: #{state[:count].to_i}"
12
+ text "Is even: #{state[:count].to_i.even?}"
13
+ text "Double: #{state[:count].to_i * 2}"
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+ # S2: Search-Filtered List
3
+ # React equivalent: useMemo with [search] dependency array
4
+ # StreamWeaver: inline computation — the filter IS the render
5
+
6
+ app "S2 — Search-Filtered List" do
7
+ text_field :search, placeholder: "Filter fruits..."
8
+
9
+ fruits = %w[Apple Apricot Avocado Banana Blueberry Cherry Cranberry Date Elderberry Fig Grape Kiwi]
10
+ query = state[:search].to_s.downcase
11
+ items = query.empty? ? fruits : fruits.select { |f| f.downcase.include?(query) }
12
+
13
+ text "#{items.size} of #{fruits.size} shown"
14
+ div do
15
+ items.each { |fruit| text fruit }
16
+ end
17
+ end