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,853 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'json'
5
+ require 'socket'
6
+ require 'sinatra/base'
7
+ require 'stream_weaver/app'
8
+ require 'stream_weaver/views'
9
+ require 'stream_weaver/adapter/alpinejs'
10
+ require 'stream_weaver/canvas/doc_store'
11
+ require 'stream_weaver/canvas/doc_roots'
12
+ require 'stream_weaver/canvas/gist_store'
13
+ require 'stream_weaver/canvas/gist_publisher'
14
+ require 'stream_weaver/canvas/gist_save_handler'
15
+ require 'stream_weaver/canvas/save_doc_widget'
16
+ require 'stream_weaver/page_shell'
17
+ require 'stream_weaver/export/html_exporter'
18
+ require 'stream_weaver/org/writer'
19
+ require 'stream_weaver/org/reader'
20
+
21
+ module StreamWeaver
22
+ module Canvas
23
+ class Reader < Sinatra::Base
24
+ include GistSaveHandler
25
+
26
+ class NoFilesError < StandardError; end
27
+
28
+ class FileList
29
+ attr_reader :files, :history_roots, :labels
30
+
31
+ # `labels` is {root_path => display_label} for multi-repo discovery
32
+ # (stream_weaver-iugu). Optional and defaulted so the single-root and
33
+ # history-root callers that predate it are untouched -- a directory
34
+ # with no label falls back to its own basename, which is exactly what
35
+ # the sidebar showed before labels existed.
36
+ def self.build(args, history_roots: [], labels: {})
37
+ files = args.flat_map do |arg|
38
+ if File.directory?(arg)
39
+ Dir.glob(File.join(arg, '*.{rb,org}')).sort
40
+ elsif File.exist?(arg) && arg.end_with?('.rb', '.org')
41
+ [File.expand_path(arg)]
42
+ else
43
+ []
44
+ end
45
+ end.uniq
46
+
47
+ raise NoFilesError, "No .rb or .org files found in: #{args.join(', ')}" if files.empty?
48
+
49
+ new(files, args: args, history_roots: history_roots, labels: labels)
50
+ end
51
+
52
+ # A list with no files, still remembering where it was looking.
53
+ # Deliberately not reachable from .build (which raises NoFilesError
54
+ # instead, so `canvas-read nosuchdir` still fails loudly at boot) --
55
+ # this is only for a list that HAD files and lost its last one while
56
+ # the reader was running (stream_weaver-uvaj).
57
+ def self.empty(args, history_roots: [], labels: {})
58
+ new([], args: args, history_roots: history_roots, labels: labels)
59
+ end
60
+
61
+ def initialize(files, args:, history_roots: [], labels: {})
62
+ @files = files
63
+ @args = args
64
+ @history_roots = history_roots.map { |p| File.expand_path(p) }
65
+ @labels = labels.each_with_object({}) { |(root, label), out| out[File.expand_path(root)] = label }
66
+ @dir_mtimes = snapshot_dir_mtimes
67
+ end
68
+
69
+ # True when a source directory's mtime has moved since this list was
70
+ # built (stream_weaver-gnj8) -- a directory's own mtime bumps when
71
+ # an entry is added or removed inside it (a Save-as-doc write, a git
72
+ # pull, a direct edit from any process), which is the only kind of
73
+ # change that affects what FILES are in this list. An existing
74
+ # file's own content changing does NOT bump its directory's mtime
75
+ # and doesn't need to: GET / already re-reads file content fresh on
76
+ # every request regardless of this cache, so only the *set* of
77
+ # files can ever go stale here.
78
+ def stale?
79
+ @dir_mtimes.any? { |dir, snapshot| current_mtime(dir) != snapshot }
80
+ end
81
+
82
+ # Rebuilds from the same source args if stale, otherwise returns
83
+ # self unchanged -- the common (nothing changed) case costs one
84
+ # stat() per source directory and nothing else.
85
+ def rebuild_if_stale
86
+ return self unless stale?
87
+
88
+ self.class.build(@args, history_roots: @history_roots, labels: @labels)
89
+ rescue NoFilesError
90
+ # Two very different situations land here and they want opposite
91
+ # answers:
92
+ #
93
+ # * Every source directory VANISHED (deleted, unmounted, or -- in
94
+ # specs -- a Dir.mktmpdir block that already exited). Keep
95
+ # serving the last-known list rather than raising: a stale list
96
+ # is still useful navigation, a crashed request is not, and the
97
+ # paths may well come back. Matches browse_entries/
98
+ # resolve_browse_dir's existing philosophy elsewhere in this
99
+ # class -- filesystem drift degrades gracefully, never 500s.
100
+ # * The directories are all still THERE and simply hold no docs
101
+ # any more -- which is exactly what deleting the last one
102
+ # produces (stream_weaver-uvaj). Here the list really is empty,
103
+ # and answering with the stale one leaves the file you just
104
+ # deleted in the rail behind a link that 500s on File.read.
105
+ if @args.any? { |arg| File.exist?(arg) }
106
+ self.class.empty(@args, history_roots: @history_roots, labels: @labels)
107
+ else
108
+ self
109
+ end
110
+ end
111
+
112
+ def groups
113
+ @files.group_by { |f| File.dirname(f) }
114
+ end
115
+
116
+ def indexed_groups
117
+ @indexed_groups ||= @files.each_with_index.group_by { |f, _| File.dirname(f) }
118
+ end
119
+
120
+ # True when dir is the same as a history root or sits underneath one.
121
+ def history_dir?(dir)
122
+ @history_roots.any? { |r| dir == r || dir.start_with?(r + '/') }
123
+ end
124
+
125
+ # Display name for a docs directory: its registered label when it has
126
+ # one (the repo it belongs to), otherwise its own basename.
127
+ def label_for(dir)
128
+ @labels[dir] || @labels[File.expand_path(dir)] || File.basename(dir)
129
+ end
130
+
131
+ # Label for an arbitrary root path, compared canonically -- the host
132
+ # repo's docs root arrives from Reader.repo_docs_root, which resolves
133
+ # via Dir.pwd and can differ from the registered spelling by a
134
+ # symlink (/tmp vs /private/tmp on macOS). nil when no group matches,
135
+ # which is what tells the filter to fall back rather than select a
136
+ # group that isn't there.
137
+ def label_for_root(path)
138
+ return nil unless path
139
+
140
+ expanded = File.expand_path(path)
141
+ return @labels[expanded] if @labels.key?(expanded)
142
+
143
+ key = DocRoots.canonical(expanded)
144
+ hit = @labels.keys.find { |k| DocRoots.canonical(k) == key }
145
+ hit && @labels[hit]
146
+ end
147
+
148
+ # Sidebar splits: docs (explicit args) render above, history (auto-collected
149
+ # snapshots from ~/.streamweaver/history/) renders below collapsed.
150
+ #
151
+ # `repo:` narrows to one label (stream_weaver-iugu). It filters which
152
+ # GROUPS render, never the file indices inside them -- ?file=N stays a
153
+ # position in the whole list, so a filtered sidebar and an unfiltered
154
+ # one address the same files and no link changes meaning when the
155
+ # filter does.
156
+ def docs_groups(repo: nil)
157
+ groups = indexed_groups.reject { |dir, _| history_dir?(dir) }
158
+ return groups unless repo
159
+
160
+ groups.select { |dir, _| label_for(dir) == repo }
161
+ end
162
+
163
+ # Labels of every docs group with at least one file, in sidebar order.
164
+ def repo_labels
165
+ indexed_groups.keys.reject { |dir| history_dir?(dir) }.map { |dir| label_for(dir) }.uniq
166
+ end
167
+
168
+ def history_groups
169
+ indexed_groups.select { |dir, _| history_dir?(dir) }
170
+ end
171
+
172
+ # Array#[] wraps on negative indices and nil.to_i is 0 -- neither is a
173
+ # valid file reference, so this refuses both centrally rather than
174
+ # every caller re-deriving the same guard (and GET / not bothering to).
175
+ def at(index)
176
+ @files[index] if index.is_a?(Integer) && index >= 0
177
+ end
178
+
179
+ def size
180
+ @files.size
181
+ end
182
+
183
+ private
184
+
185
+ def snapshot_dir_mtimes
186
+ @args.each_with_object({}) do |arg, snapshot|
187
+ snapshot[arg] = current_mtime(arg) if File.directory?(arg)
188
+ end
189
+ end
190
+
191
+ # A directory that vanished since boot reads as nil, which never
192
+ # equals a real mtime -- stale? then forces a rebuild attempt
193
+ # rather than silently trusting a cache that points at nothing.
194
+ # If genuinely nothing is left, .build's own NoFilesError still
195
+ # fires the way it always has.
196
+ def current_mtime(dir)
197
+ File.mtime(dir)
198
+ rescue SystemCallError
199
+ nil
200
+ end
201
+ end
202
+
203
+ MERMAID_ZOOM_JS = File.read(File.expand_path('../assets/js/sw-mermaid-zoom.js', __dir__))
204
+ private_constant :MERMAID_ZOOM_JS
205
+
206
+ configure do
207
+ set :views, File.expand_path('../views/canvas', __dir__)
208
+ set :bind, '127.0.0.1'
209
+ set :server, :puma
210
+ set :logging, false
211
+ end
212
+
213
+ # What Reader.render_doc hands the layout template: the rendered HTML
214
+ # plus the theme/layout/CSS the DSL declared for itself, which the shell
215
+ # needs for the <body> class and the user-CSS <style> tags.
216
+ Doc = Struct.new(:html, :theme, :layout, :inline_stylesheets, keyword_init: true)
217
+
218
+ class << self
219
+ attr_reader :default_theme, :default_layout
220
+
221
+ # Returns the configured FileList, rebuilding it first if it's gone
222
+ # stale (stream_weaver-gnj8 -- see FileList#stale?). nil-safe: a
223
+ # future Browse-only boot mode with no configured files at all
224
+ # would leave @file_list nil, which has nothing to rebuild.
225
+ # Concurrent requests both detecting staleness and both rebuilding
226
+ # is possible under Puma's threaded server -- harmless (each
227
+ # rebuild produces an equivalent, immutable FileList; worst case is
228
+ # redundant work, never corruption), so not worth a mutex for a
229
+ # single-user local dev tool.
230
+ def file_list
231
+ @file_list = @file_list.rebuild_if_stale if @file_list
232
+ @file_list
233
+ end
234
+
235
+ def configure_files!(list)
236
+ @file_list = list
237
+ end
238
+
239
+ # Fallback theme/layout for files that don't declare their own
240
+ # (`canvas-read --theme=doc --layout=fluid`). Precedence is
241
+ # DSL use_theme > CLI flag > :default/:fluid.
242
+ def configure_defaults!(theme: nil, layout: nil)
243
+ @default_theme = theme&.to_sym
244
+ @default_layout = layout&.to_sym
245
+ end
246
+
247
+ # render_doc and GET /export both need these; a single source of
248
+ # truth is what makes "the download matches what's on screen" true
249
+ # rather than aspirational -- two independent `|| :fluid`s could
250
+ # silently drift.
251
+ def fallback_theme
252
+ default_theme || :default
253
+ end
254
+
255
+ def fallback_layout
256
+ default_layout || :fluid
257
+ end
258
+
259
+ def find_available_port(start = 4800)
260
+ port = start
261
+ loop do
262
+ TCPServer.new('127.0.0.1', port).close
263
+ return port
264
+ rescue Errno::EADDRINUSE
265
+ port += 1
266
+ raise "No available port found starting from #{start}" if port > start + 100
267
+ end
268
+ end
269
+
270
+ # Absolute path of the current repo's docs root, or nil outside a
271
+ # git repo. Same resolution DocStore.path itself would use for the
272
+ # "in a repo" branch -- kept as a named shortcut (not stored state)
273
+ # for the Browse view's quick-jump link (stream_weaver-rdh).
274
+ def repo_docs_root
275
+ root = StreamWeaver::Canvas::DocStore.git_root(Dir.pwd)
276
+ root && File.join(root, StreamWeaver::Canvas::DocStore::DOCS_SUBPATH)
277
+ end
278
+
279
+ # The only two docs roots this process is allowed to delete from
280
+ # (stream_weaver-uvaj), canonicalized. Both resolve from THIS
281
+ # process's own state -- the repo canvas-read was launched in, and
282
+ # the global store -- never from what an attaching client claims,
283
+ # and never from the peer repos DocRoots surfaces for reading. A
284
+ # peer repo's docs are readable here and permanently undeletable:
285
+ # narrower blast radius wins over full reach (design doc, Delete).
286
+ def deletable_roots
287
+ [repo_docs_root, DocStore::DEFAULT_ROOT].compact.filter_map { |root| canonical_file(root) }
288
+ end
289
+
290
+ # Symlink- and `..`-resolved absolute path, or nil for anything that
291
+ # doesn't resolve to something on disk. One helper for every
292
+ # comparison below, so the delete guard and the "is this the file we
293
+ # just deleted?" lookup can't drift apart on how they canonicalize.
294
+ # expand_path first, for `~` (and its ArgumentError on `~nosuchuser`).
295
+ def canonical_file(path)
296
+ File.realpath(File.expand_path(path.to_s))
297
+ rescue SystemCallError, ArgumentError, TypeError
298
+ nil
299
+ end
300
+
301
+ # True when a sidebar group's directory is one of those two roots --
302
+ # what decides whether its rows render a delete control at all. The
303
+ # UI just stops you asking for something POST /delete-doc would
304
+ # refuse anyway; the server-side check below is the actual boundary.
305
+ def deletable_dir?(dir)
306
+ real = canonical_file(dir)
307
+ !real.nil? && deletable_roots.include?(real)
308
+ end
309
+
310
+ # The canonical absolute path when `raw` names a doc file sitting
311
+ # DIRECTLY in one of the deletable roots; nil for anything else.
312
+ #
313
+ # The containment test is equality on the canonicalized direct
314
+ # PARENT, not `start_with?` against the root. A prefix match would
315
+ # happily accept a sibling directory whose name merely begins with
316
+ # the root's (`..._canvas_evil/x.rb`), and would accept arbitrarily
317
+ # nested subdirectories the sidebar never lists. realpath resolves
318
+ # every `..` and symlink first, so neither a traversal
319
+ # (`<root>/../../../etc/x.rb`) nor a symlink planted inside the root
320
+ # pointing out of it survives the comparison -- the link resolves to
321
+ # its target, whose parent is not the root.
322
+ #
323
+ # Deliberately NOT hardened against a symlink swapped in between
324
+ # this check and the File.delete that follows: single-user local
325
+ # tool, explicitly out of the threat model (design doc, Explicitly
326
+ # declined).
327
+ def deletable_path(raw)
328
+ return nil unless raw.is_a?(String) && !raw.empty? && !raw.include?("\0")
329
+ return nil unless raw.end_with?('.rb', '.org')
330
+
331
+ real = canonical_file(raw)
332
+ return nil unless real && File.file?(real)
333
+
334
+ deletable_roots.include?(File.dirname(real)) ? real : nil
335
+ end
336
+
337
+ # Which `?file=N` to land on after a delete, read off the REBUILT
338
+ # list (file_list re-globs itself once the directory mtime moves).
339
+ # `deleted` is the removed file's index in the pre-delete list,
340
+ # `current` the index that was open in the browser.
341
+ #
342
+ # Deleting the open doc keeps the same position, because the next
343
+ # surviving doc slides into it -- clamped to the last index, which
344
+ # is what "step back one" means when the deleted doc was last. A doc
345
+ # deleted from ABOVE the open one shifts it down by one, so the same
346
+ # file stays on screen instead of the rail silently jumping. nil
347
+ # means nothing is left to open.
348
+ def index_after_delete(deleted, current)
349
+ size = file_list&.size.to_i
350
+ return nil if size.zero?
351
+
352
+ current = nil unless current.is_a?(Integer) && current >= 0
353
+ return 0 unless current
354
+
355
+ adjusted = deleted && current > deleted ? current - 1 : current
356
+ [adjusted, size - 1].min
357
+ end
358
+
359
+ # Which repo group the sidebar shows, from the `?repo=` param
360
+ # (stream_weaver-iugu). Returns a label, or nil meaning "all."
361
+ #
362
+ # - `?repo=all` (or any label with no matching group) is the explicit
363
+ # clear. An unknown label falling back to "all" rather than 404ing
364
+ # is deliberate: a bookmarked filter whose repo has since been
365
+ # deleted should still show you your docs.
366
+ # - No param at all defaults to the repo the reader process was
367
+ # launched from, so `cd myrepo && canvas-read` opens on myrepo's
368
+ # docs. Falls back to the global store, then to "all" -- launched
369
+ # from a repo with no docs of its own, showing everything beats
370
+ # showing an empty rail.
371
+ def resolve_repo_filter(raw, list)
372
+ return nil unless list
373
+
374
+ value = raw.to_s
375
+ return nil if value == 'all'
376
+ return list.repo_labels.include?(value) ? value : nil unless value.empty?
377
+
378
+ default_repo_filter(list)
379
+ end
380
+
381
+ def default_repo_filter(list)
382
+ labels = list.repo_labels
383
+ # The host repo has no label of its own to compare against -- it
384
+ # arrives as a path -- so it's matched by path. The global store
385
+ # is matched by its label instead, since that label is a constant
386
+ # DocRoots assigns and is the same thing `?repo=` addresses.
387
+ host = list.label_for_root(repo_docs_root)
388
+ return host if host && labels.include?(host)
389
+
390
+ DocRoots::GLOBAL_LABEL if labels.include?(DocRoots::GLOBAL_LABEL)
391
+ end
392
+
393
+ # {dirs:, files:} immediately under `dir` -- one level, not recursive
394
+ # (Browse navigates by clicking in, not by a pre-walked tree). Dotfiles
395
+ # excluded (matches normal file-browser expectations; a .git directory
396
+ # in the listing is noise, never something you'd navigate into here).
397
+ # Swallows ENOENT/EACCES rather than raising: a stale bookmark or a
398
+ # permission-denied directory should render an empty listing, not a 500.
399
+ def browse_entries(dir)
400
+ entries = Dir.children(dir).reject { |e| e.start_with?('.') }.sort
401
+ # partition, not two independent #select calls: a directory named
402
+ # "bundle.rb" (rare, but real -- generator fixtures do this) would
403
+ # otherwise satisfy both the dirs and files predicates and get
404
+ # listed twice, the second listing a dead link (/open 404s on it).
405
+ dirs, rest = entries.partition { |e| File.directory?(File.join(dir, e)) }
406
+ { dirs: dirs, files: rest.select { |e| e.end_with?('.rb', '.org') } }
407
+ rescue SystemCallError
408
+ # Broader than Errno::ENOENT/EACCES alone: a TCC-protected macOS
409
+ # directory (~/Library/Mail, ~/Documents without Full Disk Access)
410
+ # raises Errno::EPERM, not EACCES, and $HOME -- Browse's own
411
+ # default landing directory -- routinely contains one. A symlink
412
+ # loop raises Errno::ELOOP. All of them mean the same thing here:
413
+ # show an empty listing, not a 500.
414
+ { dirs: [], files: [] }
415
+ end
416
+
417
+ # Expands and validates a Browse `dir` param, falling back to $HOME
418
+ # for anything blank, relative-and-missing, or not actually a
419
+ # directory -- Browse always has *somewhere* valid to show rather
420
+ # than erroring on a stale/hand-edited query string. The rescue
421
+ # covers what File.expand_path itself can raise on bad input
422
+ # (?dir=~nosuchuser, a null byte, a non-String param from
423
+ # ?dir[]=x) -- all real, reachable ways to reach this from a
424
+ # browser address bar or a stale link, not just theoretical.
425
+ def resolve_browse_dir(raw)
426
+ expanded = raw && !raw.to_s.empty? && (File.expand_path(raw.to_s) rescue nil)
427
+ expanded && File.directory?(expanded) ? expanded : Dir.home
428
+ end
429
+ end
430
+
431
+ # 127.0.0.1-binding stops a remote client, but not a browser already
432
+ # on this machine: any page open in any tab can issue a cross-origin
433
+ # GET here with no CSRF token required (an <img src="http://127.0.0.1:
434
+ # 4800/open?path=...">, a bare <a>, a form) -- same-origin policy
435
+ # blocks that page from READING the response, not from sending the
436
+ # request. That distinction didn't matter much when the worst case
437
+ # was an unwanted render; it matters a great deal now that /open
438
+ # (stream_weaver-rdh) *evaluates* the .rb file it opens.
439
+ #
440
+ # Two independent checks, because either alone has a gap:
441
+ # - Host: blocks the common drive-by case outright. Beaten by DNS
442
+ # rebinding, where an attacker's domain re-resolves to 127.0.0.1
443
+ # mid-session, making the request genuinely same-origin by the time
444
+ # it arrives.
445
+ # - Sec-Fetch-Site: set by the browser itself from the *page's own*
446
+ # origin, not spoofable by page JS, so it still reads "cross-site"
447
+ # after a rebind. Older browsers omit the header entirely; failing
448
+ # open on absence (rather than blocking) is deliberate -- this is a
449
+ # single-user local dev tool where the primary path is a modern
450
+ # browser navigating here directly, and false positives there would
451
+ # be worse than the residual risk from a browser old enough to omit
452
+ # Fetch Metadata.
453
+ before do
454
+ halt 403, 'Forbidden' unless %w[127.0.0.1 localhost].include?(request.host)
455
+
456
+ site = request.env['HTTP_SEC_FETCH_SITE']
457
+ halt 403, 'Forbidden' if site && !%w[same-origin none].include?(site)
458
+ end
459
+
460
+ get '/health' do
461
+ 'ok'
462
+ end
463
+
464
+ get '/' do
465
+ return redirect '/?file=0' unless params.key?('file')
466
+
467
+ index = params[:file].to_i
468
+ list = self.class.file_list
469
+ path = list&.at(index)
470
+ # "Nothing left to open" is a real state now (stream_weaver-uvaj:
471
+ # you just deleted the last doc), not a bad request -- answering a
472
+ # successful delete with a 404 page would be a lie about what
473
+ # happened. Renders the same no-doc-open placeholder a bare Browse
474
+ # already uses.
475
+ return render_no_doc(list) if path.nil? && list && list.size.zero?
476
+ halt 404, 'File not found' unless path
477
+
478
+ dsl = File.read(path)
479
+ @doc = Reader.render_doc(dsl, path: path)
480
+ @file_list = list
481
+ @current_index = index
482
+ @current_file = path
483
+ # @repo_filter is the resolved label the sidebar renders; @repo_param
484
+ # is the raw param, carried through every in-sidebar link so an
485
+ # explicit filter survives navigation instead of snapping back to the
486
+ # host-repo default on the next click.
487
+ @repo_filter = self.class.resolve_repo_filter(params[:repo], list)
488
+ @repo_param = params[:repo].to_s.empty? ? nil : params[:repo].to_s
489
+ @mermaid_zoom_js = MERMAID_ZOOM_JS
490
+ erb :reader_layout, layout: false
491
+ end
492
+
493
+ # Live filesystem browse (stream_weaver-rdh) -- the thing actually
494
+ # missing before this: canvas-read already renders any file/dir handed
495
+ # to it as a CLI arg, but once running, you're stuck with what you
496
+ # started it with. No index, no registered locations: browsing IS the
497
+ # discovery, computed fresh on every request. NOT the same trust
498
+ # boundary as the CLI args this already accepts, despite first
499
+ # appearances -- a CLI arg is the user naming a file once, with
500
+ # intent; an HTTP GET is reachable from any tab in the user's
501
+ # browser, and /open below evaluates what it opens. See the `before`
502
+ # filter above for why that gap is actually closed.
503
+ #
504
+ # No `?file=N` here -- Browse's sidebar replaces the file-list view
505
+ # entirely rather than adding to it, so there's no index into anything
506
+ # to render disabled/enabled Prev/Next against. @current_index stays
507
+ # unset, which the layout's nav block treats as "no file open."
508
+ get '/browse' do
509
+ set_browse_ivars(self.class.resolve_browse_dir(params[:dir]))
510
+ @file_list = self.class.file_list
511
+ @mermaid_zoom_js = MERMAID_ZOOM_JS
512
+ erb :reader_layout, layout: false
513
+ end
514
+
515
+ # Renders one file found via Browse, independent of the configured
516
+ # FileList/docs_groups/history_groups -- this is what makes Browse not
517
+ # need an index: viewing a browsed file was never routed through a
518
+ # precomputed list to begin with. Sidebar stays in Browse mode (the
519
+ # opened file's own directory), so browsing feels continuous rather
520
+ # than dropping back to the original file list.
521
+ get '/open' do
522
+ path = File.expand_path(params[:path].to_s)
523
+ halt 404, 'File not found' unless File.file?(path) && path.end_with?('.rb', '.org')
524
+
525
+ dsl = begin
526
+ File.read(path)
527
+ rescue SystemCallError
528
+ halt 404, 'File not found'
529
+ end
530
+ @doc = Reader.render_doc(dsl, path: path)
531
+ @current_file = path
532
+ set_browse_ivars(File.dirname(path))
533
+ @file_list = self.class.file_list
534
+ @mermaid_zoom_js = MERMAID_ZOOM_JS
535
+ erb :reader_layout, layout: false
536
+ end
537
+
538
+ # Download the currently-viewed file as a standalone HTML document.
539
+ # Same ?file=N index convention as GET /, and the same theme/layout
540
+ # fallbacks render_doc uses (fallback_theme/fallback_layout), so the
541
+ # download matches what's on screen.
542
+ #
543
+ # Failures answer text/plain with a status, never a partial document:
544
+ # a half-written .html landing in ~/Downloads looks like a success.
545
+ # content_type is set to :text up front for exactly that reason --
546
+ # halt'ing after a `content_type :html` would still serve the error
547
+ # body as HTML. The export link itself must never carry a `download`
548
+ # attribute, or the browser saves an error body as a mystery file
549
+ # instead of showing it (see reader_layout.erb).
550
+ # ?offline=1 inlines mermaid's own library instead of referencing its
551
+ # CDN (stream_weaver-dnq), so a diagram renders in a viewer whose CSP
552
+ # blocks external scripts entirely (SharePoint's HTML preview, etc.).
553
+ # Needs network access at export time; a fetch failure there is a 502
554
+ # (this server tried an upstream and it failed), distinct from the
555
+ # 422s below for bad input.
556
+ get '/export' do
557
+ content_type :text
558
+ path = self.class.file_list&.at(params[:file].to_i)
559
+ halt 404, 'File not found' unless path
560
+
561
+ html = begin
562
+ StreamWeaver::Export::HtmlExporter.from_dsl_file(
563
+ path,
564
+ theme: self.class.fallback_theme,
565
+ layout: self.class.fallback_layout
566
+ ).to_html(offline: params[:offline] == '1')
567
+ rescue StreamWeaver::Export::InvalidDslError => e
568
+ halt 422, "Export failed: #{e.message}"
569
+ rescue StreamWeaver::Export::OfflineAssetError => e
570
+ halt 502, "Export failed: #{e.message}"
571
+ rescue ScriptError, StandardError => e
572
+ # A DSL that fails to eval is bad input, not an exporter failure --
573
+ # same 422 GET / gives the equivalent case (its red error box).
574
+ halt 422, "Export failed: #{e.message}"
575
+ end
576
+
577
+ content_type :html
578
+ headers['Content-Disposition'] =
579
+ %(attachment; filename="#{StreamWeaver::Export::HtmlExporter.export_filename(path)}")
580
+ html
581
+ end
582
+
583
+ # Promote a history snapshot to a persistent canvas doc (Tier 2).
584
+ # Body: {"file": <integer-index>, "name": "<doc-name>", "format": "rb"|"org"}.
585
+ # format defaults to "rb". Mirrors BridgeServer's /canvas/:name/save-doc
586
+ # contract: 200 on success, 422 on bad index / bad name / bad format,
587
+ # 404 when no list configured, 500 otherwise.
588
+ post '/save-doc' do
589
+ content_type :json
590
+ list = self.class.file_list
591
+ halt 404, { ok: false, error: 'No file list configured' }.to_json unless list
592
+
593
+ body = JSON.parse(request.body.read, symbolize_names: true) rescue {}
594
+ index = body[:file]
595
+ name = body[:name]
596
+ format = body[:format] || 'rb'
597
+ halt 422, { ok: false, error: "unrecognized format: #{format.inspect}" }.to_json unless %w[rb org].include?(format)
598
+
599
+ # FileList#at already refuses non-Integer/negative indices; File.exist?
600
+ # covers the case an in-range index still points at a file that's
601
+ # since been deleted out from under us.
602
+ file_path = list.at(index)
603
+ unless file_path && File.exist?(file_path)
604
+ halt 422, { ok: false, error: "File index out of range: #{index.inspect}" }.to_json
605
+ end
606
+
607
+ # No DocStore.dsl_with_metadata call here, unlike BridgeServer's
608
+ # save-doc route: this promotes a history snapshot, and snapshots are
609
+ # written by `canvas-push` (CLI.record_push_history), which never sees
610
+ # the bridge session's theme/layout. There is nothing to inject.
611
+ # Snapshots whose DSL doesn't declare `use_theme` itself keep rendering
612
+ # with canvas-read's default -- accepted limitation (stream_weaver-csf).
613
+ dsl = File.read(file_path)
614
+
615
+ # Gist is a third destination, not a third format (mirrors
616
+ # bridge_server.rb's equivalent guard). No theme/layout to carry
617
+ # into the gist's .rb file either, for the same reason the org/rb
618
+ # branches above carry none: the reader has no live session.
619
+ return handle_gist_save(dsl, name) if body[:scope] == 'gist'
620
+
621
+ # The Save-as-doc toggle (stream_weaver-j3b3). No source_dir is passed
622
+ # to DocStore.save below -- unlike BridgeServer, the reader has no
623
+ # live session to carry one, so "This repo" keeps resolving the same
624
+ # way it always has: DocStore's own auto-detection off the reader
625
+ # process's cwd (see reader_layout.erb's SaveDocWidget.render call).
626
+ # Computed here, after the gist escape hatch above, rather than
627
+ # collapsed earlier -- unused on the gist path, so there's nothing
628
+ # to compute early for it.
629
+ scope = body[:scope] == 'global' ? :global : :repo
630
+
631
+ rescue_save_errors do
632
+ if format == 'org'
633
+ writer = StreamWeaver::Org::Writer.new(dsl)
634
+ org_text = writer.call
635
+ # Strip any .rb/.org the user already typed before appending .org --
636
+ # otherwise a name like "mydoc.org" round-trips to "mydoc.org.org"
637
+ # (DocStore.normalize_name only strips ONE trailing extension, so
638
+ # blindly appending here is not idempotent against an already-typed one).
639
+ # Only strip when name is actually a String -- a non-String is
640
+ # passed through as-is so DocStore.save's own type check rejects
641
+ # it with the same ArgumentError the .rb path below already gets
642
+ # for free, instead of silently coercing it via #to_s.
643
+ org_name = name.is_a?(String) ? "#{name.sub(/\.(rb|org)\z/, '')}.org" : name
644
+ saved_path = StreamWeaver::Canvas::DocStore.save(org_name, org_text, scope: scope)
645
+ { ok: true, path: saved_path, coverage: writer.coverage }.to_json
646
+ else
647
+ saved_path = StreamWeaver::Canvas::DocStore.save(name, dsl, scope: scope)
648
+ { ok: true, path: saved_path }.to_json
649
+ end
650
+ end
651
+ end
652
+
653
+ # Deletes one saved doc (stream_weaver-uvaj). Body:
654
+ # {"path": "<absolute path>", "file": <currently-open index or null>}.
655
+ #
656
+ # Reader-only on purpose: BridgeServer has no doc inventory to delete
657
+ # from. Plain File.delete, no git shelling -- the unstaged deletion
658
+ # showing up in `git status` afterward is the intended outcome, and
659
+ # committing it is the user's call, same as deleting in Finder.
660
+ #
661
+ # 403 (not 404) for a path outside the two deletable roots: the
662
+ # request was understood and refused, and a 404 would leak whether the
663
+ # file exists. Never reachable from Browse mode, which can point at
664
+ # any directory on the machine -- arbitrary-path READ is a reviewed,
665
+ # accepted risk there; arbitrary-path DELETE would not be.
666
+ post '/delete-doc' do
667
+ content_type :json
668
+ body = begin
669
+ JSON.parse(request.body.read, symbolize_names: true)
670
+ rescue StandardError
671
+ {}
672
+ end
673
+
674
+ path = self.class.deletable_path(body[:path])
675
+ halt 403, { ok: false, error: 'Not a deletable doc path' }.to_json unless path
676
+
677
+ # Resolved BEFORE the delete, while the file is still in the list:
678
+ # this is the position the doc occupied, which is what tells the
679
+ # client whether the open doc's index shifts.
680
+ list = self.class.file_list
681
+ deleted = list&.files&.index { |f| self.class.canonical_file(f) == path }
682
+
683
+ begin
684
+ File.delete(path)
685
+ rescue SystemCallError => e
686
+ halt 500, { ok: false, error: e.message }.to_json
687
+ end
688
+
689
+ { ok: true, path: path, file: self.class.index_after_delete(deleted, body[:file]) }.to_json
690
+ end
691
+
692
+ # Evaluates `dsl` and returns a Doc carrying the rendered HTML plus the
693
+ # theme/layout/inline CSS the DSL declared for itself -- the reader shell
694
+ # needs all four, and render_dsl's HTML-only return threw the rest away
695
+ # (stream_weaver-csf).
696
+ #
697
+ # Caveat: rendering goes through AppContentView, which does not evaluate
698
+ # exclusive-layout render blocks or layout slots (AppView-only concepts).
699
+ # So `layout` here only reaches body-class/CSS-selector level layout --
700
+ # a known, accepted gap, not something the reader can close.
701
+ #
702
+ # `mode: :websocket` is kept for component-markup parity with the canvas
703
+ # (some components render differently in websocket mode). The reader's
704
+ # <head> deliberately does NOT use adapter.cdn_scripts, which would drag
705
+ # in a connect attempt to a /canvas/reader/ws endpoint that doesn't exist.
706
+ #
707
+ # `inert: true` is the consequence of that omission: without cdn_scripts
708
+ # there is no `sendEvent`, so buttons and radio groups that would call it
709
+ # rendered as live controls which self-disabled and then threw a
710
+ # ReferenceError on click (disc-095). Inert renders them disabled with an
711
+ # explanatory title -- honest about being a preview -- rather than
712
+ # defining a no-op `sendEvent` stub, which would preserve the lie.
713
+ #
714
+ # @param path [String, nil] source file, passed through to instance_eval
715
+ # so a DSL error names the actual file/line instead of "(eval)".
716
+ #
717
+ # `dsl` may actually be `.org` text -- detected the same content-based
718
+ # way content.js/sandbox.js do client-side (StreamWeaver::Org::Reader.
719
+ # streamweaver_org?) and converted via Org::Reader.to_dsl before eval,
720
+ # same path the extension already uses. The converted text's line
721
+ # numbers no longer match the original .org file's, so a DSL error in
722
+ # a converted doc names the wrong line -- an accepted, precedented gap
723
+ # (the extension's sandbox.js has the same limitation; org->DSL is a
724
+ # real rewrite, not a 1:1 mapping).
725
+ #
726
+ # An `.org` file WITHOUT the marker (stream_weaver-gnj8 -- e.g. Browse
727
+ # pointed at $HOME turns up ordinary org-mode notes, not StreamWeaver
728
+ # docs) is caught before eval, not after: raw org markup is never
729
+ # valid Ruby, so letting it fall through to instance_eval only ever
730
+ # produces a confusing syntax error naming some fragment of prose. A
731
+ # `.rb` file with no StreamWeaver DSL calls doesn't get the same
732
+ # short-circuit -- it could legitimately be valid, unrelated Ruby
733
+ # (or invalid for an unrelated reason), so eval-and-show-DSL-error
734
+ # remains the right fallback there.
735
+ def self.render_doc(dsl, path: nil)
736
+ theme = fallback_theme
737
+ layout = fallback_layout
738
+ if path.to_s.end_with?('.org') && !StreamWeaver::Org::Reader.streamweaver_org?(dsl)
739
+ return Doc.new(html: not_a_streamweaver_doc_html, theme: theme, layout: layout, inline_stylesheets: [])
740
+ end
741
+
742
+ dsl = StreamWeaver::Org::Reader.to_dsl(dsl) if StreamWeaver::Org::Reader.streamweaver_org?(dsl)
743
+ mini_app = StreamWeaver::App.new('reader', theme: theme, layout: layout)
744
+ mini_app.instance_eval(dsl, path.to_s, 1)
745
+ adapter = StreamWeaver::Adapter::AlpineJS.new(
746
+ url_prefix: '/canvas/reader',
747
+ mode: :websocket,
748
+ inert: true
749
+ )
750
+ Doc.new(
751
+ html: StreamWeaver::Views::AppContentView.new(mini_app, {}, adapter, false).call,
752
+ theme: mini_app.theme,
753
+ layout: mini_app.layout,
754
+ inline_stylesheets: mini_app.inline_stylesheets
755
+ )
756
+ rescue ScriptError, StandardError => e
757
+ Doc.new(
758
+ html: "<div style='color:red;padding:1rem;font-family:monospace'>" \
759
+ "<strong>DSL error:</strong> #{CGI.escapeHTML(e.message)}</div>",
760
+ theme: theme,
761
+ layout: layout,
762
+ inline_stylesheets: []
763
+ )
764
+ end
765
+
766
+ def self.render_dsl(dsl)
767
+ render_doc(dsl).html
768
+ end
769
+
770
+ # Neutral, not red -- this isn't a bug in the doc or in canvas-read,
771
+ # it's just the wrong kind of file (stream_weaver-gnj8). Red/"error"
772
+ # styling here would read as "something's broken," which is the
773
+ # opposite of what a stray personal org file browsed into deserves.
774
+ def self.not_a_streamweaver_doc_html
775
+ "<div style='color:#4b5563;padding:1rem;font-family:monospace'>" \
776
+ "This doesn't look like a StreamWeaver doc -- no " \
777
+ "<code>#+STREAMWEAVER_DSL:</code> header found. It's probably a " \
778
+ "plain org-mode file.</div>"
779
+ end
780
+ private_class_method :not_a_streamweaver_doc_html
781
+
782
+ private
783
+
784
+ # Builds SaveDocWidget's `gist:` kwarg (share-to-gist epic,
785
+ # reader-gist-parity story) for the is_history Save-as-doc call site in
786
+ # reader_layout.erb. Kept out of the ERB itself -- reading the local
787
+ # GistStore file and shelling out to check `gh`'s presence are
788
+ # controller work, not view formatting (same rationale as
789
+ # #set_browse_ivars below).
790
+ #
791
+ # `name` is `default_name` (computed in the ERB from the archived
792
+ # snapshot's own filename) -- unlike BridgeServer#gist_widget_data,
793
+ # which does a `latest_for_prefix` search because a live canvas
794
+ # session's default name is a fresh timestamp on every dialog open, the
795
+ # reader's default_name is fixed per snapshot, so it's already the
796
+ # exact name a direct GistStore.lookup wants.
797
+ def gist_widget_data(name)
798
+ available = StreamWeaver::Canvas::GistPublisher.gh_available?
799
+ entry = StreamWeaver::Canvas::GistStore.lookup(name)
800
+
801
+ {
802
+ available: available,
803
+ unavailable_reason: available ? nil : 'gh CLI not found on PATH',
804
+ known: entry ? { name => { url: entry['url'], revisions: entry['revisions'] } } : {},
805
+ prefill_name: entry ? name : nil
806
+ }
807
+ end
808
+
809
+ # Every Browse-related link carries this instead of the chrome's
810
+ # narrower default (hx-select-oob="#sw-reader-nav" alone) -- Browse
811
+ # needs the sidebar's own CONTENT to change between directories,
812
+ # unlike normal docs/history navigation, which needs it to stay
813
+ # untouched so accordion state survives (stream_weaver-8v1). A
814
+ # boosted element's own hx-* attributes override its ancestor's, so
815
+ # this widening stays scoped to exactly the links that opt into it.
816
+ # One constant instead of nine hand-typed copies in the template:
817
+ # dropping #sw-reader-nav from any one of them would silently stop
818
+ # that link's swap from refreshing the nav bar.
819
+ BROWSE_OOB = 'hx-select-oob="#sw-reader-nav, #sw-reader-files"'
820
+
821
+ # The docs-mode counterpart of a bare GET /browse: sidebar and nav
822
+ # render against an empty list, and #app-container falls back to the
823
+ # same "pick a file" placeholder, because @doc is nil. Reached only
824
+ # when the last doc was deleted out from under the reader
825
+ # (stream_weaver-uvaj) -- @current_index stays unset, so the layout's
826
+ # Prev/Next/Export block is skipped exactly as it is for a browsed file.
827
+ def render_no_doc(list)
828
+ @file_list = list
829
+ @doc = nil
830
+ @mermaid_zoom_js = MERMAID_ZOOM_JS
831
+ erb :reader_layout, layout: false
832
+ end
833
+
834
+ # Populates every ivar reader_layout.erb's Browse sidebar needs,
835
+ # shared by GET /browse and GET /open so both compute breadcrumbs and
836
+ # the repo-root shortcut identically. Kept out of the ERB entirely --
837
+ # the breadcrumb walk and repo_docs_root's filesystem/.git lookup are
838
+ # controller work, not view formatting, and doing it here means it
839
+ # runs once per request instead of once per render.
840
+ def set_browse_ivars(dir)
841
+ @browse_mode = true
842
+ @browse_dir = dir
843
+ @browse_entries = self.class.browse_entries(dir)
844
+ @browse_parent = dir == '/' ? nil : File.dirname(dir)
845
+
846
+ parts = dir.split('/').reject(&:empty?)
847
+ @breadcrumbs = parts.each_index.map { |i| [parts[i], "/#{parts[0..i].join('/')}"] }
848
+
849
+ @repo_docs_root = self.class.repo_docs_root
850
+ end
851
+ end
852
+ end
853
+ end