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,882 @@
|
|
|
1
|
+
<!-- GENERATED FILE -- do not edit directly. -->
|
|
2
|
+
<!-- Source outline: examples/tutorials/tutorial_content.rb -->
|
|
3
|
+
<!-- Regenerate: ruby examples/tutorials/render_markdown.rb -->
|
|
4
|
+
|
|
5
|
+
# Build My Todos: the StreamWeaver Way
|
|
6
|
+
|
|
7
|
+
*StreamWeaver · Tutorial* · Zero custom JavaScript · Four features, one file · Rebuilt from examples/my_todos
|
|
8
|
+
|
|
9
|
+
A guided rebuild of the four Turbo Frames features from the
|
|
10
|
+
[learnhotwire.com](https://learnhotwire.com) course, in StreamWeaver, with
|
|
11
|
+
no script tag, no inline JavaScript, and no hand-written Alpine anywhere in
|
|
12
|
+
app code. You will write about 120 lines of Ruby and get inline editing,
|
|
13
|
+
submit-as-you-type search, lazy hover cards, and Russian-doll infinite
|
|
14
|
+
scroll.
|
|
15
|
+
|
|
16
|
+
The `streamweaver-way` skill tells you **what** the conventions are. This
|
|
17
|
+
tutorial tells you **why** each one exists, in the order you'd hit them if
|
|
18
|
+
you built the app yourself. Every step names the law it obeys, the gotcha it
|
|
19
|
+
avoids, and — where one was actually measured — the number that makes the
|
|
20
|
+
case.
|
|
21
|
+
|
|
22
|
+
## Contents
|
|
23
|
+
|
|
24
|
+
- [01. Why this tutorial exists](#why)
|
|
25
|
+
- [02. The six laws, and why they're laws](#laws)
|
|
26
|
+
- [03. Start with the store](#store)
|
|
27
|
+
- [04. Feature 1 — Inline editing](#inline-editing)
|
|
28
|
+
- [05. Feature 2 — Scoped live search](#search)
|
|
29
|
+
- [06. Interlude — the three fragment timings](#timings)
|
|
30
|
+
- [07. Feature 3 — Lazy hover cards](#hover-cards)
|
|
31
|
+
- [08. Feature 4 — Russian-doll infinite scroll](#infinite-scroll)
|
|
32
|
+
- [09. What you built](#scoreboard)
|
|
33
|
+
- [10. Where to go next](#next)
|
|
34
|
+
|
|
35
|
+
<a id="why"></a>
|
|
36
|
+
|
|
37
|
+
## 01. Why this tutorial exists
|
|
38
|
+
|
|
39
|
+
Most frontend libraries ask nothing of your backend. That is a design
|
|
40
|
+
goal for them and a tax on you: htmx will happily swap any HTML you
|
|
41
|
+
send it, but it has no opinion about what your fragment ids should be,
|
|
42
|
+
so you invent a convention, then you invent the JavaScript that
|
|
43
|
+
upholds it. Hotwire's insight was that a framework which owns *both*
|
|
44
|
+
sides of the wire can just decide — `dom_id` on the server, matching
|
|
45
|
+
`turbo_frame_tag` in the view, and the swap needs no glue code because
|
|
46
|
+
both halves already agree.
|
|
47
|
+
|
|
48
|
+
StreamWeaver owns both sides too. The DSL that renders the page also
|
|
49
|
+
mints the interactive ids, signs the endpoints, and emits the `hx-*`
|
|
50
|
+
attributes. So the conventions in this tutorial are not style advice
|
|
51
|
+
you could reasonably ignore — they are the framework. Follow them and
|
|
52
|
+
you write no JavaScript. Fight them and you write a lot.
|
|
53
|
+
|
|
54
|
+
### What you'll build
|
|
55
|
+
|
|
56
|
+
The learnhotwire.com course's Turbo Frames chapter builds four
|
|
57
|
+
features against a Rails app. We rebuild all four:
|
|
58
|
+
|
|
59
|
+
1. **Inline editing** — click Edit and the row becomes a form in place
|
|
60
|
+
2. **Scoped live search** — results filter as you type, no controller
|
|
61
|
+
3. **Lazy hover cards** — six cards that cost nothing until hovered
|
|
62
|
+
4. **Russian-doll infinite scroll** — pages load as you reach them
|
|
63
|
+
|
|
64
|
+
One file. One in-memory store. The finished app is
|
|
65
|
+
`examples/my_todos/` in this repo, which is where every code sample
|
|
66
|
+
below comes from — these are extracts, not illustrations.
|
|
67
|
+
|
|
68
|
+
> [!IMPORTANT]
|
|
69
|
+
> **The rule that makes this interesting**
|
|
70
|
+
>
|
|
71
|
+
> **Zero custom JavaScript in app code.** No `<script>` tags, no inline `onclick`, no hand-written Alpine, no raw `hx-*` attributes, no reaching into `lib/stream_weaver/adapter/*`. Only DSL verbs and CSS. If a feature needs JavaScript, that is a finding about the framework, not a licence to write some. All four features below clear the bar.
|
|
72
|
+
|
|
73
|
+
### How to read this
|
|
74
|
+
|
|
75
|
+
Each feature section has the same four beats, in this order:
|
|
76
|
+
|
|
77
|
+
**Why** — the problem the feature has, stated before any code.
|
|
78
|
+
**Build it** — the code, with the Rails mechanism it mirrors.
|
|
79
|
+
**The law it obeys** — which of the six laws is doing the work.
|
|
80
|
+
**The gotcha it avoids** — what breaks if you write the tempting shape.
|
|
81
|
+
|
|
82
|
+
Where a number was measured on the real app, it is quoted with its
|
|
83
|
+
source. Nothing here is estimated.
|
|
84
|
+
|
|
85
|
+
**These two documents are twins.** This tutorial exists as a StreamWeaver
|
|
86
|
+
doc-app (`examples/tutorials/streamweaver_way_tutorial.rb`) and as markdown
|
|
87
|
+
(`docs/tutorials/the-streamweaver-way.md`). Both are rendered from one
|
|
88
|
+
outline, `examples/tutorials/tutorial_content.rb`, and a spec fails if the
|
|
89
|
+
checked-in markdown drifts from it. Edit the outline, never a twin.
|
|
90
|
+
|
|
91
|
+
<a id="laws"></a>
|
|
92
|
+
|
|
93
|
+
## 02. The six laws, and why they're laws
|
|
94
|
+
|
|
95
|
+
Read these once now. They will not mean much yet — that is fine, each
|
|
96
|
+
one gets earned by a feature below. Come back after section 08 and
|
|
97
|
+
they will read as obvious.
|
|
98
|
+
|
|
99
|
+
| # | Law | What it costs you to ignore |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| 1 | Key by what is unique per *position* on the page, not by what the content is *about*. | Two rows sharing an assignee collide silently — hovering the second shows the first one's data. |
|
|
102
|
+
| 2 | Deferred over hand-rolled timers. | A 1.5s region holds the whole shell hostage, or you write a polling loop and a loading flag. |
|
|
103
|
+
| 3 | Lazy means visible. | Sixty hover cards fetch on page load for a reader who hovers none of them. |
|
|
104
|
+
| 4 | Dev loud, prod self-heals. | Either your users see "Content missing", or you see nothing and debug blind. |
|
|
105
|
+
| 5 | State your intent in DSL verbs. Zero custom JavaScript is the default, not an aspiration. | You hand-roll the glue the framework already emits, and it drifts out of sync with the server. |
|
|
106
|
+
| 6 | Run every feature through three design filters: is it findable and digestible; is it zero-friction with a real perk; is the *default* path the correct path. | You ship something that demos well and changes nothing. |
|
|
107
|
+
|
|
108
|
+
Law 1 is the one that bites hardest, so it is worth a sentence more.
|
|
109
|
+
StreamWeaver derives an id for every interactive element from its label
|
|
110
|
+
plus its block source location, with `-dup-N` auto-disambiguation on
|
|
111
|
+
repeats — so you rarely pass anything. But a derived id is
|
|
112
|
+
*position*-stable, not *content*-stable: insert or delete an earlier
|
|
113
|
+
row and every later suffix shifts. That is fine for a static form and
|
|
114
|
+
fatal for a list you filter, sort, or delete from. Reach for `key:`
|
|
115
|
+
exactly when identity must outlive reordering.
|
|
116
|
+
|
|
117
|
+
Law 6's third filter is the *why* under the other five: **the default
|
|
118
|
+
path must be the correct path.** Design for the developer you have, not
|
|
119
|
+
the one who remembers to pass the right option. Law 1's auto-keying is
|
|
120
|
+
that filter in code — because the default already disambiguates, the
|
|
121
|
+
silent-wrong-callback bug can't happen to someone who never read law 1.
|
|
122
|
+
Every law below is really this one, applied to a different primitive.
|
|
123
|
+
|
|
124
|
+
<a id="store"></a>
|
|
125
|
+
|
|
126
|
+
## 03. Start with the store
|
|
127
|
+
|
|
128
|
+
Before any feature, the data. A **store** in StreamWeaver is any object
|
|
129
|
+
that answers five methods: `all`, `find(id)`, `create(attrs)`,
|
|
130
|
+
`update(id, attrs)`, and `destroy(id)`. There is no base class to
|
|
131
|
+
inherit and no schema to declare. A plain module over an Array is
|
|
132
|
+
enough to build all four features — which is the point: the framework
|
|
133
|
+
asks for a protocol, not a dependency.
|
|
134
|
+
|
|
135
|
+
Two extra methods show up later (`search` in section 05, `page` in
|
|
136
|
+
section 08); each is introduced by the feature that needs it, so you
|
|
137
|
+
can see exactly what the feature costs.
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
require 'stream_weaver'
|
|
141
|
+
|
|
142
|
+
module UserStore
|
|
143
|
+
USERS = {
|
|
144
|
+
'u1' => { id: 'u1', name: 'Ada Lovelace', role: 'Engineering' },
|
|
145
|
+
'u2' => { id: 'u2', name: 'Alan Turing', role: 'Research' },
|
|
146
|
+
'u3' => { id: 'u3', name: 'Grace Hopper', role: 'Platform' }
|
|
147
|
+
}.freeze
|
|
148
|
+
|
|
149
|
+
def self.find(id) = USERS[id]
|
|
150
|
+
|
|
151
|
+
# Deliberately slow, opt-in, so "was this fetched lazily or eagerly?"
|
|
152
|
+
# is observable. The course's Rails app uses a literal `sleep 1.5`
|
|
153
|
+
# here for the same reason. Section 07 turns it on.
|
|
154
|
+
def self.delay
|
|
155
|
+
seconds = ENV['SW_HOVERCARD_DELAY'].to_f
|
|
156
|
+
sleep(seconds) if seconds.positive?
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
module TodoStore
|
|
161
|
+
TITLES = [
|
|
162
|
+
'Buy milk', 'Ship the parity spike', 'Review the fragment docs',
|
|
163
|
+
'Book the offsite', 'Refactor the store protocol', 'Write release notes',
|
|
164
|
+
'Answer the support thread', 'Update the changelog', 'Prune stale branches',
|
|
165
|
+
'Draft the design memo', 'Pair on the routing bug', 'Buy coffee beans'
|
|
166
|
+
].freeze
|
|
167
|
+
|
|
168
|
+
# Sixty rows: enough to paginate, enough that eager hover cards hurt.
|
|
169
|
+
@todos = 60.times.map do |i|
|
|
170
|
+
{ id: (i + 1).to_s,
|
|
171
|
+
title: "#{TITLES[i % TITLES.length]} ##{i + 1}",
|
|
172
|
+
completed: (i % 5).zero?,
|
|
173
|
+
user_id: "u#{(i % 3) + 1}" }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
class << self
|
|
177
|
+
def all = @todos
|
|
178
|
+
def find(id) = @todos.find { |t| t[:id] == id.to_s }
|
|
179
|
+
|
|
180
|
+
def create(attrs)
|
|
181
|
+
id = ((@todos.map { |t| t[:id].to_i }.max || 0) + 1).to_s
|
|
182
|
+
@todos << { id: id, completed: false, user_id: 'u1', **attrs }
|
|
183
|
+
id
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def update(id, attrs)
|
|
187
|
+
todo = find(id) or return false
|
|
188
|
+
todo.merge!(attrs)
|
|
189
|
+
true
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def destroy(id)
|
|
193
|
+
@todos.reject! { |t| t[:id] == id.to_s }
|
|
194
|
+
true
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
> [!NOTE]
|
|
201
|
+
> **Inside this repo**
|
|
202
|
+
>
|
|
203
|
+
> The samples say `require 'stream_weaver'`, which is what a reader with the gem installed writes. Working inside the StreamWeaver checkout instead? Use `require_relative '../../lib/stream_weaver'` — that is what `examples/my_todos/my_todos.rb` does.
|
|
204
|
+
|
|
205
|
+
<a id="inline-editing"></a>
|
|
206
|
+
|
|
207
|
+
## 04. Feature 1 — Inline editing
|
|
208
|
+
|
|
209
|
+
### Why
|
|
210
|
+
|
|
211
|
+
Clicking Edit on a row should turn *that row* into a form, leave the
|
|
212
|
+
other 59 rows alone, and — this is the part that is easy to get wrong —
|
|
213
|
+
make it structurally impossible for the edit form to modify a field it
|
|
214
|
+
doesn't display. The course demos this as "edit safe": the form shows
|
|
215
|
+
only the title, so submitting it must not be able to flip the
|
|
216
|
+
completed checkbox, even for someone who crafts the request by hand.
|
|
217
|
+
|
|
218
|
+
Rails does this with two `turbo_frame_tag` frames sharing a `dom_id`,
|
|
219
|
+
plus a deliberately narrow controller
|
|
220
|
+
(`params.expect(project: [:name])`). Two mechanisms, held in agreement
|
|
221
|
+
by you remembering to keep them in agreement.
|
|
222
|
+
|
|
223
|
+
### Build it
|
|
224
|
+
|
|
225
|
+
StreamWeaver needs one mechanism for the swap and gets the safety for
|
|
226
|
+
free. `fragment(name) { ... }` marks a region that interactions
|
|
227
|
+
*inside it* swap on their own, rather than swapping the whole app
|
|
228
|
+
container. Everything interactive rendered inside a fragment is
|
|
229
|
+
auto-scoped to it — so the Edit button and the form's submit both
|
|
230
|
+
swap just this row, without either of them naming the row.
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
# Declare ONLY the editable field. `form_for` coerces exactly the fields
|
|
234
|
+
# listed here and nothing else, so a submit through this form cannot
|
|
235
|
+
# reach :completed. That is the "edit safe" guarantee, enforced by
|
|
236
|
+
# construction rather than by remembering to narrow a controller.
|
|
237
|
+
TITLE_ONLY = [StreamWeaver::Field.new(:title, :string, {})].freeze
|
|
238
|
+
|
|
239
|
+
app 'My Todos' do
|
|
240
|
+
TodoStore.all.first(6).each do |todo|
|
|
241
|
+
fragment("todo-#{todo[:id]}") do
|
|
242
|
+
if state[:editing_id].to_s == todo[:id]
|
|
243
|
+
text "#{todo[:completed] ? '☑' : '☐'} (editing)"
|
|
244
|
+
form_for(
|
|
245
|
+
store: TodoStore,
|
|
246
|
+
fields: TITLE_ONLY,
|
|
247
|
+
name: :"todo_#{todo[:id]}_form",
|
|
248
|
+
record: todo,
|
|
249
|
+
on_success: ->(_id) { state[:editing_id] = nil }
|
|
250
|
+
) { submit_label 'Save' }
|
|
251
|
+
button('Cancel', style: :secondary, key: "cancel-#{todo[:id]}") do |s|
|
|
252
|
+
s[:editing_id] = nil
|
|
253
|
+
end
|
|
254
|
+
else
|
|
255
|
+
hstack spacing: :sm do
|
|
256
|
+
text "#{todo[:completed] ? '☑' : '☐'} #{todo[:title]}"
|
|
257
|
+
button('Edit', style: :secondary, key: "edit-#{todo[:id]}") do |s|
|
|
258
|
+
s[:editing_id] = todo[:id]
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end.run!
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Read the shape rather than the lines. There is one fragment per row,
|
|
268
|
+
named by record id. Inside it, a branch on `state[:editing_id]`: either
|
|
269
|
+
the display row with an Edit button, or the form with a Cancel button.
|
|
270
|
+
Clicking Edit sets `editing_id` and re-renders — but only this
|
|
271
|
+
fragment, because the button was rendered inside it. There is no
|
|
272
|
+
"which frame should this target?" decision to get wrong, because the
|
|
273
|
+
answer is always "the one you're inside".
|
|
274
|
+
|
|
275
|
+
`form_for` is the record-bound form primitive: it seeds fields from the
|
|
276
|
+
record, infers create-vs-update from record identity, coerces and
|
|
277
|
+
validates on submit, calls `store.update`, flashes, and redirects.
|
|
278
|
+
`on_success:` is where the row goes back to display mode.
|
|
279
|
+
|
|
280
|
+
### The law it obeys
|
|
281
|
+
|
|
282
|
+
**Law 1 — key by position.** Look at the two buttons: both carry
|
|
283
|
+
`key:`. That is not decoration. Without it, their ids are derived from
|
|
284
|
+
label plus source location, which for six identical Edit buttons in one
|
|
285
|
+
loop means `edit`, `edit-dup-1`, `edit-dup-2`, and so on — stable only
|
|
286
|
+
as long as the list is. Delete row 2 and every later suffix shifts by
|
|
287
|
+
one, so the callback fires for the wrong todo. `key: "edit-#{todo[:id]}"`
|
|
288
|
+
ties the id to the record, and the record survives deletion of its
|
|
289
|
+
neighbours.
|
|
290
|
+
|
|
291
|
+
**Law 5 — state your intent in DSL verbs.** There is no JavaScript here
|
|
292
|
+
and there is also no `hx-target`. The scoping is a consequence of where
|
|
293
|
+
the code sits, which is the omakase bargain: give up the ability to
|
|
294
|
+
target an arbitrary frame, get correctness by default.
|
|
295
|
+
|
|
296
|
+
### The gotcha it avoids
|
|
297
|
+
|
|
298
|
+
> [!WARNING]
|
|
299
|
+
> **Per-row buttons must carry `key:`**
|
|
300
|
+
>
|
|
301
|
+
> This is the single most common way to ship a silently-wrong list. The symptom is not an error — it's clicking Edit on row 3 and watching row 2 open. If you want the framework to shout instead of guess, turn on `strict_ids` (section 09), which promotes a derived-id collision from a warning to a hard failure in development and test.
|
|
302
|
+
|
|
303
|
+
Two smaller ones, both real, both cosmetic-or-worse:
|
|
304
|
+
|
|
305
|
+
- **`on_success:` is required** for a standalone `form_for`. Inside a
|
|
306
|
+
`resource` block there is a post-submit transition to fall back to;
|
|
307
|
+
standalone there isn't, so you must say what happens next.
|
|
308
|
+
- **The flash noun is derived by stripping `_form` from `name:`**, so
|
|
309
|
+
`:todo_1_form` flashes "Todo_1 updated." Cosmetic, known, unfixed.
|
|
310
|
+
Mentioned here so you don't go looking for the bug you introduced.
|
|
311
|
+
|
|
312
|
+
### The number
|
|
313
|
+
|
|
314
|
+
| Interaction | Bytes on the wire |
|
|
315
|
+
|---|---|
|
|
316
|
+
| Click Edit (row becomes a form) | 1,327 |
|
|
317
|
+
| Submit the form (title renamed, `☑` preserved) | 806 |
|
|
318
|
+
| Full page load of the same view, for comparison | 108,792 |
|
|
319
|
+
|
|
320
|
+
Two orders of magnitude, and the reason is entirely structural: the
|
|
321
|
+
response is one row, because the interaction happened inside one row's
|
|
322
|
+
fragment. Source: `docs/research/streamweaver-way-spike-findings.md`.
|
|
323
|
+
|
|
324
|
+
<a id="search"></a>
|
|
325
|
+
|
|
326
|
+
## 05. Feature 2 — Scoped live search
|
|
327
|
+
|
|
328
|
+
### Why
|
|
329
|
+
|
|
330
|
+
Filter-as-you-type is the feature everyone reaches for JavaScript to
|
|
331
|
+
build: an input listener, a debounce, a fetch, a DOM update, and a
|
|
332
|
+
loading state. In the course's Rails app it is a form outside the
|
|
333
|
+
results frame, named with `data: {turbo_frame: :todos}`, plus a
|
|
334
|
+
Stimulus `autosubmit` controller that turns keystrokes into
|
|
335
|
+
`requestSubmit()`.
|
|
336
|
+
|
|
337
|
+
### Build it
|
|
338
|
+
|
|
339
|
+
`text_field` auto-submits on input. There is no controller, no
|
|
340
|
+
debounce code, and no wiring — that behaviour is the verb. The only
|
|
341
|
+
decision left is **where the field lives**, and unlike most of this
|
|
342
|
+
tutorial, that one is a genuine trade-off rather than a right answer.
|
|
343
|
+
|
|
344
|
+
First, the store gains one method:
|
|
345
|
+
|
|
346
|
+
```ruby
|
|
347
|
+
# The blank guard is load-bearing -- see the gotcha below.
|
|
348
|
+
def TodoStore.search(query)
|
|
349
|
+
return all if query.to_s.strip.empty?
|
|
350
|
+
needle = query.to_s.downcase
|
|
351
|
+
all.select { |t| t[:title].downcase.include?(needle) }
|
|
352
|
+
end
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Then the view. Put the field **inside** the results fragment:
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
app 'Search' do
|
|
359
|
+
fragment(:search_results) do
|
|
360
|
+
text_field :query, placeholder: 'Filter todos…', label: 'Search'
|
|
361
|
+
results = TodoStore.search(state[:query])
|
|
362
|
+
text "#{results.length} of #{TodoStore.all.length} todos"
|
|
363
|
+
results.first(8).each { |t| text "• #{t[:title]}" }
|
|
364
|
+
end
|
|
365
|
+
end.run!
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Five lines, and it filters as you type. The field is inside the
|
|
369
|
+
fragment, so its auto-submit is scoped to the fragment, so the response
|
|
370
|
+
is just the results region — and because StreamWeaver morphs rather
|
|
371
|
+
than replaces, focus and caret position survive the swap.
|
|
372
|
+
|
|
373
|
+
### The law it obeys
|
|
374
|
+
|
|
375
|
+
**Law 5 — zero custom JavaScript is the default.** The Rails version
|
|
376
|
+
needs a Stimulus controller because a plain form doesn't submit on
|
|
377
|
+
input; the StreamWeaver version needs nothing because `text_field`
|
|
378
|
+
already does. When you find yourself about to write JavaScript in a
|
|
379
|
+
StreamWeaver app, the odds strongly favour a verb you haven't met yet.
|
|
380
|
+
|
|
381
|
+
### The gotcha it avoids
|
|
382
|
+
|
|
383
|
+
> [!CAUTION]
|
|
384
|
+
> **`text_field :query, updates: :results` is accepted and ignored**
|
|
385
|
+
>
|
|
386
|
+
> This is the natural transliteration of Rails' `data: {turbo_frame: :todos}` and it looks completely right. It is silently a no-op: `updates:` is read only by `button` and `clickable`. There is no `data-turbo-frame` equivalent for inputs yet, so **an input can target only its own enclosing fragment or `#app-container`** — it cannot name a sibling fragment from outside it. A field placed outside the results fragment still filters correctly; it just swaps the entire app body on every keystroke.
|
|
387
|
+
|
|
388
|
+
That constraint is what makes the field's placement a trade-off rather
|
|
389
|
+
than a preference:
|
|
390
|
+
|
|
391
|
+
| Arrangement | Swap target | Response size | Cost |
|
|
392
|
+
|---|---|---|---|
|
|
393
|
+
| A — field outside the results fragment (the Rails shape) | `#app-container` | 2,123 bytes | Whole app body every keystroke; field never re-renders itself |
|
|
394
|
+
| B — field inside the results fragment (shown above) | `#sw-frag-search-results` | 800 bytes | Field re-renders itself every keystroke; focus and caret survive |
|
|
395
|
+
|
|
396
|
+
Prefer B unless you specifically need the input never to re-render.
|
|
397
|
+
Source for both measurements:
|
|
398
|
+
`docs/research/streamweaver-way-spike-findings.md`.
|
|
399
|
+
|
|
400
|
+
Two more, quickly:
|
|
401
|
+
|
|
402
|
+
- **Guard the blank query in the store.** Without `return all if
|
|
403
|
+
query.to_s.strip.empty?`, clearing the box makes the whole list
|
|
404
|
+
vanish. The course demos exactly this as a live bug, because in Rails
|
|
405
|
+
the `LIKE` runs against `nil`.
|
|
406
|
+
- **Search is a GET filter**, so if you want a result set to be
|
|
407
|
+
shareable, put the query in the URL with `route_with`. The finished
|
|
408
|
+
`examples/my_todos/` app does this.
|
|
409
|
+
|
|
410
|
+
<a id="timings"></a>
|
|
411
|
+
|
|
412
|
+
## 06. Interlude — the three fragment timings
|
|
413
|
+
|
|
414
|
+
You have now used `fragment` twice, both times in its plain form: the
|
|
415
|
+
block runs during the page render, inline, like any other DSL call.
|
|
416
|
+
That is one of three timings, and the other two are what make features
|
|
417
|
+
3 and 4 possible.
|
|
418
|
+
|
|
419
|
+
| Form | When the block runs | Use for |
|
|
420
|
+
|---|---|---|
|
|
421
|
+
| `fragment(:n) { }` | Inline, with the page | Scoping a swap to one region |
|
|
422
|
+
| `fragment(:n, defer: true) { }` | Right after page load | Slow regions, off the critical path |
|
|
423
|
+
| `fragment(:n, lazy: true) { }` | The first time it is *visible* | Hover cards, infinite scroll, tab panels |
|
|
424
|
+
|
|
425
|
+
`defer: true` is **Law 2 — deferred over hand-rolled timers.** A slow
|
|
426
|
+
region ships a placeholder with the page and fetches its own content
|
|
427
|
+
immediately afterward, over the same signed endpoint every in-fragment
|
|
428
|
+
interaction already uses. No new route, no new authorization path, and
|
|
429
|
+
nothing for you to write. Measured on a page with one `sleep 1.5`
|
|
430
|
+
fragment:
|
|
431
|
+
|
|
432
|
+
| Request | Time |
|
|
433
|
+
|---|---|
|
|
434
|
+
| Initial `GET` (was 1.5s before deferring) | 0.018s |
|
|
435
|
+
| The fragment's own fetch, afterward | 1.508s |
|
|
436
|
+
|
|
437
|
+
The work didn't get cheaper — it got moved off the path the reader is
|
|
438
|
+
waiting on. Source: `CHANGELOG.md`, deferred fragments entry.
|
|
439
|
+
|
|
440
|
+
`lazy: true` is **Law 3 — lazy means visible**, and it implies
|
|
441
|
+
`defer: true`, so you only ever write one option. It holds the fetch
|
|
442
|
+
until the fragment is actually visible: scrolled into the viewport, or
|
|
443
|
+
CSS flipping an ancestor out of `display: none`. Two guarantees follow,
|
|
444
|
+
and both are load-bearing for what comes next.
|
|
445
|
+
|
|
446
|
+
**A hidden fragment never fetches while it stays hidden.** That is what
|
|
447
|
+
makes a hover card cost literally nothing on page load.
|
|
448
|
+
|
|
449
|
+
**It fetches exactly once.** The content lands in the DOM and stays, so
|
|
450
|
+
hiding and re-showing the region doesn't refetch it.
|
|
451
|
+
|
|
452
|
+
Under the hood the placeholder carries `hx-trigger="intersect once"`.
|
|
453
|
+
htmx's other visibility trigger, `revealed`, reads
|
|
454
|
+
`getBoundingClientRect()` and therefore counts a `display: none`
|
|
455
|
+
element as revealed — which would fetch every hidden fragment on load
|
|
456
|
+
and destroy the first guarantee. That choice is documented in
|
|
457
|
+
`docs/research/2026-08-22-lazy-fragments-trigger-decision.md`.
|
|
458
|
+
|
|
459
|
+
> [!WARNING]
|
|
460
|
+
> **Three rules for every deferred or lazy fragment**
|
|
461
|
+
>
|
|
462
|
+
> **The block is skipped on the shell render**, so anything it *registers* — actions, timers — isn't registered until the fetch lands. Keep registration outside deferred blocks.
|
|
463
|
+
>
|
|
464
|
+
> **A named-action button (`button 'X', action: :foo`) inside one does not fire.** Its token is minted outside the session's action manifest. Use a block button (`button('X') { |s| ... }`), which dispatches by id and is unaffected.
|
|
465
|
+
>
|
|
466
|
+
> **Each fetch re-runs its ancestors' blocks.** Nest for staging, not to split one expensive block into cheaper pieces.
|
|
467
|
+
|
|
468
|
+
One practical note that costs people an afternoon: **give the
|
|
469
|
+
placeholder height.** The observer watches the placeholder wrapper, and
|
|
470
|
+
a zero-area element never intersects anything, so a lazy fragment with
|
|
471
|
+
an empty placeholder simply never fires. `placeholder:` takes nothing
|
|
472
|
+
(a small spinner), a String (rendered as text), or a Proc (run as DSL).
|
|
473
|
+
|
|
474
|
+
<a id="hover-cards"></a>
|
|
475
|
+
|
|
476
|
+
## 07. Feature 3 — Lazy hover cards
|
|
477
|
+
|
|
478
|
+
### Why
|
|
479
|
+
|
|
480
|
+
Each todo has an assignee. Hovering the assignee's name should reveal a
|
|
481
|
+
card with their details — details expensive enough to fetch that you
|
|
482
|
+
would not want to fetch sixty of them for a reader who hovers none.
|
|
483
|
+
|
|
484
|
+
This is the feature that most clearly separates "it works" from "it is
|
|
485
|
+
right". A version that renders every card eagerly and hides them with
|
|
486
|
+
CSS *looks* identical. The difference is only visible in the network
|
|
487
|
+
tab, and in how long the page took to arrive.
|
|
488
|
+
|
|
489
|
+
### Build it
|
|
490
|
+
|
|
491
|
+
Three lines of CSS and one option. The two halves interlock: CSS does
|
|
492
|
+
the revealing, and revealing is what does the fetching.
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
HOVERCARD_CSS = <<~CSS
|
|
496
|
+
.hovercard { position: relative; display: inline-block; cursor: help; }
|
|
497
|
+
.hovercard .hovercard-panel {
|
|
498
|
+
display: none; position: absolute; top: 1.6em; left: 0; z-index: 50;
|
|
499
|
+
min-width: 15rem;
|
|
500
|
+
}
|
|
501
|
+
.hovercard:hover .hovercard-panel { display: block; }
|
|
502
|
+
CSS
|
|
503
|
+
|
|
504
|
+
app 'Hover cards' do
|
|
505
|
+
use_stylesheet HOVERCARD_CSS
|
|
506
|
+
|
|
507
|
+
TodoStore.all.first(6).each do |todo|
|
|
508
|
+
user = UserStore.find(todo[:user_id])
|
|
509
|
+
hstack spacing: :sm do
|
|
510
|
+
text "#{todo[:completed] ? '☑' : '☐'} #{todo[:title]} —"
|
|
511
|
+
div class: 'hovercard' do
|
|
512
|
+
text user[:name]
|
|
513
|
+
div class: 'hovercard-panel' do
|
|
514
|
+
# Keyed by the TODO, not the user. See the gotcha below.
|
|
515
|
+
fragment(:"hovercard_#{todo[:id]}", lazy: true, placeholder: 'Loading…') do
|
|
516
|
+
UserStore.delay
|
|
517
|
+
card do
|
|
518
|
+
header3 user[:name]
|
|
519
|
+
text user[:role]
|
|
520
|
+
text "#{TodoStore.all.count { |t| t[:user_id] == user[:id] }} todos"
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
end.run!
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
Boot it with `SW_HOVERCARD_DELAY=1.5 ruby my_todos.rb` and hover a
|
|
531
|
+
name. The page was already there; the card you hovered pays 1.5
|
|
532
|
+
seconds, once, and the five you didn't hover pay nothing at all — ever,
|
|
533
|
+
if you never hover them.
|
|
534
|
+
|
|
535
|
+
To check laziness yourself, count the fragments that have *not* fetched
|
|
536
|
+
yet, in the browser console:
|
|
537
|
+
`document.querySelectorAll('.sw-fragment-lazy').length`.
|
|
538
|
+
|
|
539
|
+
### The law it obeys
|
|
540
|
+
|
|
541
|
+
**Law 3 — lazy means visible**, in its purest form. Nothing on this
|
|
542
|
+
page fetches anything until the pointer arrives, and the mechanism that
|
|
543
|
+
hides the card is the same mechanism that gates the fetch. You did not
|
|
544
|
+
write a hover handler, because there is nothing for a hover handler to
|
|
545
|
+
do: the CSS already reacted, and the observer already noticed.
|
|
546
|
+
|
|
547
|
+
**Law 1 — key by position** is here too, and it is the reason the
|
|
548
|
+
fragment is named `hovercard_#{todo[:id]}` and not `hovercard_#{user[:id]}`.
|
|
549
|
+
|
|
550
|
+
### The gotcha it avoids
|
|
551
|
+
|
|
552
|
+
> [!CAUTION]
|
|
553
|
+
> **Key by the todo, not the user**
|
|
554
|
+
>
|
|
555
|
+
> Sixty todos share three assignees. Key the fragment by the user and twenty todos emit the same fragment name — hovering the second one shows the first one's card, with no error anywhere. The rule states itself once you've been bitten: key by what is unique per *position* on the page (the todo), never by what the row happens to *display* (the assignee). The course demos this exact collision in Rails, which is how we knew to look for it.
|
|
556
|
+
|
|
557
|
+
> [!WARNING]
|
|
558
|
+
> **`display: none`, never `visibility: hidden` or `opacity: 0`**
|
|
559
|
+
>
|
|
560
|
+
> This one is subtle enough to survive review. `visibility: hidden` and `opacity: 0` both leave the element laid out and intersecting, so the IntersectionObserver counts it as visible and **every card fetches eagerly on page load** — the exact cost you added `lazy: true` to avoid. The page looks right. It just does all the work anyway.
|
|
561
|
+
|
|
562
|
+
### The number
|
|
563
|
+
|
|
564
|
+
| Page load with `SW_HOVERCARD_DELAY=1.5` | Time |
|
|
565
|
+
|---|---|
|
|
566
|
+
| Eager — every card rendered in the list request (6 × 1.5s) | 9.151s |
|
|
567
|
+
| Lazy — shell only, cards not yet fetched | 0.085s |
|
|
568
|
+
|
|
569
|
+
On six rows that is an ugly page. On the full sixty it is ninety
|
|
570
|
+
seconds of work to render a page nobody may hover at all. Source:
|
|
571
|
+
`docs/research/streamweaver-way-spike-findings.md`.
|
|
572
|
+
|
|
573
|
+
<a id="infinite-scroll"></a>
|
|
574
|
+
|
|
575
|
+
## 08. Feature 4 — Russian-doll infinite scroll
|
|
576
|
+
|
|
577
|
+
### Why
|
|
578
|
+
|
|
579
|
+
Sixty rows, ten at a time, loading as the reader reaches them — with no
|
|
580
|
+
scroll handler, no page counter in state, and no "Load more" button.
|
|
581
|
+
|
|
582
|
+
Nesting lazy fragments gets you this for free, but only in one
|
|
583
|
+
particular shape, and the wrong shape is the more tempting one. This
|
|
584
|
+
section is the reason the tutorial is worth reading rather than
|
|
585
|
+
skimming.
|
|
586
|
+
|
|
587
|
+
### Build it
|
|
588
|
+
|
|
589
|
+
The store gains one method, and it returns **one page's slice** — never
|
|
590
|
+
everything-so-far:
|
|
591
|
+
|
|
592
|
+
```ruby
|
|
593
|
+
module TodoStore
|
|
594
|
+
PER_PAGE = 10
|
|
595
|
+
def self.page(number)
|
|
596
|
+
all.drop(([number.to_i, 1].max - 1) * PER_PAGE).first(PER_PAGE)
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
Now the recursion. Read this one slowly — the whole feature is four
|
|
602
|
+
lines of logic, and which lines are inside the lazy fragment is the
|
|
603
|
+
entire design:
|
|
604
|
+
|
|
605
|
+
```ruby
|
|
606
|
+
def scroll_todos_page(number)
|
|
607
|
+
# THIS page renders NOW, inline, in whatever response we are already
|
|
608
|
+
# writing. It is not deferred and it is not lazy.
|
|
609
|
+
TodoStore.page(number).each do |t|
|
|
610
|
+
text "#{t[:completed] ? '☑' : '☐'} #{t[:title]}"
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# Only the NEXT page is lazy. The guard is load-bearing: without it
|
|
614
|
+
# the chain dead-ends in an empty fragment that fetches nothing.
|
|
615
|
+
nxt = number + 1
|
|
616
|
+
return if TodoStore.page(nxt).empty?
|
|
617
|
+
|
|
618
|
+
fragment(:"todos_page_#{nxt}", lazy: true, placeholder: 'Loading…') do
|
|
619
|
+
scroll_todos_page(nxt) # the recursion lives INSIDE the lazy fragment
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
app('Infinite scroll') { scroll_todos_page(1) }.run!
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
Trace one scroll. The shell response contains page 1's ten rows, really
|
|
627
|
+
there in the HTML, plus exactly one placeholder for page 2. Page 2 does
|
|
628
|
+
not exist in the DOM. Scroll to the bottom of page 1 and the placeholder
|
|
629
|
+
enters the viewport, which is what fires its fetch. That fetch runs
|
|
630
|
+
`scroll_todos_page(2)`, which writes page 2's ten rows and one
|
|
631
|
+
placeholder for page 3. And so on, until `TodoStore.page(nxt)` comes
|
|
632
|
+
back empty and the chain simply stops.
|
|
633
|
+
|
|
634
|
+
That recursion is the whole of the pagination logic. There is no state
|
|
635
|
+
to keep, because "which page am I on" is answered by which fragment is
|
|
636
|
+
currently executing.
|
|
637
|
+
|
|
638
|
+
### The gotcha it avoids
|
|
639
|
+
|
|
640
|
+
> [!CAUTION]
|
|
641
|
+
> **Do not wrap page N itself in the lazy fragment**
|
|
642
|
+
>
|
|
643
|
+
> The tempting shape is `fragment(:"page_#{number}", lazy: true) { rows; recurse }`, called with `1` — one uniform function, each page wrapped the same way. It is wrong, and it is wrong in a way that passes a casual test.
|
|
644
|
+
>
|
|
645
|
+
> A deferred block **is skipped on the shell render**. So the served HTML contains no rows at all — just a placeholder. It looks like it works only because page 1's placeholder sits in the initial viewport, and a lazy fragment that is already visible fetches immediately. In a browser you see rows appear and conclude it's fine. `curl` the same URL and you get an empty shell.
|
|
646
|
+
|
|
647
|
+
This is not hypothetical. It is the bug a clean-room agent hit while
|
|
648
|
+
rebuilding these features from the conventions alone: the app rendered
|
|
649
|
+
correctly in the browser, every feature demonstrably worked, and the
|
|
650
|
+
served HTML had zero rows in it. Nothing in the running app said so.
|
|
651
|
+
The failure only surfaced when someone read the response instead of the
|
|
652
|
+
screen.
|
|
653
|
+
|
|
654
|
+
Rendering the current page inline and lazying only the next one is also
|
|
655
|
+
exactly what Turbo does — a frame around the current page's rows, plus a
|
|
656
|
+
separate `loading: :lazy` frame for the next page. It is what keeps the
|
|
657
|
+
shell honest for a crawler, for a reader with JavaScript disabled, and
|
|
658
|
+
for anyone who ever reads the served HTML. The pretty version fails all
|
|
659
|
+
three silently.
|
|
660
|
+
|
|
661
|
+
> [!TIP]
|
|
662
|
+
> **How to check your own version**
|
|
663
|
+
>
|
|
664
|
+
> `curl -s http://127.0.0.1:PORT/infinite-scroll | grep -c 'Buy milk'` — if that returns 0 while the browser shows rows, you wrote the tempting shape. The screen is not the artifact; the response is.
|
|
665
|
+
|
|
666
|
+
Two smaller ones:
|
|
667
|
+
|
|
668
|
+
- **Return one page's slice, not everything-so-far.** Each response
|
|
669
|
+
should be a constant size. If your payloads grow per page, you are
|
|
670
|
+
re-sending rows the reader already has.
|
|
671
|
+
- **Nested fragment ids are `parent--child`.** Page 2's fragment is
|
|
672
|
+
declared beside page 1's rows, so it is `sw-frag-todos-page-2`; page
|
|
673
|
+
3's is declared inside page 2's block, giving
|
|
674
|
+
`sw-frag-todos-page-2--todos-page-3`. Useful when you are reading the
|
|
675
|
+
DOM to work out what fetched what.
|
|
676
|
+
- **Inside a tab, the lazy fragment goes *inside* a `tab` block**, never
|
|
677
|
+
beside one at the top level of a `tabs` block — a non-tab child there
|
|
678
|
+
shifts every panel index. Route tabs (`tabs :view, url: true`) render
|
|
679
|
+
inactive panels as `display: none`, so a lazy fragment in one
|
|
680
|
+
correctly waits until that tab is opened.
|
|
681
|
+
|
|
682
|
+
### The number
|
|
683
|
+
|
|
684
|
+
This is the clearest measurement in the whole tutorial, because it
|
|
685
|
+
changes the *shape* of the cost rather than its size. Before nested
|
|
686
|
+
fragments existed, "Load more" re-rendered and re-sent every row loaded
|
|
687
|
+
so far:
|
|
688
|
+
|
|
689
|
+
| Old: click-driven, O(n) per click | Payload | Rows in payload |
|
|
690
|
+
|---|---|---|
|
|
691
|
+
| Click 1 | 1,248 bytes | 20 |
|
|
692
|
+
| Click 2 | 1,603 bytes | 30 |
|
|
693
|
+
| Click 3 | 1,957 bytes | 40 |
|
|
694
|
+
|
|
695
|
+
With nested lazy fragments, each fetch carries only its own page:
|
|
696
|
+
|
|
697
|
+
| New: Russian-doll, O(1) per page | Payload |
|
|
698
|
+
|---|---|
|
|
699
|
+
| Page 2 | 809 bytes |
|
|
700
|
+
| Page 3 | 877 bytes |
|
|
701
|
+
| Page 4 | 934 bytes |
|
|
702
|
+
| Page 5 | 993 bytes |
|
|
703
|
+
| Page 6 | 1,060 bytes |
|
|
704
|
+
| Page 7 (terminal — chain ends) | 371 bytes |
|
|
705
|
+
|
|
706
|
+
The remaining growth is not rows. It is the nested id string getting
|
|
707
|
+
longer — `todos-page-2--todos-page-3--todos-page-4` and so on — which
|
|
708
|
+
is a few bytes per level, not a page of content. O(n) per click and
|
|
709
|
+
O(n²) over a session became O(1) per page. Source:
|
|
710
|
+
`docs/research/streamweaver-way-spike-findings.md`.
|
|
711
|
+
|
|
712
|
+
<a id="scoreboard"></a>
|
|
713
|
+
|
|
714
|
+
## 09. What you built
|
|
715
|
+
|
|
716
|
+
Four features from a Turbo Frames course, rebuilt with no script tag,
|
|
717
|
+
no inline JavaScript, and no hand-written Alpine. Here is the whole
|
|
718
|
+
thing on one line each:
|
|
719
|
+
|
|
720
|
+
| Feature | The verb that did the work | The law | The measured win |
|
|
721
|
+
|---|---|---|---|
|
|
722
|
+
| Inline editing | `fragment` per row + `form_for` + `key:` | 1, 5 | 806-byte save vs 108,792-byte page |
|
|
723
|
+
| Scoped live search | `text_field` inside a `fragment` | 5 | 800 bytes scoped vs 2,123 whole-body |
|
|
724
|
+
| Lazy hover cards | `fragment(..., lazy: true)` + 3 lines of CSS | 1, 3 | 0.085s shell vs 9.151s eager |
|
|
725
|
+
| Infinite scroll | nested `fragment(..., lazy: true)` | 2, 3 | O(1) per page vs O(n) per click |
|
|
726
|
+
|
|
727
|
+
### Assembling the four into one app
|
|
728
|
+
|
|
729
|
+
Each section above showed its feature as its own `app` block, so you
|
|
730
|
+
could run it in isolation while building it. Putting all four in one
|
|
731
|
+
file needs one more thing: a way to say which feature the reader is
|
|
732
|
+
looking at. That is `route_with` plus a `case`, and it is the last new
|
|
733
|
+
verb in the tutorial.
|
|
734
|
+
|
|
735
|
+
`route_with` takes a **parser** (URL path in, state out) and a
|
|
736
|
+
**builder** (state in, URL path out). Between them the browser's
|
|
737
|
+
address bar and `state[:feature]` stay in agreement, so every feature
|
|
738
|
+
is a real, bookmarkable URL rather than a tab index.
|
|
739
|
+
|
|
740
|
+
```ruby
|
|
741
|
+
FEATURE_PATHS = {
|
|
742
|
+
inline_edit: '/', search: '/search',
|
|
743
|
+
hover_cards: '/hover-cards', infinite_scroll: '/infinite-scroll'
|
|
744
|
+
}.freeze
|
|
745
|
+
|
|
746
|
+
# routing.md Pitfall 1: `route_with` MERGES GET state rather than
|
|
747
|
+
# replacing it, so every parser branch merges on top of this. Without
|
|
748
|
+
# it, an `editing_id` set on the inline-edit page leaks into the search
|
|
749
|
+
# page and quietly opens a form there.
|
|
750
|
+
VIEW_RESET = { editing_id: nil }.freeze
|
|
751
|
+
|
|
752
|
+
app 'My Todos', layout: :wide do
|
|
753
|
+
use_stylesheet HOVERCARD_CSS
|
|
754
|
+
|
|
755
|
+
route_with(
|
|
756
|
+
parser: lambda do |path|
|
|
757
|
+
base = path.to_s.split('?').first
|
|
758
|
+
feature = FEATURE_PATHS.key(base.empty? ? '/' : base)
|
|
759
|
+
feature ? VIEW_RESET.merge(feature: feature) : nil
|
|
760
|
+
end,
|
|
761
|
+
builder: lambda do |current_state|
|
|
762
|
+
base = FEATURE_PATHS[current_state[:feature]&.to_sym] || '/'
|
|
763
|
+
# Search is a GET filter, so the query belongs in the URL.
|
|
764
|
+
query = current_state[:query].to_s
|
|
765
|
+
if current_state[:feature]&.to_sym == :search && !query.strip.empty?
|
|
766
|
+
"#{base}?query=#{CGI.escape(query)}"
|
|
767
|
+
else
|
|
768
|
+
base
|
|
769
|
+
end
|
|
770
|
+
end
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
state[:feature] ||= :inline_edit
|
|
774
|
+
|
|
775
|
+
navbar do
|
|
776
|
+
nav_item 'Inline editing', href: '/', active: state[:feature] == :inline_edit
|
|
777
|
+
nav_item 'Search', href: '/search', active: state[:feature] == :search
|
|
778
|
+
nav_item 'Hover cards', href: '/hover-cards', active: state[:feature] == :hover_cards
|
|
779
|
+
nav_item 'Infinite scroll', href: '/infinite-scroll', active: state[:feature] == :infinite_scroll
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
fragment(:flash) { flash_messages }
|
|
783
|
+
|
|
784
|
+
case state[:feature].to_sym
|
|
785
|
+
when :inline_edit then inline_edit_view # section 04's body
|
|
786
|
+
when :search then search_view # section 05's body
|
|
787
|
+
when :hover_cards then hover_cards_view # section 07's body
|
|
788
|
+
when :infinite_scroll then scroll_todos_page(1) # section 08's recursion
|
|
789
|
+
end
|
|
790
|
+
end.run!
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
> [!WARNING]
|
|
794
|
+
> **An incomplete route table fails silently, on both sides**
|
|
795
|
+
>
|
|
796
|
+
> `route_with` merges GET state rather than replacing it, and a `case` with a missing `when` renders nothing rather than raising. Add a feature and forget its `FEATURE_PATHS` entry and you get a blank page with a 200 status. `docs/routing.md`'s Common Pitfalls section is worth reading before any app with more than a couple of routes — this is Pitfall 1 and it has cost real time.
|
|
797
|
+
|
|
798
|
+
### Turn on `strict_ids` before you ship
|
|
799
|
+
|
|
800
|
+
Everything in section 04 about keying is enforceable. `strict_ids`
|
|
801
|
+
promotes a derived-id collision from a warning to a hard failure — it
|
|
802
|
+
raises in development and test, and degrades to a warning in production,
|
|
803
|
+
because a live page shouldn't return a 500 over an id the framework
|
|
804
|
+
already disambiguated for it.
|
|
805
|
+
|
|
806
|
+
```ruby
|
|
807
|
+
StreamWeaver::App.new('My App', strict_ids: true) { ... } # per app
|
|
808
|
+
StreamWeaver.strict_ids = true # global
|
|
809
|
+
SW_STRICT_IDS=1 ruby my_todos.rb # env, e.g. in CI
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
### When something is wired wrong
|
|
813
|
+
|
|
814
|
+
**Law 4 — dev loud, prod self-heals.** A stale action token gets a 409
|
|
815
|
+
plus a full-container re-render that is byte-for-byte identical in
|
|
816
|
+
production: the user never sees an error, the page just heals. In
|
|
817
|
+
development only, the same response prepends a dismissible
|
|
818
|
+
`.sw-dev-fallback` overlay naming the stale target and the likely cause.
|
|
819
|
+
|
|
820
|
+
This is a deliberate inversion of Hotwire's "Content missing", which
|
|
821
|
+
shows the failure to the user and tells the developer nothing useful.
|
|
822
|
+
Keep the debugging signal; don't charge the reader for it. **If you see
|
|
823
|
+
that overlay, your wiring is wrong — don't style it away.**
|
|
824
|
+
|
|
825
|
+
### The anti-pattern list
|
|
826
|
+
|
|
827
|
+
Each of these has bitten someone, which is why it is written down:
|
|
828
|
+
|
|
829
|
+
| Don't | Do |
|
|
830
|
+
|---|---|
|
|
831
|
+
| `defer { ... }` | `fragment(:name, defer: true)` — the bare verb raises and names the real one |
|
|
832
|
+
| `every(...)` to refresh a slow region | `fragment(..., defer: true)` (Law 2) |
|
|
833
|
+
| A named-action button inside a deferred/lazy fragment | A block button — `button('X') { \|s\| ... }` |
|
|
834
|
+
| `key: i` (positional index) | `key: record.id` — index is the identity reordering destroys |
|
|
835
|
+
| `text "**bold**"` | `md "**bold**"` — `text` does not render markdown |
|
|
836
|
+
| `spacer` / `divider` | They don't exist. `div(style: 'height:24px')` |
|
|
837
|
+
| `<button onclick="location.href=...">` | `link_to` / `nav_item` with a real `href` |
|
|
838
|
+
| Assuming port 4567 | Read the URL StreamWeaver prints — it finds a free port |
|
|
839
|
+
|
|
840
|
+
<a id="next"></a>
|
|
841
|
+
|
|
842
|
+
## 10. Where to go next
|
|
843
|
+
|
|
844
|
+
**Run the finished app.** `examples/my_todos/` is all four features in
|
|
845
|
+
one file, annotated with the Rails mechanism each mirrors:
|
|
846
|
+
|
|
847
|
+
```
|
|
848
|
+
SW_NO_OPEN=1 ruby examples/my_todos/my_todos.rb
|
|
849
|
+
SW_HOVERCARD_DELAY=1.5 SW_NO_OPEN=1 ruby examples/my_todos/my_todos.rb
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
The second one is worth the extra keystrokes. Feeling a card take 1.5
|
|
853
|
+
seconds *only when you hover it* is the moment Law 3 stops being a
|
|
854
|
+
sentence.
|
|
855
|
+
|
|
856
|
+
**Get the conventions on tap.** The `streamweaver-way` skill is the
|
|
857
|
+
prescriptive twin of this tutorial: the same six laws and the same four
|
|
858
|
+
recipes, written for an agent that needs the answer rather than the
|
|
859
|
+
reasoning. Install it with `streamweaver setup`.
|
|
860
|
+
|
|
861
|
+
| Where to look | For |
|
|
862
|
+
|---|---|
|
|
863
|
+
| `llms.txt` (aliased `docs/for_llms.md`) | The full DSL reference — every verb, every option |
|
|
864
|
+
| `docs/resource-dsl.md` | `resource` + `form_for`, complete reference |
|
|
865
|
+
| `docs/routing.md` | **Read Common Pitfalls** before any app with more than a couple of routes |
|
|
866
|
+
| `docs/crud-patterns.md` | Create/edit/delete modals, URL-addressable edit pages, store adapters |
|
|
867
|
+
| `docs/components_reference.md` | Every component's API |
|
|
868
|
+
|
|
869
|
+
**Read the why.** These exist for when you are extending a primitive
|
|
870
|
+
rather than using one:
|
|
871
|
+
|
|
872
|
+
| Document | What it answers |
|
|
873
|
+
|---|---|
|
|
874
|
+
| `docs/research/streamweaver-way-spike-findings.md` | What broke building these four features, and how each break was closed — the source of every gotcha above |
|
|
875
|
+
| `docs/research/2026-08-22-lazy-fragments-trigger-decision.md` | Why `intersect once` and not `revealed` |
|
|
876
|
+
| `docs/research/2026-08-22-learnhotwire-syllabus-coverage.md` | Hotwire feature-by-feature coverage: what exists, what's next |
|
|
877
|
+
| `docs/streamweaver-frontend-vision.md` | The strategic thesis — why owning both sides of the wire is the whole bet |
|
|
878
|
+
|
|
879
|
+
> [!NOTE]
|
|
880
|
+
> **One closing thought**
|
|
881
|
+
>
|
|
882
|
+
> Every feature in this tutorial got shorter than its Rails equivalent, and none of them got shorter by being cleverer. They got shorter because the framework already knew something — which fragment you're inside, which record a row is for, whether a region is visible — that the other arrangement makes you say out loud. That is what an omakase layer buys, and it is the only thing it buys. Everything else in this document is a consequence.
|