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,800 @@
1
+ <%
2
+ doc = @doc
3
+ # Browse (stream_weaver-rdh) can land here with no file open yet
4
+ # (GET /browse before any file is clicked) -- doc.theme/layout have
5
+ # nothing to fall back to in that case, so the body class falls back to
6
+ # the reader's own defaults instead of reading through a nil doc.
7
+ body_class = if doc
8
+ "sw-theme-#{doc.theme} sw-layout-#{doc.layout} sw-reader"
9
+ else
10
+ "sw-theme-#{StreamWeaver::Canvas::Reader.fallback_theme} " \
11
+ "sw-layout-#{StreamWeaver::Canvas::Reader.fallback_layout} sw-reader"
12
+ end
13
+ browse_mode = !!@browse_mode
14
+ # docs_groups/history_groups are meaningless in Browse mode (its sidebar
15
+ # replaces them entirely) -- skipping them isn't just wasted work, it
16
+ # avoids a NoMethodError the day canvas-read ships a "no configured
17
+ # files, Browse only" boot mode, where @file_list would be nil.
18
+ docs_groups = browse_mode ? {} : @file_list.docs_groups(repo: @repo_filter)
19
+ history_groups = browse_mode ? {} : @file_list.history_groups
20
+ # Repo filter (stream_weaver-iugu). repo_labels lists every discovered docs
21
+ # root; repo_qs carries the ACTIVE filter through every /?file=N link so it
22
+ # survives navigation -- without it, one Prev/Next click silently snaps the
23
+ # rail back to the host-repo default.
24
+ repo_labels = browse_mode ? [] : @file_list.repo_labels
25
+ repo_qs = @repo_param ? "&amp;repo=#{ERB::Util.url_encode(@repo_param)}" : ''
26
+ is_history = @current_file && !browse_mode && @file_list.history_dir?(File.dirname(@current_file))
27
+ default_name = if is_history
28
+ session = File.basename(File.dirname(@current_file))
29
+ timestamp = File.basename(@current_file, '.rb')
30
+ "#{session}-#{timestamp}"
31
+ end
32
+ # @breadcrumbs/@browse_parent/@repo_docs_root come from the route (Reader
33
+ # #set_browse_ivars) -- the breadcrumb walk and repo_docs_root's
34
+ # filesystem/.git lookup are controller work, not view formatting.
35
+ browse_oob = StreamWeaver::Canvas::Reader::BROWSE_OOB
36
+ # Which groups get a delete control (stream_weaver-uvaj): only the two
37
+ # roots THIS process owns -- the repo it was launched in and the global
38
+ # store. A peer repo surfaced by discovery is readable here and never
39
+ # deletable, so its rows get no trash icon; that's the same boundary
40
+ # POST /delete-doc enforces server-side, mirrored into the UI so you're
41
+ # never offered a button that can only answer 403. Resolved once per
42
+ # render rather than per row -- each answer costs a realpath plus the
43
+ # .git walk behind repo_docs_root.
44
+ deletable_dirs = browse_mode ? [] : docs_groups.keys.select { |d| StreamWeaver::Canvas::Reader.deletable_dir?(d) }
45
+ %>
46
+ <!DOCTYPE html>
47
+ <html lang="en">
48
+ <head>
49
+ <meta charset="UTF-8">
50
+ <meta name="viewport" content="width=device-width, initial-scale=1">
51
+ <title>canvas-read</title>
52
+
53
+ <%# Framework CSS, identical ordering to the canvas (PageShell is the single
54
+ source of truth): layer pin, then CANVAS_CSS / master_theme_css /
55
+ visual_skills_css all inside @layer stream-weaver. The reader's own
56
+ chrome gets a later layer so it can't be outranked by, nor bleed into,
57
+ framework rules. %>
58
+ <%= StreamWeaver::PageShell.framework_css_html(extra_layers: %w[sw-reader-chrome]) %>
59
+
60
+ <%# The doc's own use_stylesheet CSS -- unlayered, so it wins everything.
61
+ Nothing to inline with no doc open yet (a bare Browse listing). %>
62
+ <%= StreamWeaver::PageShell.user_css_html(inline_stylesheets: doc ? doc.inline_stylesheets : []) %>
63
+
64
+ <%# Reader chrome. BOTH layered and #sw-reader-*-scoped, on purpose:
65
+ scoping alone would let CANVAS_CSS/master_theme_css's bare h1..h6/p/a
66
+ rules style the rail; layering alone would let an unscoped rule here
67
+ (e.g. `body { font-family }`) beat theme rules for the whole page.
68
+ The `html` padding rule below is the one deliberate exception -- it has
69
+ no framework equivalent to lose to. spec/canvas/reader_chrome_css_spec.rb
70
+ enforces this scoping; keep every new selector rooted at html or
71
+ #sw-reader-. %>
72
+ <style id="sw-reader-chrome-css">@layer sw-reader-chrome {
73
+ /* Offset for the fixed rail lives on `html`, NOT body: this shrinks html's
74
+ content box so body's own `margin: 0 auto` centering and the max-width
75
+ the sidebar_toc CSS sets on body both keep working untouched, with zero
76
+ cascade contact with views.rb's
77
+ `body[class*="sw-layout-"] { margin: 0 auto; padding: ... }` shorthand. */
78
+ html { padding-left: 220px; }
79
+ html.sw-rail-collapsed { padding-left: 0; }
80
+
81
+ /* Collapse is driven by `left`, never `transform`: a transformed ancestor
82
+ becomes the containing block for its position:fixed descendants, which
83
+ would trap the Save-as-doc modal (docked in #sw-reader-nav) inside the
84
+ 220px rail instead of covering the viewport. */
85
+ #sw-reader-chrome {
86
+ position: fixed; top: 0; bottom: 0; left: 0; width: 220px; z-index: 40;
87
+ display: flex; flex-direction: column;
88
+ background: #f9fafb; border-right: 1px solid #d1d5db;
89
+ font-family: system-ui, sans-serif; font-size: 16px; line-height: 1.5;
90
+ color: #4b5563;
91
+ transition: left 160ms ease;
92
+ }
93
+ html.sw-rail-collapsed #sw-reader-chrome { left: -220px; }
94
+
95
+ #sw-reader-toggle {
96
+ position: absolute; top: 0.5rem; right: -2.25rem;
97
+ width: 2rem; height: 2rem; padding: 0; line-height: 1;
98
+ border: 1px solid #d1d5db; border-radius: 4px;
99
+ background: #f9fafb; color: #4b5563;
100
+ font-size: 0.95rem; cursor: pointer;
101
+ }
102
+ #sw-reader-toggle:hover { background: #e5e7eb; color: #111827; }
103
+
104
+ /* Only the file list scrolls; #sw-reader-nav stays docked at the bottom. */
105
+ #sw-reader-files { flex: 1; overflow-y: auto; min-height: 0; }
106
+ #sw-reader-files h2 {
107
+ margin: 0; padding: 0.75rem 1rem; font-size: 0.7rem; font-weight: 600;
108
+ text-transform: uppercase; letter-spacing: .08em; color: #6b7280;
109
+ border-bottom: 1px solid #e5e7eb;
110
+ }
111
+ #sw-reader-files .dir-header {
112
+ padding: 0.4rem 1rem; font-size: 0.75rem; color: #4b5563; cursor: pointer;
113
+ user-select: none; display: flex; align-items: center; gap: 0.4rem;
114
+ }
115
+ #sw-reader-files .dir-header:hover { background: #e5e7eb; }
116
+ /* Base glyph (▶) is the collapsed state; .expanded rotates it to point
117
+ down. Same convention as .history-section-header.expanded below, so
118
+ toggleSection() can drive both with one function. */
119
+ #sw-reader-files .dir-arrow { font-size: 0.6rem; transition: transform 0.15s; display: inline-block; }
120
+ #sw-reader-files .dir-header.expanded .dir-arrow { transform: rotate(90deg); }
121
+ #sw-reader-files .dir-files { list-style: none; margin: 0; padding: 0; }
122
+ #sw-reader-files .dir-files.collapsed { display: none; }
123
+ #sw-reader-files .dir-files li a {
124
+ display: block; padding: 0.35rem 1rem 0.35rem 1.75rem; font-size: 0.8rem;
125
+ color: #4b5563; text-decoration: none; white-space: nowrap;
126
+ overflow: hidden; text-overflow: ellipsis;
127
+ }
128
+ #sw-reader-files .dir-files li a:hover { background: #e5e7eb; color: #111827; }
129
+ #sw-reader-files .dir-files li a.active { background: #dbeafe; color: #1d4ed8; font-weight: 600; }
130
+ /* Delete control (stream_weaver-uvaj). The row becomes a flex line so
131
+ the trash sits at its right edge; the link keeps flex:1 + min-width:0
132
+ so a long name still ellipsizes instead of shoving the button out of
133
+ the 220px rail. Visible at rest, not hover-only: a control you have to
134
+ discover by hovering is friction, and it's the confirm dialog -- not
135
+ concealment -- that protects against a mis-click. */
136
+ #sw-reader-files .dir-files li { display: flex; align-items: center; }
137
+ #sw-reader-files .dir-files li a { flex: 1 1 auto; min-width: 0; }
138
+ #sw-reader-files .sw-doc-delete {
139
+ flex: 0 0 auto; border: none; background: none; cursor: pointer;
140
+ padding: 0.35rem 0.65rem 0.35rem 0.15rem; font-size: 0.72rem;
141
+ line-height: 1; opacity: 0.4;
142
+ }
143
+ #sw-reader-files .sw-doc-delete:hover { opacity: 1; }
144
+
145
+ /* Confirm dialog. Same modal treatment as the Save-as-doc widget's
146
+ (.sw-save-doc-*, further down), but rooted at #sw-reader-chrome so it
147
+ stays inside this block's scoping guard (reader_chrome_css_spec)
148
+ instead of needing a second unscoped <style> the way that widget did. */
149
+ #sw-reader-chrome [x-cloak] { display: none !important; }
150
+ #sw-reader-chrome .sw-doc-delete-modal {
151
+ position: fixed; inset: 0; z-index: 60;
152
+ background: rgba(15, 17, 23, 0.45);
153
+ display: flex; align-items: center; justify-content: center;
154
+ }
155
+ #sw-reader-chrome .sw-doc-delete-dialog {
156
+ background: #fff; border-radius: 8px; padding: 1.5rem;
157
+ width: min(420px, 90vw); box-shadow: 0 20px 50px rgba(0,0,0,0.25);
158
+ font-family: system-ui, sans-serif; color: #111827; text-align: left;
159
+ }
160
+ #sw-reader-chrome .sw-doc-delete-dialog h3 { margin: 0 0 0.5rem 0; font-size: 1.1rem; }
161
+ #sw-reader-chrome .sw-doc-delete-dialog p.hint {
162
+ margin: 0; color: #6b7280; font-size: 0.85rem; word-break: break-word;
163
+ }
164
+ #sw-reader-chrome .sw-doc-delete-error {
165
+ margin-top: 0.75rem; padding: 0.5rem 0.75rem;
166
+ background: #fee2e2; color: #991b1b; border-radius: 4px; font-size: 0.85rem;
167
+ }
168
+ #sw-reader-chrome .sw-doc-delete-actions {
169
+ display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem;
170
+ }
171
+ #sw-reader-chrome .sw-doc-delete-actions button {
172
+ padding: 0.45rem 1rem; border-radius: 5px; font-size: 0.9rem;
173
+ cursor: pointer; border: 1px solid transparent;
174
+ }
175
+ #sw-reader-chrome .sw-doc-delete-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
176
+ #sw-reader-chrome .sw-doc-delete-cancel { background: #f3f4f6; color: #374151; border-color: #d1d5db; }
177
+ #sw-reader-chrome .sw-doc-delete-cancel:hover:not(:disabled) { background: #e5e7eb; }
178
+ #sw-reader-chrome .sw-doc-delete-confirm { background: #b91c1c; color: #fff; }
179
+ #sw-reader-chrome .sw-doc-delete-confirm:hover:not(:disabled) { filter: brightness(1.08); }
180
+
181
+ /* .org format badge (stream_weaver-gnj8) -- unconditional on every org
182
+ entry, not just on a name collision with its .rb sibling; see the
183
+ ERB comment above this class's only use site for why. */
184
+ #sw-reader-files .sw-reader-file-badge {
185
+ display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.35rem;
186
+ font-size: 0.65rem; font-weight: 600; letter-spacing: 0.02em;
187
+ color: #6b7280; background: #e5e7eb; border-radius: 3px;
188
+ }
189
+
190
+ /* Repo filter (stream_weaver-iugu) -- same chip treatment as
191
+ .browse-shortcuts below, since it is the same kind of control (a row
192
+ of small jump targets above the listing) and two different-looking
193
+ chip rows in one 220px rail would read as two unrelated mechanisms.
194
+ Only the .active state is new: unlike Browse's shortcuts, exactly one
195
+ of these is always the current view. */
196
+ #sw-reader-files .repo-filter {
197
+ padding: 0.5rem 1rem; display: flex; gap: 0.4rem; flex-wrap: wrap;
198
+ border-bottom: 1px solid #e5e7eb;
199
+ }
200
+ #sw-reader-files .repo-filter a {
201
+ font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 3px;
202
+ background: #e5e7eb; color: #374151; text-decoration: none;
203
+ max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
204
+ }
205
+ #sw-reader-files .repo-filter a:hover { background: #d1d5db; }
206
+ #sw-reader-files .repo-filter a.active {
207
+ background: #dbeafe; color: #1d4ed8; font-weight: 600;
208
+ }
209
+
210
+ /* Browse (stream_weaver-rdh). .browse-listing reuses .dir-files li a's
211
+ styling above (same selector chain) for consistent link appearance;
212
+ only the browse-specific chrome (shortcuts, breadcrumbs, up/back
213
+ links) needs its own rules. */
214
+ #sw-reader-files .browse-shortcuts {
215
+ padding: 0.5rem 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap;
216
+ border-bottom: 1px solid #e5e7eb;
217
+ }
218
+ #sw-reader-files .browse-shortcuts a {
219
+ font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 3px;
220
+ background: #e5e7eb; color: #374151; text-decoration: none;
221
+ }
222
+ #sw-reader-files .browse-shortcuts a:hover { background: #d1d5db; }
223
+ #sw-reader-files .browse-crumbs {
224
+ padding: 0.4rem 1rem; font-size: 0.72rem; color: #6b7280;
225
+ word-break: break-all; border-bottom: 1px solid #e5e7eb;
226
+ }
227
+ #sw-reader-files .browse-crumbs a { color: #4b5563; text-decoration: none; }
228
+ #sw-reader-files .browse-crumbs a:hover { text-decoration: underline; }
229
+ #sw-reader-files .browse-crumbs .crumb-sep { color: #9ca3af; }
230
+ #sw-reader-files .browse-listing .browse-up,
231
+ #sw-reader-files .browse-listing .browse-dir { font-weight: 500; }
232
+ #sw-reader-files .browse-empty {
233
+ padding: 0.5rem 1rem 0.5rem 1.75rem; font-size: 0.75rem; color: #9ca3af;
234
+ font-style: italic;
235
+ }
236
+ #sw-reader-files .browse-back, #sw-reader-files .browse-link {
237
+ display: block; padding: 0.5rem 1rem; font-size: 0.75rem;
238
+ color: #4b5563; text-decoration: none; border-top: 1px solid #e5e7eb;
239
+ }
240
+ #sw-reader-files .browse-back:hover, #sw-reader-files .browse-link:hover {
241
+ background: #e5e7eb; color: #111827;
242
+ }
243
+
244
+ /* History section (collapsed by default) */
245
+ #sw-reader-files .history-section-header {
246
+ border-top: 1px solid #e5e7eb; border-bottom: none;
247
+ cursor: pointer; user-select: none; display: flex; align-items: center; gap: 0.4rem;
248
+ }
249
+ #sw-reader-files .history-section-header:hover { background: #e5e7eb; color: #374151; }
250
+ #sw-reader-files .history-arrow { font-size: 0.6rem; transition: transform 0.15s; display: inline-block; }
251
+ #sw-reader-files .history-section-header.expanded .history-arrow { transform: rotate(90deg); }
252
+ #sw-reader-files .history-content.collapsed { display: none; }
253
+ #sw-reader-files .history-session .dir-header { font-size: 0.7rem; color: #6b7280; padding-left: 1.25rem; }
254
+ #sw-reader-files .history-session .dir-files li a {
255
+ padding-left: 2.25rem; font-family: ui-monospace, monospace; font-size: 0.72rem;
256
+ }
257
+
258
+ /* Prev/Next docked in the rail -- not a fixed bottom bar, which would
259
+ collide with the Save-as-doc button at bottom right. flex-wrap
260
+ because Export (and, on a doc with mermaid, a second Export
261
+ (offline) row -- stream_weaver-dnq) sits below Prev/counter/Next,
262
+ which already fill the 220px width -- a fourth same-row item would
263
+ squeeze the counter. */
264
+ #sw-reader-nav {
265
+ flex-shrink: 0; border-top: 1px solid #e5e7eb; background: #f9fafb;
266
+ padding: 0.5rem 0.75rem; display: flex; flex-wrap: wrap; align-items: center;
267
+ justify-content: space-between; gap: 0.4rem;
268
+ }
269
+ #sw-reader-nav .nav-link {
270
+ background: #e5e7eb; color: #374151; border: none; padding: 0.3rem 0.6rem;
271
+ border-radius: 4px; cursor: pointer; font-size: 0.75rem;
272
+ text-decoration: none; display: inline-block;
273
+ }
274
+ #sw-reader-nav .nav-link:hover { background: #d1d5db; color: #111827; }
275
+ #sw-reader-nav .nav-link--disabled { opacity: 0.35; cursor: default; pointer-events: none; }
276
+ #sw-reader-nav .counter { font-size: 0.72rem; color: #6b7280; white-space: nowrap; }
277
+ #sw-reader-nav .nav-export { flex-basis: 100%; text-align: center; }
278
+
279
+ /* Mandatory below ~1100px: the sidebar_toc's own desktop breakpoint
280
+ (min-width: 1000px) measures the VIEWPORT, not the doc's content
281
+ region, so a permanently-present 220px rail leaves the doc cramped at
282
+ common laptop widths. Default to collapsed and let the toggle slide it
283
+ back in over the content. */
284
+ @media (max-width: 1100px) {
285
+ html { padding-left: 0; }
286
+ #sw-reader-chrome { left: -220px; }
287
+ html.sw-rail-open #sw-reader-chrome { left: 0; box-shadow: 0 0 30px rgba(0,0,0,0.18); }
288
+ }
289
+ }</style>
290
+
291
+ <%# CDN dependencies. Written out here rather than via adapter.cdn_scripts:
292
+ the adapter is in :websocket mode (for component-markup parity with the
293
+ canvas) and would emit a connect script for /canvas/reader/ws, which the
294
+ reader doesn't serve -- 5 failed reconnects in the console. %>
295
+ <script src="https://unpkg.com/htmx.org@2.0.4"></script>
296
+ <script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
297
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
298
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css">
299
+ <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
300
+
301
+ <%# Mermaid zoom/pan engine — inlined here so it self-inits on DOMContentLoaded %>
302
+ <script><%= @mermaid_zoom_js %></script>
303
+ <%# Kick off mermaid CDN preload immediately (same pattern as bridge server) %>
304
+ <script>if (window.swMermaidPreload) window.swMermaidPreload();</script>
305
+ </head>
306
+ <body class="<%= body_class %>">
307
+
308
+ <%# #app-container is a DIRECT body child, exactly as the canvas renders it.
309
+ Every sidebar_toc/theme selector that matters uses the ">" combinator
310
+ against `body[class*="sw-layout-"] > #app-container` -- nesting it inside
311
+ a wrapper leaves all of them dead. data-sw-body-class is read back by the
312
+ htmx:afterSwap handler below, since <body>'s own class attribute is never
313
+ part of the swapped region. %>
314
+ <div id="app-container" data-sw-body-class="<%= body_class %>">
315
+ <% if doc %>
316
+ <%= doc.html %>
317
+ <% else %>
318
+ <div style="padding: 3rem; text-align: center; color: #6b7280; font-family: system-ui, sans-serif;">
319
+ Pick a <code>.rb</code> or <code>.org</code> file from the browser on the left.
320
+ </div>
321
+ <% end %>
322
+ </div>
323
+
324
+ <%# hx-boost lives on the chrome, not <body>: that keeps in-document
325
+ .sw-sidebar-toc__link anchors (#section-id jumps) structurally outside the
326
+ boosted subtree. hx-swap="outerHTML" is required -- hx-select keeps the
327
+ matched element itself, so innerHTML would nest a new #app-container
328
+ inside the old one on every click, killing every ">" selector and
329
+ getElementById('app-container') lookup after the first navigation.
330
+ #sw-reader-files is deliberately NOT in this default hx-select-oob --
331
+ stream_weaver-8v1 keeps accordion expand/collapse state alive across
332
+ Prev/Next and sidebar clicks precisely BY never swapping it. Browse
333
+ (stream_weaver-rdh) needs the opposite (the sidebar's own content has to
334
+ change between directories), so its links carry their own, wider
335
+ hx-select-oob (BROWSE_OOB below) that overrides this default -- boosted
336
+ per-element attributes win over the ancestor's, so normal docs/history
337
+ navigation is untouched. %>
338
+ <%# x-data for the delete confirm (stream_weaver-uvaj) sits HERE, on the one
339
+ piece of chrome that is never itself swapped -- the delete buttons live
340
+ in #sw-reader-files and the request they fire replaces that whole region,
341
+ so component state kept inside it would be torn out from under its own
342
+ in-flight callback. Descendants inherit the scope, which is all the
343
+ buttons need. %>
344
+ <nav id="sw-reader-chrome"
345
+ x-data="swDeleteDoc()" @keydown.escape.window="delOpen = false"
346
+ hx-boost="true" hx-target="#app-container" hx-select="#app-container"
347
+ hx-swap="outerHTML" hx-select-oob="#sw-reader-nav">
348
+
349
+ <button id="sw-reader-toggle" type="button" onclick="swToggleRail()"
350
+ title="Toggle file rail" aria-label="Toggle file rail">☰</button>
351
+
352
+ <div id="sw-reader-files">
353
+ <% if browse_mode %>
354
+ <%# Every link in this block carries its own hx-select-oob, wider than
355
+ the chrome's default (#sw-reader-nav alone, see the <nav> comment
356
+ above) -- Browse needs the sidebar's own CONTENT to change between
357
+ directories, unlike normal docs/history navigation, which needs it
358
+ to stay untouched so accordion state survives (stream_weaver-8v1).
359
+ A boosted element's own hx-* attributes override its ancestor's. %>
360
+ <h2>Browse</h2>
361
+ <%# First thing in the listing, not last -- previously sat after the
362
+ whole file/dir listing (browse-back below), which meant scrolling
363
+ past a potentially long directory to find the only way out of
364
+ Browse mode. stream_weaver-gnj8. %>
365
+ <a href="/?file=0" class="browse-back" <%= browse_oob %>>← Back to docs</a>
366
+ <div class="browse-shortcuts">
367
+ <% if @repo_docs_root %>
368
+ <a href="/browse?dir=<%= ERB::Util.url_encode(@repo_docs_root) %>" <%= browse_oob %>>This repo</a>
369
+ <% end %>
370
+ <a href="/browse?dir=<%= ERB::Util.url_encode(StreamWeaver::Canvas::DocStore::DEFAULT_ROOT) %>" <%= browse_oob %>>StreamWeaver (global)</a>
371
+ </div>
372
+ <div class="browse-crumbs">
373
+ <a href="/browse?dir=%2F" <%= browse_oob %>>/</a>
374
+ <% @breadcrumbs.each do |label, crumb_path| %>
375
+ <span class="crumb-sep">/</span><a href="/browse?dir=<%= ERB::Util.url_encode(crumb_path) %>" <%= browse_oob %>><%= ERB::Util.h(label) %></a>
376
+ <% end %>
377
+ </div>
378
+ <%# Filenames/dirnames come straight off the filesystem -- '"', '<', '&'
379
+ are all legal there, and unlike the fixed docs sections above,
380
+ Browse can be pointed at literally any directory (~/Downloads
381
+ included, where an attacker controls a dropped file's name). Every
382
+ filesystem-derived string in this block is ERB::Util.h'd; the
383
+ hrefs are already safe via url_encode. %>
384
+ <ul class="dir-files browse-listing">
385
+ <% if @browse_parent %>
386
+ <li><a href="/browse?dir=<%= ERB::Util.url_encode(@browse_parent) %>" class="browse-up" <%= browse_oob %>>.. (up)</a></li>
387
+ <% end %>
388
+ <% @browse_entries[:dirs].each do |name| %>
389
+ <li><a href="/browse?dir=<%= ERB::Util.url_encode(File.join(@browse_dir, name)) %>" class="browse-dir" <%= browse_oob %>>📁 <%= ERB::Util.h(name) %></a></li>
390
+ <% end %>
391
+ <% @browse_entries[:files].each do |name| %>
392
+ <% full = File.join(@browse_dir, name) %>
393
+ <li><a href="/open?path=<%= ERB::Util.url_encode(full) %>" class="<%= full == @current_file ? 'active' : '' %>" title="<%= ERB::Util.h(full) %>" <%= browse_oob %>><%= ERB::Util.h(name) %></a></li>
394
+ <% end %>
395
+ <% if @browse_entries[:dirs].empty? && @browse_entries[:files].empty? %>
396
+ <li class="browse-empty">(empty)</li>
397
+ <% end %>
398
+ </ul>
399
+ <% else %>
400
+ <h2><%= history_groups.any? ? 'Docs' : 'Files' %></h2>
401
+ <%# Repo filter (stream_weaver-iugu). Only rendered once there is more
402
+ than one repo to choose between -- a single-repo rail has nothing to
403
+ filter and the row would be pure noise. Each link carries browse_oob
404
+ for the same reason Browse's links do: the sidebar's own CONTENT has
405
+ to change, and the chrome's default OOB (#sw-reader-nav alone,
406
+ stream_weaver-8v1) deliberately leaves #sw-reader-files untouched. %>
407
+ <% if repo_labels.size > 1 %>
408
+ <div class="repo-filter">
409
+ <% repo_labels.each do |label| %>
410
+ <a href="/?file=<%= @current_index || 0 %>&amp;repo=<%= ERB::Util.url_encode(label) %>"
411
+ class="<%= label == @repo_filter ? 'active' : '' %>" <%= browse_oob %>><%= ERB::Util.h(label) %></a>
412
+ <% end %>
413
+ <a href="/?file=<%= @current_index || 0 %>&amp;repo=all"
414
+ class="<%= @repo_filter.nil? ? 'active' : '' %>" <%= browse_oob %>>All</a>
415
+ </div>
416
+ <% end %>
417
+ <% docs_groups.each do |dir, entries| %>
418
+ <% dir_label = @file_list.label_for(dir) %>
419
+ <% deletable = deletable_dirs.include?(dir) %>
420
+ <div class="dir-section">
421
+ <div class="dir-header expanded" onclick="toggleSection(this)">
422
+ <span class="dir-arrow">▶</span>
423
+ <span title="<%= dir %>"><%= dir_label %></span>
424
+ </div>
425
+ <ul class="dir-files">
426
+ <% entries.each do |filepath, idx| %>
427
+ <li>
428
+ <a href="/?file=<%= idx %><%= repo_qs %>"
429
+ class="<%= idx == @current_index ? 'active' : '' %>"
430
+ title="<%= filepath %>">
431
+ <%# History entries are always .rb (written by canvas-push,
432
+ see doc_store.rb) so plain File.basename(path, '.rb')
433
+ is still correct below in the history block -- this
434
+ docs_groups block is the one FileList.build now also
435
+ populates with .org, so it needs both extensions
436
+ stripped, not just one. %>
437
+ <%= File.basename(filepath).sub(/\.(rb|org)\z/, '') %>
438
+ <%# Shown unconditionally on every .org entry, not just on
439
+ a name collision (stream_weaver-gnj8) -- a same-stem
440
+ .rb/.org pair (demo.rb + demo.org) is otherwise
441
+ indistinguishable once the extension is stripped
442
+ above, and a badge that only sometimes appears would
443
+ be worse than one that's always there: you couldn't
444
+ trust its absence to mean "no other version exists."
445
+ .rb gets no badge -- it's the implied default. %>
446
+ <% if filepath.end_with?('.org') %><span class="sw-reader-file-badge">org</span><% end %>
447
+ </a>
448
+ <%# One row = one file: a .rb/.org pair are two entries with
449
+ two independent delete buttons, and deleting one never
450
+ cascades to its sibling. This button opens the confirm
451
+ dialog and nothing else -- the actual POST only ever fires
452
+ from the dialog's own Delete button (no JS confirm dialog
453
+ anywhere on this page; the design doc rules it out). Same
454
+ to_json-then-h quoting the Save-as-doc widget documents
455
+ below: a filesystem name can legally contain a quote or an
456
+ apostrophe, and either escape alone breaks out of the
457
+ attribute. %>
458
+ <% if deletable %>
459
+ <button type="button" class="sw-doc-delete"
460
+ title="Delete <%= ERB::Util.h(File.basename(filepath)) %>"
461
+ aria-label="Delete <%= ERB::Util.h(File.basename(filepath)) %>"
462
+ @click.prevent.stop="askDelete(<%= ERB::Util.h(filepath.to_json) %>, <%= ERB::Util.h(File.basename(filepath).to_json) %>)">🗑</button>
463
+ <% end %>
464
+ </li>
465
+ <% end %>
466
+ </ul>
467
+ </div>
468
+ <% end %>
469
+
470
+ <% if history_groups.any? %>
471
+ <h2 class="history-section-header" onclick="toggleSection(this)">
472
+ <span class="history-arrow">▶</span>
473
+ History (<%= history_groups.size %>)
474
+ </h2>
475
+ <div class="history-content collapsed">
476
+ <% history_groups.each do |dir, entries| %>
477
+ <% session_label = File.basename(dir) %>
478
+ <div class="dir-section history-session">
479
+ <div class="dir-header" onclick="toggleSection(this)">
480
+ <span class="dir-arrow">▶</span>
481
+ <span title="<%= dir %>"><%= session_label %></span>
482
+ </div>
483
+ <ul class="dir-files collapsed">
484
+ <% entries.each do |filepath, idx| %>
485
+ <li>
486
+ <a href="/?file=<%= idx %><%= repo_qs %>"
487
+ class="<%= idx == @current_index ? 'active' : '' %>"
488
+ title="<%= filepath %>">
489
+ <%= File.basename(filepath, '.rb') %>
490
+ </a>
491
+ </li>
492
+ <% end %>
493
+ </ul>
494
+ </div>
495
+ <% end %>
496
+ </div>
497
+ <% end %>
498
+
499
+ <a href="/browse" class="browse-link" <%= browse_oob %>>📁 Browse files…</a>
500
+ <% end %>
501
+ </div>
502
+
503
+ <div id="sw-reader-nav">
504
+ <%# @current_index is unset for a file opened via Browse (stream_weaver-rdh)
505
+ -- it isn't part of any FileList, so there's no index to page
506
+ Prev/Next through and no ?file=N to hand /export. Browsed files skip
507
+ this whole block rather than guessing at a meaningless index. %>
508
+ <% if @current_index %>
509
+ <% prev_idx = @current_index - 1 %>
510
+ <% next_idx = @current_index + 1 %>
511
+ <% if prev_idx >= 0 %>
512
+ <a href="/?file=<%= prev_idx %><%= repo_qs %>" class="nav-link" data-nav="prev">◀ Prev</a>
513
+ <% else %>
514
+ <span class="nav-link nav-link--disabled">◀ Prev</span>
515
+ <% end %>
516
+ <span class="counter"><%= @current_index + 1 %> of <%= @file_list.size %></span>
517
+ <% if next_idx < @file_list.size %>
518
+ <a href="/?file=<%= next_idx %><%= repo_qs %>" class="nav-link" data-nav="next">Next ▶</a>
519
+ <% else %>
520
+ <span class="nav-link nav-link--disabled">Next ▶</span>
521
+ <% end %>
522
+
523
+ <%# Available for every file, not just history snapshots. hx-boost="false"
524
+ is mandatory: this link is inside the boosted <nav>, and a boosted
525
+ click would swap the downloaded document into #app-container instead
526
+ of handing it to the browser as a file.
527
+ No `download` attribute: Content-Disposition: attachment already
528
+ triggers the save-as on success and leaves this page in place --
529
+ `download` would additionally force a 422/500 error body into
530
+ ~/Downloads as an extensionless file instead of showing the error.
531
+ target="_blank" keeps a failed export's error text out of the way
532
+ of the page you were reading. %>
533
+ <a href="/export?file=<%= @current_index %>" class="nav-link nav-export"
534
+ hx-boost="false" target="_blank"
535
+ title="Download this doc as a standalone HTML file">⬇ Export HTML</a>
536
+
537
+ <%# Fetches and inlines mermaid's own library instead of referencing its CDN
538
+ (stream_weaver-dnq), so mermaid diagrams render in a viewer whose CSP
539
+ blocks external scripts entirely -- SharePoint's HTML preview is the
540
+ motivating case. Only shown on a doc that actually has a mermaid
541
+ diagram (@doc.html always contains "sw-mermaid" for one, in either
542
+ the diagram's own CSS class or its lazily-injected CSS block) --
543
+ the flag does nothing on a doc without one, so a second identical
544
+ button there would just be a confusing extra click with no effect. %>
545
+ <% if @doc.html.include?("sw-mermaid") %>
546
+ <a href="/export?file=<%= @current_index %>&amp;offline=1" class="nav-link nav-export"
547
+ hx-boost="false" target="_blank"
548
+ title="Download as standalone HTML with mermaid's library inlined, for viewers that block external scripts (e.g. SharePoint)">⬇ Export HTML (offline)</a>
549
+ <% end %>
550
+ <% elsif @current_file %>
551
+ <span class="counter" title="<%= ERB::Util.h(@current_file) %>">Browsed: <%= ERB::Util.h(File.basename(@current_file)) %></span>
552
+ <% end %>
553
+
554
+ <% if is_history %>
555
+ <%# Save-as-doc widget — promotes this history snapshot to docs/streamweaver_canvas/.
556
+ Chrome (CSS + Alpine skeleton) shared with the canvas page's own widget via
557
+ SaveDocWidget (stream_weaver-e13); only the endpoint, default-name source,
558
+ the file: index, and copy below are specific to the reader. Rendered inside
559
+ this same is_history conditional (not hoisted up with #sw-reader-chrome-css)
560
+ because spec/canvas/reader_promote_history_spec.rb asserts the sw-save-doc-*
561
+ strings are absent entirely for docs files. %>
562
+ <%
563
+ # to_json gives valid JS-string quoting (handles embedded quotes/apostrophes
564
+ # from a filesystem-derived name correctly); ERB::Util.h then HTML-escapes
565
+ # the result for the surrounding "..." attribute. Either alone is wrong: bare
566
+ # to_json leaks a literal " that closes the attribute early (the exact q4y
567
+ # regression the spec below guards against); the old ERB::Util.h(name) with
568
+ # hand-written '...' quotes HTML-escapes ' to &#39;, which the HTML parser
569
+ # decodes back to a literal ' before Alpine ever sees it, so an apostrophe in
570
+ # the name still breaks out.
571
+ %>
572
+ <%= StreamWeaver::Canvas::SaveDocWidget.render(
573
+ endpoint: '/save-doc',
574
+ button_title: 'Save this history snapshot as a persistent doc',
575
+ dialog_title: 'Save snapshot as doc',
576
+ hint_html: <<~HINT.strip,
577
+ <span x-show="format !== 'org'">Promotes this history entry to <code>docs/streamweaver_canvas/&lt;name&gt;.rb</code> (or <code>~/.streamweaver/canvas/</code> outside a git repo).</span>
578
+ <span x-show="format === 'org'">Promotes this history entry to a human-readable <code>&lt;name&gt;.org</code> sibling file alongside the canonical <code>.rb</code>.</span>
579
+ HINT
580
+ # The reader's default name is derived from the archived snapshot's own
581
+ # filename, not wall-clock "now", so it's computed once server-side and
582
+ # needs no client-side recompute on openDialog() (reset_name_js: '').
583
+ name_init: ERB::Util.h(default_name.to_json),
584
+ # stream_weaver-j3b3: the reader has no live session/source_dir to carry
585
+ # (see the save-doc route's own comment) -- this just computes the same
586
+ # git-root auto-detection DocStore.save already does internally, so the
587
+ # toggle's label/hide-when-nil matches what "This repo" actually resolves
588
+ # to today.
589
+ source_dir: StreamWeaver::Canvas::DocStore.git_root(Dir.pwd),
590
+ extra_alpine_data: "file: #{@current_index},\n ",
591
+ extra_body_fields: 'file: this.file, ',
592
+ # display:contents keeps the widget (inside #sw-reader-nav, a flex row so
593
+ # the OOB swap refreshes its file: index on every boosted navigation) from
594
+ # becoming a fourth flex item and pushing Next off the right edge -- both
595
+ # its real children are position:fixed, so nothing lands in the flow.
596
+ host_class: 'sw-save-doc-host',
597
+ css_layer: 'sw-reader-chrome',
598
+ dialog_css_extra: "\n color: #111827; text-align: left;",
599
+ gist: gist_widget_data(default_name)
600
+ ) %>
601
+ <% end %>
602
+ </div>
603
+
604
+ <%# Delete confirm (stream_weaver-uvaj). Outside #sw-reader-files and
605
+ #sw-reader-nav on purpose: both are swapped by the very request this
606
+ dialog triggers. Rendered only when something on screen is actually
607
+ deletable. position:fixed, so its placement in the rail's flex column
608
+ doesn't affect where it appears -- and the rail collapses with `left`
609
+ rather than `transform` precisely so fixed descendants stay
610
+ viewport-fixed (see #sw-reader-chrome's CSS comment). %>
611
+ <% if deletable_dirs.any? %>
612
+ <div x-show="delOpen" x-cloak class="sw-doc-delete-modal" @click.self="delOpen = false">
613
+ <div class="sw-doc-delete-dialog" @click.stop>
614
+ <h3>Delete this doc?</h3>
615
+ <p class="hint">
616
+ <%# Deliberately conditional about git: only ONE of the two deletable
617
+ roots is a repo. The global store (~/.streamweaver/canvas) has no
618
+ git safety net at all, and promising one there would be a lie at
619
+ exactly the moment it matters most. %>
620
+ <code x-text="delName"></code> is removed from disk. Nothing is staged or
621
+ committed — inside a repo the deletion simply shows up in <code>git status</code>,
622
+ the same as deleting the file in Finder.
623
+ </p>
624
+ <div x-show="delError" x-text="delError" class="sw-doc-delete-error"></div>
625
+ <div class="sw-doc-delete-actions">
626
+ <button type="button" class="sw-doc-delete-cancel" @click="delOpen = false" :disabled="delBusy">Cancel</button>
627
+ <%# hx-select-oob is here, not on #sw-reader-files: htmx resolves it
628
+ from the element passed as the request's `source`, and this button
629
+ is the one element that can carry the wider Browse-style OOB
630
+ without ALSO becoming an ancestor of the ordinary file links --
631
+ which would make every sidebar click re-render the rail and blow
632
+ away accordion state (stream_weaver-8v1). %>
633
+ <button type="button" class="sw-doc-delete-confirm"
634
+ <%= browse_oob %>
635
+ @click="doDelete()" :disabled="delBusy">
636
+ <span x-show="!delBusy">Delete</span>
637
+ <span x-show="delBusy">Deleting…</span>
638
+ </button>
639
+ </div>
640
+ </div>
641
+ </div>
642
+ <% end %>
643
+ </nav>
644
+
645
+ <script>
646
+ // Shared by docs/history-session dir-headers and the outer History (N)
647
+ // header -- both are a header immediately followed by the region it
648
+ // discloses, so one function drives all three. Arrow rotation is pure CSS
649
+ // (.expanded .dir-arrow / .history-arrow), not inline style, so it can't
650
+ // drift out of sync with the collapsed state the way per-glyph inline
651
+ // transforms did.
652
+ function toggleSection(header) {
653
+ header.classList.toggle('expanded');
654
+ header.nextElementSibling.classList.toggle('collapsed');
655
+ }
656
+
657
+ // Delete a saved doc (stream_weaver-uvaj). Defined as a global rather than
658
+ // inlined into x-data so the same component can be re-created verbatim
659
+ // every time htmx swaps the rail. This inline script runs during parsing,
660
+ // Alpine's CDN tag is `defer`, so the function always exists by the time
661
+ // Alpine evaluates the attribute.
662
+ function swDeleteDoc() {
663
+ return {
664
+ delOpen: false, delPath: null, delName: null, delBusy: false, delError: null,
665
+
666
+ // The row button's only job. Nothing is deleted until the dialog's own
667
+ // Delete button calls doDelete() -- there is deliberately no JS
668
+ // confirm dialog anywhere on this page.
669
+ askDelete(path, name) {
670
+ this.delPath = path;
671
+ this.delName = name;
672
+ this.delError = null;
673
+ this.delOpen = true;
674
+ },
675
+
676
+ async doDelete() {
677
+ if (this.delBusy) return;
678
+ this.delBusy = true;
679
+ this.delError = null;
680
+ let landing;
681
+ try {
682
+ const res = await fetch('/delete-doc', {
683
+ method: 'POST',
684
+ headers: {'Content-Type': 'application/json'},
685
+ body: JSON.stringify({path: this.delPath, file: swCurrentFileIndex()})
686
+ });
687
+ const data = await res.json();
688
+ if (res.ok && data.ok) {
689
+ landing = data.file;
690
+ this.delOpen = false;
691
+ } else {
692
+ this.delError = data.error || ('HTTP ' + res.status);
693
+ }
694
+ } catch (e) {
695
+ this.delError = e.message;
696
+ }
697
+ this.delBusy = false;
698
+ // Strictly after this component's own state is settled: the refresh
699
+ // swaps #sw-reader-files, and Alpine tears down every component
700
+ // inside it as it goes.
701
+ if (landing !== undefined) swRefreshAfterDelete(landing);
702
+ }
703
+ };
704
+ }
705
+
706
+ function swCurrentFileIndex() {
707
+ const n = parseInt(new URLSearchParams(window.location.search).get('file'), 10);
708
+ return Number.isInteger(n) ? n : null;
709
+ }
710
+
711
+ // Re-fetches the doc AND both sidebar regions through htmx rather than
712
+ // reloading the page. `source` is the element htmx resolves hx-select-oob
713
+ // from, and the dialog's Delete button carries the same wider OOB the
714
+ // Browse links use -- the chrome's default (#sw-reader-nav alone)
715
+ // deliberately leaves the file rail untouched, which is exactly what has to
716
+ // change here. fileIndex is null when nothing survived: ?file=0 against an
717
+ // empty list renders the no-doc-open placeholder rather than a 404.
718
+ function swRefreshAfterDelete(fileIndex) {
719
+ const repo = new URLSearchParams(window.location.search).get('repo');
720
+ let url = '/?file=' + (fileIndex === null ? 0 : fileIndex);
721
+ if (repo) url += '&repo=' + encodeURIComponent(repo);
722
+ window.history.replaceState(null, '', url);
723
+ htmx.ajax('GET', url, {
724
+ source: document.querySelector('.sw-doc-delete-confirm'),
725
+ target: '#app-container',
726
+ select: '#app-container',
727
+ swap: 'outerHTML'
728
+ });
729
+ }
730
+
731
+ // Rail collapse. Below 1100px the rail is collapsed by default and the
732
+ // toggle slides it in (.sw-rail-open); above, it's open by default and the
733
+ // toggle slides it out (.sw-rail-collapsed). Classes go on <html> because
734
+ // the offset rule they drive is `html { padding-left }`.
735
+ function swToggleRail() {
736
+ const root = document.documentElement;
737
+ if (window.matchMedia('(max-width: 1100px)').matches) {
738
+ root.classList.remove('sw-rail-collapsed');
739
+ root.classList.toggle('sw-rail-open');
740
+ } else {
741
+ root.classList.remove('sw-rail-open');
742
+ root.classList.toggle('sw-rail-collapsed');
743
+ }
744
+ }
745
+
746
+ // Keyboard arrows trigger Prev/Next anchors so they go through htmx
747
+ // (preserving sidebar state). The anchors handle their own enabled/disabled
748
+ // state via the [data-nav] selector — disabled state is rendered as <span>
749
+ // (no [data-nav]) so the click finds nothing.
750
+ //
751
+ // Suppressed while focus is in a text input/textarea/contenteditable (the
752
+ // Save-as-doc name field lives on this same page) -- otherwise correcting
753
+ // a typo with Left/Right silently navigates the reader out from under the
754
+ // open dialog. Also suppressed with any modifier held, so it doesn't
755
+ // shadow browser/OS shortcuts that happen to use the arrow keys.
756
+ document.addEventListener('keydown', (e) => {
757
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
758
+ if (e.target.closest('input, textarea, [contenteditable="true"]')) return;
759
+ // A mermaid fullscreen expand (stream_weaver-yjv) is a <dialog> opened
760
+ // via showModal(), which makes the rest of the page inert to *user*
761
+ // interaction -- but inert doesn't stop a programmatic .click() like
762
+ // this handler's, so without this guard, arrow-key nav still silently
763
+ // navigates the reader out from under an open diagram.
764
+ if (document.querySelector('dialog[open]')) return;
765
+ if (e.key === 'ArrowLeft') document.querySelector('[data-nav="prev"]')?.click();
766
+ if (e.key === 'ArrowRight') document.querySelector('[data-nav="next"]')?.click();
767
+ });
768
+
769
+ // Only #app-container and #sw-reader-nav are swapped by default (Browse
770
+ // links, stream_weaver-rdh, additionally swap #sw-reader-files via their
771
+ // own wider BROWSE_OOB), so two things the rest of the page depends on
772
+ // have to be re-derived by hand after each swap:
773
+ // 1. <body>'s theme/layout classes — file N may be :doc themed and N+1 not.
774
+ // 2. the active link in #sw-reader-files, when it wasn't itself swapped
775
+ // this time (stream_weaver-8v1 -- the whole reason it usually isn't:
776
+ // an actual re-render would reset any accordion section a user had
777
+ // opened).
778
+ document.addEventListener('htmx:afterSwap', () => {
779
+ const container = document.getElementById('app-container');
780
+ if (container && container.dataset.swBodyClass) {
781
+ document.body.className = container.dataset.swBodyClass;
782
+ }
783
+ const current = new URLSearchParams(window.location.search).get('file');
784
+ // Browse/Open URLs (and "/", mid-redirect to "/?file=0") carry no
785
+ // ?file= at all -- current is null there. Without this guard,
786
+ // `href.searchParams.get('file') === current` reads as `null === null`
787
+ // for every single Browse link (none of them have a file= param
788
+ // either), so the whole listing lit up .active, and the one
789
+ // non-Browse link on that response ("Back to docs", also file-less)
790
+ // stripped the .active class the OOB swap had just correctly set. Only
791
+ // do this comparison when there's an actual file index to compare.
792
+ if (current === null) return;
793
+ document.querySelectorAll('#sw-reader-files .dir-files a').forEach((a) => {
794
+ const href = new URL(a.getAttribute('href'), window.location.origin);
795
+ a.classList.toggle('active', href.searchParams.get('file') === current);
796
+ });
797
+ });
798
+ </script>
799
+ </body>
800
+ </html>