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,151 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Manual-verification harness for LAZY fragments
|
|
5
|
+
# (`fragment ..., defer: true, lazy: true`) -- Turbo's `loading="lazy"`.
|
|
6
|
+
#
|
|
7
|
+
# A lazy fragment is a deferred fragment whose fetch waits until the element is
|
|
8
|
+
# VISIBLE, whatever made it visible: scrolling to it, or a CSS rule flipping an
|
|
9
|
+
# ancestor from display:none to display:block. It fetches exactly once. There is
|
|
10
|
+
# no JavaScript in this file -- the trigger is `hx-trigger="intersect once"`,
|
|
11
|
+
# backed by the browser's own IntersectionObserver.
|
|
12
|
+
#
|
|
13
|
+
# SW_NO_OPEN=1 STREAMWEAVER_PORT=4641 ruby examples/lazy_fragments_demo.rb
|
|
14
|
+
#
|
|
15
|
+
# Then, with devtools' Network tab open and filtered to `update`:
|
|
16
|
+
#
|
|
17
|
+
# 1. On load, ZERO fetches fire. Every panel below is either off-screen or
|
|
18
|
+
# CSS-hidden.
|
|
19
|
+
# 2. Hover the card in section 1 and HOLD -> exactly one fetch, and the card
|
|
20
|
+
# fills in. Move away and hover again -> no second fetch.
|
|
21
|
+
# 3. Scroll to section 2 -> one fetch when the panel enters the viewport.
|
|
22
|
+
# 4. Keep scrolling through section 3 -> one fetch per page, in order, each
|
|
23
|
+
# page's tail carrying the next page's placeholder. Each fetch re-runs its
|
|
24
|
+
# ancestors' blocks, so page N takes N * SW_LAZY_DELAY -- the pages getting
|
|
25
|
+
# progressively slower is the nesting cost, not a stall.
|
|
26
|
+
# 5. Click "Re-render the page" -> every panel returns to its placeholder and
|
|
27
|
+
# re-arms, but still waits for visibility rather than refetching at once.
|
|
28
|
+
#
|
|
29
|
+
# SW_LAZY_DELAY overrides the per-fragment sleep (default 1.0s), so the
|
|
30
|
+
# placeholder is on screen long enough to see.
|
|
31
|
+
|
|
32
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
33
|
+
require "stream_weaver"
|
|
34
|
+
|
|
35
|
+
DELAY = (ENV["SW_LAZY_DELAY"] || "1.0").to_f
|
|
36
|
+
PAGES = 4
|
|
37
|
+
|
|
38
|
+
def slow_work(label)
|
|
39
|
+
sleep DELAY
|
|
40
|
+
"#{label} rendered by the server at #{Time.now.strftime('%H:%M:%S.%L')}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The Russian doll: each page's content ends with the NEXT page's lazy
|
|
44
|
+
# placeholder, so the pages materialize one at a time as the reader scrolls and
|
|
45
|
+
# the app never has to know how many there are up front.
|
|
46
|
+
#
|
|
47
|
+
# NOTE: page 1 is deliberately lazy here TOO, so this demo's "zero fetches on
|
|
48
|
+
# load" check holds — that exercises the primitive, not the Turbo parity shape.
|
|
49
|
+
# For a real feed, render the current page's rows in the shell and lazy-wrap
|
|
50
|
+
# only the NEXT page — see the streamweaver-way skill's infinite-scroll recipe.
|
|
51
|
+
def scroll_page(number)
|
|
52
|
+
fragment :"page_#{number}", lazy: true, placeholder: -> { spinner(size: :sm, label: "Loading page #{number}…") } do
|
|
53
|
+
text slow_work("Page #{number}")
|
|
54
|
+
div(class: "lazy-demo-filler") { text "(page #{number} content, scroll on)" }
|
|
55
|
+
scroll_page(number + 1) if number < PAGES
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
app "Lazy Fragments" do
|
|
60
|
+
use_stylesheet <<~CSS
|
|
61
|
+
/* Section 1 -- the zero-JS hover card. CSS alone flips the popup from
|
|
62
|
+
display:none to display:block; that is what fires the fetch. */
|
|
63
|
+
.lazy-demo-hover-host { position: relative; display: inline-block; }
|
|
64
|
+
.lazy-demo-hover-label {
|
|
65
|
+
border-bottom: 2px dotted var(--sw-accent, #0d9488);
|
|
66
|
+
cursor: help;
|
|
67
|
+
font-weight: 600;
|
|
68
|
+
}
|
|
69
|
+
.lazy-demo-hover-card {
|
|
70
|
+
display: none;
|
|
71
|
+
position: absolute;
|
|
72
|
+
top: 1.6rem;
|
|
73
|
+
left: 0;
|
|
74
|
+
z-index: 20;
|
|
75
|
+
min-width: 22rem;
|
|
76
|
+
padding: 0.75rem 1rem;
|
|
77
|
+
background: var(--sw-surface, #ffffff);
|
|
78
|
+
color: var(--sw-text, #111111);
|
|
79
|
+
border: 1px solid var(--sw-border, #e0e0e0);
|
|
80
|
+
border-radius: 6px;
|
|
81
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
|
82
|
+
}
|
|
83
|
+
.lazy-demo-hover-host:hover .lazy-demo-hover-card { display: block; }
|
|
84
|
+
|
|
85
|
+
/* Pushes the later sections below the fold so scrolling is what reveals
|
|
86
|
+
them. */
|
|
87
|
+
.lazy-demo-spacer {
|
|
88
|
+
height: 90vh;
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
justify-content: center;
|
|
92
|
+
color: var(--sw-text-dim, #444444);
|
|
93
|
+
border: 1px dashed var(--sw-border, #e0e0e0);
|
|
94
|
+
border-radius: 6px;
|
|
95
|
+
margin: 1.5rem 0;
|
|
96
|
+
}
|
|
97
|
+
.lazy-demo-filler {
|
|
98
|
+
height: 55vh;
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
justify-content: center;
|
|
102
|
+
color: var(--sw-text-dim, #444444);
|
|
103
|
+
background: var(--sw-surface-elevated, #f3f3f3);
|
|
104
|
+
border-radius: 6px;
|
|
105
|
+
margin: 0.75rem 0;
|
|
106
|
+
}
|
|
107
|
+
CSS
|
|
108
|
+
|
|
109
|
+
header1 "Lazy fragments"
|
|
110
|
+
md "Every panel on this page sleeps #{DELAY}s inside its block. **None of them " \
|
|
111
|
+
"has fetched yet.** A lazy fragment holds its fetch until the element is " \
|
|
112
|
+
"visible. Open devtools' Network tab, filter to `update`, and watch the " \
|
|
113
|
+
"count stay at zero until you hover or scroll."
|
|
114
|
+
text "Shell rendered at #{Time.now.strftime('%H:%M:%S.%L')}"
|
|
115
|
+
|
|
116
|
+
header3 "1. Hover card: CSS-hidden, so it never fetches until it is shown"
|
|
117
|
+
md "The popup below is `display: none` until `:hover` flips it. No JavaScript " \
|
|
118
|
+
"is involved on either side: CSS makes it visible, the IntersectionObserver " \
|
|
119
|
+
"notices, htmx fetches once."
|
|
120
|
+
div(class: "lazy-demo-hover-host") do
|
|
121
|
+
div(class: "lazy-demo-hover-label") { text "Hover me for the account summary" }
|
|
122
|
+
div(class: "lazy-demo-hover-card") do
|
|
123
|
+
fragment :hovercard, lazy: true, placeholder: "Fetching summary…" do
|
|
124
|
+
text slow_work("Hover card")
|
|
125
|
+
text "Hover away and back: this does not fetch again."
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
header3 "2. Below the fold: fetches when scrolled into the viewport"
|
|
131
|
+
div(class: "lazy-demo-spacer") { text "↓ keep scrolling ↓" }
|
|
132
|
+
fragment :below_fold, lazy: true, placeholder: -> { spinner(size: :sm, label: "Waiting to be seen…") } do
|
|
133
|
+
text slow_work("Below-the-fold panel")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
header3 "3. Infinite scroll: #{PAGES} pages of Russian dolls"
|
|
137
|
+
md "Each page's block ends by declaring the next page as its own lazy " \
|
|
138
|
+
"fragment, so page N+1 does not exist in the DOM until page N has landed " \
|
|
139
|
+
"and been scrolled past. This is the whole of the pagination logic."
|
|
140
|
+
scroll_page(1)
|
|
141
|
+
|
|
142
|
+
header3 "4. Full re-render: re-arms, but stays lazy"
|
|
143
|
+
md "This button swaps the whole app container. Every panel above returns to " \
|
|
144
|
+
"its placeholder and re-arms; the ones still off-screen or hidden wait " \
|
|
145
|
+
"rather than fetching immediately, which is what makes this different from " \
|
|
146
|
+
"a plain `defer: true`."
|
|
147
|
+
button "Re-render the page" do |s|
|
|
148
|
+
s[:renders] = s[:renders].to_i + 1
|
|
149
|
+
end
|
|
150
|
+
text "Full re-renders so far: #{state[:renders].to_i}"
|
|
151
|
+
end.run!
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# My Todos — Turbo Frames parity proof
|
|
2
|
+
|
|
3
|
+
A StreamWeaver mirror of the learnhotwire.com course's Rails "My Todos" app
|
|
4
|
+
(`github.com/learnhotwire/rails`), building all four Turbo Frames chapter features under one
|
|
5
|
+
rule: **zero custom JavaScript in app code**. No script tags, no inline JS, no hand-written
|
|
6
|
+
Alpine — DSL verbs and CSS only.
|
|
7
|
+
|
|
8
|
+
This started as a discovery spike (two of the four features didn't work yet); it's now the
|
|
9
|
+
parity proof — all four work end to end on the primitives the spike's findings motivated
|
|
10
|
+
(deferred/lazy fragments, strict-ids keying).
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
SW_NO_OPEN=1 STREAMWEAVER_PORT=4599 ruby examples/my_todos/my_todos.rb
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
| Route | Feature | How |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `/` | Inline editing | Per-row `fragment` + title-only `form_for`, keyed by record id |
|
|
19
|
+
| `/search` | Search | `text_field` auto-submit, in two arrangements (field outside vs. inside the results fragment) — both filter correctly; see the app for the remaining cost trade-off |
|
|
20
|
+
| `/hover-cards` | Hover cards | `fragment(..., lazy: true)` keyed by todo, inside the CSS `:hover` reveal — fetches once, only when hovered |
|
|
21
|
+
| `/infinite-scroll` | Infinite scroll | Russian-doll nested `fragment(..., lazy: true)` — each page declares the next as its own lazy fragment; scrolling triggers the fetch |
|
|
22
|
+
|
|
23
|
+
Boot with `SW_HOVERCARD_DELAY=1.5` to feel the difference: the shell renders in well under a
|
|
24
|
+
second, and each card pays its 1.5s only once, only if hovered (the equivalent of the chapter's
|
|
25
|
+
`sleep 1.5`).
|
|
26
|
+
|
|
27
|
+
**Full write-up**: [`docs/research/streamweaver-way-spike-findings.md`](../../docs/research/streamweaver-way-spike-findings.md)
|
|
28
|
+
— per feature, the Rails mechanism with its key elements, the StreamWeaver equivalent, and the
|
|
29
|
+
resolution history (what was missing, what shipped to close it).
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# My Todos -- StreamWeaver parity PROOF against the learnhotwire.com course's
|
|
5
|
+
# Rails app (github.com/learnhotwire/rails), Turbo Frames chapter.
|
|
6
|
+
#
|
|
7
|
+
# Rule: ZERO custom JavaScript in app code. No script tags, no inline JS, no
|
|
8
|
+
# hand-written Alpine. Only DSL verbs and CSS. All four chapter features --
|
|
9
|
+
# inline editing, search, hover cards, infinite scroll -- work end to end
|
|
10
|
+
# under that rule, on the primitives shipped by this epic (strict-ids keying,
|
|
11
|
+
# deferred/lazy fragments). This app started as a spike (SDRD: build to
|
|
12
|
+
# discover); docs/research/streamweaver-way-spike-findings.md is the full
|
|
13
|
+
# history, now with every recorded stumble resolved or re-filed.
|
|
14
|
+
#
|
|
15
|
+
# SW_NO_OPEN=1 ruby examples/my_todos/my_todos.rb
|
|
16
|
+
#
|
|
17
|
+
# Findings doc: docs/research/streamweaver-way-spike-findings.md
|
|
18
|
+
|
|
19
|
+
require_relative '../../lib/stream_weaver'
|
|
20
|
+
require_relative 'store'
|
|
21
|
+
|
|
22
|
+
# The chapter's hover card is "three lines of CSS, no JavaScript": the frame is
|
|
23
|
+
# display:none until :hover flips it to display:block, and Turbo's
|
|
24
|
+
# loading="lazy" IntersectionObserver fires the fetch at exactly that moment.
|
|
25
|
+
# StreamWeaver gets both halves -- the CSS reveal, plus `fragment(...,
|
|
26
|
+
# lazy: true)`'s own visibility-triggered fetch. See feature 3 below.
|
|
27
|
+
HOVERCARD_CSS = <<~CSS
|
|
28
|
+
.hovercard { position: relative; display: inline-block; cursor: help; }
|
|
29
|
+
.hovercard .hovercard-panel {
|
|
30
|
+
display: none; position: absolute; top: 1.6em; left: 0; z-index: 50;
|
|
31
|
+
min-width: 15rem;
|
|
32
|
+
}
|
|
33
|
+
.hovercard:hover .hovercard-panel { display: block; }
|
|
34
|
+
.todo-row { padding: 0.15rem 0; }
|
|
35
|
+
.spike-gap { border-left: 3px solid #d97706; padding-left: 0.75rem; }
|
|
36
|
+
CSS
|
|
37
|
+
|
|
38
|
+
# Only `:title` -- the StreamWeaver equivalent of the course's narrow
|
|
39
|
+
# `Projects::NamesController` with `params.expect(project: [:name])`. `form_for`
|
|
40
|
+
# coerces exactly the fields listed here and nothing else, so a submit through
|
|
41
|
+
# this form cannot reach `:completed` (app.rb#form_for_coerce). That is the
|
|
42
|
+
# "edit safe" half of the chapter's demo, enforced by construction rather than
|
|
43
|
+
# by remembering to pass the right `url:`.
|
|
44
|
+
TITLE_ONLY = [StreamWeaver::Field.new(:title, :string, {})].freeze
|
|
45
|
+
|
|
46
|
+
FEATURE_PATHS = {
|
|
47
|
+
inline_edit: '/',
|
|
48
|
+
search: '/search',
|
|
49
|
+
hover_cards: '/hover-cards',
|
|
50
|
+
infinite_scroll: '/infinite-scroll'
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# routing.md Pitfall 1: every parser branch merges on top of this so a
|
|
54
|
+
# feature-local key set by an earlier request cannot leak into a later view.
|
|
55
|
+
VIEW_RESET = { editing_id: nil }.freeze
|
|
56
|
+
|
|
57
|
+
# The Russian doll, in Turbo's actual shape: the page the reader is ON renders
|
|
58
|
+
# inline in whatever response we are already writing, and only the NEXT page is
|
|
59
|
+
# wrapped in a lazy fragment. Rails does exactly this -- a frame around the
|
|
60
|
+
# current page's rows, plus a separate `loading: :lazy` frame for `@pagy.next`.
|
|
61
|
+
# Page N+1 does not exist in the DOM -- and never fetches -- until page N has
|
|
62
|
+
# been scrolled past. That recursion is the whole of the pagination logic: no
|
|
63
|
+
# scroll handler, no page counter in state (llms.txt "Recipe: infinite scroll,
|
|
64
|
+
# as nested fragments").
|
|
65
|
+
#
|
|
66
|
+
# Do NOT wrap page `number` itself in the lazy fragment. A deferred block is
|
|
67
|
+
# skipped on the shell render, so that shape serves HTML with zero rows in it;
|
|
68
|
+
# it only LOOKS right because page 1's placeholder lands in the initial viewport
|
|
69
|
+
# and the observer fires immediately.
|
|
70
|
+
def scroll_todos_page(number)
|
|
71
|
+
TodoStore.page(number).each { |t| text "#{t[:completed] ? '☑' : '☐'} #{t[:title]}" }
|
|
72
|
+
|
|
73
|
+
# The guard is the chapter's `@pagy.next` check: without it Rails renders a
|
|
74
|
+
# frame with a blank id and the chain dead-ends in "Content missing".
|
|
75
|
+
nxt = number + 1
|
|
76
|
+
return if TodoStore.page(nxt).empty?
|
|
77
|
+
|
|
78
|
+
fragment(:"todos_page_#{nxt}", lazy: true, placeholder: 'Loading…') do
|
|
79
|
+
scroll_todos_page(nxt)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
app 'My Todos', layout: :wide do
|
|
84
|
+
use_stylesheet HOVERCARD_CSS
|
|
85
|
+
|
|
86
|
+
route_with(
|
|
87
|
+
parser: lambda do |path|
|
|
88
|
+
base = path.to_s.split('?').first
|
|
89
|
+
feature = FEATURE_PATHS.key(base.empty? ? '/' : base)
|
|
90
|
+
feature ? VIEW_RESET.merge(feature: feature) : nil
|
|
91
|
+
end,
|
|
92
|
+
builder: lambda do |current_state|
|
|
93
|
+
base = FEATURE_PATHS[current_state[:feature]&.to_sym] || '/'
|
|
94
|
+
# Search is a GET filter, so the query belongs in the URL -- the same
|
|
95
|
+
# reason the chapter uses `method: :get` for its search form.
|
|
96
|
+
query = current_state[:query].to_s
|
|
97
|
+
if current_state[:feature]&.to_sym == :search && !query.strip.empty?
|
|
98
|
+
"#{base}?query=#{CGI.escape(query)}"
|
|
99
|
+
else
|
|
100
|
+
base
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
state[:feature] ||= :inline_edit
|
|
106
|
+
|
|
107
|
+
navbar do
|
|
108
|
+
nav_item 'Inline editing', href: '/', active: state[:feature] == :inline_edit
|
|
109
|
+
nav_item 'Search', href: '/search', active: state[:feature] == :search
|
|
110
|
+
nav_item 'Hover cards', href: '/hover-cards', active: state[:feature] == :hover_cards
|
|
111
|
+
nav_item 'Infinite scroll', href: '/infinite-scroll', active: state[:feature] == :infinite_scroll
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
fragment(:flash) { flash_messages }
|
|
115
|
+
|
|
116
|
+
case state[:feature].to_sym
|
|
117
|
+
# ==========================================================================
|
|
118
|
+
# 1. Inline editing
|
|
119
|
+
#
|
|
120
|
+
# Rails: two `turbo_frame_tag @project, :edit_name` frames -- one wrapping the
|
|
121
|
+
# display <h1>, one wrapping the edit form -- that swap by matching dom_id.
|
|
122
|
+
# StreamWeaver: one `fragment` per row, keyed by record id, whose body
|
|
123
|
+
# branches on `state[:editing_id]`. Every interactive element rendered inside
|
|
124
|
+
# a fragment is auto-scoped to it (adapter/alpinejs.rb#htmx_attrs), so the
|
|
125
|
+
# Edit button and the form submit both swap just this row.
|
|
126
|
+
# ==========================================================================
|
|
127
|
+
when :inline_edit
|
|
128
|
+
header1 'My Todos'
|
|
129
|
+
md 'Click **Edit** on a row: the row becomes a title-only form in place. ' \
|
|
130
|
+
'The completed checkbox is rendered *outside* the form -- watch that it ' \
|
|
131
|
+
'survives the save.'
|
|
132
|
+
|
|
133
|
+
TodoStore.all.first(6).each do |todo|
|
|
134
|
+
fragment("todo-#{todo[:id]}") do
|
|
135
|
+
div class: 'todo-row' do
|
|
136
|
+
if state[:editing_id].to_s == todo[:id]
|
|
137
|
+
text "#{todo[:completed] ? '☑' : '☐'} (editing)"
|
|
138
|
+
form_for(
|
|
139
|
+
store: TodoStore,
|
|
140
|
+
fields: TITLE_ONLY,
|
|
141
|
+
name: :"todo_#{todo[:id]}_form",
|
|
142
|
+
record: todo,
|
|
143
|
+
on_success: ->(_id) { state[:editing_id] = nil }
|
|
144
|
+
) { submit_label 'Save' }
|
|
145
|
+
button('Cancel', style: :secondary, key: "cancel-#{todo[:id]}") do |s|
|
|
146
|
+
s[:editing_id] = nil
|
|
147
|
+
end
|
|
148
|
+
else
|
|
149
|
+
hstack spacing: :sm do
|
|
150
|
+
text "#{todo[:completed] ? '☑' : '☐'} #{todo[:title]}"
|
|
151
|
+
button('Edit', style: :secondary, key: "edit-#{todo[:id]}") do |s|
|
|
152
|
+
s[:editing_id] = todo[:id]
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# ==========================================================================
|
|
161
|
+
# 2. Search
|
|
162
|
+
#
|
|
163
|
+
# Rails: the form sits OUTSIDE the results frame and names it with
|
|
164
|
+
# `data: {turbo_frame: :todos}`; a Stimulus `autosubmit` controller turns
|
|
165
|
+
# keystrokes into `requestSubmit()`.
|
|
166
|
+
# StreamWeaver: `text_field` auto-submits on input with no controller at all.
|
|
167
|
+
# Both arrangements below now filter correctly -- the fragment-scoped param
|
|
168
|
+
# merge bug the spike found is fixed (InteractionRunner merges posted state
|
|
169
|
+
# before every scoped rebuild; see spec/live_input_fragment_wiring_spec.rb
|
|
170
|
+
# and spec/deferred_fragments_spec.rb's regression coverage). What's left is
|
|
171
|
+
# a genuine trade-off, not a break: shown in both arrangements below.
|
|
172
|
+
# ==========================================================================
|
|
173
|
+
when :search
|
|
174
|
+
header1 'Search'
|
|
175
|
+
alert(variant: :success) do
|
|
176
|
+
text 'WORKS -- submit-as-you-type is free in both arrangements below, ' \
|
|
177
|
+
'with no custom JavaScript.'
|
|
178
|
+
text 'A is the Rails layout (field outside the results): swaps the whole ' \
|
|
179
|
+
'app body, so it costs more bytes but the field never re-renders ' \
|
|
180
|
+
'itself. B moves the field inside the fragment for a scoped morph ' \
|
|
181
|
+
'-- fewer bytes, and focus/caret survive the morph -- at the cost ' \
|
|
182
|
+
'of the field re-rendering itself on every keystroke.'
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
header3 'A. Rails arrangement -- field outside the results region'
|
|
186
|
+
text_field :query, placeholder: 'Filter todos…', label: 'Search (outside)'
|
|
187
|
+
fragment(:search_results_outside) do
|
|
188
|
+
results = TodoStore.search(state[:query])
|
|
189
|
+
text "#{results.length} of #{TodoStore.all.length} todos"
|
|
190
|
+
results.first(8).each { |t| text "• #{t[:title]}" }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
div(style: 'height:24px') { }
|
|
194
|
+
|
|
195
|
+
header3 'B. StreamWeaver arrangement -- field inside the results fragment'
|
|
196
|
+
fragment(:search_results_inside) do
|
|
197
|
+
text_field :query_inside, placeholder: 'Filter todos…', label: 'Search (inside)'
|
|
198
|
+
results = TodoStore.search(state[:query_inside])
|
|
199
|
+
text "#{results.length} of #{TodoStore.all.length} todos"
|
|
200
|
+
results.first(8).each { |t| text "• #{t[:title]}" }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
div class: 'spike-gap' do
|
|
204
|
+
header3 'The remaining trade-off'
|
|
205
|
+
md 'Both arrangements filter correctly now. What StreamWeaver still ' \
|
|
206
|
+
'lacks is a `data-turbo-frame` equivalent: `text_field` never passes ' \
|
|
207
|
+
'`sw_updates:` to `htmx_attrs`, so an input can only target its own ' \
|
|
208
|
+
'enclosing fragment (B) or `#app-container` (A) -- it cannot name a ' \
|
|
209
|
+
'sibling fragment from outside it. `text_field :query, updates: ' \
|
|
210
|
+
':results` is accepted and silently ignored (a real gap, tracked, ' \
|
|
211
|
+
'not fixed by this story). Until that lands, A pays the whole-body ' \
|
|
212
|
+
'swap and B pays the self-re-render -- pick per feature which cost ' \
|
|
213
|
+
'you\'d rather carry.'
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# ==========================================================================
|
|
217
|
+
# 3. Hover cards
|
|
218
|
+
#
|
|
219
|
+
# Rails: `turbo_frame_tag todo.user, :hovercard, src: ..., loading: :lazy`
|
|
220
|
+
# inside a `div.hovercard`. CSS reveals it; `loading=lazy` means the fetch
|
|
221
|
+
# only happens because the reveal made it visible.
|
|
222
|
+
# StreamWeaver: `fragment(..., lazy: true)` is the same two-part interlock --
|
|
223
|
+
# CSS does the revealing, `hx-trigger="intersect once"` does the fetching,
|
|
224
|
+
# and it never fires while the card is `display: none`.
|
|
225
|
+
# ==========================================================================
|
|
226
|
+
when :hover_cards
|
|
227
|
+
header1 'Hover cards'
|
|
228
|
+
alert(variant: :success) do
|
|
229
|
+
text 'WORKS -- CSS reveals the card, and revealing it is what fires the ' \
|
|
230
|
+
'fetch. Nothing has fetched until the pointer arrives.'
|
|
231
|
+
text 'Boot with SW_HOVERCARD_DELAY=1.5 and hover a name: the shell above ' \
|
|
232
|
+
'already rendered; only the hovered card pays the 1.5s, once.'
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
TodoStore.all.first(6).each do |todo|
|
|
236
|
+
user = UserStore.find(todo[:user_id])
|
|
237
|
+
div class: 'todo-row' do
|
|
238
|
+
hstack spacing: :sm do
|
|
239
|
+
text "#{todo[:completed] ? '☑' : '☐'} #{todo[:title]} —"
|
|
240
|
+
# Keyed by the todo, not the user: the chapter (38:52) demos two todos
|
|
241
|
+
# sharing an assignee emitting the same frame id twice. The fragment
|
|
242
|
+
# name below carries that same rule -- key by what is unique per
|
|
243
|
+
# position on the page (the todo), not by what the content is about
|
|
244
|
+
# (the user) -- per llms.txt "Interactive IDs and keying".
|
|
245
|
+
div class: 'hovercard' do
|
|
246
|
+
text user[:name]
|
|
247
|
+
div class: 'hovercard-panel' do
|
|
248
|
+
fragment(:"hovercard_#{todo[:id]}", lazy: true, placeholder: 'Loading…') do
|
|
249
|
+
UserStore.delay
|
|
250
|
+
card do
|
|
251
|
+
header3 user[:name]
|
|
252
|
+
text user[:role]
|
|
253
|
+
text "#{TodoStore.all.count { |t| t[:user_id] == user[:id] }} todos"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
div class: 'spike-gap' do
|
|
263
|
+
header3 'How it works now'
|
|
264
|
+
md 'Each card is its own `fragment(:"hovercard_#{todo[:id]}", lazy: ' \
|
|
265
|
+
'true)`. The CSS wrapper hides it (`display: none`), so the fragment' \
|
|
266
|
+
'\'s IntersectionObserver never fires -- no request goes out. Hovering ' \
|
|
267
|
+
'flips the CSS to `display: block`, which is what makes the fragment ' \
|
|
268
|
+
'visible, which is what fires the fetch. It fetches exactly once: ' \
|
|
269
|
+
'hover away and back, and the content that already landed stays put, ' \
|
|
270
|
+
'no second request. Six cards, zero eager cost.'
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# ==========================================================================
|
|
274
|
+
# 4. Infinite scroll
|
|
275
|
+
#
|
|
276
|
+
# Rails: nested "Russian doll" frames -- each response renders the CURRENT
|
|
277
|
+
# page's rows inline and appends page N+1's placeholder frame, already
|
|
278
|
+
# `loading: :lazy`, so scrolling to the bottom fetches the next page and
|
|
279
|
+
# nothing is ever removed from the DOM.
|
|
280
|
+
# StreamWeaver: the same shape, with `fragment(..., lazy: true)` around the
|
|
281
|
+
# NEXT page only -- see `scroll_todos_page` above. No button, no scroll
|
|
282
|
+
# listener, no page counter in state: scrolling to page N's bottom is what
|
|
283
|
+
# makes page N+1's fragment visible, which is what fetches it.
|
|
284
|
+
# ==========================================================================
|
|
285
|
+
when :infinite_scroll
|
|
286
|
+
header1 'Infinite scroll'
|
|
287
|
+
alert(variant: :success) do
|
|
288
|
+
text 'WORKS -- scroll-to-load-more, nested "Russian doll" fragments, ' \
|
|
289
|
+
'each fetch sends exactly its own page.'
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
scroll_todos_page(1)
|
|
293
|
+
|
|
294
|
+
div class: 'spike-gap' do
|
|
295
|
+
header3 'How it works now'
|
|
296
|
+
md 'The page you are ON renders inline; only the NEXT page is wrapped in ' \
|
|
297
|
+
'`fragment(:"todos_page_N+1", lazy: true)`. So this shell already ' \
|
|
298
|
+
'carries page 1\'s ten rows plus exactly one placeholder — view source ' \
|
|
299
|
+
'and the rows are really there, which is the same progressive-' \
|
|
300
|
+
'enhancement guarantee Rails gives you. Page N+1 is not in the DOM -- ' \
|
|
301
|
+
'and has not fetched -- until scrolling reveals its placeholder. Each ' \
|
|
302
|
+
'response carries only that page\'s rows (`TodoStore.page(n)`), not ' \
|
|
303
|
+
'everything loaded so far: a constant per-page payload where the old ' \
|
|
304
|
+
'click-driven version grew with every click. Wrapping page N itself in ' \
|
|
305
|
+
'the lazy fragment is the tempting shape and it is wrong -- the block ' \
|
|
306
|
+
'is skipped on the shell render, so the served HTML has no rows at ' \
|
|
307
|
+
'all, and it only looks right because page 1\'s placeholder starts in ' \
|
|
308
|
+
'the viewport. See findings doc, feature 4 resolution for the measured ' \
|
|
309
|
+
'byte counts.'
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end.run!
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# In-memory stores for the My Todos parity spike.
|
|
4
|
+
#
|
|
5
|
+
# Mirrors the learnhotwire.com course app's `Todo` / `User` models closely
|
|
6
|
+
# enough to exercise the same four Turbo Frames chapter features:
|
|
7
|
+
# `Todo.search(query)` (including the blank guard the chapter demos as a live
|
|
8
|
+
# bug), a `user_id` assignee for hover cards, and enough rows to paginate.
|
|
9
|
+
|
|
10
|
+
module UserStore
|
|
11
|
+
USERS = {
|
|
12
|
+
'u1' => { id: 'u1', name: 'Ada Lovelace', role: 'Engineering', joined: '2024-03-11' },
|
|
13
|
+
'u2' => { id: 'u2', name: 'Alan Turing', role: 'Research', joined: '2023-11-02' },
|
|
14
|
+
'u3' => { id: 'u3', name: 'Grace Hopper', role: 'Platform', joined: '2025-01-20' }
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def self.all = USERS.values
|
|
18
|
+
def self.find(id) = USERS[id]
|
|
19
|
+
|
|
20
|
+
# Deliberately slow, to make "was this fetched lazily or eagerly?" observable
|
|
21
|
+
# the way the chapter's `sleep 1.5` does in the Rails app. Off by default so
|
|
22
|
+
# the spike app stays usable; set SW_HOVERCARD_DELAY=1.5 to feel the cost of
|
|
23
|
+
# rendering every card eagerly.
|
|
24
|
+
def self.delay
|
|
25
|
+
seconds = ENV['SW_HOVERCARD_DELAY'].to_f
|
|
26
|
+
sleep(seconds) if seconds.positive?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
module TodoStore
|
|
31
|
+
PER_PAGE = 10
|
|
32
|
+
|
|
33
|
+
TITLES = [
|
|
34
|
+
'Buy milk', 'Ship the parity spike', 'Review the fragment docs',
|
|
35
|
+
'Book the offsite', 'Refactor the store protocol', 'Write release notes',
|
|
36
|
+
'Answer the support thread', 'Update the changelog', 'Prune stale branches',
|
|
37
|
+
'Draft the design memo', 'Pair on the routing bug', 'Buy coffee beans'
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
@todos = 60.times.map do |i|
|
|
41
|
+
{
|
|
42
|
+
id: (i + 1).to_s,
|
|
43
|
+
title: "#{TITLES[i % TITLES.length]} ##{i + 1}",
|
|
44
|
+
completed: (i % 5).zero?,
|
|
45
|
+
user_id: "u#{(i % 3) + 1}"
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
def all = @todos
|
|
51
|
+
def find(id) = @todos.find { |t| t[:id] == id.to_s }
|
|
52
|
+
|
|
53
|
+
def create(attrs)
|
|
54
|
+
id = ((@todos.map { |t| t[:id].to_i }.max || 0) + 1).to_s
|
|
55
|
+
@todos << { id: id, completed: false, user_id: 'u1', **attrs }
|
|
56
|
+
id
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def update(id, attrs)
|
|
60
|
+
todo = find(id) or return false
|
|
61
|
+
todo.merge!(attrs)
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def destroy(id)
|
|
66
|
+
@todos.reject! { |t| t[:id] == id.to_s }
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Mirrors the course's `Todo.search`, blank guard included. The chapter
|
|
71
|
+
# demos the missing-guard version as a live bug: clear the box and the
|
|
72
|
+
# whole list vanishes because the LIKE runs against nil.
|
|
73
|
+
def search(query)
|
|
74
|
+
return all if query.to_s.strip.empty?
|
|
75
|
+
needle = query.to_s.downcase
|
|
76
|
+
all.select { |t| t[:title].downcase.include?(needle) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A single page's slice -- the Russian-doll infinite-scroll recipe fetches
|
|
80
|
+
# one page per request, not everything-so-far. See
|
|
81
|
+
# docs/research/streamweaver-way-spike-findings.md, feature 4 resolution.
|
|
82
|
+
# An empty return is the recursion's own termination signal -- see
|
|
83
|
+
# scroll_todos_page in my_todos.rb.
|
|
84
|
+
def page(number) = all.drop(([number.to_i, 1].max - 1) * PER_PAGE).first(PER_PAGE)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Demo: ReactiveState — watch, on_start, and computed/derived state
|
|
3
|
+
# Build: bundle exec streamweaver opal-build examples/opal/reactive_demo.rb --output dist/reactive_demo
|
|
4
|
+
# Open: open dist/reactive_demo/index.html
|
|
5
|
+
|
|
6
|
+
app "Reactive Demo" do
|
|
7
|
+
# ── S8: Loan Calculator (derived state, no useMemo needed) ──────────
|
|
8
|
+
card do
|
|
9
|
+
header2 "Loan Calculator"
|
|
10
|
+
text_field :principal, label: "Loan Amount ($)", type: :number
|
|
11
|
+
text_field :rate, label: "Annual Rate (%)", type: :number
|
|
12
|
+
text_field :term, label: "Term (months)", type: :number
|
|
13
|
+
|
|
14
|
+
p = state[:principal].to_f
|
|
15
|
+
r = state[:rate].to_f / 100.0 / 12.0
|
|
16
|
+
n = state[:term].to_f
|
|
17
|
+
payment = (r > 0 && n > 0) ? (p * r * (1 + r)**n) / ((1 + r)**n - 1) : 0.0
|
|
18
|
+
|
|
19
|
+
card do
|
|
20
|
+
header3 "Monthly Payment"
|
|
21
|
+
text "$#{"%.2f" % payment}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
div(style: "height: 32px")
|
|
26
|
+
|
|
27
|
+
# ── S5: Watch — filter list reacts to search ──────────────────────
|
|
28
|
+
card do
|
|
29
|
+
header2 "Search Filter (watch)"
|
|
30
|
+
watch(:search_query) do |query|
|
|
31
|
+
all = %w[Apple Banana Blueberry Cherry Cranberry Date Elderberry Fig Grape]
|
|
32
|
+
state[:filtered] = query.to_s.empty? ? all : all.select { |f| f.downcase.include?(query.downcase) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
text_field :search_query, placeholder: "Type to filter fruit..."
|
|
36
|
+
|
|
37
|
+
div do
|
|
38
|
+
(state[:filtered] || %w[Apple Banana Blueberry Cherry Cranberry Date Elderberry Fig Grape]).each do |fruit|
|
|
39
|
+
text fruit
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
div(style: "height: 32px")
|
|
45
|
+
|
|
46
|
+
# ── S6: on_start — runs once after first render ───────────────────
|
|
47
|
+
card do
|
|
48
|
+
header2 "on_start Demo"
|
|
49
|
+
md "**on_start** fires exactly once after the first render — like `useEffect(fn, [])` in React. Watch for the 'Loading...' state to briefly appear, then swap to real data after 800ms."
|
|
50
|
+
state[:items] = state[:items] || []
|
|
51
|
+
on_start do
|
|
52
|
+
after(0.8) { state[:items] = ["Loaded Item A", "Loaded Item B", "Loaded Item C"] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if state[:items].empty?
|
|
56
|
+
text "Loading..."
|
|
57
|
+
else
|
|
58
|
+
badge "Loaded via on_start", color: :green
|
|
59
|
+
state[:items].each { |item| text item }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# S1: Counter with Derived Display
|
|
3
|
+
# React equivalent: useState + useMemo
|
|
4
|
+
# StreamWeaver: inline computation, zero annotation
|
|
5
|
+
|
|
6
|
+
app "S1 — Counter with Derived Display" do
|
|
7
|
+
button("Increment") { state[:count] = state[:count].to_i + 1 }
|
|
8
|
+
button("Reset") { state[:count] = 0 }
|
|
9
|
+
|
|
10
|
+
card do
|
|
11
|
+
text "Count: #{state[:count].to_i}"
|
|
12
|
+
text "Is even: #{state[:count].to_i.even?}"
|
|
13
|
+
text "Double: #{state[:count].to_i * 2}"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# S2: Search-Filtered List
|
|
3
|
+
# React equivalent: useMemo with [search] dependency array
|
|
4
|
+
# StreamWeaver: inline computation — the filter IS the render
|
|
5
|
+
|
|
6
|
+
app "S2 — Search-Filtered List" do
|
|
7
|
+
text_field :search, placeholder: "Filter fruits..."
|
|
8
|
+
|
|
9
|
+
fruits = %w[Apple Apricot Avocado Banana Blueberry Cherry Cranberry Date Elderberry Fig Grape Kiwi]
|
|
10
|
+
query = state[:search].to_s.downcase
|
|
11
|
+
items = query.empty? ? fruits : fruits.select { |f| f.downcase.include?(query) }
|
|
12
|
+
|
|
13
|
+
text "#{items.size} of #{fruits.size} shown"
|
|
14
|
+
div do
|
|
15
|
+
items.each { |fruit| text fruit }
|
|
16
|
+
end
|
|
17
|
+
end
|