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
data/llms.txt
ADDED
|
@@ -0,0 +1,1269 @@
|
|
|
1
|
+
# StreamWeaver - LLM Quick Reference
|
|
2
|
+
|
|
3
|
+
StreamWeaver is a Ruby DSL for building reactive web UIs with minimal code. Think "Streamlit for Ruby".
|
|
4
|
+
|
|
5
|
+
## CRITICAL: Auto-Port and Auto-Browser
|
|
6
|
+
|
|
7
|
+
**StreamWeaver automatically:**
|
|
8
|
+
1. **Finds a free port** - Does NOT use a fixed port. Scans from 4567 upward.
|
|
9
|
+
2. **Opens the browser** - You do NOT need to open it manually.
|
|
10
|
+
3. **Prints the URL to stdout** - Read terminal output to get the actual port.
|
|
11
|
+
|
|
12
|
+
**NEVER assume port 4567.** The actual port is printed to stdout:
|
|
13
|
+
```
|
|
14
|
+
╔═══════════════════════════════════════════════════════════════╗
|
|
15
|
+
║ StreamWeaver App Running ║
|
|
16
|
+
╚═══════════════════════════════════════════════════════════════╝
|
|
17
|
+
|
|
18
|
+
🌐 http://localhost:4572 ← READ THIS, don't assume 4567!
|
|
19
|
+
📱 My App
|
|
20
|
+
|
|
21
|
+
Press Ctrl+C to stop
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**For AI agents:** Parse the terminal output after `ruby app.rb` to find the actual URL.
|
|
25
|
+
|
|
26
|
+
**Finding the port an agent is already driving (do this, not a second boot):** Run the app as a
|
|
27
|
+
background task, then read its own stdout for the `http://127.0.0.1:<port>` line the startup
|
|
28
|
+
banner prints — that line IS the port, there is nothing to discover beyond reading it. **NEVER
|
|
29
|
+
boot a second instance of the same app to "find" a port** — StreamWeaver's own port-scan means a
|
|
30
|
+
second launch just finds the NEXT free port and starts a duplicate, orphaned server; it never
|
|
31
|
+
tells you what the first one is running on. If the banner didn't reach your captured output
|
|
32
|
+
(e.g. you weren't backgrounding the process yet), find the live port with
|
|
33
|
+
`lsof -i :4567-4620 -sTCP:LISTEN` against the ALREADY-running process rather than sleeping on
|
|
34
|
+
the log or launching another one.
|
|
35
|
+
|
|
36
|
+
## THE KEY INSIGHT
|
|
37
|
+
|
|
38
|
+
**Your Ruby DSL block RE-EXECUTES on every user interaction.**
|
|
39
|
+
|
|
40
|
+
- User types in a field → entire block runs again with updated `state[:field]`
|
|
41
|
+
- User clicks a button → callback runs, then entire block runs again
|
|
42
|
+
- This creates reactive UI without you writing any event handling code
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
app "Counter" do
|
|
46
|
+
# This ENTIRE block runs on EVERY interaction
|
|
47
|
+
state[:count] ||= 0
|
|
48
|
+
|
|
49
|
+
text "Count: #{state[:count]}"
|
|
50
|
+
|
|
51
|
+
button "+" do |s|
|
|
52
|
+
s[:count] += 1 # Modifies state, triggers re-run
|
|
53
|
+
end
|
|
54
|
+
end.run!
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## The StreamWeaver Way
|
|
58
|
+
|
|
59
|
+
StreamWeaver is an omakase layer, not a widget kit — backend and frontend were designed as one system, so these conventions are the framework, not style advice. Follow them and you write no JavaScript. Six laws, each linked to the section that details it:
|
|
60
|
+
|
|
61
|
+
1. **Key by what is unique per position on the page, not by what the content is about.** Auto-derived ids are position-stable, not content-stable — reach for `key: record_id` when identity must outlive reordering, filtering, or deletion. → [Interactive IDs and keying](#interactive-ids-and-keying)
|
|
62
|
+
2. **Deferred over hand-rolled timers.** A slow region gets `fragment(..., defer: true)`, never an `every()` poll. → [Deferred Fragments](#deferred-fragments-fragment--defer-true)
|
|
63
|
+
3. **Lazy means visible.** Hidden content costs nothing until CSS or scroll reveals it, and it fetches exactly once. → [Lazy Fragments](#lazy-fragments-fragment--lazy-true)
|
|
64
|
+
4. **Dev loud, prod self-heals.** The deliberate inversion of Hotwire's "content missing": keep the debugging signal, don't charge the user for it. → [Dev Loud, Prod Self-Heal](#dev-loud-prod-self-heal-stale-action-fallback)
|
|
65
|
+
5. **State your intent in DSL verbs.** Zero custom JavaScript is the default, not an aspiration — no script tags, no raw `hx-*` from app code, no adapter internals. → [Repo Conventions](#repo-conventions-anti-patterns-that-cause-churn)
|
|
66
|
+
6. **Trilaws as design filters.** Matt (findable/digestible), Forrest (zero friction + real perks), Gloria (the default path must be the correct path). Law 1's auto-keying is Gloria's Law in code: the default already disambiguates, so the silent-wrong-callback bug cannot happen.
|
|
67
|
+
|
|
68
|
+
The `streamweaver-way` skill ([Agent Skills](#agent-skills-install-these-before-hand-rolling-the-above)) teaches these with the four benchmark recipes — inline editing, scoped live search, lazy hover cards, Russian-doll infinite scroll. `examples/my_todos/` runs all four in one app with zero custom JavaScript.
|
|
69
|
+
|
|
70
|
+
**Tutorial — `docs/tutorials/the-streamweaver-way.md`.** "Build My Todos: the StreamWeaver Way" rebuilds those same four features step by step, why-first: each step names the law it obeys, the gotcha it avoids, and the measured number where one exists (deferring a 1.5s region: 0.018s shell vs 1.508s fragment fetch; hover cards: 0.085s shell vs 9.151s eager; search: 800-byte scoped morph vs 2,123-byte whole-body swap; infinite scroll: O(1) per page vs O(n) per click). The skill *prescribes*; the tutorial *narrates* — read the tutorial when you want the reasoning, the skill when you want the answer. Runs as a `:doc`-theme app too: `SW_NO_OPEN=1 ruby examples/tutorials/streamweaver_way_tutorial.rb`.
|
|
71
|
+
|
|
72
|
+
## Minimal Example
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
require 'stream_weaver'
|
|
76
|
+
|
|
77
|
+
app "Hello" do
|
|
78
|
+
text_field :name, placeholder: "Your name"
|
|
79
|
+
|
|
80
|
+
if state[:name].to_s.strip != ""
|
|
81
|
+
text "Hello, #{state[:name]}!"
|
|
82
|
+
end
|
|
83
|
+
end.run!
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Run: `ruby hello.rb` → browser opens automatically.
|
|
87
|
+
|
|
88
|
+
## State
|
|
89
|
+
|
|
90
|
+
State is a hash. Access with `state[:key]`. Components auto-bind to keys.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
text_field :email # state[:email] = user input
|
|
94
|
+
checkbox :agree, "I agree" # state[:agree] = true/false
|
|
95
|
+
select :color, ["Red", "Blue"] # state[:color] = selected value
|
|
96
|
+
|
|
97
|
+
# Read state anywhere
|
|
98
|
+
if state[:agree]
|
|
99
|
+
text "You agreed!"
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Essential Components
|
|
104
|
+
|
|
105
|
+
### Input
|
|
106
|
+
```ruby
|
|
107
|
+
text_field :name, placeholder: "Name"
|
|
108
|
+
text_area :bio, rows: 5
|
|
109
|
+
text_area :paste, transient: true # excluded from session cookie (for large paste areas)
|
|
110
|
+
checkbox :key, "Label" # state[:key] = true/false
|
|
111
|
+
select :role, ["Admin", "User"], default: "User"
|
|
112
|
+
radio_group :choice, ["A", "B", "C"]
|
|
113
|
+
date_field :due_on, label: "Due date" # native <input type=date>; state[:due_on] = "YYYY-MM-DD" (ISO 8601)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### IMPORTANT: Form Input Auto-Submit Behavior
|
|
117
|
+
|
|
118
|
+
**By default, ALL input components auto-submit on change.** Every keystroke, checkbox click, or selection triggers a server round-trip and page re-render. This is intentional for reactive UIs.
|
|
119
|
+
|
|
120
|
+
**ANTI-PATTERN — text_area / text_field / select paired with a button:**
|
|
121
|
+
```ruby
|
|
122
|
+
# WRONG — every keystroke redaws the UI, textarea resize is lost, select jumps
|
|
123
|
+
text_area :prompt, rows: 4
|
|
124
|
+
select :choice, options
|
|
125
|
+
button "Submit" do |s| ... end
|
|
126
|
+
|
|
127
|
+
# CORRECT — no round-trips until button click
|
|
128
|
+
text_area :prompt, rows: 4, submit: false
|
|
129
|
+
select :choice, options, submit: false
|
|
130
|
+
button "Submit" do |s| ... end
|
|
131
|
+
```
|
|
132
|
+
Any `text_field`, `text_area`, or `select` that feeds into a button action **must** use `submit: false`. Without it, typing causes full page re-renders, textarea resize resets, and the UI flickers on every keystroke.
|
|
133
|
+
|
|
134
|
+
**For "form-like" behavior** (collect multiple inputs, then act):
|
|
135
|
+
|
|
136
|
+
Option 1: Use `submit: false` on individual inputs:
|
|
137
|
+
```ruby
|
|
138
|
+
# Checkboxes that don't auto-submit - user can check multiple before action
|
|
139
|
+
items.each do |item|
|
|
140
|
+
checkbox :"select_#{item.id}", item.name, submit: false
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
button "Process Selected" do |state|
|
|
144
|
+
items.each do |item|
|
|
145
|
+
if state[:"select_#{item.id}"]
|
|
146
|
+
# act on selected items
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Option 2: Use `form` block for deferred submission:
|
|
153
|
+
```ruby
|
|
154
|
+
form :my_form do
|
|
155
|
+
text_field :name
|
|
156
|
+
select :status, %w[active paused]
|
|
157
|
+
submit "Save" do |form_values|
|
|
158
|
+
# All values submitted together
|
|
159
|
+
end
|
|
160
|
+
cancel "Reset"
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**When to use which:**
|
|
165
|
+
- Default (auto-submit): Filters, toggles, instant feedback
|
|
166
|
+
- `submit: false`: Multi-select lists with action buttons
|
|
167
|
+
- `form` block: Traditional forms with Save/Cancel
|
|
168
|
+
|
|
169
|
+
### Checkbox Group (Multi-Select with Select All/None)
|
|
170
|
+
```ruby
|
|
171
|
+
# For batch selection (emails, files, items to process)
|
|
172
|
+
checkbox_group :selected_items, select_all: "Select All", select_none: "Clear" do
|
|
173
|
+
items.each do |item|
|
|
174
|
+
item item.id do # Use `item value do`, NOT `checkbox`
|
|
175
|
+
text item.name
|
|
176
|
+
# Any components can be nested here
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
# state[:selected_items] = ["id1", "id3", ...] (array of selected IDs)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**CRITICAL:** Inside `checkbox_group`, ONLY `item` elements are allowed at the top level. You CANNOT use `text`, `header`, `div`, or other components directly inside `checkbox_group` - they must be nested inside an `item` block. If you need section headers, put them OUTSIDE the checkbox_group.
|
|
184
|
+
|
|
185
|
+
### Display
|
|
186
|
+
```ruby
|
|
187
|
+
text "Plain text with #{state[:value]}"
|
|
188
|
+
md "**Bold** and *italic* markdown"
|
|
189
|
+
header "Section Title" # h2
|
|
190
|
+
header1 "Page Title" # h1
|
|
191
|
+
header3 "Subsection" # h3
|
|
192
|
+
|
|
193
|
+
# Mermaid diagrams — Mermaid.js loaded lazily from CDN
|
|
194
|
+
mermaid "graph LR; A-->B" # basic
|
|
195
|
+
mermaid diagram_code, zoom: true # zoom/pan controls
|
|
196
|
+
mermaid diagram_code, compact: true # reduced padding (use inside card)
|
|
197
|
+
mermaid diagram_code, layout: :elk # ELK layout engine for dense graphs
|
|
198
|
+
mermaid diagram_code, theme_vars: { primaryColor: "#6366f1" } # custom colors
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Every diagram, regardless of `zoom:`, also gets an "expand to full screen" button (no opt-in) — a wide diagram's fixed-px labels shrink to fit whatever doc column width it renders in no matter how the layout is tuned, so this is the actual fix for an illegible complex diagram, not just a bigger version of `zoom: true`.
|
|
202
|
+
|
|
203
|
+
### CRITICAL: `text` Does NOT Render Markdown
|
|
204
|
+
|
|
205
|
+
**`text` is literal — markdown syntax appears as raw characters.**
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
# WRONG — renders as: **Select the article PDF:** (asterisks visible)
|
|
209
|
+
text "**Select the article PDF:**"
|
|
210
|
+
|
|
211
|
+
# CORRECT — renders as bold text
|
|
212
|
+
md "**Select the article PDF:**"
|
|
213
|
+
|
|
214
|
+
# BETTER — use semantic headers for section labels
|
|
215
|
+
header3 "Select the article PDF:"
|
|
216
|
+
header4 "What do you want to know?"
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**Rule:** Never put `**`, `*`, `#`, or other markdown in a `text` call. Use `md` if you need inline formatting, or `header1`–`header6` for structural labels.
|
|
220
|
+
|
|
221
|
+
### Tables
|
|
222
|
+
```ruby
|
|
223
|
+
# Basic - explicit headers/rows
|
|
224
|
+
table headers: ["Name", "Size"], rows: [["app.rb", "12kb"]]
|
|
225
|
+
|
|
226
|
+
# Array of hashes - auto-infer headers
|
|
227
|
+
table [{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }]
|
|
228
|
+
|
|
229
|
+
# Column DSL with formatters
|
|
230
|
+
table users do
|
|
231
|
+
column :name
|
|
232
|
+
column :balance, format: :currency, align: :right
|
|
233
|
+
column :joined, format: :date
|
|
234
|
+
column(:active) { |u| u.active? ? "Yes" : "No" }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Interactive features
|
|
238
|
+
table data, sortable: true, sticky_header: true, striped: true
|
|
239
|
+
|
|
240
|
+
# Markdown links in cells
|
|
241
|
+
table [{ issue: "[JIRA-123](https://jira.example.com)" }], markdown: true
|
|
242
|
+
|
|
243
|
+
# Data sources
|
|
244
|
+
table data: :users # From state
|
|
245
|
+
table file: "data.yaml" # From file
|
|
246
|
+
|
|
247
|
+
# Cell style escape hatches: column style: (String|Proc), id_style: true/false, table-level id_column:
|
|
248
|
+
table headers: [...], rows: [...], id_column: false # disable the default first-column accent
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Formatters: `:date`, `:datetime`, `:currency`, `:number`, `:percent`
|
|
252
|
+
|
|
253
|
+
### Buttons
|
|
254
|
+
```ruby
|
|
255
|
+
button "Click Me" do |s|
|
|
256
|
+
s[:clicked] = true
|
|
257
|
+
s[:count] = (s[:count] || 0) + 1
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
button "Secondary", style: :secondary do |s|
|
|
261
|
+
# ...
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Client-side clipboard copy -- no server round-trip
|
|
265
|
+
copy_button "Copy summary", text: report_text
|
|
266
|
+
|
|
267
|
+
# Syntax-highlighted code, with an optional copy affordance (off by default)
|
|
268
|
+
code_block source, lang: "ruby", copy: true
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Interactive IDs and keying
|
|
272
|
+
|
|
273
|
+
Every interactive component needs an id the server can dispatch on. StreamWeaver
|
|
274
|
+
derives one for you; you only reach for `key:`/`id:` when you want identity to
|
|
275
|
+
survive something the derivation can't see.
|
|
276
|
+
|
|
277
|
+
**Precedence: `id:` > `key:` > auto-derivation.**
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
button "Delete" do |s| ... end # auto: label + block source location
|
|
281
|
+
button "Delete", key: todo[:id] do |s| ... end # content-stable: bound to the record
|
|
282
|
+
button "Delete", id: "delete-todo-#{todo[:id]}" # verbatim override -- you own the id
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
- **auto-derivation** hashes the label plus the block's source location. Two loop
|
|
286
|
+
iterations render the same label at the same line, so they derive the same id;
|
|
287
|
+
StreamWeaver auto-disambiguates each repeat occurrence with a `-dup-N` suffix
|
|
288
|
+
(and warns once, pointing at `key:`). Every button stays independently
|
|
289
|
+
dispatchable with zero author intervention -- the old silent wrong-callback
|
|
290
|
+
bug cannot happen.
|
|
291
|
+
- **`key:`** accepts stable scalars ONLY -- String, Symbol, or Integer. Arrays,
|
|
292
|
+
hashes, and arbitrary objects raise `ArgumentError`, because their
|
|
293
|
+
stringification is not a stable identity. Never pass a positional index.
|
|
294
|
+
- **`id:`** is the full explicit override: the emitted DOM id is
|
|
295
|
+
`btn_<label>_<your id>` verbatim, which is what you want when other code
|
|
296
|
+
(SSE pushes, CSS, tests) targets the element by selector.
|
|
297
|
+
|
|
298
|
+
**Key by what is unique per position, not by content.** Auto-derived ids
|
|
299
|
+
(including the `-dup-N` suffixes) are POSITION-stable, not CONTENT-stable: they
|
|
300
|
+
hold steady across rerenders of the same list, but inserting or deleting an
|
|
301
|
+
earlier item shifts every later occurrence's suffix. If a row's identity must
|
|
302
|
+
outlive reordering, filtering, or deletion -- per-row Delete/Edit buttons, hover
|
|
303
|
+
cards, anything whose payload is "this record" -- pass `key: record_id`. The
|
|
304
|
+
classic failure is two cards sharing one derived id: hovering the second shows
|
|
305
|
+
the first one's data, and nothing errors.
|
|
306
|
+
|
|
307
|
+
**`strict_ids`** turns a derived-id collision from a warning into a hard failure,
|
|
308
|
+
so a loop that relies on position-stability can't ship unnoticed:
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
StreamWeaver::App.new("My App", strict_ids: true) { ... } # per app
|
|
312
|
+
StreamWeaver.strict_ids = true # global (boot file)
|
|
313
|
+
SW_STRICT_IDS=1 ruby my_app.rb # env, e.g. in CI
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
It raises in development (and test), and degrades to a warning when
|
|
317
|
+
`RACK_ENV=production` -- a live page should not 500 over an id the framework
|
|
318
|
+
already disambiguated. The same rules apply to `clickable(action:, key:)`,
|
|
319
|
+
`fragment` names, and feed/canvas-pushed buttons.
|
|
320
|
+
|
|
321
|
+
### Layout
|
|
322
|
+
```ruby
|
|
323
|
+
card do
|
|
324
|
+
header3 "Card Title"
|
|
325
|
+
text "Content"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
vstack spacing: :md do
|
|
329
|
+
text "Vertical"
|
|
330
|
+
text "Stack"
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
hstack spacing: :sm do
|
|
334
|
+
button "Left"
|
|
335
|
+
button "Right"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
columns widths: ['30%', '70%'] do
|
|
339
|
+
column { text "Sidebar" }
|
|
340
|
+
column { text "Main" }
|
|
341
|
+
end
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### Modal
|
|
345
|
+
```ruby
|
|
346
|
+
button "Open" do |s|
|
|
347
|
+
s[:my_modal_open] = true
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
modal :my_modal, title: "Dialog", size: :md do
|
|
351
|
+
text "Modal content"
|
|
352
|
+
modal_footer do
|
|
353
|
+
button "Close" do |s|
|
|
354
|
+
s[:my_modal_open] = false
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
### Navigation
|
|
361
|
+
```ruby
|
|
362
|
+
# Horizontal navbar — active item is bold/non-clickable, others are links
|
|
363
|
+
navbar do
|
|
364
|
+
nav_item "Dashboard", href: "/", active: true
|
|
365
|
+
nav_item "Settings", href: "/settings"
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Inline link
|
|
369
|
+
link_to "View docs", href: "https://example.com"
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### URL Routing (Deep Links & Bookmarks)
|
|
373
|
+
|
|
374
|
+
Use `route_by` to map a state key's values to URL paths. This enables:
|
|
375
|
+
- Direct URL access (e.g. visiting `/settings` loads the settings page)
|
|
376
|
+
- Browser back/forward navigation
|
|
377
|
+
- Bookmarkable URLs
|
|
378
|
+
- External deep-linking
|
|
379
|
+
|
|
380
|
+
```ruby
|
|
381
|
+
app "My App" do
|
|
382
|
+
route_by :page, home: "/", dashboard: "/dashboard", settings: "/settings"
|
|
383
|
+
|
|
384
|
+
state[:page] ||= :home
|
|
385
|
+
|
|
386
|
+
navbar do
|
|
387
|
+
nav_item "Home", href: "/", active: state[:page] == :home
|
|
388
|
+
nav_item "Dashboard", href: "/dashboard", active: state[:page] == :dashboard
|
|
389
|
+
nav_item "Settings", href: "/settings", active: state[:page] == :settings
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
case state[:page]
|
|
393
|
+
when :home then # ...
|
|
394
|
+
when :dashboard then # ...
|
|
395
|
+
when :settings then # ...
|
|
396
|
+
end
|
|
397
|
+
end.run!
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
**How it works:** When `state[:page]` changes (via button callback or nav click), StreamWeaver emits an `HX-Push-Url` header that updates the browser URL. Visiting `/dashboard` directly seeds `state[:page] = :dashboard` before render. Back/forward uses HTMX to re-fetch the page at the popped URL.
|
|
401
|
+
|
|
402
|
+
**Important:** `route_by` is opt-in — apps without it are unaffected.
|
|
403
|
+
|
|
404
|
+
### Route Tabs (`tabs url: true`)
|
|
405
|
+
|
|
406
|
+
Puts a tab group's active index in a query param, so a tab is bookmarkable:
|
|
407
|
+
|
|
408
|
+
```ruby
|
|
409
|
+
tabs :view, url: true do # active tab lives in ?view=<index>
|
|
410
|
+
tab("Overview") { text "..." }
|
|
411
|
+
tab("Findings") { text "..." }
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Read state BELOW the declaration — above it, state[:view] is still the raw
|
|
415
|
+
# pre-authority value (param string or stale session index), not the resolved Integer.
|
|
416
|
+
md "Active view: #{state[:view]}"
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Tab switching is client-side (History API), so no request is made; back/forward and
|
|
420
|
+
bookmarks work. Multiple groups compose, one param per key (`/?view=2&panel=1`).
|
|
421
|
+
|
|
422
|
+
**URL authority:** on a full GET the param wins — present and valid → that index,
|
|
423
|
+
absent or invalid → tab 0, never the session value. Same URL, same tabs, always.
|
|
424
|
+
`?view=999`, `?view=abc`, `?view[]=` all return 200 on tab 0; `?view=2` is a valid deep
|
|
425
|
+
link. A route-tab group therefore can't be server-side preset (`state[:view] ||= 1`
|
|
426
|
+
above the declaration is overwritten) — link to `/?view=1` instead.
|
|
427
|
+
|
|
428
|
+
Raises at build time: reserved keys (`app_id`, `splat`, `captures`, `button_id`),
|
|
429
|
+
duplicate `url:` keys, or `url: true` combined with `lazy: true`. On canvas, `url:` is
|
|
430
|
+
ignored (plain client tabs + one logged warning; the page URL is untouched).
|
|
431
|
+
|
|
432
|
+
Plain tabs also clamp: an out-of-range index renders tab 0 instead of blank panels.
|
|
433
|
+
|
|
434
|
+
### Resource Scaffolding
|
|
435
|
+
|
|
436
|
+
Use `resource` for convention-over-configuration CRUD. One block replaces ~40 lines of route/state/form boilerplate:
|
|
437
|
+
|
|
438
|
+
```ruby
|
|
439
|
+
resource :post, store: PostStore do
|
|
440
|
+
field :title, :string
|
|
441
|
+
field :body, :text
|
|
442
|
+
field :status, :enum, values: %w[draft published]
|
|
443
|
+
end
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Generates index / show / new / edit / destroy with deep-linkable URLs, named-route helpers (`posts_path`, `post_path(rec)`, etc.), store validation at startup, and override blocks for any action. Declare `page` / `route` calls before `resource` blocks (first-registered-wins routing chain).
|
|
447
|
+
|
|
448
|
+
See `docs/resource-dsl.md` for full reference: field types, `only`/`except`, store protocol, `_sw_` state schema, override blocks.
|
|
449
|
+
|
|
450
|
+
### Record-Bound Forms (`form_for`)
|
|
451
|
+
|
|
452
|
+
`form_for` is the primitive `resource`'s own `new`/`edit` views build on: seeds fields from a record, infers create vs. update from record identity, coerces + validates on submit, then `store.create`/`update` → flash notice → PRG to the resource's `show` action. Use it inside `resource` override blocks (or standalone) whenever you need more control than the default form gives you:
|
|
453
|
+
|
|
454
|
+
```ruby
|
|
455
|
+
resource :person, store: PersonStore do
|
|
456
|
+
field :name, :string
|
|
457
|
+
field :role, :enum, values: %w[lead champion decision_maker]
|
|
458
|
+
|
|
459
|
+
edit do |person|
|
|
460
|
+
header1 "Edit #{person[:name]}"
|
|
461
|
+
form_for :person, record: person do
|
|
462
|
+
submit_label "Save"
|
|
463
|
+
cancel_label "Cancel"
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
- `record:` present with an `:id` → update mode (`"Save"`); `nil`/absent → create mode (`"Create"`).
|
|
470
|
+
- Seeds the form scope from `record:` only on first render for that id — an unrelated re-render never clobbers an in-progress edit; switching to a different id re-seeds fresh.
|
|
471
|
+
- Validation failures (coercion or a `validate { |values| ... }` block) populate an error `Alert` above the fields and re-render same-request, keeping the user's typed values — never a redirect.
|
|
472
|
+
- `on_success:` overrides the default show/index transition; required for standalone (non-`resource`) usage.
|
|
473
|
+
|
|
474
|
+
Full reference (all options, in-block DSL, standalone usage): `docs/resource-dsl.md#form_for`.
|
|
475
|
+
|
|
476
|
+
### Turbo Frame-Style Inline Editing with `form_for`
|
|
477
|
+
|
|
478
|
+
The learnhotwire.com course's canonical Turbo Frames demo: click a todo's title, it turns into an edit field in place, submit swaps back to the display — no full navigation. Rails does this with two matching-`dom_id` frames; StreamWeaver gets the same *result* (deep-linkable edit state, no hand-written JS, no full page reload — HTMX swaps the container) from `resource` + `form_for`, using the resource's own routed `edit` action instead of a client-side frame swap:
|
|
479
|
+
|
|
480
|
+
```ruby
|
|
481
|
+
require 'stream_weaver'
|
|
482
|
+
require 'securerandom'
|
|
483
|
+
|
|
484
|
+
module TodoStore
|
|
485
|
+
@todos = [{ id: '1', title: 'Buy milk', completed: false }]
|
|
486
|
+
def self.all; @todos; end
|
|
487
|
+
def self.find(id); @todos.find { |t| t[:id] == id }; end
|
|
488
|
+
def self.create(attrs); id = SecureRandom.hex(4); @todos << { id: id, **attrs }; id; end
|
|
489
|
+
def self.update(id, attrs) t = find(id); t&.merge!(attrs); !!t; end
|
|
490
|
+
def self.destroy(id); @todos.reject! { |t| t[:id] == id }; true; end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
app 'My Todos' do
|
|
494
|
+
resource :todo, store: TodoStore do
|
|
495
|
+
field :title, :string
|
|
496
|
+
field :completed, :boolean
|
|
497
|
+
|
|
498
|
+
# Row shows the title as plain text with an Edit link — click it to swap
|
|
499
|
+
# this row's spot for an inline form_for, submit swaps back to the row.
|
|
500
|
+
index do |todos|
|
|
501
|
+
header1 'My Todos'
|
|
502
|
+
todos.each do |todo|
|
|
503
|
+
hstack do
|
|
504
|
+
text todo[:title]
|
|
505
|
+
link_to 'Edit', href: edit_todo_path(todo)
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# /todo/:id/edit — form_for seeds :title/:completed from the record,
|
|
511
|
+
# submit coerces + validates, calls TodoStore.update, flashes, and
|
|
512
|
+
# PRGs back to :show (URL pushed, deep-linkable either way).
|
|
513
|
+
edit do |todo|
|
|
514
|
+
header1 "Edit #{todo[:title]}"
|
|
515
|
+
form_for :todo, record: todo do
|
|
516
|
+
submit_label 'Save'
|
|
517
|
+
cancel_label 'Cancel'
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
end.run!
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
**Key mapping** (Rails → StreamWeaver): matching `dom_id` frames that swap in place →
|
|
525
|
+
`resource`'s routed `edit` action (`edit_todo_path(todo)`) whose default/override view
|
|
526
|
+
calls `form_for :todo, record: todo`; Rails' `form_with model: @todo` field/value
|
|
527
|
+
seeding + strong-params allowlist → `form_for`'s record seeding + `field` declarations
|
|
528
|
+
(only declared fields are read from submitted state); Rails' controller-driven
|
|
529
|
+
create/update + redirect-back-to-show → `form_for`'s built-in coerce → validate →
|
|
530
|
+
`store.update` → flash → PRG to `show`, all for free. What StreamWeaver does not yet do
|
|
531
|
+
is a same-DOM-fragment swap scoped to just the one row — today the edit view is its own
|
|
532
|
+
routed, deep-linkable page/fragment, not a client-side frame replacement. For the other
|
|
533
|
+
half of the Turbo Frames story — rendering the shell now and letting a slow region land
|
|
534
|
+
later — see [Deferred Fragments](#deferred-fragments-fragment--defer-true) below.
|
|
535
|
+
|
|
536
|
+
### Deferred Fragments (`fragment ..., defer: true`)
|
|
537
|
+
|
|
538
|
+
`fragment(:name) { ... }` marks a region of the page that interactions inside it can swap on
|
|
539
|
+
its own, instead of swapping the whole app container. Adding `defer: true` makes it the
|
|
540
|
+
equivalent of Turbo's `turbo_frame_tag ..., src:`: **the block does not run on the initial
|
|
541
|
+
render at all.** The page ships immediately with a placeholder, and the fragment fetches its
|
|
542
|
+
own content right after — no custom JavaScript in your app, just the option:
|
|
543
|
+
|
|
544
|
+
```ruby
|
|
545
|
+
app 'Dashboard' do
|
|
546
|
+
header1 'Dashboard' # renders instantly
|
|
547
|
+
text "#{Order.count} orders" # renders instantly
|
|
548
|
+
|
|
549
|
+
# This block never runs during the page render. The shell responds, the
|
|
550
|
+
# browser fetches this fragment, and the content swaps in when ready.
|
|
551
|
+
fragment :revenue_report, defer: true do
|
|
552
|
+
text expensive_quarterly_rollup # 1.5s of work, off the critical path
|
|
553
|
+
end
|
|
554
|
+
end.run!
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
**Placeholder** — what stands in until the content lands. Three forms:
|
|
558
|
+
|
|
559
|
+
```ruby
|
|
560
|
+
fragment(:stats, defer: true) { ... } # default: small spinner
|
|
561
|
+
fragment(:stats, defer: true, placeholder: 'Loading stats…') { ... } # a String renders as text
|
|
562
|
+
fragment(:stats, defer: true, placeholder: -> { skeleton_rows(5) }) { ... } # a Proc runs as DSL
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
**How it works**: the deferred fragment renders its placeholder wrapped in an element carrying
|
|
566
|
+
`hx-trigger="load"` pointing at the same signed fragment-update endpoint every in-fragment
|
|
567
|
+
interaction already uses. That fetch re-runs the DSL with this fragment marked as the one to
|
|
568
|
+
materialize, so its block executes and only its content comes back over the wire.
|
|
569
|
+
|
|
570
|
+
**Things to know**:
|
|
571
|
+
|
|
572
|
+
- The block is skipped on the shell render, so anything it registers as a side effect (actions,
|
|
573
|
+
timers) is not registered until the fetch. Keep registration outside deferred blocks.
|
|
574
|
+
- **A named-action button (`button 'X', action: :foo`) inside a deferred fragment does not fire.**
|
|
575
|
+
The fetch mints its token outside the session's action manifest, so clicking it re-renders the
|
|
576
|
+
fragment without running the action. Put such buttons in a non-deferred fragment, or use a block
|
|
577
|
+
button (`button('X') { |s| ... }`), which dispatches by id and is unaffected.
|
|
578
|
+
- Current input values are posted with the fetch, so a deferred fragment can read `state` set by
|
|
579
|
+
fields elsewhere on the page.
|
|
580
|
+
- Nesting works and chains: a `defer:` fragment inside a plain fragment loads in place; a
|
|
581
|
+
`defer:` fragment inside another `defer:` fragment loads after its parent does. Because
|
|
582
|
+
every fetch re-runs the DSL, a nested fetch also runs its ancestors' blocks — two nested
|
|
583
|
+
1.5s panels cost 1.5s for the outer fetch and 3s for the inner one. Nest deferred
|
|
584
|
+
fragments for staging, not to divide one expensive block into cheaper pieces.
|
|
585
|
+
- An interaction that swaps the whole app container re-renders the deferred fragment as its
|
|
586
|
+
placeholder, and it fetches again. That keeps the shell fast on every render rather than
|
|
587
|
+
paying the slow cost inline.
|
|
588
|
+
- `defer:` is an option on `fragment`. The bare `defer { ... }` display verb is an unimplemented
|
|
589
|
+
stub that silently drops its block — do not use it.
|
|
590
|
+
|
|
591
|
+
### Lazy Fragments (`fragment ..., lazy: true`)
|
|
592
|
+
|
|
593
|
+
`defer: true` fetches the moment the page loads. Adding `lazy: true` holds that fetch until the
|
|
594
|
+
fragment is **visible** — Turbo's `loading="lazy"`. Visible means visible, whatever caused it:
|
|
595
|
+
scrolled into the viewport, or a CSS rule flipping an ancestor out of `display: none`. It is not
|
|
596
|
+
"scrolled near" and it is not "hovered"; a hover only counts because the hover changed `display`.
|
|
597
|
+
|
|
598
|
+
```ruby
|
|
599
|
+
fragment :revenue_report, lazy: true do
|
|
600
|
+
text expensive_quarterly_rollup # runs the first time this region is seen, never before
|
|
601
|
+
end
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
**`lazy: true` implies `defer: true`** — write both if it reads better, but `lazy:` alone is
|
|
605
|
+
enough. `placeholder:` works the same as on any deferred fragment.
|
|
606
|
+
|
|
607
|
+
**The two guarantees**:
|
|
608
|
+
|
|
609
|
+
- **A CSS-hidden fragment never fetches.** `display: none` means no fetch, for as long as it
|
|
610
|
+
lasts. This is what makes the hover-card recipe below cost nothing on page load.
|
|
611
|
+
- **It fetches exactly once.** The content lands in the DOM and stays there; hiding and
|
|
612
|
+
re-showing the region does not fetch again.
|
|
613
|
+
|
|
614
|
+
**How it works**: the placeholder wrapper carries `hx-trigger="intersect once"` instead of
|
|
615
|
+
`hx-trigger="load"`, which htmx backs with an `IntersectionObserver` — the same browser API Turbo
|
|
616
|
+
uses. (`revealed`, the other htmx visibility trigger, reads `getBoundingClientRect()`, which
|
|
617
|
+
reports an all-zero rect for a hidden element and so counts it as revealed; it would fetch every
|
|
618
|
+
hidden fragment on page load. See `docs/research/2026-08-22-lazy-fragments-trigger-decision.md`.)
|
|
619
|
+
The wrapper also gets a `sw-fragment-lazy` class, which disappears with the wrapper once the
|
|
620
|
+
content lands — so `document.querySelectorAll('.sw-fragment-lazy').length` counts what has not
|
|
621
|
+
fetched yet, which is how you verify a hidden fragment stayed hidden.
|
|
622
|
+
|
|
623
|
+
#### Recipe: hover cards, with no JavaScript
|
|
624
|
+
|
|
625
|
+
CSS reveals the card; revealing it is what fires the fetch. Nothing on the page has fetched
|
|
626
|
+
anything until the pointer arrives.
|
|
627
|
+
|
|
628
|
+
```ruby
|
|
629
|
+
app 'Directory' do
|
|
630
|
+
use_stylesheet <<~CSS
|
|
631
|
+
.hovercard-host { position: relative; display: inline-block; }
|
|
632
|
+
.hovercard-pop { display: none; position: absolute; top: 1.6rem; left: 0; z-index: 20; }
|
|
633
|
+
.hovercard-host:hover .hovercard-pop { display: block; }
|
|
634
|
+
CSS
|
|
635
|
+
|
|
636
|
+
div(class: 'hovercard-host') do
|
|
637
|
+
text 'Ada Lovelace'
|
|
638
|
+
div(class: 'hovercard-pop') do
|
|
639
|
+
fragment :ada_card, lazy: true, placeholder: 'Loading…' do
|
|
640
|
+
text expensive_profile_lookup('ada')
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
end.run!
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
#### Recipe: infinite scroll, as nested fragments
|
|
648
|
+
|
|
649
|
+
**The page the reader is on renders inline; only the *next* page is lazy.** Each response
|
|
650
|
+
carries its own rows plus one placeholder for the page after it, so page N+1 does not exist in
|
|
651
|
+
the DOM until page N has been scrolled past. That is the whole of the pagination logic — no
|
|
652
|
+
scroll handler, no page counter in state.
|
|
653
|
+
|
|
654
|
+
```ruby
|
|
655
|
+
def scroll_page(number)
|
|
656
|
+
# THIS page renders now, in whatever response we're already writing.
|
|
657
|
+
Post.page(number).each { |post| card { text post.title } }
|
|
658
|
+
|
|
659
|
+
# Only the NEXT page is deferred.
|
|
660
|
+
nxt = number + 1
|
|
661
|
+
if Post.page?(nxt)
|
|
662
|
+
fragment :"page_#{nxt}", lazy: true, placeholder: 'Loading…' do
|
|
663
|
+
scroll_page(nxt) # recursion lives INSIDE the lazy fragment
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
app('Feed') { scroll_page(1) }.run!
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
**Do not wrap page `number` itself in the lazy fragment.** A deferred block is skipped on the
|
|
672
|
+
shell render, so `fragment(:"page_#{number}", lazy: true) { rows; recurse }` called with `1`
|
|
673
|
+
serves HTML containing no rows at all — just a placeholder. It *looks* correct only because
|
|
674
|
+
page 1's placeholder sits in the initial viewport, so the IntersectionObserver fires
|
|
675
|
+
immediately; a lazy fragment that is already visible fetches at once, which is precisely what
|
|
676
|
+
hides the mistake. Rendering the current page inline is also what Turbo does, and it is what
|
|
677
|
+
keeps the served HTML honest for crawlers and for a reader with no JavaScript.
|
|
678
|
+
|
|
679
|
+
Nested fragment ids are `parent--child`: page 2's fragment is declared beside page 1's rows so
|
|
680
|
+
its id is `sw-frag-page-2`, and page 3's is declared inside page 2's block, giving
|
|
681
|
+
`sw-frag-page-2--page-3`. Each fetch re-runs its ancestors' blocks (see the deferred nesting
|
|
682
|
+
note above), so keep the per-page work cheap.
|
|
683
|
+
|
|
684
|
+
#### Recipe: a tab panel that loads when you open it
|
|
685
|
+
|
|
686
|
+
Route tabs (`tabs :view, url: true`) render inactive panels as `display: none`, so a lazy
|
|
687
|
+
fragment inside one waits until that tab is selected — and then fetches just itself, without the
|
|
688
|
+
whole-container morph the deprecated `tabs ..., lazy: true` mode used. Put the fragment **inside**
|
|
689
|
+
a `tab` block, never beside one at the top level of the `tabs` block (a non-tab child there shifts
|
|
690
|
+
every panel index):
|
|
691
|
+
|
|
692
|
+
```ruby
|
|
693
|
+
tabs :view, url: true do
|
|
694
|
+
tab('Summary') { text summary_line }
|
|
695
|
+
tab('Revenue') do
|
|
696
|
+
fragment :revenue, lazy: true, placeholder: -> { spinner(label: 'Loading…') } do
|
|
697
|
+
revenue_table
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
**Things to know**:
|
|
704
|
+
|
|
705
|
+
- Everything true of `defer:` is true here: the block is skipped on the shell render, current
|
|
706
|
+
input values ride along with the fetch, a named-action button inside it does not fire (use a
|
|
707
|
+
block button), and a static export runs the block inline.
|
|
708
|
+
- A full-container swap re-renders a lazy fragment back to its placeholder and **re-arms** it —
|
|
709
|
+
but lazily. If it is off-screen or hidden when that happens, it waits, where a plain `defer:`
|
|
710
|
+
fragment would refetch immediately.
|
|
711
|
+
- The wrapper is what the observer watches, so give it something with height. The default spinner
|
|
712
|
+
placeholder has it; a placeholder that renders nothing gives the observer a zero-area target.
|
|
713
|
+
|
|
714
|
+
### Feedback
|
|
715
|
+
```ruby
|
|
716
|
+
alert(variant: :success) { text "Saved!" }
|
|
717
|
+
alert(variant: :error) { text "Failed!" }
|
|
718
|
+
# variants: :info, :success, :warning, :error
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
## Running Modes
|
|
722
|
+
|
|
723
|
+
### Interactive (default)
|
|
724
|
+
```ruby
|
|
725
|
+
app "My App" do
|
|
726
|
+
# ...
|
|
727
|
+
end.run!
|
|
728
|
+
```
|
|
729
|
+
Server runs until Ctrl+C. State persists in session cookies (~4KB limit).
|
|
730
|
+
|
|
731
|
+
**Reset corrupted state:** `ruby my_app.rb --reset` clears the session on first page load.
|
|
732
|
+
|
|
733
|
+
**Debug logging:** `SW_DEBUG=1 ruby my_app.rb` logs every request with cookie size, state size, and SSE connection counts to stderr.
|
|
734
|
+
|
|
735
|
+
### Remote/Mobile Access (Tailscale, LAN)
|
|
736
|
+
|
|
737
|
+
By default, StreamWeaver binds to `127.0.0.1` (localhost only). For access from phones, tablets, or other machines via Tailscale or LAN:
|
|
738
|
+
|
|
739
|
+
```bash
|
|
740
|
+
STREAMWEAVER_HOST=0.0.0.0 STREAMWEAVER_PORT=4580 ruby my_app.rb
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
Or in code:
|
|
744
|
+
```ruby
|
|
745
|
+
app "Dashboard" do
|
|
746
|
+
# ...
|
|
747
|
+
end.run!(host: '0.0.0.0', port: 4580)
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
**Environment variables** (overridden by code options):
|
|
751
|
+
- `STREAMWEAVER_HOST` — bind address (default: `127.0.0.1`)
|
|
752
|
+
- `STREAMWEAVER_PORT` — fixed port (default: auto-detect)
|
|
753
|
+
|
|
754
|
+
**Why fixed port matters for mobile:** Auto-port detection finds a different port each restart, breaking bookmarks. Set a fixed port when you need a stable URL.
|
|
755
|
+
|
|
756
|
+
**Host authorization:** When bound to `0.0.0.0`, StreamWeaver automatically disables Sinatra's host authorization so Tailscale DNS names, LAN IPs, etc. are accepted.
|
|
757
|
+
|
|
758
|
+
### Agentic (one-shot)
|
|
759
|
+
```ruby
|
|
760
|
+
result = app "Survey" do
|
|
761
|
+
text_field :name
|
|
762
|
+
select :priority, ["Low", "High"]
|
|
763
|
+
end.run_once!(auto_close_window: true)
|
|
764
|
+
|
|
765
|
+
puts result # => { "name" => "Alice", "priority" => "High" }
|
|
766
|
+
```
|
|
767
|
+
Blocks until user submits, returns form data as hash, exits.
|
|
768
|
+
|
|
769
|
+
**Important for agentic mode:** Only input component values are returned. State set by button callbacks is NOT included.
|
|
770
|
+
|
|
771
|
+
## Common Patterns
|
|
772
|
+
|
|
773
|
+
### Conditional Display
|
|
774
|
+
```ruby
|
|
775
|
+
app "Login" do
|
|
776
|
+
if state[:logged_in]
|
|
777
|
+
text "Welcome!"
|
|
778
|
+
button "Logout" do |s| s[:logged_in] = false end
|
|
779
|
+
else
|
|
780
|
+
text_field :username
|
|
781
|
+
button "Login" do |s| s[:logged_in] = true end
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
### List with Add/Remove
|
|
787
|
+
```ruby
|
|
788
|
+
app "Todo" do
|
|
789
|
+
text_field :new_item
|
|
790
|
+
|
|
791
|
+
button "Add" do |s|
|
|
792
|
+
s[:items] ||= []
|
|
793
|
+
s[:items] << s[:new_item] if s[:new_item].to_s.strip != ""
|
|
794
|
+
s[:new_item] = ""
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
state[:items]&.each_with_index do |item, i|
|
|
798
|
+
hstack do
|
|
799
|
+
text item
|
|
800
|
+
button "X" do |s| s[:items].delete_at(i) end
|
|
801
|
+
end
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
### Multi-Step Wizard
|
|
807
|
+
```ruby
|
|
808
|
+
app "Wizard" do
|
|
809
|
+
state[:step] ||= 1
|
|
810
|
+
|
|
811
|
+
case state[:step]
|
|
812
|
+
when 1
|
|
813
|
+
text_field :name
|
|
814
|
+
button "Next" do |s| s[:step] = 2 end
|
|
815
|
+
when 2
|
|
816
|
+
select :plan, ["Free", "Pro"]
|
|
817
|
+
button "Back" do |s| s[:step] = 1 end
|
|
818
|
+
button "Submit" do |s| s[:done] = true end
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
text "Done!" if state[:done]
|
|
822
|
+
end
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
## App Options
|
|
826
|
+
|
|
827
|
+
```ruby
|
|
828
|
+
app "Title",
|
|
829
|
+
layout: :wide, # :default, :wide, :full, :fluid
|
|
830
|
+
theme: :dashboard, # :default, :dashboard, :document, :doc (artifact-exact editorial; supports dark via data-sw-theme="dark" on <html>)
|
|
831
|
+
strict_ids: true # raise (dev) / warn (prod) on derived-id collisions -- see "Interactive IDs and keying"
|
|
832
|
+
do
|
|
833
|
+
# ...
|
|
834
|
+
end
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
## Session Persistence
|
|
838
|
+
|
|
839
|
+
State persists in browser cookies across server restarts. To reset:
|
|
840
|
+
- Clear browser cookies
|
|
841
|
+
- Use incognito mode
|
|
842
|
+
- Add: `button "Reset" do |s| s.clear end`
|
|
843
|
+
|
|
844
|
+
---
|
|
845
|
+
|
|
846
|
+
## Canvas Mode (Side-by-Side Panels)
|
|
847
|
+
|
|
848
|
+
Canvas mode lets scripts display rich interactive UI in a browser panel alongside the terminal.
|
|
849
|
+
Useful for presenting forms, selections, or analysis results that are hard to show in terminal.
|
|
850
|
+
|
|
851
|
+
### Quick Example (Shell Script)
|
|
852
|
+
|
|
853
|
+
```bash
|
|
854
|
+
#!/bin/bash
|
|
855
|
+
SESSION="my-panel-$$"
|
|
856
|
+
|
|
857
|
+
# Open panel in iTerm2 split pane
|
|
858
|
+
streamweaver panel "$SESSION"
|
|
859
|
+
|
|
860
|
+
# Push UI content
|
|
861
|
+
streamweaver canvas-push "$SESSION" <<'RUBY'
|
|
862
|
+
header1 "Select Options"
|
|
863
|
+
checkbox :option_a, "Enable feature A"
|
|
864
|
+
checkbox :option_b, "Enable feature B"
|
|
865
|
+
radio_group :mode, ["Fast", "Safe", "Balanced"]
|
|
866
|
+
button "Apply"
|
|
867
|
+
RUBY
|
|
868
|
+
|
|
869
|
+
# Wait for button click, get JSON result
|
|
870
|
+
result=$(streamweaver canvas-wait "$SESSION")
|
|
871
|
+
echo "User selected: $result"
|
|
872
|
+
|
|
873
|
+
# Cleanup
|
|
874
|
+
streamweaver canvas-close "$SESSION"
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
### Key Commands
|
|
878
|
+
|
|
879
|
+
| Command | Purpose |
|
|
880
|
+
|---------|---------|
|
|
881
|
+
| `streamweaver panel SESSION [--layout=LAYOUT] [--theme=THEME] [--fresh]` | Open canvas in iTerm2 split pane |
|
|
882
|
+
| `streamweaver canvas-push SESSION` | Push DSL content (stdin or `<<'RUBY'`) |
|
|
883
|
+
| `streamweaver canvas-wait SESSION [--event TYPE\|--any]` | Wait for a control to dispatch, return JSON (see the control table below) |
|
|
884
|
+
| `streamweaver canvas-close SESSION` | Close the canvas session |
|
|
885
|
+
| `streamweaver canvas-read FILE [FILE\|DIR ...]` | Browse saved canvas DSL files in a local viewer (sidebar + prev/next nav) |
|
|
886
|
+
| `streamweaver export FILE.rb [-o OUT.html] [--inline-images] [--offline]` | Write a canvas DSL doc out as standalone HTML |
|
|
887
|
+
|
|
888
|
+
`panel` flags: `--layout=` is one of `default`/`wide`/`full`/`fluid` (default `fluid`). `--theme=` selects any registered theme for the session (default `:default`); e.g. `--theme=doc` renders the canvas in the `:doc` theme, including its dark-mode variant. `--fresh` closes any existing session with the same name before creating a new one.
|
|
889
|
+
|
|
890
|
+
`export` flags: `--inline-images` embeds local images as base64 data URIs so the file is fully portable. `--offline` fetches and inlines mermaid's own library (instead of referencing its CDN) so diagrams render in a viewer whose CSP blocks external scripts entirely — SharePoint's built-in HTML preview is the motivating case; needs network access once, at export time, not at view time. Same capability from `canvas-read`'s nav rail: "⬇ Export HTML" on every file, plus a second "⬇ Export HTML (offline)" link shown only on docs with a mermaid diagram.
|
|
891
|
+
|
|
892
|
+
### Which canvas controls wake `canvas-wait`
|
|
893
|
+
|
|
894
|
+
| Control | Canvas event | Payload |
|
|
895
|
+
|---|---|---|
|
|
896
|
+
| `button` | `action` | `button`, `state` |
|
|
897
|
+
| `clickable(action:)` | `action` | `button` (the action token), `state` |
|
|
898
|
+
| `menu_item` with a block | `action` | `button` (the item id), `state` |
|
|
899
|
+
| `form` submit | `action` | `button`, `form`, `values` (the form scope), `state` |
|
|
900
|
+
| `radio_group`, `tag_buttons`, `chip_group` | `change` | `field`, `value`, `state` |
|
|
901
|
+
|
|
902
|
+
Text, date, select and checkbox fields dispatch no canvas event of their own -- their values ride along in the `state` of whichever `action` follows. `tag_buttons` is the exception among the `change` controls: it has no `x-model`, so its choice reaches the agent in its own `change` event but is *not* in a later `action`'s `state`.
|
|
903
|
+
|
|
904
|
+
### Important Behavior
|
|
905
|
+
|
|
906
|
+
- **canvas-wait returns on `action` events by default** - see the control table above; the `change` events from `radio_group`, `tag_buttons` and `chip_group` return only with `--event change` or `--any`
|
|
907
|
+
- **Form state is captured** - JSON includes all checkbox, radio, text field values
|
|
908
|
+
- **Use heredoc for DSL** - `<<'RUBY'` prevents shell interpolation issues
|
|
909
|
+
|
|
910
|
+
### Canvas DSL
|
|
911
|
+
|
|
912
|
+
Same components as regular StreamWeaver apps:
|
|
913
|
+
```ruby
|
|
914
|
+
header1 "Title"
|
|
915
|
+
md "**Bold** and `code` work"
|
|
916
|
+
card do
|
|
917
|
+
checkbox :agree, "I agree to **terms**" # Markdown in labels
|
|
918
|
+
end
|
|
919
|
+
columns widths: ['50%', '50%'] do
|
|
920
|
+
column { radio_group :choice, ["A", "B"] }
|
|
921
|
+
column { checkbox :extra, "Extra option" }
|
|
922
|
+
end
|
|
923
|
+
button "Submit" # Returns JSON when clicked
|
|
924
|
+
|
|
925
|
+
# Mermaid diagrams work in canvas-push too
|
|
926
|
+
mermaid "graph LR; A-->B", zoom: true
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
---
|
|
930
|
+
|
|
931
|
+
## Canvas Persistence (Two-Tier)
|
|
932
|
+
|
|
933
|
+
Every `canvas-push` is auto-saved. The user can promote a snapshot to a permanent doc with one click. Both tiers Just Work — you don't need to ask, you don't need to drive a save.
|
|
934
|
+
|
|
935
|
+
| Tier | Path | Origin | Git? |
|
|
936
|
+
|---|---|---|---|
|
|
937
|
+
| 1 (history) | `~/.streamweaver/history/<session>/<YYYYMMDD_HHMMSS>.rb` | every `canvas-push`, automatic, 7-day cleanup | NO |
|
|
938
|
+
| 2 (docs) | `<git_root>/docs/streamweaver_canvas/<name>.rb` | floating 💾 button in canvas, or curl POST | YES (intentional) |
|
|
939
|
+
|
|
940
|
+
**For Claude:** if the user says "save this canvas as X", point at the floating Save-as-doc button (bottom-right of the canvas) — they click it, name it, done. Don't reinvent the save flow. As a fallback for non-canvas contexts:
|
|
941
|
+
|
|
942
|
+
```bash
|
|
943
|
+
curl -sX POST "http://localhost:<bridge-port>/canvas/<session>/save-doc" \
|
|
944
|
+
-H 'Content-Type: application/json' \
|
|
945
|
+
-d '{"name":"<doc-name>"}'
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
**Browsing saved docs:** `streamweaver canvas-read` with no arguments opens the project's `docs/streamweaver_canvas/` directory in a viewer with two sidebar sections:
|
|
949
|
+
- **Docs** (expanded) — explicit saves
|
|
950
|
+
- **History** (collapsed by default) — every auto-saved snapshot, browseable retroactively
|
|
951
|
+
|
|
952
|
+
History entries also show a 💾 button so the user can promote any past snapshot to a doc without re-pushing.
|
|
953
|
+
|
|
954
|
+
The reader also discovers docs roots across the whole machine (scan of `$STREAMWEAVER_DOCS_SCAN_ROOTS`, default `~/work`, unioned with an auto-written registry at `~/.streamweaver/docs_roots.log`), grouped by repo with a repo filter — and it renders StreamWeaver-flavored `.org` docs (`#+STREAMWEAVER_DSL:` marker) everywhere `.rb` docs work.
|
|
955
|
+
|
|
956
|
+
**`.rb` vs `.org` when saving** (`"format":"org"` in the POST body, or the dialog's Org toggle): `.rb` is the full-fidelity format and keeps everything. `.org` is a document — it leaves out live controls (`button`, `radio_group`, `text_field`, `form`, ...), one `#+STREAMWEAVER_OMITTED: <call>` keyword line each, reported as `omitted` in the response's `coverage` alongside `recognized`/`passthrough_verbatim`/`passthrough_lossy`. Saving a session that carries a form is therefore fine in both formats; only `.rb` brings the form back.
|
|
957
|
+
|
|
958
|
+
## Agent Skills (Install These Before Hand-Rolling the Above)
|
|
959
|
+
|
|
960
|
+
If you are Claude Code, Codex CLI, Gemini CLI, GitHub Copilot, or another agent that uses the `SKILL.md` convention, StreamWeaver ships three skills that already encode the workflows in this doc as trained procedure — check whether they're loaded before building UI from this doc's raw examples:
|
|
961
|
+
|
|
962
|
+
- **streamweaver-way** — for building or changing an *interactive* app or feature: inline editing, live search, per-row buttons, hover cards, infinite scroll, or any region slow enough to hold up the page. Encodes the six laws above plus the four benchmark recipes, with the gotchas that actually bite.
|
|
963
|
+
- **streamweaver-visual-companion** — the default choice for mockups, diagrams, layout comparisons, and brainstorming, any time you'd otherwise reach for a chrome-based visual companion or a claude.ai Artifact. Canvas-push is 5-7x cheaper in tokens and never leaves the terminal loop.
|
|
964
|
+
- **streamweaver-doc-builder** — for long-form editorial documents (PRDs, reports, explainers) using the `:doc` theme, proven to reach 1:1 visual parity with a real claude.ai Artifact.
|
|
965
|
+
|
|
966
|
+
**If those skills don't appear in your available-skills list, ask the user whether to install them** — it's one idempotent command that installs to both Claude Code's own path and the `.agents/skills/` cross-tool alias that Codex CLI, Gemini CLI, and GitHub Copilot all read natively (see `docs/reference/agent-skills-comparison.md` for how each tool discovers skills):
|
|
967
|
+
|
|
968
|
+
```bash
|
|
969
|
+
streamweaver setup # global: ~/.claude/skills/ + ~/.agents/skills/ + bash permission (recommended)
|
|
970
|
+
streamweaver install-skill # project-local instead: .claude/skills/ + .agents/skills/ (no --global)
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
## Hotwire-Style Navigation Pattern
|
|
974
|
+
|
|
975
|
+
Layouts with sidebars (or any persistent client-side UI) MUST preserve state across navigation. Full page reloads collapse accordions, scroll-jump, and reset client state. The pattern, on the canvas-read layout, is the reference implementation:
|
|
976
|
+
|
|
977
|
+
```erb
|
|
978
|
+
<body hx-boost="true"
|
|
979
|
+
hx-target="#content"
|
|
980
|
+
hx-select="#content"
|
|
981
|
+
hx-select-oob="#nav"
|
|
982
|
+
hx-swap="innerHTML">
|
|
983
|
+
<nav id="sidebar"> <!-- preserved across navigations --> </nav>
|
|
984
|
+
<div id="main">
|
|
985
|
+
<div id="content"> <%= @content_html %> </div>
|
|
986
|
+
<div id="nav"> <!-- prev/next/counter, OOB-swapped --> </div>
|
|
987
|
+
</div>
|
|
988
|
+
</body>
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
Rules for any new layout following this pattern:
|
|
992
|
+
|
|
993
|
+
1. **Navigation links must be `<a href>`** — `hx-boost` only intercepts anchor clicks. NEVER `<button onclick="location.href=...">`.
|
|
994
|
+
2. **Disabled state renders as `<span>`**, not `<a>` — anchors don't have a real disabled state.
|
|
995
|
+
3. **Keyboard shortcuts call `.click()` on the existing anchor** rather than mutating `location.href`.
|
|
996
|
+
4. **Read current state from the URL at click time**, not from server-baked JS variables — after htmx swaps the page hasn't reloaded; baked variables are stale.
|
|
997
|
+
5. **Update active-link highlights via `htmx:afterSwap` listener** that reads the URL and toggles `.active` (the sidebar isn't re-rendered, so the server-rendered class is stale).
|
|
998
|
+
|
|
999
|
+
Reference: `lib/stream_weaver/views/canvas/reader_layout.erb`.
|
|
1000
|
+
|
|
1001
|
+
**Future direction:** once Idiomorph (bd `stream_weaver-2ds`) lands, the OOB and active-class boilerplate goes away — morph diffs the rendered HTML and surgically updates only what changed, including class attributes. See `docs/streamweaver-frontend-vision.md` for the full thesis.
|
|
1002
|
+
|
|
1003
|
+
## Dev Loud, Prod Self-Heal (Stale Action Fallback)
|
|
1004
|
+
|
|
1005
|
+
Every named action's token embeds a digest of the app's registered actions
|
|
1006
|
+
plus a per-session generation. When a request arrives whose token no longer
|
|
1007
|
+
matches either (`lib/stream_weaver/interaction_runner.rb`'s
|
|
1008
|
+
`StaleActionDefinition`) — most commonly because the dev server hot-reloaded
|
|
1009
|
+
code and the browser is still holding a token minted before the reload —
|
|
1010
|
+
`lib/stream_weaver/server.rb`'s `/action/:button_id` route responds `409` +
|
|
1011
|
+
`HX-Retarget: #app-container` and re-renders the whole app. **This
|
|
1012
|
+
self-healing full-container swap is unconditional and stays byte-for-byte
|
|
1013
|
+
identical in production**: a user never sees an error, the app just
|
|
1014
|
+
silently recovers.
|
|
1015
|
+
|
|
1016
|
+
This is a **deliberate inversion of Hotwire's philosophy**. Turbo Frames
|
|
1017
|
+
treat a missing/stale frame target as "content missing" and surface it loud
|
|
1018
|
+
by design — Rails' own course material calls this Turbo's single best
|
|
1019
|
+
debugging aid, because a silent frame swap during development hides the
|
|
1020
|
+
exact wiring bug the author needs to see. StreamWeaver keeps Hotwire's
|
|
1021
|
+
signal without inheriting its user-facing cost: **in development only**
|
|
1022
|
+
(`RACK_ENV` not `production`/`test` — see `sw_dev_mode?` in server.rb), the
|
|
1023
|
+
same 409 response additionally prepends a dismissible `.sw-dev-fallback`
|
|
1024
|
+
overlay (`lib/stream_weaver/dev_fallback_overlay.rb`) naming:
|
|
1025
|
+
|
|
1026
|
+
- the stale target — the scoped fragment id the token declared (or the
|
|
1027
|
+
action name, when no fragment was declared)
|
|
1028
|
+
- the likely cause — action token stale after a code reload (definitions
|
|
1029
|
+
changed) or a server restart/session reset (generation changed)
|
|
1030
|
+
|
|
1031
|
+
The overlay is framework-emitted HTML only — a checkbox+label CSS pattern
|
|
1032
|
+
dismisses it without a page reload, no inline `onclick`, no script tag, so
|
|
1033
|
+
app code stays zero-JS. Default styling comes from `--sw-dev-fallback-*`
|
|
1034
|
+
custom properties (`CSS.dev_fallback_css`); restyle via the `.sw-dev-fallback`
|
|
1035
|
+
class hook the same way any other framework CSS is overridden.
|
|
1036
|
+
|
|
1037
|
+
**Decision note — only one loud-failure cause is reachable through this
|
|
1038
|
+
path.** The 409 route only ever raises `StaleActionDefinition` for a
|
|
1039
|
+
digest/generation mismatch (a stale token). A separately-missing fragment
|
|
1040
|
+
target (e.g. `find_fragment` returning nil after a conditional re-render
|
|
1041
|
+
removed it) is a *different*, non-exceptional code path inside
|
|
1042
|
+
`InteractionRunner#scoped_response` — it already falls back to the same
|
|
1043
|
+
`HX-Retarget: #app-container` swap, but at `200`, not `409`, and isn't
|
|
1044
|
+
raised as an exception at all. That branch is out of scope for this
|
|
1045
|
+
overlay; only "stale action token" is distinguishable at the 409 rescue
|
|
1046
|
+
site today.
|
|
1047
|
+
|
|
1048
|
+
## Repo Conventions (Anti-Patterns That Cause Churn)
|
|
1049
|
+
|
|
1050
|
+
Common failure modes when LLMs build StreamWeaver apps. Avoid these.
|
|
1051
|
+
|
|
1052
|
+
- **Don't launch new servers per visual question.** Use `streamweaver canvas-push` to update a single persistent canvas. Multiple `streamweaver run app.rb` invocations create orphaned processes and port conflicts.
|
|
1053
|
+
- **Don't write Python helper scripts.** `streamweaver panel <session>` already detects iTerm vs. other terminals and opens the right thing. Ruby-first; iTerm2 control via `~/work/iterm2_ruby` if needed at all.
|
|
1054
|
+
- **Don't search `~`.** It's 2/3 of the home disk. Target `~/work/<project>` or use `uregistry locate <name>`.
|
|
1055
|
+
- **Don't `text "**markdown**"`.** `text` does not render markdown — use `md`.
|
|
1056
|
+
- **Don't use `spacer` or `divider`.** They don't exist — use `div(style: "height:Npx")`.
|
|
1057
|
+
- **Don't `<button onclick="location.href=...">` for navigation.** Bypasses htmx, full page reload, loses sidebar state. Use `<a href>` so `hx-boost` intercepts.
|
|
1058
|
+
- **Don't use positional URL indexes** like `?file=2` for shareable resources. Use Rails-style resource URLs (`/docs/:name`, `/history/:session/:timestamp`) — see `route_by` and `resource` (above) and bd `stream_weaver-9ei` for the canvas-read migration.
|
|
1059
|
+
- **Don't pass `theme: :light`.** Unrecognized — defaults to `:default`, just omit.
|
|
1060
|
+
- **Don't assume port 4567.** StreamWeaver finds a free port, may be 4568+. Read the actual URL from stdout or `streamweaver canvas-list`.
|
|
1061
|
+
|
|
1062
|
+
## Detailed Documentation
|
|
1063
|
+
|
|
1064
|
+
For comprehensive reference, see these docs:
|
|
1065
|
+
|
|
1066
|
+
| Document | Contents |
|
|
1067
|
+
|----------|----------|
|
|
1068
|
+
| `docs/components_reference.md` | Full component API (all options, variants) |
|
|
1069
|
+
| `docs/frontend-only.md` | What survives `canvas-read` and `streamweaver export` (no backend) vs. the live canvas — read before building a doc meant to be exported or browsed read-only |
|
|
1070
|
+
| `docs/SERVICE_MODE.md` | Multi-app service mode, CLI commands |
|
|
1071
|
+
| `docs/testing.md` | Unit and integration testing |
|
|
1072
|
+
| `docs/architecture/how_streamweaver_works.md` | Internal architecture, event flow |
|
|
1073
|
+
| `docs/routing.md` | URL routing: `route_by`, `route_with`, parameterized paths, edit routes — **read the Common Pitfalls section before building any app with more than a couple of routes**: `route_with` merges GET state rather than replacing it, and an incomplete `case`/`when` route table fails silently on both the parse and build sides |
|
|
1074
|
+
| `docs/crud-patterns.md` | CRUD: create/edit/delete modals, URL-addressable edit pages, store adapters |
|
|
1075
|
+
| `docs/streamweaver-frontend-vision.md` | Strategic direction: htmx + Alpine + Idiomorph as a Hotwire-equivalent stack for Ruby devs |
|
|
1076
|
+
| `docs/porting-artifacts.md` | Porting a claude.ai Artifact into StreamWeaver at visual parity: dissect, gap-assess, port, verify, promote |
|
|
1077
|
+
| `docs/shared-dsl-fragments.md` | Two or more docs for different audiences sharing ONE canonical copy of a table via an `instance_eval`'d fragment file, so they cannot drift — includes the per-mode `__dir__` truth table (export and canvas-read pass a filename to `instance_eval`; org-export and the canvas-push bridge do not) |
|
|
1078
|
+
| `docs/tutorials/the-streamweaver-way.md` | **Tutorial** — "Build My Todos: the StreamWeaver Way": the four benchmark features rebuilt step by step, each naming its law, its gotcha, and its measured number. The narrated twin of the `streamweaver-way` skill |
|
|
1079
|
+
|
|
1080
|
+
## Live Streaming with `every` Timers
|
|
1081
|
+
|
|
1082
|
+
Push live updates to the browser via SSE (Server-Sent Events). No polling, no WebSockets — just `every(seconds)`.
|
|
1083
|
+
|
|
1084
|
+
### Periodic Timers
|
|
1085
|
+
|
|
1086
|
+
```ruby
|
|
1087
|
+
app "Monitor", theme: :dark do
|
|
1088
|
+
div id: "status" do
|
|
1089
|
+
text "Waiting..."
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
every(3) do |streamer|
|
|
1093
|
+
streamer.replace("#status") do
|
|
1094
|
+
div id: "status" do
|
|
1095
|
+
text "Updated at #{Time.now.strftime('%H:%M:%S')}"
|
|
1096
|
+
end
|
|
1097
|
+
end
|
|
1098
|
+
end
|
|
1099
|
+
end.run!
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
The `every` block receives a `streamer` object with these SSE actions:
|
|
1103
|
+
|
|
1104
|
+
| Method | Purpose |
|
|
1105
|
+
|--------|---------|
|
|
1106
|
+
| `streamer.replace(selector) { ... }` | Replace element innerHTML |
|
|
1107
|
+
| `streamer.append(selector) { ... }` | Append to element |
|
|
1108
|
+
| `streamer.prepend(selector) { ... }` | Prepend to element |
|
|
1109
|
+
| `streamer.remove(selector)` | Remove element from DOM |
|
|
1110
|
+
| `streamer.add_class(selector, class)` | Add CSS class |
|
|
1111
|
+
| `streamer.remove_class(selector, class)` | Remove CSS class |
|
|
1112
|
+
|
|
1113
|
+
All methods accept either a block (renders DSL components) or a raw HTML string.
|
|
1114
|
+
|
|
1115
|
+
### Multiple Timers with Shared State
|
|
1116
|
+
|
|
1117
|
+
Closure variables let timers share state naturally:
|
|
1118
|
+
|
|
1119
|
+
```ruby
|
|
1120
|
+
app "Dashboard", theme: :dark do
|
|
1121
|
+
div id: "metric" do
|
|
1122
|
+
stat_display value: "\u2014", label: "CPU", color: :blue
|
|
1123
|
+
end
|
|
1124
|
+
div id: "alert-css", style: "display:none"
|
|
1125
|
+
|
|
1126
|
+
latest = { cpu: 0.0 }
|
|
1127
|
+
|
|
1128
|
+
every(3) do |streamer|
|
|
1129
|
+
latest[:cpu] = `sysctl -n vm.loadavg`.scan(/[\d.]+/)[0].to_f
|
|
1130
|
+
|
|
1131
|
+
streamer.replace("#metric") do
|
|
1132
|
+
div id: "metric" do
|
|
1133
|
+
stat_display value: latest[:cpu].to_s, label: "CPU", color: :green
|
|
1134
|
+
end
|
|
1135
|
+
end
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
every(5) do |streamer|
|
|
1139
|
+
# Idempotent CSS injection via replace (not append — avoids duplicates)
|
|
1140
|
+
streamer.replace("#alert-css", "<style>.alert-ring{box-shadow:0 0 0 3px red}</style>")
|
|
1141
|
+
streamer.add_class("#card-cpu", "alert-ring") if latest[:cpu] > 4.0
|
|
1142
|
+
end
|
|
1143
|
+
end.run!
|
|
1144
|
+
```
|
|
1145
|
+
|
|
1146
|
+
### Key Patterns
|
|
1147
|
+
|
|
1148
|
+
- **Target elements by `id:`** — use `div id: "my-target"` in initial render, then `streamer.replace("#my-target")`
|
|
1149
|
+
- **Re-include `id:` inside replace blocks** — the id must survive replacement
|
|
1150
|
+
- **CSS injection via `replace`** — use a hidden placeholder div, not `append("head")` which duplicates on every tick
|
|
1151
|
+
- **Always re-apply `add_class`** — new SSE connections miss past class changes, so re-apply each tick
|
|
1152
|
+
- **`expandable_card key: :foo` generates `id="card-foo"`** — target it with `#card-foo` in SSE actions
|
|
1153
|
+
|
|
1154
|
+
### CRITICAL: Accessing `state` Inside `streamer.replace` Blocks
|
|
1155
|
+
|
|
1156
|
+
**The problem:** `streamer.replace("#target") { ... }` evaluates its block in a `FeedBuilder` context, NOT in the App context. This means `state` — which is a method on the App — is **not accessible** inside the block. This will raise `NameError: undefined local variable or method 'state'` at runtime (not at startup).
|
|
1157
|
+
|
|
1158
|
+
```ruby
|
|
1159
|
+
# WRONG — `state` is not available inside replace blocks
|
|
1160
|
+
every(30) do |streamer|
|
|
1161
|
+
streamer.replace("#my-panel") do
|
|
1162
|
+
text "Filter: #{state[:filter]}" # NameError: undefined method `state'
|
|
1163
|
+
end
|
|
1164
|
+
end
|
|
1165
|
+
|
|
1166
|
+
# CORRECT — pass state: explicitly
|
|
1167
|
+
every(30) do |streamer|
|
|
1168
|
+
streamer.replace("#my-panel", state: state) do
|
|
1169
|
+
text "Filter: #{state[:filter]}" # works
|
|
1170
|
+
end
|
|
1171
|
+
end
|
|
1172
|
+
```
|
|
1173
|
+
|
|
1174
|
+
The same applies to `streamer.append` and `streamer.prepend`.
|
|
1175
|
+
|
|
1176
|
+
### CRITICAL: Helper Methods Inside `streamer.replace` Blocks
|
|
1177
|
+
|
|
1178
|
+
Top-level `def` helper methods work inside `streamer.replace` blocks (they're globally accessible). However, if those helpers call `state`, the `state:` keyword must be passed to `replace` — not to the helper itself.
|
|
1179
|
+
|
|
1180
|
+
```ruby
|
|
1181
|
+
def render_summary(data:, state:)
|
|
1182
|
+
card do
|
|
1183
|
+
text "Mode: #{state[:mode]}" # state passed as arg — fine
|
|
1184
|
+
stat_display value: data[:count], label: "Items"
|
|
1185
|
+
end
|
|
1186
|
+
end
|
|
1187
|
+
|
|
1188
|
+
app "Dashboard" do
|
|
1189
|
+
div id: "summary" do
|
|
1190
|
+
render_summary(data: load_data, state: state)
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
every(10) do |streamer|
|
|
1194
|
+
# Pass state: to replace so FeedBuilder exposes it
|
|
1195
|
+
streamer.replace("#summary", state: state) do
|
|
1196
|
+
render_summary(data: load_data, state: state)
|
|
1197
|
+
end
|
|
1198
|
+
end
|
|
1199
|
+
end.run!
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
**Why this matters:** The error appears in the timer thread log (`[StreamWeaver] Timer error: NameError`), not on page load. It's easy to miss during development if the timer interval is long.
|
|
1203
|
+
|
|
1204
|
+
### Timer Examples
|
|
1205
|
+
|
|
1206
|
+
| File | Description |
|
|
1207
|
+
|------|-------------|
|
|
1208
|
+
| `examples/timer_showcase.rb` | Live CPU/Memory/Disk with spotlight effects and alert feed |
|
|
1209
|
+
| `examples/timer_health_checker.rb` | HTTP endpoint health monitoring with SLA badges |
|
|
1210
|
+
|
|
1211
|
+
## Architecture Summary
|
|
1212
|
+
|
|
1213
|
+
- **Backend**: Sinatra server, Phlex for HTML
|
|
1214
|
+
- **Frontend**: Alpine.js (reactivity) + HTMX (server communication)
|
|
1215
|
+
- **State**: Server-side, persisted in session cookies
|
|
1216
|
+
- **Updates**: HTMX swaps `#app-container` innerHTML on state changes; SSE pushes for `every` timers
|
|
1217
|
+
|
|
1218
|
+
## Common Mistakes
|
|
1219
|
+
|
|
1220
|
+
1. **Forgetting state is a hash** - Use `state[:key]`, not `state.key`
|
|
1221
|
+
2. **Assuming port 4567, or booting a second instance to "find" the port** - Read the already-running process's stdout for the actual port; a second launch just starts an orphaned duplicate on the next free one
|
|
1222
|
+
3. **Missing `.run!`** - App won't start without it
|
|
1223
|
+
4. **Agentic mode button state** - Only input values returned, not button-set state
|
|
1224
|
+
5. **Wrong checkbox signature** - `checkbox :key, "label"` (key first, then label). NOT `checkbox "label", value: x`
|
|
1225
|
+
6. **Using checkbox inside checkbox_group** - Use `item value do...end`, NOT `checkbox`. checkbox_group has its own DSL.
|
|
1226
|
+
7. **Putting text/headers inside checkbox_group** - ONLY `item` elements allowed directly inside `checkbox_group`. Put section headers OUTSIDE the checkbox_group block.
|
|
1227
|
+
8. **Expecting form-like behavior by default** - ALL inputs auto-submit on change. For multi-select lists where user picks several items then clicks an action button, use `submit: false` on each checkbox. For traditional forms, use the `form` block.
|
|
1228
|
+
9. **Using buttons to simulate selection** - Don't set `state[:x] = index` in button callbacks to track which option is selected. Use `select :key, choices, submit: false` or `radio_group :key, choices` instead. Buttons are for actions, not selection state.
|
|
1229
|
+
10. **Storing array indices in state** - Indices break when lists change. Store the string name/label and resolve to index at action time.
|
|
1230
|
+
11. **Long lists without scroll** - Use `scroll_box(max_height: "400px") do...end` to constrain list height. See `docs/form-patterns.md` for all canonical patterns.
|
|
1231
|
+
|
|
1232
|
+
---
|
|
1233
|
+
|
|
1234
|
+
## Opal Mode — Static Browser Apps
|
|
1235
|
+
|
|
1236
|
+
StreamWeaver apps can be compiled to static JavaScript and deployed without a server via `streamweaver opal-build`.
|
|
1237
|
+
|
|
1238
|
+
### Build command
|
|
1239
|
+
|
|
1240
|
+
```bash
|
|
1241
|
+
streamweaver opal-build app.rb # outputs to dist/
|
|
1242
|
+
streamweaver opal-build app.rb --output /my/dir
|
|
1243
|
+
```
|
|
1244
|
+
|
|
1245
|
+
Produces `dist/index.html` + `dist/app.js` + `dist/morphdom.min.js`. Deploy `dist/` to GitHub Pages or any static host.
|
|
1246
|
+
|
|
1247
|
+
### What works in Opal mode
|
|
1248
|
+
|
|
1249
|
+
- `app`, `header`, `text_field`, `checkbox`, `button`, `md`, `div` — all Phase 1 components
|
|
1250
|
+
- State is fully in-browser (no server round-trips)
|
|
1251
|
+
- Button callbacks via data attributes + delegated event listeners
|
|
1252
|
+
- Reactivity: whole-block re-execution + morphdom DOM patching
|
|
1253
|
+
|
|
1254
|
+
### What doesn't work in Opal mode (Phase 1)
|
|
1255
|
+
|
|
1256
|
+
- `feed`, `streamer`, `service_client` — require a server by definition
|
|
1257
|
+
- `form_block` — deferred
|
|
1258
|
+
- `chartjs`, `tabs`, `table` — not yet implemented in Adapter::Opal
|
|
1259
|
+
|
|
1260
|
+
### Key files
|
|
1261
|
+
|
|
1262
|
+
| File | Role |
|
|
1263
|
+
|---|---|
|
|
1264
|
+
| `lib/stream_weaver/opal_entry.rb` | Browser-only require tree |
|
|
1265
|
+
| `lib/stream_weaver/opal/renderer.rb` | String-accumulating view (replaces Phlex) |
|
|
1266
|
+
| `lib/stream_weaver/opal/runtime.rb` | State + callback registry |
|
|
1267
|
+
| `lib/stream_weaver/opal/bridge.rb` | Wires Ruby to window.SWRuntime |
|
|
1268
|
+
| `lib/stream_weaver/opal/builder.rb` | Opal compilation pipeline |
|
|
1269
|
+
| `lib/stream_weaver/adapter/opal.rb` | Render methods for browser |
|