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,342 @@
1
+ # StreamWeaver Resource DSL
2
+
3
+ Convention-over-configuration CRUD scaffolding. One `resource` block replaces 30–50 lines of route/state/form boilerplate.
4
+
5
+ ---
6
+
7
+ ## Quick Start
8
+
9
+ ```ruby
10
+ require 'stream_weaver'
11
+
12
+ module PostStore
13
+ @posts = [{ id: '1', title: 'Hello', body: 'First post', status: 'published' }]
14
+ def self.all; @posts; end
15
+ def self.find(id); @posts.find { |p| p[:id] == id }; end
16
+ def self.create(attrs)
17
+ id = ((@posts.map { |p| p[:id].to_i }.max || 0) + 1).to_s
18
+ @posts << { id: id, **attrs }; id
19
+ end
20
+ def self.update(id, attrs); post = find(id) or return false; post.merge!(attrs); true; end
21
+ def self.destroy(id); @posts.reject! { |p| p[:id] == id }; true; end
22
+ end
23
+
24
+ app 'Blog' do
25
+ page :home, '/' do
26
+ header1 'My Blog'
27
+ end
28
+
29
+ resource :post, store: PostStore do
30
+ field :title, :string
31
+ field :body, :text
32
+ field :status, :enum, values: %w[draft published]
33
+ end
34
+ end.run!
35
+ ```
36
+
37
+ That single `resource` block gives you:
38
+
39
+ | URL | Action |
40
+ |---|---|
41
+ | `GET /posts` | Index — table with View / Edit / Delete per row |
42
+ | `GET /posts/new` | New — form with field inputs, Create button |
43
+ | `GET /post/:id` | Show — card with field values, Edit / Delete buttons |
44
+ | `GET /post/:id/edit` | Edit — form seeded from record, Save button |
45
+ | Delete button | Inline confirmation alert, then destroys record |
46
+
47
+ All URLs are deep-linkable and browser back/forward works.
48
+
49
+ ---
50
+
51
+ ## DSL Reference
52
+
53
+ ### `resource`
54
+
55
+ ```ruby
56
+ resource :name, store: MyStore, plural: 'custom_plural' do
57
+ # field, edit_view, new_view, only, except, override blocks
58
+ end
59
+ ```
60
+
61
+ | Option | Default | Description |
62
+ |---|---|---|
63
+ | `store:` | required | Object responding to store protocol (see below) |
64
+ | `plural:` | `"#{name}s"` | Override pluralization for irregular nouns |
65
+
66
+ **Declare `page` / `route` calls before `resource` blocks.** The routing chain is first-registered-wins; static page routes must appear before resource collection/member routes.
67
+
68
+ ---
69
+
70
+ ### `field`
71
+
72
+ ```ruby
73
+ field :name, :type
74
+ field :name, :type, values: [...] # for :enum
75
+ ```
76
+
77
+ | Type | Input component rendered |
78
+ |---|---|
79
+ | `:string` | `text_field` |
80
+ | `:text` | `text_area` (4 rows) |
81
+ | `:enum` | `select` with `values:` list |
82
+ | `:boolean` | `checkbox` |
83
+ | `:integer` / `:number` | `text_field` |
84
+ | `:date` | `text_field` |
85
+
86
+ ---
87
+
88
+ ### `edit_view` / `new_view`
89
+
90
+ ```ruby
91
+ edit_view :page # URL-addressable /resource/:id/edit (default: :modal)
92
+ new_view :page # URL-addressable /resource/new (default: :modal)
93
+ ```
94
+
95
+ ---
96
+
97
+ ### `only` / `except`
98
+
99
+ ```ruby
100
+ only %i[index show] # whitelist actions
101
+ except %i[destroy] # blacklist actions
102
+ ```
103
+
104
+ Default set: `[:index, :show, :new, :edit, :destroy]`
105
+
106
+ ---
107
+
108
+ ### Override Blocks
109
+
110
+ Fully replace any default view. Block runs in App context (`instance_exec`).
111
+
112
+ ```ruby
113
+ resource :goal, store: GoalStore do
114
+ field :title, :string
115
+ field :horizon, :enum, values: %w[month quarter year]
116
+
117
+ index do |goals|
118
+ header1 'Goals'
119
+ goals.group_by { |g| g[:horizon] }.each do |horizon, gs|
120
+ header3 horizon.capitalize
121
+ table gs do
122
+ column :title
123
+ end
124
+ end
125
+ end
126
+
127
+ show do |goal|
128
+ card { header3 goal[:title]; text goal[:horizon] }
129
+ end
130
+ end
131
+ ```
132
+
133
+ | Block | Receives | Replaces |
134
+ |---|---|---|
135
+ | `index do \|items\|` | Array of all records | Default index table |
136
+ | `show do \|item\|` | Single record hash | Default show card |
137
+ | `new do \|_\|` | nil | Default new form |
138
+ | `edit do \|item\|` | Single record hash | Default edit form |
139
+
140
+ App ivars (`@current_form`, `@form_context`) are saved before the block runs and restored after, preventing bleed-through.
141
+
142
+ ---
143
+
144
+ ### `page` and `route`
145
+
146
+ ```ruby
147
+ page :home, '/' do
148
+ header1 'Welcome'
149
+ end
150
+
151
+ route :about, '/about' # same as page with empty block
152
+ ```
153
+
154
+ Declares a static named route. Renders the block when the current URL matches. Use before `resource` blocks.
155
+
156
+ ---
157
+
158
+ ### `form_for`
159
+
160
+ A resource-bound form primitive: seeds fields from a record, infers create vs. update
161
+ from record identity, renders inputs via the shared field-type table, and wires submit
162
+ to coerce → validate → `store.create`/`update` → flash + PRG. It's what `resource`'s own
163
+ default `new`/`edit` views call under the hood — `form_for` exposes the same machinery
164
+ for use inside your own override blocks.
165
+
166
+ ```ruby
167
+ resource :person, store: PersonStore do
168
+ field :name, :string
169
+ field :role, :enum, values: %w[lead champion decision_maker]
170
+
171
+ edit do |person|
172
+ header1 "Edit #{person[:name]}"
173
+ form_for :person, record: person do
174
+ submit_label "Save"
175
+ cancel_label "Cancel"
176
+ end
177
+ end
178
+
179
+ new do
180
+ header1 "New Person"
181
+ form_for :person do
182
+ submit_label "Create"
183
+ end
184
+ end
185
+ end
186
+ ```
187
+
188
+ | Argument | Default | Description |
189
+ |---|---|---|
190
+ | `resource_name` | `nil` | A registered `resource` name — reuses its `store:`/`fields:` |
191
+ | `record:` | `nil` | The record being edited. `nil`, or an id-less hash, means create mode |
192
+ | `store:` | resource's store | Required if `resource_name` is omitted |
193
+ | `fields:` | resource's fields | Required if `resource_name` is omitted |
194
+ | `name:` | `"#{singular}_form"` | Scope/form name override |
195
+ | `on_success:` | resource's default transition | `Proc`, `instance_exec`'d with the new/updated id |
196
+ | `validate:` | `nil` | `Proc`, called with coerced values, returns `Hash[field, Array[String]]` of extra errors |
197
+
198
+ Can also be used standalone, without a `resource` block, by passing `store:`/`fields:`
199
+ directly:
200
+
201
+ ```ruby
202
+ form_for store: PersonStore, fields: [
203
+ StreamWeaver::Field.new(:name, :string, {}),
204
+ StreamWeaver::Field.new(:age, :integer, {})
205
+ ], name: :person_form
206
+ ```
207
+
208
+ **Create vs. update inference** — a `nil` `record:`, or a `record:` whose `:id` is nil,
209
+ means create; a present record with an id means update. This also sets the default
210
+ submit label (`"Create"` / `"Save"`).
211
+
212
+ **Seeding and dirty-draft safety** — the form's scope is seeded from `record:` only on
213
+ the first render for that record id, and never re-seeded on subsequent re-renders for
214
+ the same id — an unrelated re-render (a sidebar filter, a toast dismiss) never clobbers
215
+ an in-progress edit. Switching to a different record id (e.g. navigating from editing
216
+ person `1` to editing person `2`) re-seeds fresh, so there's no cross-record leakage.
217
+
218
+ **Validation** — coercion failures (`:integer`/`:number` fields that don't parse) and any
219
+ `validate { }` block errors both populate `state[:"#{name}_form_errors"]`, rendered as a
220
+ single `Alert(variant: :error)` summary above the fields. A validation failure is a
221
+ same-request re-render, not a redirect — the user's just-typed values stay in the scope
222
+ and the store is never called.
223
+
224
+ **Success** — on a valid submit, `form_for` calls `store.create`/`store.update`, sets
225
+ `flash[:notice]`, and transitions to the resource's `show` action (or `index` if the
226
+ resource doesn't declare `show`) with the URL pushed via the existing PRG mechanism.
227
+ Pass `on_success:` to override the transition — required for standalone (non-`resource`)
228
+ usage, which has no resource action to fall back to.
229
+
230
+ **In-block DSL** — inside the `form_for do ... end` block:
231
+
232
+ | Call | Effect |
233
+ |---|---|
234
+ | `submit_label "text"` | Overrides the default submit button label |
235
+ | `cancel_label "text"` | Adds a Cancel button (omitted by default) |
236
+ | `validate { \|values\| ... }` | Registers the extra validation hook described above |
237
+ | any other field/component call | Renders alongside the auto-generated fields, inside the same form |
238
+
239
+ All three (`submit_label`/`cancel_label`/`validate`) raise if called outside a `form_for`
240
+ block.
241
+
242
+ **Inline-edit example** — the course's [Turbo Frame inline-editing pattern](for_llms.md#turbo-frame-style-inline-editing-with-form_for)
243
+ (click a title to edit it in place, submit swaps back to the display view) is a direct
244
+ `form_for` use case; see `docs/for_llms.md` for the worked example.
245
+
246
+ ---
247
+
248
+ ## Named-Route Helpers
249
+
250
+ Defined automatically on the App instance when `resource :post` is declared:
251
+
252
+ | Helper | Returns |
253
+ |---|---|
254
+ | `posts_path` | `"/posts"` |
255
+ | `new_post_path` | `"/posts/new"` |
256
+ | `post_path(rec)` | `"/post/#{rec[:id]}"` |
257
+ | `edit_post_path(rec)` | `"/post/#{rec[:id]}/edit"` |
258
+
259
+ For irregular plurals (`plural: 'people'` on `:person`), helpers use the overridden plural: `people_path`, `new_person_path`, etc.
260
+
261
+ ---
262
+
263
+ ## Store Protocol
264
+
265
+ Stores are duck-typed. Any object (module, class, instance) that responds to these five methods works:
266
+
267
+ | Method | Signature | Return |
268
+ |---|---|---|
269
+ | `all` | `()` | Array of record hashes |
270
+ | `find` | `(id)` | Record hash or nil |
271
+ | `create` | `(attrs_hash)` | New record id (String) |
272
+ | `update` | `(id, attrs_hash)` | true / false |
273
+ | `destroy` | `(id)` | true / false |
274
+
275
+ Records are plain hashes with a symbol `:id` key. StreamWeaver validates the store at app startup (not at request time) and raises `ArgumentError` with a clear message listing missing methods.
276
+
277
+ ---
278
+
279
+ ## State Schema (`_sw_` Namespace)
280
+
281
+ The `_sw_` prefix is reserved. Do not use `state[:_sw_*]` keys in your own code.
282
+
283
+ | Key | Values | Meaning |
284
+ |---|---|---|
285
+ | `state[:_sw_action]` | `:index`, `:show`, `:new`, `:edit`, page name sym | Current action |
286
+ | `state[:_sw_resource]` | `:post`, `:goal`, etc. / `nil` | Active resource (nil for pages) |
287
+ | `state[:_sw_id]` | String id / nil | Selected record |
288
+ | `state[:_sw_action]` `:destroy_confirm` | — | Delete confirmation (routed via `GET /singular/:id/delete`) |
289
+ | `state[:"#{singular}_form"]` | Hash | Form state (managed by `form` DSL) |
290
+
291
+ Use `state[:_sw_resource]` and `state[:_sw_action]` in navbar `active:` checks:
292
+
293
+ ```ruby
294
+ navbar do
295
+ nav_item 'Posts', href: posts_path, active: state[:_sw_resource] == :post
296
+ nav_item 'Home', href: '/', active: state[:_sw_action] == :home
297
+ end
298
+ ```
299
+
300
+ ---
301
+
302
+ ## Route Precedence
303
+
304
+ Routes are first-registered-wins. Recommended declaration order:
305
+
306
+ 1. `page` / `route` (static exact-match routes)
307
+ 2. `resource` blocks (collection then member patterns)
308
+ 3. Manual `route_with` rules
309
+
310
+ ```ruby
311
+ app 'Example' do
312
+ page :home, '/' # declared first — matched first
313
+
314
+ resource :post, store: PostStore do # declared after page
315
+ field :title, :string
316
+ end
317
+ end.run!
318
+ ```
319
+
320
+ ---
321
+
322
+ ## Default View Behavior
323
+
324
+ When no override block is provided:
325
+
326
+ **Index** — renders a `header1` with the plural name, a "New" button, and a `table` with one column per field plus an actions column with View / Edit / Delete links per row. Delete links navigate to `/singular/:id/delete`.
327
+
328
+ **Show** — renders a `card` with `header3` (value of first field), one `text` line per field, and an hstack with Edit and Delete buttons. Delete navigates to `/singular/:id/delete`.
329
+
330
+ **Destroy confirm** — renders a warning `alert` prompting confirmation. "Confirm Delete" calls `store.destroy` and transitions to `:index`; "Cancel" returns to `:index`.
331
+
332
+ **New** — renders `header1 "New ..."` and a `form` block with inputs auto-generated from field types. Submit calls `store.create`, then transitions to `:show` for the new record.
333
+
334
+ **Edit** — renders `header1 "Edit ..."`, seeds form state from the record on first load (guarded by a seeded-for key to prevent reset on every re-render), same form inputs as new. Submit calls `store.update`, transitions to `:show`.
335
+
336
+ ---
337
+
338
+ ## Complete Example with Override
339
+
340
+ See `examples/scaffolding/blog.rb` for a zero-dependency smoke test (~50 lines).
341
+
342
+ See `examples/scaffolding/utf_lite.rb` for a multi-resource app with a custom index override and `edit_view :page`.
data/docs/routing.md ADDED
@@ -0,0 +1,302 @@
1
+ # StreamWeaver URL Routing
2
+
3
+ StreamWeaver supports URL-addressable views — deep links, browser back/forward, and bookmarkable URLs — without adding a router library. Two mechanisms are available depending on complexity.
4
+
5
+ **Not looking for state routing?** Everything on this page maps a URL path to *state* — the same StreamWeaver view still renders, just seeded differently. If you need a genuine HTTP endpoint (webhook receiver, JSON API, file download) that bypasses StreamWeaver's page rendering entirely, see [`docs/endpoints.md`](endpoints.md) (`endpoint` DSL) instead.
6
+
7
+ ## How It Works (Internals)
8
+
9
+ StreamWeaver uses a bidirectional URL ↔ state contract:
10
+
11
+ - **On GET `/*`**: The path is parsed into a partial state hash and merged into session state before render. This seeds the right view on direct URL load.
12
+ - **After POST actions**: The current state is converted to a path and sent as `HX-Push-Url`, updating the browser URL bar without a full page load.
13
+
14
+ This means **routing is just state**. A URL like `/goals` means `state[:main_nav] = 2`. A URL like `/initiative/init-001` means `state[:main_nav] = 2, state[:initiative_id] = "init-001"`.
15
+
16
+ ---
17
+
18
+ ## `route_by` — Simple Key→Path Mapping
19
+
20
+ For apps with a single state key driving navigation (tab switchers, simple page routers):
21
+
22
+ ```ruby
23
+ app "My App" do
24
+ route_by :page, home: "/", dashboard: "/dashboard", settings: "/settings"
25
+
26
+ state[:page] ||= :home
27
+
28
+ navbar do
29
+ nav_item "Home", href: "/", active: state[:page] == :home
30
+ nav_item "Dashboard", href: "/dashboard", active: state[:page] == :dashboard
31
+ nav_item "Settings", href: "/settings", active: state[:page] == :settings
32
+ end
33
+
34
+ case state[:page]
35
+ when :home then render_home
36
+ when :dashboard then render_dashboard
37
+ when :settings then render_settings
38
+ end
39
+ end.run!
40
+ ```
41
+
42
+ **How it works:** `route_by :page, home: "/"` creates a bidirectional map. When `state[:page]` changes (button callback or nav click), StreamWeaver emits `HX-Push-Url: /` automatically. Visiting `/dashboard` directly seeds `state[:page] = :dashboard` before render.
43
+
44
+ **Limitation:** One state key only. Doesn't handle parameterized paths like `/initiative/:id`.
45
+
46
+ ---
47
+
48
+ ## `route_with` — Bidirectional Parser/Builder
49
+
50
+ For apps with parameterized routes, multi-key navigation state, or complex URL structures. Requires two lambdas:
51
+
52
+ - **`parser`**: `path → partial_state_hash | nil` — called on every GET request, **and now also re-run against the requesting tab's own current URL before every htmx POST** (see Pitfall 3). Returns a hash to merge into state, or `nil` to pass through.
53
+ - **`builder`**: `current_state → path_string | nil` — called after every POST action. Returns the new path to push, or `nil` to leave the URL unchanged.
54
+
55
+ ### Example: UTF Dashboard
56
+
57
+ ```ruby
58
+ app "UTF Dashboard", layout: :full do
59
+ route_with(
60
+ parser: lambda do |path|
61
+ case path
62
+ when '/', ''
63
+ { main_nav: 0 }
64
+ when '/tasks'
65
+ { main_nav: 1 }
66
+ when '/goals'
67
+ { main_nav: 2 }
68
+ when '/secretaries'
69
+ { main_nav: 3 }
70
+ when %r{\A/secretary/([^/]+)\z}
71
+ { main_nav: 3, secretary_name: CGI.unescape(Regexp.last_match(1)) }
72
+ when '/sessions'
73
+ { main_nav: 4 }
74
+ when %r{\A/task/([^/]+)\z}
75
+ { main_nav: 0, view_task_id: CGI.unescape(Regexp.last_match(1)) }
76
+ when %r{\A/initiative/([^/]+)/surface/([^/]+)\z}
77
+ { main_nav: 2,
78
+ initiative_id: CGI.unescape(Regexp.last_match(1)),
79
+ surface_id: CGI.unescape(Regexp.last_match(2)) }
80
+ when %r{\A/initiative/([^/]+)\z}
81
+ { main_nav: 2, initiative_id: CGI.unescape(Regexp.last_match(1)) }
82
+ else
83
+ nil
84
+ end
85
+ end,
86
+
87
+ builder: lambda do |current_state|
88
+ if current_state[:view_task_id].to_s.strip != ''
89
+ "/task/#{CGI.escape(current_state[:view_task_id].to_s)}"
90
+ elsif current_state[:secretary_name].to_s.strip != ''
91
+ "/secretary/#{CGI.escape(current_state[:secretary_name].to_s)}"
92
+ elsif current_state[:initiative_id].to_s.strip != ''
93
+ "/initiative/#{CGI.escape(current_state[:initiative_id].to_s)}"
94
+ else
95
+ case current_state[:main_nav].to_i
96
+ when 1 then '/tasks'
97
+ when 2 then '/goals'
98
+ when 3 then '/secretaries'
99
+ when 4 then '/sessions'
100
+ else '/'
101
+ end
102
+ end
103
+ end
104
+ )
105
+
106
+ # ... app body
107
+ end
108
+ ```
109
+
110
+ ### Parser Rules
111
+
112
+ - Return a **hash** to merge into state (only the keys you want to set, not the full state) —
113
+ **merge, not replace**: any key you don't mention keeps whatever value a *previous* request
114
+ left in session state. See "Common Pitfalls" below before relying on this for anything beyond
115
+ a single-view app.
116
+ - Return `nil` to indicate "this path isn't handled by me" — Sinatra will 404
117
+ - Always `CGI.unescape` captured path segments before storing in state
118
+ - Match most-specific patterns first (`:id/surface/:sid` before `:id`)
119
+
120
+ ### Builder Rules
121
+
122
+ - Return a **path string** (starting with `/`) to push to the browser history
123
+ - Return `nil` to leave the URL unchanged (good for transient state like modal open/close)
124
+ - Check priority: specific context (task detail, initiative detail) before generic tabs
125
+ - Always `CGI.escape` state values interpolated into paths
126
+
127
+ ---
128
+
129
+ ## URL Parameters vs State
130
+
131
+ Query params (`?key=value`) are automatically synced to state on every GET request via `sync_params_to_state`. You can use them alongside path-based routing:
132
+
133
+ ```
134
+ GET /goals?filter=active
135
+ # → state[:main_nav] = 2 (from path parser)
136
+ # → state[:filter] = "active" (from query param sync)
137
+ ```
138
+
139
+ ---
140
+
141
+ ## Navigating Programmatically
142
+
143
+ To trigger URL updates from button callbacks, just update the state key that the builder watches:
144
+
145
+ ```ruby
146
+ button "View Initiative" do |s|
147
+ s[:initiative_id] = "init-042" # builder will push /initiative/init-042
148
+ s[:main_nav] = 2
149
+ end
150
+ ```
151
+
152
+ No explicit redirect needed — the `after` hook calls `path_for_state` on every POST response automatically.
153
+
154
+ ---
155
+
156
+ ## Edit Routes (CRUD Pattern)
157
+
158
+ For edit views (`/initiative/:id/edit`), add an edit flag to the route:
159
+
160
+ ```ruby
161
+ # In parser:
162
+ when %r{\A/initiative/([^/]+)/edit\z}
163
+ { main_nav: 2, initiative_id: CGI.unescape(Regexp.last_match(1)), editing_initiative: true }
164
+
165
+ # In builder:
166
+ elsif current_state[:initiative_id].to_s.strip != '' && current_state[:editing_initiative]
167
+ "/initiative/#{CGI.escape(current_state[:initiative_id])}/edit"
168
+ elsif current_state[:initiative_id].to_s.strip != ''
169
+ "/initiative/#{CGI.escape(current_state[:initiative_id])}"
170
+ ```
171
+
172
+ ---
173
+
174
+ ## Common Pitfalls
175
+
176
+ Three bug classes that show up in any sufficiently large `route_with` app (found in practice in an
177
+ app with ~20 branches and 15 tabs). The first two come from the same source: a `case`/`when` route
178
+ table that isn't exhaustive in one direction or the other. The third is architectural: session
179
+ state is one hash per browser, not per tab.
180
+
181
+ ### Pitfall 1 — a narrow branch leaks a previous view's state
182
+
183
+ Because parser results are **merged** into session state, not replaced, a branch that only sets
184
+ its own key(s) leaves every other stateful key exactly as an earlier request left it:
185
+
186
+ ```ruby
187
+ # BAD — only sets main_nav; any special-view flag set by a PRIOR request survives untouched
188
+ when "/messages"
189
+ { main_nav: MESSAGES_TAB_INDEX }
190
+ ```
191
+
192
+ If some other branch earlier set `view_task_id` (a "show this one task, bypass the tab board"
193
+ flag) and your render logic checks `view_task_id` before `main_nav`, navigating to `/messages`
194
+ after having visited `/task/:id` renders the stale task view, not Messages — the URL you're on
195
+ is not what's on screen. This is easy to miss because it's **intermittent**: it only reproduces
196
+ when a session has visited the leaking view before, so it looks like flakiness rather than a
197
+ routing bug.
198
+
199
+ **Fix**: define one frozen hash listing every "special view" key with its off value, and have
200
+ every branch merge its own keys on top of it, not just `nil`/base-hash-free literals:
201
+
202
+ ```ruby
203
+ SPECIAL_VIEW_RESET = { view_task_id: nil, messages_view_stream: nil, checkin_slug: nil, ... }.freeze
204
+
205
+ when "/messages"
206
+ SPECIAL_VIEW_RESET.merge(main_nav: MESSAGES_TAB_INDEX)
207
+ ```
208
+
209
+ Adding a new special view later means adding one key to the list, not remembering to touch every
210
+ existing branch.
211
+
212
+ ### Pitfall 2 — an uncovered `case`/`when` value fails silently, in both directions
213
+
214
+ A `case current_state[:main_nav].to_i` (or any keyed dispatch) with `when` clauses that don't
215
+ cover every value your app actually uses returns `nil` on the uncovered values — and `nil` from
216
+ a builder means **"leave the URL unchanged"** (see Builder Rules above), not an error. The
217
+ symptom is not a crash, it's silence: clicking a tab that maps to an uncovered index changes what
218
+ renders but never touches the URL bar, so it looks like the click "didn't do anything" to the
219
+ address bar specifically. The same gap on the parser side means the corresponding path never
220
+ seeds that index into state, so a direct GET to a URL nobody wrote a `when` for either falls
221
+ through to `nil` (404) or, if it matches a *different* branch that doesn't set `main_nav` at all
222
+ (e.g. bare `/` only clearing special-view flags), inherits whatever `main_nav` a previous request
223
+ left behind.
224
+
225
+ **Fix**: audit the full index/key range your app actually dispatches on and confirm every value
226
+ has a `when` clause on **both** the parser and the builder — not just the branch that was
227
+ reported broken. In practice this bug hunts in pairs: if one branch of a route table is
228
+ incomplete, check the others before considering it fixed.
229
+
230
+ ### Pitfall 3 — one session, many tabs
231
+
232
+ `parser` only ran on GET, so a "special view" flag it sets (Pitfall 1's `SPECIAL_VIEW_RESET`
233
+ pattern) only ever got reset on a real page navigation. Session state is one hash per browser
234
+ (one cookie), not per tab — so once a tab navigated to a dedicated view, that flag stayed true
235
+ for every OTHER tab of the same browser too, and `builder` read the same shared flag on every
236
+ POST from any of them. Symptom: click something in tab A, land on whatever dedicated view tab B
237
+ happens to have open — even a tab that's just sitting in the background, never clicked.
238
+
239
+ **Fix**: htmx already sends the requesting tab's own on-screen URL on every request
240
+ (`HX-Current-URL`). `parser` is now re-run against that URL — same merge-not-replace GET already
241
+ does — before every htmx POST dispatches, so state gets re-scoped to what THAT tab is actually
242
+ showing before `builder` decides where to push it. A sibling tab's stale flag can't survive
243
+ contact with a real click. A raw (non-htmx) POST has no such header and is left unchanged.
244
+
245
+ **Consequence for parser authors**: every key your parser returns is now re-asserted on every
246
+ htmx POST, not just at navigation time — put only URL-derived truth in a parser hash, never
247
+ something a POST handler should be free to change without a URL change also occurring.
248
+
249
+ **Consequence for multi-app (`service.rb`) hosting**: the fix is mount-prefix aware — a URL for
250
+ app A's tab is never applied to app B's state, even though both live in the same session.
251
+
252
+ ## Testing route_with
253
+
254
+ `route_with`'s parser and builder are plain `path -> hash` / `hash -> path` functions — the
255
+ highest-leverage way to test them is to **extract them out of the inline DSL lambdas** into
256
+ ordinary module methods (e.g. `MyApp::Routing.parse(path)` / `MyApp::Routing.build(state)`), so
257
+ they're unit-testable with no Rack::Test or session harness at all:
258
+
259
+ ```ruby
260
+ parser: ->(path) { MyApp::Routing.parse(path) },
261
+ builder: ->(state) { MyApp::Routing.build(state) }
262
+ ```
263
+
264
+ Then the single most valuable regression test is a **round-trip check over every known path** —
265
+ `build(parse(path)) == path` for each route your app defines. It catches both pitfalls above at
266
+ once: Pitfall 1 shows up as `parse` returning a state hash with leftover keys from nothing (a
267
+ round trip alone won't catch this one directly — pair it with an explicit assertion that
268
+ `parse(path)` matches `SPECIAL_VIEW_RESET.merge(...)` exactly, not just a subset); Pitfall 2
269
+ shows up as `build(parse(path))` returning `nil` or the wrong path for any route whose index
270
+ wasn't wired into the builder's `case`, which is exactly the bug it exists to catch — a route
271
+ that parses fine but never round-trips back to a real URL.
272
+
273
+ ```ruby
274
+ KNOWN_ROUTES = ["/", "/messages", "/task/abc", ...]
275
+
276
+ KNOWN_ROUTES.each do |path|
277
+ it "round-trips #{path}" do
278
+ expect(MyApp::Routing.build(MyApp::Routing.parse(path))).to eq(path)
279
+ end
280
+ end
281
+ ```
282
+
283
+ Then in the app body:
284
+
285
+ ```ruby
286
+ if state[:editing_initiative] && state[:initiative_id]
287
+ render_initiative_edit_form(state[:initiative_id])
288
+ elsif state[:initiative_id]
289
+ render_initiative_detail(state[:initiative_id])
290
+ end
291
+ ```
292
+
293
+ ---
294
+
295
+ ## Comparison
296
+
297
+ | Scenario | Use |
298
+ |---|---|
299
+ | Simple tab nav (one active tab) | `route_by` |
300
+ | Multi-param routes (`/x/:id`, `/x/:id/edit`) | `route_with` |
301
+ | Mixed tab + entity detail | `route_with` |
302
+ | No bookmarkability needed | Neither (omit routing entirely) |