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,342 @@
|
|
|
1
|
+
# StreamWeaver Resource DSL
|
|
2
|
+
|
|
3
|
+
Convention-over-configuration CRUD scaffolding. One `resource` block replaces 30–50 lines of route/state/form boilerplate.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
require 'stream_weaver'
|
|
11
|
+
|
|
12
|
+
module PostStore
|
|
13
|
+
@posts = [{ id: '1', title: 'Hello', body: 'First post', status: 'published' }]
|
|
14
|
+
def self.all; @posts; end
|
|
15
|
+
def self.find(id); @posts.find { |p| p[:id] == id }; end
|
|
16
|
+
def self.create(attrs)
|
|
17
|
+
id = ((@posts.map { |p| p[:id].to_i }.max || 0) + 1).to_s
|
|
18
|
+
@posts << { id: id, **attrs }; id
|
|
19
|
+
end
|
|
20
|
+
def self.update(id, attrs); post = find(id) or return false; post.merge!(attrs); true; end
|
|
21
|
+
def self.destroy(id); @posts.reject! { |p| p[:id] == id }; true; end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
app 'Blog' do
|
|
25
|
+
page :home, '/' do
|
|
26
|
+
header1 'My Blog'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
resource :post, store: PostStore do
|
|
30
|
+
field :title, :string
|
|
31
|
+
field :body, :text
|
|
32
|
+
field :status, :enum, values: %w[draft published]
|
|
33
|
+
end
|
|
34
|
+
end.run!
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
That single `resource` block gives you:
|
|
38
|
+
|
|
39
|
+
| URL | Action |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `GET /posts` | Index — table with View / Edit / Delete per row |
|
|
42
|
+
| `GET /posts/new` | New — form with field inputs, Create button |
|
|
43
|
+
| `GET /post/:id` | Show — card with field values, Edit / Delete buttons |
|
|
44
|
+
| `GET /post/:id/edit` | Edit — form seeded from record, Save button |
|
|
45
|
+
| Delete button | Inline confirmation alert, then destroys record |
|
|
46
|
+
|
|
47
|
+
All URLs are deep-linkable and browser back/forward works.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## DSL Reference
|
|
52
|
+
|
|
53
|
+
### `resource`
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
resource :name, store: MyStore, plural: 'custom_plural' do
|
|
57
|
+
# field, edit_view, new_view, only, except, override blocks
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| Option | Default | Description |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `store:` | required | Object responding to store protocol (see below) |
|
|
64
|
+
| `plural:` | `"#{name}s"` | Override pluralization for irregular nouns |
|
|
65
|
+
|
|
66
|
+
**Declare `page` / `route` calls before `resource` blocks.** The routing chain is first-registered-wins; static page routes must appear before resource collection/member routes.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
### `field`
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
field :name, :type
|
|
74
|
+
field :name, :type, values: [...] # for :enum
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
| Type | Input component rendered |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `:string` | `text_field` |
|
|
80
|
+
| `:text` | `text_area` (4 rows) |
|
|
81
|
+
| `:enum` | `select` with `values:` list |
|
|
82
|
+
| `:boolean` | `checkbox` |
|
|
83
|
+
| `:integer` / `:number` | `text_field` |
|
|
84
|
+
| `:date` | `text_field` |
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
### `edit_view` / `new_view`
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
edit_view :page # URL-addressable /resource/:id/edit (default: :modal)
|
|
92
|
+
new_view :page # URL-addressable /resource/new (default: :modal)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
### `only` / `except`
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
only %i[index show] # whitelist actions
|
|
101
|
+
except %i[destroy] # blacklist actions
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Default set: `[:index, :show, :new, :edit, :destroy]`
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
### Override Blocks
|
|
109
|
+
|
|
110
|
+
Fully replace any default view. Block runs in App context (`instance_exec`).
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
resource :goal, store: GoalStore do
|
|
114
|
+
field :title, :string
|
|
115
|
+
field :horizon, :enum, values: %w[month quarter year]
|
|
116
|
+
|
|
117
|
+
index do |goals|
|
|
118
|
+
header1 'Goals'
|
|
119
|
+
goals.group_by { |g| g[:horizon] }.each do |horizon, gs|
|
|
120
|
+
header3 horizon.capitalize
|
|
121
|
+
table gs do
|
|
122
|
+
column :title
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
show do |goal|
|
|
128
|
+
card { header3 goal[:title]; text goal[:horizon] }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
| Block | Receives | Replaces |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `index do \|items\|` | Array of all records | Default index table |
|
|
136
|
+
| `show do \|item\|` | Single record hash | Default show card |
|
|
137
|
+
| `new do \|_\|` | nil | Default new form |
|
|
138
|
+
| `edit do \|item\|` | Single record hash | Default edit form |
|
|
139
|
+
|
|
140
|
+
App ivars (`@current_form`, `@form_context`) are saved before the block runs and restored after, preventing bleed-through.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
### `page` and `route`
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
page :home, '/' do
|
|
148
|
+
header1 'Welcome'
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
route :about, '/about' # same as page with empty block
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Declares a static named route. Renders the block when the current URL matches. Use before `resource` blocks.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
### `form_for`
|
|
159
|
+
|
|
160
|
+
A resource-bound form primitive: seeds fields from a record, infers create vs. update
|
|
161
|
+
from record identity, renders inputs via the shared field-type table, and wires submit
|
|
162
|
+
to coerce → validate → `store.create`/`update` → flash + PRG. It's what `resource`'s own
|
|
163
|
+
default `new`/`edit` views call under the hood — `form_for` exposes the same machinery
|
|
164
|
+
for use inside your own override blocks.
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
resource :person, store: PersonStore do
|
|
168
|
+
field :name, :string
|
|
169
|
+
field :role, :enum, values: %w[lead champion decision_maker]
|
|
170
|
+
|
|
171
|
+
edit do |person|
|
|
172
|
+
header1 "Edit #{person[:name]}"
|
|
173
|
+
form_for :person, record: person do
|
|
174
|
+
submit_label "Save"
|
|
175
|
+
cancel_label "Cancel"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
new do
|
|
180
|
+
header1 "New Person"
|
|
181
|
+
form_for :person do
|
|
182
|
+
submit_label "Create"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
| Argument | Default | Description |
|
|
189
|
+
|---|---|---|
|
|
190
|
+
| `resource_name` | `nil` | A registered `resource` name — reuses its `store:`/`fields:` |
|
|
191
|
+
| `record:` | `nil` | The record being edited. `nil`, or an id-less hash, means create mode |
|
|
192
|
+
| `store:` | resource's store | Required if `resource_name` is omitted |
|
|
193
|
+
| `fields:` | resource's fields | Required if `resource_name` is omitted |
|
|
194
|
+
| `name:` | `"#{singular}_form"` | Scope/form name override |
|
|
195
|
+
| `on_success:` | resource's default transition | `Proc`, `instance_exec`'d with the new/updated id |
|
|
196
|
+
| `validate:` | `nil` | `Proc`, called with coerced values, returns `Hash[field, Array[String]]` of extra errors |
|
|
197
|
+
|
|
198
|
+
Can also be used standalone, without a `resource` block, by passing `store:`/`fields:`
|
|
199
|
+
directly:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
form_for store: PersonStore, fields: [
|
|
203
|
+
StreamWeaver::Field.new(:name, :string, {}),
|
|
204
|
+
StreamWeaver::Field.new(:age, :integer, {})
|
|
205
|
+
], name: :person_form
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Create vs. update inference** — a `nil` `record:`, or a `record:` whose `:id` is nil,
|
|
209
|
+
means create; a present record with an id means update. This also sets the default
|
|
210
|
+
submit label (`"Create"` / `"Save"`).
|
|
211
|
+
|
|
212
|
+
**Seeding and dirty-draft safety** — the form's scope is seeded from `record:` only on
|
|
213
|
+
the first render for that record id, and never re-seeded on subsequent re-renders for
|
|
214
|
+
the same id — an unrelated re-render (a sidebar filter, a toast dismiss) never clobbers
|
|
215
|
+
an in-progress edit. Switching to a different record id (e.g. navigating from editing
|
|
216
|
+
person `1` to editing person `2`) re-seeds fresh, so there's no cross-record leakage.
|
|
217
|
+
|
|
218
|
+
**Validation** — coercion failures (`:integer`/`:number` fields that don't parse) and any
|
|
219
|
+
`validate { }` block errors both populate `state[:"#{name}_form_errors"]`, rendered as a
|
|
220
|
+
single `Alert(variant: :error)` summary above the fields. A validation failure is a
|
|
221
|
+
same-request re-render, not a redirect — the user's just-typed values stay in the scope
|
|
222
|
+
and the store is never called.
|
|
223
|
+
|
|
224
|
+
**Success** — on a valid submit, `form_for` calls `store.create`/`store.update`, sets
|
|
225
|
+
`flash[:notice]`, and transitions to the resource's `show` action (or `index` if the
|
|
226
|
+
resource doesn't declare `show`) with the URL pushed via the existing PRG mechanism.
|
|
227
|
+
Pass `on_success:` to override the transition — required for standalone (non-`resource`)
|
|
228
|
+
usage, which has no resource action to fall back to.
|
|
229
|
+
|
|
230
|
+
**In-block DSL** — inside the `form_for do ... end` block:
|
|
231
|
+
|
|
232
|
+
| Call | Effect |
|
|
233
|
+
|---|---|
|
|
234
|
+
| `submit_label "text"` | Overrides the default submit button label |
|
|
235
|
+
| `cancel_label "text"` | Adds a Cancel button (omitted by default) |
|
|
236
|
+
| `validate { \|values\| ... }` | Registers the extra validation hook described above |
|
|
237
|
+
| any other field/component call | Renders alongside the auto-generated fields, inside the same form |
|
|
238
|
+
|
|
239
|
+
All three (`submit_label`/`cancel_label`/`validate`) raise if called outside a `form_for`
|
|
240
|
+
block.
|
|
241
|
+
|
|
242
|
+
**Inline-edit example** — the course's [Turbo Frame inline-editing pattern](for_llms.md#turbo-frame-style-inline-editing-with-form_for)
|
|
243
|
+
(click a title to edit it in place, submit swaps back to the display view) is a direct
|
|
244
|
+
`form_for` use case; see `docs/for_llms.md` for the worked example.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Named-Route Helpers
|
|
249
|
+
|
|
250
|
+
Defined automatically on the App instance when `resource :post` is declared:
|
|
251
|
+
|
|
252
|
+
| Helper | Returns |
|
|
253
|
+
|---|---|
|
|
254
|
+
| `posts_path` | `"/posts"` |
|
|
255
|
+
| `new_post_path` | `"/posts/new"` |
|
|
256
|
+
| `post_path(rec)` | `"/post/#{rec[:id]}"` |
|
|
257
|
+
| `edit_post_path(rec)` | `"/post/#{rec[:id]}/edit"` |
|
|
258
|
+
|
|
259
|
+
For irregular plurals (`plural: 'people'` on `:person`), helpers use the overridden plural: `people_path`, `new_person_path`, etc.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Store Protocol
|
|
264
|
+
|
|
265
|
+
Stores are duck-typed. Any object (module, class, instance) that responds to these five methods works:
|
|
266
|
+
|
|
267
|
+
| Method | Signature | Return |
|
|
268
|
+
|---|---|---|
|
|
269
|
+
| `all` | `()` | Array of record hashes |
|
|
270
|
+
| `find` | `(id)` | Record hash or nil |
|
|
271
|
+
| `create` | `(attrs_hash)` | New record id (String) |
|
|
272
|
+
| `update` | `(id, attrs_hash)` | true / false |
|
|
273
|
+
| `destroy` | `(id)` | true / false |
|
|
274
|
+
|
|
275
|
+
Records are plain hashes with a symbol `:id` key. StreamWeaver validates the store at app startup (not at request time) and raises `ArgumentError` with a clear message listing missing methods.
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## State Schema (`_sw_` Namespace)
|
|
280
|
+
|
|
281
|
+
The `_sw_` prefix is reserved. Do not use `state[:_sw_*]` keys in your own code.
|
|
282
|
+
|
|
283
|
+
| Key | Values | Meaning |
|
|
284
|
+
|---|---|---|
|
|
285
|
+
| `state[:_sw_action]` | `:index`, `:show`, `:new`, `:edit`, page name sym | Current action |
|
|
286
|
+
| `state[:_sw_resource]` | `:post`, `:goal`, etc. / `nil` | Active resource (nil for pages) |
|
|
287
|
+
| `state[:_sw_id]` | String id / nil | Selected record |
|
|
288
|
+
| `state[:_sw_action]` `:destroy_confirm` | — | Delete confirmation (routed via `GET /singular/:id/delete`) |
|
|
289
|
+
| `state[:"#{singular}_form"]` | Hash | Form state (managed by `form` DSL) |
|
|
290
|
+
|
|
291
|
+
Use `state[:_sw_resource]` and `state[:_sw_action]` in navbar `active:` checks:
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
navbar do
|
|
295
|
+
nav_item 'Posts', href: posts_path, active: state[:_sw_resource] == :post
|
|
296
|
+
nav_item 'Home', href: '/', active: state[:_sw_action] == :home
|
|
297
|
+
end
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Route Precedence
|
|
303
|
+
|
|
304
|
+
Routes are first-registered-wins. Recommended declaration order:
|
|
305
|
+
|
|
306
|
+
1. `page` / `route` (static exact-match routes)
|
|
307
|
+
2. `resource` blocks (collection then member patterns)
|
|
308
|
+
3. Manual `route_with` rules
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
app 'Example' do
|
|
312
|
+
page :home, '/' # declared first — matched first
|
|
313
|
+
|
|
314
|
+
resource :post, store: PostStore do # declared after page
|
|
315
|
+
field :title, :string
|
|
316
|
+
end
|
|
317
|
+
end.run!
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Default View Behavior
|
|
323
|
+
|
|
324
|
+
When no override block is provided:
|
|
325
|
+
|
|
326
|
+
**Index** — renders a `header1` with the plural name, a "New" button, and a `table` with one column per field plus an actions column with View / Edit / Delete links per row. Delete links navigate to `/singular/:id/delete`.
|
|
327
|
+
|
|
328
|
+
**Show** — renders a `card` with `header3` (value of first field), one `text` line per field, and an hstack with Edit and Delete buttons. Delete navigates to `/singular/:id/delete`.
|
|
329
|
+
|
|
330
|
+
**Destroy confirm** — renders a warning `alert` prompting confirmation. "Confirm Delete" calls `store.destroy` and transitions to `:index`; "Cancel" returns to `:index`.
|
|
331
|
+
|
|
332
|
+
**New** — renders `header1 "New ..."` and a `form` block with inputs auto-generated from field types. Submit calls `store.create`, then transitions to `:show` for the new record.
|
|
333
|
+
|
|
334
|
+
**Edit** — renders `header1 "Edit ..."`, seeds form state from the record on first load (guarded by a seeded-for key to prevent reset on every re-render), same form inputs as new. Submit calls `store.update`, transitions to `:show`.
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Complete Example with Override
|
|
339
|
+
|
|
340
|
+
See `examples/scaffolding/blog.rb` for a zero-dependency smoke test (~50 lines).
|
|
341
|
+
|
|
342
|
+
See `examples/scaffolding/utf_lite.rb` for a multi-resource app with a custom index override and `edit_view :page`.
|
data/docs/routing.md
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# StreamWeaver URL Routing
|
|
2
|
+
|
|
3
|
+
StreamWeaver supports URL-addressable views — deep links, browser back/forward, and bookmarkable URLs — without adding a router library. Two mechanisms are available depending on complexity.
|
|
4
|
+
|
|
5
|
+
**Not looking for state routing?** Everything on this page maps a URL path to *state* — the same StreamWeaver view still renders, just seeded differently. If you need a genuine HTTP endpoint (webhook receiver, JSON API, file download) that bypasses StreamWeaver's page rendering entirely, see [`docs/endpoints.md`](endpoints.md) (`endpoint` DSL) instead.
|
|
6
|
+
|
|
7
|
+
## How It Works (Internals)
|
|
8
|
+
|
|
9
|
+
StreamWeaver uses a bidirectional URL ↔ state contract:
|
|
10
|
+
|
|
11
|
+
- **On GET `/*`**: The path is parsed into a partial state hash and merged into session state before render. This seeds the right view on direct URL load.
|
|
12
|
+
- **After POST actions**: The current state is converted to a path and sent as `HX-Push-Url`, updating the browser URL bar without a full page load.
|
|
13
|
+
|
|
14
|
+
This means **routing is just state**. A URL like `/goals` means `state[:main_nav] = 2`. A URL like `/initiative/init-001` means `state[:main_nav] = 2, state[:initiative_id] = "init-001"`.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## `route_by` — Simple Key→Path Mapping
|
|
19
|
+
|
|
20
|
+
For apps with a single state key driving navigation (tab switchers, simple page routers):
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
app "My App" do
|
|
24
|
+
route_by :page, home: "/", dashboard: "/dashboard", settings: "/settings"
|
|
25
|
+
|
|
26
|
+
state[:page] ||= :home
|
|
27
|
+
|
|
28
|
+
navbar do
|
|
29
|
+
nav_item "Home", href: "/", active: state[:page] == :home
|
|
30
|
+
nav_item "Dashboard", href: "/dashboard", active: state[:page] == :dashboard
|
|
31
|
+
nav_item "Settings", href: "/settings", active: state[:page] == :settings
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
case state[:page]
|
|
35
|
+
when :home then render_home
|
|
36
|
+
when :dashboard then render_dashboard
|
|
37
|
+
when :settings then render_settings
|
|
38
|
+
end
|
|
39
|
+
end.run!
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**How it works:** `route_by :page, home: "/"` creates a bidirectional map. When `state[:page]` changes (button callback or nav click), StreamWeaver emits `HX-Push-Url: /` automatically. Visiting `/dashboard` directly seeds `state[:page] = :dashboard` before render.
|
|
43
|
+
|
|
44
|
+
**Limitation:** One state key only. Doesn't handle parameterized paths like `/initiative/:id`.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## `route_with` — Bidirectional Parser/Builder
|
|
49
|
+
|
|
50
|
+
For apps with parameterized routes, multi-key navigation state, or complex URL structures. Requires two lambdas:
|
|
51
|
+
|
|
52
|
+
- **`parser`**: `path → partial_state_hash | nil` — called on every GET request, **and now also re-run against the requesting tab's own current URL before every htmx POST** (see Pitfall 3). Returns a hash to merge into state, or `nil` to pass through.
|
|
53
|
+
- **`builder`**: `current_state → path_string | nil` — called after every POST action. Returns the new path to push, or `nil` to leave the URL unchanged.
|
|
54
|
+
|
|
55
|
+
### Example: UTF Dashboard
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
app "UTF Dashboard", layout: :full do
|
|
59
|
+
route_with(
|
|
60
|
+
parser: lambda do |path|
|
|
61
|
+
case path
|
|
62
|
+
when '/', ''
|
|
63
|
+
{ main_nav: 0 }
|
|
64
|
+
when '/tasks'
|
|
65
|
+
{ main_nav: 1 }
|
|
66
|
+
when '/goals'
|
|
67
|
+
{ main_nav: 2 }
|
|
68
|
+
when '/secretaries'
|
|
69
|
+
{ main_nav: 3 }
|
|
70
|
+
when %r{\A/secretary/([^/]+)\z}
|
|
71
|
+
{ main_nav: 3, secretary_name: CGI.unescape(Regexp.last_match(1)) }
|
|
72
|
+
when '/sessions'
|
|
73
|
+
{ main_nav: 4 }
|
|
74
|
+
when %r{\A/task/([^/]+)\z}
|
|
75
|
+
{ main_nav: 0, view_task_id: CGI.unescape(Regexp.last_match(1)) }
|
|
76
|
+
when %r{\A/initiative/([^/]+)/surface/([^/]+)\z}
|
|
77
|
+
{ main_nav: 2,
|
|
78
|
+
initiative_id: CGI.unescape(Regexp.last_match(1)),
|
|
79
|
+
surface_id: CGI.unescape(Regexp.last_match(2)) }
|
|
80
|
+
when %r{\A/initiative/([^/]+)\z}
|
|
81
|
+
{ main_nav: 2, initiative_id: CGI.unescape(Regexp.last_match(1)) }
|
|
82
|
+
else
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end,
|
|
86
|
+
|
|
87
|
+
builder: lambda do |current_state|
|
|
88
|
+
if current_state[:view_task_id].to_s.strip != ''
|
|
89
|
+
"/task/#{CGI.escape(current_state[:view_task_id].to_s)}"
|
|
90
|
+
elsif current_state[:secretary_name].to_s.strip != ''
|
|
91
|
+
"/secretary/#{CGI.escape(current_state[:secretary_name].to_s)}"
|
|
92
|
+
elsif current_state[:initiative_id].to_s.strip != ''
|
|
93
|
+
"/initiative/#{CGI.escape(current_state[:initiative_id].to_s)}"
|
|
94
|
+
else
|
|
95
|
+
case current_state[:main_nav].to_i
|
|
96
|
+
when 1 then '/tasks'
|
|
97
|
+
when 2 then '/goals'
|
|
98
|
+
when 3 then '/secretaries'
|
|
99
|
+
when 4 then '/sessions'
|
|
100
|
+
else '/'
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# ... app body
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Parser Rules
|
|
111
|
+
|
|
112
|
+
- Return a **hash** to merge into state (only the keys you want to set, not the full state) —
|
|
113
|
+
**merge, not replace**: any key you don't mention keeps whatever value a *previous* request
|
|
114
|
+
left in session state. See "Common Pitfalls" below before relying on this for anything beyond
|
|
115
|
+
a single-view app.
|
|
116
|
+
- Return `nil` to indicate "this path isn't handled by me" — Sinatra will 404
|
|
117
|
+
- Always `CGI.unescape` captured path segments before storing in state
|
|
118
|
+
- Match most-specific patterns first (`:id/surface/:sid` before `:id`)
|
|
119
|
+
|
|
120
|
+
### Builder Rules
|
|
121
|
+
|
|
122
|
+
- Return a **path string** (starting with `/`) to push to the browser history
|
|
123
|
+
- Return `nil` to leave the URL unchanged (good for transient state like modal open/close)
|
|
124
|
+
- Check priority: specific context (task detail, initiative detail) before generic tabs
|
|
125
|
+
- Always `CGI.escape` state values interpolated into paths
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## URL Parameters vs State
|
|
130
|
+
|
|
131
|
+
Query params (`?key=value`) are automatically synced to state on every GET request via `sync_params_to_state`. You can use them alongside path-based routing:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
GET /goals?filter=active
|
|
135
|
+
# → state[:main_nav] = 2 (from path parser)
|
|
136
|
+
# → state[:filter] = "active" (from query param sync)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Navigating Programmatically
|
|
142
|
+
|
|
143
|
+
To trigger URL updates from button callbacks, just update the state key that the builder watches:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
button "View Initiative" do |s|
|
|
147
|
+
s[:initiative_id] = "init-042" # builder will push /initiative/init-042
|
|
148
|
+
s[:main_nav] = 2
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
No explicit redirect needed — the `after` hook calls `path_for_state` on every POST response automatically.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Edit Routes (CRUD Pattern)
|
|
157
|
+
|
|
158
|
+
For edit views (`/initiative/:id/edit`), add an edit flag to the route:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
# In parser:
|
|
162
|
+
when %r{\A/initiative/([^/]+)/edit\z}
|
|
163
|
+
{ main_nav: 2, initiative_id: CGI.unescape(Regexp.last_match(1)), editing_initiative: true }
|
|
164
|
+
|
|
165
|
+
# In builder:
|
|
166
|
+
elsif current_state[:initiative_id].to_s.strip != '' && current_state[:editing_initiative]
|
|
167
|
+
"/initiative/#{CGI.escape(current_state[:initiative_id])}/edit"
|
|
168
|
+
elsif current_state[:initiative_id].to_s.strip != ''
|
|
169
|
+
"/initiative/#{CGI.escape(current_state[:initiative_id])}"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Common Pitfalls
|
|
175
|
+
|
|
176
|
+
Three bug classes that show up in any sufficiently large `route_with` app (found in practice in an
|
|
177
|
+
app with ~20 branches and 15 tabs). The first two come from the same source: a `case`/`when` route
|
|
178
|
+
table that isn't exhaustive in one direction or the other. The third is architectural: session
|
|
179
|
+
state is one hash per browser, not per tab.
|
|
180
|
+
|
|
181
|
+
### Pitfall 1 — a narrow branch leaks a previous view's state
|
|
182
|
+
|
|
183
|
+
Because parser results are **merged** into session state, not replaced, a branch that only sets
|
|
184
|
+
its own key(s) leaves every other stateful key exactly as an earlier request left it:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
# BAD — only sets main_nav; any special-view flag set by a PRIOR request survives untouched
|
|
188
|
+
when "/messages"
|
|
189
|
+
{ main_nav: MESSAGES_TAB_INDEX }
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
If some other branch earlier set `view_task_id` (a "show this one task, bypass the tab board"
|
|
193
|
+
flag) and your render logic checks `view_task_id` before `main_nav`, navigating to `/messages`
|
|
194
|
+
after having visited `/task/:id` renders the stale task view, not Messages — the URL you're on
|
|
195
|
+
is not what's on screen. This is easy to miss because it's **intermittent**: it only reproduces
|
|
196
|
+
when a session has visited the leaking view before, so it looks like flakiness rather than a
|
|
197
|
+
routing bug.
|
|
198
|
+
|
|
199
|
+
**Fix**: define one frozen hash listing every "special view" key with its off value, and have
|
|
200
|
+
every branch merge its own keys on top of it, not just `nil`/base-hash-free literals:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
SPECIAL_VIEW_RESET = { view_task_id: nil, messages_view_stream: nil, checkin_slug: nil, ... }.freeze
|
|
204
|
+
|
|
205
|
+
when "/messages"
|
|
206
|
+
SPECIAL_VIEW_RESET.merge(main_nav: MESSAGES_TAB_INDEX)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Adding a new special view later means adding one key to the list, not remembering to touch every
|
|
210
|
+
existing branch.
|
|
211
|
+
|
|
212
|
+
### Pitfall 2 — an uncovered `case`/`when` value fails silently, in both directions
|
|
213
|
+
|
|
214
|
+
A `case current_state[:main_nav].to_i` (or any keyed dispatch) with `when` clauses that don't
|
|
215
|
+
cover every value your app actually uses returns `nil` on the uncovered values — and `nil` from
|
|
216
|
+
a builder means **"leave the URL unchanged"** (see Builder Rules above), not an error. The
|
|
217
|
+
symptom is not a crash, it's silence: clicking a tab that maps to an uncovered index changes what
|
|
218
|
+
renders but never touches the URL bar, so it looks like the click "didn't do anything" to the
|
|
219
|
+
address bar specifically. The same gap on the parser side means the corresponding path never
|
|
220
|
+
seeds that index into state, so a direct GET to a URL nobody wrote a `when` for either falls
|
|
221
|
+
through to `nil` (404) or, if it matches a *different* branch that doesn't set `main_nav` at all
|
|
222
|
+
(e.g. bare `/` only clearing special-view flags), inherits whatever `main_nav` a previous request
|
|
223
|
+
left behind.
|
|
224
|
+
|
|
225
|
+
**Fix**: audit the full index/key range your app actually dispatches on and confirm every value
|
|
226
|
+
has a `when` clause on **both** the parser and the builder — not just the branch that was
|
|
227
|
+
reported broken. In practice this bug hunts in pairs: if one branch of a route table is
|
|
228
|
+
incomplete, check the others before considering it fixed.
|
|
229
|
+
|
|
230
|
+
### Pitfall 3 — one session, many tabs
|
|
231
|
+
|
|
232
|
+
`parser` only ran on GET, so a "special view" flag it sets (Pitfall 1's `SPECIAL_VIEW_RESET`
|
|
233
|
+
pattern) only ever got reset on a real page navigation. Session state is one hash per browser
|
|
234
|
+
(one cookie), not per tab — so once a tab navigated to a dedicated view, that flag stayed true
|
|
235
|
+
for every OTHER tab of the same browser too, and `builder` read the same shared flag on every
|
|
236
|
+
POST from any of them. Symptom: click something in tab A, land on whatever dedicated view tab B
|
|
237
|
+
happens to have open — even a tab that's just sitting in the background, never clicked.
|
|
238
|
+
|
|
239
|
+
**Fix**: htmx already sends the requesting tab's own on-screen URL on every request
|
|
240
|
+
(`HX-Current-URL`). `parser` is now re-run against that URL — same merge-not-replace GET already
|
|
241
|
+
does — before every htmx POST dispatches, so state gets re-scoped to what THAT tab is actually
|
|
242
|
+
showing before `builder` decides where to push it. A sibling tab's stale flag can't survive
|
|
243
|
+
contact with a real click. A raw (non-htmx) POST has no such header and is left unchanged.
|
|
244
|
+
|
|
245
|
+
**Consequence for parser authors**: every key your parser returns is now re-asserted on every
|
|
246
|
+
htmx POST, not just at navigation time — put only URL-derived truth in a parser hash, never
|
|
247
|
+
something a POST handler should be free to change without a URL change also occurring.
|
|
248
|
+
|
|
249
|
+
**Consequence for multi-app (`service.rb`) hosting**: the fix is mount-prefix aware — a URL for
|
|
250
|
+
app A's tab is never applied to app B's state, even though both live in the same session.
|
|
251
|
+
|
|
252
|
+
## Testing route_with
|
|
253
|
+
|
|
254
|
+
`route_with`'s parser and builder are plain `path -> hash` / `hash -> path` functions — the
|
|
255
|
+
highest-leverage way to test them is to **extract them out of the inline DSL lambdas** into
|
|
256
|
+
ordinary module methods (e.g. `MyApp::Routing.parse(path)` / `MyApp::Routing.build(state)`), so
|
|
257
|
+
they're unit-testable with no Rack::Test or session harness at all:
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
parser: ->(path) { MyApp::Routing.parse(path) },
|
|
261
|
+
builder: ->(state) { MyApp::Routing.build(state) }
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Then the single most valuable regression test is a **round-trip check over every known path** —
|
|
265
|
+
`build(parse(path)) == path` for each route your app defines. It catches both pitfalls above at
|
|
266
|
+
once: Pitfall 1 shows up as `parse` returning a state hash with leftover keys from nothing (a
|
|
267
|
+
round trip alone won't catch this one directly — pair it with an explicit assertion that
|
|
268
|
+
`parse(path)` matches `SPECIAL_VIEW_RESET.merge(...)` exactly, not just a subset); Pitfall 2
|
|
269
|
+
shows up as `build(parse(path))` returning `nil` or the wrong path for any route whose index
|
|
270
|
+
wasn't wired into the builder's `case`, which is exactly the bug it exists to catch — a route
|
|
271
|
+
that parses fine but never round-trips back to a real URL.
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
KNOWN_ROUTES = ["/", "/messages", "/task/abc", ...]
|
|
275
|
+
|
|
276
|
+
KNOWN_ROUTES.each do |path|
|
|
277
|
+
it "round-trips #{path}" do
|
|
278
|
+
expect(MyApp::Routing.build(MyApp::Routing.parse(path))).to eq(path)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Then in the app body:
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
if state[:editing_initiative] && state[:initiative_id]
|
|
287
|
+
render_initiative_edit_form(state[:initiative_id])
|
|
288
|
+
elsif state[:initiative_id]
|
|
289
|
+
render_initiative_detail(state[:initiative_id])
|
|
290
|
+
end
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Comparison
|
|
296
|
+
|
|
297
|
+
| Scenario | Use |
|
|
298
|
+
|---|---|
|
|
299
|
+
| Simple tab nav (one active tab) | `route_by` |
|
|
300
|
+
| Multi-param routes (`/x/:id`, `/x/:id/edit`) | `route_with` |
|
|
301
|
+
| Mixed tab + entity detail | `route_with` |
|
|
302
|
+
| No bookmarkability needed | Neither (omit routing entirely) |
|