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.
- checksums.yaml +7 -0
- data/.beads/.gitignore +77 -0
- data/.beads/README.md +81 -0
- data/.beads/config.yaml +54 -0
- data/.beads/metadata.json +7 -0
- data/.rspec +3 -0
- data/AGENTS.md +91 -0
- data/CHANGELOG.md +294 -0
- data/CLAUDE.md +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +565 -0
- data/Rakefile +13 -0
- data/assets/streamweaver-hero.jpg +0 -0
- data/bench/baselines/ledger.rb +58 -0
- data/bench/baselines/warroom.rb +51 -0
- data/bench/fixtures/ledger.rb +76 -0
- data/bench/fixtures/warroom.rb +60 -0
- data/bench/results/6daf8b8.md +102 -0
- data/bench/results/c8d749b.md +102 -0
- data/bench/results/eb06ab9.md +102 -0
- data/bench/run.rb +151 -0
- data/bench/support.rb +84 -0
- data/copy_this/dto_run_sample_run_7631.html +220 -0
- data/docs/SERVICE_MODE.md +302 -0
- data/docs/architecture/how_streamweaver_works.md +772 -0
- data/docs/blog/2026-01-16-streamweaver-introduction.md +515 -0
- data/docs/bug-2026-08-31-canvas-bridge-socket-collision.md +83 -0
- data/docs/canvas-ipc-session-summary.md +127 -0
- data/docs/canvas-panel-workflow.md +172 -0
- data/docs/canvas-read.md +147 -0
- data/docs/canvas-roadmap.md +275 -0
- data/docs/case-studies/2026-09-03-didx-canvas-read-shelf.md +147 -0
- data/docs/claude-code-companion-skill-spec.md +567 -0
- data/docs/components_reference.md +1223 -0
- data/docs/crud-patterns.md +265 -0
- data/docs/endpoints.md +122 -0
- data/docs/for_llms.md +1 -0
- data/docs/form-patterns.md +165 -0
- data/docs/frontend-only.md +169 -0
- data/docs/html-artifact-audit.md +238 -0
- data/docs/ideas/2025-01-01-as-a-service.md +203 -0
- data/docs/ideas/2026-01-16-charm-tui-exploration.md +542 -0
- data/docs/ideas/2026-08-27-static-doc-shelf-export.md +153 -0
- data/docs/ideas/dhh-review-future-refactors.md +142 -0
- data/docs/opal-jamstack.md +47 -0
- data/docs/opal-npm-direction.md +184 -0
- data/docs/opal-spike-findings.md +194 -0
- data/docs/plans/2025-12-16-form-blocks-design.md +124 -0
- data/docs/plans/2025-12-30-examples-browser-design.md +152 -0
- data/docs/plans/2026-01-02-charts-design.md +288 -0
- data/docs/plans/2026-01-10-canvas-ipc-design.md +247 -0
- data/docs/plans/2026-01-19-cabinet-control-components.md +176 -0
- data/docs/plans/2026-01-26-gem-release-and-panel.md +140 -0
- data/docs/plans/canvas-claude-project-design.md +238 -0
- data/docs/plans/canvas-doc-location-and-discovery.md +32 -0
- data/docs/plans/org-doc-preview-surfaces.md +293 -0
- data/docs/plans/shadcn-polish-plan.md +417 -0
- data/docs/porting-artifacts.md +252 -0
- data/docs/reference/agent-skills-comparison.md +369 -0
- data/docs/reference/travel-state-prd.artifact.html +780 -0
- data/docs/research/2026-08-17-hotwire-alike-landscape.md +268 -0
- data/docs/research/2026-08-17-hotwire-concept-map.md +404 -0
- data/docs/research/2026-08-22-lazy-fragments-trigger-decision.md +246 -0
- data/docs/research/2026-08-22-learnhotwire-syllabus-coverage.md +239 -0
- data/docs/research/frontend-only-matrix.md +278 -0
- data/docs/research/streamweaver-way-spike-findings.md +529 -0
- data/docs/resource-dsl.md +342 -0
- data/docs/routing.md +302 -0
- data/docs/ruby-ui-comparison.md +319 -0
- data/docs/shared-dsl-fragments.md +356 -0
- data/docs/streamweaver-for-ai-agents.md +401 -0
- data/docs/streamweaver-frontend-vision.md +123 -0
- data/docs/streamweaver_canvas/glimmer_initial_final_layer.rb +77 -0
- data/docs/streamweaver_canvas/save_example.rb +9 -0
- data/docs/templates.md +213 -0
- data/docs/testing.md +155 -0
- data/docs/theming-hooks.md +281 -0
- data/docs/tutorials/the-streamweaver-way.md +882 -0
- data/docs/university/capability-inventory.md +137 -0
- data/docs/university/dependency-survey.md +114 -0
- data/docs/university/design-spec.md +217 -0
- data/docs/university/mockups/code-block-doc-theme-dark.png +0 -0
- data/docs/university/mockups/code-block-doc-theme-light.png +0 -0
- data/docs/university/mockups/course-list-dark.png +0 -0
- data/docs/university/mockups/course-list-light.png +0 -0
- data/docs/university/mockups/course_canvas_mockup.rb +741 -0
- data/docs/university/mockups/step-screen-dark.png +0 -0
- data/docs/university/mockups/step-screen-light.png +0 -0
- data/docs/university/roadmap.md +72 -0
- data/docs/university/send-to-coworker.md +77 -0
- data/docs/university/worker-session-mining.md +109 -0
- data/docs/visual/browser-rendering-spike.md +173 -0
- data/docs/visual/builder-visual-plan-analysis.md +378 -0
- data/docs/visual/sw-plan-format-exploration.md +511 -0
- data/docs/visual/sw-plan-rendering-deep-dive.md +453 -0
- data/docs/visual-skills/PROGRESS.md +60 -0
- data/docs/visual-skills/SESSION-CONTEXT.md +207 -0
- data/docs/visual-skills/analysis/components.md +317 -0
- data/docs/visual-skills/analysis/overlap.md +360 -0
- data/docs/visual-skills/analysis/pi-design-deck.md +1102 -0
- data/docs/visual-skills/analysis/streamweaver-inventory.md +93 -0
- data/docs/visual-skills/analysis/unified-specs.feature +1003 -0
- data/docs/visual-skills/analysis/visual-explainer.md +964 -0
- data/docs/visual-skills/blog/blog-series-outline.md +39 -0
- data/docs/visual-skills/blog/token-efficiency.md +73 -0
- data/docs/visual-skills/design/architecture.md +1725 -0
- data/docs/visual-skills/design/codex-review.md +340 -0
- data/docs/visual-skills/design/dhh-review.md +209 -0
- data/docs/visual-skills/design/evolution.md +100 -0
- data/docs/visual-skills/design/gemini-review.md +55 -0
- data/docs/visual-skills/design/review-synthesis.md +149 -0
- data/docs/visual-skills/implementation/STATE.md +22 -0
- data/docs/visual-skills/implementation/plan.md +347 -0
- data/docs/visual-skills/implementation/spike-findings.md +237 -0
- data/docs/visual-skills/implementation/tasks.md +702 -0
- data/docs/visual-skills/lessons-learned/process.md +145 -0
- data/examples/README.md +96 -0
- data/examples/advanced/all_components.rb +177 -0
- data/examples/advanced/examples_browser.rb +394 -0
- data/examples/advanced/teachables_browser.rb +261 -0
- data/examples/advanced/theme_tweaker.rb +366 -0
- data/examples/advanced/tutorial.rb +1528 -0
- data/examples/agentic/agentic_form.rb +30 -0
- data/examples/agentic/agentic_form_autoclose.rb +30 -0
- data/examples/agentic/cultivation_tracker.rb +69 -0
- data/examples/basic/hello_world.rb +22 -0
- data/examples/basic/opal_tabs_table.rb +44 -0
- data/examples/basic/todo_list.rb +39 -0
- data/examples/button_loading_test.rb +36 -0
- data/examples/canvas/mermaid_canvas_demo.sh +151 -0
- data/examples/charts/bar_chart_demo.rb +57 -0
- data/examples/charts/line_chart_demo.rb +73 -0
- data/examples/charts/pie_area_demo.rb +87 -0
- data/examples/charts/stacked_bar_chart_demo.rb +69 -0
- data/examples/claude_code/README.md +85 -0
- data/examples/claude_code/codebreaker/.claude/commands/infiltrate.md +632 -0
- data/examples/claude_code/codebreaker/.claude/settings.json +11 -0
- data/examples/claude_code/codebreaker/.claude/settings.local.json +17 -0
- data/examples/claude_code/codebreaker/.claude/skills/infiltrate.md +0 -0
- data/examples/claude_code/codebreaker/README.md +213 -0
- data/examples/claude_code/tutorial/.claude/commands/learn.md +453 -0
- data/examples/claude_code/tutorial/README.md +102 -0
- data/examples/claude_code/verification_flow/.claude/commands/verify.md +171 -0
- data/examples/claude_code/verification_flow/README.md +126 -0
- data/examples/components/annotated_code_demo.rb +54 -0
- data/examples/components/callout_demo.rb +33 -0
- data/examples/components/checkbox_group_demo.rb +46 -0
- data/examples/components/design_review.css +468 -0
- data/examples/components/design_review_demo.rb +44 -0
- data/examples/components/design_review_dsl.rb +300 -0
- data/examples/components/diff_block_demo.rb +112 -0
- data/examples/components/events_demo.rb +184 -0
- data/examples/components/form_demo.rb +62 -0
- data/examples/components/lesson_demo.rb +53 -0
- data/examples/components/markdown_demo.rb +110 -0
- data/examples/components/mermaid_demo.rb +166 -0
- data/examples/components/pareto_set.rb +64 -0
- data/examples/components/prd_demo.rb +21 -0
- data/examples/components/prd_dsl.rb +385 -0
- data/examples/components/quiz_demo.rb +47 -0
- data/examples/components/run_viewer_demo.rb +100 -0
- data/examples/components/score_and_collapsible_demo.rb +82 -0
- data/examples/components/select_stale_value_smoke_test.rb +39 -0
- data/examples/components/table_demo.rb +247 -0
- data/examples/components/timer_with_state_demo.rb +58 -0
- data/examples/components/todo_due_dates.rb +63 -0
- data/examples/components/uat_gaps_demo.rb +112 -0
- data/examples/dashboard/feed_simulator.rb +60 -0
- data/examples/dashboard/live_dashboard.rb +80 -0
- data/examples/dashboard_components.rb +108 -0
- data/examples/deferred_fragments_demo.rb +62 -0
- data/examples/generate_more_spike/README.md +63 -0
- data/examples/generate_more_spike/app.rb +441 -0
- data/examples/git_health.sh +401 -0
- data/examples/layout/layout_components_demo.rb +174 -0
- data/examples/layout/modal_demo.rb +215 -0
- data/examples/layout/navigation_demo.rb +227 -0
- data/examples/layout/route_tabs_demo.rb +52 -0
- data/examples/layout/routing_demo.rb +52 -0
- data/examples/layout/scroll_box_demo.rb +154 -0
- data/examples/lazy_fragments_demo.rb +151 -0
- data/examples/my_todos/README.md +29 -0
- data/examples/my_todos/my_todos.rb +312 -0
- data/examples/my_todos/store.rb +86 -0
- data/examples/opal/reactive_demo.rb +62 -0
- data/examples/opal/scenarios/s1_counter.rb +15 -0
- data/examples/opal/scenarios/s2_search_filter.rb +17 -0
- data/examples/opal/scenarios/s3_sibling_tabs.rb +25 -0
- data/examples/opal/scenarios/s4_shopping_cart.rb +34 -0
- data/examples/opal/scenarios/s5_watch.rb +39 -0
- data/examples/opal/scenarios/s6_on_start.rb +20 -0
- data/examples/opal/scenarios/s7_wizard.rb +33 -0
- data/examples/opal/scenarios/s8_loan_calculator.rb +21 -0
- data/examples/opal/scenarios/s9_dashboard.rb +27 -0
- data/examples/operations_dashboard_demo.rb +167 -0
- data/examples/panel_demo.sh +447 -0
- data/examples/parity/assets/tyrion_slice.css +86 -0
- data/examples/parity/rivet_people_slice.rb +182 -0
- data/examples/parity/tyrion_components.css +777 -0
- data/examples/parity/tyrion_warroom_components.rb +414 -0
- data/examples/parity/tyrion_warroom_slice.rb +549 -0
- data/examples/puma_dev/README.md +124 -0
- data/examples/puma_dev/config.ru +34 -0
- data/examples/puma_dev/standalone_app.rb +34 -0
- data/examples/scaffolding/blog.rb +49 -0
- data/examples/scaffolding/utf_lite.rb +105 -0
- data/examples/styling/feedback_demo.rb +229 -0
- data/examples/styling/style_showcase.rb +172 -0
- data/examples/styling/theme_demo.rb +508 -0
- data/examples/timer_health_checker.rb +111 -0
- data/examples/timer_showcase.rb +131 -0
- data/examples/tutorials/render_markdown.rb +112 -0
- data/examples/tutorials/streamweaver_way_tutorial.rb +44 -0
- data/examples/tutorials/tutorial_content.rb +1082 -0
- data/examples/visual_skills/design_deck_demo.rb +314 -0
- data/examples/visual_skills/explainer_demo.rb +499 -0
- data/exe/streamweaver +9 -0
- data/gsd/ROADMAP-1.0.md +167 -0
- data/gsd/STATE.md +13 -0
- data/gsd/research/market-positioning-research.md +139 -0
- data/gsd/research/production-patterns-research.md +270 -0
- data/gsd/research/repo-audit-1.0.md +274 -0
- data/lib/stream_weaver/action_token.rb +58 -0
- data/lib/stream_weaver/adapter/alpinejs.rb +8249 -0
- data/lib/stream_weaver/adapter/base.rb +591 -0
- data/lib/stream_weaver/adapter/opal.rb +334 -0
- data/lib/stream_weaver/adapter/static.rb +1118 -0
- data/lib/stream_weaver/admin.rb +176 -0
- data/lib/stream_weaver/app.rb +1821 -0
- data/lib/stream_weaver/assets/js/sw-copy.js +50 -0
- data/lib/stream_weaver/assets/js/sw-heredoc-rewrite.js +68 -0
- data/lib/stream_weaver/assets/js/sw-keyboard.js +165 -0
- data/lib/stream_weaver/assets/js/sw-mermaid-zoom.js +621 -0
- data/lib/stream_weaver/assets/js/sw-route-tabs.js +53 -0
- data/lib/stream_weaver/assets/js/sw-sidebar-toc.js +116 -0
- data/lib/stream_weaver/assets/js/sw-slide-nav.js +103 -0
- data/lib/stream_weaver/canvas/bridge.rb +239 -0
- data/lib/stream_weaver/canvas/bridge_server.rb +646 -0
- data/lib/stream_weaver/canvas/client.rb +298 -0
- data/lib/stream_weaver/canvas/doc_roots.rb +233 -0
- data/lib/stream_weaver/canvas/doc_store.rb +252 -0
- data/lib/stream_weaver/canvas/gist_publisher.rb +264 -0
- data/lib/stream_weaver/canvas/gist_save_handler.rb +89 -0
- data/lib/stream_weaver/canvas/gist_store.rb +135 -0
- data/lib/stream_weaver/canvas/helpers.rb +109 -0
- data/lib/stream_weaver/canvas/history.rb +90 -0
- data/lib/stream_weaver/canvas/protocol.rb +86 -0
- data/lib/stream_weaver/canvas/reader.rb +853 -0
- data/lib/stream_weaver/canvas/save_doc_widget.rb +457 -0
- data/lib/stream_weaver/canvas/scroll_top_hint.rb +21 -0
- data/lib/stream_weaver/canvas/session.rb +132 -0
- data/lib/stream_weaver/cli.rb +3235 -0
- data/lib/stream_weaver/component_assets.rb +70 -0
- data/lib/stream_weaver/component_registry.rb +67 -0
- data/lib/stream_weaver/component_renderer.rb +48 -0
- data/lib/stream_weaver/components/annotated_code.rb +53 -0
- data/lib/stream_weaver/components/api_endpoint.rb +42 -0
- data/lib/stream_weaver/components/callout.rb +59 -0
- data/lib/stream_weaver/components/chart.rb +84 -0
- data/lib/stream_weaver/components/code_block.rb +77 -0
- data/lib/stream_weaver/components/comparison.rb +39 -0
- data/lib/stream_weaver/components/decision.rb +38 -0
- data/lib/stream_weaver/components/deck/close_overlay.rb +84 -0
- data/lib/stream_weaver/components/deck/confirmation_bar.rb +60 -0
- data/lib/stream_weaver/components/deck/deck_option.rb +61 -0
- data/lib/stream_weaver/components/deck/deck_slide.rb +77 -0
- data/lib/stream_weaver/components/deck/deck_state.rb +469 -0
- data/lib/stream_weaver/components/deck/deck_summary.rb +73 -0
- data/lib/stream_weaver/components/deck/design_deck.rb +60 -0
- data/lib/stream_weaver/components/deck/generate_more_controls.rb +84 -0
- data/lib/stream_weaver/components/deck/model_selector.rb +84 -0
- data/lib/stream_weaver/components/deck/skeleton_placeholder.rb +36 -0
- data/lib/stream_weaver/components/diff_block.rb +125 -0
- data/lib/stream_weaver/components/doc_header.rb +57 -0
- data/lib/stream_weaver/components/image_block.rb +70 -0
- data/lib/stream_weaver/components/implementation_map.rb +33 -0
- data/lib/stream_weaver/components/keyboard_shortcuts.rb +94 -0
- data/lib/stream_weaver/components/kpi_dashboard.rb +78 -0
- data/lib/stream_weaver/components/mermaid.rb +79 -0
- data/lib/stream_weaver/components/pipeline.rb +63 -0
- data/lib/stream_weaver/components/sidebar_toc.rb +46 -0
- data/lib/stream_weaver/components/slide_container.rb +141 -0
- data/lib/stream_weaver/components/timeline_event.rb +58 -0
- data/lib/stream_weaver/components/wireframe.rb +29 -0
- data/lib/stream_weaver/components/wireframe_block.rb +34 -0
- data/lib/stream_weaver/components.rb +2711 -0
- data/lib/stream_weaver/css.rb +230 -0
- data/lib/stream_weaver/dev_fallback_overlay.rb +61 -0
- data/lib/stream_weaver/display_dsl.rb +1004 -0
- data/lib/stream_weaver/export/html_exporter.rb +478 -0
- data/lib/stream_weaver/feed.rb +34 -0
- data/lib/stream_weaver/feed_builder.rb +31 -0
- data/lib/stream_weaver/fonts.rb +33 -0
- data/lib/stream_weaver/interaction_runner.rb +487 -0
- data/lib/stream_weaver/iterm.rb +460 -0
- data/lib/stream_weaver/layout_registry.rb +92 -0
- data/lib/stream_weaver/opal/bridge.rb +52 -0
- data/lib/stream_weaver/opal/builder.rb +193 -0
- data/lib/stream_weaver/opal/env.rb +29 -0
- data/lib/stream_weaver/opal/reactive_state.rb +75 -0
- data/lib/stream_weaver/opal/regexp_anchor_patch.rb +62 -0
- data/lib/stream_weaver/opal/renderer.rb +77 -0
- data/lib/stream_weaver/opal/runtime.rb +250 -0
- data/lib/stream_weaver/opal/shell.rb +145 -0
- data/lib/stream_weaver/opal/string_bridge.rb +50 -0
- data/lib/stream_weaver/opal/stubs/diff.min.js +1 -0
- data/lib/stream_weaver/opal/stubs/digest.rb +15 -0
- data/lib/stream_weaver/opal/stubs/marked.umd.js +79 -0
- data/lib/stream_weaver/opal/stubs/md5.rb +3 -0
- data/lib/stream_weaver/opal/stubs/morphdom.min.js +775 -0
- data/lib/stream_weaver/opal/stubs/prism-tomorrow.min.css +1 -0
- data/lib/stream_weaver/opal/stubs/prism.min.js +1967 -0
- data/lib/stream_weaver/opal_entry.rb +136 -0
- data/lib/stream_weaver/org/inline.rb +95 -0
- data/lib/stream_weaver/org/reader.rb +561 -0
- data/lib/stream_weaver/org/recording_context.rb +86 -0
- data/lib/stream_weaver/org/source_splitter.rb +63 -0
- data/lib/stream_weaver/org/writer.rb +300 -0
- data/lib/stream_weaver/page_shell.rb +530 -0
- data/lib/stream_weaver/portfile.rb +79 -0
- data/lib/stream_weaver/pushable.rb +42 -0
- data/lib/stream_weaver/resource/default_views.rb +111 -0
- data/lib/stream_weaver/resource/field_input.rb +25 -0
- data/lib/stream_weaver/resource/state_keys.rb +16 -0
- data/lib/stream_weaver/resource/store.rb +18 -0
- data/lib/stream_weaver/resource.rb +104 -0
- data/lib/stream_weaver/server.rb +1471 -0
- data/lib/stream_weaver/service.rb +1240 -0
- data/lib/stream_weaver/service_client.rb +104 -0
- data/lib/stream_weaver/session_store.rb +186 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/SKILL.md +66 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/examples/canvas-safe-showcase.rb +98 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/actions-and-buttons.md +47 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/charts-and-diagrams.md +38 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/deck.md +32 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/inputs-and-forms.md +65 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/tabs-and-navigation.md +53 -0
- data/lib/stream_weaver/skills/streamweaver-doc-builder/SKILL.md +298 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/SKILL.md +130 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.css +468 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.rb +39 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example_dsl.rb +300 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example.rb +24 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example_dsl.rb +385 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/checkpoints-and-forms.md +28 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/cleanup-and-panel.md +44 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/example-gallery.md +8 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/persistence.md +58 -0
- data/lib/stream_weaver/skills/streamweaver-way/SKILL.md +396 -0
- data/lib/stream_weaver/skills/visual-plan/SKILL.md +201 -0
- data/lib/stream_weaver/skills/visual-recap/SKILL.md +244 -0
- data/lib/stream_weaver/streamer.rb +63 -0
- data/lib/stream_weaver/templates/choices.rb +149 -0
- data/lib/stream_weaver/templates/code.rb +157 -0
- data/lib/stream_weaver/templates/confirm.rb +110 -0
- data/lib/stream_weaver/templates/diff.rb +195 -0
- data/lib/stream_weaver/templates/info.rb +142 -0
- data/lib/stream_weaver/templates/table.rb +169 -0
- data/lib/stream_weaver/templates/wizard.rb +271 -0
- data/lib/stream_weaver/theme/auto_mode.rb +116 -0
- data/lib/stream_weaver/theme/presets.rb +555 -0
- data/lib/stream_weaver/theme.rb +639 -0
- data/lib/stream_weaver/university/canvas.rb +924 -0
- data/lib/stream_weaver/university/course.rb +699 -0
- data/lib/stream_weaver/university/demos/counter.rb +33 -0
- data/lib/stream_weaver/university/demos/dashboard.rb +147 -0
- data/lib/stream_weaver/university/demos/decision_form.rb +143 -0
- data/lib/stream_weaver/university/demos.rb +50 -0
- data/lib/stream_weaver/university/listener.rb +467 -0
- data/lib/stream_weaver/university/progress.rb +201 -0
- data/lib/stream_weaver/university/runner.rb +138 -0
- data/lib/stream_weaver/university/scripts/growing_doc.rb +609 -0
- data/lib/stream_weaver/university/scripts/growing_doc_state.rb +91 -0
- data/lib/stream_weaver/utils.rb +31 -0
- data/lib/stream_weaver/version.rb +5 -0
- data/lib/stream_weaver/views/canvas/reader_layout.erb +800 -0
- data/lib/stream_weaver/views.rb +3940 -0
- data/lib/stream_weaver.rb +122 -0
- data/llms.txt +1269 -0
- data/sig/stream_weaver.rbs +4 -0
- metadata +598 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module StreamWeaver
|
|
7
|
+
module Canvas
|
|
8
|
+
# Renders the floating "Save as doc" button + Alpine.js modal that POSTs
|
|
9
|
+
# a rendered page's content to a `save-doc` endpoint, promoting it into
|
|
10
|
+
# `docs/streamweaver_canvas/`. Shared by two call sites (stream_weaver-e13):
|
|
11
|
+
# the live canvas page (bridge_server.rb, saving the session's current
|
|
12
|
+
# push) and canvas-read's history view (reader_layout.erb, promoting an
|
|
13
|
+
# already-rendered history snapshot). The two differ only in POST
|
|
14
|
+
# endpoint/body shape, where the default name comes from, and a couple of
|
|
15
|
+
# copy strings -- everything else (CSS, dialog structure, org-coverage
|
|
16
|
+
# notice logic) was byte-near-identical and had already started to drift
|
|
17
|
+
# apart before this extraction.
|
|
18
|
+
module SaveDocWidget
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# endpoint - fetch() URL to POST {name, format[, ...extra_body_fields]} to.
|
|
22
|
+
# button_title/dialog_title/hint_html - the copy that differs between callers.
|
|
23
|
+
# name_init - JS expression for the Alpine `name:` field's initial value.
|
|
24
|
+
# Canvas recomputes a fresh timestamp-based name every time the dialog
|
|
25
|
+
# opens (`''`, paired with reset_name_js); the reader's default name is
|
|
26
|
+
# derived from the archived snapshot's own filename, not wall-clock
|
|
27
|
+
# "now", so it's computed once server-side and passed in as a pre-escaped
|
|
28
|
+
# JSON literal (see reader_layout.erb's `name:` line for why both
|
|
29
|
+
# `.to_json` and `ERB::Util.h` are required together).
|
|
30
|
+
# reset_name_js - statement(s) run at the top of openDialog() to (re)compute
|
|
31
|
+
# `name`; empty when the initial value never needs refreshing. Skipped
|
|
32
|
+
# entirely when `gist[:prefill_name]` is present (see `gist` below) --
|
|
33
|
+
# an already-shared canvas prefills its shared name instead of a fresh
|
|
34
|
+
# timestamp, so the next save updates the same gist.
|
|
35
|
+
# extra_alpine_data - extra x-data keys inserted verbatim after `name:`
|
|
36
|
+
# (canvas: the `defaultName()` method; reader: the `file:` index the
|
|
37
|
+
# snapshot lives at). JS object literals don't care about key order, so
|
|
38
|
+
# one insertion point suffices for both callers' extra state.
|
|
39
|
+
# extra_body_fields - extra keys inserted verbatim into the POST JSON body,
|
|
40
|
+
# ahead of `name`/`format` (reader: `file: this.file, `).
|
|
41
|
+
# host_class - extra class on the root `x-data` div, or nil. The reader
|
|
42
|
+
# needs `sw-save-doc-host` (`display: contents`) because the widget
|
|
43
|
+
# lives inside a flex row (#sw-reader-nav) and must not become a flex
|
|
44
|
+
# item itself; the canvas widget has no such host and needs none.
|
|
45
|
+
# css_layer - `@layer` name to wrap the `<style>` block in, or nil. The
|
|
46
|
+
# reader's chrome is layered so it can't outrank/be outranked by
|
|
47
|
+
# framework CSS (see reader_layout.erb's scoping comment); the canvas
|
|
48
|
+
# page has no such layering scheme.
|
|
49
|
+
# dialog_css_extra - extra CSS declarations appended into the
|
|
50
|
+
# `.sw-save-doc-dialog` rule (reader adds `color`/`text-align` that the
|
|
51
|
+
# canvas dialog doesn't need, having inherited page defaults already).
|
|
52
|
+
# source_dir - absolute path of the repo the caller resolves "This
|
|
53
|
+
# repo" to (stream_weaver-j3b3), or nil when the caller has none to
|
|
54
|
+
# offer. When present, a scope toggle ("This repo (<basename>)" vs.
|
|
55
|
+
# "Global") is rendered above the name field, defaulting to "This
|
|
56
|
+
# repo"; when nil the toggle is omitted entirely and the save is
|
|
57
|
+
# always Global -- a manual choice, never a silent auto-resolution
|
|
58
|
+
# (canvas-doc-location-and-discovery.md).
|
|
59
|
+
# gist - kwarg for the third "Gist" destination radio (share-to-gist
|
|
60
|
+
# epic), or nil when the caller has no gist capability to offer at
|
|
61
|
+
# all -- in which case the radio is omitted entirely, same as
|
|
62
|
+
# source_dir: nil for the repo radio. The scope row now renders when
|
|
63
|
+
# EITHER source_dir OR gist is present (a canvas pushed from outside
|
|
64
|
+
# a git repo can still be shared). Shape:
|
|
65
|
+
# {
|
|
66
|
+
# available: true/false, # GistPublisher.gh_available? -- a
|
|
67
|
+
# # cheap presence check, NOT an auth
|
|
68
|
+
# # check. A missing token surfaces
|
|
69
|
+
# # as a save-time error, not a
|
|
70
|
+
# # hidden option (Gloria's Law: show
|
|
71
|
+
# # the capability and the one thing
|
|
72
|
+
# # that unblocks it).
|
|
73
|
+
# unavailable_reason: "...", # helper text shown next to the
|
|
74
|
+
# # radio -- rendered disabled --
|
|
75
|
+
# # when available is false; unused
|
|
76
|
+
# # when true.
|
|
77
|
+
# known: {"doc-name" => {url:, revisions:}, ...},
|
|
78
|
+
# # the gist(s) relevant to THIS
|
|
79
|
+
# # dialog -- the caller scopes this
|
|
80
|
+
# # down (e.g. via GistStore
|
|
81
|
+
# # #latest_for_prefix for the
|
|
82
|
+
# # current canvas), not the whole
|
|
83
|
+
# # install's history, since it's
|
|
84
|
+
# # embedded into every page render.
|
|
85
|
+
# # Keyed by the doc's base name (no
|
|
86
|
+
# # extension). Embedded as a JSON
|
|
87
|
+
# # literal and looked up live, as
|
|
88
|
+
# # the name field
|
|
89
|
+
# # is edited, via the Alpine
|
|
90
|
+
# # currentGist() helper -- drives
|
|
91
|
+
# # the "already shared · N
|
|
92
|
+
# # revisions" hint, the URL preview
|
|
93
|
+
# # line, and the action button's
|
|
94
|
+
# # Update-gist/Create-gist label.
|
|
95
|
+
# prefill_name: "already-shared-name" or nil,
|
|
96
|
+
# # when a gist is already known for
|
|
97
|
+
# # the canvas this dialog belongs
|
|
98
|
+
# # to, openDialog() uses this as the
|
|
99
|
+
# # initial `name` INSTEAD OF the
|
|
100
|
+
# # caller's reset_name_js timestamp
|
|
101
|
+
# # logic, so the very next save
|
|
102
|
+
# # updates that gist rather than
|
|
103
|
+
# # minting a second one.
|
|
104
|
+
# }
|
|
105
|
+
# Gist is never the default scope -- This-repo/Global remain the only
|
|
106
|
+
# defaults (`scope: '#{source_dir ? 'repo' : 'global'}'` below is
|
|
107
|
+
# unchanged); gist is only ever chosen by an explicit user click.
|
|
108
|
+
def render(
|
|
109
|
+
endpoint:,
|
|
110
|
+
button_title:,
|
|
111
|
+
dialog_title:,
|
|
112
|
+
hint_html:,
|
|
113
|
+
name_init:,
|
|
114
|
+
source_dir: nil,
|
|
115
|
+
reset_name_js: '',
|
|
116
|
+
extra_alpine_data: '',
|
|
117
|
+
extra_body_fields: '',
|
|
118
|
+
host_class: nil,
|
|
119
|
+
css_layer: nil,
|
|
120
|
+
dialog_css_extra: '',
|
|
121
|
+
gist: nil
|
|
122
|
+
)
|
|
123
|
+
style_open = css_layer ? "<style>@layer #{css_layer} {" : '<style>'
|
|
124
|
+
style_close = css_layer ? '}</style>' : '</style>'
|
|
125
|
+
host_css = host_class ? "\n .#{host_class} { display: contents; }" : ''
|
|
126
|
+
host_attr = host_class ? %( class="#{host_class}") : ''
|
|
127
|
+
scope_html = scope_toggle_html(source_dir, gist)
|
|
128
|
+
|
|
129
|
+
# Embedded verbatim into the `x-data="{ ... }"` HTML attribute below,
|
|
130
|
+
# so any quotes the JSON produces must be HTML-escaped the same way
|
|
131
|
+
# reader_layout.erb's `name_init` already is (see its doc comment
|
|
132
|
+
# above) or they'd terminate the attribute early. gistKnown must
|
|
133
|
+
# always serialize to a JS object literal (never `null`) since it's
|
|
134
|
+
# indexed by name below -- `gist[:known]` defaults to `{}` whether
|
|
135
|
+
# `gist` itself, or just its `known:` key, is missing.
|
|
136
|
+
gist_known = (gist && gist[:known]) || {}
|
|
137
|
+
gist_known_json = ERB::Util.h(gist_known.to_json)
|
|
138
|
+
|
|
139
|
+
gist_prefill = gist && gist[:prefill_name]
|
|
140
|
+
gist_prefill_json = ERB::Util.h(gist_prefill.to_json)
|
|
141
|
+
|
|
142
|
+
<<~HTML
|
|
143
|
+
#{style_open}
|
|
144
|
+
[x-cloak] { display: none !important; }#{host_css}
|
|
145
|
+
.sw-save-doc-btn {
|
|
146
|
+
position: fixed; bottom: 1rem; right: 1rem; z-index: 50;
|
|
147
|
+
background: var(--sw-color-primary, #1f6feb); color: #fff;
|
|
148
|
+
border: none; border-radius: 999px; padding: 0.55rem 1rem;
|
|
149
|
+
font-size: 0.85rem; font-weight: 600; cursor: pointer;
|
|
150
|
+
box-shadow: 0 6px 16px rgba(28,25,23,0.18);
|
|
151
|
+
opacity: 0.85; transition: opacity 120ms ease, transform 120ms ease;
|
|
152
|
+
}
|
|
153
|
+
.sw-save-doc-btn:hover { opacity: 1; transform: translateY(-1px); }
|
|
154
|
+
.sw-save-doc-modal {
|
|
155
|
+
position: fixed; inset: 0; z-index: 60;
|
|
156
|
+
background: rgba(15, 17, 23, 0.45);
|
|
157
|
+
display: flex; align-items: center; justify-content: center;
|
|
158
|
+
}
|
|
159
|
+
.sw-save-doc-dialog {
|
|
160
|
+
background: #fff; border-radius: 8px; padding: 1.5rem;
|
|
161
|
+
width: min(440px, 90vw); box-shadow: 0 20px 50px rgba(0,0,0,0.25);
|
|
162
|
+
font-family: 'Source Sans 3', system-ui, sans-serif;#{dialog_css_extra}
|
|
163
|
+
}
|
|
164
|
+
.sw-save-doc-dialog h3 { margin: 0 0 0.5rem 0; font-size: 1.1rem; }
|
|
165
|
+
.sw-save-doc-dialog p.hint {
|
|
166
|
+
margin: 0 0 1rem 0; color: #6b7280; font-size: 0.85rem;
|
|
167
|
+
}
|
|
168
|
+
.sw-save-doc-scope {
|
|
169
|
+
display: flex; gap: 1rem; margin: 0 0 0.5rem 0;
|
|
170
|
+
font-size: 0.85rem; flex-wrap: wrap;
|
|
171
|
+
}
|
|
172
|
+
.sw-save-doc-scope label {
|
|
173
|
+
display: flex; align-items: center; gap: 0.35rem; cursor: pointer;
|
|
174
|
+
}
|
|
175
|
+
.sw-save-doc-scope label:has(input:disabled) { cursor: not-allowed; opacity: 0.6; }
|
|
176
|
+
.sw-save-doc-scope-path {
|
|
177
|
+
margin: 0 0 0.75rem 0; color: #6b7280; font-size: 0.78rem;
|
|
178
|
+
font-family: ui-monospace, monospace; word-break: break-all;
|
|
179
|
+
}
|
|
180
|
+
.sw-save-doc-scope-hint {
|
|
181
|
+
color: #6b7280; font-size: 0.78rem;
|
|
182
|
+
}
|
|
183
|
+
.sw-save-doc-scope-reason {
|
|
184
|
+
margin: 0 0 0.75rem 0; color: #b45309; font-size: 0.78rem;
|
|
185
|
+
}
|
|
186
|
+
.sw-save-doc-dialog input[type=text] {
|
|
187
|
+
width: 100%; padding: 0.55rem 0.75rem;
|
|
188
|
+
border: 1px solid #d1d5db; border-radius: 5px;
|
|
189
|
+
font-size: 0.95rem; font-family: ui-monospace, monospace;
|
|
190
|
+
box-sizing: border-box;
|
|
191
|
+
}
|
|
192
|
+
.sw-save-doc-dialog input[type=text]:focus {
|
|
193
|
+
outline: 2px solid var(--sw-color-primary, #1f6feb); outline-offset: -1px;
|
|
194
|
+
border-color: transparent;
|
|
195
|
+
}
|
|
196
|
+
.sw-save-doc-error {
|
|
197
|
+
margin-top: 0.75rem; padding: 0.5rem 0.75rem;
|
|
198
|
+
background: #fee2e2; color: #991b1b; border-radius: 4px;
|
|
199
|
+
font-size: 0.85rem;
|
|
200
|
+
}
|
|
201
|
+
.sw-save-doc-success {
|
|
202
|
+
margin-top: 0.75rem; padding: 0.5rem 0.75rem;
|
|
203
|
+
background: #dcfce7; color: #166534; border-radius: 4px;
|
|
204
|
+
font-size: 0.85rem; word-break: break-all;
|
|
205
|
+
}
|
|
206
|
+
.sw-save-doc-success a { color: #166534; }
|
|
207
|
+
.sw-save-doc-notice {
|
|
208
|
+
margin-top: 0.5rem; padding: 0.5rem 0.75rem;
|
|
209
|
+
background: #fef9c3; color: #854d0e; border-radius: 4px;
|
|
210
|
+
font-size: 0.85rem;
|
|
211
|
+
}
|
|
212
|
+
.sw-save-doc-actions {
|
|
213
|
+
display: flex; justify-content: flex-end; gap: 0.5rem;
|
|
214
|
+
margin-top: 1rem;
|
|
215
|
+
}
|
|
216
|
+
.sw-save-doc-actions button {
|
|
217
|
+
padding: 0.45rem 1rem; border-radius: 5px;
|
|
218
|
+
font-size: 0.9rem; cursor: pointer; border: 1px solid transparent;
|
|
219
|
+
}
|
|
220
|
+
.sw-save-doc-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
221
|
+
.sw-save-doc-cancel { background: #f3f4f6; color: #374151; border-color: #d1d5db; }
|
|
222
|
+
.sw-save-doc-cancel:hover:not(:disabled) { background: #e5e7eb; }
|
|
223
|
+
.sw-save-doc-save {
|
|
224
|
+
background: var(--sw-color-primary, #1f6feb); color: #fff;
|
|
225
|
+
}
|
|
226
|
+
.sw-save-doc-save:hover:not(:disabled) { filter: brightness(1.05); }
|
|
227
|
+
.sw-save-doc-save-org {
|
|
228
|
+
background: #fff; color: var(--sw-color-primary, #1f6feb);
|
|
229
|
+
border-color: var(--sw-color-primary, #1f6feb);
|
|
230
|
+
}
|
|
231
|
+
.sw-save-doc-save-org:hover:not(:disabled) { background: #eff6ff; }
|
|
232
|
+
.sw-save-doc-save-gist {
|
|
233
|
+
background: #fff; color: #166534; border-color: #166534;
|
|
234
|
+
}
|
|
235
|
+
.sw-save-doc-save-gist:hover:not(:disabled) { background: #f0fdf4; }
|
|
236
|
+
#{style_close}
|
|
237
|
+
<div x-data="{
|
|
238
|
+
open: false,
|
|
239
|
+
name: #{name_init},
|
|
240
|
+
scope: '#{source_dir ? 'repo' : 'global'}',
|
|
241
|
+
#{extra_alpine_data}format: 'rb',
|
|
242
|
+
saving: false,
|
|
243
|
+
savedPath: null,
|
|
244
|
+
error: null,
|
|
245
|
+
coverage: null,
|
|
246
|
+
notice: null,
|
|
247
|
+
gistKnown: #{gist_known_json},
|
|
248
|
+
gistPrefill: #{gist_prefill_json},
|
|
249
|
+
gistResult: null,
|
|
250
|
+
openDialog() {
|
|
251
|
+
this.error = null; this.savedPath = null; this.coverage = null; this.notice = null; this.gistResult = null;
|
|
252
|
+
if (this.gistPrefill) {
|
|
253
|
+
this.name = this.gistPrefill;
|
|
254
|
+
} else {
|
|
255
|
+
#{reset_name_js}
|
|
256
|
+
}
|
|
257
|
+
this.format = 'rb';
|
|
258
|
+
this.open = true;
|
|
259
|
+
this.$nextTick(() => this.$refs.input && this.$refs.input.select());
|
|
260
|
+
},
|
|
261
|
+
// The single definition of 'what key does this name map to in
|
|
262
|
+
// gistKnown' -- shared by currentGist() (read) and save()'s
|
|
263
|
+
// record-the-new-gist step (write) so the two can never drift.
|
|
264
|
+
// A drift would mean a just-saved gist gets recorded under a key
|
|
265
|
+
// currentGist() can't find, the button would stay on 'Create
|
|
266
|
+
// gist', and the next save would mint a second gist -- exactly
|
|
267
|
+
// what prefill_name exists to prevent. Strips a .rb/.org
|
|
268
|
+
// extension in case the field ever carries one.
|
|
269
|
+
gistKey(name) {
|
|
270
|
+
return (name || '').replace(/\\.(rb|org)$/, '');
|
|
271
|
+
},
|
|
272
|
+
// Looks up the typed name in gistKnown -- live, as the name
|
|
273
|
+
// field is edited -- so the scope hint, URL preview, and the
|
|
274
|
+
// gist action button's label all stay in sync with what's
|
|
275
|
+
// actually already shared under that name. Returns null when
|
|
276
|
+
// nothing is known for the current name.
|
|
277
|
+
currentGist() {
|
|
278
|
+
return this.gistKnown[this.gistKey(this.name)] || null;
|
|
279
|
+
},
|
|
280
|
+
// Tiered copy for the org-coverage notice (Phase 2 design spec's
|
|
281
|
+
// Save-as-Org UX section, including its 2026-08-14 copy-accuracy
|
|
282
|
+
// correction: N/M must name passthrough_verbatim/passthrough_lossy
|
|
283
|
+
// specifically, not a conflated total-recognized count, or a doc
|
|
284
|
+
// with mostly-verbatim elements gets told MORE of it is lossy
|
|
285
|
+
// than actually is). Only ever non-null after a format=org save
|
|
286
|
+
// whose response included coverage; a plain .rb save leaves
|
|
287
|
+
// coverage null so this stays hidden.
|
|
288
|
+
orgNotice() {
|
|
289
|
+
if (!this.coverage) return null;
|
|
290
|
+
const { total, recognized, passthrough_verbatim, passthrough_lossy, omitted } = this.coverage;
|
|
291
|
+
if (recognized === total) return null;
|
|
292
|
+
let body;
|
|
293
|
+
if (passthrough_verbatim > 0 && passthrough_lossy > 0) {
|
|
294
|
+
body = `${passthrough_verbatim} element(s) will show as raw code in a plain org viewer (nothing lost); ${passthrough_lossy} more can't be verbatim-recovered and will be replaced with a placeholder comment instead — those specific parts won't survive the round trip.`;
|
|
295
|
+
} else if (passthrough_lossy > 0) {
|
|
296
|
+
body = `${passthrough_lossy} element(s) can't be verbatim-recovered and will be replaced with a placeholder comment — those specific parts won't survive the round trip.`;
|
|
297
|
+
} else if (passthrough_verbatim > 0) {
|
|
298
|
+
body = `${passthrough_verbatim} element(s) will show as raw code in a plain org viewer — nothing is lost, just not styled.`;
|
|
299
|
+
} else {
|
|
300
|
+
body = '';
|
|
301
|
+
}
|
|
302
|
+
// Interactive controls are DROPPED, not passed through: an org
|
|
303
|
+
// document is static and cannot hold a live form (Org::Writer
|
|
304
|
+
// #omitted_control). Say so plainly and point at the format
|
|
305
|
+
// that does keep them, rather than folding them into the
|
|
306
|
+
// raw-code copy above, which would be a lie about where they went.
|
|
307
|
+
if (omitted > 0) {
|
|
308
|
+
const dropped = `${omitted} interactive control(s) (form fields, buttons) are left out — an org document can't hold a live form; save as .rb to keep them.`;
|
|
309
|
+
body = body ? `${dropped} ${body}` : dropped;
|
|
310
|
+
}
|
|
311
|
+
return recognized / total < 0.5
|
|
312
|
+
? `This looks like an app, not a document — Org format won't add much here. ${body}`
|
|
313
|
+
: body;
|
|
314
|
+
},
|
|
315
|
+
async save() {
|
|
316
|
+
if (this.saving) return;
|
|
317
|
+
this.saving = true; this.error = null; this.coverage = null; this.notice = null; this.gistResult = null;
|
|
318
|
+
try {
|
|
319
|
+
const res = await fetch('#{endpoint}', {
|
|
320
|
+
method: 'POST',
|
|
321
|
+
headers: {'Content-Type': 'application/json'},
|
|
322
|
+
body: JSON.stringify({#{extra_body_fields}name: this.name, format: this.format, scope: this.scope})
|
|
323
|
+
});
|
|
324
|
+
const data = await res.json();
|
|
325
|
+
if (res.ok && data.ok) {
|
|
326
|
+
this.savedPath = data.path;
|
|
327
|
+
this.coverage = data.coverage || null;
|
|
328
|
+
this.notice = this.orgNotice();
|
|
329
|
+
if (data.gist_url) {
|
|
330
|
+
// A gist save's payload IS the URL, not a path -- keep
|
|
331
|
+
// the dialog open until the user dismisses it (no
|
|
332
|
+
// auto-close below) and remember it under this name so
|
|
333
|
+
// the button/hint flip to 'Update gist' immediately.
|
|
334
|
+
this.gistResult = { url: data.gist_url, revisions: data.revisions };
|
|
335
|
+
this.gistKnown[this.gistKey(this.name)] = { url: data.gist_url, revisions: data.revisions };
|
|
336
|
+
// Auto-copy for zero-friction hand-off to a coworker.
|
|
337
|
+
// Clipboard access can fail (permissions, insecure
|
|
338
|
+
// context) -- that must never hide the URL, which stays
|
|
339
|
+
// visible and manually selectable in gistResult below
|
|
340
|
+
// regardless of whether the copy itself succeeded.
|
|
341
|
+
try {
|
|
342
|
+
await navigator.clipboard.writeText(data.gist_url);
|
|
343
|
+
} catch (e) {
|
|
344
|
+
// ignored -- URL is still shown and selectable below
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
// A tiered notice can be a full sentence or two -- the
|
|
348
|
+
// default 1.8s auto-close (tuned for a one-line success
|
|
349
|
+
// confirmation) isn't enough time to read it.
|
|
350
|
+
setTimeout(() => { this.open = false; }, this.notice ? 6000 : 1800);
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
this.error = data.error || ('HTTP ' + res.status);
|
|
354
|
+
}
|
|
355
|
+
} catch (e) {
|
|
356
|
+
this.error = e.message;
|
|
357
|
+
} finally {
|
|
358
|
+
this.saving = false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}"#{host_attr} @keydown.escape.window="open = false">
|
|
362
|
+
<button class="sw-save-doc-btn" @click="openDialog()" title="#{button_title}">
|
|
363
|
+
💾 Save as doc
|
|
364
|
+
</button>
|
|
365
|
+
<div x-show="open" x-cloak class="sw-save-doc-modal" @click.self="open = false">
|
|
366
|
+
<div class="sw-save-doc-dialog" @click.stop>
|
|
367
|
+
<h3>#{dialog_title}</h3>
|
|
368
|
+
<p class="hint">
|
|
369
|
+
#{hint_html}
|
|
370
|
+
</p>
|
|
371
|
+
#{scope_html}
|
|
372
|
+
<input type="text" x-model="name" x-ref="input"
|
|
373
|
+
@keydown.enter.prevent="save()"
|
|
374
|
+
:disabled="saving"
|
|
375
|
+
placeholder="my-canvas-doc">
|
|
376
|
+
<div x-show="error" x-text="error" class="sw-save-doc-error"></div>
|
|
377
|
+
<div x-show="savedPath && !gistResult" class="sw-save-doc-success">
|
|
378
|
+
✓ Saved to <code x-text="savedPath"></code>
|
|
379
|
+
</div>
|
|
380
|
+
<div x-show="gistResult" class="sw-save-doc-success">
|
|
381
|
+
✓ Gist saved · <span x-text="gistResult ? gistResult.revisions : ''"></span> revision(s)<br>
|
|
382
|
+
<code x-text="gistResult ? gistResult.url : ''"></code><br>
|
|
383
|
+
<a :href="gistResult ? gistResult.url : '#'" target="_blank">Open gist</a>
|
|
384
|
+
·
|
|
385
|
+
<a :href="gistResult ? (gistResult.url + '/revisions') : '#'" target="_blank">Revisions</a>
|
|
386
|
+
</div>
|
|
387
|
+
<div x-show="notice" x-text="notice" class="sw-save-doc-notice"></div>
|
|
388
|
+
<div class="sw-save-doc-actions">
|
|
389
|
+
<button class="sw-save-doc-cancel" @click="open = false" :disabled="saving">Cancel</button>
|
|
390
|
+
<button class="sw-save-doc-save-org" @click="format = 'org'; save()" :disabled="saving" x-show="scope !== 'gist'" title="Save as a plain-text .org sibling file">
|
|
391
|
+
<span x-show="!(saving && format === 'org')">Save as Org</span>
|
|
392
|
+
<span x-show="saving && format === 'org'">Saving...</span>
|
|
393
|
+
</button>
|
|
394
|
+
<button class="sw-save-doc-save" @click="format = 'rb'; save()" :disabled="saving" x-show="scope !== 'gist'">
|
|
395
|
+
<span x-show="!(saving && format === 'rb')">Save</span>
|
|
396
|
+
<span x-show="saving && format === 'rb'">Saving...</span>
|
|
397
|
+
</button>
|
|
398
|
+
<!-- No format= assignment here on purpose: scope picks the destination,
|
|
399
|
+
format picks the serialization. A gist save always sends both files
|
|
400
|
+
regardless of format, so pinning format to a third value here would
|
|
401
|
+
only risk a future 422 against a server whitelist of ['rb', 'org']. -->
|
|
402
|
+
<button class="sw-save-doc-save-gist" @click="save()" :disabled="saving" x-show="scope === 'gist'">
|
|
403
|
+
<span x-show="!saving" x-text="currentGist() ? 'Update gist' : 'Create gist'"></span>
|
|
404
|
+
<span x-show="saving">Saving...</span>
|
|
405
|
+
</button>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
HTML
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Builds the "This repo (<basename>)" / "Global" / "Gist" radio toggle,
|
|
414
|
+
# or '' when there's nothing to offer at all -- the caller-visible
|
|
415
|
+
# contract is `render`'s `source_dir:`/`gist:` kwargs (see `render`'s
|
|
416
|
+
# doc comment above). Renders when EITHER source_dir OR gist is
|
|
417
|
+
# present; each radio is independently optional (a canvas pushed from
|
|
418
|
+
# outside a git repo has no repo radio but can still offer Gist).
|
|
419
|
+
def scope_toggle_html(source_dir, gist)
|
|
420
|
+
return '' unless source_dir || gist
|
|
421
|
+
|
|
422
|
+
repo_radio = if source_dir
|
|
423
|
+
repo_label = "This repo (#{ERB::Util.h(File.basename(source_dir))})"
|
|
424
|
+
%(<label><input type="radio" x-model="scope" value="repo"> #{repo_label}</label>)
|
|
425
|
+
else
|
|
426
|
+
''
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
gist_radio = gist ? gist_radio_html(gist) : ''
|
|
430
|
+
gist_reason = gist && !gist[:available] ? %(<p class="sw-save-doc-scope-reason">#{ERB::Util.h(gist[:unavailable_reason])}</p>) : ''
|
|
431
|
+
|
|
432
|
+
<<~HTML
|
|
433
|
+
<div class="sw-save-doc-scope">
|
|
434
|
+
#{repo_radio}
|
|
435
|
+
<label><input type="radio" x-model="scope" value="global"> Global</label>
|
|
436
|
+
#{gist_radio}
|
|
437
|
+
</div>
|
|
438
|
+
#{source_dir ? %(<p class="sw-save-doc-scope-path" x-show="scope === 'repo'">#{ERB::Util.h(source_dir)}</p>) : ''}
|
|
439
|
+
#{gist_reason}
|
|
440
|
+
HTML
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# The Gist `<label>` itself. Rendered disabled (never hidden -- Gloria's
|
|
444
|
+
# Law: show the capability and the one thing that unblocks it) when
|
|
445
|
+
# `gist[:available]` is false; its `unavailable_reason` is rendered as
|
|
446
|
+
# helper text by the caller (`scope_toggle_html`, above), not here.
|
|
447
|
+
def gist_radio_html(gist)
|
|
448
|
+
disabled_attr = gist[:available] ? '' : ' disabled'
|
|
449
|
+
<<~HTML.strip
|
|
450
|
+
<label><input type="radio" x-model="scope" value="gist"#{disabled_attr}> Gist
|
|
451
|
+
<span class="sw-save-doc-scope-hint" x-show="currentGist()">(already shared · <span x-text="currentGist() ? currentGist().revisions : ''"></span> revision(s))</span>
|
|
452
|
+
</label>
|
|
453
|
+
HTML
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StreamWeaver
|
|
4
|
+
module Canvas
|
|
5
|
+
# The id of the one-shot marker element a poll response's swapped-in
|
|
6
|
+
# html can carry to mean "land the viewer at the top of this page
|
|
7
|
+
# instead of following its own near-bottom scroll" (round-9 UAT --
|
|
8
|
+
# BridgeServer#polling_script is the consumer; University::Listener's
|
|
9
|
+
# SCROLL_TOP_HINT_DSL, the producer, interpolates this same constant
|
|
10
|
+
# rather than repeating the string).
|
|
11
|
+
#
|
|
12
|
+
# Deliberately its own file, with no other requires: the producer lives
|
|
13
|
+
# in University::Listener, whose subprocess (`Listener.start!` spawns it
|
|
14
|
+
# with `-r stream_weaver/university/listener` alone, never the full
|
|
15
|
+
# `stream_weaver` entry point) breaks on bridge_server.rb's own
|
|
16
|
+
# dependency chain (Sinatra, GistSaveHandler, the org writer's
|
|
17
|
+
# Components lookup) if listener.rb requires that file just to read one
|
|
18
|
+
# string. Both ends require this instead.
|
|
19
|
+
SCROLL_TOP_HINT_ID = 'sw-scroll-top-hint'
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'protocol'
|
|
4
|
+
|
|
5
|
+
module StreamWeaver
|
|
6
|
+
module Canvas
|
|
7
|
+
# Represents a single canvas session with its state and WebSocket connections.
|
|
8
|
+
class Session
|
|
9
|
+
VALID_LAYOUTS = %i[default wide full fluid].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :name, :state, :websockets, :created_at, :layout, :dsl, :theme, :stylesheets, :source_dir
|
|
12
|
+
attr_accessor :html, :html_version, :pane_id
|
|
13
|
+
|
|
14
|
+
# @param name [String] Session name
|
|
15
|
+
# @param layout [Symbol] Layout mode: :default (900px), :wide (1100px), :full (1400px), :fluid (100%)
|
|
16
|
+
# @param theme [Symbol] Theme name (e.g. :default, :doc)
|
|
17
|
+
def initialize(name, layout: :fluid, theme: :default)
|
|
18
|
+
@name = name
|
|
19
|
+
@layout = VALID_LAYOUTS.include?(layout) ? layout : :fluid
|
|
20
|
+
@theme = theme
|
|
21
|
+
@state = {}
|
|
22
|
+
@websockets = []
|
|
23
|
+
@created_at = Time.now
|
|
24
|
+
@html = nil
|
|
25
|
+
@html_version = 0
|
|
26
|
+
@dsl = nil
|
|
27
|
+
@stylesheets = []
|
|
28
|
+
@source_dir = nil
|
|
29
|
+
@pending_toasts = []
|
|
30
|
+
@pane_id = nil
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Set the rendered HTML content and increment version
|
|
35
|
+
# @param content [String] HTML content
|
|
36
|
+
def set_html(content)
|
|
37
|
+
@html = content
|
|
38
|
+
@html_version += 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Set the raw DSL for this session. Called by Bridge#handle_push only on
|
|
42
|
+
# successful render. Failed renders preserve the last good DSL so the
|
|
43
|
+
# user can still save it.
|
|
44
|
+
# @param content [String] Raw DSL source
|
|
45
|
+
def set_dsl(content)
|
|
46
|
+
@dsl = content
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Set the directory the current DSL was pushed from. Called by
|
|
50
|
+
# Bridge#handle_push only on successful render, same as #set_dsl -- a
|
|
51
|
+
# failed push must not desync source_dir from the DSL it's paired with
|
|
52
|
+
# (canvas-doc-location-and-discovery.md).
|
|
53
|
+
# @param dir [String, nil] Absolute git root path, or nil outside a repo
|
|
54
|
+
def set_source_dir(dir)
|
|
55
|
+
@source_dir = dir
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Replace the inline stylesheets carried by the current DSL (stream_weaver-9uk).
|
|
59
|
+
# Replacing rather than accumulating means a re-push with the same (or no)
|
|
60
|
+
# `use_stylesheet` calls never stacks duplicate <style> tags across pushes.
|
|
61
|
+
# @param list [Array<String>] CSS text, deduped
|
|
62
|
+
def set_stylesheets(list)
|
|
63
|
+
@stylesheets = list.uniq
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Update session state
|
|
67
|
+
# @param new_state [Hash] State to merge
|
|
68
|
+
def update_state(new_state)
|
|
69
|
+
@state.merge!(new_state)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Add a WebSocket connection
|
|
73
|
+
# @param ws [Object] WebSocket connection
|
|
74
|
+
def add_websocket(ws)
|
|
75
|
+
@websockets << ws
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Remove a WebSocket connection
|
|
79
|
+
# @param ws [Object] WebSocket connection
|
|
80
|
+
def remove_websocket(ws)
|
|
81
|
+
@websockets.delete(ws)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Broadcast a message to all connected WebSockets
|
|
85
|
+
# @param message [Hash] Message to send
|
|
86
|
+
def broadcast(message)
|
|
87
|
+
encoded = Protocol.encode(message)
|
|
88
|
+
@websockets.each { |ws| ws.send(encoded) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def reset!
|
|
92
|
+
@state.clear
|
|
93
|
+
@html = nil
|
|
94
|
+
@stylesheets = []
|
|
95
|
+
@html_version += 1
|
|
96
|
+
@mutex.synchronize { @pending_toasts.clear }
|
|
97
|
+
broadcast(type: 'reset')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Queue a toast for polling clients
|
|
101
|
+
def queue_toast(message:, variant: 'warning', duration: 0)
|
|
102
|
+
toast = { message: message, variant: variant, duration: duration }
|
|
103
|
+
@mutex.synchronize { @pending_toasts << toast }
|
|
104
|
+
# Also broadcast to websocket clients
|
|
105
|
+
broadcast(type: 'toast', **toast)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Pop all pending toasts (for polling clients)
|
|
109
|
+
def pop_toasts
|
|
110
|
+
@mutex.synchronize do
|
|
111
|
+
toasts = @pending_toasts.dup
|
|
112
|
+
@pending_toasts.clear
|
|
113
|
+
toasts
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Return session info as a hash
|
|
118
|
+
# @return [Hash]
|
|
119
|
+
def to_h
|
|
120
|
+
{
|
|
121
|
+
name: @name,
|
|
122
|
+
state: @state,
|
|
123
|
+
websocket_count: @websockets.size,
|
|
124
|
+
created_at: @created_at,
|
|
125
|
+
pane_id: @pane_id,
|
|
126
|
+
theme: @theme,
|
|
127
|
+
layout: @layout
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|