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,1004 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StreamWeaver
4
+ # Shared display-only component DSL methods.
5
+ # Included by both App (which adds interactive components) and FeedBuilder.
6
+ module DisplayDSL
7
+ # =========================================
8
+ # Containers
9
+ # =========================================
10
+
11
+ def div(**options, &block)
12
+ with_container(Components::Div.new(**options), &block)
13
+ end
14
+
15
+ def app_header(title, subtitle: nil, variant: :dark, &block)
16
+ with_container(Components::AppHeader.new(title, subtitle: subtitle, variant: variant), &block)
17
+ end
18
+
19
+ def card(**options, &block)
20
+ with_container(Components::Card.new(**options), &block)
21
+ end
22
+
23
+ def card_header(content_or_options = nil, **options, &block)
24
+ component = if content_or_options.is_a?(String)
25
+ Components::CardHeader.new(content_or_options, **options)
26
+ else
27
+ opts = content_or_options.is_a?(Hash) ? content_or_options.merge(options) : options
28
+ Components::CardHeader.new(nil, **opts)
29
+ end
30
+ with_container(component, &block)
31
+ end
32
+
33
+ def card_body(**options, &block)
34
+ with_container(Components::CardBody.new(**options), &block)
35
+ end
36
+
37
+ def card_footer(**options, &block)
38
+ with_container(Components::CardFooter.new(**options), &block)
39
+ end
40
+
41
+ def vstack(spacing: :md, align: nil, divider: false, **options, &block)
42
+ with_container(Components::VStack.new(spacing: spacing, align: align, divider: divider, **options), &block)
43
+ end
44
+
45
+ def hstack(spacing: :sm, align: nil, justify: nil, divider: false, **options, &block)
46
+ with_container(Components::HStack.new(spacing: spacing, align: align, justify: justify, divider: divider, **options), &block)
47
+ end
48
+
49
+ def grid(columns: 3, gap: :md, template: nil, template_areas: nil, template_rows: nil, template_columns: nil, **options, &block)
50
+ with_container(Components::Grid.new(columns: columns, gap: gap, template: template, template_areas: template_areas, template_rows: template_rows, template_columns: template_columns, **options), &block)
51
+ end
52
+
53
+ # @param n [Integer, nil] Optional column count, primarily used with
54
+ # items:. With no widths:, columns already divide evenly (flex 1 1 0
55
+ # per column) -- n alone is advisory. Combined with items:, it drives
56
+ # auto-distribution (see below).
57
+ # @param widths [Array<String>, nil] Explicit per-column widths -- still
58
+ # honored as before.
59
+ # @param items [Enumerable, nil] When given, items are split into n (or
60
+ # widths.size, or 2) equal chunks via each_slice and each chunk is
61
+ # wrapped in its own `column`; the block runs once per item (03
62
+ # honorable mention: presidential_mockup hand-computed
63
+ # `each_slice((size / 3.0).ceil)` for exactly this).
64
+ def columns(n = nil, widths: nil, items: nil, **options, &block)
65
+ return with_container(Components::Columns.new(widths: widths, **options), &block) unless items
66
+
67
+ n ||= widths&.size || 2
68
+ slice_size = [(items.size / n.to_f).ceil, 1].max
69
+
70
+ with_container(Components::Columns.new(widths: widths, **options)) do
71
+ items.each_slice(slice_size) do |slice|
72
+ column { slice.each { |item| block.call(item) } }
73
+ end
74
+ end
75
+ end
76
+
77
+ def column(**options, &block)
78
+ with_container(Components::Column.new(**options), &block)
79
+ end
80
+
81
+ def scroll_box(max_height: "300px", **options, &block)
82
+ with_container(Components::ScrollBox.new(max_height: max_height, **options), &block)
83
+ end
84
+
85
+ def grid_area(name, **options, &block)
86
+ with_container(Components::GridArea.new(name, **options), &block)
87
+ end
88
+
89
+ def sticky(top: nil, bottom: nil, left: nil, right: nil, z_index: nil, **options, &block)
90
+ with_container(Components::Sticky.new(top: top, bottom: bottom, left: left, right: right, z_index: z_index, **options), &block)
91
+ end
92
+
93
+ def overlay(z: 1, pointer_events: nil, **options, &block)
94
+ with_container(Components::Overlay.new(z: z, pointer_events: pointer_events, **options), &block)
95
+ end
96
+
97
+ def fullbleed(**options, &block)
98
+ with_container(Components::Fullbleed.new(**options), &block)
99
+ end
100
+
101
+ def collapsible(label, expanded: false, subtitle: nil, badge_text: nil, badge_variant: :default, **options, &block)
102
+ with_container(Components::Collapsible.new(label, expanded: expanded, subtitle: subtitle, badge_text: badge_text, badge_variant: badge_variant, **options), &block)
103
+ end
104
+
105
+ # Progressive disclosure without nested expandable_card hacks (03 gap #7):
106
+ # native <details>/<summary>, works with zero JS/Alpine.
107
+ def accordion(**options, &block)
108
+ with_container(Components::Accordion.new(**options), &block)
109
+ end
110
+
111
+ def section(title, open: false, **options, &block)
112
+ with_container(Components::AccordionSection.new(title, open: open, **options), &block)
113
+ end
114
+
115
+ # Static Kanban board layout (03 gap #9: tyrion hand-built its "War Room"
116
+ # board from plain divs with inline styles). No drag-and-drop -- that's
117
+ # future work; this is the static-columns layout primitive real apps
118
+ # were hand-rolling.
119
+ def board(**options, &block)
120
+ with_container(Components::Board.new(**options), &block)
121
+ end
122
+
123
+ def lane(title, **options, &block)
124
+ with_container(Components::Lane.new(title, **options), &block)
125
+ end
126
+
127
+ def board_card(**options, &block)
128
+ with_container(Components::BoardCard.new(**options), &block)
129
+ end
130
+
131
+ def alert(variant: :info, title: nil, dismissible: false, **options, &block)
132
+ with_container(Components::Alert.new(variant: variant, title: title, dismissible: dismissible, **options), &block)
133
+ end
134
+
135
+ # =========================================
136
+ # Text / display
137
+ # =========================================
138
+
139
+ def text(content, tone: nil, **options)
140
+ components << Components::Text.new(content, tone: tone, **options)
141
+ end
142
+
143
+ def md(content, **options)
144
+ components << Components::Markdown.new(content, **options)
145
+ end
146
+ alias_method :markdown, :md
147
+
148
+ (1..6).each do |level|
149
+ define_method(:"header#{level}") { |content, **options| components << Components::Header.new(content, level: level, **options) }
150
+ end
151
+ alias_method :header, :header2
152
+
153
+ def phrase(content, **options)
154
+ components << Components::Phrase.new(content, **options)
155
+ end
156
+
157
+ # =========================================
158
+ # Dashboard display
159
+ # =========================================
160
+
161
+ def stat_display(value:, label:, color: :blue, size: :md, **options)
162
+ components << Components::StatDisplay.new(value: value, label: label, color: color, size: size, **options)
163
+ end
164
+
165
+ def badge(text, variant: :default, size: :sm, **options)
166
+ components << Components::Badge.new(text, variant: variant, size: size, **options)
167
+ end
168
+
169
+ def status_dot(status: :gray, pulse: false, size: :md, **options)
170
+ components << Components::StatusDot.new(status: status, pulse: pulse, size: size, **options)
171
+ end
172
+
173
+ def type_tag(type_name, color: nil, **options)
174
+ components << Components::TypeTag.new(type_name, color: color, **options)
175
+ end
176
+
177
+ def pulse_indicator(color: :green, label: nil, **options)
178
+ components << Components::PulseIndicator.new(color: color, label: label, **options)
179
+ end
180
+
181
+ def activity_item(time:, title:, summary: nil, type: nil, **options)
182
+ components << Components::ActivityItem.new(time: time, title: title, summary: summary, type: type, **options)
183
+ end
184
+
185
+ def timeline_event(index:, event_type:, timestamp:, label:, fields: {}, expanded: false, **options)
186
+ components << Components::TimelineEvent.new(
187
+ index: index, event_type: event_type, timestamp: timestamp,
188
+ label: label, fields: fields, expanded: expanded, **options
189
+ )
190
+ end
191
+
192
+ def priority_item(priority: :normal, title:, description: nil, meta_left: nil, meta_right: nil, **options, &block)
193
+ component = Components::PriorityItem.new(
194
+ priority: priority, title: title, description: description,
195
+ meta_left: meta_left, meta_right: meta_right, **options
196
+ )
197
+ with_container(component, &block)
198
+ end
199
+
200
+ def progress_bar(value:, max: 100, variant: :default, show_label: false, animated: false, **options)
201
+ components << Components::ProgressBar.new(value: value, max: max, variant: variant, show_label: show_label, animated: animated, **options)
202
+ end
203
+
204
+ def spinner(size: :md, label: nil, **options)
205
+ components << Components::Spinner.new(size: size, label: label, **options)
206
+ end
207
+
208
+ def score_table(scores:, **options)
209
+ components << Components::ScoreTable.new(scores: scores, **options)
210
+ end
211
+
212
+ def table(positional_data = nil, data: nil, headers: nil, rows: nil, file: nil, path: nil, **options, &block)
213
+ actual_data = positional_data || data
214
+ table_component = Components::Table.new(
215
+ actual_data, headers: headers, rows: rows, file: file, path: path, **options, &block
216
+ )
217
+ # A deterministic (not SecureRandom) dom id, keyed off the enclosing
218
+ # fragment id plus a per-render occurrence counter, both of which
219
+ # replay identically across rebuilds of the same DSL for the same
220
+ # control-flow path. This is what lets InteractionRunner's row-granular
221
+ # narrowing (stream_weaver-95k) address a `<tr>` rendered by a prior
222
+ # rebuild -- a random id would never match between requests. Only
223
+ # tables using the column DSL have row identity worth addressing.
224
+ if table_component.columns.any?
225
+ render_state.table_counter += 1
226
+ fragment_part = (render_state.fragment_stack.last || "root").downcase.gsub(/[^a-z0-9]+/, "-")
227
+ table_component.dom_id = "table_#{fragment_part}-#{render_state.table_counter}"
228
+ end
229
+ # Resolve cells now (not lazily at render time) so any buttons a cell
230
+ # builds already exist in the component tree for dispatch to find --
231
+ # button-action requests never call #render (FAC-P2.1 decision 4).
232
+ table_component.resolve!(self, state, fragment: render_state.fragment_stack.last)
233
+ components << table_component
234
+ end
235
+
236
+ def status_badge(status, reasoning)
237
+ components << Components::StatusBadge.new(status, reasoning)
238
+ end
239
+
240
+ def external_link_button(label, url:, submit: false)
241
+ components << Components::ExternalLinkButton.new(label, url: url, submit: submit)
242
+ end
243
+
244
+ def link_to(label, href:, **options)
245
+ components << Components::Link.new(label, href: href, **options)
246
+ end
247
+
248
+ def navbar(**options, &block)
249
+ with_container(Components::Navbar.new(**options), &block)
250
+ end
251
+
252
+ def topbar(**options, &block)
253
+ with_container(Components::Topbar.new(**options), &block)
254
+ end
255
+
256
+ def nav_item(label, href: nil, active: false, **options)
257
+ components << Components::NavItem.new(label, href: href, active: active, **options)
258
+ end
259
+
260
+ # =========================================
261
+ # Visual skills content blocks
262
+ # =========================================
263
+
264
+ # Render a syntax-highlighted code block with optional file header.
265
+ # Uses Prism.js CDN for highlighting (loaded lazily).
266
+ #
267
+ # @param code [String] The source code to display
268
+ # @param lang [String, nil] Language for Prism.js highlighting (e.g. "ruby", "javascript")
269
+ # @param file [String, nil] File path to show in a header bar above the code
270
+ # @param truncate [Integer, nil] Max lines to show (with truncation indicator)
271
+ # @param scroll [Boolean] Enable scrolling for long code (default: true)
272
+ # @param copy [Boolean] Show a copy-to-clipboard affordance in the header (default: false)
273
+ #
274
+ # @example Basic
275
+ # code_block("puts 'hi'", lang: "ruby")
276
+ #
277
+ # @example With file header and truncation
278
+ # code_block(code, file: "src/app.rb", lang: "ruby", truncate: 10)
279
+ #
280
+ # @example With copy-to-clipboard affordance
281
+ # code_block(code, lang: "ruby", copy: true)
282
+ def code_block(code, copy: false, **options)
283
+ components << Components::CodeBlock.new(code, copy: copy, **options)
284
+ end
285
+
286
+ # Render an image with optional caption.
287
+ # Supports local files, URLs, and base64 data URI conversion for export.
288
+ #
289
+ # @param src [String] Image source (URL or file path)
290
+ # @param alt [String] Alt text for accessibility
291
+ # @param caption [String, nil] Caption text below the image
292
+ # @param base64 [Boolean] Convert local file to base64 data URI (default: false)
293
+ #
294
+ # @example Basic
295
+ # image_block("photo.png", alt: "A photo")
296
+ #
297
+ # @example With caption
298
+ # image_block("diagram.svg", caption: "Figure 1: Architecture")
299
+ def image_block(src, **options)
300
+ components << Components::ImageBlock.new(src, **options)
301
+ end
302
+
303
+ # Render a Mermaid diagram.
304
+ # CDN loads lazily -- only when this method is called.
305
+ #
306
+ # @param code [String] Mermaid diagram definition
307
+ # @param zoom [Boolean] Enable zoom/pan controls (default: false)
308
+ # @param compact [Boolean] Compact mode for card embedding (default: false)
309
+ # @param layout [Symbol] Layout engine (:default or :elk)
310
+ # @param theme_vars [Hash, nil] Per-block Mermaid themeVariables overrides
311
+ # @param options [Hash] Additional options
312
+ #
313
+ # @example
314
+ # mermaid("graph LR; A-->B")
315
+ # mermaid("graph TD; A-->B-->C", zoom: true)
316
+ # mermaid("graph LR; A-->B", compact: true)
317
+ # mermaid("graph TD; A-->B", layout: :elk)
318
+ def mermaid(code, zoom: false, compact: false, layout: :default, theme_vars: nil, **options)
319
+ components << Components::Mermaid.new(
320
+ code, zoom: zoom, compact: compact, layout: layout,
321
+ theme_vars: theme_vars, **options
322
+ )
323
+ end
324
+
325
+ # =========================================
326
+ # Visual explainer components (T12)
327
+ # =========================================
328
+
329
+ # Render a horizontal pipeline step flow with arrow connectors.
330
+ # Steps are color-coded by status: complete (green), active (blue), pending (gray).
331
+ # Responsive: collapses to vertical layout on narrow screens.
332
+ #
333
+ # @param steps [Array<Hash>] Steps with :label, :description (optional), :status (:complete, :active, :pending)
334
+ #
335
+ # @example
336
+ # pipeline steps: [
337
+ # { label: "Build", status: :complete },
338
+ # { label: "Test", status: :active },
339
+ # { label: "Deploy", status: :pending }
340
+ # ]
341
+ def pipeline(steps:, **options)
342
+ components << Components::Pipeline.new(steps: steps, **options)
343
+ end
344
+
345
+ # Render a KPI dashboard with auto-fit grid of metric cards.
346
+ # Each card shows a large value, label, optional trend arrow, and optional color.
347
+ #
348
+ # @param metrics [Array<Hash>] Metrics with :value, :label, :color (optional), :trend (optional :up/:down/:flat)
349
+ #
350
+ # @example
351
+ # kpi_dashboard metrics: [
352
+ # { value: "99.9%", label: "Uptime", color: :green, trend: :up },
353
+ # { value: "42ms", label: "Latency", trend: :down }
354
+ # ]
355
+ def kpi_dashboard(metrics:, **options)
356
+ components << Components::KpiDashboard.new(metrics: metrics, **options)
357
+ end
358
+
359
+ # Render a Chart.js chart. CDN loads lazily (only when this method is called).
360
+ # Supports: :bar, :line, :pie, :doughnut, :radar.
361
+ # Dark mode aware: reads --sw-text and --sw-border for grid/text colors.
362
+ #
363
+ # @param type [Symbol] Chart type
364
+ # @param data [Hash] Chart.js data config (labels, datasets)
365
+ # @param options [Hash] Chart.js options config
366
+ # @param height [Integer] Canvas height in pixels (default: 300)
367
+ #
368
+ # @example
369
+ # chart type: :bar, data: { labels: ["A", "B"], datasets: [{ data: [1, 2] }] }
370
+ def chart(type:, data:, options: {}, height: 300, **extra)
371
+ components << Components::Chart.new(type: type, data: data, options: options, height: height, **extra)
372
+ end
373
+
374
+ # =========================================
375
+ # Keyboard shortcuts (visual skills)
376
+ # =========================================
377
+
378
+ # Register keyboard shortcuts with context-aware suppression.
379
+ # Non-visual component -- emits a <script> block.
380
+ # "mod" maps to Cmd on Mac, Ctrl elsewhere.
381
+ #
382
+ # @yield [kb] Builder for registering shortcuts
383
+ # @yieldparam kb [Components::KeyboardShortcuts] The shortcut registry
384
+ #
385
+ # @example
386
+ # keyboard_shortcuts do |kb|
387
+ # kb.on "mod+s", context: :global, js_action: "alert('save')"
388
+ # kb.on "ArrowRight", context: :navigation, js_action: "console.log('next')"
389
+ # end
390
+ def keyboard_shortcuts(**options, &block)
391
+ component = Components::KeyboardShortcuts.new(**options)
392
+ yield component if block
393
+ components << component
394
+ component
395
+ end
396
+
397
+ # =========================================
398
+ # Slide container (visual skills)
399
+ # =========================================
400
+
401
+ # Create a slide container for navigable slides.
402
+ # Supports :swap (one visible at a time) and :scroll_snap (CSS scroll-snap) modes.
403
+ #
404
+ # @param mode [Symbol] Display mode (:swap or :scroll_snap)
405
+ # @param progress_bar [Boolean] Show progress bar (default: true)
406
+ # @param keyboard_nav [Boolean] Enable arrow key navigation (default: true)
407
+ # @param nav_dots [Boolean] Show navigation dots (default: false)
408
+ # @param counter [Boolean] Show slide counter (default: false)
409
+ # @param options [Hash] Additional options
410
+ #
411
+ # @example Swap mode
412
+ # slide_container mode: :swap do
413
+ # slide "intro", "Introduction" do
414
+ # text "Welcome"
415
+ # end
416
+ # slide "arch", "Architecture" do
417
+ # text "Design"
418
+ # end
419
+ # end
420
+ #
421
+ # @example Scroll-snap mode
422
+ # slide_container mode: :scroll_snap, nav_dots: true do
423
+ # slide "s1" do
424
+ # text "Slide 1"
425
+ # end
426
+ # end
427
+ def slide_container(**options, &block)
428
+ with_container(Components::SlideContainer.new(**options), &block)
429
+ end
430
+
431
+ # Create a slide within a slide_container.
432
+ #
433
+ # @param id [String] Unique slide identifier
434
+ # @param title [String, nil] Optional slide title
435
+ # @param type [Symbol] Slide type (:content, :title)
436
+ # @param options [Hash] Additional options
437
+ #
438
+ # @example
439
+ # slide "intro", "Introduction" do
440
+ # text "Welcome to the presentation"
441
+ # end
442
+ def slide(id, title = nil, **options, &block)
443
+ with_container(Components::Slide.new(id, title, **options), &block)
444
+ end
445
+
446
+ # =========================================
447
+ # Theme toggle (visual skills auto-mode)
448
+ # =========================================
449
+
450
+ # Add a dark/light/auto mode toggle button.
451
+ # Auto-mode follows OS prefers-color-scheme via CSS media query + JS listener.
452
+ # Preference persists in localStorage.
453
+ #
454
+ # @param mode [Symbol] Initial mode (:dark, :light, :auto)
455
+ # @param hotkey [String, nil] Keyboard shortcut (e.g. "mod+shift+l")
456
+ # @param persist [Boolean] Persist preference in localStorage (default: true)
457
+ #
458
+ # @example
459
+ # theme_toggle mode: :auto
460
+ # theme_toggle mode: :auto, hotkey: "mod+shift+l"
461
+ def theme_toggle(mode: :auto, hotkey: nil, persist: true, **options)
462
+ components << Components::ThemeToggle.new(mode: mode, hotkey: hotkey, persist: persist, **options)
463
+ end
464
+
465
+ # =========================================
466
+ # Theme presets (visual skills T15)
467
+ # =========================================
468
+
469
+ # Apply a curated theme preset.
470
+ # Injects Google Fonts <link> and CSS custom property overrides
471
+ # for the selected preset. Affects both light and dark modes.
472
+ #
473
+ # Available presets: :editorial, :technical, :warm, :minimal, :terminal
474
+ #
475
+ # @param name [Symbol] Preset name
476
+ #
477
+ # @example
478
+ # theme_preset :editorial # Magazine serif + terracotta
479
+ # theme_preset :warm # Friendly rounded + amber
480
+ # theme_preset :terminal # Monospace retro hacker
481
+ def theme_preset(name, **options)
482
+ components << Components::ThemePreset.new(name, **options)
483
+ end
484
+
485
+ # =========================================
486
+ # Explainer components (visual skills T11)
487
+ # =========================================
488
+
489
+ # Render a sticky sidebar table-of-contents with scroll spy.
490
+ # Desktop (>=1000px): sticky 170px sidebar with IntersectionObserver.
491
+ # Mobile (<1000px): horizontal scrollable sticky bar.
492
+ #
493
+ # @param sections [Array<Hash>] Array of { id:, label: } hashes
494
+ # @param options [Hash] Additional options
495
+ #
496
+ # @example
497
+ # sidebar_toc sections: [
498
+ # { id: "summary", label: "Executive Summary" },
499
+ # { id: "architecture", label: "Architecture" }
500
+ # ]
501
+ def sidebar_toc(sections:, **options)
502
+ components << Components::SidebarToc.new(sections: sections, **options)
503
+ end
504
+
505
+ # Render a document-level header with eyebrow, serif title, and meta pills.
506
+ # Used to open PRDs, reports, and long-form explainer documents.
507
+ #
508
+ # Pills are an array of mixed items:
509
+ # - String → plain meta text (e.g., "June 25, 2026")
510
+ # - Hash { text:, variant: } → colored pill — variant: :default (blue), :warn (amber), :good (green)
511
+ #
512
+ # @param eyebrow [String, nil] Small all-caps label above the title
513
+ # @param title [String] Main document title (rendered in serif)
514
+ # @param pills [Array] Status pills and meta text items
515
+ #
516
+ # @example
517
+ # doc_header(
518
+ # eyebrow: "cultiv-ai · Personal OS",
519
+ # title: "Calendar-Driven Travel State",
520
+ # pills: [{ text: "Draft" }, "June 25, 2026", "Author: Forrest Chang"]
521
+ # )
522
+ def doc_header(title:, eyebrow: nil, pills: [], **options)
523
+ components << Components::DocHeader.new(title: title, eyebrow: eyebrow, pills: pills, **options)
524
+ end
525
+
526
+ # Render a numbered section eyebrow + h2 heading.
527
+ # The eyebrow is a monospace number with an extending decorative line.
528
+ # Pass id: to make the section linkable from sidebar_toc.
529
+ #
530
+ # @param number [String] Section number label, e.g., "01"
531
+ # @param title [String] Section heading text
532
+ # @param id [String, nil] HTML id for TOC anchor linking
533
+ #
534
+ # @example
535
+ # doc_section_header "01", "Problem Statement", id: "problem"
536
+ def doc_section_header(number, title, id: nil, **options)
537
+ components << Components::DocSectionHeader.new(number, title, id: id, **options)
538
+ end
539
+
540
+ # Render a non-dismissible callout box with colored left border.
541
+ # Unlike Alert, Callout is static -- no dismiss button.
542
+ #
543
+ # @param content [String, nil] Optional banner text, rendered as the sole
544
+ # (or first) child -- sugar for the common one-line-banner case (03
545
+ # honorable mention: raw hex/padding divs standing in for a banner).
546
+ # @param variant [Symbol] Callout type (:info, :warning, :success, :error, :tip)
547
+ # @param tone [Symbol, nil] Alias for variant: (kept consistent with `text`'s
548
+ # tone: vocabulary); wins over variant: when both are given.
549
+ # @param title [String, nil] Optional callout title
550
+ # @param options [Hash] Additional options
551
+ # @yield Block of child components rendered inside the callout body
552
+ #
553
+ # @example
554
+ # callout(variant: :warning, title: "Caution") do
555
+ # text "Be careful with this API."
556
+ # end
557
+ #
558
+ # @example
559
+ # callout("Saved successfully.", tone: :success)
560
+ def callout(content = nil, variant: :info, tone: nil, title: nil, **options, &block)
561
+ component = Components::Callout.new(variant: tone || variant, title: title, **options)
562
+ components << component
563
+
564
+ parent_components = components
565
+ self.components = []
566
+ text(content) if content
567
+ evaluate_dsl_block(block) if block
568
+ component.children = components
569
+ self.components = parent_components
570
+ component
571
+ end
572
+
573
+ # Render a file-path-to-rationale mapping for pre-flight planning.
574
+ #
575
+ # @param files [Array<Hash>] Array of {path:, note:} hashes
576
+ # @example
577
+ # implementation_map(files: [
578
+ # { path: "lib/foo.rb", note: "Add the new method" }
579
+ # ])
580
+ def implementation_map(files: [], **options)
581
+ components << Components::ImplementationMap.new(files: files, **options)
582
+ end
583
+
584
+ # Render an architecture decision block with labeled option cards.
585
+ # The block is evaluated in DecisionBuilder scope — only `option(...)` is valid inside it.
586
+ # Other DSL helpers (text, mermaid, etc.) are not available inside the block.
587
+ #
588
+ # @param question [String] The decision question shown as a heading
589
+ # @yield Block of `option(id:, label:, detail:, recommended:)` calls
590
+ #
591
+ # @example
592
+ # decision(question: "Which database?") do
593
+ # option(id: :pg, label: "PostgreSQL", detail: "Full ACID", recommended: true)
594
+ # option(id: :sqlite, label: "SQLite", detail: "Zero-dep")
595
+ # end
596
+ def decision(question:, **options, &block)
597
+ component = Components::Decision.new(question: question, **options)
598
+ components << component
599
+ if block
600
+ builder = DecisionBuilder.new(component)
601
+ builder.instance_eval(&block)
602
+ end
603
+ component
604
+ end
605
+
606
+ # Builder context for decision component's option calls
607
+ class DecisionBuilder
608
+ def initialize(component)
609
+ @component = component
610
+ end
611
+
612
+ def option(id:, label:, detail:, recommended: false)
613
+ @component.add_option(id: id, label: label, detail: detail, recommended: recommended)
614
+ end
615
+ end
616
+
617
+ # Render a wireframe surface block with raw HTML.
618
+ # CSS tokens (--wf-ink, --wf-muted, etc.) and helper classes
619
+ # (.wf-card, .wf-pill, .wf-muted, button.primary) are automatically
620
+ # scoped to .sw-wireframe-surface.
621
+ #
622
+ # @param html [String] Raw HTML fragment to render inside the wireframe surface
623
+ # @param surface [String] Surface type: browser, desktop, mobile, popover, panel
624
+ #
625
+ # @example
626
+ # wireframe_block(html: '<h1>Login</h1><button class="primary">Sign in</button>')
627
+ def wireframe_block(html: "", surface: "browser", **options)
628
+ components << Components::WireframeBlock.new(html: html, surface: surface, **options)
629
+ end
630
+
631
+ # Render an HTML mockup wrapped in device chrome matching the surface type.
632
+ # Accepts a block that returns the HTML string to display inside the chrome frame.
633
+ # CSS tokens (--wf-ink, --wf-muted, etc.) and helper classes apply inside the frame.
634
+ #
635
+ # @param surface [Symbol, String] Device surface: :browser, :desktop, :mobile, :phone,
636
+ # :tablet, :popover, :card, :widget, :panel
637
+ #
638
+ # @example
639
+ # wireframe(surface: :browser) do
640
+ # "<h1>Login</h1><button class='primary'>Sign in</button>"
641
+ # end
642
+ def wireframe(surface: :browser, **options, &block)
643
+ html = block ? instance_exec(&block).to_s : ""
644
+ components << Components::Wireframe.new(html: html, surface: surface, **options)
645
+ end
646
+
647
+ # Render a code block with line-number-pinned annotation bubbles in a side panel.
648
+ # Uses Prism.js for syntax highlighting (loaded once per page).
649
+ # Layout: code pane left, annotation panel right — annotations aligned to their target lines.
650
+ # Annotated lines receive a subtle left-border highlight. Close annotations stack without overlap.
651
+ # See +Components::AnnotatedCode+ for behaviour notes on per-line highlighting limits.
652
+ def annotated_code(language: nil, annotations: [], **options, &block)
653
+ component = Components::AnnotatedCode.new(language: language, annotations: annotations, **options)
654
+ component.code = block ? instance_exec(&block) : ""
655
+ components << component
656
+ component
657
+ end
658
+
659
+ def diff(language: nil, **options, &block)
660
+ component = Components::DiffBlock.new(language: language, **options)
661
+ components << component
662
+ return component unless block
663
+
664
+ builder = DiffBlockBuilder.new
665
+ builder.instance_eval(&block)
666
+ component.before_code = builder.before_code
667
+ component.after_code = builder.after_code
668
+ component
669
+ end
670
+
671
+ class DiffBlockBuilder
672
+ attr_reader :before_code, :after_code
673
+
674
+ def initialize
675
+ @before_code = ""
676
+ @after_code = ""
677
+ end
678
+
679
+ def before(&block)
680
+ @before_code = block.call.to_s
681
+ end
682
+
683
+ def after(&block)
684
+ @after_code = block.call.to_s
685
+ end
686
+ end
687
+
688
+ def api_endpoint(method:, path:, description: nil, params: [], response: {}, **options)
689
+ component = Components::ApiEndpoint.new(
690
+ method: method, path: path, description: description,
691
+ params: params, response: response, **options
692
+ )
693
+ components << component
694
+ component
695
+ end
696
+
697
+ # Render side-by-side comparison panels.
698
+ # Use `before { ... }` and `after { ... }` named blocks inside
699
+ # to populate each panel.
700
+ #
701
+ # @param before_label [String] Label for the "before" panel (default: "Before")
702
+ # @param after_label [String] Label for the "after" panel (default: "After")
703
+ # @param options [Hash] Additional options
704
+ # @yield Block containing `before { ... }` and `after { ... }` calls
705
+ #
706
+ # @example
707
+ # comparison(before_label: "Old", after_label: "New") do
708
+ # before { text "Version 1" }
709
+ # after { text "Version 2" }
710
+ # end
711
+ def comparison(before_label: "Before", after_label: "After", **options, &block)
712
+ component = Components::Comparison.new(
713
+ before_label: before_label, after_label: after_label, **options
714
+ )
715
+ components << component
716
+ return component unless block
717
+
718
+ # Capture before/after blocks using a builder context
719
+ builder = ComparisonBuilder.new(self)
720
+ builder.instance_eval(&block)
721
+ component.before_children = builder.before_components
722
+ component.after_children = builder.after_components
723
+ component
724
+ end
725
+
726
+ # Builder context for comparison component's before/after blocks
727
+ class ComparisonBuilder
728
+ attr_reader :before_components, :after_components
729
+
730
+ def initialize(dsl)
731
+ @dsl = dsl
732
+ @before_components = []
733
+ @after_components = []
734
+ end
735
+
736
+ def before(&block)
737
+ @before_components = capture_components(&block)
738
+ end
739
+
740
+ def after(&block)
741
+ @after_components = capture_components(&block)
742
+ end
743
+
744
+ private
745
+
746
+ def capture_components(&block)
747
+ parent = @dsl.components
748
+ @dsl.components = []
749
+ @dsl.instance_eval(&block)
750
+ captured = @dsl.components
751
+ @dsl.components = parent
752
+ captured
753
+ end
754
+ end
755
+
756
+ # =========================================
757
+ # CSS-only helpers (T13)
758
+ # =========================================
759
+
760
+ # Render a hero section with accent background tint and large padding.
761
+ #
762
+ # @param options [Hash] Additional options
763
+ # @yield Block of child components rendered inside the hero
764
+ #
765
+ # @example
766
+ # hero { header1 "Welcome" }
767
+ def hero(**options, &block)
768
+ with_container(Components::Hero.new(**options), &block)
769
+ end
770
+
771
+ # Render a reading-optimized prose container (max-width ~65ch).
772
+ #
773
+ # @param dropcap [Boolean] Whether to enable dropcap on first paragraph (default: false)
774
+ # @param options [Hash] Additional options
775
+ # @yield Block of child components rendered inside the prose container
776
+ #
777
+ # @example
778
+ # prose(dropcap: true) { md "Long form text..." }
779
+ def prose(dropcap: false, **options, &block)
780
+ with_container(Components::Prose.new(dropcap: dropcap, **options), &block)
781
+ end
782
+
783
+ # Render a styled pullquote with optional attribution.
784
+ #
785
+ # @param text [String] The quote text
786
+ # @param attribution [String, nil] Attribution (e.g., "Author Name")
787
+ #
788
+ # @example
789
+ # pullquote "Design is not just what it looks like.", attribution: "Steve Jobs"
790
+ def pullquote(text, attribution: nil, **options)
791
+ components << Components::Pullquote.new(text, attribution: attribution, **options)
792
+ end
793
+
794
+ # Render a monospace file tree display with color-coded status.
795
+ # Lines ending with [new], [modified], or [deleted] are color-coded.
796
+ #
797
+ # @param tree_text [String] Multi-line file tree text
798
+ #
799
+ # @example
800
+ # dir_tree "src/\n app.rb [modified]\n new_file.rb [new]"
801
+ def dir_tree(tree_text, **options)
802
+ components << Components::DirTree.new(tree_text, **options)
803
+ end
804
+
805
+ # Render a color swatch legend (horizontal row of dots with labels).
806
+ #
807
+ # @param items [Array<Hash>] Array of { color: "#hex", label: "text" }
808
+ #
809
+ # @example
810
+ # legend items: [{ color: "#22c55e", label: "New" }, { color: "#f59e0b", label: "Modified" }]
811
+ def legend(items:, **options)
812
+ components << Components::Legend.new(items: items, **options)
813
+ end
814
+
815
+ # Render a vertical arrow connector between sections.
816
+ #
817
+ # @param label [String, nil] Optional label on the arrow
818
+ #
819
+ # @example
820
+ # flow_arrow label: "transforms into"
821
+ def flow_arrow(label: nil, **options)
822
+ components << Components::FlowArrow.new(label: label, **options)
823
+ end
824
+
825
+ # Render column count override buttons (1/2/3/4 columns).
826
+ # Uses JS to change grid-template-columns on the target element.
827
+ #
828
+ # @param target [String] CSS selector of the grid to control
829
+ # @param columns [Array<Integer>] Available column counts
830
+ #
831
+ # @example
832
+ # layout_toggle target: ".my-grid", columns: [1, 2, 3]
833
+ def layout_toggle(target: ".sw-layout-target", columns: [1, 2, 3, 4], **options)
834
+ components << Components::LayoutToggle.new(target: target, columns: columns, **options)
835
+ end
836
+
837
+ # @example
838
+ # copy_button "Copy summary", text: report_text
839
+ def copy_button(label = "Copy", text:, copied_label: "Copied!", **options)
840
+ components << Components::CopyButton.new(label, text: text, copied_label: copied_label, **options)
841
+ end
842
+
843
+ # =========================================
844
+ # Interactive components (render-only versions for FeedBuilder context)
845
+ # App overrides these with full callback-wiring implementations.
846
+ # =========================================
847
+
848
+ # Feed-context buttons carry the same identity guarantees as App#button:
849
+ # id: is an explicit override, key: gives content-stable identity, and
850
+ # repeat occurrences are auto-disambiguated (id: > key: > auto-derivation).
851
+ def button(label, key: nil, id: nil, **options, &block)
852
+ require 'digest/md5'
853
+ explicit_id = validate_scalar_key!(id, context: "button")
854
+ key = validate_scalar_key!(key, context: "button")
855
+ stable_id = if explicit_id
856
+ sanitize_explicit_id(explicit_id)
857
+ elsif block
858
+ id_input = key ? "#{label}:#{key}" : "#{label}:#{block.source_location.join(':')}"
859
+ Digest::MD5.hexdigest(id_input)[0..7]
860
+ else
861
+ @button_counter = (@button_counter || 0) + 1
862
+ @button_counter.to_s
863
+ end
864
+ btn = Components::Button.new(label, stable_id, **options, &block)
865
+ btn.id = disambiguate_component_id(btn.id, label: label, source_loc: block&.source_location)
866
+ components << btn
867
+ end
868
+
869
+ def select(key, choices, **options)
870
+ @_state[key] = options[:default] || "" unless @_state&.key?(key)
871
+ components << Components::Select.new(key, choices, **options)
872
+ end
873
+
874
+ def expandable_card(key:, title:, subtitle: nil, badge_text: nil, badge_variant: :default,
875
+ status: nil, initially_expanded: false, **options, &block)
876
+ @_state[key] ||= initially_expanded if @_state
877
+ component = Components::ExpandableCard.new(
878
+ key: key, title: title, subtitle: subtitle,
879
+ badge_text: badge_text, badge_variant: badge_variant,
880
+ status: status, initially_expanded: initially_expanded,
881
+ **options
882
+ )
883
+ with_container(component, &block)
884
+ end
885
+
886
+ def show_toast(message, variant: :info, duration: nil)
887
+ return unless @_state
888
+ @_state[:_toasts] ||= []
889
+ toast_id = "toast_#{Time.now.to_f.to_s.gsub('.', '_')}_#{rand(1000)}"
890
+ toast = { id: toast_id, message: message, variant: variant }
891
+ toast[:duration] = duration if duration
892
+ @_state[:_toasts] << toast
893
+ end
894
+
895
+ private
896
+
897
+ # =========================================
898
+ # Interactive component identity (FAC-P0.1)
899
+ # =========================================
900
+
901
+ # Ensures a component's key/id is a stable scalar -- never a positional
902
+ # index or an arbitrary object's #inspect, which would silently break on
903
+ # any collection reorder.
904
+ def validate_scalar_key!(key, context:)
905
+ return key if key.nil?
906
+ unless key.is_a?(String) || key.is_a?(Symbol) || key.is_a?(Integer)
907
+ raise ArgumentError, "#{context}: key: must be a String, Symbol, or Integer, got #{key.class}"
908
+ end
909
+ key
910
+ end
911
+
912
+ # An explicit id: is used verbatim (it is the author's override), minus
913
+ # characters that aren't legal in an HTML id / CSS selector.
914
+ def sanitize_explicit_id(id)
915
+ id.to_s.gsub(/[^a-zA-Z0-9_-]+/, '_')
916
+ end
917
+
918
+ # Detects two interactive components resolving to the same id within a
919
+ # single build (typically same-label buttons in a loop, sharing block
920
+ # source_location). First occurrence keeps its id unchanged; each further
921
+ # occurrence gets a stable "-dup-N" suffix so it remains independently
922
+ # dispatchable -- no author intervention required.
923
+ #
924
+ # Occurrence-index ids are POSITION-stable, not CONTENT-stable: they hold
925
+ # steady across rerenders of the same list, but deleting or inserting an
926
+ # earlier item shifts the later suffixes. That is why the warning points
927
+ # at key:, which binds identity to the record instead of the slot.
928
+ #
929
+ # Under strict_ids the collision raises (dev) or warns (production) --
930
+ # after auto-disambiguation a residual collision is not reachable, so
931
+ # strict mode is really "tell me my ids are position-derived, loudly".
932
+ def disambiguate_component_id(candidate_id, label:, source_loc:)
933
+ occurrence = seen_component_ids[candidate_id]
934
+ seen_component_ids[candidate_id] = occurrence + 1
935
+ return candidate_id if occurrence.zero?
936
+
937
+ loc = source_loc ? Array(source_loc).join(':') : 'unknown location'
938
+ message = "StreamWeaver: duplicate component id for \"#{label}\" at #{loc} " \
939
+ "-- likely the same label/block in a loop. Pass a stable key:, e.g. " \
940
+ "button(#{label.inspect}, key: item.id) { ... }, for distinct, order-independent ids."
941
+
942
+ raise ArgumentError, message if strict_ids? && !StreamWeaver.production_env?
943
+
944
+ unless warned_duplicate_ids.include?(candidate_id)
945
+ warned_duplicate_ids << candidate_id
946
+ warn "#{message} Auto-assigned a unique id for now."
947
+ end
948
+
949
+ "#{candidate_id}-dup-#{occurrence + 1}"
950
+ end
951
+
952
+ # Occurrence table for auto-disambiguation. App overrides this with its
953
+ # pass-scoped render_state accumulator so every rebuild starts clean.
954
+ def seen_component_ids
955
+ @seen_component_ids ||= Hash.new(0)
956
+ end
957
+
958
+ def warned_duplicate_ids
959
+ @_warned_duplicate_ids ||= Set.new
960
+ end
961
+
962
+ def strict_ids?
963
+ StreamWeaver.strict_ids?
964
+ end
965
+
966
+ # Evaluates a user-supplied DSL block (FAC-P0.3). Arity-0 blocks keep the
967
+ # legacy instance_eval behavior (self inside the block is `receiver`).
968
+ # Arity>=1 blocks are called as a plain block.call(receiver) instead --
969
+ # instance_exec is deliberately not used, so the block keeps its own
970
+ # self/binding and can call methods on its enclosing object (e.g. a
971
+ # presenter) while still reaching the DSL via the yielded argument.
972
+ def evaluate_dsl_block(block, receiver = self)
973
+ return unless block
974
+ # instance_exec, not instance_eval: instance_eval yields the receiver to
975
+ # the block, which a zero-arity lambda -- the natural way to write
976
+ # `placeholder: -> { ... }` -- refuses to accept.
977
+ block.arity >= 1 ? block.call(receiver) : receiver.instance_exec(&block)
978
+ end
979
+
980
+ def with_container(component, &block)
981
+ components << component
982
+ return component unless block
983
+
984
+ parent_components = components
985
+ self.components = []
986
+ evaluate_dsl_block(block)
987
+ component.children = components
988
+ self.components = parent_components
989
+ component
990
+ end
991
+
992
+ def watch(key, &block); end
993
+ def on_start(&block); end
994
+ def after(seconds, &block); end
995
+ def every(seconds, &block); end
996
+
997
+ # Never implemented, and it reads exactly like the deferred-rendering verb
998
+ # an author would reach for -- so it raises instead of silently dropping the
999
+ # block it is given.
1000
+ def defer(*)
1001
+ raise NoMethodError, "defer is not a verb; use `fragment(:name, defer: true) { ... }`"
1002
+ end
1003
+ end
1004
+ end