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,1240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sinatra/base'
4
+ require 'sinatra/streaming'
5
+ require 'securerandom'
6
+ require 'json'
7
+ require 'socket'
8
+ require 'fileutils'
9
+ require 'uri'
10
+ require_relative 'css'
11
+
12
+ module StreamWeaver
13
+ # Multi-app service that renders StreamWeaver apps without per-app process management.
14
+ # CLI spawns this service if needed, then sends commands to load apps.
15
+ class Service < Sinatra::Base
16
+ helpers Sinatra::Streaming
17
+
18
+ enable :sessions
19
+ set :session_secret, ENV.fetch('SESSION_SECRET') {
20
+ 'stream-weaver-service-development-secret-key-minimum-64-characters-for-security'
21
+ }
22
+
23
+ set :logging, false
24
+ set :show_exceptions, :after_handler
25
+ set :server, :puma
26
+
27
+ DEFAULT_PORT = 4567
28
+
29
+ class << self
30
+ # { app_id => { app: StreamWeaver::App, path: file_path, loaded_at: Time } }
31
+ attr_accessor :apps
32
+
33
+ def apps
34
+ @apps ||= {}
35
+ end
36
+
37
+ # =========================================
38
+ # Live Sessions (polling-based update-in-place)
39
+ # =========================================
40
+
41
+ def live_sessions
42
+ @live_sessions ||= {}
43
+ end
44
+
45
+ # { slug => app_id } registry for human-readable /apps/:slug URLs
46
+ def slug_registry
47
+ @slug_registry ||= {}
48
+ end
49
+
50
+ def get_or_create_live_session(name)
51
+ live_sessions[name] ||= {
52
+ content: {}, # { target => { html:, action:, timestamp: } }
53
+ submissions: [], # Array of form submissions waiting for Claude to read
54
+ created_at: Time.now,
55
+ last_push: nil
56
+ }
57
+ end
58
+
59
+ # Store a form submission for Claude to retrieve
60
+ def add_live_submission(name, data)
61
+ session = live_sessions[name]
62
+ return false unless session
63
+ session[:submissions] << {
64
+ data: data,
65
+ timestamp: (Time.now.to_f * 1000).to_i
66
+ }
67
+ true
68
+ end
69
+
70
+ # Get pending submissions (Claude polls this)
71
+ def get_live_submissions(name, clear: true)
72
+ session = live_sessions[name]
73
+ return [] unless session
74
+ submissions = session[:submissions].dup
75
+ session[:submissions].clear if clear
76
+ submissions
77
+ end
78
+
79
+ def push_to_live_session(name, target:, content:, action: :replace)
80
+ session = live_sessions[name]
81
+ return false unless session
82
+
83
+ session[:content][target] = {
84
+ html: content,
85
+ action: action,
86
+ timestamp: (Time.now.to_f * 1000).to_i
87
+ }
88
+ session[:last_push] = Time.now
89
+ true
90
+ end
91
+
92
+ def remove_live_session(name)
93
+ live_sessions.delete(name) ? true : false
94
+ end
95
+
96
+ def list_live_sessions
97
+ live_sessions.map do |name, session|
98
+ {
99
+ name: name,
100
+ created_at: session[:created_at],
101
+ last_push: session[:last_push]
102
+ }
103
+ end
104
+ end
105
+
106
+ # Load an app from a file path and register it
107
+ # Returns the app_id for the newly loaded app
108
+ #
109
+ # Works with existing examples that use the pattern:
110
+ # App = app "Title" do ... end
111
+ # App.run! if __FILE__ == $0
112
+ #
113
+ # When loaded from service, __FILE__ != $0 so run! is skipped,
114
+ # and we capture the SinatraApp from StreamWeaver.last_generated_app
115
+ #
116
+ # @param file_path [String] Path to the Ruby file
117
+ # @param name [String, nil] Optional custom name for the app
118
+ # @param source [String, nil] Optional source identifier (e.g., "examples_browser", "tutorial")
119
+ # @return [String] The app_id
120
+ def load_app(file_path, name: nil, source: nil)
121
+ app_id = SecureRandom.hex(4)
122
+ expanded_path = File.expand_path(file_path)
123
+
124
+ raise "File not found: #{expanded_path}" unless File.exist?(expanded_path)
125
+
126
+ # Clear any previous captured app
127
+ StreamWeaver.last_generated_app = nil
128
+
129
+ # Load the file - the global app() helper captures the result.
130
+ # service_loading makes any trailing `end.run!` in the file a
131
+ # warn-and-no-op instead of booting a second server inside us.
132
+ StreamWeaver.service_loading = true
133
+ begin
134
+ load expanded_path
135
+ ensure
136
+ StreamWeaver.service_loading = false
137
+ end
138
+
139
+ # Get the captured app
140
+ sinatra_app = StreamWeaver.last_generated_app
141
+ raise "No app found. File should use: app \"Title\" do ... end" unless sinatra_app
142
+
143
+ # Extract the StreamWeaver::App from the SinatraApp
144
+ streamlit_app = sinatra_app.settings.streamlit_app
145
+
146
+ # Derive name from: explicit name > app title > filename
147
+ app_name = name || streamlit_app.title || File.basename(expanded_path, '.rb')
148
+ slug = assign_slug(app_id, app_name, expanded_path)
149
+
150
+ apps[app_id] = {
151
+ app: streamlit_app,
152
+ path: expanded_path,
153
+ name: app_name,
154
+ source: source,
155
+ slug: slug,
156
+ loaded_at: Time.now,
157
+ last_accessed: Time.now
158
+ }
159
+
160
+ app_id
161
+ end
162
+
163
+ # Convert a string into a URL-safe slug: lowercase, non-alphanumerics
164
+ # collapsed to hyphens, leading/trailing hyphens stripped.
165
+ # @param str [String]
166
+ # @return [String]
167
+ def slugify(str)
168
+ str.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')
169
+ end
170
+
171
+ # Assign (or reuse) a human-readable slug for an app_id, derived from the
172
+ # app's declared name with fallback to the script filename.
173
+ #
174
+ # Re-loading the same file reuses its previous slug. A different file
175
+ # whose derived slug collides with an existing one gets a numeric
176
+ # suffix (-2, -3, ...).
177
+ #
178
+ # @param app_id [String] The app_id being assigned a slug
179
+ # @param app_name [String] The app's declared/derived name
180
+ # @param expanded_path [String] Absolute path to the app's file
181
+ # @return [String] The assigned slug
182
+ def assign_slug(app_id, app_name, expanded_path)
183
+ base = slugify(app_name)
184
+ base = slugify(File.basename(expanded_path, '.rb')) if base.empty?
185
+ base = 'app' if base.empty?
186
+
187
+ slug = base
188
+ suffix = 2
189
+ loop do
190
+ holder_id = slug_registry[slug]
191
+ holder_entry = holder_id && apps[holder_id]
192
+ break if holder_entry.nil? || holder_entry[:path] == expanded_path
193
+ slug = "#{base}-#{suffix}"
194
+ suffix += 1
195
+ end
196
+
197
+ slug_registry[slug] = app_id
198
+ slug
199
+ end
200
+
201
+ # Resolve a hex app_id or a human-readable slug to the canonical app_id
202
+ # @param id_or_slug [String]
203
+ # @return [String, nil] The canonical app_id, or nil if not found
204
+ def resolve_app_id(id_or_slug)
205
+ return id_or_slug if apps.key?(id_or_slug)
206
+ slug_registry[id_or_slug]
207
+ end
208
+
209
+ # Remove an app by ID
210
+ # @param app_id [String] The app ID to remove
211
+ # @return [Boolean] true if removed, false if not found
212
+ def remove_app(app_id)
213
+ return false unless apps.key?(app_id)
214
+ apps.delete(app_id)
215
+ true
216
+ end
217
+
218
+ # Remove all apps
219
+ # @return [Integer] Number of apps removed
220
+ def clear_apps
221
+ count = apps.size
222
+ @apps = {}
223
+ @slug_registry = {}
224
+ count
225
+ end
226
+
227
+ # Remove all apps from a specific source
228
+ # @param source [String] The source identifier
229
+ # @return [Integer] Number of apps removed
230
+ def remove_apps_by_source(source)
231
+ return 0 if source.nil? || source.empty?
232
+ ids_to_remove = apps.select { |_id, entry| entry[:source] == source }.keys
233
+ ids_to_remove.each { |id| apps.delete(id) }
234
+ ids_to_remove.size
235
+ end
236
+
237
+ # Find app by source and name (for aliased routes)
238
+ # @param source [String] The source identifier
239
+ # @param name [String] The app name (partial match OK)
240
+ # @return [Array<String, Hash>] [app_id, entry] or [nil, nil]
241
+ def find_app_by_source_and_name(source, name)
242
+ apps.find do |_id, entry|
243
+ entry[:source] == source && entry[:name]&.include?(name)
244
+ end || [nil, nil]
245
+ end
246
+
247
+ # Generate aliased route path for an app
248
+ # @param app_id [String] The app ID
249
+ # @return [String, nil] The aliased path or nil if no source
250
+ def aliased_path_for(app_id)
251
+ entry = apps[app_id]
252
+ return nil unless entry && entry[:source]
253
+ # Use custom name if provided, otherwise fall back to filename
254
+ name_part = entry[:name] || File.basename(entry[:path], '.rb')
255
+ # Handle names that might include source prefix (e.g., "tutorial/philosophy")
256
+ name_part = name_part.sub(%r{^#{entry[:source]}/}, '')
257
+ "/#{entry[:source]}/#{name_part}"
258
+ end
259
+
260
+ # PID file management
261
+ def pid_file_path
262
+ File.expand_path('~/.streamweaver/server.pid')
263
+ end
264
+
265
+ def write_pid_file(port)
266
+ FileUtils.mkdir_p(File.dirname(pid_file_path))
267
+ File.write(pid_file_path, "port=#{port}\npid=#{Process.pid}\n")
268
+ end
269
+
270
+ def delete_pid_file
271
+ File.delete(pid_file_path) if File.exist?(pid_file_path)
272
+ end
273
+
274
+ def read_pid_file
275
+ return nil unless File.exist?(pid_file_path)
276
+ content = File.read(pid_file_path)
277
+ port = content[/port=(\d+)/, 1]&.to_i
278
+ pid = content[/pid=(\d+)/, 1]&.to_i
279
+ { port: port, pid: pid }
280
+ end
281
+
282
+ # Check if a service is already running (named to avoid shadowing Sinatra's running?)
283
+ def service_running?
284
+ info = read_pid_file
285
+ return false unless info && info[:pid]
286
+
287
+ # Check if process exists
288
+ Process.kill(0, info[:pid])
289
+ true
290
+ rescue Errno::ESRCH
291
+ # Process not found, clean up stale PID file
292
+ delete_pid_file
293
+ false
294
+ end
295
+
296
+ def find_available_port(start_port = DEFAULT_PORT)
297
+ port = start_port
298
+ loop do
299
+ begin
300
+ # Check both 127.0.0.1 and 0.0.0.0 to avoid conflicts where
301
+ # a wildcard listener already holds the port (macOS routes to it)
302
+ s1 = TCPServer.new('127.0.0.1', port)
303
+ s1.close
304
+ s2 = TCPServer.new('0.0.0.0', port)
305
+ s2.close
306
+ return port
307
+ rescue Errno::EADDRINUSE
308
+ port += 1
309
+ raise "No available ports found" if port > start_port + 100
310
+ end
311
+ end
312
+ end
313
+
314
+ # Launch service in background using spawn
315
+ def launch_background(port: nil)
316
+ port ||= find_available_port
317
+
318
+ # Ensure directory exists
319
+ FileUtils.mkdir_p(File.dirname(pid_file_path))
320
+
321
+ # Log file for debugging
322
+ log_file = File.join(File.dirname(pid_file_path), 'server.log')
323
+
324
+ # Get the lib path for stream_weaver
325
+ lib_path = File.expand_path('../..', __FILE__)
326
+
327
+ # Create a simple server script
328
+ server_script = <<~RUBY
329
+ $stdout.sync = true
330
+ $stderr.sync = true
331
+ puts "Starting StreamWeaver service..."
332
+
333
+ $LOAD_PATH.unshift('#{lib_path}')
334
+ require 'stream_weaver'
335
+
336
+ puts "Setting port to #{port}..."
337
+ StreamWeaver::Service.set :port, #{port}
338
+ StreamWeaver::Service.set :bind, '127.0.0.1'
339
+
340
+ # Configure Puma for SSE (more threads to handle long-lived connections)
341
+ StreamWeaver::Service.set :server_settings, {
342
+ :Threads => '5:20' # Min 5, max 20 threads for SSE support
343
+ }
344
+
345
+ # Write PID file
346
+ puts "Writing PID file..."
347
+ File.write('#{pid_file_path}', "port=#{port}\\npid=\#{Process.pid}\\n")
348
+
349
+ # Clean up on exit
350
+ at_exit { File.delete('#{pid_file_path}') rescue nil }
351
+
352
+ puts "Calling run!..."
353
+ StreamWeaver::Service.run!
354
+ RUBY
355
+
356
+ # Write script to temp file
357
+ script_file = File.join(File.dirname(pid_file_path), 'server_start.rb')
358
+ File.write(script_file, server_script)
359
+
360
+ # Spawn the server process
361
+ pid = spawn(
362
+ RbConfig.ruby, script_file,
363
+ out: [log_file, 'a'],
364
+ err: [log_file, 'a'],
365
+ pgroup: true # New process group
366
+ )
367
+ Process.detach(pid)
368
+
369
+ # Wait for PID file to be written (server started)
370
+ 10.times do
371
+ if File.exist?(pid_file_path)
372
+ sleep 0.5 # Extra time for server to be ready
373
+ return { port: port, pid: pid }
374
+ end
375
+ sleep 0.5
376
+ end
377
+
378
+ { port: port, pid: pid }
379
+ end
380
+
381
+ def stop
382
+ info = read_pid_file
383
+ return false unless info && info[:pid]
384
+
385
+ Process.kill('TERM', info[:pid])
386
+ sleep 1
387
+ delete_pid_file
388
+ true
389
+ rescue Errno::ESRCH
390
+ delete_pid_file
391
+ true
392
+ end
393
+ end
394
+
395
+ helpers do
396
+ # Get app-specific state from session
397
+ def app_state(app_id)
398
+ session[:app_states] ||= {}
399
+ session[:app_states][app_id] ||= {}
400
+ end
401
+
402
+ # Service-mode parity with SessionStore::Base#filter (flash-prg.md §5):
403
+ # flash is one-shot, so it must never be the value persisted for the
404
+ # *next* request. Storing a stripped copy -- not mutating `state` in
405
+ # place -- leaves the caller's live `state` object (already used, or
406
+ # about to be used, for *this* request's render) untouched.
407
+ def set_app_state(app_id, state)
408
+ session[:app_states] ||= {}
409
+ session[:app_states][app_id] = state.reject { |k, _| k == :_flash }
410
+ end
411
+
412
+ def action_generation(app_id)
413
+ session[:action_generations] ||= {}
414
+ session[:action_generations][app_id] ||= SecureRandom.hex(12)
415
+ end
416
+
417
+ def action_manifest(app_id)
418
+ session[:action_manifests] ||= {}
419
+ session[:action_manifests][app_id] ||= []
420
+ end
421
+
422
+ def set_action_manifest(app_id, tokens)
423
+ session[:action_manifests] ||= {}
424
+ session[:action_manifests][app_id] = tokens.to_a
425
+ end
426
+
427
+ def state_version(app_id)
428
+ (session[:state_versions] || {})[app_id] || 0
429
+ end
430
+
431
+ def set_state_version(app_id, version)
432
+ session[:state_versions] ||= {}
433
+ session[:state_versions][app_id] = version
434
+ end
435
+
436
+ # Multi-tab route_with fix (2026-08-31) -- service-mode counterpart to
437
+ # server.rb's `seed_route_state!`/`reseed_route_state!` (see that
438
+ # file's comment for the full rationale). Mount-prefix aware: an
439
+ # app hosted at /apps/:app_id needs the prefix stripped from
440
+ # HX-Current-URL before it matches what `state_for_path` expects, the
441
+ # same way the `after '/apps/:app_id/*'` hook re-applies it on the way
442
+ # out. A stale HX-Current-URL from a DIFFERENT mounted app (a session
443
+ # can hold many) is skipped rather than misapplied to this one.
444
+ def seed_route_state!(streamlit_app, state, path)
445
+ return if path.to_s.empty?
446
+ return unless streamlit_app.routable?
447
+
448
+ streamlit_app.state_for_path(path)&.each { |k, v| state[k] = v }
449
+ end
450
+
451
+ def reseed_route_state!(app_id, streamlit_app, state)
452
+ current_url = request.env['HTTP_HX_CURRENT_URL']
453
+ return unless current_url
454
+
455
+ path = begin
456
+ URI.parse(current_url).path
457
+ rescue URI::Error
458
+ nil
459
+ end
460
+ return unless path
461
+
462
+ prefix = "/apps/#{app_id}"
463
+ return unless path == prefix || path.start_with?("#{prefix}/")
464
+
465
+ suffix = path.sub(prefix, "")
466
+ suffix = "/" if suffix.empty?
467
+ seed_route_state!(streamlit_app, state, suffix)
468
+ end
469
+
470
+ # Sync form params to state hash (mirrors server.rb's helper of the same name)
471
+ def sync_params_to_state(state, excluded_keys: [])
472
+ excluded = App::ROUTE_OWNED_PARAMS + excluded_keys.map(&:to_s)
473
+ params.each do |key, value|
474
+ next if excluded.include?(key)
475
+ state[key.to_sym] = coerce_param_value(value, state[key.to_sym])
476
+ end
477
+ end
478
+
479
+ def coerce_param_value(value, current_value)
480
+ case
481
+ when value.is_a?(Array) then value
482
+ when value == "on" || value == "true" then true
483
+ when value == "false" then false
484
+ when current_value.is_a?(Array) then Array(value)
485
+ else value
486
+ end
487
+ end
488
+
489
+ def render_error(message, error = nil)
490
+ status 500
491
+ content_type 'text/html'
492
+ backtrace = error&.backtrace&.first(10)&.join("\n") || ""
493
+ <<~HTML
494
+ <div style="color: red; padding: 1rem; border: 1px solid red; margin: 1rem; font-family: monospace;">
495
+ <h3>Error</h3>
496
+ <p><strong>#{Rack::Utils.escape_html(message)}</strong></p>
497
+ #{"<pre>#{Rack::Utils.escape_html(backtrace)}</pre>" unless backtrace.empty?}
498
+ </div>
499
+ HTML
500
+ end
501
+
502
+ end
503
+
504
+ # =========================================
505
+ # Service Management Routes
506
+ # =========================================
507
+
508
+ # Status endpoint for CLI detection
509
+ get '/api/status' do
510
+ content_type :json
511
+ {
512
+ app: 'streamweaver',
513
+ version: StreamWeaver::VERSION,
514
+ port: settings.port,
515
+ apps: self.class.apps.keys
516
+ }.to_json
517
+ end
518
+
519
+ # Load an app from file path
520
+ post '/load-app' do
521
+ content_type :json
522
+ file_path = params[:file_path]
523
+ name = params[:name] # Optional custom name
524
+ source = params[:source] # Optional source identifier (e.g., "examples_browser")
525
+
526
+ begin
527
+ app_id = self.class.load_app(file_path, name: name, source: source)
528
+ app_entry = self.class.apps[app_id]
529
+ aliased = self.class.aliased_path_for(app_id)
530
+ {
531
+ success: true,
532
+ app_id: app_id,
533
+ slug: app_entry[:slug],
534
+ name: app_entry[:name],
535
+ source: app_entry[:source],
536
+ url: "/apps/#{app_entry[:slug]}",
537
+ canonical_url: "/apps/#{app_id}",
538
+ aliased_url: aliased
539
+ }.to_json
540
+ rescue => e
541
+ status 400
542
+ { success: false, error: e.message }.to_json
543
+ end
544
+ end
545
+
546
+ # Remove a specific app
547
+ delete '/apps/:app_id' do
548
+ content_type :json
549
+ app_id = params[:app_id]
550
+
551
+ if self.class.remove_app(app_id)
552
+ { success: true, message: "App #{app_id} removed" }.to_json
553
+ else
554
+ status 404
555
+ { success: false, error: "App not found: #{app_id}" }.to_json
556
+ end
557
+ end
558
+
559
+ # Also support POST for clients that don't support DELETE
560
+ post '/remove-app' do
561
+ content_type :json
562
+ app_id = params[:app_id]
563
+
564
+ if self.class.remove_app(app_id)
565
+ { success: true, message: "App #{app_id} removed" }.to_json
566
+ else
567
+ status 404
568
+ { success: false, error: "App not found: #{app_id}" }.to_json
569
+ end
570
+ end
571
+
572
+ # Clear all apps
573
+ post '/clear-apps' do
574
+ content_type :json
575
+ count = self.class.clear_apps
576
+ { success: true, message: "Removed #{count} app(s)" }.to_json
577
+ end
578
+
579
+ # Remove all apps from a specific source
580
+ post '/clear-source' do
581
+ content_type :json
582
+ source = params[:source]
583
+ count = self.class.remove_apps_by_source(source)
584
+ { success: true, message: "Removed #{count} app(s) from #{source}", count: count }.to_json
585
+ end
586
+
587
+ # List apps with details (JSON API)
588
+ get '/api/apps' do
589
+ content_type :json
590
+ apps_list = self.class.apps.map do |id, entry|
591
+ {
592
+ id: id,
593
+ name: entry[:name],
594
+ path: entry[:path],
595
+ source: entry[:source],
596
+ title: entry[:app].title,
597
+ url: "/apps/#{id}",
598
+ aliased_url: self.class.aliased_path_for(id),
599
+ loaded_at: entry[:loaded_at].iso8601,
600
+ last_accessed: entry[:last_accessed].iso8601,
601
+ age_seconds: (Time.now - entry[:loaded_at]).to_i,
602
+ idle_seconds: (Time.now - entry[:last_accessed]).to_i
603
+ }
604
+ end
605
+ { apps: apps_list }.to_json
606
+ end
607
+
608
+ # =========================================
609
+ # Live Sessions (SSE-based update-in-place)
610
+ # =========================================
611
+
612
+ # Create or get a live session and serve the live page
613
+ get '/live/:session_name' do
614
+ session_name = params[:session_name]
615
+ theme = (params[:theme] || 'default').to_s
616
+ layout = (params[:layout] || 'default').to_s
617
+ self.class.get_or_create_live_session(session_name)
618
+
619
+ # Validate theme
620
+ valid_themes = %w[default dashboard document]
621
+ theme = 'default' unless valid_themes.include?(theme)
622
+
623
+ content_type 'text/html'
624
+ <<~HTML
625
+ <!DOCTYPE html>
626
+ <html>
627
+ <head>
628
+ <title>StreamWeaver Live: #{Rack::Utils.escape_html(session_name)}</title>
629
+ #{StreamWeaver::CSS.cdn_scripts_html}
630
+ #{StreamWeaver::CSS.google_fonts_html}
631
+ <style>
632
+ #{StreamWeaver::CSS.full_stylesheet}
633
+ #{StreamWeaver::CSS.animation_css}
634
+
635
+ /* Live session header overlay */
636
+ .sw-live-header {
637
+ position: fixed;
638
+ top: 0;
639
+ left: 0;
640
+ right: 0;
641
+ display: flex;
642
+ justify-content: space-between;
643
+ align-items: center;
644
+ padding: 0.5rem 1rem;
645
+ background: rgba(0, 0, 0, 0.85);
646
+ backdrop-filter: blur(8px);
647
+ color: #ffffff;
648
+ font-size: 0.75rem;
649
+ z-index: 9999;
650
+ font-family: var(--sw-font-body);
651
+ }
652
+ .sw-live-status {
653
+ display: flex;
654
+ align-items: center;
655
+ gap: 0.5rem;
656
+ }
657
+ .sw-live-dot {
658
+ width: 8px;
659
+ height: 8px;
660
+ border-radius: 50%;
661
+ background: var(--sw-color-primary);
662
+ animation: sw-pulse 2s infinite;
663
+ }
664
+ @keyframes sw-pulse {
665
+ 0%, 100% { opacity: 1; }
666
+ 50% { opacity: 0.4; }
667
+ }
668
+ .sw-live-content {
669
+ padding-top: 2.5rem; /* Space for fixed header */
670
+ }
671
+ /* Theme selector in header */
672
+ .sw-live-theme-select {
673
+ background: rgba(255,255,255,0.1);
674
+ border: 1px solid rgba(255,255,255,0.2);
675
+ color: #ffffff;
676
+ padding: 0.25rem 0.5rem;
677
+ border-radius: 4px;
678
+ font-size: 0.75rem;
679
+ cursor: pointer;
680
+ }
681
+ .sw-live-theme-select:hover {
682
+ background: rgba(255,255,255,0.2);
683
+ }
684
+ </style>
685
+ </head>
686
+ <body class="sw-theme-#{Rack::Utils.escape_html(theme)} sw-layout-#{Rack::Utils.escape_html(layout)}">
687
+ <div class="sw-live-header">
688
+ <div class="sw-live-status">
689
+ <span class="sw-live-dot"></span>
690
+ <span>Live: #{Rack::Utils.escape_html(session_name)}</span>
691
+ </div>
692
+ <div style="display: flex; align-items: center; gap: 1rem;">
693
+ <select class="sw-live-theme-select" onchange="switchTheme(this.value)">
694
+ <option value="default" #{theme == 'default' ? 'selected' : ''}>Default</option>
695
+ <option value="dashboard" #{theme == 'dashboard' ? 'selected' : ''}>Dashboard</option>
696
+ <option value="document" #{theme == 'document' ? 'selected' : ''}>Document</option>
697
+ </select>
698
+ <span id="sw-connection-status">Connecting...</span>
699
+ </div>
700
+ </div>
701
+ <div class="sw-live-content">
702
+ <div id="app-container">
703
+ <div id="main">
704
+ <p style="color: var(--sw-color-text-muted);">Waiting for content...</p>
705
+ </div>
706
+ </div>
707
+ </div>
708
+
709
+ <script>
710
+ // Theme switching
711
+ function switchTheme(theme) {
712
+ const body = document.body;
713
+ body.className = body.className.replace(/sw-theme-\\w+/, 'sw-theme-' + theme);
714
+ // Update URL without reload
715
+ const url = new URL(window.location);
716
+ url.searchParams.set('theme', theme);
717
+ window.history.replaceState({}, '', url);
718
+ }
719
+
720
+ // Polling for updates
721
+ (function() {
722
+ const sessionName = #{session_name.to_json};
723
+ const statusEl = document.getElementById('sw-connection-status');
724
+ let lastTs = 0;
725
+
726
+ async function poll() {
727
+ try {
728
+ const r = await fetch('/live/' + encodeURIComponent(sessionName) + '/poll?since=' + lastTs);
729
+ const d = await r.json();
730
+ statusEl.textContent = 'Connected';
731
+ statusEl.style.color = '#22c55e';
732
+ d.updates.forEach(u => {
733
+ const el = document.querySelector(u.target);
734
+ if (el) {
735
+ // Add animation class
736
+ el.classList.remove('sw-fade-in');
737
+ void el.offsetWidth; // Trigger reflow
738
+ el.classList.add('sw-fade-in');
739
+
740
+ if (u.action === 'replace') el.innerHTML = u.html;
741
+ else if (u.action === 'append') el.insertAdjacentHTML('beforeend', u.html);
742
+ else if (u.action === 'prepend') el.insertAdjacentHTML('afterbegin', u.html);
743
+ // Tell HTMX to process new content
744
+ if (typeof htmx !== 'undefined') htmx.process(el);
745
+ }
746
+ });
747
+ lastTs = d.timestamp;
748
+ } catch(e) {
749
+ statusEl.textContent = 'Reconnecting...';
750
+ statusEl.style.color = '#eab308';
751
+ }
752
+ setTimeout(poll, 300);
753
+ }
754
+ poll();
755
+ })();
756
+ </script>
757
+ </body>
758
+ </html>
759
+ HTML
760
+ end
761
+
762
+ # Polling endpoint for live session updates
763
+ get '/live/:session_name/poll' do
764
+ content_type :json
765
+ session_name = params[:session_name]
766
+ since = params[:since]&.to_i || 0
767
+
768
+ session = self.class.get_or_create_live_session(session_name)
769
+
770
+ updates = []
771
+ session[:content].each do |target, data|
772
+ updates << { target: target, action: data[:action], html: data[:html] } if data[:timestamp] > since
773
+ end
774
+
775
+ { updates: updates, timestamp: (Time.now.to_f * 1000).to_i }.to_json
776
+ end
777
+
778
+ # Push content to a live session
779
+ post '/live/:session_name/push' do
780
+ content_type :json
781
+ session_name = params[:session_name]
782
+ target = params[:target] || '#main'
783
+ action = (params[:action] || 'replace').to_sym
784
+ html_content = params[:content] || request.body.read
785
+
786
+ # Ensure session exists
787
+ self.class.get_or_create_live_session(session_name)
788
+
789
+ if self.class.push_to_live_session(session_name, target: target, content: html_content, action: action)
790
+ { success: true, session: session_name, target: target, action: action }.to_json
791
+ else
792
+ status 404
793
+ { success: false, error: "Session not found: #{session_name}" }.to_json
794
+ end
795
+ end
796
+
797
+ # List live sessions
798
+ get '/api/live' do
799
+ content_type :json
800
+ { sessions: self.class.list_live_sessions }.to_json
801
+ end
802
+
803
+ # Update from live session (text field changes, etc.)
804
+ post '/live/:session_name/update' do
805
+ # Return 204 No Content - tells HTMX to not swap anything
806
+ status 204
807
+ ''
808
+ end
809
+
810
+ # Button action from live session
811
+ post '/live/:session_name/action/:button_id' do
812
+ content_type 'text/html'
813
+ session_name = params[:session_name]
814
+ button_id = params[:button_id]
815
+
816
+ # Collect all form data
817
+ excluded = %w[splat captures session_name button_id]
818
+ form_data = { _button: button_id }
819
+ params.each do |key, value|
820
+ next if excluded.include?(key)
821
+ form_data[key.to_sym] = case value
822
+ when "on", "true" then true
823
+ when "false" then false
824
+ else value
825
+ end
826
+ end
827
+
828
+ self.class.add_live_submission(session_name, form_data)
829
+
830
+ # Return HTML with "thinking" indicator - Claude will push real content when ready
831
+ <<~HTML
832
+ <div id="main" style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px; gap: 1rem;">
833
+ <div class="sw-spinner" style="width: 40px; height: 40px; border: 3px solid var(--sw-color-border, #e0e0e0); border-top-color: var(--sw-color-primary, #c2410c); border-radius: 50%; animation: sw-spin 0.8s linear infinite;"></div>
834
+ <p style="color: var(--sw-color-text-muted, #666); font-size: 1rem;">Claude is processing your response...</p>
835
+ <style>@keyframes sw-spin { to { transform: rotate(360deg); } }</style>
836
+ </div>
837
+ HTML
838
+ end
839
+
840
+ # Submit form data from live session (user interactions)
841
+ post '/live/:session_name/submit' do
842
+ content_type :json
843
+ session_name = params[:session_name]
844
+
845
+ # Collect all form data
846
+ excluded = %w[splat captures session_name]
847
+ form_data = {}
848
+ params.each do |key, value|
849
+ next if excluded.include?(key)
850
+ form_data[key.to_sym] = case value
851
+ when "on", "true" then true
852
+ when "false" then false
853
+ else value
854
+ end
855
+ end
856
+
857
+ self.class.add_live_submission(session_name, form_data)
858
+
859
+ # Return confirmation and push a "submitted" message
860
+ self.class.push_to_live_session(
861
+ session_name,
862
+ target: '#main',
863
+ content: '<p style="color: #22c55e; padding: 1rem;">Submitted! Waiting for response...</p>',
864
+ action: :replace
865
+ )
866
+
867
+ { success: true, data: form_data }.to_json
868
+ end
869
+
870
+ # Get pending submissions (Claude polls this)
871
+ get '/live/:session_name/submissions' do
872
+ content_type :json
873
+ session_name = params[:session_name]
874
+ clear = params[:clear] != 'false'
875
+
876
+ submissions = self.class.get_live_submissions(session_name, clear: clear)
877
+ { submissions: submissions }.to_json
878
+ end
879
+
880
+ # Remove a live session
881
+ delete '/live/:session_name' do
882
+ content_type :json
883
+ session_name = params[:session_name]
884
+
885
+ if self.class.remove_live_session(session_name)
886
+ { success: true, message: "Session '#{session_name}' removed" }.to_json
887
+ else
888
+ status 404
889
+ { success: false, error: "Session not found: #{session_name}" }.to_json
890
+ end
891
+ end
892
+
893
+ # =========================================
894
+ # Admin Dashboard
895
+ # =========================================
896
+
897
+ # Admin dashboard - StreamWeaver app managing itself
898
+ get '/admin' do
899
+ admin_app = Admin.create_app
900
+ state = app_state('admin')
901
+ adapter = Adapter::AlpineJS.new(url_prefix: "/admin", deck_server: false)
902
+
903
+ admin_app.with_render_lock do
904
+ admin_app.rebuild_with_state(state, generation: action_generation('admin'),
905
+ state_version: state_version('admin'), url_params: params)
906
+ set_action_manifest('admin', admin_app.render_state.action_tokens)
907
+ set_app_state('admin', state)
908
+
909
+ Views::AppView.new(admin_app, state, adapter, false).call
910
+ end
911
+ end
912
+
913
+ post '/admin/update' do
914
+ admin_app = Admin.create_app
915
+ state = app_state('admin')
916
+ adapter = Adapter::AlpineJS.new(url_prefix: "/admin", deck_server: false)
917
+
918
+ InteractionRunner.new(
919
+ app: admin_app, state: state, params: params,
920
+ interaction: :update, adapter: adapter,
921
+ persist: ->(value) { set_app_state('admin', value) },
922
+ response_headers: ->(values) { headers values },
923
+ state_version: state_version('admin'), persist_state_version: ->(version) { set_state_version('admin', version) }
924
+ ).call
925
+ end
926
+
927
+ post '/admin/action/:button_id' do
928
+ button_id = params[:button_id]
929
+ admin_app = Admin.create_app
930
+ state = app_state('admin')
931
+ adapter = Adapter::AlpineJS.new(url_prefix: "/admin", deck_server: false)
932
+
933
+ InteractionRunner.new(
934
+ app: admin_app, state: state, params: params,
935
+ interaction: :action, target: button_id, adapter: adapter,
936
+ persist: ->(value) { set_app_state('admin', value) },
937
+ action_manifest: Set.new(action_manifest('admin')),
938
+ generation: action_generation('admin'),
939
+ persist_manifest: ->(tokens) { set_action_manifest('admin', tokens) },
940
+ response_headers: ->(values) { headers values },
941
+ state_version: state_version('admin'), persist_state_version: ->(version) { set_state_version('admin', version) }
942
+ ).call
943
+ end
944
+
945
+ # Aliased route: /:source/:name -> /apps/:app_id
946
+ # Allows memorable URLs like /examples_browser/hello_world
947
+ get '/:source/:name' do
948
+ source = params[:source]
949
+ name = params[:name]
950
+
951
+ # Skip if this looks like a system route
952
+ pass if %w[apps api admin].include?(source)
953
+
954
+ app_id, _entry = self.class.find_app_by_source_and_name(source, name)
955
+ halt 404, "App not found: #{source}/#{name}" unless app_id
956
+
957
+ redirect "/apps/#{app_id}"
958
+ end
959
+
960
+ # List all loaded apps
961
+ get '/' do
962
+ content_type 'text/html'
963
+ apps = self.class.apps
964
+
965
+ if apps.empty?
966
+ <<~HTML
967
+ <!DOCTYPE html>
968
+ <html>
969
+ <head><title>StreamWeaver Service</title></head>
970
+ <body style="font-family: system-ui; padding: 2rem;">
971
+ <h1>StreamWeaver Service</h1>
972
+ <p>No apps loaded yet.</p>
973
+ <p>Use: <code>streamweaver run &lt;file.rb&gt;</code></p>
974
+ </body>
975
+ </html>
976
+ HTML
977
+ else
978
+ app_list = apps.map do |id, entry|
979
+ "<li><a href='/apps/#{id}'>#{entry[:app].title}</a> (#{File.basename(entry[:path])})</li>"
980
+ end.join("\n")
981
+
982
+ <<~HTML
983
+ <!DOCTYPE html>
984
+ <html>
985
+ <head><title>StreamWeaver Service</title></head>
986
+ <body style="font-family: system-ui; padding: 2rem;">
987
+ <h1>StreamWeaver Service</h1>
988
+ <h2>Loaded Apps</h2>
989
+ <ul>#{app_list}</ul>
990
+ </body>
991
+ </html>
992
+ HTML
993
+ end
994
+ end
995
+
996
+ # =========================================
997
+ # App Rendering Routes
998
+ # =========================================
999
+
1000
+ # Render app main page
1001
+ # Accepts either the canonical hex app_id or its human-readable slug
1002
+ get '/apps/:app_id' do
1003
+ requested_id = params[:app_id]
1004
+ app_id = self.class.resolve_app_id(requested_id)
1005
+ app_entry = app_id && self.class.apps[app_id]
1006
+ halt 404, "App not found: #{requested_id}" unless app_entry
1007
+
1008
+ # Track last access time
1009
+ app_entry[:last_accessed] = Time.now
1010
+
1011
+ streamlit_app = app_entry[:app]
1012
+ state = app_state(app_id)
1013
+ adapter = Adapter::AlpineJS.new(url_prefix: "/apps/#{app_id}", deck_server: false)
1014
+
1015
+ # Seed state from URL routing (e.g., `page :home, '/'` registers a parser for
1016
+ # '/') -- mirrors the standalone `GET /` seeding so a routed app's default view
1017
+ # doesn't depend on how it's hosted (stream_weaver-oow).
1018
+ seed_route_state!(streamlit_app, state, '/')
1019
+
1020
+ streamlit_app.with_render_lock do
1021
+ # Only a URL decides a `url: true` tabs group's index (App#tab_index_source).
1022
+ # No `request.get?` guard is needed here as in server.rb's shared render_app:
1023
+ # the POST routes below render through InteractionRunner, which passes none.
1024
+ streamlit_app.rebuild_with_state(state, generation: action_generation(app_id),
1025
+ state_version: state_version(app_id), url_params: params)
1026
+ set_action_manifest(app_id, streamlit_app.render_state.action_tokens)
1027
+ set_app_state(app_id, state)
1028
+
1029
+ Views::AppView.new(streamlit_app, state, adapter, false).call
1030
+ end
1031
+ end
1032
+
1033
+ # URL routing: seed state from the path suffix under /apps/:app_id/* -- the
1034
+ # service-mode counterpart to the standalone catch-all GET '/*' (server.rb). The
1035
+ # mount prefix is already stripped by Sinatra's splat capture, so `path` below is
1036
+ # the same suffix state_for_path expects from a standalone app (stream_weaver-oow).
1037
+ get '/apps/:app_id/*' do
1038
+ app_id = self.class.resolve_app_id(params[:app_id])
1039
+ app_entry = app_id && self.class.apps[app_id]
1040
+ pass unless app_entry
1041
+
1042
+ streamlit_app = app_entry[:app]
1043
+ pass unless streamlit_app.routable?
1044
+
1045
+ path = "/#{params['splat'].first}"
1046
+ route_state = streamlit_app.state_for_path(path)
1047
+ pass unless route_state
1048
+
1049
+ app_entry[:last_accessed] = Time.now
1050
+
1051
+ state = app_state(app_id)
1052
+ route_state.each { |k, v| state[k] = v }
1053
+ sync_params_to_state(state)
1054
+ set_app_state(app_id, state)
1055
+
1056
+ adapter = Adapter::AlpineJS.new(url_prefix: "/apps/#{app_id}", deck_server: false)
1057
+ is_htmx = request.env.key?('HTTP_HX_REQUEST')
1058
+ streamlit_app.with_render_lock do
1059
+ streamlit_app.rebuild_with_state(state, generation: action_generation(app_id),
1060
+ state_version: state_version(app_id), url_params: params)
1061
+ set_action_manifest(app_id, streamlit_app.render_state.action_tokens)
1062
+ if is_htmx
1063
+ Views::AppContentView.new(streamlit_app, state, adapter, false).call
1064
+ else
1065
+ Views::AppView.new(streamlit_app, state, adapter, false).call
1066
+ end
1067
+ end
1068
+ end
1069
+
1070
+ # Multi-tab route_with fix (2026-08-31) -- service-mode counterpart to
1071
+ # server.rb's `before` filter. A path-scoped filter, not a call at each
1072
+ # POST route, so a route #5 under /apps/:app_id/* can't be added without
1073
+ # it -- see `reseed_route_state!` above for the full rationale.
1074
+ before '/apps/:app_id/*' do
1075
+ next unless request.post?
1076
+
1077
+ app_id = params[:app_id]
1078
+ app_entry = self.class.apps[app_id]
1079
+ next unless app_entry
1080
+
1081
+ reseed_route_state!(app_id, app_entry[:app], app_state(app_id))
1082
+ end
1083
+
1084
+ # URL routing: push URL on POST responses when route state changes -- the
1085
+ # service-mode counterpart to the standalone `after` hook (server.rb), with the
1086
+ # app's /apps/:app_id mount prefix re-applied so the pushed URL stays inside the
1087
+ # mounted app (stream_weaver-oow).
1088
+ after '/apps/:app_id/*' do
1089
+ next unless request.post?
1090
+
1091
+ app_id = self.class.resolve_app_id(params[:app_id])
1092
+ app_entry = app_id && self.class.apps[app_id]
1093
+ next unless app_entry
1094
+
1095
+ streamlit_app = app_entry[:app]
1096
+ next unless streamlit_app.routable?
1097
+
1098
+ new_path = streamlit_app.path_for_state(app_state(app_id))
1099
+ headers['HX-Push-Url'] = "/apps/#{app_id}#{new_path}" if new_path
1100
+ end
1101
+
1102
+ # Update state from form inputs
1103
+ post '/apps/:app_id/update' do
1104
+ app_id = params[:app_id]
1105
+ app_entry = self.class.apps[app_id]
1106
+ halt 404, "App not found" unless app_entry
1107
+
1108
+ streamlit_app = app_entry[:app]
1109
+ state = app_state(app_id)
1110
+ adapter = Adapter::AlpineJS.new(url_prefix: "/apps/#{app_id}", deck_server: false)
1111
+
1112
+ InteractionRunner.new(
1113
+ app: streamlit_app, state: state, params: params,
1114
+ interaction: :update, adapter: adapter,
1115
+ persist: ->(value) { set_app_state(app_id, value) },
1116
+ response_headers: ->(values) { headers values },
1117
+ state_version: state_version(app_id), persist_state_version: ->(version) { set_state_version(app_id, version) }
1118
+ ).call
1119
+ end
1120
+
1121
+ # Button actions
1122
+ post '/apps/:app_id/action/:button_id' do
1123
+ app_id = params[:app_id]
1124
+ button_id = params[:button_id]
1125
+ app_entry = self.class.apps[app_id]
1126
+ halt 404, "App not found" unless app_entry
1127
+
1128
+ streamlit_app = app_entry[:app]
1129
+ state = app_state(app_id)
1130
+ adapter = Adapter::AlpineJS.new(url_prefix: "/apps/#{app_id}", deck_server: false)
1131
+
1132
+ InteractionRunner.new(
1133
+ app: streamlit_app, state: state, params: params,
1134
+ interaction: :action, target: button_id, adapter: adapter,
1135
+ persist: ->(value) { set_app_state(app_id, value) },
1136
+ action_manifest: Set.new(action_manifest(app_id)),
1137
+ generation: action_generation(app_id),
1138
+ persist_manifest: ->(tokens) { set_action_manifest(app_id, tokens) },
1139
+ response_headers: ->(values) { headers values },
1140
+ state_version: state_version(app_id), persist_state_version: ->(version) { set_state_version(app_id, version) }
1141
+ ).call
1142
+ rescue StaleActionDefinition
1143
+ status 409
1144
+ headers 'HX-Retarget' => '#app-container'
1145
+ streamlit_app.with_render_lock do
1146
+ streamlit_app.rebuild_with_state(state, generation: action_generation(app_id), state_version: state_version(app_id))
1147
+ set_action_manifest(app_id, streamlit_app.render_state.action_tokens)
1148
+ Views::AppContentView.new(streamlit_app, state, adapter, false).call
1149
+ end
1150
+ end
1151
+
1152
+ # Event callback endpoint
1153
+ post '/apps/:app_id/event/:key' do
1154
+ app_id = params[:app_id]
1155
+ key = params[:key].to_sym
1156
+ app_entry = self.class.apps[app_id]
1157
+ halt 404, "App not found" unless app_entry
1158
+
1159
+ streamlit_app = app_entry[:app]
1160
+ state = app_state(app_id)
1161
+ adapter = Adapter::AlpineJS.new(url_prefix: "/apps/#{app_id}", deck_server: false)
1162
+
1163
+ InteractionRunner.new(
1164
+ app: streamlit_app, state: state, params: params,
1165
+ interaction: :event, target: key, adapter: adapter,
1166
+ persist: ->(value) { set_app_state(app_id, value) },
1167
+ response_headers: ->(values) { headers values },
1168
+ state_version: state_version(app_id), persist_state_version: ->(version) { set_state_version(app_id, version) }
1169
+ ).call
1170
+ end
1171
+
1172
+ # Form submission endpoint
1173
+ post '/apps/:app_id/form/:form_name' do
1174
+ app_id = params[:app_id]
1175
+ form_name = params[:form_name].to_sym
1176
+ app_entry = self.class.apps[app_id]
1177
+ halt 404, "App not found" unless app_entry
1178
+
1179
+ streamlit_app = app_entry[:app]
1180
+ state = app_state(app_id)
1181
+ adapter = Adapter::AlpineJS.new(url_prefix: "/apps/#{app_id}", deck_server: false)
1182
+
1183
+ InteractionRunner.new(
1184
+ app: streamlit_app, state: state, params: params,
1185
+ interaction: :form, target: form_name, adapter: adapter,
1186
+ persist: ->(value) { set_app_state(app_id, value) },
1187
+ response_headers: ->(values) { headers values },
1188
+ state_version: state_version(app_id), persist_state_version: ->(version) { set_state_version(app_id, version) }
1189
+ ).call
1190
+ end
1191
+
1192
+ # Theme switching
1193
+ post '/apps/:app_id/theme/:theme_name' do
1194
+ app_id = params[:app_id]
1195
+ theme = params[:theme_name].to_sym
1196
+ app_entry = self.class.apps[app_id]
1197
+ halt 404, "App not found" unless app_entry
1198
+
1199
+ if StreamWeaver.theme_exists?(theme)
1200
+ session[:theme_overrides] ||= {}
1201
+ session[:theme_overrides][app_id] = theme
1202
+ status 200
1203
+ "sw-theme-#{theme} sw-layout-#{app_entry[:app].layout}"
1204
+ else
1205
+ status 400
1206
+ "Invalid theme"
1207
+ end
1208
+ end
1209
+
1210
+ # =========================================
1211
+ # Custom user-defined HTTP endpoints (App#endpoint DSL) — service mode
1212
+ # =========================================
1213
+ # Scoped under /apps/:app_id/* so multiple apps' endpoints can't collide with each
1214
+ # other. Defined AFTER every fixed /apps/:app_id/... route above, so on a path
1215
+ # collision the internal route always wins (Sinatra dispatches to the first route
1216
+ # that matches; `endpoint` also warns at registration time about known collisions).
1217
+ %i[get post put patch delete].each do |verb|
1218
+ send(verb, '/apps/:app_id/*') do
1219
+ # Resolve slugs the same way the page route does — endpoints must be
1220
+ # reachable at /apps/my-dashboard/api/... as well as the hex id.
1221
+ app_id = self.class.resolve_app_id(params[:app_id])
1222
+ app_entry = app_id && self.class.apps[app_id]
1223
+ pass unless app_entry
1224
+
1225
+ streamlit_app = app_entry[:app]
1226
+ path = "/#{params['splat'].first}"
1227
+ ep = streamlit_app.find_endpoint(verb, path)
1228
+ pass unless ep
1229
+
1230
+ status, headers, body = SinatraApp.normalize_endpoint_result(ep[:block].call(request))
1231
+ halt status, headers, body
1232
+ end
1233
+ end
1234
+ end
1235
+
1236
+ # Module-level helper for apps to register with the service
1237
+ def self.register_service_app(app)
1238
+ Service.register_app(app)
1239
+ end
1240
+ end