stream_weaver 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.beads/.gitignore +77 -0
- data/.beads/README.md +81 -0
- data/.beads/config.yaml +54 -0
- data/.beads/metadata.json +7 -0
- data/.rspec +3 -0
- data/AGENTS.md +91 -0
- data/CHANGELOG.md +294 -0
- data/CLAUDE.md +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +565 -0
- data/Rakefile +13 -0
- data/assets/streamweaver-hero.jpg +0 -0
- data/bench/baselines/ledger.rb +58 -0
- data/bench/baselines/warroom.rb +51 -0
- data/bench/fixtures/ledger.rb +76 -0
- data/bench/fixtures/warroom.rb +60 -0
- data/bench/results/6daf8b8.md +102 -0
- data/bench/results/c8d749b.md +102 -0
- data/bench/results/eb06ab9.md +102 -0
- data/bench/run.rb +151 -0
- data/bench/support.rb +84 -0
- data/copy_this/dto_run_sample_run_7631.html +220 -0
- data/docs/SERVICE_MODE.md +302 -0
- data/docs/architecture/how_streamweaver_works.md +772 -0
- data/docs/blog/2026-01-16-streamweaver-introduction.md +515 -0
- data/docs/bug-2026-08-31-canvas-bridge-socket-collision.md +83 -0
- data/docs/canvas-ipc-session-summary.md +127 -0
- data/docs/canvas-panel-workflow.md +172 -0
- data/docs/canvas-read.md +147 -0
- data/docs/canvas-roadmap.md +275 -0
- data/docs/case-studies/2026-09-03-didx-canvas-read-shelf.md +147 -0
- data/docs/claude-code-companion-skill-spec.md +567 -0
- data/docs/components_reference.md +1223 -0
- data/docs/crud-patterns.md +265 -0
- data/docs/endpoints.md +122 -0
- data/docs/for_llms.md +1 -0
- data/docs/form-patterns.md +165 -0
- data/docs/frontend-only.md +169 -0
- data/docs/html-artifact-audit.md +238 -0
- data/docs/ideas/2025-01-01-as-a-service.md +203 -0
- data/docs/ideas/2026-01-16-charm-tui-exploration.md +542 -0
- data/docs/ideas/2026-08-27-static-doc-shelf-export.md +153 -0
- data/docs/ideas/dhh-review-future-refactors.md +142 -0
- data/docs/opal-jamstack.md +47 -0
- data/docs/opal-npm-direction.md +184 -0
- data/docs/opal-spike-findings.md +194 -0
- data/docs/plans/2025-12-16-form-blocks-design.md +124 -0
- data/docs/plans/2025-12-30-examples-browser-design.md +152 -0
- data/docs/plans/2026-01-02-charts-design.md +288 -0
- data/docs/plans/2026-01-10-canvas-ipc-design.md +247 -0
- data/docs/plans/2026-01-19-cabinet-control-components.md +176 -0
- data/docs/plans/2026-01-26-gem-release-and-panel.md +140 -0
- data/docs/plans/canvas-claude-project-design.md +238 -0
- data/docs/plans/canvas-doc-location-and-discovery.md +32 -0
- data/docs/plans/org-doc-preview-surfaces.md +293 -0
- data/docs/plans/shadcn-polish-plan.md +417 -0
- data/docs/porting-artifacts.md +252 -0
- data/docs/reference/agent-skills-comparison.md +369 -0
- data/docs/reference/travel-state-prd.artifact.html +780 -0
- data/docs/research/2026-08-17-hotwire-alike-landscape.md +268 -0
- data/docs/research/2026-08-17-hotwire-concept-map.md +404 -0
- data/docs/research/2026-08-22-lazy-fragments-trigger-decision.md +246 -0
- data/docs/research/2026-08-22-learnhotwire-syllabus-coverage.md +239 -0
- data/docs/research/frontend-only-matrix.md +278 -0
- data/docs/research/streamweaver-way-spike-findings.md +529 -0
- data/docs/resource-dsl.md +342 -0
- data/docs/routing.md +302 -0
- data/docs/ruby-ui-comparison.md +319 -0
- data/docs/shared-dsl-fragments.md +356 -0
- data/docs/streamweaver-for-ai-agents.md +401 -0
- data/docs/streamweaver-frontend-vision.md +123 -0
- data/docs/streamweaver_canvas/glimmer_initial_final_layer.rb +77 -0
- data/docs/streamweaver_canvas/save_example.rb +9 -0
- data/docs/templates.md +213 -0
- data/docs/testing.md +155 -0
- data/docs/theming-hooks.md +281 -0
- data/docs/tutorials/the-streamweaver-way.md +882 -0
- data/docs/university/capability-inventory.md +137 -0
- data/docs/university/dependency-survey.md +114 -0
- data/docs/university/design-spec.md +217 -0
- data/docs/university/mockups/code-block-doc-theme-dark.png +0 -0
- data/docs/university/mockups/code-block-doc-theme-light.png +0 -0
- data/docs/university/mockups/course-list-dark.png +0 -0
- data/docs/university/mockups/course-list-light.png +0 -0
- data/docs/university/mockups/course_canvas_mockup.rb +741 -0
- data/docs/university/mockups/step-screen-dark.png +0 -0
- data/docs/university/mockups/step-screen-light.png +0 -0
- data/docs/university/roadmap.md +72 -0
- data/docs/university/send-to-coworker.md +77 -0
- data/docs/university/worker-session-mining.md +109 -0
- data/docs/visual/browser-rendering-spike.md +173 -0
- data/docs/visual/builder-visual-plan-analysis.md +378 -0
- data/docs/visual/sw-plan-format-exploration.md +511 -0
- data/docs/visual/sw-plan-rendering-deep-dive.md +453 -0
- data/docs/visual-skills/PROGRESS.md +60 -0
- data/docs/visual-skills/SESSION-CONTEXT.md +207 -0
- data/docs/visual-skills/analysis/components.md +317 -0
- data/docs/visual-skills/analysis/overlap.md +360 -0
- data/docs/visual-skills/analysis/pi-design-deck.md +1102 -0
- data/docs/visual-skills/analysis/streamweaver-inventory.md +93 -0
- data/docs/visual-skills/analysis/unified-specs.feature +1003 -0
- data/docs/visual-skills/analysis/visual-explainer.md +964 -0
- data/docs/visual-skills/blog/blog-series-outline.md +39 -0
- data/docs/visual-skills/blog/token-efficiency.md +73 -0
- data/docs/visual-skills/design/architecture.md +1725 -0
- data/docs/visual-skills/design/codex-review.md +340 -0
- data/docs/visual-skills/design/dhh-review.md +209 -0
- data/docs/visual-skills/design/evolution.md +100 -0
- data/docs/visual-skills/design/gemini-review.md +55 -0
- data/docs/visual-skills/design/review-synthesis.md +149 -0
- data/docs/visual-skills/implementation/STATE.md +22 -0
- data/docs/visual-skills/implementation/plan.md +347 -0
- data/docs/visual-skills/implementation/spike-findings.md +237 -0
- data/docs/visual-skills/implementation/tasks.md +702 -0
- data/docs/visual-skills/lessons-learned/process.md +145 -0
- data/examples/README.md +96 -0
- data/examples/advanced/all_components.rb +177 -0
- data/examples/advanced/examples_browser.rb +394 -0
- data/examples/advanced/teachables_browser.rb +261 -0
- data/examples/advanced/theme_tweaker.rb +366 -0
- data/examples/advanced/tutorial.rb +1528 -0
- data/examples/agentic/agentic_form.rb +30 -0
- data/examples/agentic/agentic_form_autoclose.rb +30 -0
- data/examples/agentic/cultivation_tracker.rb +69 -0
- data/examples/basic/hello_world.rb +22 -0
- data/examples/basic/opal_tabs_table.rb +44 -0
- data/examples/basic/todo_list.rb +39 -0
- data/examples/button_loading_test.rb +36 -0
- data/examples/canvas/mermaid_canvas_demo.sh +151 -0
- data/examples/charts/bar_chart_demo.rb +57 -0
- data/examples/charts/line_chart_demo.rb +73 -0
- data/examples/charts/pie_area_demo.rb +87 -0
- data/examples/charts/stacked_bar_chart_demo.rb +69 -0
- data/examples/claude_code/README.md +85 -0
- data/examples/claude_code/codebreaker/.claude/commands/infiltrate.md +632 -0
- data/examples/claude_code/codebreaker/.claude/settings.json +11 -0
- data/examples/claude_code/codebreaker/.claude/settings.local.json +17 -0
- data/examples/claude_code/codebreaker/.claude/skills/infiltrate.md +0 -0
- data/examples/claude_code/codebreaker/README.md +213 -0
- data/examples/claude_code/tutorial/.claude/commands/learn.md +453 -0
- data/examples/claude_code/tutorial/README.md +102 -0
- data/examples/claude_code/verification_flow/.claude/commands/verify.md +171 -0
- data/examples/claude_code/verification_flow/README.md +126 -0
- data/examples/components/annotated_code_demo.rb +54 -0
- data/examples/components/callout_demo.rb +33 -0
- data/examples/components/checkbox_group_demo.rb +46 -0
- data/examples/components/design_review.css +468 -0
- data/examples/components/design_review_demo.rb +44 -0
- data/examples/components/design_review_dsl.rb +300 -0
- data/examples/components/diff_block_demo.rb +112 -0
- data/examples/components/events_demo.rb +184 -0
- data/examples/components/form_demo.rb +62 -0
- data/examples/components/lesson_demo.rb +53 -0
- data/examples/components/markdown_demo.rb +110 -0
- data/examples/components/mermaid_demo.rb +166 -0
- data/examples/components/pareto_set.rb +64 -0
- data/examples/components/prd_demo.rb +21 -0
- data/examples/components/prd_dsl.rb +385 -0
- data/examples/components/quiz_demo.rb +47 -0
- data/examples/components/run_viewer_demo.rb +100 -0
- data/examples/components/score_and_collapsible_demo.rb +82 -0
- data/examples/components/select_stale_value_smoke_test.rb +39 -0
- data/examples/components/table_demo.rb +247 -0
- data/examples/components/timer_with_state_demo.rb +58 -0
- data/examples/components/todo_due_dates.rb +63 -0
- data/examples/components/uat_gaps_demo.rb +112 -0
- data/examples/dashboard/feed_simulator.rb +60 -0
- data/examples/dashboard/live_dashboard.rb +80 -0
- data/examples/dashboard_components.rb +108 -0
- data/examples/deferred_fragments_demo.rb +62 -0
- data/examples/generate_more_spike/README.md +63 -0
- data/examples/generate_more_spike/app.rb +441 -0
- data/examples/git_health.sh +401 -0
- data/examples/layout/layout_components_demo.rb +174 -0
- data/examples/layout/modal_demo.rb +215 -0
- data/examples/layout/navigation_demo.rb +227 -0
- data/examples/layout/route_tabs_demo.rb +52 -0
- data/examples/layout/routing_demo.rb +52 -0
- data/examples/layout/scroll_box_demo.rb +154 -0
- data/examples/lazy_fragments_demo.rb +151 -0
- data/examples/my_todos/README.md +29 -0
- data/examples/my_todos/my_todos.rb +312 -0
- data/examples/my_todos/store.rb +86 -0
- data/examples/opal/reactive_demo.rb +62 -0
- data/examples/opal/scenarios/s1_counter.rb +15 -0
- data/examples/opal/scenarios/s2_search_filter.rb +17 -0
- data/examples/opal/scenarios/s3_sibling_tabs.rb +25 -0
- data/examples/opal/scenarios/s4_shopping_cart.rb +34 -0
- data/examples/opal/scenarios/s5_watch.rb +39 -0
- data/examples/opal/scenarios/s6_on_start.rb +20 -0
- data/examples/opal/scenarios/s7_wizard.rb +33 -0
- data/examples/opal/scenarios/s8_loan_calculator.rb +21 -0
- data/examples/opal/scenarios/s9_dashboard.rb +27 -0
- data/examples/operations_dashboard_demo.rb +167 -0
- data/examples/panel_demo.sh +447 -0
- data/examples/parity/assets/tyrion_slice.css +86 -0
- data/examples/parity/rivet_people_slice.rb +182 -0
- data/examples/parity/tyrion_components.css +777 -0
- data/examples/parity/tyrion_warroom_components.rb +414 -0
- data/examples/parity/tyrion_warroom_slice.rb +549 -0
- data/examples/puma_dev/README.md +124 -0
- data/examples/puma_dev/config.ru +34 -0
- data/examples/puma_dev/standalone_app.rb +34 -0
- data/examples/scaffolding/blog.rb +49 -0
- data/examples/scaffolding/utf_lite.rb +105 -0
- data/examples/styling/feedback_demo.rb +229 -0
- data/examples/styling/style_showcase.rb +172 -0
- data/examples/styling/theme_demo.rb +508 -0
- data/examples/timer_health_checker.rb +111 -0
- data/examples/timer_showcase.rb +131 -0
- data/examples/tutorials/render_markdown.rb +112 -0
- data/examples/tutorials/streamweaver_way_tutorial.rb +44 -0
- data/examples/tutorials/tutorial_content.rb +1082 -0
- data/examples/visual_skills/design_deck_demo.rb +314 -0
- data/examples/visual_skills/explainer_demo.rb +499 -0
- data/exe/streamweaver +9 -0
- data/gsd/ROADMAP-1.0.md +167 -0
- data/gsd/STATE.md +13 -0
- data/gsd/research/market-positioning-research.md +139 -0
- data/gsd/research/production-patterns-research.md +270 -0
- data/gsd/research/repo-audit-1.0.md +274 -0
- data/lib/stream_weaver/action_token.rb +58 -0
- data/lib/stream_weaver/adapter/alpinejs.rb +8249 -0
- data/lib/stream_weaver/adapter/base.rb +591 -0
- data/lib/stream_weaver/adapter/opal.rb +334 -0
- data/lib/stream_weaver/adapter/static.rb +1118 -0
- data/lib/stream_weaver/admin.rb +176 -0
- data/lib/stream_weaver/app.rb +1821 -0
- data/lib/stream_weaver/assets/js/sw-copy.js +50 -0
- data/lib/stream_weaver/assets/js/sw-heredoc-rewrite.js +68 -0
- data/lib/stream_weaver/assets/js/sw-keyboard.js +165 -0
- data/lib/stream_weaver/assets/js/sw-mermaid-zoom.js +621 -0
- data/lib/stream_weaver/assets/js/sw-route-tabs.js +53 -0
- data/lib/stream_weaver/assets/js/sw-sidebar-toc.js +116 -0
- data/lib/stream_weaver/assets/js/sw-slide-nav.js +103 -0
- data/lib/stream_weaver/canvas/bridge.rb +239 -0
- data/lib/stream_weaver/canvas/bridge_server.rb +646 -0
- data/lib/stream_weaver/canvas/client.rb +298 -0
- data/lib/stream_weaver/canvas/doc_roots.rb +233 -0
- data/lib/stream_weaver/canvas/doc_store.rb +252 -0
- data/lib/stream_weaver/canvas/gist_publisher.rb +264 -0
- data/lib/stream_weaver/canvas/gist_save_handler.rb +89 -0
- data/lib/stream_weaver/canvas/gist_store.rb +135 -0
- data/lib/stream_weaver/canvas/helpers.rb +109 -0
- data/lib/stream_weaver/canvas/history.rb +90 -0
- data/lib/stream_weaver/canvas/protocol.rb +86 -0
- data/lib/stream_weaver/canvas/reader.rb +853 -0
- data/lib/stream_weaver/canvas/save_doc_widget.rb +457 -0
- data/lib/stream_weaver/canvas/scroll_top_hint.rb +21 -0
- data/lib/stream_weaver/canvas/session.rb +132 -0
- data/lib/stream_weaver/cli.rb +3235 -0
- data/lib/stream_weaver/component_assets.rb +70 -0
- data/lib/stream_weaver/component_registry.rb +67 -0
- data/lib/stream_weaver/component_renderer.rb +48 -0
- data/lib/stream_weaver/components/annotated_code.rb +53 -0
- data/lib/stream_weaver/components/api_endpoint.rb +42 -0
- data/lib/stream_weaver/components/callout.rb +59 -0
- data/lib/stream_weaver/components/chart.rb +84 -0
- data/lib/stream_weaver/components/code_block.rb +77 -0
- data/lib/stream_weaver/components/comparison.rb +39 -0
- data/lib/stream_weaver/components/decision.rb +38 -0
- data/lib/stream_weaver/components/deck/close_overlay.rb +84 -0
- data/lib/stream_weaver/components/deck/confirmation_bar.rb +60 -0
- data/lib/stream_weaver/components/deck/deck_option.rb +61 -0
- data/lib/stream_weaver/components/deck/deck_slide.rb +77 -0
- data/lib/stream_weaver/components/deck/deck_state.rb +469 -0
- data/lib/stream_weaver/components/deck/deck_summary.rb +73 -0
- data/lib/stream_weaver/components/deck/design_deck.rb +60 -0
- data/lib/stream_weaver/components/deck/generate_more_controls.rb +84 -0
- data/lib/stream_weaver/components/deck/model_selector.rb +84 -0
- data/lib/stream_weaver/components/deck/skeleton_placeholder.rb +36 -0
- data/lib/stream_weaver/components/diff_block.rb +125 -0
- data/lib/stream_weaver/components/doc_header.rb +57 -0
- data/lib/stream_weaver/components/image_block.rb +70 -0
- data/lib/stream_weaver/components/implementation_map.rb +33 -0
- data/lib/stream_weaver/components/keyboard_shortcuts.rb +94 -0
- data/lib/stream_weaver/components/kpi_dashboard.rb +78 -0
- data/lib/stream_weaver/components/mermaid.rb +79 -0
- data/lib/stream_weaver/components/pipeline.rb +63 -0
- data/lib/stream_weaver/components/sidebar_toc.rb +46 -0
- data/lib/stream_weaver/components/slide_container.rb +141 -0
- data/lib/stream_weaver/components/timeline_event.rb +58 -0
- data/lib/stream_weaver/components/wireframe.rb +29 -0
- data/lib/stream_weaver/components/wireframe_block.rb +34 -0
- data/lib/stream_weaver/components.rb +2711 -0
- data/lib/stream_weaver/css.rb +230 -0
- data/lib/stream_weaver/dev_fallback_overlay.rb +61 -0
- data/lib/stream_weaver/display_dsl.rb +1004 -0
- data/lib/stream_weaver/export/html_exporter.rb +478 -0
- data/lib/stream_weaver/feed.rb +34 -0
- data/lib/stream_weaver/feed_builder.rb +31 -0
- data/lib/stream_weaver/fonts.rb +33 -0
- data/lib/stream_weaver/interaction_runner.rb +487 -0
- data/lib/stream_weaver/iterm.rb +460 -0
- data/lib/stream_weaver/layout_registry.rb +92 -0
- data/lib/stream_weaver/opal/bridge.rb +52 -0
- data/lib/stream_weaver/opal/builder.rb +193 -0
- data/lib/stream_weaver/opal/env.rb +29 -0
- data/lib/stream_weaver/opal/reactive_state.rb +75 -0
- data/lib/stream_weaver/opal/regexp_anchor_patch.rb +62 -0
- data/lib/stream_weaver/opal/renderer.rb +77 -0
- data/lib/stream_weaver/opal/runtime.rb +250 -0
- data/lib/stream_weaver/opal/shell.rb +145 -0
- data/lib/stream_weaver/opal/string_bridge.rb +50 -0
- data/lib/stream_weaver/opal/stubs/diff.min.js +1 -0
- data/lib/stream_weaver/opal/stubs/digest.rb +15 -0
- data/lib/stream_weaver/opal/stubs/marked.umd.js +79 -0
- data/lib/stream_weaver/opal/stubs/md5.rb +3 -0
- data/lib/stream_weaver/opal/stubs/morphdom.min.js +775 -0
- data/lib/stream_weaver/opal/stubs/prism-tomorrow.min.css +1 -0
- data/lib/stream_weaver/opal/stubs/prism.min.js +1967 -0
- data/lib/stream_weaver/opal_entry.rb +136 -0
- data/lib/stream_weaver/org/inline.rb +95 -0
- data/lib/stream_weaver/org/reader.rb +561 -0
- data/lib/stream_weaver/org/recording_context.rb +86 -0
- data/lib/stream_weaver/org/source_splitter.rb +63 -0
- data/lib/stream_weaver/org/writer.rb +300 -0
- data/lib/stream_weaver/page_shell.rb +530 -0
- data/lib/stream_weaver/portfile.rb +79 -0
- data/lib/stream_weaver/pushable.rb +42 -0
- data/lib/stream_weaver/resource/default_views.rb +111 -0
- data/lib/stream_weaver/resource/field_input.rb +25 -0
- data/lib/stream_weaver/resource/state_keys.rb +16 -0
- data/lib/stream_weaver/resource/store.rb +18 -0
- data/lib/stream_weaver/resource.rb +104 -0
- data/lib/stream_weaver/server.rb +1471 -0
- data/lib/stream_weaver/service.rb +1240 -0
- data/lib/stream_weaver/service_client.rb +104 -0
- data/lib/stream_weaver/session_store.rb +186 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/SKILL.md +66 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/examples/canvas-safe-showcase.rb +98 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/actions-and-buttons.md +47 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/charts-and-diagrams.md +38 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/deck.md +32 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/inputs-and-forms.md +65 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/tabs-and-navigation.md +53 -0
- data/lib/stream_weaver/skills/streamweaver-doc-builder/SKILL.md +298 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/SKILL.md +130 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.css +468 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.rb +39 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example_dsl.rb +300 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example.rb +24 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example_dsl.rb +385 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/checkpoints-and-forms.md +28 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/cleanup-and-panel.md +44 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/example-gallery.md +8 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/persistence.md +58 -0
- data/lib/stream_weaver/skills/streamweaver-way/SKILL.md +396 -0
- data/lib/stream_weaver/skills/visual-plan/SKILL.md +201 -0
- data/lib/stream_weaver/skills/visual-recap/SKILL.md +244 -0
- data/lib/stream_weaver/streamer.rb +63 -0
- data/lib/stream_weaver/templates/choices.rb +149 -0
- data/lib/stream_weaver/templates/code.rb +157 -0
- data/lib/stream_weaver/templates/confirm.rb +110 -0
- data/lib/stream_weaver/templates/diff.rb +195 -0
- data/lib/stream_weaver/templates/info.rb +142 -0
- data/lib/stream_weaver/templates/table.rb +169 -0
- data/lib/stream_weaver/templates/wizard.rb +271 -0
- data/lib/stream_weaver/theme/auto_mode.rb +116 -0
- data/lib/stream_weaver/theme/presets.rb +555 -0
- data/lib/stream_weaver/theme.rb +639 -0
- data/lib/stream_weaver/university/canvas.rb +924 -0
- data/lib/stream_weaver/university/course.rb +699 -0
- data/lib/stream_weaver/university/demos/counter.rb +33 -0
- data/lib/stream_weaver/university/demos/dashboard.rb +147 -0
- data/lib/stream_weaver/university/demos/decision_form.rb +143 -0
- data/lib/stream_weaver/university/demos.rb +50 -0
- data/lib/stream_weaver/university/listener.rb +467 -0
- data/lib/stream_weaver/university/progress.rb +201 -0
- data/lib/stream_weaver/university/runner.rb +138 -0
- data/lib/stream_weaver/university/scripts/growing_doc.rb +609 -0
- data/lib/stream_weaver/university/scripts/growing_doc_state.rb +91 -0
- data/lib/stream_weaver/utils.rb +31 -0
- data/lib/stream_weaver/version.rb +5 -0
- data/lib/stream_weaver/views/canvas/reader_layout.erb +800 -0
- data/lib/stream_weaver/views.rb +3940 -0
- data/lib/stream_weaver.rb +122 -0
- data/llms.txt +1269 -0
- data/sig/stream_weaver.rbs +4 -0
- metadata +598 -0
|
@@ -0,0 +1,1528 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# StreamWeaver Interactive Tutorial
|
|
5
|
+
# A self-documenting app that teaches StreamWeaver using StreamWeaver itself.
|
|
6
|
+
#
|
|
7
|
+
# Run with: streamweaver tutorial
|
|
8
|
+
# (Or: ruby examples/advanced/tutorial.rb)
|
|
9
|
+
|
|
10
|
+
require_relative '../../lib/stream_weaver'
|
|
11
|
+
|
|
12
|
+
# Source identifier for tracking apps in service
|
|
13
|
+
SOURCE = "tutorial"
|
|
14
|
+
|
|
15
|
+
# Track loaded apps: section_id => { app_id:, aliased_url: }
|
|
16
|
+
LOADED_APPS = {}
|
|
17
|
+
|
|
18
|
+
# CodeMirror 5 CDN URLs
|
|
19
|
+
CODEMIRROR_CSS = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"
|
|
20
|
+
CODEMIRROR_JS = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"
|
|
21
|
+
CODEMIRROR_RUBY = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/ruby/ruby.min.js"
|
|
22
|
+
|
|
23
|
+
# Cleanup all loaded apps on exit
|
|
24
|
+
at_exit do
|
|
25
|
+
next if LOADED_APPS.empty?
|
|
26
|
+
puts "\nCleaning up #{LOADED_APPS.size} tutorial playground(s)..."
|
|
27
|
+
# Use a temporary includer to call ServiceClient methods
|
|
28
|
+
Object.new.extend(StreamWeaver::ServiceClient).clear_source_via_service(SOURCE)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Section data object (DHH-style: prefer objects over hashes)
|
|
32
|
+
Section = Data.define(:id, :nav_title, :title, :content, :code) do
|
|
33
|
+
def state_key = :"#{id}_edited_code"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# =============================================================================
|
|
37
|
+
# SECTIONS - Each section is self-contained for easy editing
|
|
38
|
+
# =============================================================================
|
|
39
|
+
|
|
40
|
+
module Sections
|
|
41
|
+
PHILOSOPHY = Section.new(
|
|
42
|
+
id: :philosophy,
|
|
43
|
+
nav_title: "Philosophy",
|
|
44
|
+
title: "Why StreamWeaver?",
|
|
45
|
+
content: <<~MD,
|
|
46
|
+
## Express Intent, Nothing Else
|
|
47
|
+
|
|
48
|
+
Think about what a simple UI actually requires: some text, a few inputs,
|
|
49
|
+
maybe a dropdown, a button. **That's it.** That's what you're trying to build.
|
|
50
|
+
|
|
51
|
+
But to get there you're dealing with HTML structure, CSS styling, JavaScript,
|
|
52
|
+
controllers, state management...
|
|
53
|
+
|
|
54
|
+
### The Ruby Way
|
|
55
|
+
|
|
56
|
+
Ruby's beauty is that you can express your intent and nothing else.
|
|
57
|
+
StreamWeaver brings that philosophy to UI:
|
|
58
|
+
|
|
59
|
+
> "I want a title, an input, and a button that does something."
|
|
60
|
+
|
|
61
|
+
That's exactly what you write. No more, no less.
|
|
62
|
+
|
|
63
|
+
### Token Efficiency for AI
|
|
64
|
+
|
|
65
|
+
When building with Claude Code or other AI assistants, this minimal approach
|
|
66
|
+
pays off even more:
|
|
67
|
+
|
|
68
|
+
- **Smaller generation = faster + cheaper** - Concise DSL instead of verbose HTML/React
|
|
69
|
+
- **More context** - Less code means more room for your actual problem
|
|
70
|
+
- **Data-only generation** - Pre-build the app, let AI just generate data
|
|
71
|
+
MD
|
|
72
|
+
# Standalone example with real interactivity
|
|
73
|
+
code: <<~RUBY
|
|
74
|
+
header1 "Welcome!"
|
|
75
|
+
text_field :name, placeholder: "Your name"
|
|
76
|
+
|
|
77
|
+
if state[:name].to_s.strip != ""
|
|
78
|
+
text "Hello, \#{state[:name]}!"
|
|
79
|
+
else
|
|
80
|
+
text "Type your name above."
|
|
81
|
+
end
|
|
82
|
+
RUBY
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
HELLO_WORLD = Section.new(
|
|
86
|
+
id: :hello_world,
|
|
87
|
+
nav_title: "Hello World",
|
|
88
|
+
title: "Your First App",
|
|
89
|
+
content: <<~MD,
|
|
90
|
+
## The Simplest App
|
|
91
|
+
|
|
92
|
+
Every StreamWeaver app starts with `app` and a title.
|
|
93
|
+
Inside the block, you describe your UI.
|
|
94
|
+
|
|
95
|
+
### Try It
|
|
96
|
+
|
|
97
|
+
Type your name in the input below. Notice how the greeting
|
|
98
|
+
appears automatically - no JavaScript, no event handlers,
|
|
99
|
+
just Ruby.
|
|
100
|
+
MD
|
|
101
|
+
code: <<~RUBY
|
|
102
|
+
header1 "Welcome!"
|
|
103
|
+
text_field :name, placeholder: "Your name"
|
|
104
|
+
|
|
105
|
+
if state[:name].to_s.strip != ""
|
|
106
|
+
text "Hello, \#{state[:name]}!"
|
|
107
|
+
end
|
|
108
|
+
RUBY
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
FOUR_MODES = Section.new(
|
|
112
|
+
id: :four_modes,
|
|
113
|
+
nav_title: "The Four Modes",
|
|
114
|
+
title: "Standalone, Agentic, Service, Canvas/Panel",
|
|
115
|
+
content: <<~MD,
|
|
116
|
+
## Four Ways To Run a StreamWeaver App
|
|
117
|
+
|
|
118
|
+
Every mode uses the same DSL you just saw - what changes is **how it's
|
|
119
|
+
launched** and **who's driving**. Get oriented before going further:
|
|
120
|
+
|
|
121
|
+
| Mode | Launch | Who it's for |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| **Standalone** | `ruby app.rb` (calls `.run!`) | A person, in a browser, for the life of the process |
|
|
124
|
+
| **Agentic** | `StreamWeaver.run_once!` inside a script | A script needs one answer, then keeps going |
|
|
125
|
+
| **Service** | `streamweaver run app.rb` | Multiple apps on one server, human `/apps/:slug` URLs |
|
|
126
|
+
| **Canvas / Panel** | `streamweaver panel <session>` + `canvas-push` | An agent (Claude Code) drives live UI mid-conversation |
|
|
127
|
+
|
|
128
|
+
### When To Use Which
|
|
129
|
+
|
|
130
|
+
- Building a tool a human opens in a tab and uses for a while? **Standalone.**
|
|
131
|
+
- A script needs a single decision from a human before continuing? **Agentic** (`run_once!`).
|
|
132
|
+
- Running several apps side by side with memorable URLs? **Service.**
|
|
133
|
+
- An agent needs to show live, updating UI while it works? **Canvas/Panel.**
|
|
134
|
+
|
|
135
|
+
This tutorial is itself a **standalone** app (`ruby examples/advanced/tutorial.rb`).
|
|
136
|
+
Its "Run" button launches each playground into the **service** - you'll see
|
|
137
|
+
that in action a few lessons from now, and each of the other three modes
|
|
138
|
+
gets its own lesson later on.
|
|
139
|
+
MD
|
|
140
|
+
code: <<~RUBY
|
|
141
|
+
select :demo_mode, [
|
|
142
|
+
"Standalone (ruby app.rb)",
|
|
143
|
+
"Agentic (run_once!)",
|
|
144
|
+
"Service (streamweaver run)",
|
|
145
|
+
"Canvas/Panel (streamweaver panel)"
|
|
146
|
+
], default: "Standalone (ruby app.rb)"
|
|
147
|
+
|
|
148
|
+
case state[:demo_mode]
|
|
149
|
+
when "Standalone (ruby app.rb)"
|
|
150
|
+
alert(variant: :info) { text 'app("My App") { ... }.run!' }
|
|
151
|
+
when "Agentic (run_once!)"
|
|
152
|
+
alert(variant: :info) { text "StreamWeaver.run_once! { ... } # blocks, returns a Hash" }
|
|
153
|
+
when "Service (streamweaver run)"
|
|
154
|
+
alert(variant: :info) { text "streamweaver run app.rb -> http://localhost:4567/apps/your-app" }
|
|
155
|
+
when "Canvas/Panel (streamweaver panel)"
|
|
156
|
+
alert(variant: :info) { text "streamweaver panel my-session, then canvas-push my-session" }
|
|
157
|
+
end
|
|
158
|
+
RUBY
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
GETTING_INPUT = Section.new(
|
|
162
|
+
id: :getting_input,
|
|
163
|
+
nav_title: "Getting Input",
|
|
164
|
+
title: "Text Fields & State",
|
|
165
|
+
content: <<~MD,
|
|
166
|
+
## How State Works
|
|
167
|
+
|
|
168
|
+
Every input binds to a **state key**. When you write:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
text_field :name
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
StreamWeaver automatically:
|
|
175
|
+
1. Creates `state[:name]`
|
|
176
|
+
2. Syncs the input value with state
|
|
177
|
+
3. Re-renders when state changes
|
|
178
|
+
|
|
179
|
+
### Conditional Display
|
|
180
|
+
|
|
181
|
+
Since the app block re-evaluates on state changes, use normal Ruby conditionals:
|
|
182
|
+
MD
|
|
183
|
+
code: <<~RUBY
|
|
184
|
+
text_field :email, placeholder: "Email"
|
|
185
|
+
text_area :message, placeholder: "Your message...", rows: 3
|
|
186
|
+
|
|
187
|
+
if state[:email].to_s.include?("@")
|
|
188
|
+
alert(variant: :success) { text "Valid email!" }
|
|
189
|
+
elsif state[:email].to_s.length > 0
|
|
190
|
+
alert(variant: :warning) { text "Need @ in email" }
|
|
191
|
+
end
|
|
192
|
+
RUBY
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
MAKING_CHOICES = Section.new(
|
|
196
|
+
id: :making_choices,
|
|
197
|
+
nav_title: "Making Choices",
|
|
198
|
+
title: "Selection Components",
|
|
199
|
+
content: <<~MD,
|
|
200
|
+
## Dropdowns, Checkboxes, Radio Buttons
|
|
201
|
+
|
|
202
|
+
StreamWeaver provides several ways to capture user choices:
|
|
203
|
+
|
|
204
|
+
- **select** - Dropdown menu
|
|
205
|
+
- **checkbox** - Boolean toggle
|
|
206
|
+
- **radio_group** - Single choice from options
|
|
207
|
+
- **checkbox_group** - Multiple selections
|
|
208
|
+
MD
|
|
209
|
+
code: <<~RUBY
|
|
210
|
+
header3 "Preferences"
|
|
211
|
+
select :priority, ["Low", "Medium", "High"], default: "Medium"
|
|
212
|
+
checkbox :urgent, "Mark as urgent"
|
|
213
|
+
|
|
214
|
+
text "Priority: \#{state[:priority]}"
|
|
215
|
+
text "Urgent: \#{state[:urgent] ? 'YES!' : 'no'}"
|
|
216
|
+
RUBY
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
TAKING_ACTION = Section.new(
|
|
220
|
+
id: :taking_action,
|
|
221
|
+
nav_title: "Taking Action",
|
|
222
|
+
title: "Buttons & Callbacks",
|
|
223
|
+
content: <<~MD,
|
|
224
|
+
## Making Things Happen
|
|
225
|
+
|
|
226
|
+
Buttons execute code when clicked. The callback receives the current state:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
button "Click Me" do |state|
|
|
230
|
+
# Your code here
|
|
231
|
+
state[:count] += 1
|
|
232
|
+
end
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Button Styles
|
|
236
|
+
|
|
237
|
+
Use `style: :secondary` for less prominent actions.
|
|
238
|
+
MD
|
|
239
|
+
code: <<~RUBY
|
|
240
|
+
state[:count] ||= 0
|
|
241
|
+
|
|
242
|
+
header2 "Count: \#{state[:count]}"
|
|
243
|
+
|
|
244
|
+
hstack spacing: :sm do
|
|
245
|
+
button "+" do |s|
|
|
246
|
+
s[:count] += 1
|
|
247
|
+
end
|
|
248
|
+
button "-", style: :secondary do |s|
|
|
249
|
+
s[:count] -= 1
|
|
250
|
+
end
|
|
251
|
+
button "Reset", style: :secondary do |s|
|
|
252
|
+
s[:count] = 0
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
RUBY
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
LAYOUT = Section.new(
|
|
259
|
+
id: :layout,
|
|
260
|
+
nav_title: "Layout",
|
|
261
|
+
title: "Cards, Columns & Stacks",
|
|
262
|
+
content: <<~MD,
|
|
263
|
+
## Organizing Your UI
|
|
264
|
+
|
|
265
|
+
StreamWeaver provides several layout components:
|
|
266
|
+
|
|
267
|
+
- **card** - Styled container with optional header/footer
|
|
268
|
+
- **columns** - Multi-column layouts
|
|
269
|
+
- **vstack/hstack** - Vertical/horizontal stacking
|
|
270
|
+
- **grid** - Responsive grid layouts
|
|
271
|
+
|
|
272
|
+
### Labeled Card Headers
|
|
273
|
+
|
|
274
|
+
`card_header "Title", badge: "C1", meta: "..."` renders a small mono
|
|
275
|
+
badge before the title and right-aligned meta text after it - handy for
|
|
276
|
+
compact, labeled sections (a status panel, a numbered step, ...).
|
|
277
|
+
MD
|
|
278
|
+
code: <<~RUBY
|
|
279
|
+
columns widths: ['30%', '70%'] do
|
|
280
|
+
column do
|
|
281
|
+
card do
|
|
282
|
+
card_header "Sidebar"
|
|
283
|
+
card_body do
|
|
284
|
+
text "Navigation"
|
|
285
|
+
button "Option 1"
|
|
286
|
+
button "Option 2", style: :secondary
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
column do
|
|
291
|
+
card do
|
|
292
|
+
card_header "Main Content", badge: "C1", meta: "content pane"
|
|
293
|
+
card_body do
|
|
294
|
+
text_field :search, placeholder: "Search..."
|
|
295
|
+
text "Wide area for content"
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
RUBY
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
TABLES = Section.new(
|
|
304
|
+
id: :tables,
|
|
305
|
+
nav_title: "Tables",
|
|
306
|
+
title: "Displaying Data",
|
|
307
|
+
content: <<~MD,
|
|
308
|
+
## Tables and Lists
|
|
309
|
+
|
|
310
|
+
Build data displays using divs with CSS grid or flexbox.
|
|
311
|
+
|
|
312
|
+
### The Pattern
|
|
313
|
+
|
|
314
|
+
Use nested divs with grid styling:
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
div style: "display: grid; grid-template-columns: repeat(3, 1fr);" do
|
|
318
|
+
# Header row
|
|
319
|
+
div { text "Name" }
|
|
320
|
+
# Data rows...
|
|
321
|
+
end
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Iteration
|
|
325
|
+
|
|
326
|
+
Loop over data with `.each` to generate rows.
|
|
327
|
+
MD
|
|
328
|
+
code: <<~RUBY
|
|
329
|
+
data = [
|
|
330
|
+
{ name: "Alice", role: "Admin", active: "Yes" },
|
|
331
|
+
{ name: "Bob", role: "User", active: "Yes" },
|
|
332
|
+
{ name: "Charlie", role: "Guest", active: "No" }
|
|
333
|
+
]
|
|
334
|
+
|
|
335
|
+
# Grid-based table
|
|
336
|
+
div style: "display: grid; grid-template-columns: 1fr 1fr 80px; border: 1px solid #ddd; border-radius: 4px;" do
|
|
337
|
+
# Header
|
|
338
|
+
["Name", "Role", "Active?"].each do |h|
|
|
339
|
+
div style: "padding: 10px; background: #f5f5f5; font-weight: 600; border-bottom: 2px solid #ddd;" do
|
|
340
|
+
text h
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Data rows
|
|
345
|
+
data.each do |row|
|
|
346
|
+
[:name, :role, :active].each do |col|
|
|
347
|
+
div style: "padding: 10px; border-bottom: 1px solid #eee;" do
|
|
348
|
+
text row[col]
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
RUBY
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
MODALS = Section.new(
|
|
357
|
+
id: :modals,
|
|
358
|
+
nav_title: "Modals",
|
|
359
|
+
title: "Dialogs & Confirmations",
|
|
360
|
+
content: <<~MD,
|
|
361
|
+
## Modal Dialogs
|
|
362
|
+
|
|
363
|
+
Use modals for confirmations, forms, or any content that needs focus.
|
|
364
|
+
|
|
365
|
+
### How Modals Work
|
|
366
|
+
|
|
367
|
+
1. Define the modal with a unique key
|
|
368
|
+
2. Set `state[:key_open] = true` to show it
|
|
369
|
+
3. Use `modal_footer` for action buttons
|
|
370
|
+
|
|
371
|
+
### Sizes
|
|
372
|
+
|
|
373
|
+
Available sizes: `:sm`, `:md`, `:lg`, `:xl`
|
|
374
|
+
MD
|
|
375
|
+
code: <<~RUBY
|
|
376
|
+
button "Delete Item" do |s|
|
|
377
|
+
s[:confirm_open] = true
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
if state[:confirmed]
|
|
381
|
+
alert(variant: :success) { text "Item deleted!" }
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
modal :confirm, title: "Confirm Delete", size: :sm do
|
|
385
|
+
text "Are you sure you want to delete this item?"
|
|
386
|
+
text "This action cannot be undone."
|
|
387
|
+
|
|
388
|
+
modal_footer do
|
|
389
|
+
button "Delete", style: :primary do |s|
|
|
390
|
+
s[:confirmed] = true
|
|
391
|
+
s[:confirm_open] = false
|
|
392
|
+
end
|
|
393
|
+
button "Cancel", style: :secondary do |s|
|
|
394
|
+
s[:confirm_open] = false
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
RUBY
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
THEMES = Section.new(
|
|
402
|
+
id: :themes,
|
|
403
|
+
nav_title: "Themes",
|
|
404
|
+
title: "Custom Styling",
|
|
405
|
+
content: <<~MD,
|
|
406
|
+
## Themes
|
|
407
|
+
|
|
408
|
+
StreamWeaver theming has two independent layers, both switchable via CSS
|
|
409
|
+
variables and the `style` attribute.
|
|
410
|
+
|
|
411
|
+
### Built-in Themes (`app theme: :name`)
|
|
412
|
+
|
|
413
|
+
- `:default` - Warm Industrial
|
|
414
|
+
- `:dashboard` - Data Dense
|
|
415
|
+
- `:document` - Reading Mode (serif, generous line-height)
|
|
416
|
+
- `:doc` - Compact Editorial (tight, magazine-like)
|
|
417
|
+
|
|
418
|
+
### Presets (`theme_preset :name`)
|
|
419
|
+
|
|
420
|
+
A second layer of curated font + color palettes you can drop onto any
|
|
421
|
+
theme with one line: `:editorial`, `:technical`, `:warm`, `:minimal`,
|
|
422
|
+
`:terminal`, and `:sketch` (hand-drawn wireframe look - rough.js borders
|
|
423
|
+
and a Caveat handwriting font).
|
|
424
|
+
|
|
425
|
+
```ruby
|
|
426
|
+
app "My App", theme: :doc do
|
|
427
|
+
theme_preset :sketch
|
|
428
|
+
# ...
|
|
429
|
+
end.run!
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### Dark Mode, Automatically
|
|
433
|
+
|
|
434
|
+
`theme_toggle mode: :auto` adds a dark/light/auto button that **follows
|
|
435
|
+
the OS `prefers-color-scheme`** out of the box and remembers the user's
|
|
436
|
+
override in `localStorage`. `theme_switcher` renders a full picker over
|
|
437
|
+
every registered theme instead of a single toggle - try both below,
|
|
438
|
+
they actually change this page's theme.
|
|
439
|
+
|
|
440
|
+
### CSS Variables & Inline Styles
|
|
441
|
+
|
|
442
|
+
Override colors per-app with `theme_overrides:`, or reach for `style:`
|
|
443
|
+
on any component for one-off styling:
|
|
444
|
+
MD
|
|
445
|
+
code: <<~RUBY
|
|
446
|
+
theme_switcher
|
|
447
|
+
theme_toggle mode: :auto
|
|
448
|
+
|
|
449
|
+
# Using CSS variables
|
|
450
|
+
div style: "background: var(--sw-color-primary); color: white; padding: 1rem; border-radius: 8px; margin-top: 0.75rem;" do
|
|
451
|
+
text "Primary colored box"
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
div style: "margin-top: 1rem;" do end
|
|
455
|
+
|
|
456
|
+
# Custom styling
|
|
457
|
+
div style: "background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 1.5rem; border-radius: 12px; color: white;" do
|
|
458
|
+
header3 "Gradient Header"
|
|
459
|
+
text "Custom styled content"
|
|
460
|
+
end
|
|
461
|
+
RUBY
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
NAVIGATION = Section.new(
|
|
465
|
+
id: :navigation,
|
|
466
|
+
nav_title: "Navigation",
|
|
467
|
+
title: "navbar, nav_item, link_to & State Routing",
|
|
468
|
+
content: <<~MD,
|
|
469
|
+
## Cross-App Navigation
|
|
470
|
+
|
|
471
|
+
- `navbar` - a horizontal nav bar container
|
|
472
|
+
- `nav_item "Label", href: "/path", active: true` - bold and
|
|
473
|
+
non-clickable when active, a link otherwise
|
|
474
|
+
- `link_to "Label", href: "/path"` - a plain inline anchor
|
|
475
|
+
|
|
476
|
+
```ruby
|
|
477
|
+
navbar do
|
|
478
|
+
nav_item "Dashboard", href: "/", active: true
|
|
479
|
+
nav_item "Settings", href: "/settings"
|
|
480
|
+
end
|
|
481
|
+
link_to "Docs", href: "https://example.com"
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
### `route_by` / `route_with` - State Routing, Not HTTP Routing
|
|
485
|
+
|
|
486
|
+
These map a URL to **state**, not to a server-side handler - the same
|
|
487
|
+
StreamWeaver view still renders, just seeded differently.
|
|
488
|
+
`route_by :page, home: "/", settings: "/settings"` gives one state key a
|
|
489
|
+
bidirectional URL mapping; `route_with(parser:, builder:)` handles
|
|
490
|
+
parameterized routes (`/post/:id`) with two lambdas. See
|
|
491
|
+
`docs/routing.md` for the full parser/builder contract - including a
|
|
492
|
+
Common Pitfalls section worth reading once your app grows past a
|
|
493
|
+
handful of routes.
|
|
494
|
+
|
|
495
|
+
If you need a genuine HTTP route that bypasses rendering entirely (a
|
|
496
|
+
webhook, a JSON API, a file download), that's `endpoint` - next lesson.
|
|
497
|
+
|
|
498
|
+
**Heads up:** Run below launches into the multi-app *service*, where
|
|
499
|
+
every app lives under `/apps/:slug`. State routing itself works the
|
|
500
|
+
same way there as standalone - visiting `/apps/:slug/settings`
|
|
501
|
+
correctly seeds `state[:page] = :settings`. What doesn't carry over is
|
|
502
|
+
this snippet's hardcoded `/settings` href, which points above the
|
|
503
|
+
service's `/apps/:slug` scope and will 404 if you click it there. Save
|
|
504
|
+
this code to a file and `ruby` it directly to see the hrefs resolve at
|
|
505
|
+
the real root.
|
|
506
|
+
MD
|
|
507
|
+
code: <<~RUBY
|
|
508
|
+
route_by :page, home: "/", settings: "/settings"
|
|
509
|
+
state[:page] ||= :home
|
|
510
|
+
|
|
511
|
+
navbar do
|
|
512
|
+
nav_item "Home", href: "/", active: state[:page] == :home
|
|
513
|
+
nav_item "Settings", href: "/settings", active: state[:page] == :settings
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
case state[:page]
|
|
517
|
+
when :home
|
|
518
|
+
header3 "Home"
|
|
519
|
+
text "Visiting / sets state[:page] = :home"
|
|
520
|
+
when :settings
|
|
521
|
+
header3 "Settings"
|
|
522
|
+
text "Visiting /settings sets state[:page] = :settings"
|
|
523
|
+
end
|
|
524
|
+
RUBY
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
RESOURCE_DSL = Section.new(
|
|
528
|
+
id: :resource_dsl,
|
|
529
|
+
nav_title: "Resource DSL",
|
|
530
|
+
title: "CRUD Scaffolding",
|
|
531
|
+
content: <<~MD,
|
|
532
|
+
## One Block, Full CRUD
|
|
533
|
+
|
|
534
|
+
`resource :name, store: MyStore do field ... end` replaces 30-50 lines
|
|
535
|
+
of hand-written routes/state/forms with index, show, new, edit, and
|
|
536
|
+
delete - all deep-linkable.
|
|
537
|
+
|
|
538
|
+
| URL | Action |
|
|
539
|
+
|---|---|
|
|
540
|
+
| `GET /posts` | Index - table with View / Edit / Delete |
|
|
541
|
+
| `GET /posts/new` | New - form, Create button |
|
|
542
|
+
| `GET /post/:id` | Show - card with field values |
|
|
543
|
+
| `GET /post/:id/edit` | Edit - form seeded from the record |
|
|
544
|
+
| Delete button | Inline confirm, then destroys |
|
|
545
|
+
|
|
546
|
+
Stores are duck-typed - any object with `all`, `find`, `create`,
|
|
547
|
+
`update`, `destroy` works. See `docs/resource-dsl.md` for the full
|
|
548
|
+
field-type table, override blocks, and named-route helpers
|
|
549
|
+
(`posts_path`, `post_path(rec)`, ...).
|
|
550
|
+
|
|
551
|
+
Declare `page`/`route` calls **before** `resource` blocks - routing is
|
|
552
|
+
first-registered-wins, so the root path needs its own landing page.
|
|
553
|
+
|
|
554
|
+
**Heads up:** like the Navigation lesson, Run below launches into the
|
|
555
|
+
multi-app service, where every app lives under `/apps/:slug`. Deep
|
|
556
|
+
links like `/apps/:slug/posts` correctly seed the index action there -
|
|
557
|
+
what doesn't carry over is this snippet's hardcoded "View Posts" href,
|
|
558
|
+
which points above the service's `/apps/:slug` scope and will 404 if
|
|
559
|
+
you click it there. Save this code to a file and `ruby` it directly to
|
|
560
|
+
click through the real thing.
|
|
561
|
+
MD
|
|
562
|
+
code: <<~RUBY
|
|
563
|
+
module PostStore
|
|
564
|
+
@posts = [{ id: '1', title: 'Hello', body: 'First post', status: 'published' }]
|
|
565
|
+
def self.all; @posts; end
|
|
566
|
+
def self.find(id); @posts.find { |p| p[:id] == id }; end
|
|
567
|
+
def self.create(attrs)
|
|
568
|
+
id = ((@posts.map { |p| p[:id].to_i }.max || 0) + 1).to_s
|
|
569
|
+
@posts << { id: id, **attrs }; id
|
|
570
|
+
end
|
|
571
|
+
def self.update(id, attrs); post = find(id) or return false; post.merge!(attrs); true; end
|
|
572
|
+
def self.destroy(id); @posts.reject! { |p| p[:id] == id }; true; end
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
page :home, '/' do
|
|
576
|
+
header1 "Blog"
|
|
577
|
+
link_to "View Posts", href: "/posts" # posts_path isn't defined until `resource` below runs
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
resource :post, store: PostStore do
|
|
581
|
+
field :title, :string
|
|
582
|
+
field :body, :text
|
|
583
|
+
field :status, :enum, values: %w[draft published]
|
|
584
|
+
end
|
|
585
|
+
RUBY
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
ENDPOINTS = Section.new(
|
|
589
|
+
id: :endpoints,
|
|
590
|
+
nav_title: "Endpoints",
|
|
591
|
+
title: "The endpoint DSL - a Real HTTP Escape Hatch",
|
|
592
|
+
content: <<~MD,
|
|
593
|
+
## When You Need Real HTTP
|
|
594
|
+
|
|
595
|
+
`route_by`/`route_with` map URLs to *state* - same page, different
|
|
596
|
+
seed. Sometimes you need a genuine HTTP route instead: a webhook
|
|
597
|
+
receiver, a JSON API, a file download. That's `endpoint`.
|
|
598
|
+
|
|
599
|
+
```ruby
|
|
600
|
+
app "My App" do
|
|
601
|
+
endpoint :get, "/api/status" do |req|
|
|
602
|
+
{ ok: true, uptime: 42 } # Hash -> 200 application/json
|
|
603
|
+
end
|
|
604
|
+
end.run!
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
- Supported verbs: `:get`, `:post`, `:put`, `:patch`, `:delete`
|
|
608
|
+
- The block receives the raw `Rack::Request`; return a `Hash` (JSON), a
|
|
609
|
+
`String` (HTML), or `[status, headers, body]` for full control
|
|
610
|
+
- Endpoints bypass state/session/CSRF entirely - no StreamWeaver
|
|
611
|
+
machinery, just Rack
|
|
612
|
+
- **Reserved paths**: anything under `/update`, `/action/*`, `/submit`,
|
|
613
|
+
`/event/*`, `/form/*`, `/theme/*`, `/sw/*` always loses to
|
|
614
|
+
StreamWeaver's internal routes - `endpoint` warns at registration
|
|
615
|
+
time if you collide
|
|
616
|
+
|
|
617
|
+
### This Tutorial Eats Its Own Dog Food
|
|
618
|
+
|
|
619
|
+
This tutorial app registers a real endpoint:
|
|
620
|
+
`endpoint :get, "/tutorial/api/hello"`. With the tutorial running, hit
|
|
621
|
+
it from another terminal (check the startup banner for the actual port
|
|
622
|
+
- StreamWeaver auto-picks one starting at 4567):
|
|
623
|
+
|
|
624
|
+
```bash
|
|
625
|
+
curl http://127.0.0.1:<PORT>/tutorial/api/hello
|
|
626
|
+
```
|
|
627
|
+
MD
|
|
628
|
+
code: <<~RUBY
|
|
629
|
+
endpoint :get, "/api/ping" do |req|
|
|
630
|
+
{ ok: true, message: "pong", name: req.params["name"] || "world" }
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
header3 "Live Endpoint"
|
|
634
|
+
text "Registered: GET /api/ping"
|
|
635
|
+
text "Click Run, then curl the playground URL's /api/ping"
|
|
636
|
+
RUBY
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
SERVICE_MODE = Section.new(
|
|
640
|
+
id: :service_mode,
|
|
641
|
+
nav_title: "Service Mode",
|
|
642
|
+
title: "One Server, Many Apps, Human URLs",
|
|
643
|
+
content: <<~MD,
|
|
644
|
+
## Multiple Apps, One Server
|
|
645
|
+
|
|
646
|
+
`streamweaver run app.rb` (or just `streamweaver app.rb`) starts (or
|
|
647
|
+
reuses) a single background service and serves your app at a
|
|
648
|
+
human-readable slug URL like `/apps/sales-dashboard`, derived from the
|
|
649
|
+
app's declared name (falling back to the filename). The old opaque hex
|
|
650
|
+
`/apps/:app_id` URL still resolves too, as a canonical fallback.
|
|
651
|
+
|
|
652
|
+
- Slugs that collide across different files get a numeric suffix
|
|
653
|
+
(`-2`, `-3`, ...)
|
|
654
|
+
- Re-loading the same file reuses its existing slug
|
|
655
|
+
- `streamweaver list` shows every loaded app and its URL;
|
|
656
|
+
`streamweaver remove <app_id>` / `streamweaver clear` tear them down
|
|
657
|
+
|
|
658
|
+
**This tutorial already uses the service** - every "Run" button on a
|
|
659
|
+
code panel loads that playground into the service and hands you back
|
|
660
|
+
its slug URL. That's `streamweaver`'s service doing exactly this job,
|
|
661
|
+
one playground at a time.
|
|
662
|
+
MD
|
|
663
|
+
code: <<~RUBY
|
|
664
|
+
text_field :demo_app_name, placeholder: "App name...", default: "Sales Dashboard"
|
|
665
|
+
|
|
666
|
+
name = state[:demo_app_name].to_s.strip
|
|
667
|
+
name = "Sales Dashboard" if name.empty?
|
|
668
|
+
slug = name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\\A-+|-+\\z/, "")
|
|
669
|
+
|
|
670
|
+
header3 "Derived slug"
|
|
671
|
+
code_block "/apps/\#{slug}", lang: "text"
|
|
672
|
+
text "A second app with the same name gets /apps/\#{slug}-2, and so on."
|
|
673
|
+
RUBY
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
CANVAS_PANEL = Section.new(
|
|
677
|
+
id: :canvas_panel,
|
|
678
|
+
nav_title: "Canvas / Panel",
|
|
679
|
+
title: "Live UI For Coding Agents",
|
|
680
|
+
content: <<~MD,
|
|
681
|
+
## A Canvas Claude Code Can Push To
|
|
682
|
+
|
|
683
|
+
Canvas/Panel mode gives an agent (Claude Code or similar) a
|
|
684
|
+
**persistent browser session** it can push UI into and read responses
|
|
685
|
+
from, mid-conversation - no polling, no screenshots.
|
|
686
|
+
|
|
687
|
+
This lesson is a CLI walkthrough, not an in-page demo - canvas needs
|
|
688
|
+
its own terminal and browser pane. Copy these into a real terminal:
|
|
689
|
+
|
|
690
|
+
```bash
|
|
691
|
+
# 1. Open a panel - splits iTerm2, opens the canvas in the right pane
|
|
692
|
+
# (falls back to your system browser outside iTerm2 / without iterm2_ruby)
|
|
693
|
+
streamweaver panel demo-session
|
|
694
|
+
|
|
695
|
+
# 2. Push some UI to it (DSL via stdin)
|
|
696
|
+
streamweaver canvas-push demo-session <<'RUBY'
|
|
697
|
+
header1 "Working..."
|
|
698
|
+
spinner label: "Crunching numbers"
|
|
699
|
+
RUBY
|
|
700
|
+
|
|
701
|
+
# 3. Ask the user something and block for the answer
|
|
702
|
+
streamweaver canvas-wait demo-session
|
|
703
|
+
# => {"choice":"B"}
|
|
704
|
+
|
|
705
|
+
# High-level one-shot helpers - skip the create/push/wait dance
|
|
706
|
+
streamweaver pick "Pick a branch" "main" "feature/x" "feature/y"
|
|
707
|
+
streamweaver confirm "Deploy to production?"
|
|
708
|
+
|
|
709
|
+
# When you're done
|
|
710
|
+
streamweaver canvas-close demo-session
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
See `docs/canvas-roadmap.md` and `docs/streamweaver-for-ai-agents.md`
|
|
714
|
+
for the full pattern catalog.
|
|
715
|
+
MD
|
|
716
|
+
code: <<~RUBY
|
|
717
|
+
# This is a shell walkthrough, not a StreamWeaver app - copy it into a
|
|
718
|
+
# terminal rather than clicking Run.
|
|
719
|
+
|
|
720
|
+
# streamweaver panel demo-session
|
|
721
|
+
# streamweaver canvas-push demo-session <<'RUBY'
|
|
722
|
+
# header1 "Working..."
|
|
723
|
+
# spinner label: "Crunching numbers"
|
|
724
|
+
# RUBY
|
|
725
|
+
# streamweaver canvas-wait demo-session
|
|
726
|
+
RUBY
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
PATTERNS = Section.new(
|
|
730
|
+
id: :patterns,
|
|
731
|
+
nav_title: "Patterns",
|
|
732
|
+
title: "Real-World Patterns",
|
|
733
|
+
content: <<~MD,
|
|
734
|
+
## Multi-Step Forms
|
|
735
|
+
|
|
736
|
+
Combine state and conditionals to build wizards and multi-step flows.
|
|
737
|
+
|
|
738
|
+
### The Pattern
|
|
739
|
+
|
|
740
|
+
1. Track current step in state
|
|
741
|
+
2. Use `case` or `if` to show the right content
|
|
742
|
+
3. Buttons update the step
|
|
743
|
+
|
|
744
|
+
This same pattern works for:
|
|
745
|
+
- Onboarding flows
|
|
746
|
+
- Checkout processes
|
|
747
|
+
- Survey wizards
|
|
748
|
+
MD
|
|
749
|
+
code: <<~RUBY
|
|
750
|
+
state[:step] ||= 1
|
|
751
|
+
|
|
752
|
+
# Progress indicator
|
|
753
|
+
hstack spacing: :sm do
|
|
754
|
+
[1, 2, 3].each do |n|
|
|
755
|
+
style = state[:step] >= n ? "background: var(--sw-color-primary); color: white;" : "background: #eee; color: #666;"
|
|
756
|
+
div style: "\#{style} width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;" do
|
|
757
|
+
text n.to_s
|
|
758
|
+
end
|
|
759
|
+
end
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
div style: "margin: 1rem 0;" do end
|
|
763
|
+
|
|
764
|
+
case state[:step]
|
|
765
|
+
when 1
|
|
766
|
+
header3 "Step 1: Your Info"
|
|
767
|
+
text_field :wizard_name, placeholder: "Your name"
|
|
768
|
+
button "Next" do |s|
|
|
769
|
+
s[:step] = 2 if s[:wizard_name].to_s.strip != ""
|
|
770
|
+
end
|
|
771
|
+
when 2
|
|
772
|
+
header3 "Step 2: Preferences"
|
|
773
|
+
select :wizard_plan, ["Free", "Pro", "Enterprise"]
|
|
774
|
+
hstack spacing: :sm do
|
|
775
|
+
button "Back", style: :secondary do |s| s[:step] = 1 end
|
|
776
|
+
button "Next" do |s| s[:step] = 3 end
|
|
777
|
+
end
|
|
778
|
+
when 3
|
|
779
|
+
header3 "Step 3: Confirm"
|
|
780
|
+
text "Name: \#{state[:wizard_name]}"
|
|
781
|
+
text "Plan: \#{state[:wizard_plan] || 'Free'}"
|
|
782
|
+
hstack spacing: :sm do
|
|
783
|
+
button "Back", style: :secondary do |s| s[:step] = 2 end
|
|
784
|
+
button "Submit" do |s| s[:done] = true end
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
if state[:done]
|
|
789
|
+
alert(variant: :success) { text "All done! Welcome, \#{state[:wizard_name]}!" }
|
|
790
|
+
end
|
|
791
|
+
RUBY
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
ALL_SECTIONS = [
|
|
795
|
+
PHILOSOPHY,
|
|
796
|
+
HELLO_WORLD,
|
|
797
|
+
FOUR_MODES,
|
|
798
|
+
GETTING_INPUT,
|
|
799
|
+
MAKING_CHOICES,
|
|
800
|
+
TAKING_ACTION,
|
|
801
|
+
LAYOUT,
|
|
802
|
+
TABLES,
|
|
803
|
+
MODALS,
|
|
804
|
+
THEMES,
|
|
805
|
+
NAVIGATION,
|
|
806
|
+
RESOURCE_DSL,
|
|
807
|
+
ENDPOINTS,
|
|
808
|
+
SERVICE_MODE,
|
|
809
|
+
CANVAS_PANEL,
|
|
810
|
+
PATTERNS
|
|
811
|
+
].freeze
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
# =============================================================================
|
|
815
|
+
# SERVICE HELPERS - API calls to StreamWeaver service
|
|
816
|
+
# =============================================================================
|
|
817
|
+
|
|
818
|
+
module ServiceHelpers
|
|
819
|
+
include StreamWeaver::ServiceClient
|
|
820
|
+
|
|
821
|
+
def run_playground_via_service(section_id, code)
|
|
822
|
+
# Remove old version if same section was already loaded
|
|
823
|
+
if LOADED_APPS[section_id]
|
|
824
|
+
remove_app_via_service(LOADED_APPS[section_id][:app_id])
|
|
825
|
+
LOADED_APPS.delete(section_id)
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# Write temp file with full app wrapper
|
|
829
|
+
temp_file = "/tmp/streamweaver_tutorial_#{section_id}.rb"
|
|
830
|
+
full_code = <<~RUBY
|
|
831
|
+
require 'stream_weaver'
|
|
832
|
+
|
|
833
|
+
app "Tutorial: #{section_id}" do
|
|
834
|
+
#{code}
|
|
835
|
+
end
|
|
836
|
+
RUBY
|
|
837
|
+
File.write(temp_file, full_code)
|
|
838
|
+
|
|
839
|
+
# Load via ServiceClient
|
|
840
|
+
result = load_app_via_service(temp_file, source: SOURCE, name: "tutorial/#{section_id}")
|
|
841
|
+
|
|
842
|
+
if result[:ok]
|
|
843
|
+
LOADED_APPS[section_id] = {
|
|
844
|
+
app_id: result[:app_id],
|
|
845
|
+
aliased_url: result[:aliased_url]
|
|
846
|
+
}
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
result
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
def remove_playground_via_service(app_id)
|
|
853
|
+
remove_app_via_service(app_id)
|
|
854
|
+
LOADED_APPS.delete_if { |_id, info| info[:app_id] == app_id }
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
# =============================================================================
|
|
859
|
+
# HELPER MODULE - Reusable rendering helpers
|
|
860
|
+
# =============================================================================
|
|
861
|
+
|
|
862
|
+
module TutorialHelpers
|
|
863
|
+
include ServiceHelpers
|
|
864
|
+
|
|
865
|
+
def code_panel(section_id, original_code)
|
|
866
|
+
# Store edited code in state (initialized in main app)
|
|
867
|
+
state_key = :"#{section_id}_edited_code"
|
|
868
|
+
|
|
869
|
+
# Track if modified
|
|
870
|
+
is_modified = state[state_key] != original_code
|
|
871
|
+
|
|
872
|
+
# Use CodeMirror editor with syntax highlighting
|
|
873
|
+
code_editor state_key, language: :ruby, readonly: false, height: "350px"
|
|
874
|
+
|
|
875
|
+
# Show modified indicator
|
|
876
|
+
if is_modified
|
|
877
|
+
div style: "margin-top: 4px; font-size: 12px; color: #666;" do
|
|
878
|
+
text "Modified"
|
|
879
|
+
end
|
|
880
|
+
end
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
def nav_link(section, current_id)
|
|
884
|
+
is_active = section.id == current_id
|
|
885
|
+
|
|
886
|
+
# Check if modified
|
|
887
|
+
is_modified = state[:"#{section.id}_edited_code"] != section.code
|
|
888
|
+
|
|
889
|
+
# Styled list item instead of button - looks like a ToC
|
|
890
|
+
nav_item_style = <<~CSS.gsub("\n", " ")
|
|
891
|
+
display: block;
|
|
892
|
+
padding: 8px 12px;
|
|
893
|
+
cursor: pointer;
|
|
894
|
+
border-radius: 4px;
|
|
895
|
+
border-left: 3px solid #{is_active ? 'var(--sw-color-primary)' : 'transparent'};
|
|
896
|
+
background: #{is_active ? '#f0f7ff' : 'transparent'};
|
|
897
|
+
color: #{is_active ? '#1a73e8' : '#444'};
|
|
898
|
+
font-weight: #{is_active ? '500' : 'normal'};
|
|
899
|
+
font-size: 14px;
|
|
900
|
+
text-decoration: none;
|
|
901
|
+
border-top: none;
|
|
902
|
+
border-right: none;
|
|
903
|
+
border-bottom: none;
|
|
904
|
+
text-align: left;
|
|
905
|
+
width: 100%;
|
|
906
|
+
CSS
|
|
907
|
+
|
|
908
|
+
# Use stable label for button ID (don't include indicator)
|
|
909
|
+
# Show modified indicator separately in the button content
|
|
910
|
+
div style: "display: flex; align-items: center;" do
|
|
911
|
+
if is_modified
|
|
912
|
+
div style: "width: 6px; height: 6px; background: var(--sw-color-primary); border-radius: 50%; margin-right: 6px;" do end
|
|
913
|
+
end
|
|
914
|
+
button section.nav_title, style: nav_item_style do |s|
|
|
915
|
+
s[:current_section] = section.id
|
|
916
|
+
end
|
|
917
|
+
end
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
def check_syntax(code)
|
|
921
|
+
require 'open3'
|
|
922
|
+
temp_file = "/tmp/sw_tutorial_syntax_#{Process.pid}.rb"
|
|
923
|
+
File.write(temp_file, code)
|
|
924
|
+
_stdout, stderr, status = Open3.capture3("ruby", "-c", temp_file)
|
|
925
|
+
File.delete(temp_file) rescue nil
|
|
926
|
+
|
|
927
|
+
if status.success?
|
|
928
|
+
{ ok: true, message: "Syntax OK" }
|
|
929
|
+
else
|
|
930
|
+
error = stderr.gsub(temp_file, "code")
|
|
931
|
+
{ ok: false, message: error.strip }
|
|
932
|
+
end
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
def code_action_buttons(section, current)
|
|
936
|
+
is_modified = state[:"#{section.id}_edited_code"] != section.code
|
|
937
|
+
|
|
938
|
+
hstack spacing: :xs do
|
|
939
|
+
# Check syntax
|
|
940
|
+
button "Check", style: :secondary do |s|
|
|
941
|
+
result = check_syntax(s[:"#{section.id}_edited_code"])
|
|
942
|
+
if result[:ok]
|
|
943
|
+
s[:syntax_result] = :ok
|
|
944
|
+
else
|
|
945
|
+
s[:syntax_result] = :error
|
|
946
|
+
s[:syntax_error] = result[:message]
|
|
947
|
+
end
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
# Reset this lesson only - ALWAYS render with block for stable ID
|
|
951
|
+
# The block checks is_modified at execution time, not render time
|
|
952
|
+
reset_style = is_modified ? :secondary : "padding: 8px 16px; background: #f5f5f5; color: #aaa; border: 1px solid #ddd; border-radius: 6px; cursor: default;"
|
|
953
|
+
button "Reset", style: reset_style do |s|
|
|
954
|
+
# Only reset if actually modified (check at execution time)
|
|
955
|
+
edited = s[:"#{section.id}_edited_code"]
|
|
956
|
+
if edited != section.code
|
|
957
|
+
s[:"#{section.id}_reset"] = true
|
|
958
|
+
s[:syntax_result] = nil
|
|
959
|
+
end
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
# Run as standalone app
|
|
963
|
+
button "Run" do |s|
|
|
964
|
+
edited_code = s[:"#{section.id}_edited_code"]
|
|
965
|
+
result = check_syntax(edited_code)
|
|
966
|
+
if result[:ok]
|
|
967
|
+
run_result = run_playground_via_service(section.id, edited_code)
|
|
968
|
+
if run_result[:ok]
|
|
969
|
+
open_in_browser(run_result[:url])
|
|
970
|
+
s[:standalone_launched] = section.id
|
|
971
|
+
s[:syntax_result] = nil
|
|
972
|
+
else
|
|
973
|
+
s[:syntax_result] = :error
|
|
974
|
+
s[:syntax_error] = run_result[:error]
|
|
975
|
+
end
|
|
976
|
+
else
|
|
977
|
+
s[:syntax_result] = :error
|
|
978
|
+
s[:syntax_error] = result[:message]
|
|
979
|
+
end
|
|
980
|
+
end
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
# Show running playground link
|
|
984
|
+
if LOADED_APPS[section.id]
|
|
985
|
+
app_info = LOADED_APPS[section.id]
|
|
986
|
+
url = app_info[:aliased_url] || "http://localhost:#{service_port}/apps/#{app_info[:app_id]}"
|
|
987
|
+
div style: "margin-top: 8px; padding: 6px 10px; background: #e8f5e9; border-radius: 4px; font-size: 13px; display: flex; justify-content: space-between; align-items: center;" do
|
|
988
|
+
div style: "color: #2e7d32;" do
|
|
989
|
+
text "Running"
|
|
990
|
+
if app_info[:aliased_url]
|
|
991
|
+
div style: "font-family: monospace; font-size: 11px; color: #666;" do
|
|
992
|
+
text app_info[:aliased_url].sub("http://localhost:#{service_port}", "")
|
|
993
|
+
end
|
|
994
|
+
end
|
|
995
|
+
end
|
|
996
|
+
hstack spacing: :xs do
|
|
997
|
+
button "Open", style: "padding: 2px 8px; font-size: 12px; background: #4caf50; color: white; border: none; border-radius: 3px; cursor: pointer;" do |_s|
|
|
998
|
+
open_in_browser(url)
|
|
999
|
+
end
|
|
1000
|
+
button "Stop", style: "padding: 2px 8px; font-size: 12px; background: transparent; color: #666; border: none; cursor: pointer;" do |s|
|
|
1001
|
+
remove_playground_via_service(app_info[:app_id])
|
|
1002
|
+
LOADED_APPS.delete(section.id)
|
|
1003
|
+
s[:standalone_launched] = nil
|
|
1004
|
+
end
|
|
1005
|
+
end
|
|
1006
|
+
end
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
# Show syntax result
|
|
1010
|
+
if state[:syntax_result] == :ok
|
|
1011
|
+
div style: "margin-top: 8px; padding: 6px 10px; background: #d4edda; color: #155724; border-radius: 4px; font-size: 13px;" do
|
|
1012
|
+
text "Syntax OK"
|
|
1013
|
+
end
|
|
1014
|
+
elsif state[:syntax_result] == :error
|
|
1015
|
+
div style: "margin-top: 8px; padding: 6px 10px; background: #f8d7da; color: #721c24; border-radius: 4px; font-size: 13px; font-family: monospace; white-space: pre-wrap;" do
|
|
1016
|
+
text state[:syntax_error]
|
|
1017
|
+
end
|
|
1018
|
+
end
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
def clear_playgrounds_button
|
|
1022
|
+
# Always render for stable button IDs
|
|
1023
|
+
if LOADED_APPS.any?
|
|
1024
|
+
button "Clear Playgrounds", style: :secondary do |s|
|
|
1025
|
+
LOADED_APPS.each { |_id, info| remove_playground_via_service(info[:app_id]) }
|
|
1026
|
+
LOADED_APPS.clear
|
|
1027
|
+
s[:standalone_launched] = nil
|
|
1028
|
+
end
|
|
1029
|
+
else
|
|
1030
|
+
# Disabled placeholder for stable ID
|
|
1031
|
+
button "Clear Playgrounds", style: "padding: 8px 16px; background: #f5f5f5; color: #aaa; border: 1px solid #ddd; border-radius: 6px; cursor: default;", submit: false
|
|
1032
|
+
end
|
|
1033
|
+
end
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
# =============================================================================
|
|
1037
|
+
# DEMO RENDERERS - Live demos for each section
|
|
1038
|
+
# =============================================================================
|
|
1039
|
+
|
|
1040
|
+
module DemoRenderers
|
|
1041
|
+
# Dynamic dispatch instead of case statement (DRY)
|
|
1042
|
+
def render_demo(section_id, state)
|
|
1043
|
+
method_name = :"render_#{section_id}_demo"
|
|
1044
|
+
send(method_name, state) if respond_to?(method_name, true)
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
def render_philosophy_demo(state)
|
|
1048
|
+
card do
|
|
1049
|
+
card_header "Try It"
|
|
1050
|
+
card_body do
|
|
1051
|
+
header1 "Welcome!"
|
|
1052
|
+
text_field :demo_philosophy_name, placeholder: "Your name"
|
|
1053
|
+
if state[:demo_philosophy_name].to_s.strip != ""
|
|
1054
|
+
text "Hello, #{state[:demo_philosophy_name]}! Welcome to StreamWeaver."
|
|
1055
|
+
else
|
|
1056
|
+
text "Type your name above to see reactive updates."
|
|
1057
|
+
end
|
|
1058
|
+
end
|
|
1059
|
+
end
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
def render_hello_world_demo(state)
|
|
1063
|
+
card do
|
|
1064
|
+
card_header "Try It"
|
|
1065
|
+
card_body do
|
|
1066
|
+
header1 "Welcome!"
|
|
1067
|
+
text_field :demo_name, placeholder: "Your name"
|
|
1068
|
+
if state[:demo_name].to_s.strip != ""
|
|
1069
|
+
text "Hello, #{state[:demo_name]}!"
|
|
1070
|
+
end
|
|
1071
|
+
end
|
|
1072
|
+
end
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
def render_four_modes_demo(state)
|
|
1076
|
+
card do
|
|
1077
|
+
card_header "Try It"
|
|
1078
|
+
card_body do
|
|
1079
|
+
select :demo_mode_pick, [
|
|
1080
|
+
"Standalone (ruby app.rb)",
|
|
1081
|
+
"Agentic (run_once!)",
|
|
1082
|
+
"Service (streamweaver run)",
|
|
1083
|
+
"Canvas/Panel (streamweaver panel)"
|
|
1084
|
+
], default: "Standalone (ruby app.rb)"
|
|
1085
|
+
|
|
1086
|
+
case state[:demo_mode_pick]
|
|
1087
|
+
when "Standalone (ruby app.rb)"
|
|
1088
|
+
alert(variant: :info) { text 'app("My App") { ... }.run!' }
|
|
1089
|
+
when "Agentic (run_once!)"
|
|
1090
|
+
alert(variant: :info) { text "StreamWeaver.run_once! { ... } # blocks, returns a Hash" }
|
|
1091
|
+
when "Service (streamweaver run)"
|
|
1092
|
+
alert(variant: :info) { text "streamweaver run app.rb -> http://localhost:4567/apps/your-app" }
|
|
1093
|
+
when "Canvas/Panel (streamweaver panel)"
|
|
1094
|
+
alert(variant: :info) { text "streamweaver panel my-session, then canvas-push my-session" }
|
|
1095
|
+
end
|
|
1096
|
+
end
|
|
1097
|
+
end
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
def render_getting_input_demo(state)
|
|
1101
|
+
card do
|
|
1102
|
+
card_header "Try It"
|
|
1103
|
+
card_body do
|
|
1104
|
+
text_field :demo_email, placeholder: "Email"
|
|
1105
|
+
text_area :demo_message, placeholder: "Your message...", rows: 3
|
|
1106
|
+
|
|
1107
|
+
if state[:demo_email].to_s.include?("@")
|
|
1108
|
+
alert(variant: :success) { text "Valid email format!" }
|
|
1109
|
+
elsif state[:demo_email].to_s.length > 0
|
|
1110
|
+
alert(variant: :warning) { text "Please include @ in email" }
|
|
1111
|
+
end
|
|
1112
|
+
end
|
|
1113
|
+
end
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1116
|
+
def render_making_choices_demo(state)
|
|
1117
|
+
card do
|
|
1118
|
+
card_header "Try It"
|
|
1119
|
+
card_body do
|
|
1120
|
+
select :demo_priority, ["Low", "Medium", "High"], default: "Medium"
|
|
1121
|
+
checkbox :demo_urgent, "Mark as urgent"
|
|
1122
|
+
radio_group :demo_category, ["Bug", "Feature", "Question"]
|
|
1123
|
+
|
|
1124
|
+
if state[:demo_priority] || state[:demo_category]
|
|
1125
|
+
vstack spacing: :sm do
|
|
1126
|
+
text "Priority: #{state[:demo_priority] || 'Not set'}"
|
|
1127
|
+
text "Urgent: #{state[:demo_urgent] ? 'Yes' : 'No'}"
|
|
1128
|
+
text "Category: #{state[:demo_category] || 'Not set'}"
|
|
1129
|
+
end
|
|
1130
|
+
end
|
|
1131
|
+
end
|
|
1132
|
+
end
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
def render_taking_action_demo(state)
|
|
1136
|
+
card do
|
|
1137
|
+
card_header "Try It"
|
|
1138
|
+
card_body do
|
|
1139
|
+
state[:demo_count] ||= 0
|
|
1140
|
+
|
|
1141
|
+
header2 "Count: #{state[:demo_count]}"
|
|
1142
|
+
|
|
1143
|
+
hstack spacing: :sm do
|
|
1144
|
+
button "Increment" do |s|
|
|
1145
|
+
s[:demo_count] += 1
|
|
1146
|
+
end
|
|
1147
|
+
button "Decrement", style: :secondary do |s|
|
|
1148
|
+
s[:demo_count] -= 1
|
|
1149
|
+
end
|
|
1150
|
+
button "Reset", style: :secondary do |s|
|
|
1151
|
+
s[:demo_count] = 0
|
|
1152
|
+
end
|
|
1153
|
+
end
|
|
1154
|
+
end
|
|
1155
|
+
end
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
def render_layout_demo(_state)
|
|
1159
|
+
card do
|
|
1160
|
+
card_header "Try It"
|
|
1161
|
+
card_body do
|
|
1162
|
+
columns widths: ["40%", "60%"] do
|
|
1163
|
+
column do
|
|
1164
|
+
card do
|
|
1165
|
+
card_body do
|
|
1166
|
+
text "Sidebar (40%)"
|
|
1167
|
+
end
|
|
1168
|
+
end
|
|
1169
|
+
end
|
|
1170
|
+
column do
|
|
1171
|
+
card do
|
|
1172
|
+
card_header "Main Content", badge: "C1", meta: "content pane"
|
|
1173
|
+
card_body do
|
|
1174
|
+
vstack spacing: :sm do
|
|
1175
|
+
text "Main content (60%)"
|
|
1176
|
+
hstack spacing: :sm do
|
|
1177
|
+
alert(variant: :info) { text "Alert 1" }
|
|
1178
|
+
alert(variant: :success) { text "Alert 2" }
|
|
1179
|
+
end
|
|
1180
|
+
end
|
|
1181
|
+
end
|
|
1182
|
+
end
|
|
1183
|
+
end
|
|
1184
|
+
end
|
|
1185
|
+
end
|
|
1186
|
+
end
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1189
|
+
def render_tables_demo(_state)
|
|
1190
|
+
card do
|
|
1191
|
+
card_header "Try It"
|
|
1192
|
+
card_body do
|
|
1193
|
+
data = [
|
|
1194
|
+
{ name: "Alice", role: "Admin", active: "Yes" },
|
|
1195
|
+
{ name: "Bob", role: "User", active: "Yes" },
|
|
1196
|
+
{ name: "Charlie", role: "Guest", active: "No" }
|
|
1197
|
+
]
|
|
1198
|
+
|
|
1199
|
+
div style: "display: grid; grid-template-columns: 1fr 1fr 80px; border: 1px solid #ddd; border-radius: 4px;" do
|
|
1200
|
+
# Header
|
|
1201
|
+
["Name", "Role", "Active?"].each do |h|
|
|
1202
|
+
div style: "padding: 10px; background: #f5f5f5; font-weight: 600; border-bottom: 2px solid #ddd;" do
|
|
1203
|
+
text h
|
|
1204
|
+
end
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
# Data rows
|
|
1208
|
+
data.each do |row|
|
|
1209
|
+
[:name, :role, :active].each do |col|
|
|
1210
|
+
div style: "padding: 10px; border-bottom: 1px solid #eee;" do
|
|
1211
|
+
text row[col]
|
|
1212
|
+
end
|
|
1213
|
+
end
|
|
1214
|
+
end
|
|
1215
|
+
end
|
|
1216
|
+
end
|
|
1217
|
+
end
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
def render_modals_demo(state)
|
|
1221
|
+
card do
|
|
1222
|
+
card_header "Try It"
|
|
1223
|
+
card_body do
|
|
1224
|
+
button "Show Confirmation" do |s|
|
|
1225
|
+
s[:demo_modal_open] = true
|
|
1226
|
+
end
|
|
1227
|
+
|
|
1228
|
+
if state[:demo_modal_confirmed]
|
|
1229
|
+
alert(variant: :success) { text "Action confirmed!" }
|
|
1230
|
+
end
|
|
1231
|
+
|
|
1232
|
+
modal :demo_modal, title: "Confirm Action", size: :sm do
|
|
1233
|
+
text "Do you want to proceed with this action?"
|
|
1234
|
+
|
|
1235
|
+
modal_footer do
|
|
1236
|
+
button "Confirm" do |s|
|
|
1237
|
+
s[:demo_modal_confirmed] = true
|
|
1238
|
+
s[:demo_modal_open] = false
|
|
1239
|
+
end
|
|
1240
|
+
button "Cancel", style: :secondary do |s|
|
|
1241
|
+
s[:demo_modal_open] = false
|
|
1242
|
+
end
|
|
1243
|
+
end
|
|
1244
|
+
end
|
|
1245
|
+
end
|
|
1246
|
+
end
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
def render_themes_demo(_state)
|
|
1250
|
+
card do
|
|
1251
|
+
card_header "Try It"
|
|
1252
|
+
card_body do
|
|
1253
|
+
hstack spacing: :sm, align: :center do
|
|
1254
|
+
theme_switcher
|
|
1255
|
+
theme_toggle mode: :auto
|
|
1256
|
+
end
|
|
1257
|
+
div style: "background: var(--sw-color-primary); color: white; padding: 1rem; border-radius: 8px; margin-top: 0.75rem; margin-bottom: 0.5rem;" do
|
|
1258
|
+
text "Primary color box"
|
|
1259
|
+
end
|
|
1260
|
+
div style: "background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 1rem; border-radius: 8px; color: white;" do
|
|
1261
|
+
text "Custom gradient"
|
|
1262
|
+
end
|
|
1263
|
+
end
|
|
1264
|
+
end
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
def render_navigation_demo(_state)
|
|
1268
|
+
card do
|
|
1269
|
+
card_header "Try It"
|
|
1270
|
+
card_body do
|
|
1271
|
+
text "Preview of the navbar look (click Run above to try real state-routing URLs in an isolated playground):"
|
|
1272
|
+
navbar do
|
|
1273
|
+
nav_item "Home", active: true
|
|
1274
|
+
nav_item "Settings"
|
|
1275
|
+
nav_item "Profile"
|
|
1276
|
+
end
|
|
1277
|
+
div style: "margin-top: 0.75rem;" do end
|
|
1278
|
+
link_to "External docs example", href: "https://example.com"
|
|
1279
|
+
end
|
|
1280
|
+
end
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1283
|
+
def render_resource_dsl_demo(_state)
|
|
1284
|
+
card do
|
|
1285
|
+
card_header "Try It"
|
|
1286
|
+
card_body do
|
|
1287
|
+
text "Preview of a generated index view (real resource blocks are deep-linkable; click Run for the live version):"
|
|
1288
|
+
div style: "display: grid; grid-template-columns: 1fr 1fr 160px; border: 1px solid #ddd; border-radius: 4px; margin-top: 0.5rem;" do
|
|
1289
|
+
["Title", "Status", "Actions"].each do |h|
|
|
1290
|
+
div style: "padding: 8px 10px; background: #f5f5f5; font-weight: 600; border-bottom: 2px solid #ddd;" do
|
|
1291
|
+
text h
|
|
1292
|
+
end
|
|
1293
|
+
end
|
|
1294
|
+
[["Hello", "published"], ["Draft idea", "draft"]].each do |title, status|
|
|
1295
|
+
div(style: "padding: 8px 10px; border-bottom: 1px solid #eee;") { text title }
|
|
1296
|
+
div(style: "padding: 8px 10px; border-bottom: 1px solid #eee;") { text status }
|
|
1297
|
+
div(style: "padding: 8px 10px; border-bottom: 1px solid #eee;") { text "View · Edit · Delete" }
|
|
1298
|
+
end
|
|
1299
|
+
end
|
|
1300
|
+
end
|
|
1301
|
+
end
|
|
1302
|
+
end
|
|
1303
|
+
|
|
1304
|
+
def render_endpoints_demo(_state)
|
|
1305
|
+
card do
|
|
1306
|
+
card_header "Try It", badge: "Live", meta: "runs against this very page"
|
|
1307
|
+
card_body do
|
|
1308
|
+
text "This tutorial registered a real endpoint. From another terminal:"
|
|
1309
|
+
code_block "curl http://127.0.0.1:<PORT>/tutorial/api/hello", lang: "bash"
|
|
1310
|
+
text "(Swap <PORT> for whatever the startup banner printed - StreamWeaver auto-picks a free port starting at 4567.)"
|
|
1311
|
+
end
|
|
1312
|
+
end
|
|
1313
|
+
end
|
|
1314
|
+
|
|
1315
|
+
def render_service_mode_demo(state)
|
|
1316
|
+
card do
|
|
1317
|
+
card_header "Try It"
|
|
1318
|
+
card_body do
|
|
1319
|
+
text_field :demo_app_name, placeholder: "App name...", default: "Sales Dashboard"
|
|
1320
|
+
|
|
1321
|
+
name = state[:demo_app_name].to_s.strip
|
|
1322
|
+
name = "Sales Dashboard" if name.empty?
|
|
1323
|
+
slug = name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
1324
|
+
|
|
1325
|
+
header3 "Derived slug"
|
|
1326
|
+
code_block "/apps/#{slug}", lang: "text"
|
|
1327
|
+
text "A second app with the same name gets /apps/#{slug}-2, and so on."
|
|
1328
|
+
end
|
|
1329
|
+
end
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
def render_canvas_panel_demo(_state)
|
|
1333
|
+
card do
|
|
1334
|
+
card_header "Try It", meta: "in a real terminal"
|
|
1335
|
+
card_body do
|
|
1336
|
+
alert(variant: :info) { text "Canvas/Panel needs its own terminal + browser pane - there's nothing to click here." }
|
|
1337
|
+
text "Copy the commands from the Code panel into a terminal to try it for real."
|
|
1338
|
+
end
|
|
1339
|
+
end
|
|
1340
|
+
end
|
|
1341
|
+
|
|
1342
|
+
def render_patterns_demo(state)
|
|
1343
|
+
card do
|
|
1344
|
+
card_header "Try It"
|
|
1345
|
+
card_body do
|
|
1346
|
+
state[:demo_step] ||= 1
|
|
1347
|
+
|
|
1348
|
+
# Progress dots
|
|
1349
|
+
hstack spacing: :xs do
|
|
1350
|
+
[1, 2, 3].each do |n|
|
|
1351
|
+
bg = state[:demo_step] >= n ? "var(--sw-color-primary)" : "#ddd"
|
|
1352
|
+
fg = state[:demo_step] >= n ? "white" : "#666"
|
|
1353
|
+
div style: "background: #{bg}; color: #{fg}; width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold;" do
|
|
1354
|
+
text n.to_s
|
|
1355
|
+
end
|
|
1356
|
+
end
|
|
1357
|
+
end
|
|
1358
|
+
|
|
1359
|
+
div style: "margin: 0.75rem 0;" do end
|
|
1360
|
+
|
|
1361
|
+
case state[:demo_step]
|
|
1362
|
+
when 1
|
|
1363
|
+
text_field :demo_wizard_name, placeholder: "Your name"
|
|
1364
|
+
button "Next" do |s|
|
|
1365
|
+
s[:demo_step] = 2 if s[:demo_wizard_name].to_s.strip != ""
|
|
1366
|
+
end
|
|
1367
|
+
when 2
|
|
1368
|
+
select :demo_wizard_plan, ["Free", "Pro", "Enterprise"]
|
|
1369
|
+
hstack spacing: :sm do
|
|
1370
|
+
button "Back", style: :secondary do |s| s[:demo_step] = 1 end
|
|
1371
|
+
button "Next" do |s| s[:demo_step] = 3 end
|
|
1372
|
+
end
|
|
1373
|
+
when 3
|
|
1374
|
+
text "Name: #{state[:demo_wizard_name]}"
|
|
1375
|
+
text "Plan: #{state[:demo_wizard_plan] || 'Free'}"
|
|
1376
|
+
hstack spacing: :sm do
|
|
1377
|
+
button "Back", style: :secondary do |s| s[:demo_step] = 2 end
|
|
1378
|
+
button "Done" do |s| s[:demo_wizard_done] = true end
|
|
1379
|
+
end
|
|
1380
|
+
end
|
|
1381
|
+
|
|
1382
|
+
if state[:demo_wizard_done]
|
|
1383
|
+
alert(variant: :success) { text "Wizard complete!" }
|
|
1384
|
+
end
|
|
1385
|
+
end
|
|
1386
|
+
end
|
|
1387
|
+
end
|
|
1388
|
+
end
|
|
1389
|
+
|
|
1390
|
+
# =============================================================================
|
|
1391
|
+
# MAIN APP
|
|
1392
|
+
# =============================================================================
|
|
1393
|
+
|
|
1394
|
+
# Check for --reset flag
|
|
1395
|
+
RESET_MODE = ARGV.include?('--reset')
|
|
1396
|
+
|
|
1397
|
+
generated_app = app(
|
|
1398
|
+
"StreamWeaver Tutorial",
|
|
1399
|
+
layout: :fluid,
|
|
1400
|
+
theme: :default,
|
|
1401
|
+
stylesheets: [CODEMIRROR_CSS],
|
|
1402
|
+
scripts: [CODEMIRROR_JS, CODEMIRROR_RUBY],
|
|
1403
|
+
components: [TutorialHelpers, DemoRenderers]
|
|
1404
|
+
) do
|
|
1405
|
+
# Self-demonstrating endpoint for the "Endpoints" lesson - a real HTTP route
|
|
1406
|
+
# you can curl while the tutorial is running: see Sections::ENDPOINTS.
|
|
1407
|
+
endpoint :get, "/tutorial/api/hello" do |req|
|
|
1408
|
+
{ ok: true, message: "Hello from the StreamWeaver tutorial's own endpoint!", name: req.params["name"] || "world" }
|
|
1409
|
+
end
|
|
1410
|
+
|
|
1411
|
+
# Force clear state if --reset flag was passed
|
|
1412
|
+
if RESET_MODE && !state[:_reset_done]
|
|
1413
|
+
state.clear
|
|
1414
|
+
state[:_reset_done] = true
|
|
1415
|
+
state[:current_section] = :philosophy
|
|
1416
|
+
end
|
|
1417
|
+
|
|
1418
|
+
# Initialize state
|
|
1419
|
+
state[:current_section] ||= :philosophy
|
|
1420
|
+
|
|
1421
|
+
# Initialize edited code states for all sections
|
|
1422
|
+
# Check for reset flags (set by Reset button, survives session filtering)
|
|
1423
|
+
Sections::ALL_SECTIONS.each do |section|
|
|
1424
|
+
reset_flag = :"#{section.id}_reset"
|
|
1425
|
+
if state[reset_flag]
|
|
1426
|
+
# Reset was clicked - force original code and clear flag
|
|
1427
|
+
state[:"#{section.id}_edited_code"] = section.code
|
|
1428
|
+
state.delete(reset_flag)
|
|
1429
|
+
else
|
|
1430
|
+
state[:"#{section.id}_edited_code"] ||= section.code
|
|
1431
|
+
end
|
|
1432
|
+
end
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
# Find current section
|
|
1436
|
+
current = Sections::ALL_SECTIONS.find { |s| s.id == state[:current_section] }
|
|
1437
|
+
current ||= Sections::PHILOSOPHY
|
|
1438
|
+
|
|
1439
|
+
# Header
|
|
1440
|
+
hstack justify: :between, align: :center do
|
|
1441
|
+
header1 "StreamWeaver Tutorial"
|
|
1442
|
+
hstack spacing: :sm, align: :center do
|
|
1443
|
+
text "Learn by doing"
|
|
1444
|
+
clear_playgrounds_button
|
|
1445
|
+
button "Reset All", style: :secondary do |s|
|
|
1446
|
+
# Clear all state including edited code, but stay on current section
|
|
1447
|
+
current_section = s[:current_section]
|
|
1448
|
+
s.clear
|
|
1449
|
+
s[:current_section] = current_section || :philosophy
|
|
1450
|
+
# Set reset flags for all sections (init code will restore originals)
|
|
1451
|
+
Sections::ALL_SECTIONS.each do |section|
|
|
1452
|
+
s[:"#{section.id}_reset"] = true
|
|
1453
|
+
end
|
|
1454
|
+
end
|
|
1455
|
+
end
|
|
1456
|
+
end
|
|
1457
|
+
|
|
1458
|
+
# 3-column layout with stable widths
|
|
1459
|
+
div style: "display: flex; gap: 1.5rem; align-items: flex-start;" do
|
|
1460
|
+
# LEFT: Navigation (fixed width)
|
|
1461
|
+
div style: "flex: 0 0 180px; min-width: 180px;" do
|
|
1462
|
+
div style: "font-weight: 600; font-size: 14px; color: #666; margin-bottom: 8px; padding: 0 12px;" do
|
|
1463
|
+
text "Contents"
|
|
1464
|
+
end
|
|
1465
|
+
div do
|
|
1466
|
+
Sections::ALL_SECTIONS.each do |section|
|
|
1467
|
+
nav_link(section, state[:current_section])
|
|
1468
|
+
end
|
|
1469
|
+
end
|
|
1470
|
+
end
|
|
1471
|
+
|
|
1472
|
+
# MIDDLE: Content (roughly 50/50 with code)
|
|
1473
|
+
div style: "flex: 1 1 auto; min-width: 400px;" do
|
|
1474
|
+
header2 current.title
|
|
1475
|
+
md current.content
|
|
1476
|
+
|
|
1477
|
+
# Live demo
|
|
1478
|
+
header3 "Interactive Demo"
|
|
1479
|
+
render_demo(current.id, state)
|
|
1480
|
+
end
|
|
1481
|
+
|
|
1482
|
+
# RIGHT: Code (flexible - takes remaining space)
|
|
1483
|
+
div style: "flex: 1 1 auto; min-width: 400px;" do
|
|
1484
|
+
# Header row with Code label and action buttons
|
|
1485
|
+
div style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;" do
|
|
1486
|
+
div style: "font-weight: 600; font-size: 14px; color: #666;" do
|
|
1487
|
+
text "Code"
|
|
1488
|
+
end
|
|
1489
|
+
code_action_buttons(current, current)
|
|
1490
|
+
end
|
|
1491
|
+
|
|
1492
|
+
# Code editor in a card
|
|
1493
|
+
card do
|
|
1494
|
+
card_body do
|
|
1495
|
+
code_panel(current.id, current.code)
|
|
1496
|
+
end
|
|
1497
|
+
end
|
|
1498
|
+
end
|
|
1499
|
+
end
|
|
1500
|
+
|
|
1501
|
+
# Footer navigation (use stable labels for button IDs)
|
|
1502
|
+
hstack justify: :between do
|
|
1503
|
+
current_idx = Sections::ALL_SECTIONS.index(current)
|
|
1504
|
+
|
|
1505
|
+
# Previous button - always render with stable label
|
|
1506
|
+
if current_idx > 0
|
|
1507
|
+
prev_section = Sections::ALL_SECTIONS[current_idx - 1]
|
|
1508
|
+
button "Previous", style: :secondary do |s|
|
|
1509
|
+
s[:current_section] = prev_section.id
|
|
1510
|
+
end
|
|
1511
|
+
else
|
|
1512
|
+
button "Previous", style: "padding: 8px 16px; background: #f5f5f5; color: #aaa; border: 1px solid #ddd; border-radius: 6px; cursor: default;", submit: false
|
|
1513
|
+
end
|
|
1514
|
+
|
|
1515
|
+
# Next button - always render with stable label
|
|
1516
|
+
if current_idx < Sections::ALL_SECTIONS.length - 1
|
|
1517
|
+
next_section = Sections::ALL_SECTIONS[current_idx + 1]
|
|
1518
|
+
button "Next", style: :primary do |s|
|
|
1519
|
+
s[:current_section] = next_section.id
|
|
1520
|
+
end
|
|
1521
|
+
else
|
|
1522
|
+
button "Next", style: "padding: 8px 16px; background: #f5f5f5; color: #aaa; border: 1px solid #ddd; border-radius: 6px; cursor: default;", submit: false
|
|
1523
|
+
end
|
|
1524
|
+
end
|
|
1525
|
+
end
|
|
1526
|
+
|
|
1527
|
+
# Always run - tutorial command runs this standalone
|
|
1528
|
+
generated_app.run!
|