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,404 @@
|
|
|
1
|
+
# Hotwire/Rails vs. StreamWeaver: Concept-by-Concept Gap Map
|
|
2
|
+
|
|
3
|
+
Grounds the "Hotwire-grade omakase layer" proposal. Honest accounting of what Rails/Hotwire
|
|
4
|
+
provides vs. what StreamWeaver ships today, by file and DSL method. Sources: `README.md`,
|
|
5
|
+
`docs/for_llms.md`, `docs/streamweaver-frontend-vision.md`, `docs/routing.md`,
|
|
6
|
+
`docs/resource-dsl.md`, `gsd/analysis/00-analysis-and-plan.md`, `features/route-tabs.feature`,
|
|
7
|
+
`features/now-view-support.feature`, `lib/stream_weaver/server.rb`,
|
|
8
|
+
`lib/stream_weaver/interaction_runner.rb`, `lib/stream_weaver/components.rb`,
|
|
9
|
+
`lib/stream_weaver/views.rb`, `lib/stream_weaver/streamer.rb`, and CHANGELOG.md.
|
|
10
|
+
|
|
11
|
+
StreamWeaver already has an explicit thesis document for this exact question
|
|
12
|
+
(`docs/streamweaver-frontend-vision.md`): a "Hotwire-equivalent stack" built on
|
|
13
|
+
`Ruby DSL + htmx + Alpine.js + Idiomorph` instead of `Turbo + Stimulus`. This map checks that
|
|
14
|
+
thesis against what's actually implemented, concept by concept.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1. Turbo Drive (full-page nav without reload; history)
|
|
19
|
+
|
|
20
|
+
**Hotwire/Rails**: Every `<a>` and form submission is intercepted, fetched via AJAX, and the
|
|
21
|
+
response body swaps into `<html>` — no full navigation, but the URL bar and browser history
|
|
22
|
+
update as if it were a real nav. Works automatically on all links with zero configuration.
|
|
23
|
+
|
|
24
|
+
**StreamWeaver today**: `hx-boost="true"` at the `<body>` level, documented as the
|
|
25
|
+
"Hotwire-Style Navigation Pattern" in `docs/for_llms.md` (§"Hotwire-Style Navigation Pattern"),
|
|
26
|
+
reference implementation `lib/stream_weaver/views/canvas/reader_layout.erb`. This is opt-in
|
|
27
|
+
per-layout, not automatic for every StreamWeaver app — only canvas-read's layout currently uses
|
|
28
|
+
it. `route_by`/`route_with` (`docs/routing.md`) separately drive `HX-Push-Url` for state-driven
|
|
29
|
+
URL changes on the main app container, which is a different mechanism (POST-then-push, not
|
|
30
|
+
GET-boosted anchor navigation).
|
|
31
|
+
|
|
32
|
+
**Gap assessment: PARTIAL** — the underlying primitive (htmx `hx-boost`) is proven in one
|
|
33
|
+
reference layout, but it is not a framework-level default; a new StreamWeaver app gets full
|
|
34
|
+
POST/morph interaction handling, not Turbo-Drive-style boosted navigation, unless the author
|
|
35
|
+
hand-wires the same `hx-boost`/`hx-select`/`hx-select-oob` pattern documented in the reader
|
|
36
|
+
layout.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 2. Turbo Frames — named region replaced independently (scoped updates)
|
|
41
|
+
|
|
42
|
+
**Hotwire/Rails**: `<turbo-frame id="x">` is a custom element (`alex_turbo_frames_transcript.txt`
|
|
43
|
+
§6:27–7:47 traces this precisely: it's just `customElements.define`, no magic). A frame's
|
|
44
|
+
`connectedCallback` fires the initial fetch if `src` is present; any link/form inside it targets
|
|
45
|
+
it by default. The server returns a full page; Turbo discards everything except the matching
|
|
46
|
+
frame ID.
|
|
47
|
+
|
|
48
|
+
**StreamWeaver today**: `fragment :name do ... end` (`lib/stream_weaver/app.rb:419`,
|
|
49
|
+
`Components::Fragment` in `lib/stream_weaver/components.rb:107`). An action declares
|
|
50
|
+
`updates: :name` or `updates: %i[a b]`; on interaction, `InteractionRunner`
|
|
51
|
+
(`lib/stream_weaver/interaction_runner.rb:170`) renders `Views::FragmentContentView`
|
|
52
|
+
(`lib/stream_weaver/views.rb:3801`) — the primary fragment plus any declared `updates:` regions
|
|
53
|
+
as out-of-band swaps. As of the 2026-07-11 benchmark gate
|
|
54
|
+
(`gsd/analysis/00-analysis-and-plan.md` — "GATE PASSED... 14 PASS, 0 FAIL"), this also has a
|
|
55
|
+
row-granular counterpart (`RowSwapView`, `views.rb:3821`) so a table-row edit returns ~2KB
|
|
56
|
+
instead of the whole fragment.
|
|
57
|
+
|
|
58
|
+
**Mechanism difference worth naming**: a Turbo Frame request re-renders *only that one
|
|
59
|
+
controller action*. A StreamWeaver fragment update still re-runs the **entire DSL block**
|
|
60
|
+
server-side (README: "Fragments keep the normal single full DSL rerun while limiting the HTML
|
|
61
|
+
swapped into the page") — the scoping is in what's sent to the client, not in what executes on
|
|
62
|
+
the server. True fragment-local re-execution is explicitly deferred
|
|
63
|
+
(`gsd/analysis/00-analysis-and-plan.md`, "Deferred items... decision-gated at Phase 5.1").
|
|
64
|
+
|
|
65
|
+
**Gap assessment: PARTIAL** — the client-visible outcome (one region updates, benchmarked
|
|
66
|
+
close to hand-written htmx payload sizes) is achieved and shipped. The server-side execution
|
|
67
|
+
model is architecturally different (whole-rerun-then-slice vs. true per-frame dispatch), which
|
|
68
|
+
is a real cost for apps with expensive per-region computation, and multi-fragment composition
|
|
69
|
+
(nested/nameable nesting like Turbo's `src=`-chained frames) has no equivalent.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 3. Frame ID matching convention (dom_id; "key frame by what's unique per position on page")
|
|
74
|
+
|
|
75
|
+
**Hotwire/Rails**: `dom_id(record, prefix)` generates `prefix_recordtype_id` deterministically.
|
|
76
|
+
The transcript's stated rule (10:56–12:15, reinforced at 39:18): **key the frame by what's
|
|
77
|
+
unique per position on the page, not by what the content is about** — e.g. key a hover-card
|
|
78
|
+
frame by the to-do, not the user, or two cards for the same user collide (this exact bug is
|
|
79
|
+
demonstrated live in the transcript, 38:52–40:24).
|
|
80
|
+
|
|
81
|
+
**StreamWeaver today**: `Fragment#id` (`components.rb:111`) — an author-supplied name, not a
|
|
82
|
+
derived-from-record convention. Loop-rendered interactive components (buttons, in particular)
|
|
83
|
+
historically collided when two loop iterations produced identical label+source-location IDs;
|
|
84
|
+
`CHANGELOG.md` records a shipped **button `id:` option** to disambiguate manually. The larger
|
|
85
|
+
fix — auto-disambiguation by render occurrence, a `key:` option accepting only stable scalars,
|
|
86
|
+
and a `strict_ids: true` mode that becomes the 1.0 default — is scoped as "Phase 0" in
|
|
87
|
+
`gsd/analysis/00-analysis-and-plan.md` and is not confirmed shipped (no `strict_ids` or
|
|
88
|
+
automatic-warn-and-disambiguate logic found in `lib/`).
|
|
89
|
+
|
|
90
|
+
**Gap assessment: MISSING** for the Rails convention itself (there is no `dom_id`-equivalent
|
|
91
|
+
helper deriving a guaranteed-unique ID from a record); **PARTIAL** for the underlying safety
|
|
92
|
+
property — manual `id:`/`key:` opt-out exists, but StreamWeaver's own gap catalog names silent
|
|
93
|
+
loop-ID collision as "the most dangerous bug in the catalog" (`00-analysis-and-plan.md`, Root
|
|
94
|
+
cause 2), i.e. the framework does not yet guarantee what Rails' `dom_id` guarantees by
|
|
95
|
+
construction.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 4. "Content missing" failure semantics — loud, styleable, well-known failure when IDs don't match
|
|
100
|
+
|
|
101
|
+
**Hotwire/Rails**: When a frame's ID doesn't match anything in the response, Turbo renders the
|
|
102
|
+
literal text "Content missing" into the frame and adds a `.turbo-frame` error class — a visible,
|
|
103
|
+
CSS-styleable, well-documented failure (transcript 12:31–14:55: "It's not a crash... it's a
|
|
104
|
+
really quiet failure. There's no exception, no 500... but it's loud in the DOM"). This is a
|
|
105
|
+
deliberate design choice: fail visibly rather than guess.
|
|
106
|
+
|
|
107
|
+
**StreamWeaver today**: the opposite policy, by explicit design. README: "If a target
|
|
108
|
+
disappears or routing changes, StreamWeaver automatically falls back to a full swap." The
|
|
109
|
+
`InteractionRunner`/server.rb path validates a signed action token; on mismatch it raises
|
|
110
|
+
`StaleActionDefinition`, caught in `server.rb:299` to return `status 409` + `HX-Retarget:
|
|
111
|
+
#app-container` + a full re-render — i.e. it self-heals rather than surfacing a stuck "content
|
|
112
|
+
missing" box. `gsd/analysis/00-analysis-and-plan.md` names this explicitly:
|
|
113
|
+
"`HX-Retarget` full-container fallback (correct-by-fallback)."
|
|
114
|
+
|
|
115
|
+
**Gap assessment: N/A by design, not a gap** — this is a deliberate inversion of Hotwire's
|
|
116
|
+
philosophy (fail loud vs. self-heal), consistent with StreamWeaver's whole-rerun architecture
|
|
117
|
+
where a full fallback is always available. Worth flagging to the owner as a design decision to
|
|
118
|
+
make explicit in docs, not an accidental omission: StreamWeaver currently has no dev-mode
|
|
119
|
+
equivalent of "make broken wiring embarrassingly obvious," which the transcript's operator
|
|
120
|
+
(Forrest, 13:11) singles out as the single highest-leverage debugging aid in Turbo Frames.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## 5. Eager-loading frames (`src` attribute — render page now, slow panel lands later)
|
|
125
|
+
|
|
126
|
+
**Hotwire/Rails**: `<turbo-frame src="/slow_panel">` fires its own fetch the instant it's
|
|
127
|
+
parsed into the DOM, independent of the rest of the page. The transcript's framing (31:18–33:00):
|
|
128
|
+
"you have a page with three cheap panels and one really expensive query... with a frame you put
|
|
129
|
+
it behind a source, and the page renders it immediately." This is the textbook fix for
|
|
130
|
+
Streamlit-style whole-page-blocks-on-slow-query, which is exactly the failure mode StreamWeaver's
|
|
131
|
+
own gap catalog calls out (`00-analysis-and-plan.md`, Root cause 1: "This is exactly Streamlit's
|
|
132
|
+
scaling wall").
|
|
133
|
+
|
|
134
|
+
**StreamWeaver today**: no equivalent found. Every DSL block — including all fragments —
|
|
135
|
+
executes synchronously as part of one request/response cycle; there is no `src=`-style deferred
|
|
136
|
+
fetch that lets the shell render before a specific region's slow work completes. The closest
|
|
137
|
+
existing primitives are `every(seconds)` timers pushing SSE updates after initial render
|
|
138
|
+
(`docs/for_llms.md` §"Live Streaming with `every` Timers") and the manually-swapped
|
|
139
|
+
`streamer.replace` — both are *post-load* update mechanisms, not *initial-load* deferral. The
|
|
140
|
+
plan doc's own deferred-items list confirms this gap: `button async: true` background-work
|
|
141
|
+
primitive is explicitly "decision-gated at Phase 5.1," not built.
|
|
142
|
+
|
|
143
|
+
**Gap assessment: MISSING** — this is the single largest architectural gap for the "no custom
|
|
144
|
+
JavaScript" complex-app case: a StreamWeaver author with one slow panel today must hand-roll
|
|
145
|
+
`every` + `streamer.replace` (documented as a known workaround pattern, e.g.
|
|
146
|
+
"cultivation_dashboard's 15+ wrapped sections" per the plan doc) to approximate what Turbo gets
|
|
147
|
+
for free with one HTML attribute.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## 6. Lazy-loading frames (`loading=lazy`, fires on visibility — hover cards, infinite scroll via nested frames)
|
|
152
|
+
|
|
153
|
+
**Hotwire/Rails**: `loading="lazy"` defers the fetch until the frame becomes visible (not
|
|
154
|
+
scrolled-near, not hovered — actually visible per IntersectionObserver semantics under the
|
|
155
|
+
hood, transcript 33:07–35:02). Combined with pure CSS (`display:none` → `display:block` on
|
|
156
|
+
hover), this gives hover-cards and hover-triggered any-content with zero JS. Nested lazy frames
|
|
157
|
+
("Russian dolls," transcript 45:xx) give infinite scroll the same way: each page's response
|
|
158
|
+
contains the next page's placeholder frame, already marked lazy.
|
|
159
|
+
|
|
160
|
+
**StreamWeaver today**: `tabs :key, lazy: true` exists (`lib/stream_weaver/app.rb:859-866`,
|
|
161
|
+
renderer in `adapter/alpinejs.rb:2325-2383`) but is **click-triggered POST-then-morph**, not
|
|
162
|
+
visibility-triggered — inactive lazy tab panels render a `<!-- lazy: tab N not rendered -->`
|
|
163
|
+
comment and fetch only when the tab is clicked. This is closer to Rails' `remote: true`
|
|
164
|
+
click-to-load than to Turbo's IntersectionObserver-based `loading=lazy`. Notably,
|
|
165
|
+
`features/route-tabs.feature` (Scenario: `deprecate-lazy-post-morph`) shows this exact mode is
|
|
166
|
+
being **deprecated in favor of a future "lazy route tabs"** replacement — the team already knows
|
|
167
|
+
this primitive needs rework. No hover-card or infinite-scroll pattern (nested-lazy-frame
|
|
168
|
+
equivalent) exists anywhere in `lib/`.
|
|
169
|
+
|
|
170
|
+
**Gap assessment: PARTIAL, and self-acknowledged as needing rework** — the click-lazy primitive
|
|
171
|
+
exists for one component (tabs) and is already flagged for deprecation; visibility-based lazy
|
|
172
|
+
loading and nested-frame infinite scroll have no StreamWeaver equivalent at all.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## 7. Turbo Streams — multi-region updates from one response
|
|
177
|
+
|
|
178
|
+
**Hotwire/Rails**: A single response (from a controller action, a broadcast, or a form
|
|
179
|
+
submission) carries `<turbo-stream action="replace/append/prepend/remove" target="...">`
|
|
180
|
+
elements — arbitrary DOM operations to arbitrary named targets, all delivered together.
|
|
181
|
+
|
|
182
|
+
**StreamWeaver today**: two overlapping mechanisms, per `docs/streamweaver-frontend-vision.md`'s
|
|
183
|
+
own comparison table ("Turbo Streams | htmx out-of-band swaps + Alpine | OOB covers the
|
|
184
|
+
partial-update case; Alpine covers the live-update case"):
|
|
185
|
+
1. **Fragment `updates:` scopes** (see #2) — OOB swaps declared per-action, resolved through the
|
|
186
|
+
same request/response cycle as the primary fragment.
|
|
187
|
+
2. **`Streamer`** (`lib/stream_weaver/streamer.rb`) — `replace`/`append`/`prepend`/`remove`/
|
|
188
|
+
`add_class`/`remove_class` (`ACTIONS` constant, `streamer.rb:15`), pushed over SSE from
|
|
189
|
+
`every(seconds)` timer blocks (`docs/for_llms.md` §"Live Streaming with `every` Timers").
|
|
190
|
+
This is a near-exact operational match to Turbo Stream actions, just server-timer-driven
|
|
191
|
+
rather than broadcast-from-anywhere.
|
|
192
|
+
|
|
193
|
+
**Gap assessment: HAVE** — the action vocabulary (replace/append/prepend/remove/class toggles)
|
|
194
|
+
and the "multiple regions from one push" capability both exist and are documented with real
|
|
195
|
+
worked examples. The gap is provenance, not capability: Turbo Streams can be emitted from any
|
|
196
|
+
controller action or model callback broadcast; StreamWeaver's `Streamer` is currently reachable
|
|
197
|
+
only from `every` timer blocks and (per `now-view-support.feature`, Scenario
|
|
198
|
+
`session-scoped-broadcast`) has a **known, tracked correctness bug** — pushes broadcast to every
|
|
199
|
+
open SSE connection unfiltered by session, so one browser tab's `every`-driven update can leak
|
|
200
|
+
into a different session's tab. That story is explicitly marked "not a small patch... treat it
|
|
201
|
+
as its own sub-investigation" — i.e. multi-region push works, but not yet safely multi-tenant.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## 8. Server-push (broadcasts over websocket/SSE)
|
|
206
|
+
|
|
207
|
+
**Hotwire/Rails**: `Turbo::StreamsChannel.broadcast_*` pushes over ActionCable (WebSocket) from
|
|
208
|
+
anywhere in the Rails stack — model callbacks, background jobs, controller actions — to any
|
|
209
|
+
number of subscribed browsers.
|
|
210
|
+
|
|
211
|
+
**StreamWeaver today**: SSE only (`Streamer`, `lib/stream_weaver/streamer.rb`; client listener
|
|
212
|
+
`adapter/alpinejs.rb:1140-1184`), explicitly a deliberate choice —
|
|
213
|
+
`gsd/analysis/00-analysis-and-plan.md`'s "Deferred items" list states "WebSocket support (SSE
|
|
214
|
+
already exists and covers the dashboard case)." Push is currently triggered only from `every`
|
|
215
|
+
timer blocks inside the app definition, not from arbitrary server-side code (no equivalent of
|
|
216
|
+
"broadcast from a background job" or "broadcast from another process"). The session-scoping bug
|
|
217
|
+
in #7 also applies here directly.
|
|
218
|
+
|
|
219
|
+
**Gap assessment: PARTIAL** — SSE-based push exists and is documented as the intentional,
|
|
220
|
+
lighter-weight substitute for ActionCable, which is reasonable for StreamWeaver's single-process
|
|
221
|
+
model. But it's narrower in origin (timer-triggered only, not broadcast-from-anywhere) and has
|
|
222
|
+
an open, tracked cross-session leak bug that would block relying on it for anything beyond a
|
|
223
|
+
single-user dashboard today.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## 9. Stimulus (small JS behaviors with lifecycle, targets, cleanup) vs. Alpine's role in StreamWeaver
|
|
228
|
+
|
|
229
|
+
**Hotwire/Rails**: Stimulus controllers are JS classes with `connect()`/`disconnect()`
|
|
230
|
+
lifecycle hooks, `data-target`/`data-controller` wiring, explicit cleanup on DOM removal (the
|
|
231
|
+
transcript's word-count example, 3:36–5:43, is a canonical minimal Stimulus controller built by
|
|
232
|
+
hand to show what Turbo Frame itself is made of).
|
|
233
|
+
|
|
234
|
+
**StreamWeaver today**: Alpine.js `x-data` declarations inline in server-rendered markup —
|
|
235
|
+
`docs/streamweaver-frontend-vision.md`'s comparison table names this explicitly as the chosen
|
|
236
|
+
substitute ("Stimulus controllers | Alpine `x-data` declarations | Closer to the markup, no
|
|
237
|
+
separate JS file per component"). For genuinely reusable custom behavior beyond what inline
|
|
238
|
+
Alpine expresses well, `StreamWeaver.register_component` / `component_registry.rb` lets an
|
|
239
|
+
author register a real Phlex-backed component as a DSL verb — confirmed shipped and spec'd per
|
|
240
|
+
`gsd/analysis/00-analysis-and-plan.md` ("Component registration — already shipped
|
|
241
|
+
(`StreamWeaver.register_component`, `component_registry.rb`, spec'd); document it prominently
|
|
242
|
+
instead").
|
|
243
|
+
|
|
244
|
+
**Gap assessment: PARTIAL, by design** — this is the one area where StreamWeaver has made an
|
|
245
|
+
explicit architectural bet to *not* replicate Hotwire 1:1 (Alpine's inline-declaration model
|
|
246
|
+
instead of Stimulus's class-based one), and the reasoning is documented (token efficiency for
|
|
247
|
+
LLM-generated views, no separate-file mental overhead). The real gap is that Stimulus's
|
|
248
|
+
lifecycle guarantees (connect/disconnect firing reliably across morphs, listener cleanup
|
|
249
|
+
enforced by the base class) have no equivalent enforcement mechanism in the Alpine convention —
|
|
250
|
+
that discipline is left to whoever writes the `x-data` block, with no framework-level guardrail
|
|
251
|
+
against the leaked-listener class of bug the transcript calls out (3:36) as Stimulus's main
|
|
252
|
+
value.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## 10. Form conventions (`form_with model:`, REST resource routing, strong params foot-guns)
|
|
257
|
+
|
|
258
|
+
**Hotwire/Rails**: `form_with model: @record` infers the URL/HTTP verb from the record's
|
|
259
|
+
persistence state; omitting an explicit `url:` on a nested/nonstandard case silently POSTs to
|
|
260
|
+
the generic endpoint and can overwrite unrelated attributes if strong params are too permissive
|
|
261
|
+
(the transcript's demonstrated failure, 17:28–22:00: editing just a title accidentally
|
|
262
|
+
flips `completed` too, because the generic endpoint permitted all three fields).
|
|
263
|
+
|
|
264
|
+
**StreamWeaver today**: two form mechanisms.
|
|
265
|
+
- **`form :name do ... submit "Save" do |values| ... end`** — deferred-submission block, POSTs
|
|
266
|
+
to `/form/:form_name` with Rails-style nested params, documented in `docs/for_llms.md` and
|
|
267
|
+
handled by `InteractionRunner` (`interaction: :form` branch, `server.rb:382-397`).
|
|
268
|
+
- **`resource :post, store: PostStore do field :title, :string; ... end`**
|
|
269
|
+
(`docs/resource-dsl.md`) — the closer Rails analog: `field` declarations act as an explicit
|
|
270
|
+
allowlist (StreamWeaver's version of strong params — only declared fields are read from
|
|
271
|
+
submitted state), auto-generates `new`/`edit` forms bound to a duck-typed store protocol
|
|
272
|
+
(`all`/`find`/`create`/`update`/`destroy`), validated at startup, plus named-route helpers
|
|
273
|
+
(`posts_path`, `post_path(rec)`, etc.). This is explicitly called "shipped, tested (98
|
|
274
|
+
examples green)" in `gsd/analysis/00-analysis-and-plan.md`, described as infrastructure to
|
|
275
|
+
build on, not a gap.
|
|
276
|
+
|
|
277
|
+
**Update (2026-08-22, story `document-form-for`)**: `form_for` — the `form_with`-style
|
|
278
|
+
record-bound form primitive named above as a still-to-do item — shipped 2026-07-10
|
|
279
|
+
(`lib/stream_weaver/app.rb:598`, decision doc `gsd/analysis/decisions/form-for.md`).
|
|
280
|
+
Given a resource name (or explicit `store:`/`fields:`), it seeds fields from a
|
|
281
|
+
`record:`, infers create vs. update from record identity, coerces + validates on
|
|
282
|
+
submit, and PRGs to `show` on success — the same block-yielding record-binding
|
|
283
|
+
ergonomics `form_with(model:)` gives Rails. It's what `resource`'s own default
|
|
284
|
+
`new`/`edit` views call under the hood, and is available directly inside override
|
|
285
|
+
blocks for custom layouts. It was simply undocumented until this story; full
|
|
286
|
+
reference and a worked Turbo-Frame-inline-editing example are now in
|
|
287
|
+
`docs/resource-dsl.md#form_for` and `docs/for_llms.md`.
|
|
288
|
+
|
|
289
|
+
**Gap assessment: HAVE** — `resource`/`field` already delivers the strong-params-equivalent
|
|
290
|
+
safety property Turbo's demo is warning about (a StreamWeaver `resource` form cannot
|
|
291
|
+
accidentally write undeclared fields, by construction), CRUD scaffolding is shipped, and
|
|
292
|
+
`form_for` now closes the block-yielding form-builder ergonomics gap for record-bound
|
|
293
|
+
forms. What remains PARTIAL is `form_with`-equivalent ergonomics for hand-written,
|
|
294
|
+
non-record forms — today's plain `form` block is still closer to Rails' `form_tag` than
|
|
295
|
+
`form_with`.
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## 11. URL/history semantics (when does URL change; deep-linkable states)
|
|
300
|
+
|
|
301
|
+
**Hotwire/Rails**: Turbo Drive updates the URL/history on every boosted navigation
|
|
302
|
+
automatically; Turbo Frames deliberately do *not* touch the URL unless `data-turbo-action`
|
|
303
|
+
opts in (transcript 15:01, "Gotcha 3: the URL doesn't change... that's usually exactly what we
|
|
304
|
+
want").
|
|
305
|
+
|
|
306
|
+
**StreamWeaver today**: this is one of the most fleshed-out areas. `route_by`/`route_with`
|
|
307
|
+
(`docs/routing.md`) give a bidirectional URL↔state contract: GET seeds state from the path,
|
|
308
|
+
POST responses push `HX-Push-Url` derived from a `builder` lambda. `docs/routing.md`'s "Common
|
|
309
|
+
Pitfalls" section documents two real, previously-hit bug classes in detail (state-merge leakage
|
|
310
|
+
across narrow route branches; non-exhaustive `case`/`when` failing silently in both directions)
|
|
311
|
+
— evidence this has been exercised against real, sizeable apps (~20 branches, 15 tabs), not just
|
|
312
|
+
designed on paper. `features/route-tabs.feature` extends the same contract to `tabs :key,
|
|
313
|
+
url: true` with explicit server-authority rules ("On full GET the URL is authoritative... absent
|
|
314
|
+
param must not inherit session value" — Scenario `server-param-authority`) and documented
|
|
315
|
+
degradation in canvas/websocket mode. The `resource` DSL layers named-route helpers
|
|
316
|
+
(`posts_path`, `post_path(rec)`) on top.
|
|
317
|
+
|
|
318
|
+
**Gap assessment: HAVE** — deep-linkable, bookmarkable, back/forward-correct URL state is a
|
|
319
|
+
real, documented, pitfall-audited feature, arguably StreamWeaver's most Rails-native area today.
|
|
320
|
+
The gap vs. Hotwire is narrow: no automatic URL update on every navigation (StreamWeaver's is
|
|
321
|
+
opt-in per state key/tab, matching Turbo Frame's "don't touch the URL by default" philosophy
|
|
322
|
+
rather than Turbo Drive's "always update" one) — a reasonable design choice, not an oversight.
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## 12. Scaffolding/generators (`rails g scaffold` → working CRUD)
|
|
327
|
+
|
|
328
|
+
**Hotwire/Rails**: `rails g scaffold Post title:string body:text` generates a model, migration,
|
|
329
|
+
controller with all seven REST actions, and views — a working CRUD app from one command.
|
|
330
|
+
|
|
331
|
+
**StreamWeaver today**: `resource :post, store: PostStore do field :title, :string; ... end`
|
|
332
|
+
(`docs/resource-dsl.md`) is the declarative equivalent — one block replaces "30–50 lines of
|
|
333
|
+
route/state/form boilerplate," generating index/show/new/edit/destroy with deep-linkable URLs,
|
|
334
|
+
route helpers, store validation at startup, and override blocks per action. Two working
|
|
335
|
+
reference apps exist: `examples/scaffolding/blog.rb` (~50 lines, zero-dependency smoke test) and
|
|
336
|
+
`examples/scaffolding/utf_lite.rb` (multi-resource, custom index override). This is a
|
|
337
|
+
**declarative-scaffold-as-code** pattern rather than a **generator-writes-files** pattern — there
|
|
338
|
+
is no `streamweaver new <name>` CLI command that scaffolds files onto disk.
|
|
339
|
+
`docs/streamweaver-frontend-vision.md` names this explicitly under "Scaffolding (long-term)":
|
|
340
|
+
"well downstream — the conventions need to stabilize first," not yet built.
|
|
341
|
+
|
|
342
|
+
**Gap assessment: PARTIAL** — the *outcome* Rails scaffolding produces (working CRUD from
|
|
343
|
+
minimal input) is achieved, arguably more concisely (one `resource` block vs. generated files
|
|
344
|
+
to maintain), and is genuinely shipped/tested. What's missing is the code-generation workflow
|
|
345
|
+
itself — no `streamweaver new` / `streamweaver generate` command exists, and there's no path yet
|
|
346
|
+
for an author who wants generated, then-hand-edited files rather than a live declarative block.
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## 13. Progressive disclosure for AI agents (skills/guides that teach the framework)
|
|
351
|
+
|
|
352
|
+
**Hotwire/Rails**: no direct analog — Rails' equivalent of "teaching an AI agent the
|
|
353
|
+
framework" is its own extensive documentation, guides, and (increasingly) community-authored
|
|
354
|
+
AGENTS.md/CLAUDE.md files; there's no framework-shipped, tool-agnostic skill mechanism.
|
|
355
|
+
|
|
356
|
+
**StreamWeaver today**: this is a built, first-class part of the framework, and unusually
|
|
357
|
+
mature for a project this size.
|
|
358
|
+
- **`docs/for_llms.md`** (aliased as `llms.txt`) — an LLM-quick-reference doc, deliberately
|
|
359
|
+
structured around common failure modes ("Common Mistakes," "Anti-Patterns That Cause Churn"),
|
|
360
|
+
updated in lockstep with features (e.g. `route-tabs.feature`'s `docs-update` scenario requires
|
|
361
|
+
`for_llms.md` + `llms.txt` updates as an acceptance criterion, not an afterthought).
|
|
362
|
+
- **Two shipped SKILL.md packages**: `streamweaver-visual-companion` (mockups/diagrams/
|
|
363
|
+
brainstorming via canvas-push) and `streamweaver-doc-builder` (long-form `:doc`-theme
|
|
364
|
+
documents), both installable to Claude Code's own path *and* the cross-tool
|
|
365
|
+
`.agents/skills/` alias via `streamweaver setup` / `install-skill` — `docs/for_llms.md` names
|
|
366
|
+
the exact install commands and directs agents to check for these before hand-rolling canvas
|
|
367
|
+
DSL from raw examples.
|
|
368
|
+
- **`docs/reference/agent-skills-comparison.md`** — a researched comparison of how Claude Code,
|
|
369
|
+
Codex, Gemini CLI, and GitHub Copilot each discover/trigger skills (SKILL.md spec via
|
|
370
|
+
agentskills.io, `.agents/skills/` cross-tool alias), which is what informed the multi-tool
|
|
371
|
+
install path above. This is meta-level: StreamWeaver has already researched and built for
|
|
372
|
+
"teach every agent, not just Claude," which is exactly the concern a Hotwire-parity effort
|
|
373
|
+
would need to replicate for its own new surface area.
|
|
374
|
+
- Repo-local skill: `.claude/skills/streamweaver-panel.md` documents the panel/canvas-push/
|
|
375
|
+
canvas-wait workflow directly.
|
|
376
|
+
|
|
377
|
+
**Gap assessment: N/A — this is StreamWeaver's own concept, not a Hotwire one**, and it's a
|
|
378
|
+
genuine, shipped asset: the project already treats "does an AI agent build this correctly on
|
|
379
|
+
first try" as a first-class, tested acceptance criterion (see `docs/routing.md`'s Common
|
|
380
|
+
Pitfalls section, `for_llms.md`'s Common Mistakes, and every `.feature` file's `docs-update`
|
|
381
|
+
scenario). Any Hotwire-parity work should extend this existing discipline (update
|
|
382
|
+
`for_llms.md`/`llms.txt` + relevant skill as part of the feature, not as a follow-up) rather
|
|
383
|
+
than invent a new documentation mechanism.
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Summary Table
|
|
388
|
+
|
|
389
|
+
| # | Concept | Assessment |
|
|
390
|
+
|---|---|---|
|
|
391
|
+
| 1 | Turbo Drive | PARTIAL |
|
|
392
|
+
| 2 | Turbo Frames (scoped regions) | PARTIAL |
|
|
393
|
+
| 3 | dom_id / frame-keying convention | MISSING (helper) / PARTIAL (safety) |
|
|
394
|
+
| 4 | "Content missing" loud failure | N/A — deliberate inversion (self-heal instead) |
|
|
395
|
+
| 5 | Eager-loading frames (`src`) | MISSING |
|
|
396
|
+
| 6 | Lazy-loading frames (visibility) | PARTIAL, self-flagged for rework |
|
|
397
|
+
| 7 | Turbo Streams (multi-region push) | HAVE, with a tracked correctness bug |
|
|
398
|
+
| 8 | Server-push (websocket/SSE) | PARTIAL — SSE only, timer-triggered only |
|
|
399
|
+
| 9 | Stimulus vs. Alpine | PARTIAL, by design |
|
|
400
|
+
| 10 | Form conventions / strong params | HAVE (`form_for`, record-bound) / PARTIAL (hand-written non-record forms) |
|
|
401
|
+
| 11 | URL/history semantics | HAVE |
|
|
402
|
+
| 12 | Scaffolding/generators | PARTIAL |
|
|
403
|
+
| 13 | Agent progressive disclosure | N/A (StreamWeaver-only concept) — HAVE, mature |
|
|
404
|
+
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# Lazy fragments: which htmx trigger, and what `lazy:` means on its own
|
|
2
|
+
|
|
3
|
+
Decision note for the `visibility-lazy-fragments` story (epic `streamweaver-way`).
|
|
4
|
+
Companion to `2026-08-17-hotwire-concept-map.md`, which named deferred/lazy
|
|
5
|
+
fragments as the open gap against Turbo Frames.
|
|
6
|
+
|
|
7
|
+
`fragment(:name, defer: true, lazy: true)` is StreamWeaver's `loading="lazy"`.
|
|
8
|
+
Turbo's rule for that attribute is precise and worth restating, because two of
|
|
9
|
+
the three obvious implementations get it wrong: the frame loads **when it becomes
|
|
10
|
+
visible** — not when it is scrolled near, not when it is hovered, but when it is
|
|
11
|
+
actually visible. A frame inside a `display: none` container never loads. Flip
|
|
12
|
+
that container to `display: block` with no scrolling at all and it loads.
|
|
13
|
+
|
|
14
|
+
## Decision 1 — `hx-trigger="intersect once"`, not `revealed`
|
|
15
|
+
|
|
16
|
+
htmx offers two candidate triggers. They are not variations on a theme; they are
|
|
17
|
+
built on different primitives and only one of them matches Turbo.
|
|
18
|
+
|
|
19
|
+
Read from htmx 2.0.4 (`https://unpkg.com/htmx.org@2.0.4/dist/htmx.js`), which is
|
|
20
|
+
the exact build `Adapter::AlpineJS#render_cdn_scripts` loads:
|
|
21
|
+
|
|
22
|
+
**`revealed` is scroll-polled geometry.** `addTriggerHandler` (js:2662) calls
|
|
23
|
+
`initScrollHandler()`, which installs `scroll`/`resize` listeners that set a flag,
|
|
24
|
+
plus a 200ms `setInterval` that re-checks every `[hx-trigger*='revealed']`
|
|
25
|
+
element via `maybeReveal` → `isScrolledIntoView` (js:750):
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
function isScrolledIntoView(el) {
|
|
29
|
+
const rect = el.getBoundingClientRect()
|
|
30
|
+
return rect.top < window.innerHeight && rect.bottom >= 0
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A `display: none` element has no layout box, so `getBoundingClientRect()` returns
|
|
35
|
+
an all-zero rect — and `0 < window.innerHeight && 0 >= 0` is **true**. `revealed`
|
|
36
|
+
therefore considers every hidden element revealed and fetches it immediately, on
|
|
37
|
+
the first `maybeReveal` call at process time. That is a direct contradiction of
|
|
38
|
+
criterion 4 ("a fragment hidden via CSS never triggers a fetch"), and it takes
|
|
39
|
+
the zero-JS hover-card pattern with it: the card would load on page render for
|
|
40
|
+
every host on the page, which is the whole cost the pattern exists to avoid.
|
|
41
|
+
The 200ms polling is a second, smaller mismatch — a hover that flips `display`
|
|
42
|
+
without scrolling never sets the `windowIsScrolling` flag, so nothing re-checks.
|
|
43
|
+
|
|
44
|
+
**`intersect` is an IntersectionObserver.** Same function, js:2666:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
const observer = new IntersectionObserver(function(entries) {
|
|
48
|
+
for (let i = 0; i < entries.length; i++) {
|
|
49
|
+
if (entries[i].isIntersecting) { triggerEvent(elt, 'intersect'); break }
|
|
50
|
+
}
|
|
51
|
+
}, observerOptions)
|
|
52
|
+
observer.observe(asElement(elt))
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
IntersectionObserver reports no intersection for a target with no layout box, and
|
|
56
|
+
delivers a callback on *any* transition into intersection regardless of cause —
|
|
57
|
+
scrolling, an ancestor's `display` flip, a CSS `:hover` rule, an Alpine `x-show`.
|
|
58
|
+
That is Turbo's rule, expressed in the same browser API Turbo itself uses.
|
|
59
|
+
|
|
60
|
+
**Chosen: `hx-trigger="intersect once"`.** Locked by
|
|
61
|
+
`spec/lazy_fragments_spec.rb` ("waits for intersection instead of firing on
|
|
62
|
+
load"), which also asserts the page contains no `hx-trigger="load"` — the shell
|
|
63
|
+
must ship with no way to fetch a lazy fragment except a visibility event.
|
|
64
|
+
|
|
65
|
+
### Why `once` is load-bearing
|
|
66
|
+
|
|
67
|
+
The observer keeps observing after it fires. Without `once`, scrolling a
|
|
68
|
+
materialized fragment out of the viewport and back would fire `intersect` again
|
|
69
|
+
and refetch — exactly what criterion 3 forbids. htmx's `once` latch (js:2506) is
|
|
70
|
+
stored per element:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
if (triggerSpec.once) {
|
|
74
|
+
if (elementData.triggeredOnce) { return } else { elementData.triggeredOnce = true }
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`elementData` comes from `getInternalData(elt)`, i.e. it is keyed to the DOM node,
|
|
79
|
+
not to the id or the URL. That per-element scope is what makes the next decision
|
|
80
|
+
work.
|
|
81
|
+
|
|
82
|
+
## Decision 2 — composition with the full-container re-arm
|
|
83
|
+
|
|
84
|
+
`deferred-fragments-src` established that an interaction swapping the whole app
|
|
85
|
+
container re-renders a deferred fragment back to its placeholder and re-fetches.
|
|
86
|
+
The mechanism is the wrapper element: the fetch attributes ride an inner
|
|
87
|
+
`<div id="<frag-id>--deferred">` rather than the fragment container, so the morph
|
|
88
|
+
sees materialized content where the new markup has a wrapper, matches neither by
|
|
89
|
+
id nor positionally, and builds a **fresh element**.
|
|
90
|
+
|
|
91
|
+
A lazy fragment composes with that for free, and correctly:
|
|
92
|
+
|
|
93
|
+
- Fresh element ⇒ fresh `getInternalData` ⇒ `triggeredOnce` is unset ⇒ the
|
|
94
|
+
fragment re-arms rather than staying stuck on its placeholder.
|
|
95
|
+
- Re-armed on `intersect`, not `load` ⇒ it re-arms **lazily**. If the fragment is
|
|
96
|
+
off-screen or CSS-hidden when the re-render lands, it waits, exactly as it did
|
|
97
|
+
on first paint. A plain deferred fragment would have refetched immediately.
|
|
98
|
+
|
|
99
|
+
Locked by "re-arms as a lazy wrapper after a full-container update" and "gives
|
|
100
|
+
the lazy wrapper its own id so the morph replaces rather than reuses it".
|
|
101
|
+
|
|
102
|
+
## Decision 3 — `lazy: true` implies `defer: true`
|
|
103
|
+
|
|
104
|
+
`fragment(:card, lazy: true)` is legal and means `defer: true, lazy: true`.
|
|
105
|
+
Ruling: **imply, do not raise.**
|
|
106
|
+
|
|
107
|
+
The case for raising was consistency with the adjacent guard — `placeholder:`
|
|
108
|
+
without `defer:` raises `ArgumentError`. But those two options are not alike.
|
|
109
|
+
A placeholder with nothing to place-hold has *no* meaning; the author who wrote it
|
|
110
|
+
has a mistaken model of the API and needs to hear about it. `lazy:` has exactly
|
|
111
|
+
one possible meaning, and it is unambiguous: hold the fetch until visible. There
|
|
112
|
+
is no second reading for an error message to disambiguate, so raising would spend
|
|
113
|
+
an author's round-trip to make them type a word the DSL could have supplied.
|
|
114
|
+
That is friction with nothing on the other side of it (Forrest's Law).
|
|
115
|
+
|
|
116
|
+
It also matches Turbo, where `loading="lazy"` is only meaningful on a frame that
|
|
117
|
+
loads from `src` — the attribute presupposes the deferred load rather than
|
|
118
|
+
combining with it. And the DSL already implies options elsewhere: `area_chart` is
|
|
119
|
+
`line_chart` with `fill: true`, `doughnut_chart` is `pie_chart` with
|
|
120
|
+
`doughnut: true`.
|
|
121
|
+
|
|
122
|
+
Implementation is one line at the top of `App#fragment` (`defer ||= lazy`), placed
|
|
123
|
+
*before* the placeholder guard so `fragment(:card, lazy: true, placeholder: '…')`
|
|
124
|
+
is accepted while `fragment(:card, placeholder: '…')` still raises. All three
|
|
125
|
+
behaviours are specced.
|
|
126
|
+
|
|
127
|
+
## Design note — lazy route tabs
|
|
128
|
+
|
|
129
|
+
Required by criterion 5. **Design only; no tabs code was changed by this story.**
|
|
130
|
+
|
|
131
|
+
`tabs key, lazy: true` (the POST-morph mode) is deprecated. `App#warn_lazy_tabs_deprecated`
|
|
132
|
+
already names its successor in the warning text: "lazy route tabs will replace
|
|
133
|
+
this mode". This is the primitive that lets that happen.
|
|
134
|
+
|
|
135
|
+
### The shape
|
|
136
|
+
|
|
137
|
+
Route tabs (`tabs :view, url: true`) render every panel into the DOM as a
|
|
138
|
+
`<div class="sw-tab-panel" x-show="activeTab === N" x-cloak>`. Two independent
|
|
139
|
+
mechanisms make an inactive panel `display: none`: `[x-cloak] { display: none !important; }`
|
|
140
|
+
(adapter/alpinejs.rb:6738) before Alpine boots, and `x-show`'s own inline
|
|
141
|
+
`display: none` afterwards. So an inactive route-tab panel is already, today,
|
|
142
|
+
precisely the CSS-hidden container that a lazy fragment refuses to fetch inside.
|
|
143
|
+
|
|
144
|
+
Adoption is therefore a **DSL pattern, not a renderer feature**:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
tabs :view, url: true do
|
|
148
|
+
tab 'Summary' do
|
|
149
|
+
text summary_line # cheap, always rendered
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
tab 'Revenue' do
|
|
153
|
+
fragment :revenue, lazy: true, placeholder: -> { skeleton_rows(8) } do
|
|
154
|
+
revenue_table # 1.5s of work; runs the first time this tab is shown
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
What happens: the shell ships with all three panels present and the Revenue panel
|
|
161
|
+
hidden, so its block never runs. Clicking the Revenue trigger runs Alpine's
|
|
162
|
+
`activeTab = 1` and `swRouteTabs.push(:view, 1)` — client-side only, no server
|
|
163
|
+
round-trip, the URL updates. `x-show` flips the panel to visible; the
|
|
164
|
+
IntersectionObserver on the lazy wrapper fires; one scoped fetch materializes just
|
|
165
|
+
that fragment. Switching back and forward again refetches nothing (`once`, plus
|
|
166
|
+
the wrapper is gone). Back/forward across the tab is client-side too: `@popstate`
|
|
167
|
+
re-reads the index and re-shows an already-materialized panel.
|
|
168
|
+
|
|
169
|
+
### Why this beats the deprecated mode
|
|
170
|
+
|
|
171
|
+
- **The deprecated mode fetches the whole app container.** Its trigger button
|
|
172
|
+
carries `hx-target="#app-container"` with `hx-vals` writing the new index into
|
|
173
|
+
session state. Lazy route tabs fetch one fragment at its own signed endpoint,
|
|
174
|
+
send no state patch, and leave the tab index where route tabs put it (the URL).
|
|
175
|
+
- **The deprecated mode is dead on a canvas page** because a canvas has no route
|
|
176
|
+
for its POST-morph (`stream_weaver-pkh`, quoted at app.rb:1014). Lazy route tabs
|
|
177
|
+
inherit the deferred-fragment fetch path, so they degrade the same way every
|
|
178
|
+
other deferred fragment does — see the caveat below. That is not yet *working*
|
|
179
|
+
on canvas, but it is one shared gap instead of a second bespoke one.
|
|
180
|
+
- **Panel cost becomes per-panel and opt-in.** `lazy: true` on the tabs group was
|
|
181
|
+
all-or-nothing and skipped evaluation for every inactive tab, which forced the
|
|
182
|
+
two-pass re-render at app.rb:926 when a clamped index changed after the block
|
|
183
|
+
had run. A lazy fragment inside one tab costs nothing to the tabs machinery: the
|
|
184
|
+
panel still renders, its cheap content still evaluates, and only the fragment's
|
|
185
|
+
block is held back.
|
|
186
|
+
|
|
187
|
+
### Constraint: keep the fragment inside `tab`, never beside it
|
|
188
|
+
|
|
189
|
+
`disc-085`: the index-clamp range bound, `tab`'s positional index, and
|
|
190
|
+
`render_tabs`' `children.each_with_index` all count **all** children of the tabs
|
|
191
|
+
block, not just `Tab` components. A non-tab child at the top level of a `tabs`
|
|
192
|
+
block shifts every panel index after it and lets an in-range-but-wrong index dodge
|
|
193
|
+
the clamp.
|
|
194
|
+
|
|
195
|
+
This design does not touch that, and must not start:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
tabs :view, url: true do
|
|
199
|
+
fragment(:sidebar, lazy: true) { … } # WRONG — becomes child 0, shifts every panel
|
|
200
|
+
tab('Summary') { … }
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The pattern above puts the fragment **inside** a `tab` block, where it is a child
|
|
205
|
+
of the `Tab` component and invisible to all three counting sites. Whoever
|
|
206
|
+
implements lazy route tabs should carry this as an explicit non-goal: adopting the
|
|
207
|
+
primitive requires no change to `render_tabs`, so it must not become the occasion
|
|
208
|
+
to open the mixed-children counting question. Fixing `disc-085` remains its own
|
|
209
|
+
story, and doing it first would not block this one.
|
|
210
|
+
|
|
211
|
+
### Open question for the implementer
|
|
212
|
+
|
|
213
|
+
Whether `lazy route tabs` should also become sugar — e.g. `tab 'Revenue', lazy: true`
|
|
214
|
+
wrapping the block in a lazy fragment automatically — or stay the explicit
|
|
215
|
+
two-verb composition above. The explicit form is what this story enables and is
|
|
216
|
+
already zero-JS; sugar would need to answer where the fragment name comes from
|
|
217
|
+
(the label? the index? — an index-derived name reintroduces exactly the
|
|
218
|
+
positional coupling `disc-085` is about) and whether a placeholder can be
|
|
219
|
+
declared per tab. Recommend shipping the composition, using it in `my-todos`, and
|
|
220
|
+
only then deciding whether the sugar earns a name.
|
|
221
|
+
|
|
222
|
+
## Caveats and known limitations
|
|
223
|
+
|
|
224
|
+
- **Canvas.** `Fragment#render_deferred` posts to `url('/update')` with no
|
|
225
|
+
`websocket_mode?` guard, so a deferred *or* lazy fragment on a live canvas page
|
|
226
|
+
posts to a route the bridge does not serve. Inherited from
|
|
227
|
+
`deferred-fragments-src`, unchanged here; it is an instance of `disc-097` (only
|
|
228
|
+
4 of 21 `htmx_attrs` call sites consult `websocket_mode?`) and belongs to
|
|
229
|
+
`disc-093`'s backend-less-context matrix.
|
|
230
|
+
- **Named actions inside the fragment.** `disc-100` applies unchanged: a
|
|
231
|
+
`button 'X', action: :foo` minted during a fragment fetch is dead on arrival.
|
|
232
|
+
Use a block button.
|
|
233
|
+
- **Static export.** A lazy fragment's block runs inline in an export, like a
|
|
234
|
+
deferred one — nothing will ever scroll an exported file. Specced.
|
|
235
|
+
- **Placeholder height.** The wrapper is what the observer watches, so a
|
|
236
|
+
placeholder should occupy space. The default spinner does; a placeholder that
|
|
237
|
+
renders nothing gives the observer a zero-area target.
|
|
238
|
+
|
|
239
|
+
## What the browser pass must still settle
|
|
240
|
+
|
|
241
|
+
Everything above about the *server* is specced (`spec/lazy_fragments_spec.rb`, 20
|
|
242
|
+
examples). Everything about *visibility* is a browser claim and is deliberately
|
|
243
|
+
not asserted in MRI: that IntersectionObserver stays silent for a `display: none`
|
|
244
|
+
target, that a CSS `:hover` reveal fires it, that exactly one network request
|
|
245
|
+
results, and that re-hiding and re-showing produces none. The runbook for those
|
|
246
|
+
checks is in the story's handoff note.
|