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,2711 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module StreamWeaver
6
+ # Component classes for UI elements
7
+ module Components
8
+ # Shared callback behavior for form components
9
+ module Callbacks
10
+ attr_reader :on_change, :on_blur, :debounce
11
+
12
+ def execute_on_change(state, value)
13
+ on_change&.call(state, value)
14
+ end
15
+
16
+ def execute_on_blur(state, value)
17
+ on_blur&.call(state, value)
18
+ end
19
+
20
+ private
21
+
22
+ def init_callbacks(on_change: nil, on_blur: nil, debounce: nil)
23
+ @on_change = on_change
24
+ @on_blur = on_blur
25
+ @debounce = debounce
26
+ end
27
+ end
28
+
29
+ # Base component class that all components inherit from
30
+ class Base
31
+ # Class-level macro: declare inline CSS for this component.
32
+ # Multiple calls accumulate; all strings are emitted once per class per page.
33
+ #
34
+ # @example
35
+ # css ".my-banner { color: red }"
36
+ def self.css(string)
37
+ @component_css_strings ||= []
38
+ @component_css_strings << string
39
+ end
40
+
41
+ # Class-level macro: declare a CSS file to serve alongside this component.
42
+ # The file is served via the /sw-asset/ route (registered once at declaration time).
43
+ #
44
+ # @param path [String] Absolute path to the CSS file
45
+ def self.css_path(path)
46
+ @component_css_path = path
47
+ ComponentAssets.register_file(path)
48
+ end
49
+
50
+ # Class-level macro: declare a JS file to serve alongside this component.
51
+ #
52
+ # @param path [String] Absolute path to the JS file
53
+ def self.js_path(path)
54
+ @component_js_path = path
55
+ ComponentAssets.register_file(path)
56
+ end
57
+
58
+ def self.component_css_strings
59
+ @component_css_strings || []
60
+ end
61
+
62
+ def self.component_css_path
63
+ @component_css_path
64
+ end
65
+
66
+ def self.component_js_path
67
+ @component_js_path
68
+ end
69
+
70
+ def initialize(**options)
71
+ @options = options
72
+ end
73
+
74
+ # Render the component using Phlex view
75
+ #
76
+ # @param view [Phlex::HTML] The Phlex view instance
77
+ # @param state [Hash] The current state hash
78
+ # @raise [NotImplementedError] if not implemented by subclass
79
+ def render(view, state)
80
+ raise NotImplementedError, "#{self.class} must implement #render"
81
+ end
82
+
83
+ # Return the state key for this component (if applicable)
84
+ #
85
+ # @return [Symbol, nil] The state key or nil
86
+ def key
87
+ nil
88
+ end
89
+
90
+ # Return child components (if applicable)
91
+ #
92
+ # @return [Array] Array of child components
93
+ def children
94
+ []
95
+ end
96
+
97
+ # Register interactive callbacks with the given registry.
98
+ # Default: no-op — most components have no callbacks.
99
+ # Interactive components override this to self-register.
100
+ #
101
+ # @param registry [Hash] Mutable hash mapping dom_id => callable
102
+ def register_callbacks(registry)
103
+ # Default: no interactive callbacks. Override in interactive components.
104
+ end
105
+ end
106
+
107
+ class Fragment < Base
108
+ attr_accessor :children
109
+ attr_reader :name, :id, :deferred, :lazy
110
+
111
+ def initialize(name, id, deferred: false, lazy: false)
112
+ @name = name.to_sym
113
+ @id = id
114
+ @deferred = deferred
115
+ @lazy = lazy
116
+ @children = []
117
+ end
118
+
119
+ def render(view, state)
120
+ view.with_fragment(id) do
121
+ view.div(id: id) do
122
+ deferred ? render_deferred(view, state) : render_children(view, state)
123
+ end
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ # The auto-fetch attributes ride an inner wrapper rather than the
130
+ # container so a later full-container morph replaces the trigger element
131
+ # instead of reusing an already-initialized one, which is what re-arms
132
+ # htmx's `load` trigger. Its own id keeps the morph from matching it
133
+ # positionally against materialized content.
134
+ # `intersect once` is IntersectionObserver-backed, so it fires on whatever
135
+ # makes the element visible and never fires for one with no layout box --
136
+ # Turbo's `loading="lazy"` rule. `revealed`, the other candidate, reads
137
+ # getBoundingClientRect() and so counts a display:none element as revealed
138
+ # (docs/research/2026-08-22-lazy-fragments-trigger-decision.md).
139
+ def render_deferred(view, state)
140
+ attrs = view.adapter.htmx_attrs(view.adapter.url("/update"), view: view, loading: false,
141
+ sw_deferred: true,
142
+ "hx-trigger" => lazy ? "intersect once" : "load")
143
+ classes = ["sw-fragment-deferred", ("sw-fragment-lazy" if lazy)].compact.join(" ")
144
+ view.div(id: "#{id}--deferred", class: classes, "aria-busy" => "true", **attrs) do
145
+ render_children(view, state)
146
+ end
147
+ end
148
+
149
+ def render_children(view, state)
150
+ children.each { |child| child.render(view, state) }
151
+ end
152
+ end
153
+
154
+ # TextField component for single-line text input
155
+ class TextField < Base
156
+ include Callbacks
157
+ attr_reader :key
158
+
159
+ # @param key [Symbol] The state key
160
+ # @param on_change [Proc, nil] Callback when value changes: ->(state, value) { ... }
161
+ # @param on_blur [Proc, nil] Callback when field loses focus: ->(state, value) { ... }
162
+ # @param debounce [Integer, nil] Milliseconds to wait before triggering on_change
163
+ # @param options [Hash] Options (e.g., placeholder)
164
+ def initialize(key, on_change: nil, on_blur: nil, debounce: nil, **options)
165
+ @key = key
166
+ @options = options
167
+ init_callbacks(on_change: on_change, on_blur: on_blur, debounce: debounce)
168
+ end
169
+
170
+ def render(view, state)
171
+ view.adapter.render_text_field(view, @key, callback_options, state)
172
+ end
173
+
174
+ private
175
+
176
+ def callback_options
177
+ @options.merge(on_change: on_change, on_blur: on_blur, debounce: debounce)
178
+ end
179
+ end
180
+
181
+ # TextArea component for multi-line text input
182
+ class TextArea < Base
183
+ include Callbacks
184
+ attr_reader :key
185
+
186
+ # @param key [Symbol] The state key
187
+ # @param on_change [Proc, nil] Callback when value changes: ->(state, value) { ... }
188
+ # @param on_blur [Proc, nil] Callback when field loses focus: ->(state, value) { ... }
189
+ # @param debounce [Integer, nil] Milliseconds to wait before triggering on_change
190
+ # @param options [Hash] Options (e.g., placeholder, rows)
191
+ def initialize(key, on_change: nil, on_blur: nil, debounce: nil, **options)
192
+ @key = key
193
+ @options = options
194
+ init_callbacks(on_change: on_change, on_blur: on_blur, debounce: debounce)
195
+ end
196
+
197
+ def render(view, state)
198
+ view.adapter.render_text_area(view, @key, callback_options, state)
199
+ end
200
+
201
+ private
202
+
203
+ def callback_options
204
+ @options.merge(on_change: on_change, on_blur: on_blur, debounce: debounce)
205
+ end
206
+ end
207
+
208
+ # DateField component for native date input, state-bound like TextField.
209
+ # Value is stored/read as an ISO 8601 string ("YYYY-MM-DD").
210
+ class DateField < Base
211
+ include Callbacks
212
+ attr_reader :key
213
+
214
+ # @param key [Symbol] The state key
215
+ # @param label [String, nil] Optional label rendered above the input
216
+ # @param min [String, nil] Minimum selectable date (ISO 8601)
217
+ # @param max [String, nil] Maximum selectable date (ISO 8601)
218
+ # @param on_change [Proc, nil] Callback when value changes: ->(state, value) { ... }
219
+ # @param on_blur [Proc, nil] Callback when field loses focus: ->(state, value) { ... }
220
+ # @param debounce [Integer, nil] Milliseconds to wait before triggering on_change
221
+ # @param options [Hash] Additional options (e.g. submit: false)
222
+ def initialize(key, label: nil, min: nil, max: nil, on_change: nil, on_blur: nil, debounce: nil, **options)
223
+ @key = key
224
+ @options = options.merge(label: label, min: min, max: max)
225
+ init_callbacks(on_change: on_change, on_blur: on_blur, debounce: debounce)
226
+ end
227
+
228
+ def render(view, state)
229
+ view.adapter.render_date_field(view, @key, callback_options, state)
230
+ end
231
+
232
+ # Coerce an ISO 8601 date string (as stored in state) to a Date.
233
+ # Returns nil for blank or unparsable input instead of raising.
234
+ #
235
+ # @param value [String, nil]
236
+ # @return [Date, nil]
237
+ def self.to_date(value)
238
+ return nil if value.nil? || value.to_s.strip.empty?
239
+
240
+ Date.iso8601(value.to_s)
241
+ rescue ArgumentError
242
+ nil
243
+ end
244
+
245
+ private
246
+
247
+ def callback_options
248
+ @options.merge(on_change: on_change, on_blur: on_blur, debounce: debounce)
249
+ end
250
+ end
251
+
252
+ # Button component that executes actions on click
253
+ class Button < Base
254
+ attr_reader :id, :modal_context, :options
255
+
256
+ # @param label [String] Button label
257
+ # @param stable_id [String] Stable ID suffix (hash of source location or counter)
258
+ # @param options [Hash] Options (e.g., style: :primary or :secondary, modal_context: {key: :name})
259
+ # @param block [Proc] Action block to execute
260
+ def initialize(label, stable_id, **options, &block)
261
+ @label = label
262
+ @action = block
263
+ @modal_context = options.delete(:modal_context)
264
+ @options = options
265
+ # stable_id is derived from source location (for buttons with blocks) or counter (for blockless)
266
+ @button_id = "btn_#{label.downcase.gsub(/[^a-z0-9]+/, '_')}_#{stable_id}"
267
+ end
268
+
269
+ # Fill in options the author didn't already set (e.g. a table action
270
+ # cell defaulting to size: :sm, variant: :quiet). Explicit @options
271
+ # values always win.
272
+ #
273
+ # @param defaults [Hash]
274
+ def apply_default_options(defaults)
275
+ @options = defaults.merge(@options)
276
+ end
277
+
278
+ def render(view, state)
279
+ # Delegate to adapter - no framework knowledge in component
280
+ view.adapter.render_button(view, @button_id, @label, @options, @modal_context)
281
+ end
282
+
283
+ # Execute the button's action block
284
+ #
285
+ # @param state [Hash] The current state
286
+ def execute(state)
287
+ @action.call(state) if @action
288
+ end
289
+
290
+ def id
291
+ @button_id
292
+ end
293
+
294
+ # Reassign id after duplicate-id auto-disambiguation (FAC-P0.1) -- see
295
+ # App#disambiguate_component_id.
296
+ def id=(new_id)
297
+ @button_id = new_id
298
+ end
299
+
300
+ def register_callbacks(registry)
301
+ registry[id] = @action if @action
302
+ end
303
+ end
304
+
305
+ # Text component for displaying literal content (no markdown parsing)
306
+ class Text < Base
307
+ TONES = %i[muted caption error success].freeze
308
+
309
+ attr_reader :tone, :options
310
+
311
+ # @param content [String, Proc] The text content (can be a proc for dynamic content)
312
+ # @param tone [Symbol, nil] Visual tone -- :muted, :caption, :error, :success (03
313
+ # honorable mention: hand-coded hex/padding divs standing in for text variants)
314
+ # @param options [Hash] :class/:style passthrough (stream_weaver-1lo)
315
+ def initialize(content, tone: nil, **options)
316
+ @content = content
317
+ @tone = TONES.include?(tone) ? tone : nil
318
+ @options = options
319
+ end
320
+
321
+ def render(view, state)
322
+ content = @content.is_a?(Proc) ? @content.call(state) : @content
323
+ if @tone
324
+ view.adapter.render_text(view, content.to_s, @tone, @options)
325
+ else
326
+ classes = [@options[:class]].compact
327
+ attrs = {}
328
+ attrs[:class] = classes.join(" ") unless classes.empty?
329
+ attrs[:style] = @options[:style] if @options[:style]
330
+ view.p(**attrs) { content.to_s }
331
+ end
332
+ end
333
+ end
334
+
335
+ # AppHeader component for app header bars (full-width header with brand/actions)
336
+ class AppHeader < Base
337
+ attr_accessor :children
338
+ attr_reader :title, :subtitle, :variant
339
+
340
+ # @param title [String] The header title
341
+ # @param subtitle [String, nil] Optional subtitle
342
+ # @param variant [Symbol] Style variant (:dark, :light, :primary)
343
+ def initialize(title, subtitle: nil, variant: :dark)
344
+ @title = title
345
+ @subtitle = subtitle
346
+ @variant = variant
347
+ @children = []
348
+ end
349
+
350
+ def render(view, state)
351
+ view.adapter.render_app_header(view, self, state)
352
+ end
353
+ end
354
+
355
+ # Div component for layout containers with optional hover support
356
+ class Div < Base
357
+ attr_accessor :children
358
+ attr_reader :hover_class
359
+
360
+ # @param options [Hash] Options (e.g., class: "container")
361
+ # @option options [String] :hover_class CSS class to add on hover (client-side)
362
+ def initialize(hover_class: nil, **options)
363
+ @hover_class = hover_class
364
+ @options = options
365
+ @children = []
366
+ end
367
+
368
+ def render(view, state)
369
+ view.adapter.render_div(view, self, state)
370
+ end
371
+ end
372
+
373
+ # Checkbox component for boolean input
374
+ class Checkbox < Base
375
+ include Callbacks
376
+ attr_reader :key, :options
377
+
378
+ # @param key [Symbol] The state key
379
+ # @param label [String] The label text
380
+ # @param on_change [Proc, nil] Callback when checkbox changes: ->(state, value) { ... }
381
+ # @param options [Hash] Additional options
382
+ def initialize(key, label, on_change: nil, **options)
383
+ @key = key
384
+ @label = label
385
+ @options = options
386
+ init_callbacks(on_change: on_change)
387
+ end
388
+
389
+ def render(view, state)
390
+ view.adapter.render_checkbox(view, @key, @label, @options.merge(on_change: on_change), state)
391
+ end
392
+ end
393
+
394
+ # Select component for dropdown selection
395
+ class Select < Base
396
+ include Callbacks
397
+ attr_reader :key
398
+
399
+ # @param key [Symbol] The state key
400
+ # @param choices [Array<String>] The available choices
401
+ # @param on_change [Proc, nil] Callback when selection changes: ->(state, value) { ... }
402
+ # @param options [Hash] Additional options
403
+ def initialize(key, choices, on_change: nil, **options)
404
+ @key = key
405
+ @choices = choices
406
+ @options = options
407
+ init_callbacks(on_change: on_change)
408
+ end
409
+
410
+ def render(view, state)
411
+ view.adapter.render_select(view, @key, @choices, @options.merge(on_change: on_change), state)
412
+ end
413
+ end
414
+
415
+ # RadioGroup component for single-choice selection (radio buttons)
416
+ # Unlike Select, radio buttons show all options at once and have no pre-selected value
417
+ class RadioGroup < Base
418
+ attr_reader :key
419
+
420
+ # @param key [Symbol] The state key
421
+ # @param choices [Array<String>] The available choices
422
+ # @param options [Hash] Additional options (e.g., placeholder)
423
+ def initialize(key, choices, **options)
424
+ @key = key
425
+ @choices = choices
426
+ @options = options
427
+ end
428
+
429
+ def render(view, state)
430
+ # Delegate to adapter - no framework knowledge in component
431
+ view.adapter.render_radio_group(view, @key, @choices, @options, state)
432
+ end
433
+ end
434
+
435
+ # Card component for visual grouping of content
436
+ class Card < Base
437
+ VALID_DEPTHS = %i[hero elevated default recessed glass].freeze
438
+
439
+ attr_accessor :children
440
+ attr_reader :depth, :accent, :label
441
+
442
+ # @param depth [Symbol] Depth tier (:hero, :elevated, :default, :recessed, :glass)
443
+ # @param accent [Symbol, String, nil] Accent color for left border (:a, :b, :c or CSS color)
444
+ # @param label [String, nil] Corner label text (e.g. "RISK", "NEW")
445
+ # @param options [Hash] Options (e.g., class: "question-card")
446
+ def initialize(depth: nil, accent: nil, label: nil, **options)
447
+ @depth = depth
448
+ @accent = accent
449
+ @label = label
450
+ @options = options
451
+ @children = []
452
+ end
453
+
454
+ def render(view, state)
455
+ # "card" is the legacy, unprefixed hook (still emitted for
456
+ # back-compat -- deprecated, removed at 1.0); "sw-card" is the
457
+ # documented stable hook (stream_weaver-oeo / stream_weaver-lyb).
458
+ classes = ["card", "sw-card"]
459
+ classes << "sw-card--#{@depth}" if @depth && VALID_DEPTHS.include?(@depth)
460
+ classes << "sw-card--accent-#{@accent}" if @accent.is_a?(Symbol)
461
+ classes << "sw-card--accent" if @accent && !@accent.is_a?(Symbol)
462
+ classes << @options[:class] if @options[:class]
463
+ attrs = { class: classes.join(" ") }
464
+
465
+ # Build inline style for custom accent and/or label positioning
466
+ styles = []
467
+ styles << "border-left-color: #{@accent};" if @accent && !@accent.is_a?(Symbol)
468
+ styles << "position: relative;" if @label
469
+ styles << @options[:style] if @options[:style]
470
+ attrs[:style] = styles.join(" ") unless styles.empty?
471
+
472
+ view.div(**attrs) do
473
+ if @label
474
+ view.span(class: "sw-card__label") { @label }
475
+ end
476
+ @children.each { |child| child.render(view, state) }
477
+ end
478
+ end
479
+ end
480
+
481
+ # CardHeader component for card header section
482
+ class CardHeader < Base
483
+ attr_accessor :children
484
+ attr_reader :content, :badge, :meta
485
+
486
+ # @param content [String, nil] Optional string content (renders as h4)
487
+ # @param options [Hash] Additional options
488
+ # @option options [String] :badge Optional badge text (e.g. "C1") rendered before the title
489
+ # @option options [String] :meta Optional right-aligned meta text
490
+ def initialize(content = nil, **options)
491
+ @content = content
492
+ @options = options
493
+ @badge = options[:badge]
494
+ @meta = options[:meta]
495
+ @children = []
496
+ end
497
+
498
+ def render(view, state)
499
+ badged = @badge || @meta
500
+ # "card-header" is the legacy, unprefixed hook (still emitted for
501
+ # back-compat -- deprecated, removed at 1.0); "sw-card-header" is
502
+ # the documented stable hook (stream_weaver-oeo / stream_weaver-lyb).
503
+ classes = ["card-header", "sw-card-header"]
504
+ classes << "card-header--badged" if badged
505
+ classes << @options[:class] if @options[:class]
506
+ attrs = { class: classes.join(" ") }
507
+ attrs[:style] = @options[:style] if @options[:style]
508
+
509
+ view.div(**attrs) do
510
+ view.span(class: "card-header__badge") { @badge } if @badge
511
+ if @content
512
+ badged ? view.h4(class: "card-header__title") { @content } : view.h4 { @content }
513
+ end
514
+ view.span(class: "card-header__meta") { @meta } if @meta
515
+ @children.each { |child| child.render(view, state) }
516
+ end
517
+ end
518
+ end
519
+
520
+ # CardBody component for card main content section
521
+ class CardBody < Base
522
+ attr_accessor :children
523
+
524
+ # @param options [Hash] Additional options
525
+ def initialize(**options)
526
+ @options = options
527
+ @children = []
528
+ end
529
+
530
+ def render(view, state)
531
+ # "card-body" is the legacy, unprefixed hook (still emitted for
532
+ # back-compat -- deprecated, removed at 1.0); "sw-card-body" is the
533
+ # documented stable hook (stream_weaver-oeo / stream_weaver-lyb).
534
+ classes = ["card-body", "sw-card-body"]
535
+ classes << @options[:class] if @options[:class]
536
+ attrs = { class: classes.join(" ") }
537
+ attrs[:style] = @options[:style] if @options[:style]
538
+
539
+ view.div(**attrs) do
540
+ @children.each { |child| child.render(view, state) }
541
+ end
542
+ end
543
+ end
544
+
545
+ # CardFooter component for card footer section (typically for actions)
546
+ class CardFooter < Base
547
+ attr_accessor :children
548
+
549
+ # @param options [Hash] Additional options
550
+ def initialize(**options)
551
+ @options = options
552
+ @children = []
553
+ end
554
+
555
+ def render(view, state)
556
+ # "card-footer" is the legacy, unprefixed hook (still emitted for
557
+ # back-compat -- deprecated, removed at 1.0); "sw-card-footer" is
558
+ # the documented stable hook (stream_weaver-oeo / stream_weaver-lyb).
559
+ classes = ["card-footer", "sw-card-footer"]
560
+ classes << @options[:class] if @options[:class]
561
+ attrs = { class: classes.join(" ") }
562
+ attrs[:style] = @options[:style] if @options[:style]
563
+
564
+ view.div(**attrs) do
565
+ @children.each { |child| child.render(view, state) }
566
+ end
567
+ end
568
+ end
569
+
570
+ # VStack component for vertical stacking with spacing
571
+ class VStack < Base
572
+ attr_reader :spacing, :align, :divider, :options
573
+ attr_accessor :children
574
+
575
+ def initialize(spacing: :md, align: nil, divider: false, **options)
576
+ @spacing = spacing
577
+ @align = align
578
+ @divider = divider
579
+ @options = options
580
+ @children = []
581
+ end
582
+
583
+ def render(view, state)
584
+ view.adapter.render_vstack(view, self, state)
585
+ end
586
+ end
587
+
588
+ # HStack component for horizontal stacking with spacing
589
+ class HStack < Base
590
+ attr_reader :spacing, :align, :justify, :divider, :options
591
+ attr_accessor :children
592
+
593
+ def initialize(spacing: :sm, align: nil, justify: nil, divider: false, **options)
594
+ @spacing = spacing
595
+ @align = align
596
+ @justify = justify
597
+ @divider = divider
598
+ @options = options
599
+ @children = []
600
+ end
601
+
602
+ def render(view, state)
603
+ view.adapter.render_hstack(view, self, state)
604
+ end
605
+ end
606
+
607
+ # Grid component for responsive grid layouts
608
+ class Grid < Base
609
+ attr_reader :columns, :gap, :template, :template_areas, :template_rows, :template_columns, :options
610
+ attr_accessor :children
611
+
612
+ def initialize(columns: 3, gap: :md, template: nil, template_areas: nil, template_rows: nil, template_columns: nil, **options)
613
+ @columns = columns
614
+ @gap = gap
615
+ @template = template
616
+ @template_areas = template_areas
617
+ @template_rows = template_rows
618
+ @template_columns = template_columns
619
+ @options = options
620
+ @children = []
621
+ end
622
+
623
+ def render(view, state)
624
+ view.adapter.render_grid(view, self, state)
625
+ end
626
+ end
627
+
628
+ # GridArea — sets grid-area on its container div, for use inside named-area grids.
629
+ class GridArea < Base
630
+ attr_reader :area_name, :options
631
+ attr_accessor :children
632
+
633
+ def initialize(area_name, **options)
634
+ @area_name = area_name.to_s
635
+ @options = options
636
+ @children = []
637
+ end
638
+
639
+ def render(view, state)
640
+ view.adapter.render_grid_area(view, self, state)
641
+ end
642
+ end
643
+
644
+ # Sticky — wraps content in a position:sticky container.
645
+ class Sticky < Base
646
+ attr_reader :top, :bottom, :left, :right, :z_index, :options
647
+ attr_accessor :children
648
+
649
+ def initialize(top: nil, bottom: nil, left: nil, right: nil, z_index: nil, **options)
650
+ @top = top
651
+ @bottom = bottom
652
+ @left = left
653
+ @right = right
654
+ @z_index = z_index
655
+ @options = options
656
+ @children = []
657
+ end
658
+
659
+ def render(view, state)
660
+ view.adapter.render_sticky(view, self, state)
661
+ end
662
+ end
663
+
664
+ # Overlay — wraps content in a position:absolute overlay.
665
+ class Overlay < Base
666
+ attr_reader :z, :pointer_events, :options
667
+ attr_accessor :children
668
+
669
+ def initialize(z: 1, pointer_events: nil, **options)
670
+ @z = z
671
+ @pointer_events = pointer_events
672
+ @options = options
673
+ @children = []
674
+ end
675
+
676
+ def render(view, state)
677
+ view.adapter.render_overlay(view, self, state)
678
+ end
679
+ end
680
+
681
+ # Fullbleed — escapes parent max-width constraints for a full-width region.
682
+ class Fullbleed < Base
683
+ attr_reader :options
684
+ attr_accessor :children
685
+
686
+ def initialize(**options)
687
+ @options = options
688
+ @children = []
689
+ end
690
+
691
+ def render(view, state)
692
+ view.adapter.render_fullbleed(view, self, state)
693
+ end
694
+ end
695
+
696
+ # Phrase component for plain text within lesson content
697
+ class Phrase < Base
698
+ attr_reader :options
699
+
700
+ # @param content [String] The text content
701
+ # @param options [Hash] :class/:style passthrough (stream_weaver-1lo)
702
+ def initialize(content, **options)
703
+ @content = content
704
+ @options = options
705
+ end
706
+
707
+ def render(view, state)
708
+ classes = [@options[:class]].compact
709
+ attrs = {}
710
+ attrs[:class] = classes.join(" ") unless classes.empty?
711
+ attrs[:style] = @options[:style] if @options[:style]
712
+ view.span(**attrs) { @content }
713
+ end
714
+ end
715
+
716
+ # Term component for hoverable glossary terms with tooltips
717
+ class Term < Base
718
+ attr_reader :term_key
719
+
720
+ # @param term_key [String] The term to display (also used as glossary key)
721
+ # @param options [Hash] Options (e.g., display: "alternate text")
722
+ def initialize(term_key, **options)
723
+ @term_key = term_key
724
+ @options = options
725
+ end
726
+
727
+ def render(view, state)
728
+ # Delegate to adapter - no framework knowledge in component
729
+ view.adapter.render_term(view, @term_key, @options, state)
730
+ end
731
+ end
732
+
733
+ # LessonText component for interactive educational content with glossary tooltips
734
+ class LessonText < Base
735
+ attr_accessor :children
736
+ attr_reader :glossary
737
+
738
+ # @param glossary [Hash] Glossary definitions {term => {simple:, detailed:}}
739
+ # @param options [Hash] Additional options
740
+ def initialize(glossary: {}, **options)
741
+ @glossary = glossary
742
+ @options = options
743
+ @children = []
744
+ end
745
+
746
+ def render(view, state)
747
+ # Delegate to adapter - no framework knowledge in component
748
+ view.adapter.render_lesson_text(view, @glossary, @children, @options, state)
749
+ end
750
+ end
751
+
752
+ # ScrollBox component for scrollable content with max-height
753
+ class ScrollBox < Base
754
+ attr_reader :max_height, :options
755
+ attr_accessor :children
756
+
757
+ def initialize(max_height: "300px", **options)
758
+ @max_height = max_height
759
+ @options = options
760
+ @children = []
761
+ end
762
+
763
+ def render(view, state)
764
+ view.adapter.render_scroll_box(view, self, state)
765
+ end
766
+ end
767
+
768
+ # Collapsible component for expandable/collapsible content sections
769
+ class Collapsible < Base
770
+ attr_reader :label, :expanded, :subtitle, :badge_text, :badge_variant, :options
771
+ attr_accessor :children
772
+
773
+ # @param label [String] The header label text
774
+ # @param expanded [Boolean] Whether to start expanded (default: false)
775
+ # @param subtitle [String, nil] Optional subtitle shown next to the label
776
+ # @param badge_text [String, nil] Optional badge text (e.g., "5 activities")
777
+ # @param badge_variant [Symbol] Badge color variant
778
+ # @param options [Hash] Additional options
779
+ def initialize(label, expanded: false, subtitle: nil, badge_text: nil, badge_variant: :default, **options)
780
+ @label = label
781
+ @expanded = expanded
782
+ @subtitle = subtitle
783
+ @badge_text = badge_text
784
+ @badge_variant = badge_variant
785
+ @options = options
786
+ @children = []
787
+ end
788
+
789
+ def render(view, state)
790
+ view.adapter.render_collapsible(view, self, state)
791
+ end
792
+ end
793
+
794
+ # Accordion container -- groups AccordionSection children. Native
795
+ # <details>/<summary>, no state key, no JS (03 gap #7).
796
+ class Accordion < Base
797
+ attr_accessor :children
798
+
799
+ def initialize(**options)
800
+ @options = options
801
+ @children = []
802
+ end
803
+
804
+ def render(view, state)
805
+ view.adapter.render_accordion(view, @children, @options, state)
806
+ end
807
+ end
808
+
809
+ # Single disclosure panel within an Accordion.
810
+ class AccordionSection < Base
811
+ attr_accessor :children
812
+ attr_reader :title, :open
813
+
814
+ # @param title [String] Always-visible summary text
815
+ # @param open [Boolean] Whether the panel starts expanded (default: false)
816
+ # @param options [Hash] Additional options
817
+ def initialize(title, open: false, **options)
818
+ @title = title
819
+ @open = open
820
+ @options = options
821
+ @children = []
822
+ end
823
+
824
+ def render(view, state)
825
+ view.adapter.render_accordion_section(view, self, state)
826
+ end
827
+ end
828
+
829
+ # Semantic accent tones shared by Lane headers and BoardCard accents --
830
+ # reuse the existing --sw-success/warning/error/info theme tokens (FAC-8mj
831
+ # tyrion parity: per-lane color identity -- gold/red/green -- had no
832
+ # first-class option before this) rather than inventing app-specific
833
+ # color names. An app that wants tyrion's exact gold/red/green still
834
+ # picks the closest tone (:warning/:error/:success) and layers its own
835
+ # CSS on top for the exact hue.
836
+ BOARD_TONES = %i[neutral success warning error info].freeze
837
+
838
+ # Static Kanban board -- groups Lane children. No drag-and-drop (03 gap
839
+ # #9); a future primitive can add it without changing this shape.
840
+ class Board < Base
841
+ attr_accessor :children
842
+ attr_reader :options
843
+
844
+ # @option pinned_headers [Boolean] keep lane header bands visible while
845
+ # the board scrolls vertically
846
+ def initialize(pinned_headers: false, **options)
847
+ @pinned_headers = pinned_headers
848
+ @options = options.merge(pinned_headers: pinned_headers)
849
+ @children = []
850
+ end
851
+
852
+ def pinned_headers? = @pinned_headers
853
+
854
+ def render(view, state)
855
+ view.adapter.render_board(view, @children, @options, state)
856
+ end
857
+ end
858
+
859
+ # Single column within a Board. Children are typically BoardCards, but
860
+ # any component is accepted directly (e.g. an empty-state text).
861
+ #
862
+ # @option tone [Symbol] one of BOARD_TONES -- colors the lane header band
863
+ # @option subtitle [String] small text under the title (e.g. "In Progress")
864
+ # @option icon [String] emoji/glyph, or a URL/path (local_asset or
865
+ # /sw-asset/... http(s)/data URI) rendered as an image -- shown in the
866
+ # header before the title (stream_weaver-oeo, replaces the
867
+ # title-prefix workaround design-parity-fights.md catalogued)
868
+ class Lane < Base
869
+ attr_accessor :children
870
+ attr_reader :title, :tone, :subtitle, :icon, :options
871
+
872
+ def initialize(title, tone: nil, subtitle: nil, icon: nil, **options)
873
+ @title = title
874
+ @tone = tone if BOARD_TONES.include?(tone)
875
+ @subtitle = subtitle
876
+ @icon = icon
877
+ @options = options
878
+ @children = []
879
+ end
880
+
881
+ # Card count for the lane header -- derived from children so it can
882
+ # never drift from what's actually rendered (Forrest's Law: anything
883
+ # that can happen automatically, must).
884
+ def count = @children.size
885
+
886
+ def render(view, state)
887
+ view.adapter.render_lane(view, self, state)
888
+ end
889
+ end
890
+
891
+ # A single card within a Lane.
892
+ #
893
+ # @option tone [Symbol] one of BOARD_TONES -- left-border accent color
894
+ class BoardCard < Base
895
+ attr_accessor :children
896
+ attr_reader :tone, :options
897
+
898
+ def initialize(tone: nil, **options)
899
+ @tone = tone if BOARD_TONES.include?(tone)
900
+ @options = options
901
+ @children = []
902
+ end
903
+
904
+ def render(view, state)
905
+ view.adapter.render_board_card(view, @children, @options.merge(tone: @tone), state)
906
+ end
907
+ end
908
+
909
+ # ScoreTable component for displaying metrics with color-coded scores
910
+ class ScoreTable < Base
911
+ # @param scores [Array<Hash>] Array of {label:, value:, max:} hashes
912
+ # @param options [Hash] Additional options
913
+ def initialize(scores:, **options)
914
+ @scores = scores
915
+ @options = options
916
+ end
917
+
918
+ def render(view, state)
919
+ view.adapter.render_score_table(view, @scores, @options, state)
920
+ end
921
+ end
922
+
923
+ # Built-in formatters for table cell values
924
+ module TableFormatters
925
+ FORMATTERS = {
926
+ date: ->(v) { v.respond_to?(:strftime) ? v.strftime("%b %d, %Y") : v.to_s },
927
+ datetime: ->(v) { v.respond_to?(:strftime) ? v.strftime("%b %d, %Y %l:%M %p") : v.to_s },
928
+ currency: ->(v) { "$#{format_number(v.to_f, 2)}" },
929
+ number: ->(v) { format_number(v.to_f, 0) },
930
+ percent: ->(v) { "#{(v.to_f * 100).round}%" }
931
+ }.freeze
932
+
933
+ def self.format_number(num, decimals)
934
+ parts = format("%.#{decimals}f", num).split(".")
935
+ parts[0] = parts[0].reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
936
+ decimals.positive? ? parts.join(".") : parts[0]
937
+ end
938
+
939
+ def self.apply(value, format_spec)
940
+ return value.to_s if format_spec.nil?
941
+
942
+ formatter = format_spec.is_a?(Proc) ? format_spec : FORMATTERS[format_spec]
943
+ formatter ? formatter.call(value) : value.to_s
944
+ end
945
+ end
946
+
947
+ # Column definition for table DSL
948
+ class TableColumn
949
+ attr_reader :key, :header, :format, :align, :style, :sort_value, :id_style
950
+
951
+ def initialize(key, header: nil, format: nil, align: nil, style: nil, sort_value: nil, id_style: nil, &block)
952
+ @key = key
953
+ @header = header || key.to_s.split("_").map(&:capitalize).join(" ")
954
+ @format = format
955
+ @align = align
956
+ @style = style
957
+ @sort_value = sort_value
958
+ @id_style = id_style
959
+ @value_block = block
960
+ end
961
+
962
+ # @param item [Object] The row's item
963
+ # @param index [Integer, nil] The row's index
964
+ # @param app [StreamWeaver::App, nil] When given, the block is executed via
965
+ # app.instance_exec so it can call component-builder DSL methods
966
+ # (button, badge, hstack, ...); anything it builds is captured and
967
+ # returned as an Array<Components::Base> instead of a formatted scalar
968
+ # (FAC-P2.1 decision 1). Without an app, behavior is unchanged.
969
+ def extract_value(item, index = nil, app: nil, fragment: nil)
970
+ return extract_component_cell(item, index, app, fragment) if app && @value_block
971
+
972
+ raw = if @value_block
973
+ @value_block.arity == 2 ? @value_block.call(item, index) : @value_block.call(item)
974
+ elsif item.respond_to?(@key)
975
+ item.send(@key)
976
+ elsif item.respond_to?(:[])
977
+ item[@key] || item[@key.to_s]
978
+ else
979
+ nil
980
+ end
981
+ TableFormatters.apply(raw, @format)
982
+ end
983
+
984
+ private
985
+
986
+ def extract_component_cell(item, index, app, fragment)
987
+ parent_components = app.components
988
+ pushed_fragment = fragment && app.render_state.fragment_stack.last != fragment
989
+ app.render_state.fragment_stack << fragment if pushed_fragment
990
+ app.components = []
991
+ raw = @value_block.arity == 2 ? app.instance_exec(item, index, &@value_block) : app.instance_exec(item, &@value_block)
992
+ built = app.components
993
+ app.components = parent_components
994
+
995
+ built.any? ? built : TableFormatters.apply(raw, @format)
996
+ ensure
997
+ app.render_state.fragment_stack.pop if pushed_fragment
998
+ end
999
+ end
1000
+
1001
+ # Table component for displaying tabular data with smart data inference
1002
+ # @example Basic usage (original API)
1003
+ # table headers: ["Name", "Size"], rows: [["app.rb", "12kb"], ["cli.rb", "8kb"]]
1004
+ # @example Array of hashes (auto-infer headers)
1005
+ # table data: [{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }]
1006
+ # @example Hash of arrays
1007
+ # table data: { name: ["Alice", "Bob"], age: [30, 25] }
1008
+ # @example File loading
1009
+ # table file: "users.yaml", path: "data.users"
1010
+ # @example State binding
1011
+ # table data: :users
1012
+ # @example Column DSL
1013
+ # table users do
1014
+ # column :name
1015
+ # column :balance, format: :currency, align: :right
1016
+ # end
1017
+ class Table < Base
1018
+ attr_reader :columns, :resolved_rows, :headers, :rows, :markdown
1019
+ # Deterministic DOM id assigned by the `table` DSL method (display_dsl.rb)
1020
+ # for column-DSL tables, used to build stable `<tr id="#{dom_id}-row-#{key}">`
1021
+ # ids that survive across rebuilds (see that method for why -- FAC row-granular
1022
+ # narrowing, stream_weaver-95k). nil for tables that don't need row addressing.
1023
+ attr_accessor :dom_id
1024
+
1025
+ # Lazily resolves and memoizes a row's key, so column blocks that never
1026
+ # build a button never pay the cost (or the ArgumentError) of an
1027
+ # unresolvable row_key (FAC-P2.1 decision 3).
1028
+ class RowKeyThunk
1029
+ def initialize(&resolver)
1030
+ @resolver = resolver
1031
+ end
1032
+
1033
+ def value
1034
+ return @value if defined?(@value)
1035
+ @value = @resolver.call
1036
+ end
1037
+ end
1038
+
1039
+ def key
1040
+ @data
1041
+ end
1042
+
1043
+ def children
1044
+ @children || []
1045
+ end
1046
+
1047
+ def initialize(data = nil, headers: nil, rows: nil, file: nil, path: nil,
1048
+ striped: false, bordered: false, hoverable: true, compact: false,
1049
+ sortable: false, sticky_header: false, markdown: false, caption: nil,
1050
+ alternating: false, scrollable: false, hover: false, row_key: nil, id_column: nil, **options, &block)
1051
+ @data = data
1052
+ @headers = headers
1053
+ @rows = rows
1054
+ @file = file
1055
+ @path = path
1056
+ @striped = striped
1057
+ @bordered = bordered
1058
+ @hoverable = hoverable
1059
+ @compact = compact
1060
+ @sortable = sortable
1061
+ @sticky_header = sticky_header
1062
+ @markdown = markdown
1063
+ @caption = caption
1064
+ @alternating = alternating
1065
+ @scrollable = scrollable
1066
+ @hover = hover
1067
+ @row_key_proc = row_key
1068
+ @id_column = id_column
1069
+ @options = options
1070
+ @columns = []
1071
+ @transform_block = nil
1072
+
1073
+ if block_given?
1074
+ if block.arity == 1
1075
+ # Transform block: table file: "data.yaml" do |data| data.map {...} end
1076
+ @transform_block = block
1077
+ else
1078
+ # Column DSL block
1079
+ instance_eval(&block)
1080
+ end
1081
+ end
1082
+ end
1083
+
1084
+ # Column DSL method
1085
+ def column(key, header: nil, format: nil, align: nil, style: nil, sort_value: nil, id_style: nil, &block)
1086
+ @columns << TableColumn.new(key, header: header, format: format, align: align, style: style, sort_value: sort_value, id_style: id_style, &block)
1087
+ end
1088
+
1089
+ # Resolves headers/rows (and, for the column DSL, cell components/sort
1090
+ # values/row ids) once. Called eagerly by the `table` DSL method with the
1091
+ # owning app so component cells and their buttons exist before dispatch
1092
+ # ever runs (FAC-P2.1 decision 4) -- and lazily, without an app, from
1093
+ # #render for Table instances built directly (legacy/scalar-only path).
1094
+ def resolve!(app, state, fragment: nil)
1095
+ return self if @resolved
1096
+
1097
+ resolved = resolve_data(state, app, fragment)
1098
+ @resolved_headers = resolved[:headers]
1099
+ @resolved_rows = resolved[:rows]
1100
+ @sort_values = resolved[:sort_values] || []
1101
+ @component_columns = resolved[:component_columns] || []
1102
+ @row_ids = resolved[:row_ids] || []
1103
+ @cell_styles = resolved[:cell_styles] || []
1104
+ @children = @resolved_rows.flat_map { |row| row.flat_map { |cell| cell.is_a?(Array) ? cell : [] } }
1105
+ @resolved = true
1106
+ self
1107
+ end
1108
+
1109
+ def render(view, state)
1110
+ resolve!(nil, state) unless @resolved
1111
+ view.adapter.render_table(view, @resolved_headers, @resolved_rows, table_options, state)
1112
+ end
1113
+
1114
+ def register_callbacks(registry)
1115
+ # Sort is client-side only. Only supported when @data is a Symbol (state-bound key).
1116
+ # Direct-data tables (headers:/rows: without state key) cannot sort — @data would be nil.
1117
+ # Server-paginated sort requires app-level state + re-query; this only sorts in-memory rows.
1118
+ # Sort state uses string keys to avoid collision with update_state, which symbolizes all keys.
1119
+ return unless @sortable && @data.is_a?(Symbol)
1120
+ col_count = @headers ? @headers.length : Array(@columns).length
1121
+ col_count.times do |col_index|
1122
+ registry["#{key}_sort_#{col_index}"] = ->(state) {
1123
+ if state["#{key}_sort_col"] == col_index
1124
+ state["#{key}_sort_dir"] = state["#{key}_sort_dir"] == :asc ? :desc : :asc
1125
+ else
1126
+ state["#{key}_sort_col"] = col_index
1127
+ state["#{key}_sort_dir"] = :asc
1128
+ end
1129
+ }
1130
+ end
1131
+ end
1132
+
1133
+ private
1134
+
1135
+ def resolve_data(state, app = nil, fragment = nil)
1136
+ raw = raw_data(state)
1137
+ normalize(raw, app, fragment)
1138
+ end
1139
+
1140
+ def raw_data(state)
1141
+ return file_data if @file
1142
+ return state[@data] if @data.is_a?(Symbol) && @rows.nil?
1143
+ return @data if @data && !@data.is_a?(Symbol)
1144
+ { headers: @headers || [], rows: @rows || [] }
1145
+ end
1146
+
1147
+ def file_data
1148
+ raw = load_file(@file)
1149
+ @transform_block ? @transform_block.call(raw) : extract_path(raw, @path)
1150
+ end
1151
+
1152
+ def load_file(path)
1153
+ require "yaml"
1154
+ require "json"
1155
+ expanded = File.expand_path(path)
1156
+
1157
+ case File.extname(expanded).downcase
1158
+ when ".yaml", ".yml" then YAML.safe_load_file(expanded, symbolize_names: true, permitted_classes: [Symbol, Date, Time])
1159
+ when ".json" then JSON.parse(File.read(expanded), symbolize_names: true)
1160
+ else raise ArgumentError, "Unsupported file type: #{path}. Use .yaml, .yml, or .json"
1161
+ end
1162
+ end
1163
+
1164
+ def extract_path(data, path)
1165
+ return data unless path
1166
+
1167
+ path.split(".").reduce(data) do |obj, key|
1168
+ break if obj.nil?
1169
+ key.match?(/\A-?\d+\z/) ? obj[key.to_i] : obj.fetch(key.to_sym) { obj[key] }
1170
+ end
1171
+ end
1172
+
1173
+ def normalize(data, app = nil, fragment = nil)
1174
+ return data if data.is_a?(Hash) && data.key?(:headers) && data.key?(:rows)
1175
+
1176
+ case data
1177
+ when Array
1178
+ normalize_array(data, app, fragment)
1179
+ when Hash
1180
+ normalize_hash_of_arrays(data)
1181
+ else
1182
+ { headers: [], rows: [] }
1183
+ end
1184
+ end
1185
+
1186
+ def normalize_array(data, app, fragment)
1187
+ return { headers: [], rows: [] } if data.empty?
1188
+
1189
+ first = data.first
1190
+ if @columns.any?
1191
+ # Column DSL - works for Hash rows and for any object the columns'
1192
+ # keys/blocks know how to read (structs, records, ...).
1193
+ build_column_rows(data, app, fragment)
1194
+ elsif first.is_a?(Hash)
1195
+ keys = first.keys
1196
+ headers = keys.map { |k| k.to_s.split("_").map(&:capitalize).join(" ") }
1197
+ rows = data.map { |item| keys.map { |k| (item[k] || item[k.to_s]).to_s } }
1198
+ { headers: headers, rows: rows }
1199
+ elsif first.is_a?(Array)
1200
+ # Array of arrays - original format, no headers unless provided
1201
+ { headers: @headers || [], rows: data }
1202
+ else
1203
+ # Simple array - single column
1204
+ { headers: @headers || ["Value"], rows: data.map { |v| [v.to_s] } }
1205
+ end
1206
+ end
1207
+
1208
+ def build_column_rows(data, app, fragment)
1209
+ headers = @columns.map(&:header)
1210
+ rows = []
1211
+ sort_values = []
1212
+ row_ids = []
1213
+ cell_styles = []
1214
+
1215
+ data.each_with_index do |item, idx|
1216
+ row_key_thunk = RowKeyThunk.new { resolve_row_key(item) }
1217
+ had_render_state = app.respond_to?(:render_state)
1218
+ saved_thunk = app.render_state.current_row_key_thunk if had_render_state
1219
+ app.render_state.current_row_key_thunk = row_key_thunk if had_render_state
1220
+
1221
+ rows << @columns.map { |col| col.extract_value(item, idx, app: app, fragment: fragment) }
1222
+ sort_values << @columns.map { |col| col.sort_value ? col.sort_value.call(item) : nil }
1223
+ cell_styles << @columns.map { |col| resolve_col_style(col, item) }
1224
+ row_ids << row_dom_key(item)
1225
+
1226
+ app.render_state.current_row_key_thunk = saved_thunk if had_render_state
1227
+ end
1228
+
1229
+ component_columns = @columns.each_index.map { |ci| rows.any? { |row| row[ci].is_a?(Array) } }
1230
+
1231
+ { headers: headers, rows: rows, sort_values: sort_values, component_columns: component_columns, row_ids: row_ids,
1232
+ cell_styles: cell_styles }
1233
+ end
1234
+
1235
+ # Resolves a column's `style:` escape hatch for one row -- a static
1236
+ # String applies to every row, a Proc is called per-row with the item
1237
+ # (FAC table cell style escape hatches, stream_weaver-act).
1238
+ def resolve_col_style(col, item)
1239
+ return nil unless col&.style
1240
+ col.style.is_a?(Proc) ? col.style.call(item) : col.style
1241
+ end
1242
+
1243
+ # @raise [ArgumentError] when no row_key: proc was given and the item has
1244
+ # neither #id nor a :id/"id" Hash key (FAC-P2.1 decision 3). Only
1245
+ # invoked when something actually needs the key (a button built inside
1246
+ # a cell, or DOM row-id assignment) -- rows whose cells stay scalar
1247
+ # never pay for this.
1248
+ def resolve_row_key(item)
1249
+ return @row_key_proc.call(item) if @row_key_proc
1250
+ return item.id if item.respond_to?(:id)
1251
+ if item.is_a?(Hash)
1252
+ return item[:id] if item.key?(:id)
1253
+ return item["id"] if item.key?("id")
1254
+ end
1255
+
1256
+ raise ArgumentError,
1257
+ "table: cannot derive a row_key for #{item.inspect} -- pass row_key: ->(item) { ... } " \
1258
+ "to `table`, or give each item an #id method or :id/\"id\" key."
1259
+ end
1260
+
1261
+ # DOM row ids are best-effort: an unresolvable row_key just means no
1262
+ # <tr id> is assigned, never a raise (FAC-P2.1 decision 7).
1263
+ def row_dom_key(item)
1264
+ resolve_row_key(item)
1265
+ rescue ArgumentError
1266
+ nil
1267
+ end
1268
+
1269
+ def normalize_hash_of_arrays(data)
1270
+ # Hash of arrays: { name: ["Alice", "Bob"], age: [30, 25] }
1271
+ keys = data.keys
1272
+ headers = keys.map { |k| k.to_s.split("_").map(&:capitalize).join(" ") }
1273
+ max_len = data.values.map(&:length).max || 0
1274
+ rows = (0...max_len).map do |i|
1275
+ keys.map { |k| (data[k][i] || "").to_s }
1276
+ end
1277
+ { headers: headers, rows: rows }
1278
+ end
1279
+
1280
+ def table_options
1281
+ @options.merge(
1282
+ key: @data,
1283
+ striped: @striped,
1284
+ bordered: @bordered,
1285
+ hoverable: @hoverable,
1286
+ compact: @compact,
1287
+ sortable: @sortable,
1288
+ sticky_header: @sticky_header,
1289
+ markdown: @markdown,
1290
+ caption: @caption,
1291
+ columns: @columns,
1292
+ alternating: @alternating,
1293
+ scrollable: @scrollable,
1294
+ hover: @hover,
1295
+ sort_values: @sort_values || [],
1296
+ component_columns: @component_columns || [],
1297
+ row_ids: @row_ids || [],
1298
+ cell_styles: @cell_styles || [],
1299
+ dom_id: @dom_id,
1300
+ id_column: @id_column
1301
+ )
1302
+ end
1303
+ # #table_options is a pure derived-data reader (no side effects); the
1304
+ # adapter calls it, and InteractionRunner's row-narrowing (stream_weaver-95k)
1305
+ # needs it too to compare a table's pre/post-mutation row identity.
1306
+ public :table_options
1307
+ end
1308
+
1309
+ # Markdown component for rendering markdown-formatted content
1310
+ class Markdown < Base
1311
+ attr_reader :content, :options
1312
+
1313
+ # @param content [String, Proc] The markdown content (can be a proc for dynamic content)
1314
+ # @param options [Hash] :class/:style passthrough (stream_weaver-1lo)
1315
+ def initialize(content, **options)
1316
+ @content = content
1317
+ @options = options
1318
+ end
1319
+
1320
+ def render(view, state)
1321
+ content = @content.is_a?(Proc) ? @content.call(state) : @content
1322
+ view.adapter.render_markdown(view, content.to_s, state, @options)
1323
+ end
1324
+ end
1325
+
1326
+ # Header component for semantic headers (h1-h6)
1327
+ class Header < Base
1328
+ attr_reader :level, :options
1329
+
1330
+ # @param content [String, Proc] The header text (can be a proc for dynamic content)
1331
+ # @param level [Integer] Header level (1-6, default: 2)
1332
+ # @param options [Hash] :class/:style passthrough (stream_weaver-1lo)
1333
+ def initialize(content, level: 2, **options)
1334
+ @content = content
1335
+ @level = level.clamp(1, 6)
1336
+ @options = options
1337
+ end
1338
+
1339
+ def render(view, state)
1340
+ content = @content.is_a?(Proc) ? @content.call(state) : @content
1341
+ view.adapter.render_header(view, content.to_s, @level, state, @options)
1342
+ end
1343
+ end
1344
+
1345
+ # CheckboxGroup component for multi-select with select all/none
1346
+ # State is stored as an array of selected values
1347
+ class CheckboxGroup < Base
1348
+ attr_reader :key
1349
+ attr_accessor :children
1350
+
1351
+ # @param key [Symbol] The state key (stores array of selected values)
1352
+ # @param options [Hash] Options including select_all, select_none labels
1353
+ def initialize(key, **options)
1354
+ @key = key
1355
+ @options = options
1356
+ @children = []
1357
+ end
1358
+
1359
+ def render(view, state)
1360
+ view.adapter.render_checkbox_group(view, @key, @children, @options, state)
1361
+ end
1362
+ end
1363
+
1364
+ # CheckboxItem component - individual item within a CheckboxGroup
1365
+ class CheckboxItem < Base
1366
+ attr_reader :value
1367
+ attr_accessor :children
1368
+
1369
+ # @param value [String] The value added to the group's array when checked
1370
+ def initialize(value)
1371
+ @value = value
1372
+ @children = []
1373
+ end
1374
+ end
1375
+
1376
+ # StatusBadge component for visual match indicators
1377
+ # Displays: 🟢 Strong / 🟡 Maybe / 🔴 Skip with reasoning
1378
+ class StatusBadge < Base
1379
+ # @param status [Symbol] One of :strong, :maybe, :skip
1380
+ # @param reasoning [String] Explanation text
1381
+ def initialize(status, reasoning)
1382
+ @status = status
1383
+ @reasoning = reasoning
1384
+ end
1385
+
1386
+ def render(view, state)
1387
+ view.adapter.render_status_badge(view, @status, @reasoning, state)
1388
+ end
1389
+ end
1390
+
1391
+ # TagButtons component for quick-select tag groups
1392
+ # Single-select: clicking a tag selects it (and deselects others)
1393
+ class TagButtons < Base
1394
+ attr_reader :key
1395
+
1396
+ # @param key [Symbol] The state key for selected tag
1397
+ # @param tags [Array<String>] The available tag labels
1398
+ # @param options [Hash] Options (e.g., style: :destructive)
1399
+ def initialize(key, tags, **options)
1400
+ @key = key
1401
+ @tags = tags
1402
+ @options = options
1403
+ end
1404
+
1405
+ def render(view, state)
1406
+ view.adapter.render_tag_buttons(view, @key, @tags, @options, state)
1407
+ end
1408
+ end
1409
+
1410
+ # ChipGroup component for tag/chip multi-select bound to a state array.
1411
+ # multi: true (default) stores an Array of selected values; multi: false
1412
+ # stores a single scalar (radio-style exclusive selection).
1413
+ class ChipGroup < Base
1414
+ attr_reader :key, :choices, :multi, :options
1415
+
1416
+ # @param key [Symbol] The state key (Array when multi, scalar otherwise)
1417
+ # @param choices [Array<String>, Array<Array(String, Object)>] Chip labels,
1418
+ # or [label, value] pairs
1419
+ # @param multi [Boolean] Multi-select (Array state) vs single-select (default: true)
1420
+ # @param options [Hash] Additional options (e.g. submit: false)
1421
+ def initialize(key, choices = [], multi: true, **options)
1422
+ @key = key
1423
+ @choices = choices
1424
+ @multi = multi
1425
+ @options = options
1426
+ end
1427
+
1428
+ def render(view, state)
1429
+ view.adapter.render_chip_group(view, self, state)
1430
+ end
1431
+ end
1432
+
1433
+ # ExternalLinkButton component - opens URL and optionally submits form
1434
+ class ExternalLinkButton < Base
1435
+ # @param label [String] Button label
1436
+ # @param url [String] URL to open in new tab
1437
+ # @param submit [Boolean] Whether to also submit the form (default: false)
1438
+ def initialize(label, url:, submit: false)
1439
+ @label = label
1440
+ @url = url
1441
+ @submit = submit
1442
+ end
1443
+
1444
+ def render(view, state)
1445
+ view.adapter.render_external_link_button(view, @label, @url, @submit, state)
1446
+ end
1447
+ end
1448
+
1449
+ # Columns component for multi-column layouts
1450
+ # Contains Column children for flexible sidebar/content arrangements
1451
+ class Columns < Base
1452
+ attr_accessor :children
1453
+ attr_reader :widths
1454
+
1455
+ # @param widths [Array<String>, nil] Optional column widths (e.g., ['30%', '70%'])
1456
+ # @param options [Hash] Additional options (e.g., gap)
1457
+ def initialize(widths: nil, **options)
1458
+ @widths = widths
1459
+ @options = options
1460
+ @children = []
1461
+ end
1462
+
1463
+ def render(view, state)
1464
+ view.adapter.render_columns(view, @widths, @children, @options, state)
1465
+ end
1466
+ end
1467
+
1468
+ # Column component - individual column within a Columns container
1469
+ class Column < Base
1470
+ attr_accessor :children, :width
1471
+
1472
+ # @param options [Hash] Options (e.g., class for additional styling)
1473
+ def initialize(**options)
1474
+ @options = options
1475
+ @children = []
1476
+ @width = nil # Set by parent Columns during render
1477
+ end
1478
+
1479
+ def render(view, state)
1480
+ view.adapter.render_column(view, @width, @children, @options, state)
1481
+ end
1482
+ end
1483
+
1484
+ # Form component for deferred submission forms
1485
+ # Groups multiple form elements together, uses client-side only state until submission,
1486
+ # and sends all values in a single HTMX POST on submit.
1487
+ class Form < Base
1488
+ attr_reader :name, :submit_label, :cancel_label, :submit_action
1489
+ attr_accessor :children
1490
+
1491
+ # @param name [Symbol] The form name (used as state key, e.g., :edit_person)
1492
+ # @param options [Hash] Additional options
1493
+ def initialize(name, **options)
1494
+ @name = name
1495
+ @options = options
1496
+ @children = []
1497
+ @submit_label = nil
1498
+ @cancel_label = nil
1499
+ @submit_action = nil
1500
+ end
1501
+
1502
+ # Set the submit button configuration
1503
+ # @param label [String] Button label text
1504
+ # @param block [Proc] Action to execute on submit (receives form_values hash)
1505
+ def set_submit(label, &block)
1506
+ @submit_label = label
1507
+ @submit_action = block
1508
+ end
1509
+
1510
+ # Set the cancel button configuration
1511
+ # @param label [String] Button label text
1512
+ def set_cancel(label)
1513
+ @cancel_label = label
1514
+ end
1515
+
1516
+ # Execute the submit action block
1517
+ # @param state [Hash] Current state hash
1518
+ # @param form_values [Hash] The submitted form values
1519
+ def execute_submit(state, form_values)
1520
+ @submit_action&.call(form_values)
1521
+ end
1522
+
1523
+ def render(view, state)
1524
+ view.adapter.render_form(view, @name, @children, @submit_label, @cancel_label, @options, state)
1525
+ end
1526
+ end
1527
+
1528
+ # =========================================
1529
+ # Navigation Components
1530
+ # =========================================
1531
+
1532
+ # Tabs container component for tabbed navigation
1533
+ # Contains Tab children, manages active tab state via state key
1534
+ class Tabs < Base
1535
+ attr_reader :key, :variant, :lazy, :url, :options
1536
+ attr_accessor :children
1537
+
1538
+ # @param key [Symbol] The state key for active tab index
1539
+ # @param variant [Symbol] Visual variant (:line, :enclosed, :soft-rounded)
1540
+ # @param lazy [Boolean] When true, only render active tab content and morph on switch
1541
+ # @param url [Boolean] When true, the active tab is reflected in the URL
1542
+ # @param options [Hash] Additional options
1543
+ def initialize(key, variant: :line, lazy: false, url: false, **options)
1544
+ @key = key
1545
+ @variant = variant
1546
+ @lazy = lazy
1547
+ @url = url
1548
+ @options = options
1549
+ @children = []
1550
+ end
1551
+
1552
+ def render(view, state)
1553
+ view.adapter.render_tabs(view, self, state)
1554
+ end
1555
+
1556
+ def register_callbacks(registry)
1557
+ children.each_with_index do |_tab, index|
1558
+ registry["#{@key}_tab_#{index}"] = ->(state) { state[@key] = index }
1559
+ end
1560
+ end
1561
+ end
1562
+
1563
+ # Tab component - individual tab within a Tabs container
1564
+ class Tab < Base
1565
+ attr_reader :label, :options
1566
+ attr_accessor :children
1567
+
1568
+ # @param label [String] The tab label text
1569
+ # @param options [Hash] Additional options
1570
+ def initialize(label, **options)
1571
+ @label = label
1572
+ @options = options
1573
+ @children = []
1574
+ end
1575
+ end
1576
+
1577
+ # Breadcrumbs container component for navigation trail
1578
+ class Breadcrumbs < Base
1579
+ attr_reader :separator, :options
1580
+ attr_accessor :children
1581
+
1582
+ # @param separator [String] Separator character between crumbs (default: "/")
1583
+ # @param options [Hash] Additional options
1584
+ def initialize(separator: "/", **options)
1585
+ @separator = separator
1586
+ @options = options
1587
+ @children = []
1588
+ end
1589
+
1590
+ def render(view, state)
1591
+ view.adapter.render_breadcrumbs(view, self, state)
1592
+ end
1593
+ end
1594
+
1595
+ # Crumb component - individual item within Breadcrumbs
1596
+ class Crumb < Base
1597
+ attr_reader :label, :href, :options
1598
+
1599
+ # @param label [String] The crumb text
1600
+ # @param href [String, nil] Optional link URL (nil for current/last crumb)
1601
+ # @param options [Hash] Additional options
1602
+ def initialize(label, href: nil, **options)
1603
+ @label = label
1604
+ @href = href
1605
+ @options = options
1606
+ end
1607
+ end
1608
+
1609
+ # Dropdown container component for menus
1610
+ class Dropdown < Base
1611
+ attr_reader :options
1612
+ attr_accessor :trigger_component, :menu_component
1613
+
1614
+ # @param options [Hash] Additional options
1615
+ def initialize(**options)
1616
+ @options = options
1617
+ @trigger_component = nil
1618
+ @menu_component = nil
1619
+ end
1620
+
1621
+ # Union of trigger/menu, so component-tree walkers (button/menu_item lookup
1622
+ # in server.rb) that only know about `children` can still find the trigger
1623
+ # button and menu_items nested inside a dropdown's trigger/menu blocks.
1624
+ # Same fix as AppShell#children -- see that class for the full rationale.
1625
+ def children
1626
+ [trigger_component, menu_component].compact
1627
+ end
1628
+
1629
+ def render(view, state)
1630
+ view.adapter.render_dropdown(view, self, state)
1631
+ end
1632
+ end
1633
+
1634
+ # DropdownTrigger component - the clickable element that opens the menu
1635
+ class DropdownTrigger < Base
1636
+ attr_accessor :children
1637
+
1638
+ def initialize
1639
+ @children = []
1640
+ end
1641
+ end
1642
+
1643
+ # Menu component - the dropdown menu container
1644
+ class Menu < Base
1645
+ attr_reader :options
1646
+ attr_accessor :children
1647
+
1648
+ # @param options [Hash] Additional options
1649
+ def initialize(**options)
1650
+ @options = options
1651
+ @children = []
1652
+ end
1653
+ end
1654
+
1655
+ # MenuItem component - individual item within a Menu
1656
+ class MenuItem < Base
1657
+ attr_reader :label, :style, :action, :options
1658
+
1659
+ # @param label [String] The menu item text
1660
+ # @param style [Symbol] Visual style (:default, :destructive)
1661
+ # @param options [Hash] Additional options
1662
+ # @param block [Proc] Action to execute on click
1663
+ def initialize(label, style: :default, **options, &block)
1664
+ @label = label
1665
+ @style = style
1666
+ @action = block
1667
+ @options = options
1668
+ end
1669
+
1670
+ # The DSL's `menu_item` method sets @id via instance_variable_set (see
1671
+ # App#menu_item) rather than passing it through the constructor -- expose
1672
+ # it so find_button_recursive can match on it the same way it matches
1673
+ # Components::Button#id.
1674
+ def id
1675
+ @id
1676
+ end
1677
+
1678
+ def execute(state)
1679
+ @action&.call(state)
1680
+ end
1681
+ end
1682
+
1683
+ # MenuDivider component - visual separator between menu items
1684
+ class MenuDivider < Base
1685
+ def render(view, state)
1686
+ view.hr(class: "sw-menu-divider")
1687
+ end
1688
+ end
1689
+
1690
+ # =========================================
1691
+ # Link / Navbar / NavItem Components
1692
+ # =========================================
1693
+
1694
+ class Link < Base
1695
+ attr_reader :label, :href, :options
1696
+
1697
+ def initialize(label, href:, **options)
1698
+ @label = label
1699
+ @href = href
1700
+ @options = options
1701
+ end
1702
+
1703
+ def render(view, state)
1704
+ view.adapter.render_link(view, self, state)
1705
+ end
1706
+ end
1707
+
1708
+ # Wraps arbitrary composed content as a single click target -- either a
1709
+ # named-action dispatcher (wired exactly like a named-action button:
1710
+ # token, fragment context, loading indicator on the wrapper) or a plain
1711
+ # navigation link (routed pages). See App#clickable, which builds the
1712
+ # action_token/wrapper_id the same way App#button builds a named-action
1713
+ # button's (stream_weaver-1lo).
1714
+ class Clickable < Base
1715
+ attr_accessor :children
1716
+ attr_reader :href, :options
1717
+
1718
+ # @param href [String, nil] Plain-navigation form -- renders a real <a>.
1719
+ # Mutually exclusive with the action: form (see App#clickable).
1720
+ # @param wrapper_id [String, nil] Stable id for the action: form
1721
+ # @param options [Hash] :class/:style, plus (action: form only)
1722
+ # :action_token/:updates/:primary
1723
+ def initialize(href: nil, wrapper_id: nil, **options)
1724
+ @href = href
1725
+ @wrapper_id = wrapper_id
1726
+ @options = options
1727
+ @children = []
1728
+ end
1729
+
1730
+ def id
1731
+ @wrapper_id
1732
+ end
1733
+
1734
+ def id=(new_id)
1735
+ @wrapper_id = new_id
1736
+ end
1737
+
1738
+ def render(view, state)
1739
+ view.adapter.render_clickable(view, self, state)
1740
+ end
1741
+ end
1742
+
1743
+ class Navbar < Base
1744
+ attr_accessor :children
1745
+ attr_reader :options
1746
+
1747
+ def initialize(**options)
1748
+ @options = options
1749
+ @children = []
1750
+ end
1751
+
1752
+ def render(view, state)
1753
+ view.adapter.render_navbar(view, self, state)
1754
+ end
1755
+ end
1756
+
1757
+ # App-chrome header bar: brand (icon/glyph + wordmark), breadcrumb
1758
+ # trail, and trailing content (block children -- e.g. badge/pill
1759
+ # status). Replaces the hand-rolled tc-topbar div/phrase soup both
1760
+ # tyrion parity slices needed (stream_weaver-oeo, design-parity-
1761
+ # fights.md finding #6).
1762
+ #
1763
+ # @option icon [String] emoji/glyph, or a URL/path rendered as an
1764
+ # image (same detection as Lane#icon)
1765
+ # @option wordmark [String] brand name text
1766
+ # @option breadcrumbs [Array<String>] trail items; the last one is
1767
+ # marked active
1768
+ class Topbar < Base
1769
+ attr_accessor :children
1770
+ attr_reader :icon, :wordmark, :breadcrumbs, :options
1771
+
1772
+ def initialize(icon: nil, wordmark: nil, breadcrumbs: [], **options)
1773
+ @icon = icon
1774
+ @wordmark = wordmark
1775
+ @breadcrumbs = Array(breadcrumbs)
1776
+ @options = options
1777
+ @children = []
1778
+ end
1779
+
1780
+ def render(view, state)
1781
+ view.adapter.render_topbar(view, self, state)
1782
+ end
1783
+ end
1784
+
1785
+ class NavItem < Base
1786
+ attr_reader :label, :href, :close, :options
1787
+
1788
+ # @option close [Boolean, String] decorative tab-close chrome. true uses
1789
+ # the conventional multiplication sign; a String supplies the glyph.
1790
+ def initialize(label, href: nil, active: false, close: false, **options)
1791
+ @label = label
1792
+ @href = href
1793
+ @active = active
1794
+ @close = close
1795
+ @options = options
1796
+ end
1797
+
1798
+ def active? = @active
1799
+
1800
+ def close_label
1801
+ return nil unless @close
1802
+
1803
+ @close == true ? "×" : @close.to_s
1804
+ end
1805
+
1806
+ def render(view, state)
1807
+ view.adapter.render_nav_item(view, self, state)
1808
+ end
1809
+ end
1810
+
1811
+ # =========================================
1812
+ # Modal Components
1813
+ # =========================================
1814
+
1815
+ # Modal container component for dialog overlays
1816
+ # State key: :{key}_open controls visibility (true/false)
1817
+ class Modal < Base
1818
+ attr_reader :key, :title, :size, :options
1819
+ attr_accessor :children, :footer_component
1820
+
1821
+ # @param key [Symbol] State key for modal (opens when state[:"#{key}_open"] is true)
1822
+ # @param title [String, nil] Optional modal title
1823
+ # @param size [Symbol] Modal size (:sm, :md, :lg, :xl) - default :md
1824
+ # @param options [Hash] Additional options
1825
+ def initialize(key, title: nil, size: :md, **options)
1826
+ @key = key
1827
+ @title = title
1828
+ @size = size
1829
+ @options = options
1830
+ @children = []
1831
+ @footer_component = nil
1832
+ end
1833
+
1834
+ def render(view, state)
1835
+ view.adapter.render_modal(view, self, state)
1836
+ end
1837
+
1838
+ def register_callbacks(registry)
1839
+ return unless footer_component
1840
+ # footer_component (ModalFooter) is not in children — traverse its children explicitly.
1841
+ # ModalFooter is a plain container; its children are the interactive components (buttons).
1842
+ Array(footer_component.children).each { |c| c.register_callbacks(registry) }
1843
+ end
1844
+ end
1845
+
1846
+ # ModalFooter component - footer section with action buttons
1847
+ class ModalFooter < Base
1848
+ attr_accessor :children
1849
+
1850
+ def initialize(**options)
1851
+ @options = options
1852
+ @children = []
1853
+ end
1854
+ end
1855
+
1856
+ # =========================================
1857
+ # Feedback Components
1858
+ # =========================================
1859
+
1860
+ # Alert component for static feedback messages
1861
+ # Displays contextual messages with variant styling
1862
+ class Alert < Base
1863
+ attr_reader :variant, :title, :dismissible
1864
+ attr_accessor :children
1865
+
1866
+ # @param variant [Symbol] Alert type (:info, :success, :warning, :error)
1867
+ # @param title [String, nil] Optional alert title
1868
+ # @param dismissible [Boolean] Whether alert can be dismissed (default: false)
1869
+ # @param options [Hash] Additional options
1870
+ def initialize(variant: :info, title: nil, dismissible: false, **options)
1871
+ @variant = variant
1872
+ @title = title
1873
+ @dismissible = dismissible
1874
+ @options = options
1875
+ @children = []
1876
+ end
1877
+
1878
+ def render(view, state)
1879
+ view.adapter.render_alert(view, self, state)
1880
+ end
1881
+ end
1882
+
1883
+ # ToastContainer component for displaying multiple stacked notifications
1884
+ # Renders all active toasts from state[:_toasts] array
1885
+ class ToastContainer < Base
1886
+ attr_reader :position, :duration
1887
+
1888
+ # @param position [Symbol] Screen position (:top_right, :top_left, :bottom_right, :bottom_left)
1889
+ # @param duration [Integer] Default auto-dismiss duration in milliseconds (0 = no auto-dismiss)
1890
+ # @param options [Hash] Additional options
1891
+ def initialize(position: :top_right, duration: 5000, **options)
1892
+ @position = position
1893
+ @duration = duration
1894
+ @options = options
1895
+ end
1896
+
1897
+ def render(view, state)
1898
+ view.adapter.render_toast_container(view, self, state)
1899
+ end
1900
+ end
1901
+
1902
+ # ProgressBar component for visual progress indication
1903
+ class ProgressBar < Base
1904
+ attr_reader :value, :max, :variant, :show_label, :animated
1905
+
1906
+ # @param value [Integer, Symbol] Current value (0-100) or state key
1907
+ # @param max [Integer] Maximum value (default: 100)
1908
+ # @param variant [Symbol] Style (:default, :success, :warning, :error)
1909
+ # @param show_label [Boolean] Show percentage label (default: false)
1910
+ # @param animated [Boolean] Show animation (default: false)
1911
+ # @param options [Hash] Additional options
1912
+ def initialize(value:, max: 100, variant: :default, show_label: false, animated: false, **options)
1913
+ @value = value
1914
+ @max = max
1915
+ @variant = variant
1916
+ @show_label = show_label
1917
+ @animated = animated
1918
+ @options = options
1919
+ end
1920
+
1921
+ def render(view, state)
1922
+ # Resolve value from state if it's a symbol
1923
+ actual_value = @value.is_a?(Symbol) ? (state[@value] || 0) : @value
1924
+ view.adapter.render_progress_bar(view, actual_value, @max, @variant, @show_label, @animated, @options, state)
1925
+ end
1926
+ end
1927
+
1928
+ # Spinner component for loading states
1929
+ class Spinner < Base
1930
+ attr_reader :size, :label
1931
+
1932
+ # @param size [Symbol] Spinner size (:sm, :md, :lg)
1933
+ # @param label [String, nil] Optional loading text
1934
+ # @param options [Hash] Additional options
1935
+ def initialize(size: :md, label: nil, **options)
1936
+ @size = size
1937
+ @label = label
1938
+ @options = options
1939
+ end
1940
+
1941
+ def render(view, state)
1942
+ view.adapter.render_spinner(view, @size, @label, @options, state)
1943
+ end
1944
+ end
1945
+
1946
+ # CanvasContinue marker - tells JavaScript to show spinner instead of "close window"
1947
+ # Used in multi-phase canvas flows where more content is coming
1948
+ class CanvasContinue < Base
1949
+ attr_reader :message
1950
+
1951
+ # @param message [String] Message to show while processing
1952
+ def initialize(message: "Processing...")
1953
+ @message = message
1954
+ end
1955
+
1956
+ def render(view, state)
1957
+ view.adapter.render_canvas_continue(view, @message, state)
1958
+ end
1959
+ end
1960
+
1961
+ # ThemeSwitcher component for runtime theme selection
1962
+ # Renders a dropdown to switch between available themes
1963
+ class ThemeSwitcher < Base
1964
+ attr_reader :position, :show_label
1965
+
1966
+ # Built-in themes (for backwards compatibility)
1967
+ THEMES = [
1968
+ { id: :default, label: "Default", description: "Warm Industrial" },
1969
+ { id: :dashboard, label: "Dashboard", description: "Data Dense" },
1970
+ { id: :document, label: "Document", description: "Reading Mode" }
1971
+ ].freeze
1972
+
1973
+ # @param position [Symbol] Position (:inline, :fixed_top_right)
1974
+ # @param show_label [Boolean] Show "Theme:" label
1975
+ # @param options [Hash] Additional options
1976
+ def initialize(position: :inline, show_label: true, **options)
1977
+ @position = position
1978
+ @show_label = show_label
1979
+ @options = options
1980
+ end
1981
+
1982
+ # Get all available themes (built-in + custom registered)
1983
+ def themes
1984
+ StreamWeaver.all_themes_for_switcher
1985
+ end
1986
+
1987
+ def render(view, state)
1988
+ view.adapter.render_theme_switcher(view, self, state)
1989
+ end
1990
+ end
1991
+
1992
+ # ThemeToggle component for visual skills auto-mode theme switching.
1993
+ # Manages data-sw-theme attribute on <html>, <meta name="theme-color">,
1994
+ # and localStorage persistence.
1995
+ #
1996
+ # Unlike ThemeSwitcher (which handles theme _selection_ among presets),
1997
+ # ThemeToggle handles dark/light/auto _mode_ switching.
1998
+ #
1999
+ # sw- CSS classes:
2000
+ # sw-theme-toggle - toggle button container
2001
+ # sw-theme-toggle--auto - when in auto mode
2002
+ # sw-theme-toggle__btn - the button element
2003
+ # sw-theme-toggle__icon - sun/moon icon span
2004
+ class ThemeToggle < Base
2005
+ attr_reader :mode, :hotkey, :persist
2006
+
2007
+ # @param mode [Symbol] Initial mode (:dark, :light, :auto)
2008
+ # @param hotkey [String, nil] Keyboard shortcut (e.g. "mod+shift+l")
2009
+ # @param persist [Boolean] Persist preference in localStorage
2010
+ # @param options [Hash] Additional options
2011
+ def initialize(mode: :auto, hotkey: nil, persist: true, **options)
2012
+ @mode = mode
2013
+ @hotkey = hotkey
2014
+ @persist = persist
2015
+ @options = options
2016
+ end
2017
+
2018
+ def render(view, state)
2019
+ view.adapter.render_theme_toggle(view, self, state)
2020
+ end
2021
+ end
2022
+
2023
+ # ThemePreset component for applying curated theme presets.
2024
+ # Injects a Google Fonts <link> tag and a <style> block with
2025
+ # CSS custom property overrides for both light and dark modes.
2026
+ #
2027
+ # This is a non-visual "head-level" component: it renders CSS
2028
+ # infrastructure, not visible DOM elements.
2029
+ #
2030
+ # @example DSL usage
2031
+ # theme_preset :editorial
2032
+ # theme_preset :warm
2033
+ class ThemePreset < Base
2034
+ attr_reader :preset_name, :preset
2035
+
2036
+ # @param name [Symbol] Preset name (:editorial, :technical, :warm, :minimal, :terminal)
2037
+ # @param options [Hash] Additional options
2038
+ # @raise [ArgumentError] if preset name is not recognized
2039
+ def initialize(name, **options)
2040
+ @preset_name = name.to_sym
2041
+ @preset = Theme::Presets.get(@preset_name)
2042
+ raise ArgumentError, "Unknown theme preset: #{name}. Available: #{Theme::Presets.available.join(', ')}" unless @preset
2043
+ @options = options
2044
+ end
2045
+
2046
+ def render(view, state)
2047
+ view.adapter.render_theme_preset(view, self, state)
2048
+ end
2049
+ end
2050
+
2051
+ # =========================================
2052
+ # Chart Components
2053
+ # =========================================
2054
+
2055
+ # Shared functionality for all chart types
2056
+ class ChartBase < Base
2057
+ attr_reader :options
2058
+
2059
+ def initialize(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
2060
+ @data = data
2061
+ @file = file
2062
+ @path = path
2063
+ @labels = labels
2064
+ @values = values
2065
+ @transform_block = block
2066
+ @options = options
2067
+ end
2068
+
2069
+ def resolve_data(state)
2070
+ normalize(raw_data(state))
2071
+ end
2072
+
2073
+ private
2074
+
2075
+ def raw_data(state)
2076
+ return file_data if @file
2077
+ return state[@data] if @data.is_a?(Symbol)
2078
+ return @data if @data
2079
+ { labels: @labels || [], values: @values || [] }
2080
+ end
2081
+
2082
+ def file_data
2083
+ raw = load_file(@file)
2084
+ @transform_block ? @transform_block.call(raw) : extract_path(raw, @path)
2085
+ end
2086
+
2087
+ def load_file(path)
2088
+ require 'yaml'
2089
+ require 'json'
2090
+ expanded = File.expand_path(path)
2091
+
2092
+ case File.extname(expanded).downcase
2093
+ when '.yaml', '.yml' then YAML.safe_load_file(expanded, symbolize_names: true, permitted_classes: [Symbol, Date, Time])
2094
+ when '.json' then JSON.parse(File.read(expanded), symbolize_names: true)
2095
+ else raise ArgumentError, "Unsupported file type: #{path}. Use .yaml, .yml, or .json"
2096
+ end
2097
+ end
2098
+
2099
+ def extract_path(data, path)
2100
+ return data unless path
2101
+
2102
+ path.split('.').reduce(data) do |obj, key|
2103
+ break if obj.nil?
2104
+ key.match?(/\A-?\d+\z/) ? obj[key.to_i] : obj.fetch(key.to_sym) { obj[key] }
2105
+ end
2106
+ end
2107
+
2108
+ def normalize(data)
2109
+ case data
2110
+ when Hash then { labels: data.keys.map(&:to_s), values: data.values }
2111
+ when Array then normalize_array(data)
2112
+ else { labels: [], values: [] }
2113
+ end
2114
+ end
2115
+
2116
+ def normalize_array(data)
2117
+ return { labels: data.map { _1[:label] }, values: data.map { _1[:value] } } if labeled_array?(data)
2118
+ { labels: data.each_index.map(&:to_s), values: data }
2119
+ end
2120
+
2121
+ def labeled_array?(data)
2122
+ data.first.is_a?(Hash) && data.first.key?(:label)
2123
+ end
2124
+ end
2125
+
2126
+ class BarChart < ChartBase
2127
+ def render(view, state)
2128
+ view.adapter.render_bar_chart(view, self, state)
2129
+ end
2130
+ end
2131
+
2132
+ class LineChart < ChartBase
2133
+ def render(view, state)
2134
+ view.adapter.render_line_chart(view, self, state)
2135
+ end
2136
+ end
2137
+
2138
+ class PieChart < ChartBase
2139
+ def render(view, state)
2140
+ view.adapter.render_pie_chart(view, self, state)
2141
+ end
2142
+ end
2143
+
2144
+ class StackedBarChart < ChartBase
2145
+ def render(view, state)
2146
+ view.adapter.render_stacked_bar_chart(view, self, state)
2147
+ end
2148
+
2149
+ def resolve_data(state)
2150
+ normalize_stacked(raw_data(state))
2151
+ end
2152
+
2153
+ private
2154
+
2155
+ def normalize_stacked(data)
2156
+ case data
2157
+ when Array then normalize_array_to_stacked(data)
2158
+ when Hash then normalize_hash_to_stacked(data)
2159
+ else { labels: [], series: {} }
2160
+ end
2161
+ end
2162
+
2163
+ def normalize_hash_to_stacked(data)
2164
+ return normalize_series_hash(data) if data.values.first.is_a?(Array)
2165
+ normalize_single_hash(data)
2166
+ end
2167
+
2168
+ def normalize_series_hash(data)
2169
+ labels = (0...data.values.first.length).map(&:to_s)
2170
+ { labels: labels, series: data.transform_keys(&:to_s) }
2171
+ end
2172
+
2173
+ def normalize_single_hash(data)
2174
+ { labels: data.keys.map(&:to_s), series: { "Value" => data.values } }
2175
+ end
2176
+
2177
+ def normalize_array_to_stacked(data)
2178
+ return { labels: [], series: {} } if data.empty?
2179
+
2180
+ first = data.first
2181
+ return normalize_labeled_records(data) if first.is_a?(Hash) && first.key?(:label)
2182
+
2183
+ { labels: data.each_index.map(&:to_s), series: { "Value" => data } }
2184
+ end
2185
+
2186
+ def normalize_labeled_records(data)
2187
+ labels = data.map { _1[:label].to_s }
2188
+ series_keys = data.first.keys.reject { _1 == :label }.map(&:to_s)
2189
+ series = series_keys.to_h { |key| [key, data.map { _1[key.to_sym] || _1[key] || 0 }] }
2190
+ { labels: labels, series: series }
2191
+ end
2192
+ end
2193
+
2194
+ # =========================================
2195
+ # Dashboard Components (Cabinet Control style)
2196
+ # =========================================
2197
+
2198
+ # StatusDot component for colored status indicators
2199
+ # Displays a small colored dot with optional glow effect and label
2200
+ class StatusDot < Base
2201
+ attr_reader :status, :pulse, :size, :label
2202
+
2203
+ # Status colors: red (alert), yellow (warning), green (ok), gray (inactive)
2204
+ STATUSES = %i[red yellow green gray].freeze
2205
+ SIZES = %i[sm md lg].freeze
2206
+
2207
+ # @param status [Symbol] Status color (:red, :yellow, :green, :gray)
2208
+ # @param pulse [Boolean] Whether to animate with pulse effect (default: false)
2209
+ # @param size [Symbol] Size (:sm, :md, :lg) - default :md
2210
+ # @param label [String, nil] Optional label to display below the dot
2211
+ # @param options [Hash] Additional options
2212
+ def initialize(status: :gray, pulse: false, size: :md, label: nil, **options)
2213
+ @status = STATUSES.include?(status.to_sym) ? status.to_sym : :gray
2214
+ @pulse = pulse
2215
+ @size = SIZES.include?(size.to_sym) ? size.to_sym : :md
2216
+ @label = label
2217
+ @options = options
2218
+ end
2219
+
2220
+ def render(view, state)
2221
+ view.adapter.render_status_dot(view, self, state)
2222
+ end
2223
+ end
2224
+
2225
+ # Badge component for small count/label indicators
2226
+ # Displays pill-shaped badges with variant colors
2227
+ class Badge < Base
2228
+ attr_reader :text, :variant, :size
2229
+
2230
+ VARIANTS = %i[default danger warning success info].freeze
2231
+ SIZES = %i[sm md].freeze
2232
+
2233
+ # @param text [String] Badge text/count
2234
+ # @param variant [Symbol] Color variant (:default, :danger, :warning, :success, :info)
2235
+ # @param size [Symbol] Size (:sm, :md) - default :sm
2236
+ # @param options [Hash] Additional options
2237
+ def initialize(text, variant: :default, size: :sm, **options)
2238
+ @text = text.to_s
2239
+ @variant = VARIANTS.include?(variant.to_sym) ? variant.to_sym : :default
2240
+ @size = SIZES.include?(size.to_sym) ? size.to_sym : :sm
2241
+ @options = options
2242
+ end
2243
+
2244
+ def render(view, state)
2245
+ view.adapter.render_badge(view, self, state)
2246
+ end
2247
+ end
2248
+
2249
+ # StatDisplay component for large metric numbers with labels
2250
+ # Displays a prominent value with a small label below
2251
+ class StatDisplay < Base
2252
+ attr_reader :value, :label, :color, :size
2253
+
2254
+ COLORS = %i[default blue purple green red yellow].freeze
2255
+ SIZES = %i[sm md lg].freeze
2256
+
2257
+ # @param value [String, Integer] The main value to display
2258
+ # @param label [String] Label text below the value
2259
+ # @param color [Symbol] Value color (:default, :blue, :purple, :green, :red, :yellow)
2260
+ # @param size [Symbol] Size (:sm, :md, :lg) - default :md
2261
+ # @param options [Hash] Additional options
2262
+ def initialize(value:, label:, color: :blue, size: :md, **options)
2263
+ @value = value.to_s
2264
+ @label = label
2265
+ @color = COLORS.include?(color.to_sym) ? color.to_sym : :default
2266
+ @size = SIZES.include?(size.to_sym) ? size.to_sym : :md
2267
+ @options = options
2268
+ end
2269
+
2270
+ def render(view, state)
2271
+ view.adapter.render_stat_display(view, self, state)
2272
+ end
2273
+ end
2274
+
2275
+ # TypeTag component for activity type badges
2276
+ # Colored pills showing type like RESEARCH, TASK, ESCALATION
2277
+ class TypeTag < Base
2278
+ attr_reader :type_name, :custom_color
2279
+
2280
+ # Predefined types with colors
2281
+ TYPES = {
2282
+ research: :blue,
2283
+ task: :green,
2284
+ escalation: :red,
2285
+ communication: :purple,
2286
+ warning: :yellow,
2287
+ info: :gray
2288
+ }.freeze
2289
+
2290
+ # @param type_name [Symbol, String] The type (:research, :task, :escalation, etc.) or custom text
2291
+ # @param color [Symbol, nil] Override color for custom types
2292
+ # @param options [Hash] Additional options
2293
+ def initialize(type_name, color: nil, **options)
2294
+ @type_name = type_name.to_s.downcase
2295
+ @custom_color = color
2296
+ @options = options
2297
+ end
2298
+
2299
+ def color
2300
+ return @custom_color if @custom_color
2301
+ TYPES[@type_name.to_sym] || :gray
2302
+ end
2303
+
2304
+ def display_text
2305
+ @type_name.upcase
2306
+ end
2307
+
2308
+ def render(view, state)
2309
+ view.adapter.render_type_tag(view, self, state)
2310
+ end
2311
+ end
2312
+
2313
+ # PulseIndicator component for animated status with label
2314
+ # Shows a pulsing dot with accompanying text
2315
+ class PulseIndicator < Base
2316
+ attr_reader :color, :label
2317
+
2318
+ COLORS = %i[green red yellow blue].freeze
2319
+
2320
+ # @param color [Symbol] Dot color (:green, :red, :yellow, :blue) - default :green
2321
+ # @param label [String] Status text next to the dot
2322
+ # @param options [Hash] Additional options
2323
+ def initialize(color: :green, label: nil, **options)
2324
+ @color = COLORS.include?(color.to_sym) ? color.to_sym : :green
2325
+ @label = label
2326
+ @options = options
2327
+ end
2328
+
2329
+ def render(view, state)
2330
+ view.adapter.render_pulse_indicator(view, self, state)
2331
+ end
2332
+ end
2333
+
2334
+ # PriorityItem component for escalation-style items
2335
+ # Items with priority-colored left border
2336
+ class PriorityItem < Base
2337
+ attr_reader :priority, :title, :description, :meta_left, :meta_right
2338
+ attr_accessor :children
2339
+
2340
+ PRIORITIES = %i[critical urgent high normal low].freeze
2341
+
2342
+ # @param priority [Symbol] Priority level (:critical, :urgent, :high, :normal, :low)
2343
+ # @param title [String] Item title
2344
+ # @param description [String, nil] Optional description text
2345
+ # @param meta_left [String, nil] Left-side metadata (e.g., secretary name)
2346
+ # @param meta_right [String, nil] Right-side metadata (e.g., action link)
2347
+ # @param options [Hash] Additional options
2348
+ def initialize(priority: :normal, title:, description: nil, meta_left: nil, meta_right: nil, **options)
2349
+ @priority = PRIORITIES.include?(priority.to_sym) ? priority.to_sym : :normal
2350
+ @title = title
2351
+ @description = description
2352
+ @meta_left = meta_left
2353
+ @meta_right = meta_right
2354
+ @options = options
2355
+ @children = []
2356
+ end
2357
+
2358
+ def render(view, state)
2359
+ view.adapter.render_priority_item(view, self, state)
2360
+ end
2361
+ end
2362
+
2363
+ # ActivityItem component for activity feed items
2364
+ # Shows time, title, summary, and type badge
2365
+ class ActivityItem < Base
2366
+ attr_reader :time, :title, :summary, :type
2367
+
2368
+ # @param time [String] Time display (e.g., "15:00")
2369
+ # @param title [String] Activity title
2370
+ # @param summary [String, nil] Optional summary text
2371
+ # @param type [Symbol, nil] Activity type for TypeTag (:research, :task, etc.)
2372
+ # @param options [Hash] Additional options
2373
+ def initialize(time:, title:, summary: nil, type: nil, **options)
2374
+ @time = time
2375
+ @title = title
2376
+ @summary = summary
2377
+ @type = type
2378
+ @options = options
2379
+ end
2380
+
2381
+ def render(view, state)
2382
+ view.adapter.render_activity_item(view, self, state)
2383
+ end
2384
+ end
2385
+
2386
+ # =========================================
2387
+ # Layout Components (Cabinet Control style)
2388
+ # =========================================
2389
+
2390
+ # AppShell component for two-column app layouts
2391
+ # Provides a main content area with optional fixed sidebar
2392
+ class AppShell < Base
2393
+ attr_reader :sidebar_width, :sidebar_position, :gap, :options
2394
+ attr_accessor :main_children, :sidebar_children
2395
+
2396
+ POSITIONS = %i[left right].freeze
2397
+
2398
+ # @param sidebar_width [String] CSS width for sidebar (default: "320px")
2399
+ # @param sidebar_position [Symbol] Sidebar position (:left, :right) - default :right
2400
+ # @param gap [String] Gap between main and sidebar (default: "1.5rem")
2401
+ # @param options [Hash] Additional options
2402
+ def initialize(sidebar_width: "320px", sidebar_position: :right, gap: "1.5rem", **options)
2403
+ @sidebar_width = sidebar_width
2404
+ @sidebar_position = POSITIONS.include?(sidebar_position.to_sym) ? sidebar_position.to_sym : :right
2405
+ @gap = gap
2406
+ @options = options
2407
+ @main_children = []
2408
+ @sidebar_children = []
2409
+ end
2410
+
2411
+ # Union of main/sidebar content, so component-tree walkers (button/form/input
2412
+ # lookup in server.rb) that only know about `children` can still find buttons,
2413
+ # forms, and inputs nested inside an app_shell's main/sidebar blocks.
2414
+ def children
2415
+ main_children + sidebar_children
2416
+ end
2417
+
2418
+ def render(view, state)
2419
+ view.adapter.render_app_shell(view, self, state)
2420
+ end
2421
+ end
2422
+
2423
+ # Sidebar component for fixed sidebar content
2424
+ # Used within AppShell to define sidebar content
2425
+ class Sidebar < Base
2426
+ attr_reader :header, :sticky, :options
2427
+ attr_accessor :children
2428
+
2429
+ # @param header [String, nil] Optional header text for sidebar
2430
+ # @param sticky [Boolean] Whether sidebar content is sticky (default: true)
2431
+ # @param options [Hash] Additional options
2432
+ def initialize(header: nil, sticky: true, **options)
2433
+ @header = header
2434
+ @sticky = sticky
2435
+ @options = options
2436
+ @children = []
2437
+ end
2438
+
2439
+ def render(view, state)
2440
+ view.adapter.render_sidebar(view, self, state)
2441
+ end
2442
+ end
2443
+
2444
+ # MainContent component for main content area in AppShell
2445
+ # Used within AppShell to define main content
2446
+ class MainContent < Base
2447
+ attr_accessor :children
2448
+
2449
+ # @param options [Hash] Additional options
2450
+ def initialize(**options)
2451
+ @options = options
2452
+ @children = []
2453
+ end
2454
+
2455
+ def render(view, state)
2456
+ view.adapter.render_main_content(view, self, state)
2457
+ end
2458
+ end
2459
+
2460
+ # ExpandableCard component for cards that expand/collapse
2461
+ # Displays header always, body toggles on click
2462
+ class ExpandableCard < Base
2463
+ attr_reader :key, :title, :subtitle, :badge_text, :badge_variant, :status, :initially_expanded, :extra_classes
2464
+ attr_accessor :children, :header_children
2465
+
2466
+ # @param key [Symbol] State key for expanded state
2467
+ # @param title [String] Card title (always visible)
2468
+ # @param subtitle [String, nil] Optional subtitle
2469
+ # @param badge_text [String, nil] Optional badge text (e.g., "5 activities")
2470
+ # @param badge_variant [Symbol] Badge color variant
2471
+ # @param status [Symbol, nil] Status indicator color (:red, :yellow, :green, :gray)
2472
+ # @param initially_expanded [Boolean] Whether card starts expanded (default: false)
2473
+ # @param extra_classes [String, nil] Additional CSS classes for the card container
2474
+ # @param options [Hash] Additional options
2475
+ def initialize(key:, title:, subtitle: nil, badge_text: nil, badge_variant: :default,
2476
+ status: nil, initially_expanded: false, extra_classes: nil, **options)
2477
+ @key = key
2478
+ @title = title
2479
+ @subtitle = subtitle
2480
+ @badge_text = badge_text
2481
+ @badge_variant = badge_variant
2482
+ @status = status
2483
+ @initially_expanded = initially_expanded
2484
+ @extra_classes = extra_classes
2485
+ @options = options
2486
+ @children = []
2487
+ @header_children = []
2488
+ end
2489
+
2490
+ def render(view, state)
2491
+ view.adapter.render_expandable_card(view, self, state)
2492
+ end
2493
+ end
2494
+
2495
+ # CodeEditor component for syntax-highlighted code display/editing
2496
+ # Uses CodeMirror 5 with hx-preserve to survive HTMX swaps
2497
+ class CodeEditor < Base
2498
+ attr_reader :key, :language, :readonly, :height, :options
2499
+
2500
+ # Supported languages (CodeMirror 5 modes)
2501
+ LANGUAGES = {
2502
+ ruby: { mode: 'ruby', mime: 'text/x-ruby' },
2503
+ javascript: { mode: 'javascript', mime: 'text/javascript' },
2504
+ html: { mode: 'htmlmixed', mime: 'text/html' },
2505
+ css: { mode: 'css', mime: 'text/css' },
2506
+ markdown: { mode: 'markdown', mime: 'text/x-markdown' },
2507
+ json: { mode: 'javascript', mime: 'application/json' }
2508
+ }.freeze
2509
+
2510
+ # @param key [Symbol] State key for the editor content
2511
+ # @param language [Symbol] Syntax highlighting language (:ruby, :javascript, etc.)
2512
+ # @param readonly [Boolean] Whether the editor is read-only
2513
+ # @param height [String] CSS height value (default: "400px")
2514
+ # @param options [Hash] Additional options
2515
+ def initialize(key, language: :ruby, readonly: true, height: "400px", **options)
2516
+ @key = key
2517
+ @language = language.to_sym
2518
+ @readonly = readonly
2519
+ @height = height
2520
+ @options = options
2521
+ end
2522
+
2523
+ def language_config
2524
+ LANGUAGES[@language] || LANGUAGES[:ruby]
2525
+ end
2526
+
2527
+ def render(view, state)
2528
+ view.adapter.render_code_editor(view, self, state)
2529
+ end
2530
+ end
2531
+
2532
+ # =========================================
2533
+ # CSS-Only Helpers (T13)
2534
+ # Thin wrappers around divs with sw- CSS classes.
2535
+ # Per DHH: "a div with a CSS class is still a div."
2536
+ # =========================================
2537
+
2538
+ # Hero section -- large padded area with accent background tint
2539
+ class Hero < Base
2540
+ attr_accessor :children
2541
+
2542
+ def initialize(**options)
2543
+ @options = options
2544
+ @children = []
2545
+ end
2546
+
2547
+ def render(view, state)
2548
+ view.adapter.render_hero(view, self, state)
2549
+ end
2550
+ end
2551
+
2552
+ # Prose container -- reading-optimized text (max-width ~65ch, comfortable line-height)
2553
+ class Prose < Base
2554
+ attr_accessor :children
2555
+ attr_reader :dropcap
2556
+
2557
+ def initialize(dropcap: false, **options)
2558
+ @dropcap = dropcap
2559
+ @options = options
2560
+ @children = []
2561
+ end
2562
+
2563
+ def render(view, state)
2564
+ view.adapter.render_prose(view, self, state)
2565
+ end
2566
+ end
2567
+
2568
+ # Pullquote -- highlighted quotation with optional attribution
2569
+ class Pullquote < Base
2570
+ attr_reader :text, :attribution
2571
+
2572
+ def initialize(text, attribution: nil, **options)
2573
+ @text = text
2574
+ @attribution = attribution
2575
+ @options = options
2576
+ end
2577
+
2578
+ def render(view, state)
2579
+ view.adapter.render_pullquote(view, self, state)
2580
+ end
2581
+ end
2582
+
2583
+ # DirTree -- monospace file tree display with color-coded status
2584
+ # Status markers: [new] (green), [modified] (amber), [deleted] (red)
2585
+ class DirTree < Base
2586
+ attr_reader :tree_text
2587
+
2588
+ def initialize(tree_text, **options)
2589
+ @tree_text = tree_text
2590
+ @options = options
2591
+ end
2592
+
2593
+ # Parse lines and annotate with status
2594
+ def parsed_lines
2595
+ @tree_text.lines.map do |line|
2596
+ stripped = line.rstrip
2597
+ if stripped =~ /\[new\]\s*$/i
2598
+ { text: stripped.sub(/\s*\[new\]\s*$/i, ''), status: :new }
2599
+ elsif stripped =~ /\[modified\]\s*$/i
2600
+ { text: stripped.sub(/\s*\[modified\]\s*$/i, ''), status: :modified }
2601
+ elsif stripped =~ /\[deleted\]\s*$/i
2602
+ { text: stripped.sub(/\s*\[deleted\]\s*$/i, ''), status: :deleted }
2603
+ else
2604
+ { text: stripped, status: nil }
2605
+ end
2606
+ end
2607
+ end
2608
+
2609
+ def render(view, state)
2610
+ view.adapter.render_dir_tree(view, self, state)
2611
+ end
2612
+ end
2613
+
2614
+ # Legend -- horizontal row of color dots with labels
2615
+ class Legend < Base
2616
+ attr_reader :items
2617
+
2618
+ # @param items [Array<Hash>] Array of { color: "#hex", label: "text" }
2619
+ def initialize(items:, **options)
2620
+ @items = items
2621
+ @options = options
2622
+ end
2623
+
2624
+ def render(view, state)
2625
+ view.adapter.render_legend(view, self, state)
2626
+ end
2627
+ end
2628
+
2629
+ # FlowArrow -- vertical arrow connector between sections
2630
+ class FlowArrow < Base
2631
+ attr_reader :label
2632
+
2633
+ def initialize(label: nil, **options)
2634
+ @label = label
2635
+ @options = options
2636
+ end
2637
+
2638
+ def render(view, state)
2639
+ view.adapter.render_flow_arrow(view, self, state)
2640
+ end
2641
+ end
2642
+
2643
+ # LayoutToggle -- column count override buttons (1/2/3/4)
2644
+ class LayoutToggle < Base
2645
+ attr_reader :target, :columns
2646
+
2647
+ # @param target [String] CSS selector of the grid to control
2648
+ # @param columns [Array<Integer>] Available column counts (default: [1, 2, 3, 4])
2649
+ def initialize(target: ".sw-layout-target", columns: [1, 2, 3, 4], **options)
2650
+ @target = target
2651
+ @columns = columns
2652
+ @options = options
2653
+ end
2654
+
2655
+ def render(view, state)
2656
+ view.adapter.render_layout_toggle(view, self, state)
2657
+ end
2658
+ end
2659
+
2660
+ # CopyButton -- clipboard-copy trigger for a fixed text payload
2661
+ class CopyButton < Base
2662
+ attr_reader :label, :text, :copied_label, :options
2663
+
2664
+ # @param label [String] Button label before copy
2665
+ # @param text [String] Text to copy to the clipboard
2666
+ # @param copied_label [String] Label shown briefly after a successful copy
2667
+ def initialize(label = "Copy", text:, copied_label: "Copied!", **options)
2668
+ @label = label
2669
+ @text = text
2670
+ @copied_label = copied_label
2671
+ @options = options
2672
+ end
2673
+
2674
+ def render(view, state)
2675
+ view.adapter.render_copy_button(view, self, state)
2676
+ end
2677
+ end
2678
+ end
2679
+ end
2680
+
2681
+ # Load component files from components/ directory
2682
+ require_relative "components/code_block"
2683
+ require_relative "components/image_block"
2684
+ require_relative "components/mermaid"
2685
+ require_relative "components/keyboard_shortcuts"
2686
+ require_relative "components/slide_container"
2687
+ require_relative "components/sidebar_toc"
2688
+ require_relative "components/callout"
2689
+ require_relative "components/doc_header"
2690
+ require_relative "components/comparison"
2691
+ require_relative "components/implementation_map"
2692
+ require_relative "components/decision"
2693
+ require_relative "components/wireframe_block"
2694
+ require_relative "components/wireframe"
2695
+ require_relative "components/annotated_code"
2696
+ require_relative "components/diff_block"
2697
+ require_relative "components/api_endpoint"
2698
+ require_relative "components/pipeline"
2699
+ require_relative "components/kpi_dashboard"
2700
+ require_relative "components/chart"
2701
+ require_relative "components/timeline_event"
2702
+ require_relative "components/deck/design_deck"
2703
+ require_relative "components/deck/deck_slide"
2704
+ require_relative "components/deck/deck_option"
2705
+ require_relative "components/deck/deck_state"
2706
+ require_relative "components/deck/deck_summary"
2707
+ require_relative "components/deck/generate_more_controls"
2708
+ require_relative "components/deck/skeleton_placeholder"
2709
+ require_relative "components/deck/model_selector"
2710
+ require_relative "components/deck/confirmation_bar"
2711
+ require_relative "components/deck/close_overlay"