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,1821 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'set'
|
|
5
|
+
require_relative 'resource'
|
|
6
|
+
|
|
7
|
+
module StreamWeaver
|
|
8
|
+
# Main app class that holds the DSL block and manages the component tree
|
|
9
|
+
class App
|
|
10
|
+
include DisplayDSL
|
|
11
|
+
|
|
12
|
+
# Mutable state used only while evaluating one render tree. App-definition
|
|
13
|
+
# metadata intentionally remains on App so it survives fresh render states.
|
|
14
|
+
class RenderState
|
|
15
|
+
# Pass-scoped accumulators (button_counter, table_counter, seen_component_ids,
|
|
16
|
+
# url_tab_keys) must also be captured in App#rewind_point -- a discarded
|
|
17
|
+
# evaluation pass that skips one leaves ids/claims drifted on re-run.
|
|
18
|
+
attr_accessor :components, :layout_slots, :button_counter, :seen_component_ids,
|
|
19
|
+
:current_form, :form_context, :current_checkbox_group,
|
|
20
|
+
:current_tabs, :current_breadcrumbs, :current_dropdown,
|
|
21
|
+
:current_menu, :current_modal, :modal_context, :current_deck,
|
|
22
|
+
:current_slide, :current_app_shell, :current_row_key_thunk,
|
|
23
|
+
:checkbox_keys, :action_tokens, :generation, :fragment_stack, :state_version,
|
|
24
|
+
:table_counter, :current_scope, :url_tab_keys, :url_params, :deferred_target,
|
|
25
|
+
:form_for_active, :form_for_submit_label, :form_for_cancel_label, :form_for_validate
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
self.components = []
|
|
29
|
+
self.layout_slots = {}
|
|
30
|
+
self.button_counter = 0
|
|
31
|
+
self.table_counter = 0
|
|
32
|
+
self.seen_component_ids = Hash.new(0)
|
|
33
|
+
self.checkbox_keys = []
|
|
34
|
+
self.action_tokens = Set.new
|
|
35
|
+
self.generation = "0"
|
|
36
|
+
self.fragment_stack = []
|
|
37
|
+
self.state_version = 0
|
|
38
|
+
self.url_tab_keys = []
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Built-in themes (custom themes checked via StreamWeaver.theme_exists?)
|
|
43
|
+
BUILT_IN_THEMES = [:default, :dashboard, :document, :dark, :doc].freeze
|
|
44
|
+
# For backwards compatibility
|
|
45
|
+
VALID_THEMES = BUILT_IN_THEMES
|
|
46
|
+
|
|
47
|
+
attr_reader :title, :block, :layout, :chrome, :theme, :theme_overrides, :scripts, :stylesheets, :inline_stylesheets, :fonts, :stream_block, :timers, :transient_keys, :favicon_value, :route_key, :routes, :route_rules, :resource_defs, :endpoints, :loading_indicators, :render_state, :actions, :action_updates, :action_primary
|
|
48
|
+
|
|
49
|
+
# HTTP verbs supported by the `endpoint` DSL (real Rack routes, not state routing)
|
|
50
|
+
ENDPOINT_VERBS = %i[get post put patch delete].freeze
|
|
51
|
+
|
|
52
|
+
# Request params owned by Sinatra/StreamWeaver routing rather than by the app.
|
|
53
|
+
# This is the single source of truth for that set: every param-to-state sync
|
|
54
|
+
# strips them (server.rb / service.rb #sync_params_to_state, and
|
|
55
|
+
# InteractionRunner::ROUTE_PARAMS, which adds its own dispatch-only params),
|
|
56
|
+
# and a `url: true` tabs group can never claim one -- the key would validate
|
|
57
|
+
# here and then silently never receive its value.
|
|
58
|
+
ROUTE_OWNED_PARAMS = %w[app_id splat captures button_id].freeze
|
|
59
|
+
|
|
60
|
+
# `deferred_target:` value meaning "run every deferred block inline". For
|
|
61
|
+
# renders with no client to fetch anything afterwards -- a static export.
|
|
62
|
+
# Never collides with a real target: fragment ids are all `sw-frag-`-prefixed.
|
|
63
|
+
ALL_DEFERRED = "all"
|
|
64
|
+
|
|
65
|
+
# Latch for the once-per-process lazy-tabs deprecation warning
|
|
66
|
+
# (see #warn_lazy_tabs_deprecated). Writable so specs can re-arm it.
|
|
67
|
+
class << self
|
|
68
|
+
attr_accessor :lazy_tabs_deprecation_warned
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Paths/prefixes owned by StreamWeaver's own framework routes (see server.rb / service.rb).
|
|
72
|
+
# An `endpoint` registered on one of these is never reached -- the internal route is always
|
|
73
|
+
# defined first, and Sinatra dispatches to the first matching route. We still warn at
|
|
74
|
+
# registration time so the collision is obvious instead of silently swallowed.
|
|
75
|
+
RESERVED_ENDPOINT_EXACT = %w[/update /submit].freeze
|
|
76
|
+
RESERVED_ENDPOINT_PREFIXES = %w[/action/ /event/ /form/ /theme/ /sw/].freeze
|
|
77
|
+
|
|
78
|
+
# Content-type by extension for local files served via the /sw-asset/
|
|
79
|
+
# route (App#local_asset, stylesheets: auto-detection). Shared with
|
|
80
|
+
# server.rb's /sw-asset/ route handler.
|
|
81
|
+
LOCAL_ASSET_MIME_TYPES = {
|
|
82
|
+
'css' => 'text/css', 'js' => 'application/javascript', 'mjs' => 'application/javascript',
|
|
83
|
+
'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
|
|
84
|
+
'gif' => 'image/gif', 'svg' => 'image/svg+xml', 'webp' => 'image/webp', 'ico' => 'image/x-icon'
|
|
85
|
+
}.freeze
|
|
86
|
+
|
|
87
|
+
# @param assets_dirs [Array<String>] Extra directories (besides the
|
|
88
|
+
# calling script's own directory) that local_asset/stylesheets: are
|
|
89
|
+
# allowed to serve local files from (stream_weaver-1lo).
|
|
90
|
+
def initialize(title, layout: :default, chrome: true, theme: :default, theme_overrides: {}, components: [], scripts: [], stylesheets: [], fonts: [], strict_ids: nil, loading_indicators: true, assets_dirs: [], &block)
|
|
91
|
+
@title = title
|
|
92
|
+
@layout = layout
|
|
93
|
+
@chrome = chrome
|
|
94
|
+
@theme = validate_theme(theme)
|
|
95
|
+
@theme_overrides = theme_overrides
|
|
96
|
+
@block = block
|
|
97
|
+
@render_state = RenderState.new
|
|
98
|
+
@render_mutex = Mutex.new
|
|
99
|
+
@state_key = :streamlit_state
|
|
100
|
+
@_state = {}
|
|
101
|
+
# nil means "unset" -- fall back to the global (StreamWeaver.strict_ids
|
|
102
|
+
# / SW_STRICT_IDS). An explicit true/false here always wins.
|
|
103
|
+
@strict_ids = strict_ids.nil? ? StreamWeaver.strict_ids? : strict_ids
|
|
104
|
+
@loading_indicators = loading_indicators
|
|
105
|
+
@_warned_duplicate_ids = Set.new
|
|
106
|
+
@scripts = scripts
|
|
107
|
+
@script_dir = File.dirname(File.expand_path(caller_locations(1, 1).first.path))
|
|
108
|
+
@allowed_asset_dirs = ([@script_dir] + assets_dirs.map { |d| File.expand_path(d) }).uniq
|
|
109
|
+
@stylesheets = stylesheets.map { |href| resolve_stylesheet_href(href) }
|
|
110
|
+
@inline_stylesheets = []
|
|
111
|
+
@fonts = fonts
|
|
112
|
+
@transient_keys = Set.new
|
|
113
|
+
@timers = []
|
|
114
|
+
@favicon_value = nil
|
|
115
|
+
@route_key = nil
|
|
116
|
+
@routes = nil
|
|
117
|
+
@routes_inverse = nil
|
|
118
|
+
@route_parser = nil
|
|
119
|
+
@route_builder = nil
|
|
120
|
+
@route_rules = [] # Array<RouteRule> — persistent, never cleared in rebuild
|
|
121
|
+
@resource_defs = {} # name(sym) → ResourceDefinition — persistent
|
|
122
|
+
@endpoints = [] # Array<{verb:, path:, block:}> — persistent, never cleared in rebuild
|
|
123
|
+
@actions = {}
|
|
124
|
+
@action_updates = {}
|
|
125
|
+
@action_primary = {}
|
|
126
|
+
@scope_registry = {} # name(sym) → {kind:, retain:} — persistent, never cleared in rebuild
|
|
127
|
+
components.each { |mod| singleton_class.include(mod) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def validate_theme(theme)
|
|
133
|
+
theme = theme.to_sym
|
|
134
|
+
# Accept built-in themes or custom registered themes
|
|
135
|
+
return theme if BUILT_IN_THEMES.include?(theme) || StreamWeaver.theme_exists?(theme)
|
|
136
|
+
warn "StreamWeaver: Unknown theme '#{theme}', falling back to :default"
|
|
137
|
+
:default
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
public
|
|
141
|
+
|
|
142
|
+
def layout_entry
|
|
143
|
+
chrome ? LayoutRegistry[layout] : LayoutRegistry.chromeless
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def state
|
|
147
|
+
@_state
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def components
|
|
151
|
+
@render_state.components
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def components=(value)
|
|
155
|
+
@render_state.components = value
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def layout_slots
|
|
159
|
+
@render_state.layout_slots
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def with_render_lock(&block)
|
|
163
|
+
@render_mutex.synchronize(&block)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Declare URL routing: maps a state key's values to URL paths.
|
|
167
|
+
# Example: route_by :page, home: "/", about: "/about", settings: "/settings"
|
|
168
|
+
def route_by(state_key, paths)
|
|
169
|
+
@route_key = state_key
|
|
170
|
+
@routes = paths.transform_keys(&:to_sym)
|
|
171
|
+
@routes_inverse = @routes.invert
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Declare dynamic URL routing with custom path parser/builder lambdas.
|
|
175
|
+
# parser receives the request path and returns a partial state hash or nil.
|
|
176
|
+
# builder receives the current state and returns a path string or nil.
|
|
177
|
+
def route_with(parser:, builder: nil)
|
|
178
|
+
# Idempotent guard: don't re-register if same parser lambda already in chain
|
|
179
|
+
@route_rules << RouteRule.new(parser: parser, builder: builder) \
|
|
180
|
+
unless @route_rules.any? { |r| r.parser == parser }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def path_for_state(state)
|
|
184
|
+
@route_rules.each do |rule|
|
|
185
|
+
path = rule.builder&.call(state)
|
|
186
|
+
return path if path
|
|
187
|
+
end
|
|
188
|
+
return @route_builder.call(state) if @route_builder # legacy fallback (belt+suspenders)
|
|
189
|
+
return unless @routes
|
|
190
|
+
@routes[state[@route_key]&.to_sym]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def state_for_path(path)
|
|
194
|
+
@route_rules.each do |rule|
|
|
195
|
+
hash = rule.parser&.call(path)
|
|
196
|
+
return hash if hash
|
|
197
|
+
end
|
|
198
|
+
return unless @routes_inverse
|
|
199
|
+
val = @routes_inverse[path]
|
|
200
|
+
val ? { @route_key => val } : nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def routable?
|
|
204
|
+
@route_rules.any? || @routes || @route_parser
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def resource(name, store:, plural: nil, &block)
|
|
208
|
+
unless @resource_defs.key?(name.to_sym)
|
|
209
|
+
defn = ResourceDefinition.new(name, store, plural: plural)
|
|
210
|
+
defn.instance_eval(&block) if block
|
|
211
|
+
@resource_defs[name.to_sym] = defn
|
|
212
|
+
@route_rules << RouteRule.new(
|
|
213
|
+
parser: defn.method(:parse_path),
|
|
214
|
+
builder: defn.method(:build_path),
|
|
215
|
+
source: [:resource, name.to_sym]
|
|
216
|
+
)
|
|
217
|
+
define_path_helpers(defn)
|
|
218
|
+
end
|
|
219
|
+
@resource_defs[name.to_sym].render_if_active(self)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def page(name, path, &block)
|
|
223
|
+
key = name.to_sym
|
|
224
|
+
sk = Resource::StateKeys
|
|
225
|
+
unless @route_rules.any? { |r| r.source == [:page, key] }
|
|
226
|
+
@route_rules << RouteRule.new(
|
|
227
|
+
parser: ->(p) { p == path ? { sk::RESOURCE => nil, sk::ACTION => key } : nil },
|
|
228
|
+
builder: ->(st) { st[sk::ACTION] == key && st[sk::RESOURCE].nil? ? path : nil },
|
|
229
|
+
source: [:page, key]
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
if @_state[sk::ACTION] == key && @_state[sk::RESOURCE].nil?
|
|
233
|
+
evaluate_dsl_block(block)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def route(name, path)
|
|
238
|
+
page(name, path) {}
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Register a real HTTP endpoint (webhook receiver, JSON API, file download, etc.)
|
|
242
|
+
# — the "never rewrite in Sinatra" escape hatch. Unlike `route`/`page` (which are
|
|
243
|
+
# state-driven VIEW matchers), an `endpoint` is a genuine Rack route: it bypasses
|
|
244
|
+
# StreamWeaver's state machinery, session, and CSRF handling entirely. The block
|
|
245
|
+
# receives the raw `Rack::Request` and its return value is converted to a response:
|
|
246
|
+
#
|
|
247
|
+
# Hash -> 200 application/json (JSON.generate'd)
|
|
248
|
+
# String -> 200 text/html
|
|
249
|
+
# [status, headers, body] Array -> passed through to Rack verbatim
|
|
250
|
+
# anything else -> 200 text/plain (#to_s)
|
|
251
|
+
#
|
|
252
|
+
# @example
|
|
253
|
+
# endpoint :get, "/api/status" do |req|
|
|
254
|
+
# { ok: true, uptime: 42 }
|
|
255
|
+
# end
|
|
256
|
+
#
|
|
257
|
+
# endpoint :post, "/webhook/github" do |req|
|
|
258
|
+
# payload = req.body.read
|
|
259
|
+
# [202, {}, "queued"]
|
|
260
|
+
# end
|
|
261
|
+
#
|
|
262
|
+
# Idempotent: repeated registration of the same verb+path across `rebuild_with_state`
|
|
263
|
+
# calls (every request) keeps only the first block, mirroring `resource`/`route_with`.
|
|
264
|
+
def endpoint(verb, path, &block)
|
|
265
|
+
verb = verb.to_sym
|
|
266
|
+
unless ENDPOINT_VERBS.include?(verb)
|
|
267
|
+
raise ArgumentError, "endpoint: unsupported verb #{verb.inspect} (must be one of #{ENDPOINT_VERBS.join(', ')})"
|
|
268
|
+
end
|
|
269
|
+
raise ArgumentError, "endpoint: block required" unless block
|
|
270
|
+
|
|
271
|
+
return if @endpoints.any? { |e| e[:verb] == verb && e[:path] == path }
|
|
272
|
+
|
|
273
|
+
if reserved_endpoint_path?(path)
|
|
274
|
+
warn "StreamWeaver: endpoint #{verb.to_s.upcase} #{path} collides with a StreamWeaver-internal " \
|
|
275
|
+
"route and will never be reached — the internal route always wins."
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
@endpoints << { verb: verb, path: path, block: block }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Look up a registered endpoint by verb + exact path. Used by SinatraApp/Service
|
|
282
|
+
# route dispatch at request time.
|
|
283
|
+
def find_endpoint(verb, path)
|
|
284
|
+
verb = verb.to_sym
|
|
285
|
+
@endpoints.find { |e| e[:verb] == verb && e[:path] == path }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Points @_state at the given state object without running a full
|
|
289
|
+
# rebuild (#rebuild_with_state). Named-action dispatch skips the
|
|
290
|
+
# pre-dispatch discovery rebuild by design (see InteractionRunner
|
|
291
|
+
# #discovery_required?), so without this, app-level state helpers
|
|
292
|
+
# reached from inside the handler (flash, open_modal, close_modal,
|
|
293
|
+
# show_toast, dismiss_toast, clear_toasts -- anything that reads/writes
|
|
294
|
+
# @_state directly rather than taking state as an argument) would
|
|
295
|
+
# target the previous request's now-stale state object, and any writes
|
|
296
|
+
# made through them would be silently discarded once the post-dispatch
|
|
297
|
+
# rebuild repoints @_state at this request's state (stream_weaver-dwi).
|
|
298
|
+
def bind_dispatch_state(current_state)
|
|
299
|
+
@_state = current_state
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# @param url_params [Hash, nil] params of the URL being rendered; nil when no
|
|
303
|
+
# URL is behind this render. See #tab_index_source for what reads it.
|
|
304
|
+
# @param deferred_target [String, nil] The fragment id this pass is rendering
|
|
305
|
+
# for. A `defer: true` fragment executes its block only when it is that
|
|
306
|
+
# fragment or one of its ancestors (see #fragment); every other pass
|
|
307
|
+
# renders its placeholder instead.
|
|
308
|
+
def rebuild_with_state(current_state, generation: "0", state_version: 0, url_params: nil, deferred_target: nil)
|
|
309
|
+
@_state = current_state
|
|
310
|
+
@render_state = RenderState.new
|
|
311
|
+
@render_state.generation = generation.to_s
|
|
312
|
+
@render_state.state_version = state_version.to_i
|
|
313
|
+
@render_state.url_params = url_params
|
|
314
|
+
@render_state.deferred_target = deferred_target&.to_s
|
|
315
|
+
apply_scope_lifecycle
|
|
316
|
+
flash_messages if chrome
|
|
317
|
+
evaluate_dsl_block(@block)
|
|
318
|
+
@render_state.checkbox_keys = collect_checkbox_keys(components)
|
|
319
|
+
@timers_frozen = true
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Returns [scope_name, key] pairs (scope_name nil for flat/form-context
|
|
323
|
+
# checkboxes) so InteractionRunner#handle_unchecked_checkboxes can clear
|
|
324
|
+
# an unchecked scope-nested "live" checkbox at state[scope_name][key]
|
|
325
|
+
# instead of a same-named flat key (FAC-P3.1 handoff).
|
|
326
|
+
def collect_checkbox_keys(component_list)
|
|
327
|
+
component_list.each_with_object([]) do |component, keys|
|
|
328
|
+
keys << [component.options[:scope_name], component.key] if component.is_a?(Components::Checkbox)
|
|
329
|
+
keys.concat(collect_checkbox_keys(component.children)) if component.respond_to?(:children) && component.children
|
|
330
|
+
if component.is_a?(Components::Modal) && component.footer_component&.children
|
|
331
|
+
keys.concat(collect_checkbox_keys(component.footer_component.children))
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
private :collect_checkbox_keys
|
|
336
|
+
|
|
337
|
+
# Capture DSL components for a named layout slot.
|
|
338
|
+
# Components in a slot are rendered outside #app-container (static chrome).
|
|
339
|
+
#
|
|
340
|
+
# @param name [Symbol] Slot identifier (:header, :sidebar_left, :footer, etc.)
|
|
341
|
+
def layout_slot(name, &block)
|
|
342
|
+
layout_slots[name] ||= []
|
|
343
|
+
parent_components = components
|
|
344
|
+
self.components = layout_slots[name]
|
|
345
|
+
evaluate_dsl_block(block)
|
|
346
|
+
self.components = parent_components
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Find a component by its key (for callback execution)
|
|
350
|
+
def find_component_by_key(key, components_list = components)
|
|
351
|
+
components_list.each do |component|
|
|
352
|
+
return component if component.respond_to?(:key) && component.key == key
|
|
353
|
+
# Search in children if component has them
|
|
354
|
+
if component.respond_to?(:children) && component.children
|
|
355
|
+
found = find_component_by_key(key, component.children)
|
|
356
|
+
return found if found
|
|
357
|
+
end
|
|
358
|
+
# Also search modal footer if present
|
|
359
|
+
if component.is_a?(Components::Modal) && component.footer_component&.children
|
|
360
|
+
found = find_component_by_key(key, component.footer_component.children)
|
|
361
|
+
return found if found
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
nil
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def generate
|
|
368
|
+
rebuild_with_state(@_state) # pre-populate @route_rules before Sinatra starts
|
|
369
|
+
SinatraApp.create(self)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Save and restore form DSL ivars around a block — used by ResourceDefinition
|
|
373
|
+
# when executing override blocks so they can't leave form state dirty.
|
|
374
|
+
def with_clean_form_context
|
|
375
|
+
saved_form = render_state.current_form
|
|
376
|
+
saved_context = render_state.form_context
|
|
377
|
+
yield
|
|
378
|
+
ensure
|
|
379
|
+
render_state.current_form = saved_form
|
|
380
|
+
render_state.form_context = saved_context
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def has_charts?
|
|
384
|
+
components_include?(Components::ChartBase)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
private
|
|
388
|
+
|
|
389
|
+
def components_include?(klass)
|
|
390
|
+
components.any? { |c| c.is_a?(klass) || nested_include?(c, klass) }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def nested_include?(component, klass)
|
|
394
|
+
return false unless component.respond_to?(:children) && component.children
|
|
395
|
+
component.children.any? { |c| c.is_a?(klass) || nested_include?(c, klass) }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
public
|
|
399
|
+
|
|
400
|
+
def action(name, updates: nil, primary: nil, &handler)
|
|
401
|
+
raise ArgumentError, "action: block required" unless handler
|
|
402
|
+
name = name.to_sym
|
|
403
|
+
existing = @actions[name]
|
|
404
|
+
if existing && existing.source_location != handler.source_location
|
|
405
|
+
raise ArgumentError, "action #{name.inspect} is already registered with a different block"
|
|
406
|
+
end
|
|
407
|
+
@actions[name] ||= handler
|
|
408
|
+
normalized_updates = Array(updates).compact.map(&:to_sym)
|
|
409
|
+
if @action_updates.key?(name) && @action_updates[name] != normalized_updates
|
|
410
|
+
raise ArgumentError, "action #{name.inspect} is already registered with different updates"
|
|
411
|
+
end
|
|
412
|
+
@action_updates[name] ||= normalized_updates
|
|
413
|
+
|
|
414
|
+
normalized_primary = primary&.to_sym
|
|
415
|
+
if @action_primary.key?(name) && @action_primary[name] != normalized_primary
|
|
416
|
+
raise ArgumentError, "action #{name.inspect} is already registered with a different primary:"
|
|
417
|
+
end
|
|
418
|
+
@action_primary[name] = normalized_primary unless @action_primary.key?(name)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def action_definition_digest
|
|
422
|
+
Digest::SHA256.hexdigest(([StreamWeaver::VERSION] + actions.keys.map(&:to_s).sort).join("\0"))
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# @param primary [Symbol, String, nil] Names a sibling fragment to swap as
|
|
426
|
+
# this response's primary target instead of the button's own enclosing
|
|
427
|
+
# fragment (stream_weaver-78a) -- e.g. a board's "View" button
|
|
428
|
+
# refreshing a detail pane without resending the whole board. Falls
|
|
429
|
+
# back to the action-level default (registered via `action(...,
|
|
430
|
+
# primary:)`) the same way `updates:` does; resolved against the
|
|
431
|
+
# post-rebuild tree by InteractionRunner, so a name that doesn't match
|
|
432
|
+
# any fragment there falls back to a full-container response rather
|
|
433
|
+
# than silently doing nothing.
|
|
434
|
+
def action_token(name, key, fragment: nil, updates: nil, primary: nil)
|
|
435
|
+
name = name.to_sym
|
|
436
|
+
raise ArgumentError, "unknown named action #{name.inspect}" unless actions.key?(name)
|
|
437
|
+
scopes = Array(updates.nil? ? action_updates[name] : updates).compact.map(&:to_s)
|
|
438
|
+
primary_target = primary.nil? ? action_primary[name] : primary
|
|
439
|
+
payload = { a: name, k: key, d: action_definition_digest, g: render_state.generation }
|
|
440
|
+
payload[:f] = fragment if fragment
|
|
441
|
+
payload[:u] = scopes unless scopes.empty?
|
|
442
|
+
payload[:p] = primary_target.to_s if primary_target
|
|
443
|
+
token = ActionToken.encode(payload)
|
|
444
|
+
render_state.action_tokens << ActionToken.fingerprint(token)
|
|
445
|
+
token
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# =========================================
|
|
449
|
+
# App-specific display components
|
|
450
|
+
# =========================================
|
|
451
|
+
|
|
452
|
+
# @param defer [Boolean] Render the shell now and fetch this fragment's
|
|
453
|
+
# content afterwards -- the Turbo `turbo_frame_tag ..., src:` equivalent.
|
|
454
|
+
# The block does not run on the initial render at all, so a slow region
|
|
455
|
+
# cannot delay the page.
|
|
456
|
+
# @param lazy [Boolean] Hold the deferred fetch until the fragment is
|
|
457
|
+
# visible -- Turbo's `loading="lazy"`. Implies `defer:`, the way Turbo's
|
|
458
|
+
# `loading` only means anything on a frame that loads from `src`.
|
|
459
|
+
# @param placeholder [String, Proc, nil] What stands in until the content
|
|
460
|
+
# lands. A String renders as text, a Proc is evaluated as DSL, nil gets a
|
|
461
|
+
# small spinner. Only read when `defer:` is set.
|
|
462
|
+
def fragment(name, defer: false, lazy: false, placeholder: nil, &block)
|
|
463
|
+
defer ||= lazy
|
|
464
|
+
name = validate_scalar_key!(name, context: "fragment")
|
|
465
|
+
raise ArgumentError, "fragment: block required" unless block
|
|
466
|
+
raise ArgumentError, "fragment: placeholder: requires defer: true" if placeholder && !defer
|
|
467
|
+
|
|
468
|
+
safe_name = name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|\-\z/, "")
|
|
469
|
+
raise ArgumentError, "fragment: name must contain a letter or number" if safe_name.empty?
|
|
470
|
+
candidate_id = if render_state.fragment_stack.empty?
|
|
471
|
+
"sw-frag-#{safe_name}"
|
|
472
|
+
else
|
|
473
|
+
"#{render_state.fragment_stack.last}--#{safe_name}"
|
|
474
|
+
end
|
|
475
|
+
id = disambiguate_component_id(candidate_id, label: name.to_s, source_loc: block.source_location)
|
|
476
|
+
deferred = defer && !materializes_deferred?(id)
|
|
477
|
+
component = Components::Fragment.new(name, id, deferred: deferred, lazy: lazy)
|
|
478
|
+
components << component
|
|
479
|
+
parent = components
|
|
480
|
+
render_state.fragment_stack << id
|
|
481
|
+
self.components = []
|
|
482
|
+
evaluate_dsl_block(deferred ? placeholder_block(placeholder) : block)
|
|
483
|
+
component.children = components
|
|
484
|
+
self.components = parent
|
|
485
|
+
component
|
|
486
|
+
ensure
|
|
487
|
+
render_state.fragment_stack.pop if id && render_state.fragment_stack.last == id
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# True when this pass is the fetch that materializes fragment `id`, or the
|
|
491
|
+
# fetch of a fragment nested inside it. Nested fragment ids are literally
|
|
492
|
+
# `parent--child` (see #fragment), so an ancestor is a prefix of its
|
|
493
|
+
# descendants -- which is what lets a deferred fragment nested inside
|
|
494
|
+
# another deferred fragment be reached by a second fetch.
|
|
495
|
+
def materializes_deferred?(id)
|
|
496
|
+
target = render_state.deferred_target
|
|
497
|
+
return false unless target
|
|
498
|
+
return true if target == ALL_DEFERRED
|
|
499
|
+
|
|
500
|
+
target == id || target.start_with?("#{id}--")
|
|
501
|
+
end
|
|
502
|
+
private :materializes_deferred?
|
|
503
|
+
|
|
504
|
+
def placeholder_block(placeholder)
|
|
505
|
+
case placeholder
|
|
506
|
+
when Proc then placeholder
|
|
507
|
+
when nil then proc { spinner(size: :sm, label: "Loading…") }
|
|
508
|
+
else proc { text placeholder.to_s }
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
private :placeholder_block
|
|
512
|
+
|
|
513
|
+
def lesson_text(content_or_options = nil, **options, &block)
|
|
514
|
+
if content_or_options.is_a?(String)
|
|
515
|
+
glossary = options[:glossary] || {}
|
|
516
|
+
lesson_component = Components::LessonText.new(glossary: glossary)
|
|
517
|
+
components << lesson_component
|
|
518
|
+
lesson_component.children = parse_lesson_string(content_or_options, glossary)
|
|
519
|
+
else
|
|
520
|
+
opts = content_or_options.is_a?(Hash) ? content_or_options.merge(options) : options
|
|
521
|
+
glossary = opts[:glossary] || {}
|
|
522
|
+
with_container(Components::LessonText.new(glossary: glossary), &block)
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def term(term_key, **options)
|
|
527
|
+
components << Components::Term.new(term_key, **options)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def checkbox_group(key, **options, &block)
|
|
531
|
+
@_state[key] = options[:default] || [] unless @_state.key?(key)
|
|
532
|
+
|
|
533
|
+
group_component = Components::CheckboxGroup.new(key, **options)
|
|
534
|
+
components << group_component
|
|
535
|
+
|
|
536
|
+
parent_components = components
|
|
537
|
+
render_state.current_checkbox_group = group_component
|
|
538
|
+
self.components = []
|
|
539
|
+
|
|
540
|
+
evaluate_dsl_block(block)
|
|
541
|
+
|
|
542
|
+
group_component.children = components
|
|
543
|
+
self.components = parent_components
|
|
544
|
+
render_state.current_checkbox_group = nil
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def item(value, &block)
|
|
548
|
+
item_component = Components::CheckboxItem.new(value)
|
|
549
|
+
capture_children_then_append(item_component, &block)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# =========================================
|
|
553
|
+
# State scopes (namespacing + lifetime)
|
|
554
|
+
# =========================================
|
|
555
|
+
|
|
556
|
+
# Declare a named, kind-tagged, lifetime-managed sub-hash of state.
|
|
557
|
+
# `form(name) { }` is sugar over `scope(name, kind: :form) { }` -- same
|
|
558
|
+
# storage shape (`state[name] = { field => value, ... }`), now nameable,
|
|
559
|
+
# kind-tagged, and (unless retain: true) auto-reset when its owning
|
|
560
|
+
# routing discriminant changes between rebuilds (see #apply_scope_lifecycle).
|
|
561
|
+
#
|
|
562
|
+
# @param name [Symbol] scope name -- becomes the top-level state key
|
|
563
|
+
# @param kind [Symbol] one of :form, :resource, :fragment, :app
|
|
564
|
+
# @param retain [Boolean] opt out of auto-reset (default false)
|
|
565
|
+
# @yield [Hash] the scope's sub-hash, for direct reads/writes; the block
|
|
566
|
+
# also runs in App's DSL context, so nested field/component calls work
|
|
567
|
+
def scope(name, kind:, retain: false, &block)
|
|
568
|
+
name = name.to_sym
|
|
569
|
+
register_scope(name, kind: kind, retain: retain)
|
|
570
|
+
@_state[name] ||= {}
|
|
571
|
+
return @_state[name] unless block
|
|
572
|
+
|
|
573
|
+
saved_scope = render_state.current_scope
|
|
574
|
+
render_state.current_scope = name
|
|
575
|
+
evaluate_dsl_block(block, @_state[name])
|
|
576
|
+
render_state.current_scope = saved_scope
|
|
577
|
+
@_state[name]
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# Names of every scope registered so far (via `scope` or `form`) --
|
|
581
|
+
# used by SessionStore to know which top-level hash values are safe to
|
|
582
|
+
# recurse one level into for blank-stripping (FAC-P3.1 decision §7).
|
|
583
|
+
def scope_names
|
|
584
|
+
@scope_registry.keys
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# =========================================
|
|
588
|
+
# Form container with special context
|
|
589
|
+
# =========================================
|
|
590
|
+
|
|
591
|
+
def form(name, **options, &block)
|
|
592
|
+
name = name.to_sym
|
|
593
|
+
scope(name, kind: :form)
|
|
594
|
+
form_component = Components::Form.new(name, **options)
|
|
595
|
+
components << form_component
|
|
596
|
+
|
|
597
|
+
parent_components = components
|
|
598
|
+
render_state.current_form = form_component
|
|
599
|
+
render_state.form_context = { name: name }
|
|
600
|
+
self.components = []
|
|
601
|
+
|
|
602
|
+
evaluate_dsl_block(block)
|
|
603
|
+
|
|
604
|
+
form_component.children = components
|
|
605
|
+
self.components = parent_components
|
|
606
|
+
render_state.current_form = nil
|
|
607
|
+
render_state.form_context = nil
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def submit(label, &block)
|
|
611
|
+
raise "submit can only be used inside a form block" unless render_state.current_form
|
|
612
|
+
render_state.current_form.set_submit(label, &block)
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def cancel(label)
|
|
616
|
+
raise "cancel can only be used inside a form block" unless render_state.current_form
|
|
617
|
+
render_state.current_form.set_cancel(label)
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def form_context
|
|
621
|
+
render_state.form_context
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# =========================================
|
|
625
|
+
# form_for -- resource-bound form (builds on `form`, FAC-P3.2)
|
|
626
|
+
# =========================================
|
|
627
|
+
|
|
628
|
+
# Generates a full create/update form bound to a resource's store + fields
|
|
629
|
+
# (form-for.md): seeds from `record:` only on first render for that record
|
|
630
|
+
# id and never re-seeds an in-progress edit (reuses the :resource-kind
|
|
631
|
+
# scope auto-reset from `state-scopes.md` §4/§7 -- no hand-rolled seeded_key
|
|
632
|
+
# guard needed), infers create vs. update from record identity, renders
|
|
633
|
+
# fields via the shared Resource::FieldInput table, and wires submit to
|
|
634
|
+
# coerce -> validate -> store.create/update -> flash + PRG transition.
|
|
635
|
+
#
|
|
636
|
+
# @param resource_name [Symbol, nil] a registered `resource` name to reuse
|
|
637
|
+
# its store/fields, or nil when passing store:/fields: directly
|
|
638
|
+
# @param record [Hash, nil] the record being edited; nil/no :id means create
|
|
639
|
+
# @param store [#all,#find,#create,#update,#destroy] required unless resource_name given
|
|
640
|
+
# @param fields [Array<Field>] required unless resource_name given
|
|
641
|
+
# @param name [Symbol, nil] scope/form name override (default "#{singular}_form")
|
|
642
|
+
# @param on_success [Proc, nil] instance_exec'd with the new/updated id;
|
|
643
|
+
# overrides the resource's default show/index transition
|
|
644
|
+
# @param validate [Proc, nil] called with the coerced values, returns
|
|
645
|
+
# Hash[field, Array[String]] of extra validation errors (empty/nil = valid)
|
|
646
|
+
def form_for(resource_name = nil, record: nil, store: nil, fields: nil, name: nil, on_success: nil, validate: nil, &block)
|
|
647
|
+
defn = resource_name && @resource_defs[resource_name.to_sym]
|
|
648
|
+
raise ArgumentError, "form_for: unknown resource #{resource_name.inspect}" if resource_name && !defn
|
|
649
|
+
|
|
650
|
+
store ||= defn&.store
|
|
651
|
+
fields ||= defn&.fields
|
|
652
|
+
raise ArgumentError, "form_for: no store given and no resource #{resource_name.inspect} registered" unless store
|
|
653
|
+
raise ArgumentError, "form_for: no fields given and no resource #{resource_name.inspect} registered" unless fields
|
|
654
|
+
Resource::Store.validate!(store, resource_name || name || :form_for) unless defn
|
|
655
|
+
|
|
656
|
+
singular = resource_name&.to_s || name&.to_s&.sub(/_form\z/, '')
|
|
657
|
+
singular = "record" if singular.nil? || singular.empty?
|
|
658
|
+
scope_name = (name || :"#{singular}_form").to_sym
|
|
659
|
+
errors_key = :"#{scope_name}_errors"
|
|
660
|
+
is_update = !record.nil? && !record[:id].nil?
|
|
661
|
+
|
|
662
|
+
scope(scope_name, kind: :resource) do |s|
|
|
663
|
+
fields.each { |f| s[f.name] = record[f.name] unless s.key?(f.name) } if record
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
form(scope_name) do
|
|
667
|
+
if (errs = state[errors_key]) && !errs.empty?
|
|
668
|
+
alert(variant: :error, title: "Please fix the following") do
|
|
669
|
+
errs.each { |field, msgs| Array(msgs).each { |m| text "#{field.to_s.tr('_', ' ').capitalize}: #{m}" } }
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
fields.each { |f| instance_exec(f, &Resource::FieldInput::RENDER) }
|
|
674
|
+
|
|
675
|
+
render_state.form_for_submit_label = is_update ? "Save" : "Create"
|
|
676
|
+
render_state.form_for_cancel_label = nil
|
|
677
|
+
render_state.form_for_validate = validate
|
|
678
|
+
render_state.form_for_active = true
|
|
679
|
+
instance_exec(&block) if block
|
|
680
|
+
render_state.form_for_active = false
|
|
681
|
+
|
|
682
|
+
submit(render_state.form_for_submit_label) do |raw_values|
|
|
683
|
+
form_for_submit(fields: fields, raw_values: raw_values, store: store, record: record,
|
|
684
|
+
is_update: is_update, errors_key: errors_key, defn: defn,
|
|
685
|
+
on_success: on_success, validate_proc: render_state.form_for_validate,
|
|
686
|
+
singular: singular)
|
|
687
|
+
end
|
|
688
|
+
cancel(render_state.form_for_cancel_label) if render_state.form_for_cancel_label
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# Configures the auto-generated form's submit button label -- only valid
|
|
693
|
+
# inside a `form_for` block (dual-purpose reader/setter, like `edit_view`).
|
|
694
|
+
def submit_label(text = nil)
|
|
695
|
+
raise "submit_label can only be used inside a form_for block" unless render_state.form_for_active
|
|
696
|
+
text.nil? ? render_state.form_for_submit_label : (render_state.form_for_submit_label = text)
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# Configures the auto-generated form's cancel button label (omitted by
|
|
700
|
+
# default) -- only valid inside a `form_for` block.
|
|
701
|
+
def cancel_label(text = nil)
|
|
702
|
+
raise "cancel_label can only be used inside a form_for block" unless render_state.form_for_active
|
|
703
|
+
text.nil? ? render_state.form_for_cancel_label : (render_state.form_for_cancel_label = text)
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# Registers an extra validation hook for the enclosing `form_for`: called
|
|
707
|
+
# with the coerced submitted values, returns Hash[field, Array[String]] of
|
|
708
|
+
# additional errors (merged with coercion failures) -- only valid inside a
|
|
709
|
+
# `form_for` block.
|
|
710
|
+
def validate(&block)
|
|
711
|
+
raise "validate can only be used inside a form_for block" unless render_state.form_for_active
|
|
712
|
+
render_state.form_for_validate = block
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
# =========================================
|
|
716
|
+
# Form input components
|
|
717
|
+
# =========================================
|
|
718
|
+
|
|
719
|
+
def text_field(key, **options)
|
|
720
|
+
@transient_keys << key if options.delete(:transient)
|
|
721
|
+
initialize_form_state(key, options, options[:default] || "")
|
|
722
|
+
components << Components::TextField.new(key, **options)
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def text_area(key, **options)
|
|
726
|
+
@transient_keys << key if options.delete(:transient)
|
|
727
|
+
initialize_form_state(key, options, options[:default] || "")
|
|
728
|
+
components << Components::TextArea.new(key, **options)
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# Native <input type=date>, state-bound like text_field. Value is stored
|
|
732
|
+
# as an ISO 8601 string ("YYYY-MM-DD"); use Components::DateField.to_date
|
|
733
|
+
# to coerce it to a Date (03 gap #4: rivet hand-parsed Date from a raw
|
|
734
|
+
# text_field because date_field didn't exist).
|
|
735
|
+
def date_field(key, **options)
|
|
736
|
+
@transient_keys << key if options.delete(:transient)
|
|
737
|
+
initialize_form_state(key, options, options[:default] || "")
|
|
738
|
+
components << Components::DateField.new(key, **options)
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def code_editor(key, language: :ruby, readonly: true, height: "400px", **options)
|
|
742
|
+
initialize_form_state(key, options, options[:default] || "")
|
|
743
|
+
components << Components::CodeEditor.new(key, language: language, readonly: readonly, height: height, **options)
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
def checkbox(key, label, **options)
|
|
747
|
+
initialize_form_state(key, options, false)
|
|
748
|
+
components << Components::Checkbox.new(key, label, **options)
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def select(key, choices, **options)
|
|
752
|
+
initialize_form_state(key, options, options[:default] || "", skip_if_exists: true)
|
|
753
|
+
components << Components::Select.new(key, choices, **options)
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def radio_group(key, choices, **options)
|
|
757
|
+
initialize_form_state(key, options, "")
|
|
758
|
+
components << Components::RadioGroup.new(key, choices, **options)
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def tag_buttons(key, tags, **options)
|
|
762
|
+
@_state[key] ||= nil
|
|
763
|
+
components << Components::TagButtons.new(key, tags, **options)
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
# Tag/chip multi-select bound to a state array (03 gap #8: rivet's
|
|
767
|
+
# "* "-prefixed toggle buttons faking a checked state). multi: false
|
|
768
|
+
# binds a single scalar value (radio-style) instead of an array.
|
|
769
|
+
# Works inside form/scope like text_field/select.
|
|
770
|
+
def chip_group(key, choices = [], multi: true, **options)
|
|
771
|
+
initialize_form_state(key, options, options[:default] || (multi ? [] : nil))
|
|
772
|
+
components << Components::ChipGroup.new(key, choices, multi: multi, **options)
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# =========================================
|
|
776
|
+
# Interactive components
|
|
777
|
+
# =========================================
|
|
778
|
+
|
|
779
|
+
def button(label, action: nil, key: nil, id: nil, **options, &block)
|
|
780
|
+
raise ArgumentError, "button cannot use both action: and a block" if action && block
|
|
781
|
+
# Identity precedence: id: (verbatim override) > key: (content-stable
|
|
782
|
+
# derivation) > auto-derivation from label + block source_location.
|
|
783
|
+
explicit_id = validate_scalar_key!(id, context: "button")
|
|
784
|
+
key = validate_scalar_key!(key, context: "button")
|
|
785
|
+
if action
|
|
786
|
+
row_key = render_state.current_row_key_thunk&.value
|
|
787
|
+
action_key = key || explicit_id || row_key
|
|
788
|
+
action_key = validate_scalar_key!(action_key, context: "named action button")
|
|
789
|
+
raise ArgumentError, "named action button: key: is required" if action_key.nil?
|
|
790
|
+
options[:action_token] = action_token(
|
|
791
|
+
action, action_key,
|
|
792
|
+
fragment: render_state.fragment_stack.last,
|
|
793
|
+
updates: options.delete(:updates),
|
|
794
|
+
primary: options.delete(:primary)
|
|
795
|
+
)
|
|
796
|
+
end
|
|
797
|
+
# Generate stable ID: an explicit id: is used verbatim; otherwise use
|
|
798
|
+
# source location for buttons with blocks, falling back to a counter for
|
|
799
|
+
# blockless buttons (submit: false).
|
|
800
|
+
# If key: is provided, mix it in to disambiguate buttons in loops
|
|
801
|
+
# (order-independent -- unlike a positional index, reordering the
|
|
802
|
+
# collection doesn't change a keyed button's id). Inside a table's
|
|
803
|
+
# component cell, the row's key is auto-mixed in too (FAC-P2.1
|
|
804
|
+
# decision 3), on top of any explicit key: also passed.
|
|
805
|
+
if explicit_id
|
|
806
|
+
stable_id = sanitize_explicit_id(explicit_id)
|
|
807
|
+
elsif block || action
|
|
808
|
+
row_key = render_state.current_row_key_thunk&.value
|
|
809
|
+
combined_key = [row_key, key].compact
|
|
810
|
+
source_loc = block ? block.source_location.join(':') : "action:#{action}"
|
|
811
|
+
id_input = combined_key.any? ? "#{label}:#{combined_key.join(':')}" : "#{label}:#{source_loc}"
|
|
812
|
+
stable_id = Digest::MD5.hexdigest(id_input)[0..7]
|
|
813
|
+
else
|
|
814
|
+
render_state.button_counter += 1
|
|
815
|
+
stable_id = render_state.button_counter.to_s
|
|
816
|
+
end
|
|
817
|
+
# Pass modal context to button so it can close the modal via Alpine
|
|
818
|
+
options[:modal_context] = render_state.modal_context if render_state.modal_context
|
|
819
|
+
btn = Components::Button.new(label, stable_id, **options, &block)
|
|
820
|
+
btn.id = disambiguate_component_id(btn.id, label: label, source_loc: block&.source_location)
|
|
821
|
+
components << btn
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
# Wraps arbitrary composed content (any DSL components) as a single
|
|
825
|
+
# click target -- the click-target equivalent of what card/div already
|
|
826
|
+
# do for layout, so a whole card/row can be one dispatch target instead
|
|
827
|
+
# of a trailing "View" button glued to the bottom.
|
|
828
|
+
#
|
|
829
|
+
# clickable(action: :select, key: story[:id]) { header4 story[:slug]; badge story[:state] }
|
|
830
|
+
# clickable(href: "/stories/#{story[:id]}") { header4 story[:slug] }
|
|
831
|
+
#
|
|
832
|
+
# action: dispatches exactly like a named-action button (same token,
|
|
833
|
+
# fragment context, and current-row key: inside a table cell); href:
|
|
834
|
+
# renders a plain navigation <a> for routed pages. Mutually exclusive.
|
|
835
|
+
def clickable(action: nil, href: nil, key: nil, **options, &block)
|
|
836
|
+
if action.nil? == href.nil?
|
|
837
|
+
raise ArgumentError, "clickable requires exactly one of action: or href:"
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
if href
|
|
841
|
+
component = Components::Clickable.new(href: href, **options)
|
|
842
|
+
return with_container(component, &block)
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
require 'digest/md5'
|
|
846
|
+
row_key = render_state.current_row_key_thunk&.value
|
|
847
|
+
action_key = key || row_key
|
|
848
|
+
action_key = validate_scalar_key!(action_key, context: "clickable")
|
|
849
|
+
raise ArgumentError, "clickable action: key: is required" if action_key.nil?
|
|
850
|
+
options[:action_token] = action_token(
|
|
851
|
+
action, action_key,
|
|
852
|
+
fragment: render_state.fragment_stack.last,
|
|
853
|
+
updates: options.delete(:updates),
|
|
854
|
+
primary: options.delete(:primary)
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
combined_key = [row_key, key].compact
|
|
858
|
+
id_input = combined_key.any? ? "clickable:#{action}:#{combined_key.join(':')}" : "clickable:#{action}:#{block&.source_location&.join(':')}"
|
|
859
|
+
stable_id = Digest::MD5.hexdigest(id_input)[0..7]
|
|
860
|
+
wrapper_id = "clickable_#{action}_#{stable_id}"
|
|
861
|
+
|
|
862
|
+
component = Components::Clickable.new(wrapper_id: wrapper_id, **options)
|
|
863
|
+
component.id = disambiguate_component_id(component.id, label: "clickable_#{action}", source_loc: block&.source_location)
|
|
864
|
+
with_container(component, &block)
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
# =========================================
|
|
868
|
+
# Chart DSL methods
|
|
869
|
+
# =========================================
|
|
870
|
+
|
|
871
|
+
def bar_chart(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
|
|
872
|
+
components << Components::BarChart.new(
|
|
873
|
+
data: data, file: file, path: path, labels: labels, values: values, **options, &block
|
|
874
|
+
)
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
def hbar_chart(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
|
|
878
|
+
bar_chart(data: data, file: file, path: path, labels: labels, values: values, horizontal: true, **options, &block)
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
def line_chart(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
|
|
882
|
+
components << Components::LineChart.new(
|
|
883
|
+
data: data, file: file, path: path, labels: labels, values: values, **options, &block
|
|
884
|
+
)
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
def sparkline(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
|
|
888
|
+
line_chart(data: data, file: file, path: path, labels: labels, values: values, sparkline: true, **options, &block)
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def area_chart(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
|
|
892
|
+
line_chart(data: data, file: file, path: path, labels: labels, values: values, fill: true, **options, &block)
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def pie_chart(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
|
|
896
|
+
components << Components::PieChart.new(
|
|
897
|
+
data: data, file: file, path: path, labels: labels, values: values, **options, &block
|
|
898
|
+
)
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
def doughnut_chart(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
|
|
902
|
+
pie_chart(data: data, file: file, path: path, labels: labels, values: values, doughnut: true, **options, &block)
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
def stacked_bar_chart(data: nil, file: nil, path: nil, **options, &block)
|
|
906
|
+
components << Components::StackedBarChart.new(data: data, file: file, path: path, **options, &block)
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
# =========================================
|
|
910
|
+
# Navigation DSL methods
|
|
911
|
+
# =========================================
|
|
912
|
+
|
|
913
|
+
def tabs(key, variant: :line, url: false, **options, &block)
|
|
914
|
+
tabs_component = Components::Tabs.new(key, variant: variant, url: url, **options)
|
|
915
|
+
claim_url_tab_key!(tabs_component) if url
|
|
916
|
+
# `url: true` with lazy has already raised above, so reaching here with
|
|
917
|
+
# lazy set means the deprecated POST-morph mode.
|
|
918
|
+
warn_lazy_tabs_deprecated(key) if tabs_component.lazy
|
|
919
|
+
|
|
920
|
+
# Coerce before the block: `tab` reads this index mid-evaluation to decide
|
|
921
|
+
# which lazy panels to evaluate, so it has to be a usable Integer by then.
|
|
922
|
+
@_state[key] = coerce_tab_index(tab_index_source(key, url: url))
|
|
923
|
+
|
|
924
|
+
components << tabs_component
|
|
925
|
+
|
|
926
|
+
parent_components = components
|
|
927
|
+
render_state.current_tabs = tabs_component
|
|
928
|
+
self.components = []
|
|
929
|
+
rewind_render_state = rewind_point if tabs_component.lazy
|
|
930
|
+
|
|
931
|
+
evaluate_dsl_block(block)
|
|
932
|
+
|
|
933
|
+
# Clamp after the block: the tab count doesn't exist until the nested
|
|
934
|
+
# `tab` calls have appended, and an index past the end matches no panel.
|
|
935
|
+
requested = @_state[key]
|
|
936
|
+
@_state[key] = 0 unless requested.between?(0, components.size - 1)
|
|
937
|
+
|
|
938
|
+
if rewind_render_state && @_state[key] != requested
|
|
939
|
+
# Lazy tabs pick which blocks to evaluate while the block is still
|
|
940
|
+
# running, so an out-of-range index skipped every panel and left the
|
|
941
|
+
# group empty. The corrected index needs a second pass -- and the first
|
|
942
|
+
# pass is discarded, so its render_state marks are rewound with it.
|
|
943
|
+
self.components = []
|
|
944
|
+
rewind_render_state.call
|
|
945
|
+
evaluate_dsl_block(block)
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
tabs_component.children = components
|
|
949
|
+
self.components = parent_components
|
|
950
|
+
render_state.current_tabs = nil
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
# A tab index reaches state from a URL param, a stale session, or a tab
|
|
954
|
+
# group that shrank under it, so it can arrive as anything at all. Values
|
|
955
|
+
# with no sensible integer reading collapse to the first tab; integer
|
|
956
|
+
# strings are kept, since `url: true` groups read their index from params.
|
|
957
|
+
def coerce_tab_index(value)
|
|
958
|
+
case value
|
|
959
|
+
when Integer then value
|
|
960
|
+
# \A\d+\z + base-10, matching sw-route-tabs.js exactly -- Integer() would
|
|
961
|
+
# read a leading zero as octal and disagree with the client on ?view=010.
|
|
962
|
+
when String then value.match?(/\A\d+\z/) ? value.to_i : 0
|
|
963
|
+
else 0
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
private :coerce_tab_index
|
|
967
|
+
|
|
968
|
+
# Only the URL decides a `url: true` group's index. State cannot serve even
|
|
969
|
+
# as a fallback: the param has already been synced into it, so a stale index
|
|
970
|
+
# is indistinguishable from a fresh one -- and an absent param means tab 0
|
|
971
|
+
# as surely as `?view=0` does. Renders with no URL behind them (a POST
|
|
972
|
+
# morph, a canvas push, an export) keep the index they were handed.
|
|
973
|
+
def tab_index_source(key, url:)
|
|
974
|
+
url_params = render_state.url_params
|
|
975
|
+
return @_state[key] unless url && url_params
|
|
976
|
+
|
|
977
|
+
url_params[key.to_s]
|
|
978
|
+
end
|
|
979
|
+
private :tab_index_source
|
|
980
|
+
|
|
981
|
+
# Captures the render_state accumulators that an evaluation pass writes to,
|
|
982
|
+
# returning a callable that puts them back. Used where a pass is thrown away
|
|
983
|
+
# and re-run (see #tabs): re-running without rewinding double-counts, and
|
|
984
|
+
# ids that shift between renders break dispatch for exactly the requests the
|
|
985
|
+
# re-run exists to rescue. Mirrors #with_clean_form_context, but the rewind
|
|
986
|
+
# is deliberately explicit -- the caller only knows it needs one after the
|
|
987
|
+
# block has already run.
|
|
988
|
+
def rewind_point
|
|
989
|
+
button_counter = render_state.button_counter
|
|
990
|
+
table_counter = render_state.table_counter
|
|
991
|
+
seen_component_ids = render_state.seen_component_ids.dup
|
|
992
|
+
url_tab_keys = render_state.url_tab_keys.dup
|
|
993
|
+
|
|
994
|
+
lambda do
|
|
995
|
+
render_state.button_counter = button_counter
|
|
996
|
+
render_state.table_counter = table_counter
|
|
997
|
+
render_state.seen_component_ids = seen_component_ids
|
|
998
|
+
render_state.url_tab_keys = url_tab_keys
|
|
999
|
+
end
|
|
1000
|
+
end
|
|
1001
|
+
private :rewind_point
|
|
1002
|
+
|
|
1003
|
+
def tab(label, **options, &block)
|
|
1004
|
+
tab_component = Components::Tab.new(label, **options)
|
|
1005
|
+
|
|
1006
|
+
# Lazy tabs: skip EVALUATING inactive tab blocks entirely, not just
|
|
1007
|
+
# rendering them. The block is where apps load data, so lazy that only
|
|
1008
|
+
# skipped the render layer (adapter) still paid the full data cost for
|
|
1009
|
+
# every hidden tab. The adapter already renders inactive lazy panels as
|
|
1010
|
+
# a placeholder comment, so an empty-children Tab is consistent; tab
|
|
1011
|
+
# switches post the new index and the full morph re-render evaluates
|
|
1012
|
+
# the newly-active block.
|
|
1013
|
+
current = render_state.current_tabs
|
|
1014
|
+
if current&.lazy
|
|
1015
|
+
index = components.size
|
|
1016
|
+
active = @_state[current.key]
|
|
1017
|
+
if index != active
|
|
1018
|
+
components << tab_component
|
|
1019
|
+
return tab_component
|
|
1020
|
+
end
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
capture_children_then_append(tab_component, &block)
|
|
1024
|
+
end
|
|
1025
|
+
|
|
1026
|
+
# Lazy tabs fetch the newly-active panel with an `hx-post` morph, which a
|
|
1027
|
+
# canvas page has no route for -- inactive panels there render as a
|
|
1028
|
+
# placeholder comment and can never receive content (beads
|
|
1029
|
+
# stream_weaver-pkh). Route tabs are the direction this goes instead, and a
|
|
1030
|
+
# lazy route-tab mode is meant to replace this one; until then the mode
|
|
1031
|
+
# still works exactly as before and only says so.
|
|
1032
|
+
#
|
|
1033
|
+
# Once per process, not once per declaration: a lazy app re-evaluates its
|
|
1034
|
+
# whole DSL on every interaction, so a per-render warning would bury the
|
|
1035
|
+
# logs of the busiest apps -- the ones most likely to be using lazy.
|
|
1036
|
+
def warn_lazy_tabs_deprecated(key)
|
|
1037
|
+
return if self.class.lazy_tabs_deprecation_warned
|
|
1038
|
+
|
|
1039
|
+
self.class.lazy_tabs_deprecation_warned = true
|
|
1040
|
+
warn "StreamWeaver: tabs #{key.inspect} -- lazy: true is deprecated. Switching a lazy tab " \
|
|
1041
|
+
"round-trips to the server, which canvas cannot serve (stream_weaver-pkh). Prefer route " \
|
|
1042
|
+
"tabs (tabs #{key.inspect}, url: true); lazy route tabs will replace this mode. " \
|
|
1043
|
+
"Warned once per process."
|
|
1044
|
+
end
|
|
1045
|
+
private :warn_lazy_tabs_deprecated
|
|
1046
|
+
|
|
1047
|
+
# Validates and records a `url: true` tabs key. Keys live on render_state so
|
|
1048
|
+
# tracking resets with every DSL evaluation -- the same app re-rendering per
|
|
1049
|
+
# request must not look like a duplicate declaration of itself.
|
|
1050
|
+
def claim_url_tab_key!(group)
|
|
1051
|
+
key = group.key
|
|
1052
|
+
name = key.to_s
|
|
1053
|
+
|
|
1054
|
+
raise ArgumentError, "tabs #{key.inspect}: url: true does not support lazy: true yet" if group.lazy
|
|
1055
|
+
raise ArgumentError, "tabs #{key.inspect}: reserved request param -- pick another key for url: true tabs" if ROUTE_OWNED_PARAMS.include?(name)
|
|
1056
|
+
raise ArgumentError, "tabs #{key.inspect}: already claimed by another url: true tabs group" if render_state.url_tab_keys.include?(name)
|
|
1057
|
+
|
|
1058
|
+
render_state.url_tab_keys << name
|
|
1059
|
+
end
|
|
1060
|
+
private :claim_url_tab_key!
|
|
1061
|
+
|
|
1062
|
+
def breadcrumbs(separator: "/", **options, &block)
|
|
1063
|
+
breadcrumbs_component = Components::Breadcrumbs.new(separator: separator, **options)
|
|
1064
|
+
components << breadcrumbs_component
|
|
1065
|
+
|
|
1066
|
+
parent_components = components
|
|
1067
|
+
render_state.current_breadcrumbs = breadcrumbs_component
|
|
1068
|
+
self.components = []
|
|
1069
|
+
|
|
1070
|
+
evaluate_dsl_block(block)
|
|
1071
|
+
|
|
1072
|
+
breadcrumbs_component.children = components
|
|
1073
|
+
self.components = parent_components
|
|
1074
|
+
render_state.current_breadcrumbs = nil
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
def crumb(label, href: nil, **options)
|
|
1078
|
+
components << Components::Crumb.new(label, href: href, **options)
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
def dropdown(**options, &block)
|
|
1082
|
+
dropdown_component = Components::Dropdown.new(**options)
|
|
1083
|
+
components << dropdown_component
|
|
1084
|
+
|
|
1085
|
+
render_state.current_dropdown = dropdown_component
|
|
1086
|
+
evaluate_dsl_block(block)
|
|
1087
|
+
render_state.current_dropdown = nil
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
def trigger(&block)
|
|
1091
|
+
raise "trigger can only be used inside a dropdown block" unless render_state.current_dropdown
|
|
1092
|
+
|
|
1093
|
+
trigger_component = Components::DropdownTrigger.new
|
|
1094
|
+
parent_components = components
|
|
1095
|
+
self.components = []
|
|
1096
|
+
|
|
1097
|
+
evaluate_dsl_block(block)
|
|
1098
|
+
|
|
1099
|
+
trigger_component.children = components
|
|
1100
|
+
self.components = parent_components
|
|
1101
|
+
render_state.current_dropdown.trigger_component = trigger_component
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
def menu(**options, &block)
|
|
1105
|
+
raise "menu can only be used inside a dropdown block" unless render_state.current_dropdown
|
|
1106
|
+
|
|
1107
|
+
menu_component = Components::Menu.new(**options)
|
|
1108
|
+
parent_components = components
|
|
1109
|
+
render_state.current_menu = menu_component
|
|
1110
|
+
self.components = []
|
|
1111
|
+
|
|
1112
|
+
evaluate_dsl_block(block)
|
|
1113
|
+
|
|
1114
|
+
menu_component.children = components
|
|
1115
|
+
self.components = parent_components
|
|
1116
|
+
render_state.current_dropdown.menu_component = menu_component
|
|
1117
|
+
render_state.current_menu = nil
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
def menu_item(label, style: :default, **options, &block)
|
|
1121
|
+
raise "menu_item can only be used inside a menu block" unless render_state.current_menu
|
|
1122
|
+
render_state.button_counter += 1
|
|
1123
|
+
item = Components::MenuItem.new(label, style: style, **options, &block)
|
|
1124
|
+
item.instance_variable_set(:@id, "menu_item_#{render_state.button_counter}")
|
|
1125
|
+
components << item
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
def menu_divider
|
|
1129
|
+
raise "menu_divider can only be used inside a menu block" unless render_state.current_menu
|
|
1130
|
+
components << Components::MenuDivider.new
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
# =========================================
|
|
1134
|
+
# Modal DSL methods
|
|
1135
|
+
# =========================================
|
|
1136
|
+
|
|
1137
|
+
def modal(key, title: nil, size: :md, **options, &block)
|
|
1138
|
+
open_key = :"#{key}_open"
|
|
1139
|
+
@_state[open_key] = false unless @_state.key?(open_key)
|
|
1140
|
+
|
|
1141
|
+
modal_component = Components::Modal.new(key, title: title, size: size, **options)
|
|
1142
|
+
components << modal_component
|
|
1143
|
+
|
|
1144
|
+
parent_components = components
|
|
1145
|
+
render_state.current_modal = modal_component
|
|
1146
|
+
render_state.modal_context = { key: key }
|
|
1147
|
+
self.components = []
|
|
1148
|
+
|
|
1149
|
+
evaluate_dsl_block(block)
|
|
1150
|
+
|
|
1151
|
+
modal_component.children = components
|
|
1152
|
+
self.components = parent_components
|
|
1153
|
+
render_state.current_modal = nil
|
|
1154
|
+
render_state.modal_context = nil
|
|
1155
|
+
end
|
|
1156
|
+
|
|
1157
|
+
def modal_footer(**options, &block)
|
|
1158
|
+
raise "modal_footer can only be used inside a modal block" unless render_state.current_modal
|
|
1159
|
+
|
|
1160
|
+
footer_component = Components::ModalFooter.new(**options)
|
|
1161
|
+
parent_components = components
|
|
1162
|
+
self.components = []
|
|
1163
|
+
|
|
1164
|
+
evaluate_dsl_block(block)
|
|
1165
|
+
|
|
1166
|
+
footer_component.children = components
|
|
1167
|
+
self.components = parent_components
|
|
1168
|
+
render_state.current_modal.footer_component = footer_component
|
|
1169
|
+
end
|
|
1170
|
+
|
|
1171
|
+
def open_modal(key)
|
|
1172
|
+
@_state[:"#{key}_open"] = true
|
|
1173
|
+
end
|
|
1174
|
+
|
|
1175
|
+
def close_modal(key)
|
|
1176
|
+
@_state[:"#{key}_open"] = false
|
|
1177
|
+
end
|
|
1178
|
+
|
|
1179
|
+
# =========================================
|
|
1180
|
+
# Feedback DSL methods (App-only)
|
|
1181
|
+
# =========================================
|
|
1182
|
+
|
|
1183
|
+
def toast_container(position: :top_right, duration: 5000, **options)
|
|
1184
|
+
@_state[:_toasts] ||= []
|
|
1185
|
+
components << Components::ToastContainer.new(position: position, duration: duration, **options)
|
|
1186
|
+
end
|
|
1187
|
+
|
|
1188
|
+
def show_toast(message, variant: :info, duration: nil)
|
|
1189
|
+
@_state[:_toasts] ||= []
|
|
1190
|
+
toast_id = "toast_#{Time.now.to_f.to_s.gsub('.', '_')}_#{rand(1000)}"
|
|
1191
|
+
toast = { id: toast_id, message: message, variant: variant }
|
|
1192
|
+
toast[:duration] = duration if duration
|
|
1193
|
+
@_state[:_toasts] << toast
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
def dismiss_toast(toast_id)
|
|
1197
|
+
@_state[:_toasts] ||= []
|
|
1198
|
+
@_state[:_toasts].reject! { |t| t[:id] == toast_id }
|
|
1199
|
+
end
|
|
1200
|
+
|
|
1201
|
+
def clear_toasts
|
|
1202
|
+
@_state[:_toasts] = []
|
|
1203
|
+
end
|
|
1204
|
+
|
|
1205
|
+
# =========================================
|
|
1206
|
+
# Flash: one-shot, same-request messaging (FAC-P3.2b, flash-prg.md)
|
|
1207
|
+
# =========================================
|
|
1208
|
+
|
|
1209
|
+
# Hash-like accessor backed by state[:_flash] (mirrors how `state` itself
|
|
1210
|
+
# is exposed): `flash[:notice] = "Person created."`. flash.now-only
|
|
1211
|
+
# semantics -- visible in the render that answers this request, gone by
|
|
1212
|
+
# the next one (session persistence excludes :_flash entirely; see
|
|
1213
|
+
# SessionStore::Base#filter and Service#set_app_state). Not merged with
|
|
1214
|
+
# the toast system: toast is a persistent, dismissible list; flash is a
|
|
1215
|
+
# one-shot PRG-shaped message, deliberately a different lifetime.
|
|
1216
|
+
def flash
|
|
1217
|
+
@_state[:_flash] ||= {}
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
# Renders whatever key/value pairs are present in the current flash as
|
|
1221
|
+
# Alert summaries, mapping known keys to Alert variants (:notice ->
|
|
1222
|
+
# :success, :error -> :error) and falling back to :info for anything
|
|
1223
|
+
# else. Auto-called near the top of #app-container when chrome: true
|
|
1224
|
+
# (see #rebuild_with_state); apps with chrome: false place it explicitly.
|
|
1225
|
+
def flash_messages
|
|
1226
|
+
return unless (f = @_state[:_flash]) && !f.empty?
|
|
1227
|
+
f.each do |key, message|
|
|
1228
|
+
variant = case key.to_sym
|
|
1229
|
+
when :notice then :success
|
|
1230
|
+
when :error then :error
|
|
1231
|
+
else :info
|
|
1232
|
+
end
|
|
1233
|
+
alert(variant: variant) { text message }
|
|
1234
|
+
end
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1237
|
+
def canvas_continue(message: "Processing...")
|
|
1238
|
+
components << Components::CanvasContinue.new(message: message)
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
def theme_switcher(position: :inline, show_label: true, **options)
|
|
1242
|
+
components << Components::ThemeSwitcher.new(position: position, show_label: show_label, **options)
|
|
1243
|
+
end
|
|
1244
|
+
|
|
1245
|
+
# =========================================
|
|
1246
|
+
# Design Deck DSL methods (T7)
|
|
1247
|
+
# =========================================
|
|
1248
|
+
|
|
1249
|
+
# Create a design deck with slide-based option selection.
|
|
1250
|
+
# The deck wraps its slides in a SlideContainer with :swap mode.
|
|
1251
|
+
#
|
|
1252
|
+
# @param title [String] Deck title
|
|
1253
|
+
# @param options [Hash] Additional options
|
|
1254
|
+
# @yield Block containing slide definitions
|
|
1255
|
+
# @return [Components::Deck::DesignDeck] The deck component
|
|
1256
|
+
#
|
|
1257
|
+
# @example
|
|
1258
|
+
# design_deck "Architecture Direction" do
|
|
1259
|
+
# slide "arch", "System Architecture" do
|
|
1260
|
+
# option "Monolith" do
|
|
1261
|
+
# code_block "...", lang: "ts"
|
|
1262
|
+
# end
|
|
1263
|
+
# end
|
|
1264
|
+
# end
|
|
1265
|
+
def design_deck(title, **options, &block)
|
|
1266
|
+
deck = Components::Deck::DesignDeck.new(title, **options)
|
|
1267
|
+
components << deck
|
|
1268
|
+
render_state.current_deck = deck
|
|
1269
|
+
|
|
1270
|
+
parent_components = components
|
|
1271
|
+
self.components = []
|
|
1272
|
+
evaluate_dsl_block(block)
|
|
1273
|
+
deck.children = components
|
|
1274
|
+
self.components = parent_components
|
|
1275
|
+
|
|
1276
|
+
deck.validate!
|
|
1277
|
+
|
|
1278
|
+
# Auto-append DeckSummary as the last slide (T9)
|
|
1279
|
+
slides = deck.children.select { |c| c.is_a?(Components::Deck::DeckSlide) }
|
|
1280
|
+
summary = Components::Deck::DeckSummary.new
|
|
1281
|
+
summary.deck_slides = slides
|
|
1282
|
+
deck.children << summary
|
|
1283
|
+
|
|
1284
|
+
render_state.current_deck = nil
|
|
1285
|
+
deck
|
|
1286
|
+
end
|
|
1287
|
+
|
|
1288
|
+
# Override slide to create DeckSlide when inside a design_deck context.
|
|
1289
|
+
# Falls through to DisplayDSL#slide when not in deck context.
|
|
1290
|
+
def slide(id, title = nil, **options, &block)
|
|
1291
|
+
unless render_state.current_deck
|
|
1292
|
+
return super(id, title, **options, &block)
|
|
1293
|
+
end
|
|
1294
|
+
|
|
1295
|
+
deck_slide = Components::Deck::DeckSlide.new(id, title, **options)
|
|
1296
|
+
components << deck_slide
|
|
1297
|
+
render_state.current_slide = deck_slide
|
|
1298
|
+
|
|
1299
|
+
parent_components = components
|
|
1300
|
+
self.components = []
|
|
1301
|
+
evaluate_dsl_block(block)
|
|
1302
|
+
deck_slide.children = components
|
|
1303
|
+
self.components = parent_components
|
|
1304
|
+
|
|
1305
|
+
render_state.current_slide = nil
|
|
1306
|
+
deck_slide
|
|
1307
|
+
end
|
|
1308
|
+
|
|
1309
|
+
# Create an option card within a DeckSlide.
|
|
1310
|
+
# Must be called inside a slide block within a design_deck.
|
|
1311
|
+
#
|
|
1312
|
+
# @param label [String] Option label
|
|
1313
|
+
# @param aside [String, nil] Aside text below preview
|
|
1314
|
+
# @param recommended [Boolean] Show "Recommended" badge
|
|
1315
|
+
# @param description [String, nil] Description for tooltip/aria
|
|
1316
|
+
# @param options [Hash] Additional options
|
|
1317
|
+
# @yield Block containing preview content (mermaid, code_block, etc.)
|
|
1318
|
+
#
|
|
1319
|
+
# @example
|
|
1320
|
+
# option "Monolith", aside: "Simple", recommended: true do
|
|
1321
|
+
# mermaid "graph TD; A-->B", compact: true
|
|
1322
|
+
# end
|
|
1323
|
+
def option(label, **options, &block)
|
|
1324
|
+
raise "option must be inside a slide within design_deck" unless render_state.current_deck && render_state.current_slide
|
|
1325
|
+
|
|
1326
|
+
opt = Components::Deck::DeckOption.new(label, **options)
|
|
1327
|
+
# Track parent slide context for selection state (T8)
|
|
1328
|
+
opt.slide_id = render_state.current_slide.id
|
|
1329
|
+
# Count options already in the current build's components list (not slide.children which isn't set yet)
|
|
1330
|
+
opt.option_index = components.count { |c| c.is_a?(Components::Deck::DeckOption) }
|
|
1331
|
+
components << opt
|
|
1332
|
+
|
|
1333
|
+
parent_components = components
|
|
1334
|
+
self.components = []
|
|
1335
|
+
evaluate_dsl_block(block)
|
|
1336
|
+
opt.children = components
|
|
1337
|
+
self.components = parent_components
|
|
1338
|
+
|
|
1339
|
+
opt
|
|
1340
|
+
end
|
|
1341
|
+
|
|
1342
|
+
# =========================================
|
|
1343
|
+
# Deck Polish DSL methods (T14)
|
|
1344
|
+
# =========================================
|
|
1345
|
+
|
|
1346
|
+
# Create an AI model picker for generate-more.
|
|
1347
|
+
# Hidden when fewer than 2 models are provided.
|
|
1348
|
+
#
|
|
1349
|
+
# @param models [Array<Hash>] Array of { id:, name:, provider: } hashes
|
|
1350
|
+
# @param default_model [String, nil] ID of the default selected model
|
|
1351
|
+
#
|
|
1352
|
+
# @example
|
|
1353
|
+
# model_selector(
|
|
1354
|
+
# models: [
|
|
1355
|
+
# { id: "claude-3", name: "Claude 3 Opus", provider: "Anthropic" },
|
|
1356
|
+
# { id: "gpt-4", name: "GPT-4", provider: "OpenAI" }
|
|
1357
|
+
# ],
|
|
1358
|
+
# default_model: "claude-3"
|
|
1359
|
+
# )
|
|
1360
|
+
def model_selector(models:, default_model: nil, **options)
|
|
1361
|
+
components << Components::Deck::ModelSelector.new(
|
|
1362
|
+
models: models, default_model: default_model, **options
|
|
1363
|
+
)
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1366
|
+
# Show a fixed top confirmation bar with confirm/cancel buttons.
|
|
1367
|
+
# Slides down from top with optional auto-hide timer.
|
|
1368
|
+
#
|
|
1369
|
+
# @param message [String] Confirmation message
|
|
1370
|
+
# @param confirm_label [String] Label for confirm action (default: "Cancel")
|
|
1371
|
+
# @param cancel_label [String] Label for dismiss action (default: "Keep Going")
|
|
1372
|
+
# @param auto_hide [Integer, nil] Auto-hide after N seconds (default: 5)
|
|
1373
|
+
#
|
|
1374
|
+
# @example
|
|
1375
|
+
# confirmation_bar(
|
|
1376
|
+
# message: "Are you sure you want to cancel?",
|
|
1377
|
+
# confirm_label: "Yes, Cancel",
|
|
1378
|
+
# cancel_label: "Keep Going"
|
|
1379
|
+
# )
|
|
1380
|
+
def confirmation_bar(message:, confirm_label: "Cancel", cancel_label: "Keep Going",
|
|
1381
|
+
auto_hide: 5, **options)
|
|
1382
|
+
components << Components::Deck::ConfirmationBar.new(
|
|
1383
|
+
message: message, confirm_label: confirm_label,
|
|
1384
|
+
cancel_label: cancel_label, auto_hide: auto_hide, **options
|
|
1385
|
+
)
|
|
1386
|
+
end
|
|
1387
|
+
|
|
1388
|
+
# Show a full-screen overlay after submit or cancel.
|
|
1389
|
+
# Displays status message with blur backdrop and optional auto-close tab.
|
|
1390
|
+
#
|
|
1391
|
+
# @param status [Symbol] Status type (:submitted or :cancelled)
|
|
1392
|
+
# @param message [String] Status message
|
|
1393
|
+
# @param auto_close_delay [Integer] Auto-close tab delay in ms (default: 800)
|
|
1394
|
+
#
|
|
1395
|
+
# @example
|
|
1396
|
+
# close_overlay(status: :submitted, message: "Deck submitted!")
|
|
1397
|
+
def close_overlay(status:, message:, auto_close_delay: 800, **options)
|
|
1398
|
+
components << Components::Deck::CloseOverlay.new(
|
|
1399
|
+
status: status, message: message,
|
|
1400
|
+
auto_close_delay: auto_close_delay, **options
|
|
1401
|
+
)
|
|
1402
|
+
end
|
|
1403
|
+
|
|
1404
|
+
# =========================================
|
|
1405
|
+
# Streaming DSL (server-push via SSE)
|
|
1406
|
+
# =========================================
|
|
1407
|
+
|
|
1408
|
+
def stream(&block)
|
|
1409
|
+
@stream_block ||= block
|
|
1410
|
+
end
|
|
1411
|
+
|
|
1412
|
+
def every(seconds, &block)
|
|
1413
|
+
return if @timers_frozen
|
|
1414
|
+
@timers << { interval: seconds, block: block, last_run: nil }
|
|
1415
|
+
end
|
|
1416
|
+
|
|
1417
|
+
def has_timers?
|
|
1418
|
+
@timers.any?
|
|
1419
|
+
end
|
|
1420
|
+
|
|
1421
|
+
# Set favicon — accepts a URL string or a single emoji character
|
|
1422
|
+
# Emoji example: favicon "🔥"
|
|
1423
|
+
# URL example: favicon "https://example.com/icon.png"
|
|
1424
|
+
def favicon(value)
|
|
1425
|
+
@favicon_value = value
|
|
1426
|
+
end
|
|
1427
|
+
|
|
1428
|
+
# Returns the favicon as an href suitable for <link rel="icon">
|
|
1429
|
+
# Converts emoji to SVG data URI, file paths to base64 data URI; passes URLs through unchanged
|
|
1430
|
+
def favicon_href
|
|
1431
|
+
return nil unless @favicon_value
|
|
1432
|
+
@_favicon_href_cache ||= build_favicon_href
|
|
1433
|
+
end
|
|
1434
|
+
|
|
1435
|
+
FAVICON_MIME_TYPES = {
|
|
1436
|
+
'ico' => 'image/x-icon', 'png' => 'image/png', 'svg' => 'image/svg+xml',
|
|
1437
|
+
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp'
|
|
1438
|
+
}.freeze
|
|
1439
|
+
|
|
1440
|
+
# Serves a local file (stylesheet, image, ...) via the /sw-asset/ route
|
|
1441
|
+
# -- content-type by extension, ETag, and path-traversal-safe: the
|
|
1442
|
+
# resolved path must be under this app's own script directory or one of
|
|
1443
|
+
# its assets_dirs: (stream_weaver-1lo). Generalizes the local-path
|
|
1444
|
+
# detection favicon already did one-off (build_favicon_href) to any
|
|
1445
|
+
# asset, and to a real served/cacheable route instead of a base64 data
|
|
1446
|
+
# URI (so a large stylesheet doesn't bloat every page load).
|
|
1447
|
+
#
|
|
1448
|
+
# @param path [String] Absolute path, or relative to the calling script's directory
|
|
1449
|
+
# @return [String] URL to serve the file, e.g. "/sw-asset/<key>/name.css"
|
|
1450
|
+
# @raise [ArgumentError] if the file doesn't exist, or resolves outside the allowed directories
|
|
1451
|
+
def local_asset(path)
|
|
1452
|
+
abs_path = resolve_asset_path(path)
|
|
1453
|
+
raise ArgumentError, "local_asset: file not found: #{path}" unless abs_path
|
|
1454
|
+
|
|
1455
|
+
ensure_asset_path_allowed!(abs_path, path)
|
|
1456
|
+
"/sw-asset/#{ComponentAssets.register_file(abs_path)}/#{File.basename(abs_path)}"
|
|
1457
|
+
end
|
|
1458
|
+
|
|
1459
|
+
# Adds CSS as an inline <style> block, usable inside the DSL body itself
|
|
1460
|
+
# (unlike `stylesheets:`, which is only an App.new kwarg) -- so a single
|
|
1461
|
+
# shared-DSL file can carry its own re-skin whether it's require'd
|
|
1462
|
+
# standalone or instance_eval'd by canvas-push (stream_weaver-9uk).
|
|
1463
|
+
# Canvas has no route to serve a referenced asset file across processes
|
|
1464
|
+
# the way local_asset/stylesheets: do, so inlining the raw CSS text is
|
|
1465
|
+
# the one mechanism that works in both contexts.
|
|
1466
|
+
#
|
|
1467
|
+
# @param source [String] a local path (resolved relative to the calling
|
|
1468
|
+
# script's directory, same rule as stylesheets:) or literal CSS text
|
|
1469
|
+
# if it doesn't resolve to a file
|
|
1470
|
+
def use_stylesheet(source)
|
|
1471
|
+
css = resolve_stylesheet_content(source)
|
|
1472
|
+
@inline_stylesheets << css unless @inline_stylesheets.include?(css)
|
|
1473
|
+
end
|
|
1474
|
+
|
|
1475
|
+
# In-DSL equivalents of App.new's `theme:`/`layout:` kwargs, for the same
|
|
1476
|
+
# reason use_stylesheet exists: a DSL file that gets instance_eval'd by
|
|
1477
|
+
# canvas-push/canvas-read never runs its own App.new, so a `theme:` kwarg
|
|
1478
|
+
# is simply not reachable in that path (stream_weaver-csf). Declaring
|
|
1479
|
+
# `use_theme :doc` in the DSL body makes the doc carry its own theme
|
|
1480
|
+
# everywhere it's rendered.
|
|
1481
|
+
#
|
|
1482
|
+
# These are new methods, NOT redefinitions of the `theme`/`layout`
|
|
1483
|
+
# attr_readers -- views.rb reads `app.theme`/`app.layout` in many places.
|
|
1484
|
+
def use_theme(name)
|
|
1485
|
+
@theme = validate_theme(name)
|
|
1486
|
+
end
|
|
1487
|
+
|
|
1488
|
+
# Caveat: canvas-read renders through Views::AppContentView, which does
|
|
1489
|
+
# not evaluate exclusive-layout render blocks or layout slots (those are
|
|
1490
|
+
# AppView-only). So in the reader `use_layout` only reaches body-class /
|
|
1491
|
+
# CSS-selector level layout, not full exclusive-layout fidelity.
|
|
1492
|
+
def use_layout(name)
|
|
1493
|
+
@layout = name.to_sym
|
|
1494
|
+
end
|
|
1495
|
+
|
|
1496
|
+
# =========================================
|
|
1497
|
+
# Layout components (Cabinet Control style)
|
|
1498
|
+
# =========================================
|
|
1499
|
+
|
|
1500
|
+
def app_shell(sidebar_width: "320px", sidebar_position: :right, gap: "1.5rem", **options, &block)
|
|
1501
|
+
component = Components::AppShell.new(
|
|
1502
|
+
sidebar_width: sidebar_width,
|
|
1503
|
+
sidebar_position: sidebar_position,
|
|
1504
|
+
gap: gap,
|
|
1505
|
+
**options
|
|
1506
|
+
)
|
|
1507
|
+
components << component
|
|
1508
|
+
|
|
1509
|
+
return component unless block
|
|
1510
|
+
|
|
1511
|
+
render_state.current_app_shell = component
|
|
1512
|
+
evaluate_dsl_block(block)
|
|
1513
|
+
render_state.current_app_shell = nil
|
|
1514
|
+
|
|
1515
|
+
component
|
|
1516
|
+
end
|
|
1517
|
+
|
|
1518
|
+
def main(**options, &block)
|
|
1519
|
+
raise "main can only be used inside an app_shell block" unless render_state.current_app_shell
|
|
1520
|
+
|
|
1521
|
+
parent_components = components
|
|
1522
|
+
self.components = []
|
|
1523
|
+
evaluate_dsl_block(block)
|
|
1524
|
+
render_state.current_app_shell.main_children = components
|
|
1525
|
+
self.components = parent_components
|
|
1526
|
+
end
|
|
1527
|
+
|
|
1528
|
+
def sidebar(header: nil, sticky: true, **options, &block)
|
|
1529
|
+
raise "sidebar can only be used inside an app_shell block" unless render_state.current_app_shell
|
|
1530
|
+
|
|
1531
|
+
sidebar_component = Components::Sidebar.new(header: header, sticky: sticky, **options)
|
|
1532
|
+
|
|
1533
|
+
parent_components = components
|
|
1534
|
+
self.components = []
|
|
1535
|
+
evaluate_dsl_block(block)
|
|
1536
|
+
sidebar_component.children = components
|
|
1537
|
+
self.components = parent_components
|
|
1538
|
+
|
|
1539
|
+
render_state.current_app_shell.sidebar_children << sidebar_component
|
|
1540
|
+
end
|
|
1541
|
+
|
|
1542
|
+
def expandable_card(key:, title:, subtitle: nil, badge_text: nil, badge_variant: :default,
|
|
1543
|
+
status: nil, initially_expanded: false, **options, &block)
|
|
1544
|
+
@_state[key] ||= initially_expanded
|
|
1545
|
+
|
|
1546
|
+
component = Components::ExpandableCard.new(
|
|
1547
|
+
key: key, title: title, subtitle: subtitle,
|
|
1548
|
+
badge_text: badge_text, badge_variant: badge_variant,
|
|
1549
|
+
status: status, initially_expanded: initially_expanded,
|
|
1550
|
+
**options
|
|
1551
|
+
)
|
|
1552
|
+
with_container(component, &block)
|
|
1553
|
+
end
|
|
1554
|
+
|
|
1555
|
+
private
|
|
1556
|
+
|
|
1557
|
+
def build_favicon_href
|
|
1558
|
+
v = @favicon_value
|
|
1559
|
+
if v.match?(/\A\p{Emoji_Presentation}\z/) || v.match?(/\A[\p{So}\p{Sk}]\z/)
|
|
1560
|
+
"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>#{v}</text></svg>"
|
|
1561
|
+
elsif File.exist?(v)
|
|
1562
|
+
require 'base64'
|
|
1563
|
+
mime = FAVICON_MIME_TYPES[File.extname(v).delete('.').downcase] || 'image/png'
|
|
1564
|
+
"data:#{mime};base64,#{Base64.strict_encode64(File.binread(v))}"
|
|
1565
|
+
else
|
|
1566
|
+
v
|
|
1567
|
+
end
|
|
1568
|
+
end
|
|
1569
|
+
|
|
1570
|
+
# Resolves a `stylesheets:` entry: a local file (relative to the script
|
|
1571
|
+
# dir, or absolute) becomes a served /sw-asset/ URL; anything else
|
|
1572
|
+
# (a real URL, or a string that just doesn't resolve to a local file)
|
|
1573
|
+
# passes through unchanged, same as always. Unlike local_asset, a
|
|
1574
|
+
# missing file is not an error here -- most stylesheets: entries are
|
|
1575
|
+
# ordinary hrefs, not local paths, so a non-match must stay silent.
|
|
1576
|
+
# A real local file outside the allowed directories still raises,
|
|
1577
|
+
# though -- that IS the traversal case this feature guards against.
|
|
1578
|
+
def resolve_stylesheet_href(href)
|
|
1579
|
+
abs_path = resolve_asset_path(href)
|
|
1580
|
+
return href unless abs_path
|
|
1581
|
+
|
|
1582
|
+
ensure_asset_path_allowed!(abs_path, href)
|
|
1583
|
+
"/sw-asset/#{ComponentAssets.register_file(abs_path)}/#{File.basename(abs_path)}"
|
|
1584
|
+
end
|
|
1585
|
+
|
|
1586
|
+
# Resolves a `use_stylesheet` argument to raw CSS text: a local file
|
|
1587
|
+
# (same resolution/traversal rules as stylesheets:) is read and its
|
|
1588
|
+
# content returned; anything else is treated as literal CSS. A source
|
|
1589
|
+
# containing a newline is assumed to already be CSS text and skips file
|
|
1590
|
+
# resolution entirely -- avoids stat-ing a multi-KB string as a path.
|
|
1591
|
+
def resolve_stylesheet_content(source)
|
|
1592
|
+
return source unless source.is_a?(String)
|
|
1593
|
+
return source if source.include?("\n")
|
|
1594
|
+
|
|
1595
|
+
abs_path = resolve_asset_path(source)
|
|
1596
|
+
return source unless abs_path
|
|
1597
|
+
|
|
1598
|
+
ensure_asset_path_allowed!(abs_path, source)
|
|
1599
|
+
File.read(abs_path)
|
|
1600
|
+
end
|
|
1601
|
+
|
|
1602
|
+
# @return [String, nil] absolute path if `path` resolves to a real local
|
|
1603
|
+
# file (as given, or relative to the script dir); nil for a URL or
|
|
1604
|
+
# anything that doesn't exist on disk
|
|
1605
|
+
def resolve_asset_path(path)
|
|
1606
|
+
return nil if path.to_s.match?(%r{\A[a-z][a-z0-9+.\-]*://}i)
|
|
1607
|
+
|
|
1608
|
+
[path, File.expand_path(path, @script_dir)].each do |candidate|
|
|
1609
|
+
expanded = File.expand_path(candidate)
|
|
1610
|
+
return expanded if File.exist?(expanded)
|
|
1611
|
+
end
|
|
1612
|
+
nil
|
|
1613
|
+
end
|
|
1614
|
+
|
|
1615
|
+
def ensure_asset_path_allowed!(abs_path, original)
|
|
1616
|
+
return if @allowed_asset_dirs.any? { |dir| abs_path == dir || abs_path.start_with?("#{dir}/") }
|
|
1617
|
+
|
|
1618
|
+
raise ArgumentError, "local_asset: #{original} resolves outside the app's script directory " \
|
|
1619
|
+
"(#{@script_dir}) or its assets_dirs: -- pass assets_dirs: [...] to App.new to allow it"
|
|
1620
|
+
end
|
|
1621
|
+
|
|
1622
|
+
# Captures children then appends the component (for item, column patterns)
|
|
1623
|
+
def capture_children_then_append(component, &block)
|
|
1624
|
+
parent_components = components
|
|
1625
|
+
self.components = []
|
|
1626
|
+
evaluate_dsl_block(block)
|
|
1627
|
+
component.children = components
|
|
1628
|
+
self.components = parent_components
|
|
1629
|
+
components << component
|
|
1630
|
+
end
|
|
1631
|
+
|
|
1632
|
+
# Initialize state for form fields, handling form/scope context
|
|
1633
|
+
def initialize_form_state(key, options, default_value, skip_if_exists: false)
|
|
1634
|
+
if render_state.form_context
|
|
1635
|
+
options[:form_context] = render_state.form_context
|
|
1636
|
+
elsif render_state.current_scope
|
|
1637
|
+
# A field inside a bare `scope` block (not a `form` block, e.g. a
|
|
1638
|
+
# retained filter panel) -- FAC-P3.1 handoff: the adapter needs to
|
|
1639
|
+
# know this so it can render a scope-nested name/x-model path instead
|
|
1640
|
+
# of a flat one, the same way :form_context already does for forms.
|
|
1641
|
+
options[:scope_name] = render_state.current_scope
|
|
1642
|
+
end
|
|
1643
|
+
|
|
1644
|
+
target_scope = render_state.form_context&.fetch(:name, nil) || render_state.current_scope
|
|
1645
|
+
if target_scope
|
|
1646
|
+
@_state[target_scope] ||= {}
|
|
1647
|
+
target = @_state[target_scope]
|
|
1648
|
+
else
|
|
1649
|
+
target = @_state
|
|
1650
|
+
end
|
|
1651
|
+
|
|
1652
|
+
if skip_if_exists
|
|
1653
|
+
target[key] = default_value unless target.key?(key)
|
|
1654
|
+
else
|
|
1655
|
+
target[key] ||= default_value
|
|
1656
|
+
end
|
|
1657
|
+
end
|
|
1658
|
+
|
|
1659
|
+
# form_for submit pipeline: symbolize -> coerce -> validate -> persist ->
|
|
1660
|
+
# flash + PRG transition (form-for.md §3-§5). A validation failure never
|
|
1661
|
+
# flashes/redirects -- it just populates errors_key so the same-request
|
|
1662
|
+
# re-render shows the summary Alert with the user's in-progress values
|
|
1663
|
+
# still in the scope (untouched here on the failure path).
|
|
1664
|
+
def form_for_submit(fields:, raw_values:, store:, record:, is_update:, errors_key:, defn:,
|
|
1665
|
+
on_success:, validate_proc:, singular:)
|
|
1666
|
+
form_values = raw_values.transform_keys(&:to_sym)
|
|
1667
|
+
coerced, errors = form_for_coerce(fields, form_values)
|
|
1668
|
+
|
|
1669
|
+
if validate_proc
|
|
1670
|
+
(instance_exec(coerced, &validate_proc) || {}).each do |field, msgs|
|
|
1671
|
+
(errors[field.to_sym] ||= []).concat(Array(msgs))
|
|
1672
|
+
end
|
|
1673
|
+
end
|
|
1674
|
+
|
|
1675
|
+
if errors.any?
|
|
1676
|
+
state[errors_key] = errors
|
|
1677
|
+
return
|
|
1678
|
+
end
|
|
1679
|
+
state[errors_key] = nil
|
|
1680
|
+
|
|
1681
|
+
if is_update
|
|
1682
|
+
store.update(record[:id], coerced)
|
|
1683
|
+
id = record[:id]
|
|
1684
|
+
else
|
|
1685
|
+
id = store.create(coerced)
|
|
1686
|
+
end
|
|
1687
|
+
|
|
1688
|
+
flash[:notice] = "#{singular.capitalize} #{is_update ? 'updated' : 'created'}."
|
|
1689
|
+
|
|
1690
|
+
if on_success
|
|
1691
|
+
instance_exec(id, &on_success)
|
|
1692
|
+
elsif defn
|
|
1693
|
+
sk = Resource::StateKeys
|
|
1694
|
+
state[sk::RESOURCE] = defn.name
|
|
1695
|
+
if defn.only.include?(:show)
|
|
1696
|
+
state[sk::ACTION] = :show
|
|
1697
|
+
state[sk::ID] = id
|
|
1698
|
+
else
|
|
1699
|
+
state[sk::ACTION] = :index
|
|
1700
|
+
end
|
|
1701
|
+
end
|
|
1702
|
+
end
|
|
1703
|
+
|
|
1704
|
+
def form_for_coerce(fields, form_values)
|
|
1705
|
+
errors = {}
|
|
1706
|
+
coerced = {}
|
|
1707
|
+
fields.each do |f|
|
|
1708
|
+
key = f.name
|
|
1709
|
+
raw = form_values[key]
|
|
1710
|
+
coerced[key] = case f.type
|
|
1711
|
+
when :integer then form_for_number(raw, key, errors, :integer, "must be a whole number")
|
|
1712
|
+
when :number then form_for_number(raw, key, errors, :number, "must be a number")
|
|
1713
|
+
else raw
|
|
1714
|
+
end
|
|
1715
|
+
end
|
|
1716
|
+
[coerced, errors]
|
|
1717
|
+
end
|
|
1718
|
+
|
|
1719
|
+
def form_for_number(raw, key, errors, kind, message)
|
|
1720
|
+
return nil if raw.nil? || raw == ""
|
|
1721
|
+
kind == :integer ? Integer(raw) : Float(raw)
|
|
1722
|
+
rescue ArgumentError, TypeError
|
|
1723
|
+
(errors[key] ||= []) << message
|
|
1724
|
+
raw
|
|
1725
|
+
end
|
|
1726
|
+
|
|
1727
|
+
# Internal, flat, unscoped state key (like _sw_resource/_sw_action/_sw_id)
|
|
1728
|
+
# that snapshots each non-retained scope's owning discriminant values, so
|
|
1729
|
+
# #apply_scope_lifecycle can detect a change between rebuilds. Not scoped
|
|
1730
|
+
# data itself -- routing/lifecycle metadata (FAC-P3.0a §8).
|
|
1731
|
+
SCOPE_WATCH_KEY = :_sw_scope_watch
|
|
1732
|
+
|
|
1733
|
+
def register_scope(name, kind:, retain:)
|
|
1734
|
+
return @scope_registry[name] if @scope_registry.key?(name)
|
|
1735
|
+
|
|
1736
|
+
existing_value = @_state[name]
|
|
1737
|
+
if @_state.key?(name) && !existing_value.is_a?(Hash)
|
|
1738
|
+
raise ArgumentError, "scope #{name.inspect} collides with an existing top-level state " \
|
|
1739
|
+
"key holding a #{existing_value.class} -- scopes are always Hash sub-states"
|
|
1740
|
+
end
|
|
1741
|
+
|
|
1742
|
+
@scope_registry[name] = { kind: kind, retain: retain }
|
|
1743
|
+
end
|
|
1744
|
+
|
|
1745
|
+
# Owning routing discriminant per scope kind (FAC-P3.0a §4). :app-kind
|
|
1746
|
+
# (and any unrecognized kind) has no discriminant -- it never auto-resets.
|
|
1747
|
+
def discriminant_keys_for(kind)
|
|
1748
|
+
sk = Resource::StateKeys
|
|
1749
|
+
case kind
|
|
1750
|
+
when :resource then [sk::RESOURCE, sk::ID]
|
|
1751
|
+
when :form then [sk::RESOURCE, sk::ACTION]
|
|
1752
|
+
when :fragment then [sk::RESOURCE, sk::ACTION, sk::ID, @route_key].compact
|
|
1753
|
+
end
|
|
1754
|
+
end
|
|
1755
|
+
|
|
1756
|
+
# Clears each non-retained scope's sub-hash when its owning routing
|
|
1757
|
+
# discriminant changed since the last rebuild (FAC-P3.0a §4) -- the
|
|
1758
|
+
# framework-enforced replacement for hand-nulled edit_* keys at every
|
|
1759
|
+
# navigation-away call site. Runs before the DSL block evaluates so
|
|
1760
|
+
# stale values are gone before any field re-initializes its default.
|
|
1761
|
+
def apply_scope_lifecycle
|
|
1762
|
+
return if @scope_registry.empty?
|
|
1763
|
+
|
|
1764
|
+
watch = @_state[SCOPE_WATCH_KEY] ||= {}
|
|
1765
|
+
@scope_registry.each do |name, meta|
|
|
1766
|
+
next if meta[:retain]
|
|
1767
|
+
keys = discriminant_keys_for(meta[:kind])
|
|
1768
|
+
next unless keys
|
|
1769
|
+
|
|
1770
|
+
signature = keys.map { |k| @_state[k] }
|
|
1771
|
+
@_state[name] = {} if watch.key?(name) && watch[name] != signature
|
|
1772
|
+
watch[name] = signature
|
|
1773
|
+
end
|
|
1774
|
+
end
|
|
1775
|
+
|
|
1776
|
+
def reserved_endpoint_path?(path)
|
|
1777
|
+
RESERVED_ENDPOINT_EXACT.include?(path) ||
|
|
1778
|
+
RESERVED_ENDPOINT_PREFIXES.any? { |prefix| path.start_with?(prefix) }
|
|
1779
|
+
end
|
|
1780
|
+
|
|
1781
|
+
# Auto-disambiguation (DisplayDSL#disambiguate_component_id) counts
|
|
1782
|
+
# occurrences per render pass, so the App's table lives on render_state --
|
|
1783
|
+
# a rebuild starts from a clean slate and #rewind_point can restore it.
|
|
1784
|
+
def seen_component_ids
|
|
1785
|
+
render_state.seen_component_ids
|
|
1786
|
+
end
|
|
1787
|
+
|
|
1788
|
+
def warned_duplicate_ids
|
|
1789
|
+
@_warned_duplicate_ids
|
|
1790
|
+
end
|
|
1791
|
+
|
|
1792
|
+
def strict_ids?
|
|
1793
|
+
@strict_ids
|
|
1794
|
+
end
|
|
1795
|
+
|
|
1796
|
+
def define_path_helpers(defn)
|
|
1797
|
+
s, p = defn.singular, defn.plural
|
|
1798
|
+
define_singleton_method(:"#{p}_path") { "/#{p}" }
|
|
1799
|
+
define_singleton_method(:"new_#{s}_path") { "/#{p}/new" }
|
|
1800
|
+
define_singleton_method(:"#{s}_path") { |rec| "/#{s}/#{CGI.escape(rec[:id].to_s)}" }
|
|
1801
|
+
define_singleton_method(:"edit_#{s}_path") { |rec| "/#{s}/#{CGI.escape(rec[:id].to_s)}/edit" }
|
|
1802
|
+
end
|
|
1803
|
+
|
|
1804
|
+
# Parse a string with {term} markers into Phrase and Term components
|
|
1805
|
+
def parse_lesson_string(content, glossary)
|
|
1806
|
+
children = []
|
|
1807
|
+
parts = content.split(/(\{[^}]+\})/)
|
|
1808
|
+
|
|
1809
|
+
parts.each do |part|
|
|
1810
|
+
if part.start_with?('{') && part.end_with?('}')
|
|
1811
|
+
term_key = part[1..-2]
|
|
1812
|
+
children << Components::Term.new(term_key)
|
|
1813
|
+
elsif !part.empty?
|
|
1814
|
+
children << Components::Phrase.new(part)
|
|
1815
|
+
end
|
|
1816
|
+
end
|
|
1817
|
+
|
|
1818
|
+
children
|
|
1819
|
+
end
|
|
1820
|
+
end
|
|
1821
|
+
end
|