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,457 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'json'
5
+
6
+ module StreamWeaver
7
+ module Canvas
8
+ # Renders the floating "Save as doc" button + Alpine.js modal that POSTs
9
+ # a rendered page's content to a `save-doc` endpoint, promoting it into
10
+ # `docs/streamweaver_canvas/`. Shared by two call sites (stream_weaver-e13):
11
+ # the live canvas page (bridge_server.rb, saving the session's current
12
+ # push) and canvas-read's history view (reader_layout.erb, promoting an
13
+ # already-rendered history snapshot). The two differ only in POST
14
+ # endpoint/body shape, where the default name comes from, and a couple of
15
+ # copy strings -- everything else (CSS, dialog structure, org-coverage
16
+ # notice logic) was byte-near-identical and had already started to drift
17
+ # apart before this extraction.
18
+ module SaveDocWidget
19
+ module_function
20
+
21
+ # endpoint - fetch() URL to POST {name, format[, ...extra_body_fields]} to.
22
+ # button_title/dialog_title/hint_html - the copy that differs between callers.
23
+ # name_init - JS expression for the Alpine `name:` field's initial value.
24
+ # Canvas recomputes a fresh timestamp-based name every time the dialog
25
+ # opens (`''`, paired with reset_name_js); the reader's default name is
26
+ # derived from the archived snapshot's own filename, not wall-clock
27
+ # "now", so it's computed once server-side and passed in as a pre-escaped
28
+ # JSON literal (see reader_layout.erb's `name:` line for why both
29
+ # `.to_json` and `ERB::Util.h` are required together).
30
+ # reset_name_js - statement(s) run at the top of openDialog() to (re)compute
31
+ # `name`; empty when the initial value never needs refreshing. Skipped
32
+ # entirely when `gist[:prefill_name]` is present (see `gist` below) --
33
+ # an already-shared canvas prefills its shared name instead of a fresh
34
+ # timestamp, so the next save updates the same gist.
35
+ # extra_alpine_data - extra x-data keys inserted verbatim after `name:`
36
+ # (canvas: the `defaultName()` method; reader: the `file:` index the
37
+ # snapshot lives at). JS object literals don't care about key order, so
38
+ # one insertion point suffices for both callers' extra state.
39
+ # extra_body_fields - extra keys inserted verbatim into the POST JSON body,
40
+ # ahead of `name`/`format` (reader: `file: this.file, `).
41
+ # host_class - extra class on the root `x-data` div, or nil. The reader
42
+ # needs `sw-save-doc-host` (`display: contents`) because the widget
43
+ # lives inside a flex row (#sw-reader-nav) and must not become a flex
44
+ # item itself; the canvas widget has no such host and needs none.
45
+ # css_layer - `@layer` name to wrap the `<style>` block in, or nil. The
46
+ # reader's chrome is layered so it can't outrank/be outranked by
47
+ # framework CSS (see reader_layout.erb's scoping comment); the canvas
48
+ # page has no such layering scheme.
49
+ # dialog_css_extra - extra CSS declarations appended into the
50
+ # `.sw-save-doc-dialog` rule (reader adds `color`/`text-align` that the
51
+ # canvas dialog doesn't need, having inherited page defaults already).
52
+ # source_dir - absolute path of the repo the caller resolves "This
53
+ # repo" to (stream_weaver-j3b3), or nil when the caller has none to
54
+ # offer. When present, a scope toggle ("This repo (<basename>)" vs.
55
+ # "Global") is rendered above the name field, defaulting to "This
56
+ # repo"; when nil the toggle is omitted entirely and the save is
57
+ # always Global -- a manual choice, never a silent auto-resolution
58
+ # (canvas-doc-location-and-discovery.md).
59
+ # gist - kwarg for the third "Gist" destination radio (share-to-gist
60
+ # epic), or nil when the caller has no gist capability to offer at
61
+ # all -- in which case the radio is omitted entirely, same as
62
+ # source_dir: nil for the repo radio. The scope row now renders when
63
+ # EITHER source_dir OR gist is present (a canvas pushed from outside
64
+ # a git repo can still be shared). Shape:
65
+ # {
66
+ # available: true/false, # GistPublisher.gh_available? -- a
67
+ # # cheap presence check, NOT an auth
68
+ # # check. A missing token surfaces
69
+ # # as a save-time error, not a
70
+ # # hidden option (Gloria's Law: show
71
+ # # the capability and the one thing
72
+ # # that unblocks it).
73
+ # unavailable_reason: "...", # helper text shown next to the
74
+ # # radio -- rendered disabled --
75
+ # # when available is false; unused
76
+ # # when true.
77
+ # known: {"doc-name" => {url:, revisions:}, ...},
78
+ # # the gist(s) relevant to THIS
79
+ # # dialog -- the caller scopes this
80
+ # # down (e.g. via GistStore
81
+ # # #latest_for_prefix for the
82
+ # # current canvas), not the whole
83
+ # # install's history, since it's
84
+ # # embedded into every page render.
85
+ # # Keyed by the doc's base name (no
86
+ # # extension). Embedded as a JSON
87
+ # # literal and looked up live, as
88
+ # # the name field
89
+ # # is edited, via the Alpine
90
+ # # currentGist() helper -- drives
91
+ # # the "already shared · N
92
+ # # revisions" hint, the URL preview
93
+ # # line, and the action button's
94
+ # # Update-gist/Create-gist label.
95
+ # prefill_name: "already-shared-name" or nil,
96
+ # # when a gist is already known for
97
+ # # the canvas this dialog belongs
98
+ # # to, openDialog() uses this as the
99
+ # # initial `name` INSTEAD OF the
100
+ # # caller's reset_name_js timestamp
101
+ # # logic, so the very next save
102
+ # # updates that gist rather than
103
+ # # minting a second one.
104
+ # }
105
+ # Gist is never the default scope -- This-repo/Global remain the only
106
+ # defaults (`scope: '#{source_dir ? 'repo' : 'global'}'` below is
107
+ # unchanged); gist is only ever chosen by an explicit user click.
108
+ def render(
109
+ endpoint:,
110
+ button_title:,
111
+ dialog_title:,
112
+ hint_html:,
113
+ name_init:,
114
+ source_dir: nil,
115
+ reset_name_js: '',
116
+ extra_alpine_data: '',
117
+ extra_body_fields: '',
118
+ host_class: nil,
119
+ css_layer: nil,
120
+ dialog_css_extra: '',
121
+ gist: nil
122
+ )
123
+ style_open = css_layer ? "<style>@layer #{css_layer} {" : '<style>'
124
+ style_close = css_layer ? '}</style>' : '</style>'
125
+ host_css = host_class ? "\n .#{host_class} { display: contents; }" : ''
126
+ host_attr = host_class ? %( class="#{host_class}") : ''
127
+ scope_html = scope_toggle_html(source_dir, gist)
128
+
129
+ # Embedded verbatim into the `x-data="{ ... }"` HTML attribute below,
130
+ # so any quotes the JSON produces must be HTML-escaped the same way
131
+ # reader_layout.erb's `name_init` already is (see its doc comment
132
+ # above) or they'd terminate the attribute early. gistKnown must
133
+ # always serialize to a JS object literal (never `null`) since it's
134
+ # indexed by name below -- `gist[:known]` defaults to `{}` whether
135
+ # `gist` itself, or just its `known:` key, is missing.
136
+ gist_known = (gist && gist[:known]) || {}
137
+ gist_known_json = ERB::Util.h(gist_known.to_json)
138
+
139
+ gist_prefill = gist && gist[:prefill_name]
140
+ gist_prefill_json = ERB::Util.h(gist_prefill.to_json)
141
+
142
+ <<~HTML
143
+ #{style_open}
144
+ [x-cloak] { display: none !important; }#{host_css}
145
+ .sw-save-doc-btn {
146
+ position: fixed; bottom: 1rem; right: 1rem; z-index: 50;
147
+ background: var(--sw-color-primary, #1f6feb); color: #fff;
148
+ border: none; border-radius: 999px; padding: 0.55rem 1rem;
149
+ font-size: 0.85rem; font-weight: 600; cursor: pointer;
150
+ box-shadow: 0 6px 16px rgba(28,25,23,0.18);
151
+ opacity: 0.85; transition: opacity 120ms ease, transform 120ms ease;
152
+ }
153
+ .sw-save-doc-btn:hover { opacity: 1; transform: translateY(-1px); }
154
+ .sw-save-doc-modal {
155
+ position: fixed; inset: 0; z-index: 60;
156
+ background: rgba(15, 17, 23, 0.45);
157
+ display: flex; align-items: center; justify-content: center;
158
+ }
159
+ .sw-save-doc-dialog {
160
+ background: #fff; border-radius: 8px; padding: 1.5rem;
161
+ width: min(440px, 90vw); box-shadow: 0 20px 50px rgba(0,0,0,0.25);
162
+ font-family: 'Source Sans 3', system-ui, sans-serif;#{dialog_css_extra}
163
+ }
164
+ .sw-save-doc-dialog h3 { margin: 0 0 0.5rem 0; font-size: 1.1rem; }
165
+ .sw-save-doc-dialog p.hint {
166
+ margin: 0 0 1rem 0; color: #6b7280; font-size: 0.85rem;
167
+ }
168
+ .sw-save-doc-scope {
169
+ display: flex; gap: 1rem; margin: 0 0 0.5rem 0;
170
+ font-size: 0.85rem; flex-wrap: wrap;
171
+ }
172
+ .sw-save-doc-scope label {
173
+ display: flex; align-items: center; gap: 0.35rem; cursor: pointer;
174
+ }
175
+ .sw-save-doc-scope label:has(input:disabled) { cursor: not-allowed; opacity: 0.6; }
176
+ .sw-save-doc-scope-path {
177
+ margin: 0 0 0.75rem 0; color: #6b7280; font-size: 0.78rem;
178
+ font-family: ui-monospace, monospace; word-break: break-all;
179
+ }
180
+ .sw-save-doc-scope-hint {
181
+ color: #6b7280; font-size: 0.78rem;
182
+ }
183
+ .sw-save-doc-scope-reason {
184
+ margin: 0 0 0.75rem 0; color: #b45309; font-size: 0.78rem;
185
+ }
186
+ .sw-save-doc-dialog input[type=text] {
187
+ width: 100%; padding: 0.55rem 0.75rem;
188
+ border: 1px solid #d1d5db; border-radius: 5px;
189
+ font-size: 0.95rem; font-family: ui-monospace, monospace;
190
+ box-sizing: border-box;
191
+ }
192
+ .sw-save-doc-dialog input[type=text]:focus {
193
+ outline: 2px solid var(--sw-color-primary, #1f6feb); outline-offset: -1px;
194
+ border-color: transparent;
195
+ }
196
+ .sw-save-doc-error {
197
+ margin-top: 0.75rem; padding: 0.5rem 0.75rem;
198
+ background: #fee2e2; color: #991b1b; border-radius: 4px;
199
+ font-size: 0.85rem;
200
+ }
201
+ .sw-save-doc-success {
202
+ margin-top: 0.75rem; padding: 0.5rem 0.75rem;
203
+ background: #dcfce7; color: #166534; border-radius: 4px;
204
+ font-size: 0.85rem; word-break: break-all;
205
+ }
206
+ .sw-save-doc-success a { color: #166534; }
207
+ .sw-save-doc-notice {
208
+ margin-top: 0.5rem; padding: 0.5rem 0.75rem;
209
+ background: #fef9c3; color: #854d0e; border-radius: 4px;
210
+ font-size: 0.85rem;
211
+ }
212
+ .sw-save-doc-actions {
213
+ display: flex; justify-content: flex-end; gap: 0.5rem;
214
+ margin-top: 1rem;
215
+ }
216
+ .sw-save-doc-actions button {
217
+ padding: 0.45rem 1rem; border-radius: 5px;
218
+ font-size: 0.9rem; cursor: pointer; border: 1px solid transparent;
219
+ }
220
+ .sw-save-doc-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
221
+ .sw-save-doc-cancel { background: #f3f4f6; color: #374151; border-color: #d1d5db; }
222
+ .sw-save-doc-cancel:hover:not(:disabled) { background: #e5e7eb; }
223
+ .sw-save-doc-save {
224
+ background: var(--sw-color-primary, #1f6feb); color: #fff;
225
+ }
226
+ .sw-save-doc-save:hover:not(:disabled) { filter: brightness(1.05); }
227
+ .sw-save-doc-save-org {
228
+ background: #fff; color: var(--sw-color-primary, #1f6feb);
229
+ border-color: var(--sw-color-primary, #1f6feb);
230
+ }
231
+ .sw-save-doc-save-org:hover:not(:disabled) { background: #eff6ff; }
232
+ .sw-save-doc-save-gist {
233
+ background: #fff; color: #166534; border-color: #166534;
234
+ }
235
+ .sw-save-doc-save-gist:hover:not(:disabled) { background: #f0fdf4; }
236
+ #{style_close}
237
+ <div x-data="{
238
+ open: false,
239
+ name: #{name_init},
240
+ scope: '#{source_dir ? 'repo' : 'global'}',
241
+ #{extra_alpine_data}format: 'rb',
242
+ saving: false,
243
+ savedPath: null,
244
+ error: null,
245
+ coverage: null,
246
+ notice: null,
247
+ gistKnown: #{gist_known_json},
248
+ gistPrefill: #{gist_prefill_json},
249
+ gistResult: null,
250
+ openDialog() {
251
+ this.error = null; this.savedPath = null; this.coverage = null; this.notice = null; this.gistResult = null;
252
+ if (this.gistPrefill) {
253
+ this.name = this.gistPrefill;
254
+ } else {
255
+ #{reset_name_js}
256
+ }
257
+ this.format = 'rb';
258
+ this.open = true;
259
+ this.$nextTick(() => this.$refs.input && this.$refs.input.select());
260
+ },
261
+ // The single definition of 'what key does this name map to in
262
+ // gistKnown' -- shared by currentGist() (read) and save()'s
263
+ // record-the-new-gist step (write) so the two can never drift.
264
+ // A drift would mean a just-saved gist gets recorded under a key
265
+ // currentGist() can't find, the button would stay on 'Create
266
+ // gist', and the next save would mint a second gist -- exactly
267
+ // what prefill_name exists to prevent. Strips a .rb/.org
268
+ // extension in case the field ever carries one.
269
+ gistKey(name) {
270
+ return (name || '').replace(/\\.(rb|org)$/, '');
271
+ },
272
+ // Looks up the typed name in gistKnown -- live, as the name
273
+ // field is edited -- so the scope hint, URL preview, and the
274
+ // gist action button's label all stay in sync with what's
275
+ // actually already shared under that name. Returns null when
276
+ // nothing is known for the current name.
277
+ currentGist() {
278
+ return this.gistKnown[this.gistKey(this.name)] || null;
279
+ },
280
+ // Tiered copy for the org-coverage notice (Phase 2 design spec's
281
+ // Save-as-Org UX section, including its 2026-08-14 copy-accuracy
282
+ // correction: N/M must name passthrough_verbatim/passthrough_lossy
283
+ // specifically, not a conflated total-recognized count, or a doc
284
+ // with mostly-verbatim elements gets told MORE of it is lossy
285
+ // than actually is). Only ever non-null after a format=org save
286
+ // whose response included coverage; a plain .rb save leaves
287
+ // coverage null so this stays hidden.
288
+ orgNotice() {
289
+ if (!this.coverage) return null;
290
+ const { total, recognized, passthrough_verbatim, passthrough_lossy, omitted } = this.coverage;
291
+ if (recognized === total) return null;
292
+ let body;
293
+ if (passthrough_verbatim > 0 && passthrough_lossy > 0) {
294
+ body = `${passthrough_verbatim} element(s) will show as raw code in a plain org viewer (nothing lost); ${passthrough_lossy} more can't be verbatim-recovered and will be replaced with a placeholder comment instead — those specific parts won't survive the round trip.`;
295
+ } else if (passthrough_lossy > 0) {
296
+ body = `${passthrough_lossy} element(s) can't be verbatim-recovered and will be replaced with a placeholder comment — those specific parts won't survive the round trip.`;
297
+ } else if (passthrough_verbatim > 0) {
298
+ body = `${passthrough_verbatim} element(s) will show as raw code in a plain org viewer — nothing is lost, just not styled.`;
299
+ } else {
300
+ body = '';
301
+ }
302
+ // Interactive controls are DROPPED, not passed through: an org
303
+ // document is static and cannot hold a live form (Org::Writer
304
+ // #omitted_control). Say so plainly and point at the format
305
+ // that does keep them, rather than folding them into the
306
+ // raw-code copy above, which would be a lie about where they went.
307
+ if (omitted > 0) {
308
+ const dropped = `${omitted} interactive control(s) (form fields, buttons) are left out — an org document can't hold a live form; save as .rb to keep them.`;
309
+ body = body ? `${dropped} ${body}` : dropped;
310
+ }
311
+ return recognized / total < 0.5
312
+ ? `This looks like an app, not a document — Org format won't add much here. ${body}`
313
+ : body;
314
+ },
315
+ async save() {
316
+ if (this.saving) return;
317
+ this.saving = true; this.error = null; this.coverage = null; this.notice = null; this.gistResult = null;
318
+ try {
319
+ const res = await fetch('#{endpoint}', {
320
+ method: 'POST',
321
+ headers: {'Content-Type': 'application/json'},
322
+ body: JSON.stringify({#{extra_body_fields}name: this.name, format: this.format, scope: this.scope})
323
+ });
324
+ const data = await res.json();
325
+ if (res.ok && data.ok) {
326
+ this.savedPath = data.path;
327
+ this.coverage = data.coverage || null;
328
+ this.notice = this.orgNotice();
329
+ if (data.gist_url) {
330
+ // A gist save's payload IS the URL, not a path -- keep
331
+ // the dialog open until the user dismisses it (no
332
+ // auto-close below) and remember it under this name so
333
+ // the button/hint flip to 'Update gist' immediately.
334
+ this.gistResult = { url: data.gist_url, revisions: data.revisions };
335
+ this.gistKnown[this.gistKey(this.name)] = { url: data.gist_url, revisions: data.revisions };
336
+ // Auto-copy for zero-friction hand-off to a coworker.
337
+ // Clipboard access can fail (permissions, insecure
338
+ // context) -- that must never hide the URL, which stays
339
+ // visible and manually selectable in gistResult below
340
+ // regardless of whether the copy itself succeeded.
341
+ try {
342
+ await navigator.clipboard.writeText(data.gist_url);
343
+ } catch (e) {
344
+ // ignored -- URL is still shown and selectable below
345
+ }
346
+ } else {
347
+ // A tiered notice can be a full sentence or two -- the
348
+ // default 1.8s auto-close (tuned for a one-line success
349
+ // confirmation) isn't enough time to read it.
350
+ setTimeout(() => { this.open = false; }, this.notice ? 6000 : 1800);
351
+ }
352
+ } else {
353
+ this.error = data.error || ('HTTP ' + res.status);
354
+ }
355
+ } catch (e) {
356
+ this.error = e.message;
357
+ } finally {
358
+ this.saving = false;
359
+ }
360
+ }
361
+ }"#{host_attr} @keydown.escape.window="open = false">
362
+ <button class="sw-save-doc-btn" @click="openDialog()" title="#{button_title}">
363
+ 💾 Save as doc
364
+ </button>
365
+ <div x-show="open" x-cloak class="sw-save-doc-modal" @click.self="open = false">
366
+ <div class="sw-save-doc-dialog" @click.stop>
367
+ <h3>#{dialog_title}</h3>
368
+ <p class="hint">
369
+ #{hint_html}
370
+ </p>
371
+ #{scope_html}
372
+ <input type="text" x-model="name" x-ref="input"
373
+ @keydown.enter.prevent="save()"
374
+ :disabled="saving"
375
+ placeholder="my-canvas-doc">
376
+ <div x-show="error" x-text="error" class="sw-save-doc-error"></div>
377
+ <div x-show="savedPath && !gistResult" class="sw-save-doc-success">
378
+ ✓ Saved to <code x-text="savedPath"></code>
379
+ </div>
380
+ <div x-show="gistResult" class="sw-save-doc-success">
381
+ ✓ Gist saved · <span x-text="gistResult ? gistResult.revisions : ''"></span> revision(s)<br>
382
+ <code x-text="gistResult ? gistResult.url : ''"></code><br>
383
+ <a :href="gistResult ? gistResult.url : '#'" target="_blank">Open gist</a>
384
+ &middot;
385
+ <a :href="gistResult ? (gistResult.url + '/revisions') : '#'" target="_blank">Revisions</a>
386
+ </div>
387
+ <div x-show="notice" x-text="notice" class="sw-save-doc-notice"></div>
388
+ <div class="sw-save-doc-actions">
389
+ <button class="sw-save-doc-cancel" @click="open = false" :disabled="saving">Cancel</button>
390
+ <button class="sw-save-doc-save-org" @click="format = 'org'; save()" :disabled="saving" x-show="scope !== 'gist'" title="Save as a plain-text .org sibling file">
391
+ <span x-show="!(saving && format === 'org')">Save as Org</span>
392
+ <span x-show="saving && format === 'org'">Saving...</span>
393
+ </button>
394
+ <button class="sw-save-doc-save" @click="format = 'rb'; save()" :disabled="saving" x-show="scope !== 'gist'">
395
+ <span x-show="!(saving && format === 'rb')">Save</span>
396
+ <span x-show="saving && format === 'rb'">Saving...</span>
397
+ </button>
398
+ <!-- No format= assignment here on purpose: scope picks the destination,
399
+ format picks the serialization. A gist save always sends both files
400
+ regardless of format, so pinning format to a third value here would
401
+ only risk a future 422 against a server whitelist of ['rb', 'org']. -->
402
+ <button class="sw-save-doc-save-gist" @click="save()" :disabled="saving" x-show="scope === 'gist'">
403
+ <span x-show="!saving" x-text="currentGist() ? 'Update gist' : 'Create gist'"></span>
404
+ <span x-show="saving">Saving...</span>
405
+ </button>
406
+ </div>
407
+ </div>
408
+ </div>
409
+ </div>
410
+ HTML
411
+ end
412
+
413
+ # Builds the "This repo (<basename>)" / "Global" / "Gist" radio toggle,
414
+ # or '' when there's nothing to offer at all -- the caller-visible
415
+ # contract is `render`'s `source_dir:`/`gist:` kwargs (see `render`'s
416
+ # doc comment above). Renders when EITHER source_dir OR gist is
417
+ # present; each radio is independently optional (a canvas pushed from
418
+ # outside a git repo has no repo radio but can still offer Gist).
419
+ def scope_toggle_html(source_dir, gist)
420
+ return '' unless source_dir || gist
421
+
422
+ repo_radio = if source_dir
423
+ repo_label = "This repo (#{ERB::Util.h(File.basename(source_dir))})"
424
+ %(<label><input type="radio" x-model="scope" value="repo"> #{repo_label}</label>)
425
+ else
426
+ ''
427
+ end
428
+
429
+ gist_radio = gist ? gist_radio_html(gist) : ''
430
+ gist_reason = gist && !gist[:available] ? %(<p class="sw-save-doc-scope-reason">#{ERB::Util.h(gist[:unavailable_reason])}</p>) : ''
431
+
432
+ <<~HTML
433
+ <div class="sw-save-doc-scope">
434
+ #{repo_radio}
435
+ <label><input type="radio" x-model="scope" value="global"> Global</label>
436
+ #{gist_radio}
437
+ </div>
438
+ #{source_dir ? %(<p class="sw-save-doc-scope-path" x-show="scope === 'repo'">#{ERB::Util.h(source_dir)}</p>) : ''}
439
+ #{gist_reason}
440
+ HTML
441
+ end
442
+
443
+ # The Gist `<label>` itself. Rendered disabled (never hidden -- Gloria's
444
+ # Law: show the capability and the one thing that unblocks it) when
445
+ # `gist[:available]` is false; its `unavailable_reason` is rendered as
446
+ # helper text by the caller (`scope_toggle_html`, above), not here.
447
+ def gist_radio_html(gist)
448
+ disabled_attr = gist[:available] ? '' : ' disabled'
449
+ <<~HTML.strip
450
+ <label><input type="radio" x-model="scope" value="gist"#{disabled_attr}> Gist
451
+ <span class="sw-save-doc-scope-hint" x-show="currentGist()">(already shared &middot; <span x-text="currentGist() ? currentGist().revisions : ''"></span> revision(s))</span>
452
+ </label>
453
+ HTML
454
+ end
455
+ end
456
+ end
457
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StreamWeaver
4
+ module Canvas
5
+ # The id of the one-shot marker element a poll response's swapped-in
6
+ # html can carry to mean "land the viewer at the top of this page
7
+ # instead of following its own near-bottom scroll" (round-9 UAT --
8
+ # BridgeServer#polling_script is the consumer; University::Listener's
9
+ # SCROLL_TOP_HINT_DSL, the producer, interpolates this same constant
10
+ # rather than repeating the string).
11
+ #
12
+ # Deliberately its own file, with no other requires: the producer lives
13
+ # in University::Listener, whose subprocess (`Listener.start!` spawns it
14
+ # with `-r stream_weaver/university/listener` alone, never the full
15
+ # `stream_weaver` entry point) breaks on bridge_server.rb's own
16
+ # dependency chain (Sinatra, GistSaveHandler, the org writer's
17
+ # Components lookup) if listener.rb requires that file just to read one
18
+ # string. Both ends require this instead.
19
+ SCROLL_TOP_HINT_ID = 'sw-scroll-top-hint'
20
+ end
21
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'protocol'
4
+
5
+ module StreamWeaver
6
+ module Canvas
7
+ # Represents a single canvas session with its state and WebSocket connections.
8
+ class Session
9
+ VALID_LAYOUTS = %i[default wide full fluid].freeze
10
+
11
+ attr_reader :name, :state, :websockets, :created_at, :layout, :dsl, :theme, :stylesheets, :source_dir
12
+ attr_accessor :html, :html_version, :pane_id
13
+
14
+ # @param name [String] Session name
15
+ # @param layout [Symbol] Layout mode: :default (900px), :wide (1100px), :full (1400px), :fluid (100%)
16
+ # @param theme [Symbol] Theme name (e.g. :default, :doc)
17
+ def initialize(name, layout: :fluid, theme: :default)
18
+ @name = name
19
+ @layout = VALID_LAYOUTS.include?(layout) ? layout : :fluid
20
+ @theme = theme
21
+ @state = {}
22
+ @websockets = []
23
+ @created_at = Time.now
24
+ @html = nil
25
+ @html_version = 0
26
+ @dsl = nil
27
+ @stylesheets = []
28
+ @source_dir = nil
29
+ @pending_toasts = []
30
+ @pane_id = nil
31
+ @mutex = Mutex.new
32
+ end
33
+
34
+ # Set the rendered HTML content and increment version
35
+ # @param content [String] HTML content
36
+ def set_html(content)
37
+ @html = content
38
+ @html_version += 1
39
+ end
40
+
41
+ # Set the raw DSL for this session. Called by Bridge#handle_push only on
42
+ # successful render. Failed renders preserve the last good DSL so the
43
+ # user can still save it.
44
+ # @param content [String] Raw DSL source
45
+ def set_dsl(content)
46
+ @dsl = content
47
+ end
48
+
49
+ # Set the directory the current DSL was pushed from. Called by
50
+ # Bridge#handle_push only on successful render, same as #set_dsl -- a
51
+ # failed push must not desync source_dir from the DSL it's paired with
52
+ # (canvas-doc-location-and-discovery.md).
53
+ # @param dir [String, nil] Absolute git root path, or nil outside a repo
54
+ def set_source_dir(dir)
55
+ @source_dir = dir
56
+ end
57
+
58
+ # Replace the inline stylesheets carried by the current DSL (stream_weaver-9uk).
59
+ # Replacing rather than accumulating means a re-push with the same (or no)
60
+ # `use_stylesheet` calls never stacks duplicate <style> tags across pushes.
61
+ # @param list [Array<String>] CSS text, deduped
62
+ def set_stylesheets(list)
63
+ @stylesheets = list.uniq
64
+ end
65
+
66
+ # Update session state
67
+ # @param new_state [Hash] State to merge
68
+ def update_state(new_state)
69
+ @state.merge!(new_state)
70
+ end
71
+
72
+ # Add a WebSocket connection
73
+ # @param ws [Object] WebSocket connection
74
+ def add_websocket(ws)
75
+ @websockets << ws
76
+ end
77
+
78
+ # Remove a WebSocket connection
79
+ # @param ws [Object] WebSocket connection
80
+ def remove_websocket(ws)
81
+ @websockets.delete(ws)
82
+ end
83
+
84
+ # Broadcast a message to all connected WebSockets
85
+ # @param message [Hash] Message to send
86
+ def broadcast(message)
87
+ encoded = Protocol.encode(message)
88
+ @websockets.each { |ws| ws.send(encoded) }
89
+ end
90
+
91
+ def reset!
92
+ @state.clear
93
+ @html = nil
94
+ @stylesheets = []
95
+ @html_version += 1
96
+ @mutex.synchronize { @pending_toasts.clear }
97
+ broadcast(type: 'reset')
98
+ end
99
+
100
+ # Queue a toast for polling clients
101
+ def queue_toast(message:, variant: 'warning', duration: 0)
102
+ toast = { message: message, variant: variant, duration: duration }
103
+ @mutex.synchronize { @pending_toasts << toast }
104
+ # Also broadcast to websocket clients
105
+ broadcast(type: 'toast', **toast)
106
+ end
107
+
108
+ # Pop all pending toasts (for polling clients)
109
+ def pop_toasts
110
+ @mutex.synchronize do
111
+ toasts = @pending_toasts.dup
112
+ @pending_toasts.clear
113
+ toasts
114
+ end
115
+ end
116
+
117
+ # Return session info as a hash
118
+ # @return [Hash]
119
+ def to_h
120
+ {
121
+ name: @name,
122
+ state: @state,
123
+ websocket_count: @websockets.size,
124
+ created_at: @created_at,
125
+ pane_id: @pane_id,
126
+ theme: @theme,
127
+ layout: @layout
128
+ }
129
+ end
130
+ end
131
+ end
132
+ end