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,1821 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'set'
5
+ require_relative 'resource'
6
+
7
+ module StreamWeaver
8
+ # Main app class that holds the DSL block and manages the component tree
9
+ class App
10
+ include DisplayDSL
11
+
12
+ # Mutable state used only while evaluating one render tree. App-definition
13
+ # metadata intentionally remains on App so it survives fresh render states.
14
+ class RenderState
15
+ # Pass-scoped accumulators (button_counter, table_counter, seen_component_ids,
16
+ # url_tab_keys) must also be captured in App#rewind_point -- a discarded
17
+ # evaluation pass that skips one leaves ids/claims drifted on re-run.
18
+ attr_accessor :components, :layout_slots, :button_counter, :seen_component_ids,
19
+ :current_form, :form_context, :current_checkbox_group,
20
+ :current_tabs, :current_breadcrumbs, :current_dropdown,
21
+ :current_menu, :current_modal, :modal_context, :current_deck,
22
+ :current_slide, :current_app_shell, :current_row_key_thunk,
23
+ :checkbox_keys, :action_tokens, :generation, :fragment_stack, :state_version,
24
+ :table_counter, :current_scope, :url_tab_keys, :url_params, :deferred_target,
25
+ :form_for_active, :form_for_submit_label, :form_for_cancel_label, :form_for_validate
26
+
27
+ def initialize
28
+ self.components = []
29
+ self.layout_slots = {}
30
+ self.button_counter = 0
31
+ self.table_counter = 0
32
+ self.seen_component_ids = Hash.new(0)
33
+ self.checkbox_keys = []
34
+ self.action_tokens = Set.new
35
+ self.generation = "0"
36
+ self.fragment_stack = []
37
+ self.state_version = 0
38
+ self.url_tab_keys = []
39
+ end
40
+ end
41
+
42
+ # Built-in themes (custom themes checked via StreamWeaver.theme_exists?)
43
+ BUILT_IN_THEMES = [:default, :dashboard, :document, :dark, :doc].freeze
44
+ # For backwards compatibility
45
+ VALID_THEMES = BUILT_IN_THEMES
46
+
47
+ attr_reader :title, :block, :layout, :chrome, :theme, :theme_overrides, :scripts, :stylesheets, :inline_stylesheets, :fonts, :stream_block, :timers, :transient_keys, :favicon_value, :route_key, :routes, :route_rules, :resource_defs, :endpoints, :loading_indicators, :render_state, :actions, :action_updates, :action_primary
48
+
49
+ # HTTP verbs supported by the `endpoint` DSL (real Rack routes, not state routing)
50
+ ENDPOINT_VERBS = %i[get post put patch delete].freeze
51
+
52
+ # Request params owned by Sinatra/StreamWeaver routing rather than by the app.
53
+ # This is the single source of truth for that set: every param-to-state sync
54
+ # strips them (server.rb / service.rb #sync_params_to_state, and
55
+ # InteractionRunner::ROUTE_PARAMS, which adds its own dispatch-only params),
56
+ # and a `url: true` tabs group can never claim one -- the key would validate
57
+ # here and then silently never receive its value.
58
+ ROUTE_OWNED_PARAMS = %w[app_id splat captures button_id].freeze
59
+
60
+ # `deferred_target:` value meaning "run every deferred block inline". For
61
+ # renders with no client to fetch anything afterwards -- a static export.
62
+ # Never collides with a real target: fragment ids are all `sw-frag-`-prefixed.
63
+ ALL_DEFERRED = "all"
64
+
65
+ # Latch for the once-per-process lazy-tabs deprecation warning
66
+ # (see #warn_lazy_tabs_deprecated). Writable so specs can re-arm it.
67
+ class << self
68
+ attr_accessor :lazy_tabs_deprecation_warned
69
+ end
70
+
71
+ # Paths/prefixes owned by StreamWeaver's own framework routes (see server.rb / service.rb).
72
+ # An `endpoint` registered on one of these is never reached -- the internal route is always
73
+ # defined first, and Sinatra dispatches to the first matching route. We still warn at
74
+ # registration time so the collision is obvious instead of silently swallowed.
75
+ RESERVED_ENDPOINT_EXACT = %w[/update /submit].freeze
76
+ RESERVED_ENDPOINT_PREFIXES = %w[/action/ /event/ /form/ /theme/ /sw/].freeze
77
+
78
+ # Content-type by extension for local files served via the /sw-asset/
79
+ # route (App#local_asset, stylesheets: auto-detection). Shared with
80
+ # server.rb's /sw-asset/ route handler.
81
+ LOCAL_ASSET_MIME_TYPES = {
82
+ 'css' => 'text/css', 'js' => 'application/javascript', 'mjs' => 'application/javascript',
83
+ 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
84
+ 'gif' => 'image/gif', 'svg' => 'image/svg+xml', 'webp' => 'image/webp', 'ico' => 'image/x-icon'
85
+ }.freeze
86
+
87
+ # @param assets_dirs [Array<String>] Extra directories (besides the
88
+ # calling script's own directory) that local_asset/stylesheets: are
89
+ # allowed to serve local files from (stream_weaver-1lo).
90
+ def initialize(title, layout: :default, chrome: true, theme: :default, theme_overrides: {}, components: [], scripts: [], stylesheets: [], fonts: [], strict_ids: nil, loading_indicators: true, assets_dirs: [], &block)
91
+ @title = title
92
+ @layout = layout
93
+ @chrome = chrome
94
+ @theme = validate_theme(theme)
95
+ @theme_overrides = theme_overrides
96
+ @block = block
97
+ @render_state = RenderState.new
98
+ @render_mutex = Mutex.new
99
+ @state_key = :streamlit_state
100
+ @_state = {}
101
+ # nil means "unset" -- fall back to the global (StreamWeaver.strict_ids
102
+ # / SW_STRICT_IDS). An explicit true/false here always wins.
103
+ @strict_ids = strict_ids.nil? ? StreamWeaver.strict_ids? : strict_ids
104
+ @loading_indicators = loading_indicators
105
+ @_warned_duplicate_ids = Set.new
106
+ @scripts = scripts
107
+ @script_dir = File.dirname(File.expand_path(caller_locations(1, 1).first.path))
108
+ @allowed_asset_dirs = ([@script_dir] + assets_dirs.map { |d| File.expand_path(d) }).uniq
109
+ @stylesheets = stylesheets.map { |href| resolve_stylesheet_href(href) }
110
+ @inline_stylesheets = []
111
+ @fonts = fonts
112
+ @transient_keys = Set.new
113
+ @timers = []
114
+ @favicon_value = nil
115
+ @route_key = nil
116
+ @routes = nil
117
+ @routes_inverse = nil
118
+ @route_parser = nil
119
+ @route_builder = nil
120
+ @route_rules = [] # Array<RouteRule> — persistent, never cleared in rebuild
121
+ @resource_defs = {} # name(sym) → ResourceDefinition — persistent
122
+ @endpoints = [] # Array<{verb:, path:, block:}> — persistent, never cleared in rebuild
123
+ @actions = {}
124
+ @action_updates = {}
125
+ @action_primary = {}
126
+ @scope_registry = {} # name(sym) → {kind:, retain:} — persistent, never cleared in rebuild
127
+ components.each { |mod| singleton_class.include(mod) }
128
+ end
129
+
130
+ private
131
+
132
+ def validate_theme(theme)
133
+ theme = theme.to_sym
134
+ # Accept built-in themes or custom registered themes
135
+ return theme if BUILT_IN_THEMES.include?(theme) || StreamWeaver.theme_exists?(theme)
136
+ warn "StreamWeaver: Unknown theme '#{theme}', falling back to :default"
137
+ :default
138
+ end
139
+
140
+ public
141
+
142
+ def layout_entry
143
+ chrome ? LayoutRegistry[layout] : LayoutRegistry.chromeless
144
+ end
145
+
146
+ def state
147
+ @_state
148
+ end
149
+
150
+ def components
151
+ @render_state.components
152
+ end
153
+
154
+ def components=(value)
155
+ @render_state.components = value
156
+ end
157
+
158
+ def layout_slots
159
+ @render_state.layout_slots
160
+ end
161
+
162
+ def with_render_lock(&block)
163
+ @render_mutex.synchronize(&block)
164
+ end
165
+
166
+ # Declare URL routing: maps a state key's values to URL paths.
167
+ # Example: route_by :page, home: "/", about: "/about", settings: "/settings"
168
+ def route_by(state_key, paths)
169
+ @route_key = state_key
170
+ @routes = paths.transform_keys(&:to_sym)
171
+ @routes_inverse = @routes.invert
172
+ end
173
+
174
+ # Declare dynamic URL routing with custom path parser/builder lambdas.
175
+ # parser receives the request path and returns a partial state hash or nil.
176
+ # builder receives the current state and returns a path string or nil.
177
+ def route_with(parser:, builder: nil)
178
+ # Idempotent guard: don't re-register if same parser lambda already in chain
179
+ @route_rules << RouteRule.new(parser: parser, builder: builder) \
180
+ unless @route_rules.any? { |r| r.parser == parser }
181
+ end
182
+
183
+ def path_for_state(state)
184
+ @route_rules.each do |rule|
185
+ path = rule.builder&.call(state)
186
+ return path if path
187
+ end
188
+ return @route_builder.call(state) if @route_builder # legacy fallback (belt+suspenders)
189
+ return unless @routes
190
+ @routes[state[@route_key]&.to_sym]
191
+ end
192
+
193
+ def state_for_path(path)
194
+ @route_rules.each do |rule|
195
+ hash = rule.parser&.call(path)
196
+ return hash if hash
197
+ end
198
+ return unless @routes_inverse
199
+ val = @routes_inverse[path]
200
+ val ? { @route_key => val } : nil
201
+ end
202
+
203
+ def routable?
204
+ @route_rules.any? || @routes || @route_parser
205
+ end
206
+
207
+ def resource(name, store:, plural: nil, &block)
208
+ unless @resource_defs.key?(name.to_sym)
209
+ defn = ResourceDefinition.new(name, store, plural: plural)
210
+ defn.instance_eval(&block) if block
211
+ @resource_defs[name.to_sym] = defn
212
+ @route_rules << RouteRule.new(
213
+ parser: defn.method(:parse_path),
214
+ builder: defn.method(:build_path),
215
+ source: [:resource, name.to_sym]
216
+ )
217
+ define_path_helpers(defn)
218
+ end
219
+ @resource_defs[name.to_sym].render_if_active(self)
220
+ end
221
+
222
+ def page(name, path, &block)
223
+ key = name.to_sym
224
+ sk = Resource::StateKeys
225
+ unless @route_rules.any? { |r| r.source == [:page, key] }
226
+ @route_rules << RouteRule.new(
227
+ parser: ->(p) { p == path ? { sk::RESOURCE => nil, sk::ACTION => key } : nil },
228
+ builder: ->(st) { st[sk::ACTION] == key && st[sk::RESOURCE].nil? ? path : nil },
229
+ source: [:page, key]
230
+ )
231
+ end
232
+ if @_state[sk::ACTION] == key && @_state[sk::RESOURCE].nil?
233
+ evaluate_dsl_block(block)
234
+ end
235
+ end
236
+
237
+ def route(name, path)
238
+ page(name, path) {}
239
+ end
240
+
241
+ # Register a real HTTP endpoint (webhook receiver, JSON API, file download, etc.)
242
+ # — the "never rewrite in Sinatra" escape hatch. Unlike `route`/`page` (which are
243
+ # state-driven VIEW matchers), an `endpoint` is a genuine Rack route: it bypasses
244
+ # StreamWeaver's state machinery, session, and CSRF handling entirely. The block
245
+ # receives the raw `Rack::Request` and its return value is converted to a response:
246
+ #
247
+ # Hash -> 200 application/json (JSON.generate'd)
248
+ # String -> 200 text/html
249
+ # [status, headers, body] Array -> passed through to Rack verbatim
250
+ # anything else -> 200 text/plain (#to_s)
251
+ #
252
+ # @example
253
+ # endpoint :get, "/api/status" do |req|
254
+ # { ok: true, uptime: 42 }
255
+ # end
256
+ #
257
+ # endpoint :post, "/webhook/github" do |req|
258
+ # payload = req.body.read
259
+ # [202, {}, "queued"]
260
+ # end
261
+ #
262
+ # Idempotent: repeated registration of the same verb+path across `rebuild_with_state`
263
+ # calls (every request) keeps only the first block, mirroring `resource`/`route_with`.
264
+ def endpoint(verb, path, &block)
265
+ verb = verb.to_sym
266
+ unless ENDPOINT_VERBS.include?(verb)
267
+ raise ArgumentError, "endpoint: unsupported verb #{verb.inspect} (must be one of #{ENDPOINT_VERBS.join(', ')})"
268
+ end
269
+ raise ArgumentError, "endpoint: block required" unless block
270
+
271
+ return if @endpoints.any? { |e| e[:verb] == verb && e[:path] == path }
272
+
273
+ if reserved_endpoint_path?(path)
274
+ warn "StreamWeaver: endpoint #{verb.to_s.upcase} #{path} collides with a StreamWeaver-internal " \
275
+ "route and will never be reached — the internal route always wins."
276
+ end
277
+
278
+ @endpoints << { verb: verb, path: path, block: block }
279
+ end
280
+
281
+ # Look up a registered endpoint by verb + exact path. Used by SinatraApp/Service
282
+ # route dispatch at request time.
283
+ def find_endpoint(verb, path)
284
+ verb = verb.to_sym
285
+ @endpoints.find { |e| e[:verb] == verb && e[:path] == path }
286
+ end
287
+
288
+ # Points @_state at the given state object without running a full
289
+ # rebuild (#rebuild_with_state). Named-action dispatch skips the
290
+ # pre-dispatch discovery rebuild by design (see InteractionRunner
291
+ # #discovery_required?), so without this, app-level state helpers
292
+ # reached from inside the handler (flash, open_modal, close_modal,
293
+ # show_toast, dismiss_toast, clear_toasts -- anything that reads/writes
294
+ # @_state directly rather than taking state as an argument) would
295
+ # target the previous request's now-stale state object, and any writes
296
+ # made through them would be silently discarded once the post-dispatch
297
+ # rebuild repoints @_state at this request's state (stream_weaver-dwi).
298
+ def bind_dispatch_state(current_state)
299
+ @_state = current_state
300
+ end
301
+
302
+ # @param url_params [Hash, nil] params of the URL being rendered; nil when no
303
+ # URL is behind this render. See #tab_index_source for what reads it.
304
+ # @param deferred_target [String, nil] The fragment id this pass is rendering
305
+ # for. A `defer: true` fragment executes its block only when it is that
306
+ # fragment or one of its ancestors (see #fragment); every other pass
307
+ # renders its placeholder instead.
308
+ def rebuild_with_state(current_state, generation: "0", state_version: 0, url_params: nil, deferred_target: nil)
309
+ @_state = current_state
310
+ @render_state = RenderState.new
311
+ @render_state.generation = generation.to_s
312
+ @render_state.state_version = state_version.to_i
313
+ @render_state.url_params = url_params
314
+ @render_state.deferred_target = deferred_target&.to_s
315
+ apply_scope_lifecycle
316
+ flash_messages if chrome
317
+ evaluate_dsl_block(@block)
318
+ @render_state.checkbox_keys = collect_checkbox_keys(components)
319
+ @timers_frozen = true
320
+ end
321
+
322
+ # Returns [scope_name, key] pairs (scope_name nil for flat/form-context
323
+ # checkboxes) so InteractionRunner#handle_unchecked_checkboxes can clear
324
+ # an unchecked scope-nested "live" checkbox at state[scope_name][key]
325
+ # instead of a same-named flat key (FAC-P3.1 handoff).
326
+ def collect_checkbox_keys(component_list)
327
+ component_list.each_with_object([]) do |component, keys|
328
+ keys << [component.options[:scope_name], component.key] if component.is_a?(Components::Checkbox)
329
+ keys.concat(collect_checkbox_keys(component.children)) if component.respond_to?(:children) && component.children
330
+ if component.is_a?(Components::Modal) && component.footer_component&.children
331
+ keys.concat(collect_checkbox_keys(component.footer_component.children))
332
+ end
333
+ end
334
+ end
335
+ private :collect_checkbox_keys
336
+
337
+ # Capture DSL components for a named layout slot.
338
+ # Components in a slot are rendered outside #app-container (static chrome).
339
+ #
340
+ # @param name [Symbol] Slot identifier (:header, :sidebar_left, :footer, etc.)
341
+ def layout_slot(name, &block)
342
+ layout_slots[name] ||= []
343
+ parent_components = components
344
+ self.components = layout_slots[name]
345
+ evaluate_dsl_block(block)
346
+ self.components = parent_components
347
+ end
348
+
349
+ # Find a component by its key (for callback execution)
350
+ def find_component_by_key(key, components_list = components)
351
+ components_list.each do |component|
352
+ return component if component.respond_to?(:key) && component.key == key
353
+ # Search in children if component has them
354
+ if component.respond_to?(:children) && component.children
355
+ found = find_component_by_key(key, component.children)
356
+ return found if found
357
+ end
358
+ # Also search modal footer if present
359
+ if component.is_a?(Components::Modal) && component.footer_component&.children
360
+ found = find_component_by_key(key, component.footer_component.children)
361
+ return found if found
362
+ end
363
+ end
364
+ nil
365
+ end
366
+
367
+ def generate
368
+ rebuild_with_state(@_state) # pre-populate @route_rules before Sinatra starts
369
+ SinatraApp.create(self)
370
+ end
371
+
372
+ # Save and restore form DSL ivars around a block — used by ResourceDefinition
373
+ # when executing override blocks so they can't leave form state dirty.
374
+ def with_clean_form_context
375
+ saved_form = render_state.current_form
376
+ saved_context = render_state.form_context
377
+ yield
378
+ ensure
379
+ render_state.current_form = saved_form
380
+ render_state.form_context = saved_context
381
+ end
382
+
383
+ def has_charts?
384
+ components_include?(Components::ChartBase)
385
+ end
386
+
387
+ private
388
+
389
+ def components_include?(klass)
390
+ components.any? { |c| c.is_a?(klass) || nested_include?(c, klass) }
391
+ end
392
+
393
+ def nested_include?(component, klass)
394
+ return false unless component.respond_to?(:children) && component.children
395
+ component.children.any? { |c| c.is_a?(klass) || nested_include?(c, klass) }
396
+ end
397
+
398
+ public
399
+
400
+ def action(name, updates: nil, primary: nil, &handler)
401
+ raise ArgumentError, "action: block required" unless handler
402
+ name = name.to_sym
403
+ existing = @actions[name]
404
+ if existing && existing.source_location != handler.source_location
405
+ raise ArgumentError, "action #{name.inspect} is already registered with a different block"
406
+ end
407
+ @actions[name] ||= handler
408
+ normalized_updates = Array(updates).compact.map(&:to_sym)
409
+ if @action_updates.key?(name) && @action_updates[name] != normalized_updates
410
+ raise ArgumentError, "action #{name.inspect} is already registered with different updates"
411
+ end
412
+ @action_updates[name] ||= normalized_updates
413
+
414
+ normalized_primary = primary&.to_sym
415
+ if @action_primary.key?(name) && @action_primary[name] != normalized_primary
416
+ raise ArgumentError, "action #{name.inspect} is already registered with a different primary:"
417
+ end
418
+ @action_primary[name] = normalized_primary unless @action_primary.key?(name)
419
+ end
420
+
421
+ def action_definition_digest
422
+ Digest::SHA256.hexdigest(([StreamWeaver::VERSION] + actions.keys.map(&:to_s).sort).join("\0"))
423
+ end
424
+
425
+ # @param primary [Symbol, String, nil] Names a sibling fragment to swap as
426
+ # this response's primary target instead of the button's own enclosing
427
+ # fragment (stream_weaver-78a) -- e.g. a board's "View" button
428
+ # refreshing a detail pane without resending the whole board. Falls
429
+ # back to the action-level default (registered via `action(...,
430
+ # primary:)`) the same way `updates:` does; resolved against the
431
+ # post-rebuild tree by InteractionRunner, so a name that doesn't match
432
+ # any fragment there falls back to a full-container response rather
433
+ # than silently doing nothing.
434
+ def action_token(name, key, fragment: nil, updates: nil, primary: nil)
435
+ name = name.to_sym
436
+ raise ArgumentError, "unknown named action #{name.inspect}" unless actions.key?(name)
437
+ scopes = Array(updates.nil? ? action_updates[name] : updates).compact.map(&:to_s)
438
+ primary_target = primary.nil? ? action_primary[name] : primary
439
+ payload = { a: name, k: key, d: action_definition_digest, g: render_state.generation }
440
+ payload[:f] = fragment if fragment
441
+ payload[:u] = scopes unless scopes.empty?
442
+ payload[:p] = primary_target.to_s if primary_target
443
+ token = ActionToken.encode(payload)
444
+ render_state.action_tokens << ActionToken.fingerprint(token)
445
+ token
446
+ end
447
+
448
+ # =========================================
449
+ # App-specific display components
450
+ # =========================================
451
+
452
+ # @param defer [Boolean] Render the shell now and fetch this fragment's
453
+ # content afterwards -- the Turbo `turbo_frame_tag ..., src:` equivalent.
454
+ # The block does not run on the initial render at all, so a slow region
455
+ # cannot delay the page.
456
+ # @param lazy [Boolean] Hold the deferred fetch until the fragment is
457
+ # visible -- Turbo's `loading="lazy"`. Implies `defer:`, the way Turbo's
458
+ # `loading` only means anything on a frame that loads from `src`.
459
+ # @param placeholder [String, Proc, nil] What stands in until the content
460
+ # lands. A String renders as text, a Proc is evaluated as DSL, nil gets a
461
+ # small spinner. Only read when `defer:` is set.
462
+ def fragment(name, defer: false, lazy: false, placeholder: nil, &block)
463
+ defer ||= lazy
464
+ name = validate_scalar_key!(name, context: "fragment")
465
+ raise ArgumentError, "fragment: block required" unless block
466
+ raise ArgumentError, "fragment: placeholder: requires defer: true" if placeholder && !defer
467
+
468
+ safe_name = name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|\-\z/, "")
469
+ raise ArgumentError, "fragment: name must contain a letter or number" if safe_name.empty?
470
+ candidate_id = if render_state.fragment_stack.empty?
471
+ "sw-frag-#{safe_name}"
472
+ else
473
+ "#{render_state.fragment_stack.last}--#{safe_name}"
474
+ end
475
+ id = disambiguate_component_id(candidate_id, label: name.to_s, source_loc: block.source_location)
476
+ deferred = defer && !materializes_deferred?(id)
477
+ component = Components::Fragment.new(name, id, deferred: deferred, lazy: lazy)
478
+ components << component
479
+ parent = components
480
+ render_state.fragment_stack << id
481
+ self.components = []
482
+ evaluate_dsl_block(deferred ? placeholder_block(placeholder) : block)
483
+ component.children = components
484
+ self.components = parent
485
+ component
486
+ ensure
487
+ render_state.fragment_stack.pop if id && render_state.fragment_stack.last == id
488
+ end
489
+
490
+ # True when this pass is the fetch that materializes fragment `id`, or the
491
+ # fetch of a fragment nested inside it. Nested fragment ids are literally
492
+ # `parent--child` (see #fragment), so an ancestor is a prefix of its
493
+ # descendants -- which is what lets a deferred fragment nested inside
494
+ # another deferred fragment be reached by a second fetch.
495
+ def materializes_deferred?(id)
496
+ target = render_state.deferred_target
497
+ return false unless target
498
+ return true if target == ALL_DEFERRED
499
+
500
+ target == id || target.start_with?("#{id}--")
501
+ end
502
+ private :materializes_deferred?
503
+
504
+ def placeholder_block(placeholder)
505
+ case placeholder
506
+ when Proc then placeholder
507
+ when nil then proc { spinner(size: :sm, label: "Loading…") }
508
+ else proc { text placeholder.to_s }
509
+ end
510
+ end
511
+ private :placeholder_block
512
+
513
+ def lesson_text(content_or_options = nil, **options, &block)
514
+ if content_or_options.is_a?(String)
515
+ glossary = options[:glossary] || {}
516
+ lesson_component = Components::LessonText.new(glossary: glossary)
517
+ components << lesson_component
518
+ lesson_component.children = parse_lesson_string(content_or_options, glossary)
519
+ else
520
+ opts = content_or_options.is_a?(Hash) ? content_or_options.merge(options) : options
521
+ glossary = opts[:glossary] || {}
522
+ with_container(Components::LessonText.new(glossary: glossary), &block)
523
+ end
524
+ end
525
+
526
+ def term(term_key, **options)
527
+ components << Components::Term.new(term_key, **options)
528
+ end
529
+
530
+ def checkbox_group(key, **options, &block)
531
+ @_state[key] = options[:default] || [] unless @_state.key?(key)
532
+
533
+ group_component = Components::CheckboxGroup.new(key, **options)
534
+ components << group_component
535
+
536
+ parent_components = components
537
+ render_state.current_checkbox_group = group_component
538
+ self.components = []
539
+
540
+ evaluate_dsl_block(block)
541
+
542
+ group_component.children = components
543
+ self.components = parent_components
544
+ render_state.current_checkbox_group = nil
545
+ end
546
+
547
+ def item(value, &block)
548
+ item_component = Components::CheckboxItem.new(value)
549
+ capture_children_then_append(item_component, &block)
550
+ end
551
+
552
+ # =========================================
553
+ # State scopes (namespacing + lifetime)
554
+ # =========================================
555
+
556
+ # Declare a named, kind-tagged, lifetime-managed sub-hash of state.
557
+ # `form(name) { }` is sugar over `scope(name, kind: :form) { }` -- same
558
+ # storage shape (`state[name] = { field => value, ... }`), now nameable,
559
+ # kind-tagged, and (unless retain: true) auto-reset when its owning
560
+ # routing discriminant changes between rebuilds (see #apply_scope_lifecycle).
561
+ #
562
+ # @param name [Symbol] scope name -- becomes the top-level state key
563
+ # @param kind [Symbol] one of :form, :resource, :fragment, :app
564
+ # @param retain [Boolean] opt out of auto-reset (default false)
565
+ # @yield [Hash] the scope's sub-hash, for direct reads/writes; the block
566
+ # also runs in App's DSL context, so nested field/component calls work
567
+ def scope(name, kind:, retain: false, &block)
568
+ name = name.to_sym
569
+ register_scope(name, kind: kind, retain: retain)
570
+ @_state[name] ||= {}
571
+ return @_state[name] unless block
572
+
573
+ saved_scope = render_state.current_scope
574
+ render_state.current_scope = name
575
+ evaluate_dsl_block(block, @_state[name])
576
+ render_state.current_scope = saved_scope
577
+ @_state[name]
578
+ end
579
+
580
+ # Names of every scope registered so far (via `scope` or `form`) --
581
+ # used by SessionStore to know which top-level hash values are safe to
582
+ # recurse one level into for blank-stripping (FAC-P3.1 decision §7).
583
+ def scope_names
584
+ @scope_registry.keys
585
+ end
586
+
587
+ # =========================================
588
+ # Form container with special context
589
+ # =========================================
590
+
591
+ def form(name, **options, &block)
592
+ name = name.to_sym
593
+ scope(name, kind: :form)
594
+ form_component = Components::Form.new(name, **options)
595
+ components << form_component
596
+
597
+ parent_components = components
598
+ render_state.current_form = form_component
599
+ render_state.form_context = { name: name }
600
+ self.components = []
601
+
602
+ evaluate_dsl_block(block)
603
+
604
+ form_component.children = components
605
+ self.components = parent_components
606
+ render_state.current_form = nil
607
+ render_state.form_context = nil
608
+ end
609
+
610
+ def submit(label, &block)
611
+ raise "submit can only be used inside a form block" unless render_state.current_form
612
+ render_state.current_form.set_submit(label, &block)
613
+ end
614
+
615
+ def cancel(label)
616
+ raise "cancel can only be used inside a form block" unless render_state.current_form
617
+ render_state.current_form.set_cancel(label)
618
+ end
619
+
620
+ def form_context
621
+ render_state.form_context
622
+ end
623
+
624
+ # =========================================
625
+ # form_for -- resource-bound form (builds on `form`, FAC-P3.2)
626
+ # =========================================
627
+
628
+ # Generates a full create/update form bound to a resource's store + fields
629
+ # (form-for.md): seeds from `record:` only on first render for that record
630
+ # id and never re-seeds an in-progress edit (reuses the :resource-kind
631
+ # scope auto-reset from `state-scopes.md` §4/§7 -- no hand-rolled seeded_key
632
+ # guard needed), infers create vs. update from record identity, renders
633
+ # fields via the shared Resource::FieldInput table, and wires submit to
634
+ # coerce -> validate -> store.create/update -> flash + PRG transition.
635
+ #
636
+ # @param resource_name [Symbol, nil] a registered `resource` name to reuse
637
+ # its store/fields, or nil when passing store:/fields: directly
638
+ # @param record [Hash, nil] the record being edited; nil/no :id means create
639
+ # @param store [#all,#find,#create,#update,#destroy] required unless resource_name given
640
+ # @param fields [Array<Field>] required unless resource_name given
641
+ # @param name [Symbol, nil] scope/form name override (default "#{singular}_form")
642
+ # @param on_success [Proc, nil] instance_exec'd with the new/updated id;
643
+ # overrides the resource's default show/index transition
644
+ # @param validate [Proc, nil] called with the coerced values, returns
645
+ # Hash[field, Array[String]] of extra validation errors (empty/nil = valid)
646
+ def form_for(resource_name = nil, record: nil, store: nil, fields: nil, name: nil, on_success: nil, validate: nil, &block)
647
+ defn = resource_name && @resource_defs[resource_name.to_sym]
648
+ raise ArgumentError, "form_for: unknown resource #{resource_name.inspect}" if resource_name && !defn
649
+
650
+ store ||= defn&.store
651
+ fields ||= defn&.fields
652
+ raise ArgumentError, "form_for: no store given and no resource #{resource_name.inspect} registered" unless store
653
+ raise ArgumentError, "form_for: no fields given and no resource #{resource_name.inspect} registered" unless fields
654
+ Resource::Store.validate!(store, resource_name || name || :form_for) unless defn
655
+
656
+ singular = resource_name&.to_s || name&.to_s&.sub(/_form\z/, '')
657
+ singular = "record" if singular.nil? || singular.empty?
658
+ scope_name = (name || :"#{singular}_form").to_sym
659
+ errors_key = :"#{scope_name}_errors"
660
+ is_update = !record.nil? && !record[:id].nil?
661
+
662
+ scope(scope_name, kind: :resource) do |s|
663
+ fields.each { |f| s[f.name] = record[f.name] unless s.key?(f.name) } if record
664
+ end
665
+
666
+ form(scope_name) do
667
+ if (errs = state[errors_key]) && !errs.empty?
668
+ alert(variant: :error, title: "Please fix the following") do
669
+ errs.each { |field, msgs| Array(msgs).each { |m| text "#{field.to_s.tr('_', ' ').capitalize}: #{m}" } }
670
+ end
671
+ end
672
+
673
+ fields.each { |f| instance_exec(f, &Resource::FieldInput::RENDER) }
674
+
675
+ render_state.form_for_submit_label = is_update ? "Save" : "Create"
676
+ render_state.form_for_cancel_label = nil
677
+ render_state.form_for_validate = validate
678
+ render_state.form_for_active = true
679
+ instance_exec(&block) if block
680
+ render_state.form_for_active = false
681
+
682
+ submit(render_state.form_for_submit_label) do |raw_values|
683
+ form_for_submit(fields: fields, raw_values: raw_values, store: store, record: record,
684
+ is_update: is_update, errors_key: errors_key, defn: defn,
685
+ on_success: on_success, validate_proc: render_state.form_for_validate,
686
+ singular: singular)
687
+ end
688
+ cancel(render_state.form_for_cancel_label) if render_state.form_for_cancel_label
689
+ end
690
+ end
691
+
692
+ # Configures the auto-generated form's submit button label -- only valid
693
+ # inside a `form_for` block (dual-purpose reader/setter, like `edit_view`).
694
+ def submit_label(text = nil)
695
+ raise "submit_label can only be used inside a form_for block" unless render_state.form_for_active
696
+ text.nil? ? render_state.form_for_submit_label : (render_state.form_for_submit_label = text)
697
+ end
698
+
699
+ # Configures the auto-generated form's cancel button label (omitted by
700
+ # default) -- only valid inside a `form_for` block.
701
+ def cancel_label(text = nil)
702
+ raise "cancel_label can only be used inside a form_for block" unless render_state.form_for_active
703
+ text.nil? ? render_state.form_for_cancel_label : (render_state.form_for_cancel_label = text)
704
+ end
705
+
706
+ # Registers an extra validation hook for the enclosing `form_for`: called
707
+ # with the coerced submitted values, returns Hash[field, Array[String]] of
708
+ # additional errors (merged with coercion failures) -- only valid inside a
709
+ # `form_for` block.
710
+ def validate(&block)
711
+ raise "validate can only be used inside a form_for block" unless render_state.form_for_active
712
+ render_state.form_for_validate = block
713
+ end
714
+
715
+ # =========================================
716
+ # Form input components
717
+ # =========================================
718
+
719
+ def text_field(key, **options)
720
+ @transient_keys << key if options.delete(:transient)
721
+ initialize_form_state(key, options, options[:default] || "")
722
+ components << Components::TextField.new(key, **options)
723
+ end
724
+
725
+ def text_area(key, **options)
726
+ @transient_keys << key if options.delete(:transient)
727
+ initialize_form_state(key, options, options[:default] || "")
728
+ components << Components::TextArea.new(key, **options)
729
+ end
730
+
731
+ # Native <input type=date>, state-bound like text_field. Value is stored
732
+ # as an ISO 8601 string ("YYYY-MM-DD"); use Components::DateField.to_date
733
+ # to coerce it to a Date (03 gap #4: rivet hand-parsed Date from a raw
734
+ # text_field because date_field didn't exist).
735
+ def date_field(key, **options)
736
+ @transient_keys << key if options.delete(:transient)
737
+ initialize_form_state(key, options, options[:default] || "")
738
+ components << Components::DateField.new(key, **options)
739
+ end
740
+
741
+ def code_editor(key, language: :ruby, readonly: true, height: "400px", **options)
742
+ initialize_form_state(key, options, options[:default] || "")
743
+ components << Components::CodeEditor.new(key, language: language, readonly: readonly, height: height, **options)
744
+ end
745
+
746
+ def checkbox(key, label, **options)
747
+ initialize_form_state(key, options, false)
748
+ components << Components::Checkbox.new(key, label, **options)
749
+ end
750
+
751
+ def select(key, choices, **options)
752
+ initialize_form_state(key, options, options[:default] || "", skip_if_exists: true)
753
+ components << Components::Select.new(key, choices, **options)
754
+ end
755
+
756
+ def radio_group(key, choices, **options)
757
+ initialize_form_state(key, options, "")
758
+ components << Components::RadioGroup.new(key, choices, **options)
759
+ end
760
+
761
+ def tag_buttons(key, tags, **options)
762
+ @_state[key] ||= nil
763
+ components << Components::TagButtons.new(key, tags, **options)
764
+ end
765
+
766
+ # Tag/chip multi-select bound to a state array (03 gap #8: rivet's
767
+ # "* "-prefixed toggle buttons faking a checked state). multi: false
768
+ # binds a single scalar value (radio-style) instead of an array.
769
+ # Works inside form/scope like text_field/select.
770
+ def chip_group(key, choices = [], multi: true, **options)
771
+ initialize_form_state(key, options, options[:default] || (multi ? [] : nil))
772
+ components << Components::ChipGroup.new(key, choices, multi: multi, **options)
773
+ end
774
+
775
+ # =========================================
776
+ # Interactive components
777
+ # =========================================
778
+
779
+ def button(label, action: nil, key: nil, id: nil, **options, &block)
780
+ raise ArgumentError, "button cannot use both action: and a block" if action && block
781
+ # Identity precedence: id: (verbatim override) > key: (content-stable
782
+ # derivation) > auto-derivation from label + block source_location.
783
+ explicit_id = validate_scalar_key!(id, context: "button")
784
+ key = validate_scalar_key!(key, context: "button")
785
+ if action
786
+ row_key = render_state.current_row_key_thunk&.value
787
+ action_key = key || explicit_id || row_key
788
+ action_key = validate_scalar_key!(action_key, context: "named action button")
789
+ raise ArgumentError, "named action button: key: is required" if action_key.nil?
790
+ options[:action_token] = action_token(
791
+ action, action_key,
792
+ fragment: render_state.fragment_stack.last,
793
+ updates: options.delete(:updates),
794
+ primary: options.delete(:primary)
795
+ )
796
+ end
797
+ # Generate stable ID: an explicit id: is used verbatim; otherwise use
798
+ # source location for buttons with blocks, falling back to a counter for
799
+ # blockless buttons (submit: false).
800
+ # If key: is provided, mix it in to disambiguate buttons in loops
801
+ # (order-independent -- unlike a positional index, reordering the
802
+ # collection doesn't change a keyed button's id). Inside a table's
803
+ # component cell, the row's key is auto-mixed in too (FAC-P2.1
804
+ # decision 3), on top of any explicit key: also passed.
805
+ if explicit_id
806
+ stable_id = sanitize_explicit_id(explicit_id)
807
+ elsif block || action
808
+ row_key = render_state.current_row_key_thunk&.value
809
+ combined_key = [row_key, key].compact
810
+ source_loc = block ? block.source_location.join(':') : "action:#{action}"
811
+ id_input = combined_key.any? ? "#{label}:#{combined_key.join(':')}" : "#{label}:#{source_loc}"
812
+ stable_id = Digest::MD5.hexdigest(id_input)[0..7]
813
+ else
814
+ render_state.button_counter += 1
815
+ stable_id = render_state.button_counter.to_s
816
+ end
817
+ # Pass modal context to button so it can close the modal via Alpine
818
+ options[:modal_context] = render_state.modal_context if render_state.modal_context
819
+ btn = Components::Button.new(label, stable_id, **options, &block)
820
+ btn.id = disambiguate_component_id(btn.id, label: label, source_loc: block&.source_location)
821
+ components << btn
822
+ end
823
+
824
+ # Wraps arbitrary composed content (any DSL components) as a single
825
+ # click target -- the click-target equivalent of what card/div already
826
+ # do for layout, so a whole card/row can be one dispatch target instead
827
+ # of a trailing "View" button glued to the bottom.
828
+ #
829
+ # clickable(action: :select, key: story[:id]) { header4 story[:slug]; badge story[:state] }
830
+ # clickable(href: "/stories/#{story[:id]}") { header4 story[:slug] }
831
+ #
832
+ # action: dispatches exactly like a named-action button (same token,
833
+ # fragment context, and current-row key: inside a table cell); href:
834
+ # renders a plain navigation <a> for routed pages. Mutually exclusive.
835
+ def clickable(action: nil, href: nil, key: nil, **options, &block)
836
+ if action.nil? == href.nil?
837
+ raise ArgumentError, "clickable requires exactly one of action: or href:"
838
+ end
839
+
840
+ if href
841
+ component = Components::Clickable.new(href: href, **options)
842
+ return with_container(component, &block)
843
+ end
844
+
845
+ require 'digest/md5'
846
+ row_key = render_state.current_row_key_thunk&.value
847
+ action_key = key || row_key
848
+ action_key = validate_scalar_key!(action_key, context: "clickable")
849
+ raise ArgumentError, "clickable action: key: is required" if action_key.nil?
850
+ options[:action_token] = action_token(
851
+ action, action_key,
852
+ fragment: render_state.fragment_stack.last,
853
+ updates: options.delete(:updates),
854
+ primary: options.delete(:primary)
855
+ )
856
+
857
+ combined_key = [row_key, key].compact
858
+ id_input = combined_key.any? ? "clickable:#{action}:#{combined_key.join(':')}" : "clickable:#{action}:#{block&.source_location&.join(':')}"
859
+ stable_id = Digest::MD5.hexdigest(id_input)[0..7]
860
+ wrapper_id = "clickable_#{action}_#{stable_id}"
861
+
862
+ component = Components::Clickable.new(wrapper_id: wrapper_id, **options)
863
+ component.id = disambiguate_component_id(component.id, label: "clickable_#{action}", source_loc: block&.source_location)
864
+ with_container(component, &block)
865
+ end
866
+
867
+ # =========================================
868
+ # Chart DSL methods
869
+ # =========================================
870
+
871
+ def bar_chart(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
872
+ components << Components::BarChart.new(
873
+ data: data, file: file, path: path, labels: labels, values: values, **options, &block
874
+ )
875
+ end
876
+
877
+ def hbar_chart(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
878
+ bar_chart(data: data, file: file, path: path, labels: labels, values: values, horizontal: true, **options, &block)
879
+ end
880
+
881
+ def line_chart(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
882
+ components << Components::LineChart.new(
883
+ data: data, file: file, path: path, labels: labels, values: values, **options, &block
884
+ )
885
+ end
886
+
887
+ def sparkline(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
888
+ line_chart(data: data, file: file, path: path, labels: labels, values: values, sparkline: true, **options, &block)
889
+ end
890
+
891
+ def area_chart(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
892
+ line_chart(data: data, file: file, path: path, labels: labels, values: values, fill: true, **options, &block)
893
+ end
894
+
895
+ def pie_chart(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
896
+ components << Components::PieChart.new(
897
+ data: data, file: file, path: path, labels: labels, values: values, **options, &block
898
+ )
899
+ end
900
+
901
+ def doughnut_chart(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
902
+ pie_chart(data: data, file: file, path: path, labels: labels, values: values, doughnut: true, **options, &block)
903
+ end
904
+
905
+ def stacked_bar_chart(data: nil, file: nil, path: nil, **options, &block)
906
+ components << Components::StackedBarChart.new(data: data, file: file, path: path, **options, &block)
907
+ end
908
+
909
+ # =========================================
910
+ # Navigation DSL methods
911
+ # =========================================
912
+
913
+ def tabs(key, variant: :line, url: false, **options, &block)
914
+ tabs_component = Components::Tabs.new(key, variant: variant, url: url, **options)
915
+ claim_url_tab_key!(tabs_component) if url
916
+ # `url: true` with lazy has already raised above, so reaching here with
917
+ # lazy set means the deprecated POST-morph mode.
918
+ warn_lazy_tabs_deprecated(key) if tabs_component.lazy
919
+
920
+ # Coerce before the block: `tab` reads this index mid-evaluation to decide
921
+ # which lazy panels to evaluate, so it has to be a usable Integer by then.
922
+ @_state[key] = coerce_tab_index(tab_index_source(key, url: url))
923
+
924
+ components << tabs_component
925
+
926
+ parent_components = components
927
+ render_state.current_tabs = tabs_component
928
+ self.components = []
929
+ rewind_render_state = rewind_point if tabs_component.lazy
930
+
931
+ evaluate_dsl_block(block)
932
+
933
+ # Clamp after the block: the tab count doesn't exist until the nested
934
+ # `tab` calls have appended, and an index past the end matches no panel.
935
+ requested = @_state[key]
936
+ @_state[key] = 0 unless requested.between?(0, components.size - 1)
937
+
938
+ if rewind_render_state && @_state[key] != requested
939
+ # Lazy tabs pick which blocks to evaluate while the block is still
940
+ # running, so an out-of-range index skipped every panel and left the
941
+ # group empty. The corrected index needs a second pass -- and the first
942
+ # pass is discarded, so its render_state marks are rewound with it.
943
+ self.components = []
944
+ rewind_render_state.call
945
+ evaluate_dsl_block(block)
946
+ end
947
+
948
+ tabs_component.children = components
949
+ self.components = parent_components
950
+ render_state.current_tabs = nil
951
+ end
952
+
953
+ # A tab index reaches state from a URL param, a stale session, or a tab
954
+ # group that shrank under it, so it can arrive as anything at all. Values
955
+ # with no sensible integer reading collapse to the first tab; integer
956
+ # strings are kept, since `url: true` groups read their index from params.
957
+ def coerce_tab_index(value)
958
+ case value
959
+ when Integer then value
960
+ # \A\d+\z + base-10, matching sw-route-tabs.js exactly -- Integer() would
961
+ # read a leading zero as octal and disagree with the client on ?view=010.
962
+ when String then value.match?(/\A\d+\z/) ? value.to_i : 0
963
+ else 0
964
+ end
965
+ end
966
+ private :coerce_tab_index
967
+
968
+ # Only the URL decides a `url: true` group's index. State cannot serve even
969
+ # as a fallback: the param has already been synced into it, so a stale index
970
+ # is indistinguishable from a fresh one -- and an absent param means tab 0
971
+ # as surely as `?view=0` does. Renders with no URL behind them (a POST
972
+ # morph, a canvas push, an export) keep the index they were handed.
973
+ def tab_index_source(key, url:)
974
+ url_params = render_state.url_params
975
+ return @_state[key] unless url && url_params
976
+
977
+ url_params[key.to_s]
978
+ end
979
+ private :tab_index_source
980
+
981
+ # Captures the render_state accumulators that an evaluation pass writes to,
982
+ # returning a callable that puts them back. Used where a pass is thrown away
983
+ # and re-run (see #tabs): re-running without rewinding double-counts, and
984
+ # ids that shift between renders break dispatch for exactly the requests the
985
+ # re-run exists to rescue. Mirrors #with_clean_form_context, but the rewind
986
+ # is deliberately explicit -- the caller only knows it needs one after the
987
+ # block has already run.
988
+ def rewind_point
989
+ button_counter = render_state.button_counter
990
+ table_counter = render_state.table_counter
991
+ seen_component_ids = render_state.seen_component_ids.dup
992
+ url_tab_keys = render_state.url_tab_keys.dup
993
+
994
+ lambda do
995
+ render_state.button_counter = button_counter
996
+ render_state.table_counter = table_counter
997
+ render_state.seen_component_ids = seen_component_ids
998
+ render_state.url_tab_keys = url_tab_keys
999
+ end
1000
+ end
1001
+ private :rewind_point
1002
+
1003
+ def tab(label, **options, &block)
1004
+ tab_component = Components::Tab.new(label, **options)
1005
+
1006
+ # Lazy tabs: skip EVALUATING inactive tab blocks entirely, not just
1007
+ # rendering them. The block is where apps load data, so lazy that only
1008
+ # skipped the render layer (adapter) still paid the full data cost for
1009
+ # every hidden tab. The adapter already renders inactive lazy panels as
1010
+ # a placeholder comment, so an empty-children Tab is consistent; tab
1011
+ # switches post the new index and the full morph re-render evaluates
1012
+ # the newly-active block.
1013
+ current = render_state.current_tabs
1014
+ if current&.lazy
1015
+ index = components.size
1016
+ active = @_state[current.key]
1017
+ if index != active
1018
+ components << tab_component
1019
+ return tab_component
1020
+ end
1021
+ end
1022
+
1023
+ capture_children_then_append(tab_component, &block)
1024
+ end
1025
+
1026
+ # Lazy tabs fetch the newly-active panel with an `hx-post` morph, which a
1027
+ # canvas page has no route for -- inactive panels there render as a
1028
+ # placeholder comment and can never receive content (beads
1029
+ # stream_weaver-pkh). Route tabs are the direction this goes instead, and a
1030
+ # lazy route-tab mode is meant to replace this one; until then the mode
1031
+ # still works exactly as before and only says so.
1032
+ #
1033
+ # Once per process, not once per declaration: a lazy app re-evaluates its
1034
+ # whole DSL on every interaction, so a per-render warning would bury the
1035
+ # logs of the busiest apps -- the ones most likely to be using lazy.
1036
+ def warn_lazy_tabs_deprecated(key)
1037
+ return if self.class.lazy_tabs_deprecation_warned
1038
+
1039
+ self.class.lazy_tabs_deprecation_warned = true
1040
+ warn "StreamWeaver: tabs #{key.inspect} -- lazy: true is deprecated. Switching a lazy tab " \
1041
+ "round-trips to the server, which canvas cannot serve (stream_weaver-pkh). Prefer route " \
1042
+ "tabs (tabs #{key.inspect}, url: true); lazy route tabs will replace this mode. " \
1043
+ "Warned once per process."
1044
+ end
1045
+ private :warn_lazy_tabs_deprecated
1046
+
1047
+ # Validates and records a `url: true` tabs key. Keys live on render_state so
1048
+ # tracking resets with every DSL evaluation -- the same app re-rendering per
1049
+ # request must not look like a duplicate declaration of itself.
1050
+ def claim_url_tab_key!(group)
1051
+ key = group.key
1052
+ name = key.to_s
1053
+
1054
+ raise ArgumentError, "tabs #{key.inspect}: url: true does not support lazy: true yet" if group.lazy
1055
+ raise ArgumentError, "tabs #{key.inspect}: reserved request param -- pick another key for url: true tabs" if ROUTE_OWNED_PARAMS.include?(name)
1056
+ raise ArgumentError, "tabs #{key.inspect}: already claimed by another url: true tabs group" if render_state.url_tab_keys.include?(name)
1057
+
1058
+ render_state.url_tab_keys << name
1059
+ end
1060
+ private :claim_url_tab_key!
1061
+
1062
+ def breadcrumbs(separator: "/", **options, &block)
1063
+ breadcrumbs_component = Components::Breadcrumbs.new(separator: separator, **options)
1064
+ components << breadcrumbs_component
1065
+
1066
+ parent_components = components
1067
+ render_state.current_breadcrumbs = breadcrumbs_component
1068
+ self.components = []
1069
+
1070
+ evaluate_dsl_block(block)
1071
+
1072
+ breadcrumbs_component.children = components
1073
+ self.components = parent_components
1074
+ render_state.current_breadcrumbs = nil
1075
+ end
1076
+
1077
+ def crumb(label, href: nil, **options)
1078
+ components << Components::Crumb.new(label, href: href, **options)
1079
+ end
1080
+
1081
+ def dropdown(**options, &block)
1082
+ dropdown_component = Components::Dropdown.new(**options)
1083
+ components << dropdown_component
1084
+
1085
+ render_state.current_dropdown = dropdown_component
1086
+ evaluate_dsl_block(block)
1087
+ render_state.current_dropdown = nil
1088
+ end
1089
+
1090
+ def trigger(&block)
1091
+ raise "trigger can only be used inside a dropdown block" unless render_state.current_dropdown
1092
+
1093
+ trigger_component = Components::DropdownTrigger.new
1094
+ parent_components = components
1095
+ self.components = []
1096
+
1097
+ evaluate_dsl_block(block)
1098
+
1099
+ trigger_component.children = components
1100
+ self.components = parent_components
1101
+ render_state.current_dropdown.trigger_component = trigger_component
1102
+ end
1103
+
1104
+ def menu(**options, &block)
1105
+ raise "menu can only be used inside a dropdown block" unless render_state.current_dropdown
1106
+
1107
+ menu_component = Components::Menu.new(**options)
1108
+ parent_components = components
1109
+ render_state.current_menu = menu_component
1110
+ self.components = []
1111
+
1112
+ evaluate_dsl_block(block)
1113
+
1114
+ menu_component.children = components
1115
+ self.components = parent_components
1116
+ render_state.current_dropdown.menu_component = menu_component
1117
+ render_state.current_menu = nil
1118
+ end
1119
+
1120
+ def menu_item(label, style: :default, **options, &block)
1121
+ raise "menu_item can only be used inside a menu block" unless render_state.current_menu
1122
+ render_state.button_counter += 1
1123
+ item = Components::MenuItem.new(label, style: style, **options, &block)
1124
+ item.instance_variable_set(:@id, "menu_item_#{render_state.button_counter}")
1125
+ components << item
1126
+ end
1127
+
1128
+ def menu_divider
1129
+ raise "menu_divider can only be used inside a menu block" unless render_state.current_menu
1130
+ components << Components::MenuDivider.new
1131
+ end
1132
+
1133
+ # =========================================
1134
+ # Modal DSL methods
1135
+ # =========================================
1136
+
1137
+ def modal(key, title: nil, size: :md, **options, &block)
1138
+ open_key = :"#{key}_open"
1139
+ @_state[open_key] = false unless @_state.key?(open_key)
1140
+
1141
+ modal_component = Components::Modal.new(key, title: title, size: size, **options)
1142
+ components << modal_component
1143
+
1144
+ parent_components = components
1145
+ render_state.current_modal = modal_component
1146
+ render_state.modal_context = { key: key }
1147
+ self.components = []
1148
+
1149
+ evaluate_dsl_block(block)
1150
+
1151
+ modal_component.children = components
1152
+ self.components = parent_components
1153
+ render_state.current_modal = nil
1154
+ render_state.modal_context = nil
1155
+ end
1156
+
1157
+ def modal_footer(**options, &block)
1158
+ raise "modal_footer can only be used inside a modal block" unless render_state.current_modal
1159
+
1160
+ footer_component = Components::ModalFooter.new(**options)
1161
+ parent_components = components
1162
+ self.components = []
1163
+
1164
+ evaluate_dsl_block(block)
1165
+
1166
+ footer_component.children = components
1167
+ self.components = parent_components
1168
+ render_state.current_modal.footer_component = footer_component
1169
+ end
1170
+
1171
+ def open_modal(key)
1172
+ @_state[:"#{key}_open"] = true
1173
+ end
1174
+
1175
+ def close_modal(key)
1176
+ @_state[:"#{key}_open"] = false
1177
+ end
1178
+
1179
+ # =========================================
1180
+ # Feedback DSL methods (App-only)
1181
+ # =========================================
1182
+
1183
+ def toast_container(position: :top_right, duration: 5000, **options)
1184
+ @_state[:_toasts] ||= []
1185
+ components << Components::ToastContainer.new(position: position, duration: duration, **options)
1186
+ end
1187
+
1188
+ def show_toast(message, variant: :info, duration: nil)
1189
+ @_state[:_toasts] ||= []
1190
+ toast_id = "toast_#{Time.now.to_f.to_s.gsub('.', '_')}_#{rand(1000)}"
1191
+ toast = { id: toast_id, message: message, variant: variant }
1192
+ toast[:duration] = duration if duration
1193
+ @_state[:_toasts] << toast
1194
+ end
1195
+
1196
+ def dismiss_toast(toast_id)
1197
+ @_state[:_toasts] ||= []
1198
+ @_state[:_toasts].reject! { |t| t[:id] == toast_id }
1199
+ end
1200
+
1201
+ def clear_toasts
1202
+ @_state[:_toasts] = []
1203
+ end
1204
+
1205
+ # =========================================
1206
+ # Flash: one-shot, same-request messaging (FAC-P3.2b, flash-prg.md)
1207
+ # =========================================
1208
+
1209
+ # Hash-like accessor backed by state[:_flash] (mirrors how `state` itself
1210
+ # is exposed): `flash[:notice] = "Person created."`. flash.now-only
1211
+ # semantics -- visible in the render that answers this request, gone by
1212
+ # the next one (session persistence excludes :_flash entirely; see
1213
+ # SessionStore::Base#filter and Service#set_app_state). Not merged with
1214
+ # the toast system: toast is a persistent, dismissible list; flash is a
1215
+ # one-shot PRG-shaped message, deliberately a different lifetime.
1216
+ def flash
1217
+ @_state[:_flash] ||= {}
1218
+ end
1219
+
1220
+ # Renders whatever key/value pairs are present in the current flash as
1221
+ # Alert summaries, mapping known keys to Alert variants (:notice ->
1222
+ # :success, :error -> :error) and falling back to :info for anything
1223
+ # else. Auto-called near the top of #app-container when chrome: true
1224
+ # (see #rebuild_with_state); apps with chrome: false place it explicitly.
1225
+ def flash_messages
1226
+ return unless (f = @_state[:_flash]) && !f.empty?
1227
+ f.each do |key, message|
1228
+ variant = case key.to_sym
1229
+ when :notice then :success
1230
+ when :error then :error
1231
+ else :info
1232
+ end
1233
+ alert(variant: variant) { text message }
1234
+ end
1235
+ end
1236
+
1237
+ def canvas_continue(message: "Processing...")
1238
+ components << Components::CanvasContinue.new(message: message)
1239
+ end
1240
+
1241
+ def theme_switcher(position: :inline, show_label: true, **options)
1242
+ components << Components::ThemeSwitcher.new(position: position, show_label: show_label, **options)
1243
+ end
1244
+
1245
+ # =========================================
1246
+ # Design Deck DSL methods (T7)
1247
+ # =========================================
1248
+
1249
+ # Create a design deck with slide-based option selection.
1250
+ # The deck wraps its slides in a SlideContainer with :swap mode.
1251
+ #
1252
+ # @param title [String] Deck title
1253
+ # @param options [Hash] Additional options
1254
+ # @yield Block containing slide definitions
1255
+ # @return [Components::Deck::DesignDeck] The deck component
1256
+ #
1257
+ # @example
1258
+ # design_deck "Architecture Direction" do
1259
+ # slide "arch", "System Architecture" do
1260
+ # option "Monolith" do
1261
+ # code_block "...", lang: "ts"
1262
+ # end
1263
+ # end
1264
+ # end
1265
+ def design_deck(title, **options, &block)
1266
+ deck = Components::Deck::DesignDeck.new(title, **options)
1267
+ components << deck
1268
+ render_state.current_deck = deck
1269
+
1270
+ parent_components = components
1271
+ self.components = []
1272
+ evaluate_dsl_block(block)
1273
+ deck.children = components
1274
+ self.components = parent_components
1275
+
1276
+ deck.validate!
1277
+
1278
+ # Auto-append DeckSummary as the last slide (T9)
1279
+ slides = deck.children.select { |c| c.is_a?(Components::Deck::DeckSlide) }
1280
+ summary = Components::Deck::DeckSummary.new
1281
+ summary.deck_slides = slides
1282
+ deck.children << summary
1283
+
1284
+ render_state.current_deck = nil
1285
+ deck
1286
+ end
1287
+
1288
+ # Override slide to create DeckSlide when inside a design_deck context.
1289
+ # Falls through to DisplayDSL#slide when not in deck context.
1290
+ def slide(id, title = nil, **options, &block)
1291
+ unless render_state.current_deck
1292
+ return super(id, title, **options, &block)
1293
+ end
1294
+
1295
+ deck_slide = Components::Deck::DeckSlide.new(id, title, **options)
1296
+ components << deck_slide
1297
+ render_state.current_slide = deck_slide
1298
+
1299
+ parent_components = components
1300
+ self.components = []
1301
+ evaluate_dsl_block(block)
1302
+ deck_slide.children = components
1303
+ self.components = parent_components
1304
+
1305
+ render_state.current_slide = nil
1306
+ deck_slide
1307
+ end
1308
+
1309
+ # Create an option card within a DeckSlide.
1310
+ # Must be called inside a slide block within a design_deck.
1311
+ #
1312
+ # @param label [String] Option label
1313
+ # @param aside [String, nil] Aside text below preview
1314
+ # @param recommended [Boolean] Show "Recommended" badge
1315
+ # @param description [String, nil] Description for tooltip/aria
1316
+ # @param options [Hash] Additional options
1317
+ # @yield Block containing preview content (mermaid, code_block, etc.)
1318
+ #
1319
+ # @example
1320
+ # option "Monolith", aside: "Simple", recommended: true do
1321
+ # mermaid "graph TD; A-->B", compact: true
1322
+ # end
1323
+ def option(label, **options, &block)
1324
+ raise "option must be inside a slide within design_deck" unless render_state.current_deck && render_state.current_slide
1325
+
1326
+ opt = Components::Deck::DeckOption.new(label, **options)
1327
+ # Track parent slide context for selection state (T8)
1328
+ opt.slide_id = render_state.current_slide.id
1329
+ # Count options already in the current build's components list (not slide.children which isn't set yet)
1330
+ opt.option_index = components.count { |c| c.is_a?(Components::Deck::DeckOption) }
1331
+ components << opt
1332
+
1333
+ parent_components = components
1334
+ self.components = []
1335
+ evaluate_dsl_block(block)
1336
+ opt.children = components
1337
+ self.components = parent_components
1338
+
1339
+ opt
1340
+ end
1341
+
1342
+ # =========================================
1343
+ # Deck Polish DSL methods (T14)
1344
+ # =========================================
1345
+
1346
+ # Create an AI model picker for generate-more.
1347
+ # Hidden when fewer than 2 models are provided.
1348
+ #
1349
+ # @param models [Array<Hash>] Array of { id:, name:, provider: } hashes
1350
+ # @param default_model [String, nil] ID of the default selected model
1351
+ #
1352
+ # @example
1353
+ # model_selector(
1354
+ # models: [
1355
+ # { id: "claude-3", name: "Claude 3 Opus", provider: "Anthropic" },
1356
+ # { id: "gpt-4", name: "GPT-4", provider: "OpenAI" }
1357
+ # ],
1358
+ # default_model: "claude-3"
1359
+ # )
1360
+ def model_selector(models:, default_model: nil, **options)
1361
+ components << Components::Deck::ModelSelector.new(
1362
+ models: models, default_model: default_model, **options
1363
+ )
1364
+ end
1365
+
1366
+ # Show a fixed top confirmation bar with confirm/cancel buttons.
1367
+ # Slides down from top with optional auto-hide timer.
1368
+ #
1369
+ # @param message [String] Confirmation message
1370
+ # @param confirm_label [String] Label for confirm action (default: "Cancel")
1371
+ # @param cancel_label [String] Label for dismiss action (default: "Keep Going")
1372
+ # @param auto_hide [Integer, nil] Auto-hide after N seconds (default: 5)
1373
+ #
1374
+ # @example
1375
+ # confirmation_bar(
1376
+ # message: "Are you sure you want to cancel?",
1377
+ # confirm_label: "Yes, Cancel",
1378
+ # cancel_label: "Keep Going"
1379
+ # )
1380
+ def confirmation_bar(message:, confirm_label: "Cancel", cancel_label: "Keep Going",
1381
+ auto_hide: 5, **options)
1382
+ components << Components::Deck::ConfirmationBar.new(
1383
+ message: message, confirm_label: confirm_label,
1384
+ cancel_label: cancel_label, auto_hide: auto_hide, **options
1385
+ )
1386
+ end
1387
+
1388
+ # Show a full-screen overlay after submit or cancel.
1389
+ # Displays status message with blur backdrop and optional auto-close tab.
1390
+ #
1391
+ # @param status [Symbol] Status type (:submitted or :cancelled)
1392
+ # @param message [String] Status message
1393
+ # @param auto_close_delay [Integer] Auto-close tab delay in ms (default: 800)
1394
+ #
1395
+ # @example
1396
+ # close_overlay(status: :submitted, message: "Deck submitted!")
1397
+ def close_overlay(status:, message:, auto_close_delay: 800, **options)
1398
+ components << Components::Deck::CloseOverlay.new(
1399
+ status: status, message: message,
1400
+ auto_close_delay: auto_close_delay, **options
1401
+ )
1402
+ end
1403
+
1404
+ # =========================================
1405
+ # Streaming DSL (server-push via SSE)
1406
+ # =========================================
1407
+
1408
+ def stream(&block)
1409
+ @stream_block ||= block
1410
+ end
1411
+
1412
+ def every(seconds, &block)
1413
+ return if @timers_frozen
1414
+ @timers << { interval: seconds, block: block, last_run: nil }
1415
+ end
1416
+
1417
+ def has_timers?
1418
+ @timers.any?
1419
+ end
1420
+
1421
+ # Set favicon — accepts a URL string or a single emoji character
1422
+ # Emoji example: favicon "🔥"
1423
+ # URL example: favicon "https://example.com/icon.png"
1424
+ def favicon(value)
1425
+ @favicon_value = value
1426
+ end
1427
+
1428
+ # Returns the favicon as an href suitable for <link rel="icon">
1429
+ # Converts emoji to SVG data URI, file paths to base64 data URI; passes URLs through unchanged
1430
+ def favicon_href
1431
+ return nil unless @favicon_value
1432
+ @_favicon_href_cache ||= build_favicon_href
1433
+ end
1434
+
1435
+ FAVICON_MIME_TYPES = {
1436
+ 'ico' => 'image/x-icon', 'png' => 'image/png', 'svg' => 'image/svg+xml',
1437
+ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp'
1438
+ }.freeze
1439
+
1440
+ # Serves a local file (stylesheet, image, ...) via the /sw-asset/ route
1441
+ # -- content-type by extension, ETag, and path-traversal-safe: the
1442
+ # resolved path must be under this app's own script directory or one of
1443
+ # its assets_dirs: (stream_weaver-1lo). Generalizes the local-path
1444
+ # detection favicon already did one-off (build_favicon_href) to any
1445
+ # asset, and to a real served/cacheable route instead of a base64 data
1446
+ # URI (so a large stylesheet doesn't bloat every page load).
1447
+ #
1448
+ # @param path [String] Absolute path, or relative to the calling script's directory
1449
+ # @return [String] URL to serve the file, e.g. "/sw-asset/<key>/name.css"
1450
+ # @raise [ArgumentError] if the file doesn't exist, or resolves outside the allowed directories
1451
+ def local_asset(path)
1452
+ abs_path = resolve_asset_path(path)
1453
+ raise ArgumentError, "local_asset: file not found: #{path}" unless abs_path
1454
+
1455
+ ensure_asset_path_allowed!(abs_path, path)
1456
+ "/sw-asset/#{ComponentAssets.register_file(abs_path)}/#{File.basename(abs_path)}"
1457
+ end
1458
+
1459
+ # Adds CSS as an inline <style> block, usable inside the DSL body itself
1460
+ # (unlike `stylesheets:`, which is only an App.new kwarg) -- so a single
1461
+ # shared-DSL file can carry its own re-skin whether it's require'd
1462
+ # standalone or instance_eval'd by canvas-push (stream_weaver-9uk).
1463
+ # Canvas has no route to serve a referenced asset file across processes
1464
+ # the way local_asset/stylesheets: do, so inlining the raw CSS text is
1465
+ # the one mechanism that works in both contexts.
1466
+ #
1467
+ # @param source [String] a local path (resolved relative to the calling
1468
+ # script's directory, same rule as stylesheets:) or literal CSS text
1469
+ # if it doesn't resolve to a file
1470
+ def use_stylesheet(source)
1471
+ css = resolve_stylesheet_content(source)
1472
+ @inline_stylesheets << css unless @inline_stylesheets.include?(css)
1473
+ end
1474
+
1475
+ # In-DSL equivalents of App.new's `theme:`/`layout:` kwargs, for the same
1476
+ # reason use_stylesheet exists: a DSL file that gets instance_eval'd by
1477
+ # canvas-push/canvas-read never runs its own App.new, so a `theme:` kwarg
1478
+ # is simply not reachable in that path (stream_weaver-csf). Declaring
1479
+ # `use_theme :doc` in the DSL body makes the doc carry its own theme
1480
+ # everywhere it's rendered.
1481
+ #
1482
+ # These are new methods, NOT redefinitions of the `theme`/`layout`
1483
+ # attr_readers -- views.rb reads `app.theme`/`app.layout` in many places.
1484
+ def use_theme(name)
1485
+ @theme = validate_theme(name)
1486
+ end
1487
+
1488
+ # Caveat: canvas-read renders through Views::AppContentView, which does
1489
+ # not evaluate exclusive-layout render blocks or layout slots (those are
1490
+ # AppView-only). So in the reader `use_layout` only reaches body-class /
1491
+ # CSS-selector level layout, not full exclusive-layout fidelity.
1492
+ def use_layout(name)
1493
+ @layout = name.to_sym
1494
+ end
1495
+
1496
+ # =========================================
1497
+ # Layout components (Cabinet Control style)
1498
+ # =========================================
1499
+
1500
+ def app_shell(sidebar_width: "320px", sidebar_position: :right, gap: "1.5rem", **options, &block)
1501
+ component = Components::AppShell.new(
1502
+ sidebar_width: sidebar_width,
1503
+ sidebar_position: sidebar_position,
1504
+ gap: gap,
1505
+ **options
1506
+ )
1507
+ components << component
1508
+
1509
+ return component unless block
1510
+
1511
+ render_state.current_app_shell = component
1512
+ evaluate_dsl_block(block)
1513
+ render_state.current_app_shell = nil
1514
+
1515
+ component
1516
+ end
1517
+
1518
+ def main(**options, &block)
1519
+ raise "main can only be used inside an app_shell block" unless render_state.current_app_shell
1520
+
1521
+ parent_components = components
1522
+ self.components = []
1523
+ evaluate_dsl_block(block)
1524
+ render_state.current_app_shell.main_children = components
1525
+ self.components = parent_components
1526
+ end
1527
+
1528
+ def sidebar(header: nil, sticky: true, **options, &block)
1529
+ raise "sidebar can only be used inside an app_shell block" unless render_state.current_app_shell
1530
+
1531
+ sidebar_component = Components::Sidebar.new(header: header, sticky: sticky, **options)
1532
+
1533
+ parent_components = components
1534
+ self.components = []
1535
+ evaluate_dsl_block(block)
1536
+ sidebar_component.children = components
1537
+ self.components = parent_components
1538
+
1539
+ render_state.current_app_shell.sidebar_children << sidebar_component
1540
+ end
1541
+
1542
+ def expandable_card(key:, title:, subtitle: nil, badge_text: nil, badge_variant: :default,
1543
+ status: nil, initially_expanded: false, **options, &block)
1544
+ @_state[key] ||= initially_expanded
1545
+
1546
+ component = Components::ExpandableCard.new(
1547
+ key: key, title: title, subtitle: subtitle,
1548
+ badge_text: badge_text, badge_variant: badge_variant,
1549
+ status: status, initially_expanded: initially_expanded,
1550
+ **options
1551
+ )
1552
+ with_container(component, &block)
1553
+ end
1554
+
1555
+ private
1556
+
1557
+ def build_favicon_href
1558
+ v = @favicon_value
1559
+ if v.match?(/\A\p{Emoji_Presentation}\z/) || v.match?(/\A[\p{So}\p{Sk}]\z/)
1560
+ "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>#{v}</text></svg>"
1561
+ elsif File.exist?(v)
1562
+ require 'base64'
1563
+ mime = FAVICON_MIME_TYPES[File.extname(v).delete('.').downcase] || 'image/png'
1564
+ "data:#{mime};base64,#{Base64.strict_encode64(File.binread(v))}"
1565
+ else
1566
+ v
1567
+ end
1568
+ end
1569
+
1570
+ # Resolves a `stylesheets:` entry: a local file (relative to the script
1571
+ # dir, or absolute) becomes a served /sw-asset/ URL; anything else
1572
+ # (a real URL, or a string that just doesn't resolve to a local file)
1573
+ # passes through unchanged, same as always. Unlike local_asset, a
1574
+ # missing file is not an error here -- most stylesheets: entries are
1575
+ # ordinary hrefs, not local paths, so a non-match must stay silent.
1576
+ # A real local file outside the allowed directories still raises,
1577
+ # though -- that IS the traversal case this feature guards against.
1578
+ def resolve_stylesheet_href(href)
1579
+ abs_path = resolve_asset_path(href)
1580
+ return href unless abs_path
1581
+
1582
+ ensure_asset_path_allowed!(abs_path, href)
1583
+ "/sw-asset/#{ComponentAssets.register_file(abs_path)}/#{File.basename(abs_path)}"
1584
+ end
1585
+
1586
+ # Resolves a `use_stylesheet` argument to raw CSS text: a local file
1587
+ # (same resolution/traversal rules as stylesheets:) is read and its
1588
+ # content returned; anything else is treated as literal CSS. A source
1589
+ # containing a newline is assumed to already be CSS text and skips file
1590
+ # resolution entirely -- avoids stat-ing a multi-KB string as a path.
1591
+ def resolve_stylesheet_content(source)
1592
+ return source unless source.is_a?(String)
1593
+ return source if source.include?("\n")
1594
+
1595
+ abs_path = resolve_asset_path(source)
1596
+ return source unless abs_path
1597
+
1598
+ ensure_asset_path_allowed!(abs_path, source)
1599
+ File.read(abs_path)
1600
+ end
1601
+
1602
+ # @return [String, nil] absolute path if `path` resolves to a real local
1603
+ # file (as given, or relative to the script dir); nil for a URL or
1604
+ # anything that doesn't exist on disk
1605
+ def resolve_asset_path(path)
1606
+ return nil if path.to_s.match?(%r{\A[a-z][a-z0-9+.\-]*://}i)
1607
+
1608
+ [path, File.expand_path(path, @script_dir)].each do |candidate|
1609
+ expanded = File.expand_path(candidate)
1610
+ return expanded if File.exist?(expanded)
1611
+ end
1612
+ nil
1613
+ end
1614
+
1615
+ def ensure_asset_path_allowed!(abs_path, original)
1616
+ return if @allowed_asset_dirs.any? { |dir| abs_path == dir || abs_path.start_with?("#{dir}/") }
1617
+
1618
+ raise ArgumentError, "local_asset: #{original} resolves outside the app's script directory " \
1619
+ "(#{@script_dir}) or its assets_dirs: -- pass assets_dirs: [...] to App.new to allow it"
1620
+ end
1621
+
1622
+ # Captures children then appends the component (for item, column patterns)
1623
+ def capture_children_then_append(component, &block)
1624
+ parent_components = components
1625
+ self.components = []
1626
+ evaluate_dsl_block(block)
1627
+ component.children = components
1628
+ self.components = parent_components
1629
+ components << component
1630
+ end
1631
+
1632
+ # Initialize state for form fields, handling form/scope context
1633
+ def initialize_form_state(key, options, default_value, skip_if_exists: false)
1634
+ if render_state.form_context
1635
+ options[:form_context] = render_state.form_context
1636
+ elsif render_state.current_scope
1637
+ # A field inside a bare `scope` block (not a `form` block, e.g. a
1638
+ # retained filter panel) -- FAC-P3.1 handoff: the adapter needs to
1639
+ # know this so it can render a scope-nested name/x-model path instead
1640
+ # of a flat one, the same way :form_context already does for forms.
1641
+ options[:scope_name] = render_state.current_scope
1642
+ end
1643
+
1644
+ target_scope = render_state.form_context&.fetch(:name, nil) || render_state.current_scope
1645
+ if target_scope
1646
+ @_state[target_scope] ||= {}
1647
+ target = @_state[target_scope]
1648
+ else
1649
+ target = @_state
1650
+ end
1651
+
1652
+ if skip_if_exists
1653
+ target[key] = default_value unless target.key?(key)
1654
+ else
1655
+ target[key] ||= default_value
1656
+ end
1657
+ end
1658
+
1659
+ # form_for submit pipeline: symbolize -> coerce -> validate -> persist ->
1660
+ # flash + PRG transition (form-for.md §3-§5). A validation failure never
1661
+ # flashes/redirects -- it just populates errors_key so the same-request
1662
+ # re-render shows the summary Alert with the user's in-progress values
1663
+ # still in the scope (untouched here on the failure path).
1664
+ def form_for_submit(fields:, raw_values:, store:, record:, is_update:, errors_key:, defn:,
1665
+ on_success:, validate_proc:, singular:)
1666
+ form_values = raw_values.transform_keys(&:to_sym)
1667
+ coerced, errors = form_for_coerce(fields, form_values)
1668
+
1669
+ if validate_proc
1670
+ (instance_exec(coerced, &validate_proc) || {}).each do |field, msgs|
1671
+ (errors[field.to_sym] ||= []).concat(Array(msgs))
1672
+ end
1673
+ end
1674
+
1675
+ if errors.any?
1676
+ state[errors_key] = errors
1677
+ return
1678
+ end
1679
+ state[errors_key] = nil
1680
+
1681
+ if is_update
1682
+ store.update(record[:id], coerced)
1683
+ id = record[:id]
1684
+ else
1685
+ id = store.create(coerced)
1686
+ end
1687
+
1688
+ flash[:notice] = "#{singular.capitalize} #{is_update ? 'updated' : 'created'}."
1689
+
1690
+ if on_success
1691
+ instance_exec(id, &on_success)
1692
+ elsif defn
1693
+ sk = Resource::StateKeys
1694
+ state[sk::RESOURCE] = defn.name
1695
+ if defn.only.include?(:show)
1696
+ state[sk::ACTION] = :show
1697
+ state[sk::ID] = id
1698
+ else
1699
+ state[sk::ACTION] = :index
1700
+ end
1701
+ end
1702
+ end
1703
+
1704
+ def form_for_coerce(fields, form_values)
1705
+ errors = {}
1706
+ coerced = {}
1707
+ fields.each do |f|
1708
+ key = f.name
1709
+ raw = form_values[key]
1710
+ coerced[key] = case f.type
1711
+ when :integer then form_for_number(raw, key, errors, :integer, "must be a whole number")
1712
+ when :number then form_for_number(raw, key, errors, :number, "must be a number")
1713
+ else raw
1714
+ end
1715
+ end
1716
+ [coerced, errors]
1717
+ end
1718
+
1719
+ def form_for_number(raw, key, errors, kind, message)
1720
+ return nil if raw.nil? || raw == ""
1721
+ kind == :integer ? Integer(raw) : Float(raw)
1722
+ rescue ArgumentError, TypeError
1723
+ (errors[key] ||= []) << message
1724
+ raw
1725
+ end
1726
+
1727
+ # Internal, flat, unscoped state key (like _sw_resource/_sw_action/_sw_id)
1728
+ # that snapshots each non-retained scope's owning discriminant values, so
1729
+ # #apply_scope_lifecycle can detect a change between rebuilds. Not scoped
1730
+ # data itself -- routing/lifecycle metadata (FAC-P3.0a §8).
1731
+ SCOPE_WATCH_KEY = :_sw_scope_watch
1732
+
1733
+ def register_scope(name, kind:, retain:)
1734
+ return @scope_registry[name] if @scope_registry.key?(name)
1735
+
1736
+ existing_value = @_state[name]
1737
+ if @_state.key?(name) && !existing_value.is_a?(Hash)
1738
+ raise ArgumentError, "scope #{name.inspect} collides with an existing top-level state " \
1739
+ "key holding a #{existing_value.class} -- scopes are always Hash sub-states"
1740
+ end
1741
+
1742
+ @scope_registry[name] = { kind: kind, retain: retain }
1743
+ end
1744
+
1745
+ # Owning routing discriminant per scope kind (FAC-P3.0a §4). :app-kind
1746
+ # (and any unrecognized kind) has no discriminant -- it never auto-resets.
1747
+ def discriminant_keys_for(kind)
1748
+ sk = Resource::StateKeys
1749
+ case kind
1750
+ when :resource then [sk::RESOURCE, sk::ID]
1751
+ when :form then [sk::RESOURCE, sk::ACTION]
1752
+ when :fragment then [sk::RESOURCE, sk::ACTION, sk::ID, @route_key].compact
1753
+ end
1754
+ end
1755
+
1756
+ # Clears each non-retained scope's sub-hash when its owning routing
1757
+ # discriminant changed since the last rebuild (FAC-P3.0a §4) -- the
1758
+ # framework-enforced replacement for hand-nulled edit_* keys at every
1759
+ # navigation-away call site. Runs before the DSL block evaluates so
1760
+ # stale values are gone before any field re-initializes its default.
1761
+ def apply_scope_lifecycle
1762
+ return if @scope_registry.empty?
1763
+
1764
+ watch = @_state[SCOPE_WATCH_KEY] ||= {}
1765
+ @scope_registry.each do |name, meta|
1766
+ next if meta[:retain]
1767
+ keys = discriminant_keys_for(meta[:kind])
1768
+ next unless keys
1769
+
1770
+ signature = keys.map { |k| @_state[k] }
1771
+ @_state[name] = {} if watch.key?(name) && watch[name] != signature
1772
+ watch[name] = signature
1773
+ end
1774
+ end
1775
+
1776
+ def reserved_endpoint_path?(path)
1777
+ RESERVED_ENDPOINT_EXACT.include?(path) ||
1778
+ RESERVED_ENDPOINT_PREFIXES.any? { |prefix| path.start_with?(prefix) }
1779
+ end
1780
+
1781
+ # Auto-disambiguation (DisplayDSL#disambiguate_component_id) counts
1782
+ # occurrences per render pass, so the App's table lives on render_state --
1783
+ # a rebuild starts from a clean slate and #rewind_point can restore it.
1784
+ def seen_component_ids
1785
+ render_state.seen_component_ids
1786
+ end
1787
+
1788
+ def warned_duplicate_ids
1789
+ @_warned_duplicate_ids
1790
+ end
1791
+
1792
+ def strict_ids?
1793
+ @strict_ids
1794
+ end
1795
+
1796
+ def define_path_helpers(defn)
1797
+ s, p = defn.singular, defn.plural
1798
+ define_singleton_method(:"#{p}_path") { "/#{p}" }
1799
+ define_singleton_method(:"new_#{s}_path") { "/#{p}/new" }
1800
+ define_singleton_method(:"#{s}_path") { |rec| "/#{s}/#{CGI.escape(rec[:id].to_s)}" }
1801
+ define_singleton_method(:"edit_#{s}_path") { |rec| "/#{s}/#{CGI.escape(rec[:id].to_s)}/edit" }
1802
+ end
1803
+
1804
+ # Parse a string with {term} markers into Phrase and Term components
1805
+ def parse_lesson_string(content, glossary)
1806
+ children = []
1807
+ parts = content.split(/(\{[^}]+\})/)
1808
+
1809
+ parts.each do |part|
1810
+ if part.start_with?('{') && part.end_with?('}')
1811
+ term_key = part[1..-2]
1812
+ children << Components::Term.new(term_key)
1813
+ elsif !part.empty?
1814
+ children << Components::Phrase.new(part)
1815
+ end
1816
+ end
1817
+
1818
+ children
1819
+ end
1820
+ end
1821
+ end