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,478 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'phlex'
4
+ require 'base64'
5
+ require 'fileutils'
6
+ require 'net/http'
7
+ require_relative '../page_shell'
8
+
9
+ module StreamWeaver
10
+ module Export
11
+ # Raised when the DSL handed to the exporter isn't a canvas-doc fragment
12
+ # (see HtmlExporter.from_dsl).
13
+ class InvalidDslError < StandardError; end
14
+
15
+ # Raised when `offline:` can't fetch an asset it needs to inline (see
16
+ # HtmlExporter#mermaid_offline_script_tag).
17
+ class OfflineAssetError < StandardError; end
18
+
19
+ # Generates self-contained HTML files from StreamWeaver apps.
20
+ #
21
+ # Collects all CDN links (Mermaid, Chart.js, Prism.js, Google Fonts),
22
+ # inlines all CSS (theme + component styles), and renders the body
23
+ # via Phlex to produce a single .html file that works offline
24
+ # (except for CDN scripts which are preserved as external links).
25
+ #
26
+ # @example Export to file
27
+ # StreamWeaver::Export::HtmlExporter.export(app, path: "output.html")
28
+ #
29
+ # @example Get HTML string
30
+ # html = StreamWeaver::Export::HtmlExporter.new(app).to_html
31
+ #
32
+ # @example Export a canvas doc / history snapshot (a DSL fragment on disk)
33
+ # StreamWeaver::Export::HtmlExporter.from_dsl_file(path).export(path: out)
34
+ #
35
+ # The <head>/<body> shell comes from PageShell, so an export lands in the
36
+ # same cascade order as the canvas and the reader rather than re-deriving
37
+ # its own (stream_weaver-mdc).
38
+ class HtmlExporter
39
+ # Known CDN URLs for components that lazy-load scripts
40
+ CDN_MERMAID = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"
41
+ # The classic/global build (sets globalThis.mermaid), not the ESM one
42
+ # above -- used only by `offline:`, which inlines it as a plain
43
+ # <script> so it's covered by a CSP's 'unsafe-inline', not its
44
+ # (often absent) external-host allowlist. See #mermaid_offline_script_tag.
45
+ CDN_MERMAID_OFFLINE = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"
46
+ CDN_CHARTJS = "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"
47
+ CDN_PRISMJS_CSS = "https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css"
48
+ CDN_PRISMJS_JS = "https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js"
49
+ CDN_PRISMJS_AUTOLOADER = "https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js"
50
+ CDN_ALPINE = "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
51
+ CDN_GOOGLE_FONTS = "https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;500;600&family=Source+Sans+3:wght@400;500;600;700&display=swap"
52
+
53
+ # Ground truth for "does this export need Alpine.js" -- see
54
+ # #collect_cdn_scripts.
55
+ ALPINE_DIRECTIVE = /\sx-data=/
56
+
57
+ # A canvas-doc DSL fragment is a bare list of component calls -- it is
58
+ # instance_eval'd against an App the caller already made. A full
59
+ # standalone app file builds its own App and starts a server, so
60
+ # eval'ing one here either dies deep inside the DSL or boots a web
61
+ # server mid-export; a plain textual check catches it up front with a
62
+ # message that says what the input should have been.
63
+ #
64
+ # Deliberately literal, and it does not know about strings or comments:
65
+ # a doc whose code_block quotes `App.new` is a false positive. That
66
+ # trade is the right way round -- the export refuses with an
67
+ # explanation, versus silently hanging on a booted server.
68
+ FULL_APP_MARKERS = /App\.new|\.run!/
69
+
70
+ # @param app [StreamWeaver::App] The app to export
71
+ # @param state [Hash] State to render with (default: empty)
72
+ # @param base_dir [String, nil] Directory that relative asset paths in
73
+ # the doc resolve against (the source DSL file's dir). Defaults to
74
+ # the process working directory.
75
+ def initialize(app, state: {}, base_dir: nil)
76
+ @app = app
77
+ @state = state
78
+ @base_dir = base_dir
79
+ # An exported file has no server behind it at all, so a deck renders
80
+ # read-only rather than offering /deck/* calls nothing will answer.
81
+ @adapter = StreamWeaver::Adapter::AlpineJS.new(deck_server: false)
82
+ end
83
+
84
+ # Build an exporter from a canvas-doc DSL fragment -- the shape a
85
+ # docs/streamweaver_canvas/*.rb file or a history snapshot has: bare
86
+ # component calls, no requires, no App.new/run! wrapper of its own.
87
+ #
88
+ # @param dsl [String] the fragment
89
+ # @param path [String, nil] source file, used for the <title>, for
90
+ # relative asset resolution, and so a DSL error names the real file
91
+ # @param theme [Symbol, nil] fallback for a doc with no use_theme
92
+ # @param layout [Symbol, nil] fallback for a doc with no use_layout
93
+ # @raise [InvalidDslError] when the fragment looks like a full app file
94
+ def self.from_dsl(dsl, path: nil, theme: nil, layout: nil)
95
+ if dsl.match?(FULL_APP_MARKERS)
96
+ raise InvalidDslError,
97
+ "expected a canvas-doc DSL fragment (bare component calls) but found " \
98
+ "App.new/run! -- export takes the same input as `streamweaver canvas-push`, " \
99
+ "not a standalone app file"
100
+ end
101
+
102
+ title = path ? File.basename(path.to_s, '.rb') : 'StreamWeaver Export'
103
+ app = StreamWeaver::App.new(title, theme: theme || :default, layout: layout || :default)
104
+ app.instance_eval(dsl, path.to_s, 1)
105
+ new(app, base_dir: path && File.dirname(File.expand_path(path)))
106
+ end
107
+
108
+ # Reads `path` and builds an exporter from it. See .from_dsl.
109
+ def self.from_dsl_file(path, theme: nil, layout: nil)
110
+ from_dsl(File.read(path), path: path, theme: theme, layout: layout)
111
+ end
112
+
113
+ # A download-safe "<name>.html" derived from a source DSL path, allowing
114
+ # the same character set DocStore's doc-name allowlist does. DocStore
115
+ # raises on a bad name because a human typed it; here the name comes
116
+ # from a filesystem path the user didn't choose for this purpose, so we
117
+ # sanitize and fall back rather than fail the export.
118
+ def self.export_filename(source_path)
119
+ name = File.basename(source_path.to_s, '.rb')
120
+ .gsub(/[^A-Za-z0-9._-]+/, '-')
121
+ .gsub(/\.{2,}/, '.')
122
+ .sub(/\A[^A-Za-z0-9]+/, '')
123
+ "#{name.empty? ? 'export' : name}.html"
124
+ end
125
+
126
+ # Export to a file
127
+ #
128
+ # @param path [String] Output file path
129
+ # @param inline_images [Boolean] Convert image src to base64 data URIs
130
+ # @param offline [Boolean] Fetch and inline mermaid's own library
131
+ # instead of referencing its CDN, so a diagram renders in a viewer
132
+ # whose CSP blocks every external host (SharePoint's HTML preview,
133
+ # etc.) -- see #mermaid_offline_script_tag. Requires network access
134
+ # at export time; no effect on docs with no mermaid component.
135
+ # @return [String] The output file path
136
+ def export(path:, inline_images: false, offline: false)
137
+ html = to_html(inline_images: inline_images, offline: offline)
138
+ dir = File.dirname(path)
139
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
140
+ File.write(path, html)
141
+ path
142
+ end
143
+
144
+ # Generate self-contained HTML string
145
+ #
146
+ # @param inline_images [Boolean] Convert local image src to base64 data URIs
147
+ # @param offline [Boolean] see #export
148
+ # @return [String] Complete HTML document
149
+ def to_html(inline_images: false, offline: false)
150
+ # Only block-built apps can be rebuilt. A canvas doc is instance_eval'd
151
+ # from a DSL *string* into a bare App, so @block is nil there and
152
+ # rebuilding would re-evaluate nothing, wiping every component
153
+ # (stream_weaver-65z).
154
+ # ALL_DEFERRED: a static export has no client to run a deferred
155
+ # fragment's auto-fetch, so every deferred block runs inline here or its
156
+ # content is silently lost to a placeholder.
157
+ @app.rebuild_with_state(@state, deferred_target: App::ALL_DEFERRED) if @app.block
158
+
159
+ body_html = render_body
160
+ body_html = inline_images_in_html(body_html) if inline_images
161
+ inline_mermaid = offline && components_include?(Components::Mermaid)
162
+
163
+ build_document(
164
+ title: @app.title,
165
+ css_html: css_html,
166
+ cdn_scripts: collect_cdn_scripts(body_html, skip_mermaid: inline_mermaid),
167
+ cdn_styles: collect_cdn_styles,
168
+ inline_scripts: inline_mermaid ? [mermaid_offline_script_tag] : [],
169
+ body_html: body_html
170
+ )
171
+ end
172
+
173
+ # Class-level convenience method
174
+ #
175
+ # @param app [StreamWeaver::App] The app to export
176
+ # @param path [String] Output file path
177
+ # @param inline_images [Boolean] Convert local images to base64
178
+ # @param offline [Boolean] see #export
179
+ # @return [String] The output file path
180
+ def self.export(app, path:, inline_images: false, offline: false, state: {})
181
+ new(app, state: state).export(path: path, inline_images: inline_images, offline: offline)
182
+ end
183
+
184
+ private
185
+
186
+ # Render body components to HTML string via Phlex
187
+ def render_body
188
+ StreamWeaver::ComponentRenderer.render_html(@adapter, @app.components, @state)
189
+ end
190
+
191
+ # Fetches mermaid's classic global build (sets globalThis.mermaid --
192
+ # not the ESM build CDN_MERMAID points at) and inlines it as a plain
193
+ # <script>. A plain inline script is covered by a CSP's
194
+ # 'unsafe-inline', so it runs even in a viewer whose script-src
195
+ # doesn't allowlist any external host at all (stream_weaver-dnq) --
196
+ # the gap the non-offline fix (stream_weaver-4gs) couldn't close,
197
+ # since that one still referenced mermaid's CDN URL.
198
+ #
199
+ # sw-mermaid-zoom.js's loadMermaid() checks for this global before
200
+ # attempting its own dynamic import, so the rest of the mermaid
201
+ # pipeline (theme vars, zoom/pan, re-init on swap) is unchanged.
202
+ #
203
+ # Does not cover ELK layout (`elk: true` diagrams): ELK ships as a
204
+ # separate CDN module with no equivalent global build to fetch here,
205
+ # so an ELK diagram still needs network access to render even in an
206
+ # offline export. Rare enough (most diagrams use the default layout)
207
+ # that this is a documented limitation, not a blocker for v1.
208
+ #
209
+ # @raise [OfflineAssetError] if the fetch fails -- offline export
210
+ # still needs network access at *export* time, just not at *view*
211
+ # time; the message says so rather than surfacing a raw Net::HTTP
212
+ # or timeout error.
213
+ def mermaid_offline_script_tag
214
+ "<script>#{escape_inline_script(fetch_url(CDN_MERMAID_OFFLINE))}</script>"
215
+ rescue SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, RuntimeError => e
216
+ raise OfflineAssetError,
217
+ "offline export couldn't fetch mermaid's library from #{CDN_MERMAID_OFFLINE} " \
218
+ "(#{e.message}) -- offline export still needs network access once, at export " \
219
+ "time, to embed the library; retry with a connection, or export without --offline"
220
+ end
221
+
222
+ # `</script` and `<!--` are the two sequences that can terminate or
223
+ # mis-nest a <script> element's raw text -- the HTML tokenizer closes
224
+ # a script at the first `</script` it sees (case-insensitively, even
225
+ # inside a JS string/template literal/regex), and `<!--` inside a
226
+ # script's raw text starts "script data escaped state", after which
227
+ # the *next* `</script>` doesn't close the element either. Escaped
228
+ # this way both are inert to the tokenizer but unchanged to the JS
229
+ # engine: inside JS, `<\/script` and `<\!--` mean exactly `</script`
230
+ # and `<!--`, and neither is otherwise valid syntax on its own.
231
+ #
232
+ # Scoped to `</` followed specifically by "script" (a lookahead, not
233
+ # consumed) -- NOT every bare `</`. A first draft escaped every `</`
234
+ # and it corrupted real code: mermaid's own minified source contains
235
+ # `/</g` (a regex matching a literal "<"), and blindly inserting a
236
+ # backslash turned it into the invalid regex literal `/<\/g`, a JS
237
+ # syntax error that silently broke the entire inlined script --
238
+ # caught by a live browser check under a CSP that reproduces
239
+ # SharePoint's, not by any spec (none of them parse the output as JS).
240
+ #
241
+ # Correctness here can't rely on mermaid's own minifier already
242
+ # avoiding literal `</script`/`<!--` in its OUTPUT (it does, today) --
243
+ # CDN_MERMAID_OFFLINE is a floating minor-version URL re-fetched on
244
+ # every offline export, so this has to hold regardless of what
245
+ # jsDelivr serves tomorrow.
246
+ def escape_inline_script(js)
247
+ js.gsub(%r{</(?=script)}i, '<\/').gsub('<!--', '<\!--')
248
+ end
249
+
250
+ # GET url, following redirects, with a timeout generous enough for
251
+ # mermaid's multi-MB bundle on a slow connection.
252
+ def fetch_url(url, redirects_left: 5)
253
+ raise "too many redirects fetching #{url}" if redirects_left <= 0
254
+
255
+ uri = URI(url)
256
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
257
+ open_timeout: 15, read_timeout: 60) do |http|
258
+ http.get(uri)
259
+ end
260
+
261
+ case response
262
+ when Net::HTTPSuccess
263
+ # Net::HTTPResponse#body comes back ASCII-8BIT regardless of the
264
+ # response's actual charset -- force it to UTF-8 (what every
265
+ # asset this fetches in practice is) rather than let a later
266
+ # string interpolation against UTF-8 content (build_document's
267
+ # heredoc) raise Encoding::CompatibilityError deep in an
268
+ # unrelated method.
269
+ body = response.body.dup.force_encoding(Encoding::UTF_8)
270
+ raise "response body is not valid UTF-8" unless body.valid_encoding?
271
+
272
+ body
273
+ when Net::HTTPRedirection
274
+ # Location may be relative (RFC 7231) -- resolve against the URL
275
+ # that returned it rather than assuming an absolute URL.
276
+ redirect_uri = URI.join(uri, response['location'])
277
+ raise "redirect to non-https URL: #{redirect_uri}" unless redirect_uri.scheme == 'https'
278
+
279
+ fetch_url(redirect_uri.to_s, redirects_left: redirects_left - 1)
280
+ else
281
+ raise "HTTP #{response.code}"
282
+ end
283
+ end
284
+
285
+ # The <head> style block, in the same cascade order the canvas and the
286
+ # reader use (PageShell is the single source of truth): framework layer,
287
+ # then the app's registered theme, then unlayered user CSS last so it
288
+ # outranks everything.
289
+ def css_html
290
+ parts = [StreamWeaver::PageShell.framework_css_html]
291
+
292
+ # Registered custom themes are NOT part of master_theme_css -- AppView
293
+ # emits them separately via render_custom_theme_css, and so must we.
294
+ if (theme = custom_theme)
295
+ parts << "<style>#{StreamWeaver::CSS.layer_wrap(theme.to_css)}</style>"
296
+ end
297
+
298
+ parts << StreamWeaver::PageShell.user_css_html(inline_stylesheets: @app.inline_stylesheets)
299
+ parts.join("\n")
300
+ end
301
+
302
+ def custom_theme
303
+ return nil if @app.theme.nil? || StreamWeaver::App::BUILT_IN_THEMES.include?(@app.theme)
304
+
305
+ StreamWeaver.get_theme(@app.theme)
306
+ end
307
+
308
+ def body_class
309
+ "sw-theme-#{@app.theme} sw-layout-#{@app.layout}"
310
+ end
311
+
312
+ # Collect CDN script URLs based on what's actually in the rendered
313
+ # body.
314
+ #
315
+ # htmx/idiomorph are never included here: a static export has no
316
+ # server to talk to, so they're pure dead weight (and, on a
317
+ # CSP-locked-down viewer such as SharePoint's HTML preview, dead
318
+ # weight that also fails to load at all).
319
+ #
320
+ # Alpine.js is loaded only when the rendered markup actually contains
321
+ # an `x-data=` directive. This is checked against body_html itself
322
+ # rather than an allowlist of component classes: more than a dozen
323
+ # renderers in the adapter (tabs, charts, slide containers, dropdowns,
324
+ # copy buttons, sortable tables, ...) emit Alpine directives, and an
325
+ # allowlist here would silently drift every time one of them changes
326
+ # -- exactly the bug this method used to have, just for Alpine instead
327
+ # of mermaid (stream_weaver-4gs). Mermaid needs no entry in this
328
+ # check: sw-mermaid-zoom.js self-inits without any Alpine directive.
329
+ #
330
+ # @param skip_mermaid [Boolean] true when the caller is inlining
331
+ # mermaid's library itself (see #mermaid_offline_script_tag) -- the
332
+ # external CDN reference below would be redundant and, on the CSP
333
+ # this flag exists to work around, would just be an extra blocked
334
+ # request.
335
+ def collect_cdn_scripts(body_html, skip_mermaid: false)
336
+ scripts = []
337
+
338
+ scripts << { src: CDN_ALPINE, defer: true } if body_html.match?(ALPINE_DIRECTIVE)
339
+
340
+ # Mermaid - check if any mermaid components exist
341
+ if components_include?(Components::Mermaid) && !skip_mermaid
342
+ scripts << { src: CDN_MERMAID, type: "module" }
343
+ end
344
+
345
+ # Chart.js -- keyed on the whole chart family, not just the one class
346
+ # the generic `chart type:` DSL builds. Every shorthand (bar_chart,
347
+ # pie_chart, sparkline, ...) builds a Components::ChartBase subclass,
348
+ # none of which is a Components::Chart, so gating on Chart alone
349
+ # shipped exports with no library at all; the adapter's
350
+ # `if (typeof Chart !== 'undefined')` x-init guard then swallowed it
351
+ # into an empty box with a silent console (disc-094). Naming the base
352
+ # class means a new chart type is covered the moment it subclasses.
353
+ if components_include?(Components::Chart) || components_include?(Components::ChartBase)
354
+ scripts << { src: CDN_CHARTJS }
355
+ end
356
+
357
+ # Prism.js for code blocks
358
+ if components_include?(Components::CodeBlock)
359
+ scripts << { src: CDN_PRISMJS_JS }
360
+ scripts << { src: CDN_PRISMJS_AUTOLOADER }
361
+ end
362
+
363
+ # Custom scripts from the app
364
+ @app.scripts.each do |src|
365
+ scripts << { src: src }
366
+ end
367
+
368
+ scripts
369
+ end
370
+
371
+ # Collect CDN stylesheet URLs
372
+ def collect_cdn_styles
373
+ styles = []
374
+
375
+ # Google Fonts
376
+ styles << CDN_GOOGLE_FONTS
377
+
378
+ # Prism.js theme CSS
379
+ if components_include?(Components::CodeBlock)
380
+ styles << CDN_PRISMJS_CSS
381
+ end
382
+
383
+ # Custom stylesheets from the app
384
+ @app.stylesheets.each do |href|
385
+ styles << href
386
+ end
387
+
388
+ styles
389
+ end
390
+
391
+ # Check if any component (including nested) is an instance of a class
392
+ def components_include?(klass)
393
+ check_components(@app.components, klass)
394
+ end
395
+
396
+ def check_components(components, klass)
397
+ components.any? do |c|
398
+ c.is_a?(klass) ||
399
+ (c.respond_to?(:children) && c.children && check_components(c.children, klass))
400
+ end
401
+ end
402
+
403
+ # Replace local image paths with base64 data URIs in HTML.
404
+ # Relative paths resolve against the source DSL file's directory, not
405
+ # Dir.pwd -- a doc referencing ./diagram.png means the one next to it,
406
+ # wherever the export happens to be run from.
407
+ def inline_images_in_html(html)
408
+ html.gsub(/src="((?!data:|https?:\/\/)[^"]+)"/) do |match|
409
+ path = File.expand_path($1, @base_dir || Dir.pwd)
410
+ if File.exist?(path)
411
+ mime = case File.extname(path).downcase
412
+ when '.png' then 'image/png'
413
+ when '.jpg', '.jpeg' then 'image/jpeg'
414
+ when '.gif' then 'image/gif'
415
+ when '.svg' then 'image/svg+xml'
416
+ when '.webp' then 'image/webp'
417
+ else 'application/octet-stream'
418
+ end
419
+ data = Base64.strict_encode64(File.binread(path))
420
+ "src=\"data:#{mime};base64,#{data}\""
421
+ else
422
+ match
423
+ end
424
+ end
425
+ end
426
+
427
+ # Build the complete HTML document
428
+ #
429
+ # @param inline_scripts [Array<String>] raw "<script>...</script>"
430
+ # blocks (offline-fetched assets) emitted after cdn_scripts, so an
431
+ # inlined replacement -- e.g. mermaid's library -- is present before
432
+ # any script later in the document expects it.
433
+ def build_document(title:, css_html:, cdn_scripts:, cdn_styles:, body_html:, inline_scripts: [])
434
+ tags = cdn_scripts.map { |s| cdn_script_tag(s) } + inline_scripts
435
+ script_tags = tags.join("\n ")
436
+
437
+ style_tags = cdn_styles.map do |href|
438
+ "<link rel=\"stylesheet\" href=\"#{href}\">"
439
+ end.join("\n ")
440
+
441
+ <<~HTML
442
+ <!DOCTYPE html>
443
+ <html lang="en">
444
+ <head>
445
+ <meta charset="utf-8">
446
+ <meta name="viewport" content="width=device-width, initial-scale=1">
447
+ <title>#{escape_html(title)}</title>
448
+ <link rel="preconnect" href="https://fonts.googleapis.com">
449
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
450
+ #{style_tags}
451
+ #{script_tags}
452
+ #{css_html}
453
+ </head>
454
+ <body class="#{body_class}">
455
+ <!-- #app-container is a DIRECT body child, as the canvas and reader
456
+ render it: theme/sidebar_toc selectors use the ">" combinator
457
+ against body[class*="sw-layout-"] > #app-container. -->
458
+ <div id="app-container">
459
+ #{body_html}
460
+ </div>
461
+ </body>
462
+ </html>
463
+ HTML
464
+ end
465
+
466
+ def cdn_script_tag(script)
467
+ attrs = ["src=\"#{escape_html(script[:src])}\""]
468
+ attrs << "defer" if script[:defer]
469
+ attrs << "type=\"#{script[:type]}\"" if script[:type]
470
+ "<script #{attrs.join(' ')}></script>"
471
+ end
472
+
473
+ def escape_html(text)
474
+ text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
475
+ end
476
+ end
477
+ end
478
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+
6
+ module StreamWeaver
7
+ # HTTP push client for sending targeted DOM updates to a running StreamWeaver app.
8
+ # Accepts either raw HTML strings or component DSL blocks.
9
+ #
10
+ # @example Raw HTML
11
+ # feed = StreamWeaver.connect("Live Monitor")
12
+ # feed.replace("#clock", "<span>#{Time.now}</span>")
13
+ #
14
+ # @example Component DSL
15
+ # feed.replace("#metric-rps") do
16
+ # card { stat_display value: 3500, label: "REQ/SEC", color: :blue }
17
+ # end
18
+ class Feed
19
+ include Pushable
20
+
21
+ attr_reader :url
22
+
23
+ def initialize(url)
24
+ @url = url
25
+ end
26
+
27
+ private
28
+
29
+ def push_update(action:, target:, html:)
30
+ uri = URI("#{@url}/stream/push")
31
+ Net::HTTP.post_form(uri, target: target, action: action.to_s, html: html)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StreamWeaver
4
+ # Display-only component DSL for feed context.
5
+ # Uses the same DisplayDSL as App but omits interactive components.
6
+ #
7
+ # @example
8
+ # components = FeedBuilder.build do
9
+ # card { stat_display value: 42, label: "RPS", color: :blue }
10
+ # end
11
+ class FeedBuilder
12
+ include DisplayDSL
13
+
14
+ attr_accessor :components
15
+
16
+ def initialize(state = nil)
17
+ @components = []
18
+ @_state = state
19
+ end
20
+
21
+ def state
22
+ @_state
23
+ end
24
+
25
+ def self.build(state = nil, &block)
26
+ builder = new(state)
27
+ builder.instance_eval(&block)
28
+ builder.components
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StreamWeaver
4
+ # Builds Google Fonts link tags from a declarative fonts list.
5
+ #
6
+ # Accepts an array of:
7
+ # "Family:wght@400;700" — string shorthand (treated as Google font)
8
+ # { google: "Family:wght@400;700" } — explicit Google font
9
+ # { src: "/fonts/foo.woff2", family: "Foo" } — self-hosted (future, not yet emitted)
10
+ module Fonts
11
+ # Build a combined Google Fonts href from the entries that declare Google fonts.
12
+ #
13
+ # @param fonts [Array<String, Hash>]
14
+ # @return [String, nil] Fully-qualified googleapis CSS2 URL, or nil if no Google fonts
15
+ def self.google_fonts_href(fonts)
16
+ google_families = fonts.filter_map do |entry|
17
+ case entry
18
+ when String then entry
19
+ when Hash then entry[:google]
20
+ end
21
+ end
22
+ return nil if google_families.empty?
23
+
24
+ families = google_families.map { |f| "family=#{f}" }.join("&")
25
+ "https://fonts.googleapis.com/css2?#{families}&display=swap"
26
+ end
27
+
28
+ # Returns true if any entry is a Google font.
29
+ def self.google_fonts?(fonts)
30
+ fonts.any? { |e| e.is_a?(String) || (e.is_a?(Hash) && e.key?(:google)) }
31
+ end
32
+ end
33
+ end