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,561 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "inline"
4
+
5
+ module StreamWeaver
6
+ module Org
7
+ # Converts StreamWeaver-flavored org text back into DSL body text (the
8
+ # same shape canvas-read already instance_evals). See
9
+ # docs/superpowers/specs/2026-08-13-org-doc-format-design.md for the
10
+ # format this parses.
11
+ #
12
+ # Verified 2026-08-13 against synthetic scenarios AND the real,
13
+ # untracked example doc that motivated this feature, covering:
14
+ # infinite-loop safety on unterminated src/quote/property-drawer
15
+ # blocks, standalone callout/card/comparison (no headline) NOT
16
+ # misclassified as doc_header, a card with a nested mermaid src block
17
+ # emitting both a real `mermaid` call and surrounding `md` prose (not
18
+ # flattened), preamble lines never leaking as stray `md` calls,
19
+ # TOC_LABEL overrides, table inline-emphasis conversion, a
20
+ # multi-paragraph `md` block staying as ONE call (not split on its
21
+ # internal blank line), a `markdown: false` table's literal
22
+ # emphasis-like characters surviving the round trip untouched, and a
23
+ # full realistic document producing syntactically valid Ruby end to end.
24
+ class Reader
25
+ VARIANT_EMOJI = {
26
+ "⚠️" => :warning,
27
+ "✅" => :success,
28
+ "💡" => :tip,
29
+ "ℹ️" => :info,
30
+ "⚖️" => :decision,
31
+ "❌" => :error,
32
+ "🔺" => :risk
33
+ }.freeze
34
+
35
+ HEADLINE_RE = /\A(\*+)\s+(\d+(?:\.\d+)*)\s+(.+)\z/
36
+ SRC_BEGIN_RE = /\A#\+begin_src(?:\s+(.*))?\z/i
37
+ # Any "#+KEY: value" org keyword line, not just the two StreamWeaver reads
38
+ # itself: an unrecognized keyword used to fall through to the generic
39
+ # paragraph branch and derail whatever followed it, so a doc carrying a
40
+ # plain #+AUTHOR:/#+DATE:/#+FILETAGS: -- or the #+TYPE: a UKF wiki member
41
+ # needs to be indexed -- failed to parse at all. Two exclusions: block
42
+ # directives (#+begin_src/#+begin_quote and their #+end_ partners, which
43
+ # carry no colon anyway, kept explicit for the reader) and
44
+ # #+ATTR_STREAMWEAVER:, which is consulted a few lines LATER (see #chunks)
45
+ # -- matching it here would consume it as preamble and silently drop the
46
+ # attribute from the table it belongs to.
47
+ PREAMBLE_RE = /\A#\+(?!begin_|end_|ATTR_STREAMWEAVER:)\w+:/i
48
+ ATTR_RE = /\A#\+ATTR_STREAMWEAVER:\s*(.*)\z/i
49
+
50
+ def self.to_dsl(org_text)
51
+ new(org_text).call
52
+ end
53
+
54
+ # True when `text` carries the `#+STREAMWEAVER_DSL:` marker within its
55
+ # first 10 lines -- the same content-based check content.js/sandbox.js
56
+ # already do client-side (there's no way to share the regex literally
57
+ # across Ruby and JS, so each surface keeps its own copy; this is the
58
+ # canonical Ruby-side one, for anything server-side that needs to tell
59
+ # a StreamWeaver org doc apart from plain org/prose before parsing it).
60
+ # Looser than the `\s*1\s*\z`-anchored check `initialize` uses for its
61
+ # own @streamweaver_document flag (any digit, no end anchor) so a
62
+ # future version bump stays recognized here without an update.
63
+ def self.streamweaver_org?(text)
64
+ return false unless text.is_a?(String)
65
+
66
+ text.each_line.first(10).any? { |line| line.match?(/\A#\+STREAMWEAVER_DSL:\s*\d+/i) }
67
+ end
68
+
69
+ def initialize(org_text)
70
+ @lines = org_text.lines.map(&:chomp)
71
+
72
+ title_line = @lines.find { |line| line.match?(/\A#\+TITLE:/i) }
73
+ @title = title_line&.sub(/\A#\+TITLE:\s*/i, "")
74
+
75
+ @streamweaver_document =
76
+ @lines.any? { |line| line.match?(/\A#\+STREAMWEAVER_DSL:\s*1\s*\z/i) }
77
+ end
78
+
79
+ def call
80
+ chunk_list = chunks
81
+
82
+ # sidebar_toc is derived from depth-1 headlines, in document order,
83
+ # using TOC_LABEL when present and falling back to the headline's
84
+ # own title otherwise -- never hand-authored.
85
+ toc_entries = chunk_list.filter_map do |chunk|
86
+ next unless chunk[:type] == :headline && chunk[:depth] == 1
87
+
88
+ { id: chunk.fetch(:custom_id), label: chunk[:toc_label] || chunk.fetch(:title) }
89
+ end
90
+
91
+ output = []
92
+ header_insert_at = 0
93
+ unless toc_entries.empty?
94
+ entries = toc_entries.map { |e| "{ id: #{e[:id].inspect}, label: #{e[:label].inspect} }" }
95
+ output << "sidebar_toc sections: [#{entries.join(', ')}]"
96
+ header_insert_at = 1
97
+ end
98
+
99
+ seen_headline = false
100
+ emitted_doc_header = false
101
+ i = 0
102
+
103
+ while i < chunk_list.length
104
+ chunk = chunk_list[i]
105
+
106
+ case chunk[:type]
107
+ when :headline
108
+ seen_headline = true
109
+ output << emit_headline(chunk)
110
+
111
+ when :quote
112
+ # Rule 1 (position, NOT content shape -- see doc_header_chunk?
113
+ # below for why): only the doc_header preamble block qualifies,
114
+ # never an arbitrary headline-less callout/card.
115
+ if !seen_headline && !emitted_doc_header && doc_header_chunk?(chunk, i)
116
+ output << emit_doc_header(chunk)
117
+ emitted_doc_header = true
118
+ else
119
+ marker = chunk[:lines].first.to_s
120
+ body = chunk[:lines].drop(1)
121
+
122
+ # Rule 2: before/after comparison pairing.
123
+ if (match = marker.match(/\A\*◀ Before — (.+)\*\z/))
124
+ following = chunk_list[i + 1]
125
+ after_marker = following[:lines].first.to_s if following&.dig(:type) == :quote
126
+ after_match = after_marker&.match(/\A\*▶ After — (.+)\*\z/)
127
+ raise ArgumentError, "malformed comparison: before block is not followed by an after block" unless after_match
128
+
129
+ output << emit_comparison(match[1], body, after_match[1], following[:lines].drop(1))
130
+ i += 1 # consume the paired 'after' chunk too
131
+ elsif marker.match?(/\A\*▶ After — /)
132
+ raise ArgumentError, "malformed comparison: after block has no preceding before block"
133
+ # Rule 3: reserved-emoji callout.
134
+ elsif (match = callout_marker_match(marker))
135
+ output << emit_callout(VARIANT_EMOJI.fetch(match[1]), match[2], body)
136
+ # Rule 4: card fallback.
137
+ else
138
+ output << emit_card(marker, body)
139
+ end
140
+ end
141
+
142
+ else
143
+ output << emit(chunk)
144
+ end
145
+
146
+ i += 1
147
+ end
148
+
149
+ # A doc_header with neither eyebrow nor pills has no quote block at
150
+ # all (Writer only emits one when it has content to put in it), so
151
+ # the loop above never gets a chance to match it -- synthesize a
152
+ # title-only call here instead, in the position it would have
153
+ # occupied (right after sidebar_toc, before everything else).
154
+ if @streamweaver_document && @title && !emitted_doc_header
155
+ output.insert(header_insert_at, emit_doc_header(nil))
156
+ end
157
+
158
+ output.reject!(&:empty?)
159
+ output.empty? ? "" : "#{output.join("\n\n")}\n"
160
+ end
161
+
162
+ private
163
+
164
+ # Splits org text into typed chunks. Every delimited construct
165
+ # (headline property drawer, quote block, src block) has an EOF guard
166
+ # that raises ArgumentError instead of looping forever when
167
+ # unterminated -- this is the fix for the infinite-loop bug.
168
+ #
169
+ # Callable recursively on a sub-slice of lines (see emit_body) so a
170
+ # quote block's own body can be re-chunked to recognize a nested src
171
+ # block instead of being flattened into one md call.
172
+ def chunks(lines = @lines)
173
+ result = []
174
+ i = 0
175
+ pending_attr = nil
176
+
177
+ while i < lines.length
178
+ line = lines[i]
179
+
180
+ if line.empty?
181
+ i += 1
182
+ next
183
+ end
184
+
185
+ # Preamble metadata lines are consumed by #initialize and must
186
+ # never fall through to the generic paragraph branch -- this is
187
+ # the fix for the preamble-leak bug.
188
+ if line.match?(PREAMBLE_RE)
189
+ i += 1
190
+ next
191
+ end
192
+
193
+ # #+ATTR_STREAMWEAVER: :markdown nil marks the very next table as
194
+ # markdown: false -- attach it to that table chunk (see emit_table)
195
+ # rather than treating it as prose. Any other chunk type clears a
196
+ # stale pending attribute rather than silently misapplying it.
197
+ if (attr_match = line.match(ATTR_RE))
198
+ pending_attr = attr_match[1]
199
+ i += 1
200
+ next
201
+ end
202
+
203
+ if (match = line.match(HEADLINE_RE))
204
+ depth = match[1].length
205
+ number = match[2]
206
+ title = match[3]
207
+ custom_id = nil
208
+ toc_label = nil
209
+ i += 1
210
+
211
+ if lines[i] == ":PROPERTIES:"
212
+ i += 1
213
+ while i < lines.length && lines[i] != ":END:"
214
+ property = lines[i]
215
+ custom_id = $1 if property =~ /\A:CUSTOM_ID:\s*(.+)\z/
216
+ toc_label = $1 if property =~ /\A:TOC_LABEL:\s*(.+)\z/
217
+ i += 1
218
+ end
219
+ raise ArgumentError, "unterminated property drawer for #{number}" if i >= lines.length
220
+
221
+ i += 1
222
+ end
223
+ raise ArgumentError, "headline #{number.inspect} has no CUSTOM_ID" unless custom_id
224
+
225
+ result << { type: :headline, depth: depth, number: number, title: title, custom_id: custom_id, toc_label: toc_label }
226
+ pending_attr = nil
227
+ next
228
+ end
229
+
230
+ if line.casecmp?("#+begin_quote")
231
+ body = []
232
+ i += 1
233
+ while i < lines.length && !lines[i].casecmp?("#+end_quote")
234
+ body << lines[i]
235
+ i += 1
236
+ end
237
+ raise ArgumentError, "unterminated quote block" if i >= lines.length
238
+
239
+ i += 1
240
+ result << { type: :quote, lines: body }
241
+ pending_attr = nil
242
+ next
243
+ end
244
+
245
+ if (match = line.match(SRC_BEGIN_RE))
246
+ header = match[1].to_s.strip
247
+ body = []
248
+ i += 1
249
+ while i < lines.length && !lines[i].casecmp?("#+end_src")
250
+ body << lines[i]
251
+ i += 1
252
+ end
253
+ raise ArgumentError, "unterminated source block" if i >= lines.length
254
+
255
+ i += 1
256
+ result << { type: :src, header: header, lines: body }
257
+ pending_attr = nil
258
+ next
259
+ end
260
+
261
+ if line.start_with?("|")
262
+ rows = []
263
+ while i < lines.length && lines[i].start_with?("|")
264
+ rows << lines[i]
265
+ i += 1
266
+ end
267
+ result << { type: :table, rows: rows, attr: pending_attr }
268
+ pending_attr = nil
269
+ next
270
+ end
271
+
272
+ # Paragraph collection: a blank line does NOT always end a
273
+ # paragraph chunk (fix for the multi-paragraph md-splitting bug —
274
+ # see revision note). If, after one or more blank lines, more
275
+ # plain text follows rather than a structural element, the blank
276
+ # line was an internal paragraph break within a single logical
277
+ # prose block (one `md` call with multiple <p> tags), not a
278
+ # component boundary -- keep collecting instead of splitting.
279
+ paragraph = []
280
+ loop do
281
+ break if i >= lines.length || structural_start?(lines[i])
282
+
283
+ if lines[i].empty?
284
+ j = i
285
+ j += 1 while j < lines.length && lines[j].empty?
286
+ break if j >= lines.length || structural_start?(lines[j])
287
+
288
+ paragraph << "" # normalize any blank-line run to one separator
289
+ i = j
290
+ next
291
+ end
292
+
293
+ paragraph << lines[i]
294
+ i += 1
295
+ end
296
+ raise ArgumentError, "unexpected Org directive: #{lines[i].inspect}" if paragraph.empty?
297
+
298
+ result << { type: :paragraph, lines: paragraph }
299
+ pending_attr = nil # a paragraph between an ATTR line and a later,
300
+ # unrelated table must not let that table inherit the attribute --
301
+ # only a directly-adjacent table consumes it (see #emit_table).
302
+ end
303
+
304
+ result
305
+ end
306
+
307
+ def structural_start?(line)
308
+ line.match?(HEADLINE_RE) || line.casecmp?("#+begin_quote") ||
309
+ line.match?(SRC_BEGIN_RE) || line.start_with?("|") ||
310
+ line.match?(PREAMBLE_RE) || line.match?(ATTR_RE)
311
+ end
312
+
313
+ def emit(chunk)
314
+ case chunk.fetch(:type)
315
+ when :paragraph then emit_paragraph(chunk)
316
+ when :table then emit_table(chunk)
317
+ when :src then emit_src(chunk)
318
+ when :headline then emit_headline(chunk)
319
+ else raise ArgumentError, "cannot directly emit #{chunk[:type].inspect}"
320
+ end
321
+ end
322
+
323
+ def emit_headline(chunk)
324
+ "doc_section_header #{chunk[:number].inspect}, #{chunk[:title].inspect}, id: #{chunk[:custom_id].inspect}"
325
+ end
326
+
327
+ def emit_paragraph(chunk)
328
+ text = Inline.org_to_md(chunk[:lines].join("\n"))
329
+ heredoc("md ", text, "MD")
330
+ end
331
+
332
+ def emit_table(chunk)
333
+ # markdown: true is the default (absent #+ATTR_STREAMWEAVER: :markdown
334
+ # nil) -- only that explicit marker (see #chunks) means the Writer
335
+ # copied these cells byte-identical from a markdown: false table, so
336
+ # skip Inline.org_to_md for them too. Forcing markdown: true
337
+ # unconditionally (an earlier version of this method did) silently
338
+ # reinterprets literal characters -- e.g. "5 min *after* the
339
+ # failure" -- as real emphasis on the way back. Confirmed against
340
+ # real content; see the design spec's Tables section.
341
+ markdown = !(chunk[:attr].to_s =~ /:markdown\s+nil/)
342
+ rows = chunk[:rows].reject { |row| table_separator?(row) }.map { |row| parse_table_row(row) }
343
+ rows = rows.map { |row| row.map { |cell| markdown ? Inline.org_to_md(cell) : cell } }
344
+ raise ArgumentError, "table has no header row" if rows.empty?
345
+
346
+ headers, *data = rows
347
+ data_dsl = data.map { |row| " [#{row.map(&:inspect).join(', ')}]" }.join(",\n")
348
+ body = data_dsl.empty? ? " rows: []" : " rows: [\n#{data_dsl}\n ]"
349
+ markdown_line = markdown ? " markdown: true,\n" : ""
350
+
351
+ <<~RUBY.chomp
352
+ table(
353
+ #{markdown_line} headers: [#{headers.map(&:inspect).join(', ')}],
354
+ #{body}
355
+ )
356
+ RUBY
357
+ end
358
+
359
+ def emit_src(chunk)
360
+ tokens = chunk[:header].split(/\s+/)
361
+ lang = tokens.shift
362
+ lang = "text" if lang.nil? || lang.empty?
363
+ code = chunk[:lines].join("\n")
364
+
365
+ # Writer#raw_passthrough's escape hatch (see also its NO_OP_STATEMENT_RE
366
+ # comment): content here is either the verbatim original DSL
367
+ # statement(s) or a "# unrecognized component" comment -- either way,
368
+ # real Ruby text meant to be spliced back in AS-IS, not wrapped in a
369
+ # code_block(...) call (which would misrender it: a fake code block
370
+ # with Prism.js chrome around what used to be, say, a `header1` call).
371
+ return code if tokens.each_cons(2).any? { |key, value| key == ":streamweaver-raw" && value == "t" }
372
+
373
+ zoom = tokens.each_cons(2).any? { |key, value| key == ":zoom" && value == "t" }
374
+
375
+ if lang == "mermaid"
376
+ options = zoom ? ", zoom: true" : ""
377
+ heredoc("mermaid ", code, "MERMAID", suffix: options)
378
+ else
379
+ heredoc("code_block(", code, "TXT", suffix: ", lang: #{lang.inspect})")
380
+ end
381
+ end
382
+
383
+ # chunk is nil for a doc_header with neither eyebrow nor pills (see the
384
+ # synthesis call in #call) -- title-only in that case. Otherwise,
385
+ # each non-empty line is classified by its OWN shape rather than a
386
+ # fixed line position, so eyebrow-only, pills-only, and eyebrow+pills
387
+ # all parse correctly regardless of which one is actually present.
388
+ def emit_doc_header(chunk)
389
+ eyebrow = nil
390
+ pills = []
391
+
392
+ if chunk
393
+ chunk[:lines].reject(&:empty?).each do |line|
394
+ if (match = line.match(%r{\A/(.*)/\z}))
395
+ eyebrow = match[1]
396
+ else
397
+ pills.concat(line.split(" · ").map { |pill| format_pill(pill) })
398
+ end
399
+ end
400
+ end
401
+
402
+ <<~RUBY.chomp
403
+ doc_header(
404
+ title: #{@title.inspect},
405
+ eyebrow: #{eyebrow.inspect},
406
+ pills: [#{pills.join(', ')}]
407
+ )
408
+ RUBY
409
+ end
410
+
411
+ def format_pill(pill)
412
+ if (match = pill.match(/\A\*\[([A-Za-z_]\w*)\]\s*(.+)\*\z/))
413
+ "{ text: #{match[2].inspect}, variant: #{match[1].to_sym.inspect} }"
414
+ else
415
+ pill.inspect
416
+ end
417
+ end
418
+
419
+ def emit_callout(variant, title, body_lines)
420
+ <<~RUBY.chomp
421
+ callout(variant: #{variant.inspect}, title: #{title.inspect}) do
422
+ #{indent_block(emit_body(body_lines), 2)}
423
+ end
424
+ RUBY
425
+ end
426
+
427
+ def emit_card(marker, body_lines)
428
+ match = marker.match(%r{\A\*\[([^\]]+)\]\s*(.+?)\*(?:\s*/\((.+)\)/)?\z})
429
+ if match
430
+ badge, title, meta = match[1], match[2], match[3]
431
+ else
432
+ match = marker.match(%r{\A\*(.+?)\*(?:\s*/\((.+)\)/)?\z})
433
+ raise ArgumentError, "malformed card header: #{marker.inspect}" unless match
434
+
435
+ badge, title, meta = nil, match[1], match[2]
436
+ end
437
+
438
+ arguments = [title.inspect]
439
+ arguments << "badge: #{badge.inspect}" if badge
440
+ arguments << "meta: #{meta.inspect}" if meta
441
+
442
+ <<~RUBY.chomp
443
+ card do
444
+ card_header #{arguments.join(', ')}
445
+ card_body do
446
+ #{indent_block(emit_body(body_lines), 4)}
447
+ end
448
+ end
449
+ RUBY
450
+ end
451
+
452
+ def emit_comparison(before_label, before_lines, after_label, after_lines)
453
+ <<~RUBY.chomp
454
+ comparison(
455
+ before_label: #{before_label.inspect},
456
+ after_label: #{after_label.inspect}
457
+ ) do
458
+ before do
459
+ #{indent_block(emit_body(before_lines), 4)}
460
+ end
461
+ after do
462
+ #{indent_block(emit_body(after_lines), 4)}
463
+ end
464
+ end
465
+ RUBY
466
+ end
467
+
468
+ # THE fix for the nested-src-in-card bug: a quote block's body is not
469
+ # intrinsically one flat markdown paragraph. Re-chunk it (recursing
470
+ # into #chunks on just these lines) so a mermaid/code src block nested
471
+ # inside a card or callout emits as a real mermaid/code_block call
472
+ # alongside any surrounding md prose, instead of being swallowed into
473
+ # one md heredoc.
474
+ def emit_body(lines)
475
+ chunks(lines).map do |chunk|
476
+ case chunk[:type]
477
+ when :paragraph, :table, :src then emit(chunk)
478
+ else raise ArgumentError, "unsupported #{chunk[:type]} block inside quote body"
479
+ end
480
+ end.join("\n\n")
481
+ end
482
+
483
+ # THE fix for the doc_header misclassification bug. An earlier version
484
+ # of this check used content shape alone (eyebrow-line vs. a leading
485
+ # "*"), which is unreliable: a doc_header with no eyebrow whose pills
486
+ # are all variant-tagged renders as "*[warn] Draft* · *[success] v2*"
487
+ # -- syntactically a valid card marker, not just visually similar to
488
+ # one. That ambiguity is irreducible from shape alone (real-content
489
+ # check found it crashing/corrupting via emit_card on exactly this
490
+ # shape). Position is the reliable signal instead: Writer never emits
491
+ # ANY body content before doc_header's own preamble block, so
492
+ # whenever one exists it is unconditionally the very first chunk in
493
+ # the whole document (index 0) -- no other quote block can ever
494
+ # occupy that position while a title is set. Callout/comparison
495
+ # markers still win even at position 0, since they use reserved
496
+ # syntax (a fixed emoji set, "◀ Before —"/"▶ After —") that
497
+ # eyebrow/pills text can never produce.
498
+ #
499
+ # Known limitation this doesn't resolve: a title-only doc_header (no
500
+ # eyebrow/pills, so no preamble block at all -- see the post-loop
501
+ # synthesis in #call) whose very first BODY element, before any
502
+ # section headline, is itself a genuine standalone card. That's an
503
+ # unusual, non-idiomatic document shape (cards normally live inside a
504
+ # section, after a headline), so left unresolved.
505
+ def doc_header_chunk?(chunk, index)
506
+ return false unless @streamweaver_document && @title && index.zero?
507
+
508
+ first_content_line = chunk[:lines].find { |line| !line.empty? }
509
+ return false unless first_content_line
510
+
511
+ !callout_marker_match(first_content_line) && !first_content_line.match?(/\A\*(?:◀|▶)\s/)
512
+ end
513
+
514
+ def callout_marker_match(marker)
515
+ emoji_pattern = Regexp.union(VARIANT_EMOJI.keys)
516
+ # Title is optional (match[2] is nil without one) -- a titleless
517
+ # callout's marker is just "*<emoji>*", no trailing space (see
518
+ # Writer#render_component).
519
+ marker.match(/\A\*(#{emoji_pattern})(?:\s+(.+))?\*\z/)
520
+ end
521
+
522
+ def table_separator?(row)
523
+ row.delete(" \t").match?(/\A\|[-+:|]+\|\z/)
524
+ end
525
+
526
+ def parse_table_row(row)
527
+ row.sub(/\A\|/, "").sub(/\|\z/, "").split("|", -1).map(&:strip)
528
+ end
529
+
530
+ # Heredoc-with-trailing-args generation: `, zoom: true` / `, lang: ...)`
531
+ # belong on the OPENING `<<~TAG` line, never appended after the bare
532
+ # closing tag -- get this backwards and the emitted Ruby won't parse.
533
+ # Also guards against a body line that happens to equal the tag itself
534
+ # (which would terminate the heredoc early) by picking a fresh tag.
535
+ def heredoc(prefix, text, base_tag, suffix: "")
536
+ tag = unique_heredoc_tag(base_tag, text)
537
+ content = text.split("\n", -1)
538
+ content.pop if content.last == ""
539
+ content = [""] if content.empty?
540
+
541
+ "#{prefix}<<~#{tag}#{suffix}\n#{content.map { |line| line.empty? ? line : " #{line}" }.join("\n")}\n#{tag}"
542
+ end
543
+
544
+ def unique_heredoc_tag(base_tag, text)
545
+ occupied = text.lines.map(&:chomp)
546
+ candidate = base_tag
547
+ sequence = 1
548
+ while occupied.include?(candidate)
549
+ candidate = "#{base_tag}_#{sequence}"
550
+ sequence += 1
551
+ end
552
+ candidate
553
+ end
554
+
555
+ def indent_block(text, width)
556
+ pad = " " * width
557
+ text.lines(chomp: true).map { |line| "#{pad}#{line}" }.join("\n")
558
+ end
559
+ end
560
+ end
561
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../display_dsl"
4
+ require_relative "../app"
5
+
6
+ module StreamWeaver
7
+ module Org
8
+ # Evaluates a saved DSL body (the same text canvas-read instance_evals to
9
+ # render HTML) against a bare context that shares StreamWeaver::DisplayDSL's
10
+ # real component-building logic, WITHOUT rendering anything. After eval,
11
+ # `components` holds the exact same Components::* tree (with real
12
+ # #children/#before_children/#after_children) the live renderer would
13
+ # build -- the Writer walks this tree directly instead of re-parsing
14
+ # Ruby source.
15
+ #
16
+ # #state and #render_state exist only because DisplayDSL#table calls them
17
+ # unconditionally (even for a plain static headers:/rows: table, not just
18
+ # column-DSL tables) -- nothing else in this class's supported vocabulary
19
+ # needs them, but table's own body isn't optional to satisfy.
20
+ #
21
+ # #use_theme/#use_layout exist because DocStore.dsl_with_metadata
22
+ # (doc_store.rb) prepends `use_theme :x` / `use_layout :y` lines to every
23
+ # real saved doc at Save-as-doc time -- these are App-only methods, not
24
+ # part of DisplayDSL, so any real saved doc crashes instance_eval without
25
+ # them. No-ops here: the Writer only needs the component tree, not
26
+ # theme/layout state (org format doesn't represent either).
27
+ #
28
+ # Everything ELSE the DSL can say -- App's whole interactive vocabulary
29
+ # (radio_group, text_field, form, ...), charts, layout shells -- is
30
+ # absorbed by #method_missing as an UnsupportedCall placeholder rather
31
+ # than raising, so a Save-as-Org can never die on a call this context
32
+ # does not define. That is a claim about UNDEFINED calls only: a
33
+ # DisplayDSL method that this context's half-set-up state trips over
34
+ # never reaches method_missing at all (see @_state in #initialize for the
35
+ # one that did), so new state DisplayDSL starts depending on has to be
36
+ # set up here explicitly.
37
+ class RecordingContext
38
+ include StreamWeaver::DisplayDSL
39
+
40
+ # Stand-in for a DSL call this context builds no real component for.
41
+ # It records WHAT was called and nothing else -- deciding what a saved
42
+ # .org should do with it is the Writer's job (Writer::INTERACTIVE_CALLS).
43
+ UnsupportedCall = Struct.new(:name)
44
+
45
+ attr_accessor :components
46
+ attr_reader :state, :render_state
47
+
48
+ def initialize
49
+ @components = []
50
+ # @_state is the ivar DisplayDSL's own stateful components write to
51
+ # directly -- `select` does `@_state[key] = ...` with no nil guard on
52
+ # the write path (display_dsl.rb), so leaving it nil turned any doc
53
+ # containing a select into a NoMethodError on nil at save time. It is
54
+ # the same throwaway hash as #state here: this context records a
55
+ # component tree and never renders, so nothing reads either back.
56
+ @state = @_state = {}
57
+ @render_state = StreamWeaver::App::RenderState.new
58
+ end
59
+
60
+ def use_theme(_name); end
61
+ def use_layout(_name); end
62
+
63
+ # Two things the code below can't say for itself:
64
+ #
65
+ # The block is deliberately NOT evaluated. For an interactive container
66
+ # (`form do ... end`) its children are part of the control the Writer
67
+ # drops; for anything else the whole statement -- block included -- is
68
+ # what Writer#raw_passthrough recovers verbatim from the source.
69
+ #
70
+ # `to_*` keeps raising, because it's Ruby's implicit-conversion
71
+ # protocol (to_ary/to_str/to_hash/to_proc): answering one of those with
72
+ # a nil placeholder turns a harmless duck-type probe into a TypeError
73
+ # somewhere far away.
74
+ def method_missing(name, *_args, **_kwargs, &_block)
75
+ return super if name.to_s.start_with?("to_")
76
+
77
+ components << UnsupportedCall.new(name)
78
+ nil
79
+ end
80
+
81
+ def respond_to_missing?(name, _include_private = false)
82
+ !name.to_s.start_with?("to_")
83
+ end
84
+ end
85
+ end
86
+ end