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,529 @@
|
|
|
1
|
+
# My Todos Parity Spike — Where StreamWeaver Breaks
|
|
2
|
+
|
|
3
|
+
Empirical companion to `docs/research/2026-08-17-hotwire-concept-map.md`. That map assessed the
|
|
4
|
+
gaps by reading the code; this one assessed them by building the app and watching what came back
|
|
5
|
+
over the wire.
|
|
6
|
+
|
|
7
|
+
**The app**: `examples/my_todos/` — a StreamWeaver mirror of the learnhotwire.com course's Rails
|
|
8
|
+
"My Todos" app (`github.com/learnhotwire/rails`), attempting the four Turbo Frames chapter
|
|
9
|
+
features under one rule: **zero custom JavaScript in app code**. No script tags, no inline JS, no
|
|
10
|
+
hand-written Alpine. DSL verbs and CSS only.
|
|
11
|
+
|
|
12
|
+
**Framework changes made: none.** Everything below is recorded, not patched.
|
|
13
|
+
|
|
14
|
+
**Evidence status**: every measurement here is server-side (curl against a live boot on port
|
|
15
|
+
4599). Response shapes, byte counts, emitted htmx attributes, and timings are facts. Claims about
|
|
16
|
+
what a *user* sees — focus retention while typing, whether the CSS hover reveal actually reveals,
|
|
17
|
+
whether morph swaps look smooth — are pending a main-thread browser pass and are marked as such.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Scorecard
|
|
22
|
+
|
|
23
|
+
| # | Feature | Verdict | Breaks at |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| 1 | Inline editing | **WORKS** | — (deep-linkable edit state is the only Rails behavior not matched) |
|
|
26
|
+
| 2 | Search | **PARTIAL** | An input cannot target a sibling fragment; no `data-turbo-frame` equivalent |
|
|
27
|
+
| 3 | Hover cards | **PARTIAL** | CSS reveal works; no visibility-triggered fetch exists, so every card renders eagerly |
|
|
28
|
+
| 4 | Infinite scroll | **DEGRADED** | No visibility trigger *and* no nested/appending fragments; degrades to O(n)-per-click "Load more" |
|
|
29
|
+
|
|
30
|
+
Two surprise stumbles beyond the two expected gaps, both in the silent-failure class — see
|
|
31
|
+
[Surprise stumbles](#surprise-stumbles).
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## How fragment scoping actually works (the fact everything else follows from)
|
|
36
|
+
|
|
37
|
+
Worth stating up front, because three of the four findings are consequences of it.
|
|
38
|
+
|
|
39
|
+
StreamWeaver's fragment scoping is **lexical, not declarative**. `Adapter::AlpineJS#htmx_attrs`
|
|
40
|
+
(`lib/stream_weaver/adapter/alpinejs.rb:1277-1295`) asks the view for `current_fragment_id` — the
|
|
41
|
+
fragment the element is *being rendered inside* — and, if there is one, rewrites the element's
|
|
42
|
+
`hx-target` to `#<that fragment id>` and appends a signed `_sw_fragment` token to its POST URL.
|
|
43
|
+
|
|
44
|
+
Turbo is the opposite: a link or form *names* the frame it drives, from anywhere on the page
|
|
45
|
+
(`data: {turbo_frame: :todos}`). Position on the page is irrelevant.
|
|
46
|
+
|
|
47
|
+
So in StreamWeaver, **where you write a component determines what it can update**. The one escape
|
|
48
|
+
hatch is `updates:` (and `primary:`), which lets an element name additional fragments — but it is
|
|
49
|
+
read only by `button` and `clickable` (`lib/stream_weaver/app.rb:742`, `:799`). No input component
|
|
50
|
+
passes it through.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 1. Inline editing — WORKS
|
|
55
|
+
|
|
56
|
+
### The Rails mechanism
|
|
57
|
+
|
|
58
|
+
Two frames with the same `dom_id`, in two different templates:
|
|
59
|
+
|
|
60
|
+
- `app/views/projects/show.html.erb` — `turbo_frame_tag @project, :edit_name` wrapping
|
|
61
|
+
`<h1><%= link_to @project.name, edit_project_name_path(@project) %></h1>`
|
|
62
|
+
- `app/views/projects/names/edit.html.erb` — `turbo_frame_tag @project, :edit_name` wrapping
|
|
63
|
+
`form_with model: @project, url: project_name_path(@project)`
|
|
64
|
+
|
|
65
|
+
The link lives *inside* the frame it replaces. Clicking it fetches the edit page; Turbo discards
|
|
66
|
+
everything except the matching frame and swaps it in. Key elements: the `turbo_frame_tag` pair,
|
|
67
|
+
`dom_id` deriving the shared id from the record, and the frame not touching the URL.
|
|
68
|
+
|
|
69
|
+
**The "edit safe" half** is a separate lesson and the more interesting one. The chapter
|
|
70
|
+
(transcript 17:28–22:00) demos `form_with model: @todo` with no `url:`, which defaults to the
|
|
71
|
+
generic `project_todo_path` whose strong params are
|
|
72
|
+
`params.expect(todo: [:name, :description, :due_date, :completed, :user_id])`. Editing just the
|
|
73
|
+
title silently flips `completed` too, because the form carries a hidden `completed` field and the
|
|
74
|
+
generic endpoint permits it. Neither version errors. The fix is a **narrow nested resource** —
|
|
75
|
+
`resource :name` → `Projects::NamesController` → `params.expect(project: [:name])` — reached by
|
|
76
|
+
passing an explicit `url:`.
|
|
77
|
+
|
|
78
|
+
### The StreamWeaver equivalent
|
|
79
|
+
|
|
80
|
+
One `fragment` per row, keyed by record id, branching on `state[:editing_id]`:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
fragment("todo-#{todo[:id]}") do
|
|
84
|
+
if state[:editing_id].to_s == todo[:id]
|
|
85
|
+
form_for(store: TodoStore, fields: TITLE_ONLY, name: :"todo_#{todo[:id]}_form",
|
|
86
|
+
record: todo, on_success: ->(_id) { state[:editing_id] = nil }) { submit_label 'Save' }
|
|
87
|
+
else
|
|
88
|
+
text "#{todo[:completed] ? '☑' : '☐'} #{todo[:title]}"
|
|
89
|
+
button('Edit', key: todo[:id]) { |s| s[:editing_id] = todo[:id] }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Because the Edit button and the form's submit are both rendered inside the fragment, both are
|
|
95
|
+
auto-scoped to it. Server-verified:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
Edit button emitted: hx-target="#sw-frag-todo-1" hx-swap="morph:innerHTML"
|
|
99
|
+
hx-post="/action/btn_edit_70c8d6e8?_sw_fragment=eyJmIjoic3ctZnJhZy10b2RvLTEifQ...."
|
|
100
|
+
POST that action → 1,327 bytes (full page load of the same view: 108,792 bytes)
|
|
101
|
+
POST the form → 806 bytes ("Buy milk #1" → "Buy oat milk (renamed)", ☑ unchanged)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The flash arrives free as an out-of-band swap into `#sw-frag-flash` — `InteractionRunner` adds a
|
|
105
|
+
fragment named `flash` to the OOB extras automatically whenever a scoped response set a flash
|
|
106
|
+
message (`lib/stream_weaver/interaction_runner.rb:139-142`).
|
|
107
|
+
|
|
108
|
+
**Edit-safety is structural here, not a thing you remember to do.** `form_for_coerce`
|
|
109
|
+
(`lib/stream_weaver/app.rb:1650-1663`) iterates the *declared* `fields:` and builds the update
|
|
110
|
+
hash from those alone. A form declared with `fields: [Field.new(:title, :string, {})]` cannot
|
|
111
|
+
write `:completed` — there is no `url:` to forget, and no generic endpoint to fall back to. This
|
|
112
|
+
is a genuine improvement on the Rails ergonomics the chapter warns about: Rails makes the safe
|
|
113
|
+
path opt-in, StreamWeaver makes the unsafe path unexpressible.
|
|
114
|
+
|
|
115
|
+
### What is not matched
|
|
116
|
+
|
|
117
|
+
Rails' edit state is a real URL (`/projects/1/name/edit`) you can visit, link, or reload.
|
|
118
|
+
StreamWeaver's `state[:editing_id]` is session state; the scoped response sends `hx-push-url: /`
|
|
119
|
+
and the URL never reflects which row is open. Both frameworks agree the URL *shouldn't change on
|
|
120
|
+
a frame swap*, so this matches Turbo's default behavior — but Rails still has a canonical URL for
|
|
121
|
+
the edit view, and StreamWeaver has none unless you route it yourself (which `resource`'s
|
|
122
|
+
`edit_view :page` would give you, at the cost of it being a page navigation rather than a scoped
|
|
123
|
+
swap — the tradeoff `llms.txt` already names).
|
|
124
|
+
|
|
125
|
+
**Browser pass owed**: confirm the row visibly turns into a form and back, that only that row
|
|
126
|
+
changes, and that the other rows' completed marks are untouched.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## 2. Search — PARTIAL
|
|
131
|
+
|
|
132
|
+
### The Rails mechanism
|
|
133
|
+
|
|
134
|
+
The form sits **outside** the results frame and names it:
|
|
135
|
+
|
|
136
|
+
```erb
|
|
137
|
+
<%= form_with url: project_path(@project), method: :get, data: {turbo_frame: :todos} do |form| %>
|
|
138
|
+
<%= form.search_field :query, value: params[:query],
|
|
139
|
+
data: {controller: :autosubmit, action: "input->autosubmit#submit"} %>
|
|
140
|
+
<% end %>
|
|
141
|
+
|
|
142
|
+
<%= turbo_frame_tag "todos", target: :_top, data: {turbo_action: :advance} do %>
|
|
143
|
+
<%= render @todos %>
|
|
144
|
+
<% end %>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Key elements: `data-turbo-frame` pointing the response at a frame elsewhere on the page;
|
|
148
|
+
`method: :get` so the query lands in the URL and stays cacheable; `data: {turbo_action: :advance}`
|
|
149
|
+
so the frame swap *does* push history here (opt-in, unlike feature 1); `target: :_top` so links in
|
|
150
|
+
the results escape the frame instead of dying in "Content missing"; and a Stimulus `autosubmit`
|
|
151
|
+
controller for submit-as-you-type — which must call `requestSubmit()`, not `submit()`, or Turbo
|
|
152
|
+
never sees the event (transcript 24:xx–25:26).
|
|
153
|
+
|
|
154
|
+
### What StreamWeaver does better
|
|
155
|
+
|
|
156
|
+
`text_field` auto-submits on input with **no controller at all**, debounced:
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
hx-trigger="keyup changed delay:500ms"
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
That is the whole of the chapter's Stimulus `autosubmit` controller plus its `requestSubmit()`
|
|
163
|
+
gotcha, gone. Deep-linking works in both directions:
|
|
164
|
+
|
|
165
|
+
- **In**: `GET /search?query=oat` on a fresh session renders `1 of 60 todos` — query params sync
|
|
166
|
+
to state before render.
|
|
167
|
+
- **Out**: typing pushes `hx-push-url: /search?query=milk`, via a `route_with` builder that
|
|
168
|
+
appends the query when the search view is active.
|
|
169
|
+
|
|
170
|
+
### Where it breaks
|
|
171
|
+
|
|
172
|
+
**An input cannot target a sibling fragment.** `render_text_field`
|
|
173
|
+
(`lib/stream_weaver/adapter/alpinejs.rb:81-145`) calls `htmx_attrs(...)` without ever passing
|
|
174
|
+
`sw_updates:`, so a text field gets exactly one target: its own enclosing fragment, or
|
|
175
|
+
`#app-container` if it isn't inside one. There is no `data-turbo-frame` equivalent.
|
|
176
|
+
|
|
177
|
+
The app renders both arrangements side by side. Measured, same server, same query:
|
|
178
|
+
|
|
179
|
+
| Arrangement | Emitted `hx-target` | Response |
|
|
180
|
+
|---|---|---|
|
|
181
|
+
| A — field outside the results fragment (the Rails shape) | `#app-container` | 2,123 bytes — whole app body: navbar, both sections |
|
|
182
|
+
| B — field moved inside the results fragment | `#sw-frag-search-results-inside` | 800 bytes |
|
|
183
|
+
|
|
184
|
+
So search-as-you-type is *achievable* with zero JS, but only by inverting the Rails layout: the
|
|
185
|
+
field has to live inside the region it filters. That has a cost the Rails version doesn't pay —
|
|
186
|
+
the input re-renders itself on every keystroke (the `<input>` tag is visible in B's 800-byte
|
|
187
|
+
response body), so caret and focus survive only if idiomorph's `morph:innerHTML` preserves them.
|
|
188
|
+
|
|
189
|
+
**Browser pass owed — this is the load-bearing unknown for this feature.** Type continuously into
|
|
190
|
+
arrangement B's field and confirm focus, caret position, and in-flight characters survive the
|
|
191
|
+
morph. If they don't, StreamWeaver has no zero-JS search-as-you-type at all, and this drops from
|
|
192
|
+
PARTIAL to BREAKS. Also worth checking arrangement A for a visible full-body flash on each
|
|
193
|
+
keystroke.
|
|
194
|
+
|
|
195
|
+
### The primitive that would close it
|
|
196
|
+
|
|
197
|
+
`updates:` on input components — i.e. `text_field :query, updates: :search_results` resolving the
|
|
198
|
+
same way a button's does. Today that exact line is silently ignored (see stumble (b) below), which
|
|
199
|
+
makes this gap worse than a plain absence.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## 3. Hover cards — PARTIAL (expected gap, confirmed)
|
|
204
|
+
|
|
205
|
+
### The Rails mechanism
|
|
206
|
+
|
|
207
|
+
From `app/views/todos/_todo.html.erb`:
|
|
208
|
+
|
|
209
|
+
```erb
|
|
210
|
+
<div class="hovercard">
|
|
211
|
+
<%= image_tag todo.user.gravatar_url, class: "avatar" %>
|
|
212
|
+
<%= turbo_frame_tag todo.user, :hovercard,
|
|
213
|
+
src: project_user_path(todo.project, todo.user), loading: :lazy do %>
|
|
214
|
+
<% end %>
|
|
215
|
+
</div>
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
plus three lines of CSS: `.hovercard-panel { display: none }`, `position: absolute` in a
|
|
219
|
+
`position: relative` parent, and `.hovercard:hover turbo-frame { display: block }`.
|
|
220
|
+
|
|
221
|
+
The mechanism is a two-part interlock, and both parts matter:
|
|
222
|
+
|
|
223
|
+
1. **CSS does the revealing.** No hover handler, no fetch call, no state.
|
|
224
|
+
2. **`loading="lazy"` does the fetching.** Turbo's IntersectionObserver fires when the frame
|
|
225
|
+
becomes *visible* — and it does not know or care why. `display: none` means not visible, so no
|
|
226
|
+
request goes out; the CSS `:hover` rule flips it to visible, and that alone triggers the fetch.
|
|
227
|
+
It loads exactly once — frames don't refetch on becoming visible again.
|
|
228
|
+
|
|
229
|
+
Also worth carrying forward: the shipped course code keys the frame by `todo.user`, which the
|
|
230
|
+
chapter itself demos as a bug (transcript 38:52–40:24). Two todos assigned to the same person emit
|
|
231
|
+
the same frame id twice — duplicate ids, invalid HTML, two frames answering to one name. The rule
|
|
232
|
+
is **key the frame by whatever is unique per position on the page, not by what the content is
|
|
233
|
+
about** — here, the todo, even though the card displays a user.
|
|
234
|
+
|
|
235
|
+
### What StreamWeaver gets
|
|
236
|
+
|
|
237
|
+
Part 1, in full. `use_stylesheet` (`lib/stream_weaver/app.rb:1416`) injects the CSS, and the
|
|
238
|
+
rendered page carries `.hovercard:hover .hovercard-panel { display: block; }`. Zero JavaScript,
|
|
239
|
+
zero DSL gymnastics.
|
|
240
|
+
|
|
241
|
+
### Where it breaks
|
|
242
|
+
|
|
243
|
+
Part 2 does not exist. There is no visibility-triggered anything:
|
|
244
|
+
|
|
245
|
+
- `Components::Fragment#initialize` (`lib/stream_weaver/components.rb:107-115`) takes `(name, id)`
|
|
246
|
+
and **nothing else** — no `src:`, no `loading:`. A fragment renders inline, in the same request,
|
|
247
|
+
always.
|
|
248
|
+
- `tabs lazy: true` is click-triggered POST-then-morph, not visibility-triggered — and
|
|
249
|
+
`features/route-tabs.feature` already deprecates it.
|
|
250
|
+
- `every(n)` is a post-load SSE timer, not an initial-load deferral.
|
|
251
|
+
- `div hover_class:` toggles a CSS class client-side but fetches nothing.
|
|
252
|
+
|
|
253
|
+
So every card body is rendered eagerly, in the same request as the list. Server-verified: the
|
|
254
|
+
initial `/hover-cards` HTML contains all 6 fully-populated panels and zero `loading="lazy"` /
|
|
255
|
+
`IntersectionObserver` markers.
|
|
256
|
+
|
|
257
|
+
**The cost, measured.** The store has an opt-in `UserStore.delay`, the equivalent of the chapter's
|
|
258
|
+
`sleep 1.5`. Booting with `SW_HOVERCARD_DELAY=1.5`:
|
|
259
|
+
|
|
260
|
+
```
|
|
261
|
+
GET /hover-cards 9.151s (6 cards × 1.5s, all paid up front, whether or not anyone hovers)
|
|
262
|
+
GET /search 0.003s (same server, same session, no cards)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
In Rails that page renders instantly, each card costs 1.5s only if hovered, and only the first
|
|
266
|
+
time. StreamWeaver pays for all of them, always. On a list of 60 rows it is 90 seconds of work to
|
|
267
|
+
render a page nobody may hover at all.
|
|
268
|
+
|
|
269
|
+
**The workaround and what it costs.** There isn't a good one. Rendering eagerly (what the spike
|
|
270
|
+
does) is correct but pays the full cost up front and scales with list length. Converting the
|
|
271
|
+
reveal to a click — a `button` with `updates:` on a shared detail fragment — does fetch lazily,
|
|
272
|
+
but it is no longer a hover card: it needs a click, it holds one card at a time, and it burns the
|
|
273
|
+
page's one detail region. Neither approximates "hover, and only then, and only once."
|
|
274
|
+
|
|
275
|
+
**Browser pass owed**: hover an assignee name and confirm the card actually appears and is
|
|
276
|
+
positioned sanely; confirm (via devtools Network) that **no request is made on hover** — that's
|
|
277
|
+
the observable signature of the missing primitive.
|
|
278
|
+
|
|
279
|
+
### The primitive that would close it
|
|
280
|
+
|
|
281
|
+
`visibility-lazy-fragments` (already a story in this epic). Concretely: `fragment(:name, loading: :lazy)`
|
|
282
|
+
rendering a placeholder plus an IntersectionObserver-triggered fetch, with fetch-once semantics.
|
|
283
|
+
The story should adopt Turbo's keying rule as a documented convention — key by position, not by
|
|
284
|
+
content — because StreamWeaver has no `dom_id` helper and today the author hand-applies it (see
|
|
285
|
+
`strict-ids-auto-keying`).
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## 4. Infinite scroll — DEGRADED (expected gap, confirmed, plus a second one)
|
|
290
|
+
|
|
291
|
+
### The Rails mechanism
|
|
292
|
+
|
|
293
|
+
From `app/views/todos/index.html.erb` — "Russian dolls":
|
|
294
|
+
|
|
295
|
+
```erb
|
|
296
|
+
<%= turbo_frame_tag :todos_page, @pagy.page, target: :_top do %>
|
|
297
|
+
<%= render partial: "todos/todo", collection: @todos, locals: {inline: true} %>
|
|
298
|
+
<% if @pagy.next %>
|
|
299
|
+
<%= turbo_frame_tag :todos_page, @pagy.next, src: todos_path(page: @pagy.next),
|
|
300
|
+
loading: :lazy, target: :_top do %>
|
|
301
|
+
<%= tag.div "Loading..." %>
|
|
302
|
+
<% end %>
|
|
303
|
+
<% end %>
|
|
304
|
+
<% end %>
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Key elements: the outer frame's id is built from the *current* page number; a second frame nested
|
|
308
|
+
inside it is keyed to the *next* page, points at that page's URL, and is `loading: :lazy`. Scroll
|
|
309
|
+
it into view and it fetches; the response is the same template rendered for page N+1, whose outer
|
|
310
|
+
frame id matches the placeholder that asked for it, and which carries the *next* placeholder
|
|
311
|
+
inside itself. The chain propagates until pages run out.
|
|
312
|
+
|
|
313
|
+
Two details that matter:
|
|
314
|
+
|
|
315
|
+
- **Nesting, not replacing.** If page N+1 replaced page N's frame instead of landing inside it,
|
|
316
|
+
you'd have built pagination with a scroll trigger — the user scrolls down and watches what they
|
|
317
|
+
were reading vanish. Nesting means nothing is ever removed; the DOM gets deeper, not wider.
|
|
318
|
+
- **The `@pagy.next` guard.** Without it, the last page renders a frame with a blank id and the
|
|
319
|
+
chain dead-ends in "Content missing".
|
|
320
|
+
|
|
321
|
+
The controller is one line of Pagy, every page is a real linkable URL (`?page=7` works, and you
|
|
322
|
+
can start infinite-scrolling from there), and there are no scroll listeners and no
|
|
323
|
+
IntersectionObserver in app code.
|
|
324
|
+
|
|
325
|
+
### What StreamWeaver gets
|
|
326
|
+
|
|
327
|
+
A click-driven "Load more" inside a fragment. It works, and the scoping works — the button emits
|
|
328
|
+
`hx-target="#sw-frag-todo-pages"`. The `if page < TodoStore.page_count` guard is the direct
|
|
329
|
+
analogue of `@pagy.next`.
|
|
330
|
+
|
|
331
|
+
### Where it breaks — twice
|
|
332
|
+
|
|
333
|
+
**(a) No visibility trigger.** Same missing primitive as feature 3. A button substitutes for the
|
|
334
|
+
scroll, which changes the interaction from "infinite scroll" to "paginate by clicking".
|
|
335
|
+
|
|
336
|
+
**(b) No nested or appending fragments.** This one is separate and, for this feature, worse. A
|
|
337
|
+
fragment swap is always `morph:innerHTML` — `HTMX_SWAP` is a hardcoded constant
|
|
338
|
+
(`lib/stream_weaver/adapter/alpinejs.rb:29`) and `Fragment` accepts no options to override it. So
|
|
339
|
+
each "Load more" must re-render and re-send **every row loaded so far**:
|
|
340
|
+
|
|
341
|
+
```
|
|
342
|
+
click 1: 1,248 bytes 20 rows in payload
|
|
343
|
+
click 2: 1,603 bytes 30 rows in payload
|
|
344
|
+
click 3: 1,957 bytes 40 rows in payload
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
O(n) per click, O(n²) over a session. Turbo's nested frames send exactly one page per fetch,
|
|
348
|
+
forever — O(1) per click, no matter how deep you are.
|
|
349
|
+
|
|
350
|
+
The row-granular narrowing that exists (`RowSwapView`,
|
|
351
|
+
`InteractionRunner#row_swap_for`, `lib/stream_weaver/interaction_runner.rb:190-214`) does not
|
|
352
|
+
rescue this: it requires the fragment to contain exactly one column-DSL `table` with row identity,
|
|
353
|
+
and its `:create` branch narrows only when **exactly one** row was added
|
|
354
|
+
(`new_ids.length == old_ids.length + 1`). A page-at-a-time append of 10 rows falls through to full
|
|
355
|
+
fragment content even if the list were rendered as a table.
|
|
356
|
+
|
|
357
|
+
**Browser pass owed**: click Load more a few times and confirm rows accumulate rather than
|
|
358
|
+
replace, and that scroll position is not lost on the morph.
|
|
359
|
+
|
|
360
|
+
### The primitives that would close it
|
|
361
|
+
|
|
362
|
+
Two, composed — which is why this feature is the epic's best integration test:
|
|
363
|
+
|
|
364
|
+
1. `visibility-lazy-fragments` (feature 3's primitive), and
|
|
365
|
+
2. `deferred-fragments-src` extended with a nesting/append semantic, so a fragment's response can
|
|
366
|
+
land *inside* the previous one instead of replacing it. `Streamer#append` already has the right
|
|
367
|
+
verb vocabulary but is reachable only from `every` timer blocks.
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
## Surprise stumbles
|
|
372
|
+
|
|
373
|
+
Both are silent failures, and both are the kind an author hits precisely *because* they went
|
|
374
|
+
looking for the missing primitive above.
|
|
375
|
+
|
|
376
|
+
### (a) `defer` is an unimplemented no-op that silently drops its block
|
|
377
|
+
|
|
378
|
+
`lib/stream_weaver/display_dsl.rb:916`:
|
|
379
|
+
|
|
380
|
+
```ruby
|
|
381
|
+
def defer(&block); end
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
It sits in the display DSL alongside `every`, `after`, `watch`, and `on_start` — which are real
|
|
385
|
+
verbs implemented elsewhere — so it reads exactly like the deferred-rendering primitive an author
|
|
386
|
+
hunting for feature 3 or 4 would reach for. It is not. Probed:
|
|
387
|
+
|
|
388
|
+
```ruby
|
|
389
|
+
StreamWeaver::App.new('probe') do
|
|
390
|
+
text 'BEFORE'
|
|
391
|
+
defer { text 'INSIDE-DEFER' }
|
|
392
|
+
text 'AFTER'
|
|
393
|
+
end
|
|
394
|
+
# components => [Text("BEFORE"), Text("AFTER")]
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
No raise, no warning, no comment in the source saying it's a stub. Content written inside a
|
|
398
|
+
`defer` block simply never appears, and nothing tells the author why.
|
|
399
|
+
|
|
400
|
+
### (b) `updates:` on an input is silently accepted and silently ignored
|
|
401
|
+
|
|
402
|
+
```ruby
|
|
403
|
+
text_field :q, updates: :results # renders: hx-target="#app-container"
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
No raise, no warning. `updates:` is consumed only by `button` and `clickable`
|
|
407
|
+
(`lib/stream_weaver/app.rb:742`, `:799`); on any other component it lands in the options hash and
|
|
408
|
+
is never read. This is the single most likely wrong turn for someone porting the Rails search
|
|
409
|
+
feature — it is the natural transliteration of `data: {turbo_frame: :todos}`, it looks right, and
|
|
410
|
+
it produces a full-page swap with zero feedback.
|
|
411
|
+
|
|
412
|
+
Both stumbles are the concept map's §4 finding hitting real code: StreamWeaver chose self-healing
|
|
413
|
+
fallback over Hotwire's loud "Content missing", and that policy has quietly generalized from
|
|
414
|
+
*routing* fallbacks into *authoring* mistakes, where it is no longer a virtue. This is direct
|
|
415
|
+
evidence for `dev-loud-failure-overlay`, and suggests its scope should include build-time
|
|
416
|
+
validation of ignored options, not just runtime swap failures.
|
|
417
|
+
|
|
418
|
+
### (c) Minor: per-row `form_for` names leak into flash text
|
|
419
|
+
|
|
420
|
+
Standalone `form_for` derives its flash noun by stripping `_form` from `name:`
|
|
421
|
+
(`lib/stream_weaver/app.rb:608`), so a per-row form named `:todo_1_form` flashes
|
|
422
|
+
`"Todo_1 updated."` rather than `"Todo updated."`. Cosmetic, but per-row form names are exactly
|
|
423
|
+
what inline editing requires, so anyone following the documented inline-edit pattern will hit it.
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
## What this means for the epic
|
|
428
|
+
|
|
429
|
+
The two expected gaps were confirmed, and the confirmation was more specific than the concept map
|
|
430
|
+
predicted in two ways:
|
|
431
|
+
|
|
432
|
+
- **The lazy-fragment gap is really two gaps.** Visibility-triggering and nesting/appending are
|
|
433
|
+
independent, and infinite scroll needs both. A `visibility-lazy-fragments` story that ships
|
|
434
|
+
IntersectionObserver semantics alone gets hover cards and leaves infinite scroll still O(n) per
|
|
435
|
+
click.
|
|
436
|
+
- **The input-can't-name-a-fragment gap wasn't on the map at all.** The concept map assessed
|
|
437
|
+
fragments as PARTIAL on execution-model grounds (whole-rerun vs. per-frame dispatch). The
|
|
438
|
+
practical blocker found here is narrower and more fixable: scoping is lexical, and `updates:`
|
|
439
|
+
— the one declarative escape hatch — isn't wired to inputs. That looks like a small,
|
|
440
|
+
high-leverage story, and it's what stands between StreamWeaver and a search feature that beats
|
|
441
|
+
Rails' outright (the auto-submit half already does).
|
|
442
|
+
|
|
443
|
+
Order suggested by this spike: input `updates:` (small, unblocks feature 2 cleanly) →
|
|
444
|
+
`dev-loud-failure-overlay` scoped to include ignored-option validation (cheap, and both surprise
|
|
445
|
+
stumbles above disappear) → `visibility-lazy-fragments` → nesting/append on
|
|
446
|
+
`deferred-fragments-src`.
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
450
|
+
## Reproducing
|
|
451
|
+
|
|
452
|
+
```bash
|
|
453
|
+
SW_NO_OPEN=1 STREAMWEAVER_PORT=4599 ruby examples/my_todos/my_todos.rb
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
Then `/` (inline editing), `/search`, `/hover-cards`, `/infinite-scroll`. Boot with
|
|
457
|
+
`SW_HOVERCARD_DELAY=1.5` to reproduce the eager-render timing above. Kill the process when done.
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
## Main-thread browser verification pass (2026-08-22, playwright-cli)
|
|
462
|
+
|
|
463
|
+
All four routes exercised in a real headless Chrome per the handoff checklist. Server-side
|
|
464
|
+
findings above stand, with two NEW browser-only findings.
|
|
465
|
+
|
|
466
|
+
**F1 inline editing — PASS.** Row 1 alone morphed to the form (other 5 rows untouched, no
|
|
467
|
+
page flash); Save applied the new title AND preserved the completed glyph (the edit-safe
|
|
468
|
+
demo); Cancel restored display mode unchanged. Form seeds correctly from the record.
|
|
469
|
+
|
|
470
|
+
**F2 search — split verdict, and a NEW break point.**
|
|
471
|
+
- Outside arrangement (A): WORKS end to end in browser — typed "milk" filtered to 5 of 60,
|
|
472
|
+
URL pushed to /search?query=milk, deep link /search?query=oat seeded the field and
|
|
473
|
+
filtered on GET. (Cost stands: whole-container swap.)
|
|
474
|
+
- Inside arrangement (B): focus + caret SURVIVE the idiomorph fragment morph (the checklist's
|
|
475
|
+
load-bearing unknown — idiomorph holds), BUT the filter never applies. Instrumented
|
|
476
|
+
htmx:configRequest shows the value IS sent (query_inside=milx in request params); the
|
|
477
|
+
fragment response still renders with stale state (60 of 60). Proof of locality: a
|
|
478
|
+
subsequent OUTSIDE-field interaction (full-container path) applied BOTH values — section B
|
|
479
|
+
then showed 0 of 60 for the previously-typed "milx". **Break point: the fragment-scoped
|
|
480
|
+
/update path renders without merging posted x-model params into state; the full-container
|
|
481
|
+
path merges them.** This is the same pipeline deferred-fragments-src will ride — the state
|
|
482
|
+
merge ordering must be fixed or designed around there.
|
|
483
|
+
|
|
484
|
+
**F3 hover cards — PARTIAL as documented, gap signature confirmed.** Hovering an assignee
|
|
485
|
+
revealed the .hovercard-panel via pure CSS (display none → visible, positioned correctly),
|
|
486
|
+
and performance.getEntriesByType("resource") recorded **zero new requests** — the observable
|
|
487
|
+
signature of the missing visibility-lazy primitive (Rails fires one fetch here). Eager cost
|
|
488
|
+
not re-measured in browser; server measurement (9.151s vs 0.003s) stands.
|
|
489
|
+
|
|
490
|
+
**F4 infinite scroll — DEGRADED as documented, behavior correct.** Load more accumulated
|
|
491
|
+
10 → 40 → 60 rows (never replaced), scroll position retained across morphs (scrollY stayed
|
|
492
|
+
400), responses GREW 1538 → 1893 → 2248 bytes (each response re-sends every on-screen row;
|
|
493
|
+
Turbo's nested frames send a constant page), button disappeared at exhaustion (60 of 60).
|
|
494
|
+
|
|
495
|
+
**NEW framework wart (browser console):** htmx logs
|
|
496
|
+
`The selector "[x-model]" on hx-include returned no matches!` on any page whose active
|
|
497
|
+
fragment/form contains no x-model element (seen on F1 edit-form render). Noisy console on
|
|
498
|
+
every such interaction; candidate cheap fix alongside dev-loud-failure-overlay.
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
502
|
+
## Resolution (2026-08-22, story `my-todos-zero-js`)
|
|
503
|
+
|
|
504
|
+
Every stumble recorded above, resolved or re-filed. `examples/my_todos/` is now the parity
|
|
505
|
+
proof, not the spike — all four features work end to end, zero custom JavaScript.
|
|
506
|
+
|
|
507
|
+
| # | Finding | Resolved by | Evidence |
|
|
508
|
+
|---|---|---|---|
|
|
509
|
+
| 1 | Inline editing — WORKS | No change needed | Re-verified server-side this story: edit fetch 1,327 bytes, save 806 bytes, title renamed, `☑` preserved — matches original measurements exactly |
|
|
510
|
+
| 2 | Search — an input cannot target a sibling fragment | Still open, re-filed (not this story's scope) | `text_field` still doesn't wire `updates:` to `sw_updates`; confirmed unchanged in `adapter/alpinejs.rb#render_text_field`. `lib/` untouched per this story's rule. Documented in the app as "The remaining trade-off," not a break |
|
|
511
|
+
| 2b | Search — fragment-scoped `/update` path doesn't merge posted `x-model` params (the browser-pass ADDENDUM break point) | `deferred-fragments-src` story — `InteractionRunner` now merges posted state before every scoped rebuild | `spec/deferred_fragments_spec.rb` "fragment-scoped param merge (spike ADDENDUM regression)", `spec/live_input_fragment_wiring_spec.rb`; this story's curl verification: sequential outside("milk")-then-inside("coffee") test shows zero state bleed, 834-byte scoped response contains only the inside result |
|
|
512
|
+
| 3 | Hover cards — no visibility-triggered fetch primitive | `visibility-lazy-fragments` story — `fragment(..., lazy: true)` | This story: rebuilt each card as `fragment(:"hovercard_#{todo_id}", lazy: true)` keyed by todo (not user, per the transcript's key-by-position rule). Verified: 0 eager card fetches on page load, shell renders in 0.085s with `SW_HOVERCARD_DELAY=1.5` set (was 9.151s), each card's fetch pays 1.5s once, only when requested |
|
|
513
|
+
| 4a | Infinite scroll — no visibility trigger | `visibility-lazy-fragments` (same primitive as #3) | This story: Russian-doll `scroll_todos_page` recursion, each page a `fragment(..., lazy: true)` |
|
|
514
|
+
| 4b | Infinite scroll — no nested/appending fragments, O(n) per click | `deferred-fragments-src` + `visibility-lazy-fragments` (nested fragment ids, `parent--child`) | This story: `TodoStore.page(n)` returns one page's slice; verified nested fetches through page 6 (terminal) — payload sizes 809/877/934/993/1060/371 bytes, growth is only the lengthening nested-id string, not accumulated rows. `through_page` (the cumulative helper) removed as dead code |
|
|
515
|
+
| a | `defer { ... }` silently drops its block | Fixed — `defer` now raises `NoMethodError` pointing at `fragment(:name, defer: true)` | `lib/stream_weaver/display_dsl.rb` (`8627b4b feat(fragments): defer: option renders the shell without running the block`) |
|
|
516
|
+
| b | `updates:` on `text_field` is silently accepted and ignored | Still open, re-filed — same item as #2 above | Not fixed by any preceding story; documented in-app, not patched (`lib/` off-limits this story) |
|
|
517
|
+
| c | Per-row `form_for` flash text leaks the form name (`"Todo_1 updated."`) | Still open, re-filed — cosmetic, not blocking | Reproduced again this story (save response includes `"Todo_1 updated."`); no story in this epic touched `form_for`'s flash-noun derivation |
|
|
518
|
+
|
|
519
|
+
**Net**: 6 of 9 recorded items resolved by the epic's primitive stories; 3 remain open and are
|
|
520
|
+
carried forward explicitly rather than silently dropped — two are the same underlying gap
|
|
521
|
+
(input `updates:` wiring), one is a cosmetic flash-text nit. None block the four features from
|
|
522
|
+
working; all three are documented in place (in-app copy or this table) rather than patched,
|
|
523
|
+
per this story's "do not modify `lib/`" rule.
|
|
524
|
+
|
|
525
|
+
**Addendum 2026-08-22 (post clean-room skill test):** resolution row 4a's original shape
|
|
526
|
+
(every page including page 1 wrapped in `lazy:`) shipped an EMPTY shell — no rows in the raw
|
|
527
|
+
HTML, masked in browsers by page 1's in-viewport sentinel fetching instantly. Corrected in
|
|
528
|
+
dc06bcf to the true Turbo shape: current page renders inline, only the NEXT page is a nested
|
|
529
|
+
lazy fragment. The dated measurements above describe the pre-correction build.
|