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,1004 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StreamWeaver
|
|
4
|
+
# Shared display-only component DSL methods.
|
|
5
|
+
# Included by both App (which adds interactive components) and FeedBuilder.
|
|
6
|
+
module DisplayDSL
|
|
7
|
+
# =========================================
|
|
8
|
+
# Containers
|
|
9
|
+
# =========================================
|
|
10
|
+
|
|
11
|
+
def div(**options, &block)
|
|
12
|
+
with_container(Components::Div.new(**options), &block)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def app_header(title, subtitle: nil, variant: :dark, &block)
|
|
16
|
+
with_container(Components::AppHeader.new(title, subtitle: subtitle, variant: variant), &block)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def card(**options, &block)
|
|
20
|
+
with_container(Components::Card.new(**options), &block)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def card_header(content_or_options = nil, **options, &block)
|
|
24
|
+
component = if content_or_options.is_a?(String)
|
|
25
|
+
Components::CardHeader.new(content_or_options, **options)
|
|
26
|
+
else
|
|
27
|
+
opts = content_or_options.is_a?(Hash) ? content_or_options.merge(options) : options
|
|
28
|
+
Components::CardHeader.new(nil, **opts)
|
|
29
|
+
end
|
|
30
|
+
with_container(component, &block)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def card_body(**options, &block)
|
|
34
|
+
with_container(Components::CardBody.new(**options), &block)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def card_footer(**options, &block)
|
|
38
|
+
with_container(Components::CardFooter.new(**options), &block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def vstack(spacing: :md, align: nil, divider: false, **options, &block)
|
|
42
|
+
with_container(Components::VStack.new(spacing: spacing, align: align, divider: divider, **options), &block)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def hstack(spacing: :sm, align: nil, justify: nil, divider: false, **options, &block)
|
|
46
|
+
with_container(Components::HStack.new(spacing: spacing, align: align, justify: justify, divider: divider, **options), &block)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def grid(columns: 3, gap: :md, template: nil, template_areas: nil, template_rows: nil, template_columns: nil, **options, &block)
|
|
50
|
+
with_container(Components::Grid.new(columns: columns, gap: gap, template: template, template_areas: template_areas, template_rows: template_rows, template_columns: template_columns, **options), &block)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param n [Integer, nil] Optional column count, primarily used with
|
|
54
|
+
# items:. With no widths:, columns already divide evenly (flex 1 1 0
|
|
55
|
+
# per column) -- n alone is advisory. Combined with items:, it drives
|
|
56
|
+
# auto-distribution (see below).
|
|
57
|
+
# @param widths [Array<String>, nil] Explicit per-column widths -- still
|
|
58
|
+
# honored as before.
|
|
59
|
+
# @param items [Enumerable, nil] When given, items are split into n (or
|
|
60
|
+
# widths.size, or 2) equal chunks via each_slice and each chunk is
|
|
61
|
+
# wrapped in its own `column`; the block runs once per item (03
|
|
62
|
+
# honorable mention: presidential_mockup hand-computed
|
|
63
|
+
# `each_slice((size / 3.0).ceil)` for exactly this).
|
|
64
|
+
def columns(n = nil, widths: nil, items: nil, **options, &block)
|
|
65
|
+
return with_container(Components::Columns.new(widths: widths, **options), &block) unless items
|
|
66
|
+
|
|
67
|
+
n ||= widths&.size || 2
|
|
68
|
+
slice_size = [(items.size / n.to_f).ceil, 1].max
|
|
69
|
+
|
|
70
|
+
with_container(Components::Columns.new(widths: widths, **options)) do
|
|
71
|
+
items.each_slice(slice_size) do |slice|
|
|
72
|
+
column { slice.each { |item| block.call(item) } }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def column(**options, &block)
|
|
78
|
+
with_container(Components::Column.new(**options), &block)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def scroll_box(max_height: "300px", **options, &block)
|
|
82
|
+
with_container(Components::ScrollBox.new(max_height: max_height, **options), &block)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def grid_area(name, **options, &block)
|
|
86
|
+
with_container(Components::GridArea.new(name, **options), &block)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def sticky(top: nil, bottom: nil, left: nil, right: nil, z_index: nil, **options, &block)
|
|
90
|
+
with_container(Components::Sticky.new(top: top, bottom: bottom, left: left, right: right, z_index: z_index, **options), &block)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def overlay(z: 1, pointer_events: nil, **options, &block)
|
|
94
|
+
with_container(Components::Overlay.new(z: z, pointer_events: pointer_events, **options), &block)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fullbleed(**options, &block)
|
|
98
|
+
with_container(Components::Fullbleed.new(**options), &block)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def collapsible(label, expanded: false, subtitle: nil, badge_text: nil, badge_variant: :default, **options, &block)
|
|
102
|
+
with_container(Components::Collapsible.new(label, expanded: expanded, subtitle: subtitle, badge_text: badge_text, badge_variant: badge_variant, **options), &block)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Progressive disclosure without nested expandable_card hacks (03 gap #7):
|
|
106
|
+
# native <details>/<summary>, works with zero JS/Alpine.
|
|
107
|
+
def accordion(**options, &block)
|
|
108
|
+
with_container(Components::Accordion.new(**options), &block)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def section(title, open: false, **options, &block)
|
|
112
|
+
with_container(Components::AccordionSection.new(title, open: open, **options), &block)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Static Kanban board layout (03 gap #9: tyrion hand-built its "War Room"
|
|
116
|
+
# board from plain divs with inline styles). No drag-and-drop -- that's
|
|
117
|
+
# future work; this is the static-columns layout primitive real apps
|
|
118
|
+
# were hand-rolling.
|
|
119
|
+
def board(**options, &block)
|
|
120
|
+
with_container(Components::Board.new(**options), &block)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def lane(title, **options, &block)
|
|
124
|
+
with_container(Components::Lane.new(title, **options), &block)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def board_card(**options, &block)
|
|
128
|
+
with_container(Components::BoardCard.new(**options), &block)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def alert(variant: :info, title: nil, dismissible: false, **options, &block)
|
|
132
|
+
with_container(Components::Alert.new(variant: variant, title: title, dismissible: dismissible, **options), &block)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# =========================================
|
|
136
|
+
# Text / display
|
|
137
|
+
# =========================================
|
|
138
|
+
|
|
139
|
+
def text(content, tone: nil, **options)
|
|
140
|
+
components << Components::Text.new(content, tone: tone, **options)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def md(content, **options)
|
|
144
|
+
components << Components::Markdown.new(content, **options)
|
|
145
|
+
end
|
|
146
|
+
alias_method :markdown, :md
|
|
147
|
+
|
|
148
|
+
(1..6).each do |level|
|
|
149
|
+
define_method(:"header#{level}") { |content, **options| components << Components::Header.new(content, level: level, **options) }
|
|
150
|
+
end
|
|
151
|
+
alias_method :header, :header2
|
|
152
|
+
|
|
153
|
+
def phrase(content, **options)
|
|
154
|
+
components << Components::Phrase.new(content, **options)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# =========================================
|
|
158
|
+
# Dashboard display
|
|
159
|
+
# =========================================
|
|
160
|
+
|
|
161
|
+
def stat_display(value:, label:, color: :blue, size: :md, **options)
|
|
162
|
+
components << Components::StatDisplay.new(value: value, label: label, color: color, size: size, **options)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def badge(text, variant: :default, size: :sm, **options)
|
|
166
|
+
components << Components::Badge.new(text, variant: variant, size: size, **options)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def status_dot(status: :gray, pulse: false, size: :md, **options)
|
|
170
|
+
components << Components::StatusDot.new(status: status, pulse: pulse, size: size, **options)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def type_tag(type_name, color: nil, **options)
|
|
174
|
+
components << Components::TypeTag.new(type_name, color: color, **options)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def pulse_indicator(color: :green, label: nil, **options)
|
|
178
|
+
components << Components::PulseIndicator.new(color: color, label: label, **options)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def activity_item(time:, title:, summary: nil, type: nil, **options)
|
|
182
|
+
components << Components::ActivityItem.new(time: time, title: title, summary: summary, type: type, **options)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def timeline_event(index:, event_type:, timestamp:, label:, fields: {}, expanded: false, **options)
|
|
186
|
+
components << Components::TimelineEvent.new(
|
|
187
|
+
index: index, event_type: event_type, timestamp: timestamp,
|
|
188
|
+
label: label, fields: fields, expanded: expanded, **options
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def priority_item(priority: :normal, title:, description: nil, meta_left: nil, meta_right: nil, **options, &block)
|
|
193
|
+
component = Components::PriorityItem.new(
|
|
194
|
+
priority: priority, title: title, description: description,
|
|
195
|
+
meta_left: meta_left, meta_right: meta_right, **options
|
|
196
|
+
)
|
|
197
|
+
with_container(component, &block)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def progress_bar(value:, max: 100, variant: :default, show_label: false, animated: false, **options)
|
|
201
|
+
components << Components::ProgressBar.new(value: value, max: max, variant: variant, show_label: show_label, animated: animated, **options)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def spinner(size: :md, label: nil, **options)
|
|
205
|
+
components << Components::Spinner.new(size: size, label: label, **options)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def score_table(scores:, **options)
|
|
209
|
+
components << Components::ScoreTable.new(scores: scores, **options)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def table(positional_data = nil, data: nil, headers: nil, rows: nil, file: nil, path: nil, **options, &block)
|
|
213
|
+
actual_data = positional_data || data
|
|
214
|
+
table_component = Components::Table.new(
|
|
215
|
+
actual_data, headers: headers, rows: rows, file: file, path: path, **options, &block
|
|
216
|
+
)
|
|
217
|
+
# A deterministic (not SecureRandom) dom id, keyed off the enclosing
|
|
218
|
+
# fragment id plus a per-render occurrence counter, both of which
|
|
219
|
+
# replay identically across rebuilds of the same DSL for the same
|
|
220
|
+
# control-flow path. This is what lets InteractionRunner's row-granular
|
|
221
|
+
# narrowing (stream_weaver-95k) address a `<tr>` rendered by a prior
|
|
222
|
+
# rebuild -- a random id would never match between requests. Only
|
|
223
|
+
# tables using the column DSL have row identity worth addressing.
|
|
224
|
+
if table_component.columns.any?
|
|
225
|
+
render_state.table_counter += 1
|
|
226
|
+
fragment_part = (render_state.fragment_stack.last || "root").downcase.gsub(/[^a-z0-9]+/, "-")
|
|
227
|
+
table_component.dom_id = "table_#{fragment_part}-#{render_state.table_counter}"
|
|
228
|
+
end
|
|
229
|
+
# Resolve cells now (not lazily at render time) so any buttons a cell
|
|
230
|
+
# builds already exist in the component tree for dispatch to find --
|
|
231
|
+
# button-action requests never call #render (FAC-P2.1 decision 4).
|
|
232
|
+
table_component.resolve!(self, state, fragment: render_state.fragment_stack.last)
|
|
233
|
+
components << table_component
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def status_badge(status, reasoning)
|
|
237
|
+
components << Components::StatusBadge.new(status, reasoning)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def external_link_button(label, url:, submit: false)
|
|
241
|
+
components << Components::ExternalLinkButton.new(label, url: url, submit: submit)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def link_to(label, href:, **options)
|
|
245
|
+
components << Components::Link.new(label, href: href, **options)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def navbar(**options, &block)
|
|
249
|
+
with_container(Components::Navbar.new(**options), &block)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def topbar(**options, &block)
|
|
253
|
+
with_container(Components::Topbar.new(**options), &block)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def nav_item(label, href: nil, active: false, **options)
|
|
257
|
+
components << Components::NavItem.new(label, href: href, active: active, **options)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# =========================================
|
|
261
|
+
# Visual skills content blocks
|
|
262
|
+
# =========================================
|
|
263
|
+
|
|
264
|
+
# Render a syntax-highlighted code block with optional file header.
|
|
265
|
+
# Uses Prism.js CDN for highlighting (loaded lazily).
|
|
266
|
+
#
|
|
267
|
+
# @param code [String] The source code to display
|
|
268
|
+
# @param lang [String, nil] Language for Prism.js highlighting (e.g. "ruby", "javascript")
|
|
269
|
+
# @param file [String, nil] File path to show in a header bar above the code
|
|
270
|
+
# @param truncate [Integer, nil] Max lines to show (with truncation indicator)
|
|
271
|
+
# @param scroll [Boolean] Enable scrolling for long code (default: true)
|
|
272
|
+
# @param copy [Boolean] Show a copy-to-clipboard affordance in the header (default: false)
|
|
273
|
+
#
|
|
274
|
+
# @example Basic
|
|
275
|
+
# code_block("puts 'hi'", lang: "ruby")
|
|
276
|
+
#
|
|
277
|
+
# @example With file header and truncation
|
|
278
|
+
# code_block(code, file: "src/app.rb", lang: "ruby", truncate: 10)
|
|
279
|
+
#
|
|
280
|
+
# @example With copy-to-clipboard affordance
|
|
281
|
+
# code_block(code, lang: "ruby", copy: true)
|
|
282
|
+
def code_block(code, copy: false, **options)
|
|
283
|
+
components << Components::CodeBlock.new(code, copy: copy, **options)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Render an image with optional caption.
|
|
287
|
+
# Supports local files, URLs, and base64 data URI conversion for export.
|
|
288
|
+
#
|
|
289
|
+
# @param src [String] Image source (URL or file path)
|
|
290
|
+
# @param alt [String] Alt text for accessibility
|
|
291
|
+
# @param caption [String, nil] Caption text below the image
|
|
292
|
+
# @param base64 [Boolean] Convert local file to base64 data URI (default: false)
|
|
293
|
+
#
|
|
294
|
+
# @example Basic
|
|
295
|
+
# image_block("photo.png", alt: "A photo")
|
|
296
|
+
#
|
|
297
|
+
# @example With caption
|
|
298
|
+
# image_block("diagram.svg", caption: "Figure 1: Architecture")
|
|
299
|
+
def image_block(src, **options)
|
|
300
|
+
components << Components::ImageBlock.new(src, **options)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Render a Mermaid diagram.
|
|
304
|
+
# CDN loads lazily -- only when this method is called.
|
|
305
|
+
#
|
|
306
|
+
# @param code [String] Mermaid diagram definition
|
|
307
|
+
# @param zoom [Boolean] Enable zoom/pan controls (default: false)
|
|
308
|
+
# @param compact [Boolean] Compact mode for card embedding (default: false)
|
|
309
|
+
# @param layout [Symbol] Layout engine (:default or :elk)
|
|
310
|
+
# @param theme_vars [Hash, nil] Per-block Mermaid themeVariables overrides
|
|
311
|
+
# @param options [Hash] Additional options
|
|
312
|
+
#
|
|
313
|
+
# @example
|
|
314
|
+
# mermaid("graph LR; A-->B")
|
|
315
|
+
# mermaid("graph TD; A-->B-->C", zoom: true)
|
|
316
|
+
# mermaid("graph LR; A-->B", compact: true)
|
|
317
|
+
# mermaid("graph TD; A-->B", layout: :elk)
|
|
318
|
+
def mermaid(code, zoom: false, compact: false, layout: :default, theme_vars: nil, **options)
|
|
319
|
+
components << Components::Mermaid.new(
|
|
320
|
+
code, zoom: zoom, compact: compact, layout: layout,
|
|
321
|
+
theme_vars: theme_vars, **options
|
|
322
|
+
)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# =========================================
|
|
326
|
+
# Visual explainer components (T12)
|
|
327
|
+
# =========================================
|
|
328
|
+
|
|
329
|
+
# Render a horizontal pipeline step flow with arrow connectors.
|
|
330
|
+
# Steps are color-coded by status: complete (green), active (blue), pending (gray).
|
|
331
|
+
# Responsive: collapses to vertical layout on narrow screens.
|
|
332
|
+
#
|
|
333
|
+
# @param steps [Array<Hash>] Steps with :label, :description (optional), :status (:complete, :active, :pending)
|
|
334
|
+
#
|
|
335
|
+
# @example
|
|
336
|
+
# pipeline steps: [
|
|
337
|
+
# { label: "Build", status: :complete },
|
|
338
|
+
# { label: "Test", status: :active },
|
|
339
|
+
# { label: "Deploy", status: :pending }
|
|
340
|
+
# ]
|
|
341
|
+
def pipeline(steps:, **options)
|
|
342
|
+
components << Components::Pipeline.new(steps: steps, **options)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Render a KPI dashboard with auto-fit grid of metric cards.
|
|
346
|
+
# Each card shows a large value, label, optional trend arrow, and optional color.
|
|
347
|
+
#
|
|
348
|
+
# @param metrics [Array<Hash>] Metrics with :value, :label, :color (optional), :trend (optional :up/:down/:flat)
|
|
349
|
+
#
|
|
350
|
+
# @example
|
|
351
|
+
# kpi_dashboard metrics: [
|
|
352
|
+
# { value: "99.9%", label: "Uptime", color: :green, trend: :up },
|
|
353
|
+
# { value: "42ms", label: "Latency", trend: :down }
|
|
354
|
+
# ]
|
|
355
|
+
def kpi_dashboard(metrics:, **options)
|
|
356
|
+
components << Components::KpiDashboard.new(metrics: metrics, **options)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Render a Chart.js chart. CDN loads lazily (only when this method is called).
|
|
360
|
+
# Supports: :bar, :line, :pie, :doughnut, :radar.
|
|
361
|
+
# Dark mode aware: reads --sw-text and --sw-border for grid/text colors.
|
|
362
|
+
#
|
|
363
|
+
# @param type [Symbol] Chart type
|
|
364
|
+
# @param data [Hash] Chart.js data config (labels, datasets)
|
|
365
|
+
# @param options [Hash] Chart.js options config
|
|
366
|
+
# @param height [Integer] Canvas height in pixels (default: 300)
|
|
367
|
+
#
|
|
368
|
+
# @example
|
|
369
|
+
# chart type: :bar, data: { labels: ["A", "B"], datasets: [{ data: [1, 2] }] }
|
|
370
|
+
def chart(type:, data:, options: {}, height: 300, **extra)
|
|
371
|
+
components << Components::Chart.new(type: type, data: data, options: options, height: height, **extra)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# =========================================
|
|
375
|
+
# Keyboard shortcuts (visual skills)
|
|
376
|
+
# =========================================
|
|
377
|
+
|
|
378
|
+
# Register keyboard shortcuts with context-aware suppression.
|
|
379
|
+
# Non-visual component -- emits a <script> block.
|
|
380
|
+
# "mod" maps to Cmd on Mac, Ctrl elsewhere.
|
|
381
|
+
#
|
|
382
|
+
# @yield [kb] Builder for registering shortcuts
|
|
383
|
+
# @yieldparam kb [Components::KeyboardShortcuts] The shortcut registry
|
|
384
|
+
#
|
|
385
|
+
# @example
|
|
386
|
+
# keyboard_shortcuts do |kb|
|
|
387
|
+
# kb.on "mod+s", context: :global, js_action: "alert('save')"
|
|
388
|
+
# kb.on "ArrowRight", context: :navigation, js_action: "console.log('next')"
|
|
389
|
+
# end
|
|
390
|
+
def keyboard_shortcuts(**options, &block)
|
|
391
|
+
component = Components::KeyboardShortcuts.new(**options)
|
|
392
|
+
yield component if block
|
|
393
|
+
components << component
|
|
394
|
+
component
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# =========================================
|
|
398
|
+
# Slide container (visual skills)
|
|
399
|
+
# =========================================
|
|
400
|
+
|
|
401
|
+
# Create a slide container for navigable slides.
|
|
402
|
+
# Supports :swap (one visible at a time) and :scroll_snap (CSS scroll-snap) modes.
|
|
403
|
+
#
|
|
404
|
+
# @param mode [Symbol] Display mode (:swap or :scroll_snap)
|
|
405
|
+
# @param progress_bar [Boolean] Show progress bar (default: true)
|
|
406
|
+
# @param keyboard_nav [Boolean] Enable arrow key navigation (default: true)
|
|
407
|
+
# @param nav_dots [Boolean] Show navigation dots (default: false)
|
|
408
|
+
# @param counter [Boolean] Show slide counter (default: false)
|
|
409
|
+
# @param options [Hash] Additional options
|
|
410
|
+
#
|
|
411
|
+
# @example Swap mode
|
|
412
|
+
# slide_container mode: :swap do
|
|
413
|
+
# slide "intro", "Introduction" do
|
|
414
|
+
# text "Welcome"
|
|
415
|
+
# end
|
|
416
|
+
# slide "arch", "Architecture" do
|
|
417
|
+
# text "Design"
|
|
418
|
+
# end
|
|
419
|
+
# end
|
|
420
|
+
#
|
|
421
|
+
# @example Scroll-snap mode
|
|
422
|
+
# slide_container mode: :scroll_snap, nav_dots: true do
|
|
423
|
+
# slide "s1" do
|
|
424
|
+
# text "Slide 1"
|
|
425
|
+
# end
|
|
426
|
+
# end
|
|
427
|
+
def slide_container(**options, &block)
|
|
428
|
+
with_container(Components::SlideContainer.new(**options), &block)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Create a slide within a slide_container.
|
|
432
|
+
#
|
|
433
|
+
# @param id [String] Unique slide identifier
|
|
434
|
+
# @param title [String, nil] Optional slide title
|
|
435
|
+
# @param type [Symbol] Slide type (:content, :title)
|
|
436
|
+
# @param options [Hash] Additional options
|
|
437
|
+
#
|
|
438
|
+
# @example
|
|
439
|
+
# slide "intro", "Introduction" do
|
|
440
|
+
# text "Welcome to the presentation"
|
|
441
|
+
# end
|
|
442
|
+
def slide(id, title = nil, **options, &block)
|
|
443
|
+
with_container(Components::Slide.new(id, title, **options), &block)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# =========================================
|
|
447
|
+
# Theme toggle (visual skills auto-mode)
|
|
448
|
+
# =========================================
|
|
449
|
+
|
|
450
|
+
# Add a dark/light/auto mode toggle button.
|
|
451
|
+
# Auto-mode follows OS prefers-color-scheme via CSS media query + JS listener.
|
|
452
|
+
# Preference persists in localStorage.
|
|
453
|
+
#
|
|
454
|
+
# @param mode [Symbol] Initial mode (:dark, :light, :auto)
|
|
455
|
+
# @param hotkey [String, nil] Keyboard shortcut (e.g. "mod+shift+l")
|
|
456
|
+
# @param persist [Boolean] Persist preference in localStorage (default: true)
|
|
457
|
+
#
|
|
458
|
+
# @example
|
|
459
|
+
# theme_toggle mode: :auto
|
|
460
|
+
# theme_toggle mode: :auto, hotkey: "mod+shift+l"
|
|
461
|
+
def theme_toggle(mode: :auto, hotkey: nil, persist: true, **options)
|
|
462
|
+
components << Components::ThemeToggle.new(mode: mode, hotkey: hotkey, persist: persist, **options)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# =========================================
|
|
466
|
+
# Theme presets (visual skills T15)
|
|
467
|
+
# =========================================
|
|
468
|
+
|
|
469
|
+
# Apply a curated theme preset.
|
|
470
|
+
# Injects Google Fonts <link> and CSS custom property overrides
|
|
471
|
+
# for the selected preset. Affects both light and dark modes.
|
|
472
|
+
#
|
|
473
|
+
# Available presets: :editorial, :technical, :warm, :minimal, :terminal
|
|
474
|
+
#
|
|
475
|
+
# @param name [Symbol] Preset name
|
|
476
|
+
#
|
|
477
|
+
# @example
|
|
478
|
+
# theme_preset :editorial # Magazine serif + terracotta
|
|
479
|
+
# theme_preset :warm # Friendly rounded + amber
|
|
480
|
+
# theme_preset :terminal # Monospace retro hacker
|
|
481
|
+
def theme_preset(name, **options)
|
|
482
|
+
components << Components::ThemePreset.new(name, **options)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# =========================================
|
|
486
|
+
# Explainer components (visual skills T11)
|
|
487
|
+
# =========================================
|
|
488
|
+
|
|
489
|
+
# Render a sticky sidebar table-of-contents with scroll spy.
|
|
490
|
+
# Desktop (>=1000px): sticky 170px sidebar with IntersectionObserver.
|
|
491
|
+
# Mobile (<1000px): horizontal scrollable sticky bar.
|
|
492
|
+
#
|
|
493
|
+
# @param sections [Array<Hash>] Array of { id:, label: } hashes
|
|
494
|
+
# @param options [Hash] Additional options
|
|
495
|
+
#
|
|
496
|
+
# @example
|
|
497
|
+
# sidebar_toc sections: [
|
|
498
|
+
# { id: "summary", label: "Executive Summary" },
|
|
499
|
+
# { id: "architecture", label: "Architecture" }
|
|
500
|
+
# ]
|
|
501
|
+
def sidebar_toc(sections:, **options)
|
|
502
|
+
components << Components::SidebarToc.new(sections: sections, **options)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Render a document-level header with eyebrow, serif title, and meta pills.
|
|
506
|
+
# Used to open PRDs, reports, and long-form explainer documents.
|
|
507
|
+
#
|
|
508
|
+
# Pills are an array of mixed items:
|
|
509
|
+
# - String → plain meta text (e.g., "June 25, 2026")
|
|
510
|
+
# - Hash { text:, variant: } → colored pill — variant: :default (blue), :warn (amber), :good (green)
|
|
511
|
+
#
|
|
512
|
+
# @param eyebrow [String, nil] Small all-caps label above the title
|
|
513
|
+
# @param title [String] Main document title (rendered in serif)
|
|
514
|
+
# @param pills [Array] Status pills and meta text items
|
|
515
|
+
#
|
|
516
|
+
# @example
|
|
517
|
+
# doc_header(
|
|
518
|
+
# eyebrow: "cultiv-ai · Personal OS",
|
|
519
|
+
# title: "Calendar-Driven Travel State",
|
|
520
|
+
# pills: [{ text: "Draft" }, "June 25, 2026", "Author: Forrest Chang"]
|
|
521
|
+
# )
|
|
522
|
+
def doc_header(title:, eyebrow: nil, pills: [], **options)
|
|
523
|
+
components << Components::DocHeader.new(title: title, eyebrow: eyebrow, pills: pills, **options)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Render a numbered section eyebrow + h2 heading.
|
|
527
|
+
# The eyebrow is a monospace number with an extending decorative line.
|
|
528
|
+
# Pass id: to make the section linkable from sidebar_toc.
|
|
529
|
+
#
|
|
530
|
+
# @param number [String] Section number label, e.g., "01"
|
|
531
|
+
# @param title [String] Section heading text
|
|
532
|
+
# @param id [String, nil] HTML id for TOC anchor linking
|
|
533
|
+
#
|
|
534
|
+
# @example
|
|
535
|
+
# doc_section_header "01", "Problem Statement", id: "problem"
|
|
536
|
+
def doc_section_header(number, title, id: nil, **options)
|
|
537
|
+
components << Components::DocSectionHeader.new(number, title, id: id, **options)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Render a non-dismissible callout box with colored left border.
|
|
541
|
+
# Unlike Alert, Callout is static -- no dismiss button.
|
|
542
|
+
#
|
|
543
|
+
# @param content [String, nil] Optional banner text, rendered as the sole
|
|
544
|
+
# (or first) child -- sugar for the common one-line-banner case (03
|
|
545
|
+
# honorable mention: raw hex/padding divs standing in for a banner).
|
|
546
|
+
# @param variant [Symbol] Callout type (:info, :warning, :success, :error, :tip)
|
|
547
|
+
# @param tone [Symbol, nil] Alias for variant: (kept consistent with `text`'s
|
|
548
|
+
# tone: vocabulary); wins over variant: when both are given.
|
|
549
|
+
# @param title [String, nil] Optional callout title
|
|
550
|
+
# @param options [Hash] Additional options
|
|
551
|
+
# @yield Block of child components rendered inside the callout body
|
|
552
|
+
#
|
|
553
|
+
# @example
|
|
554
|
+
# callout(variant: :warning, title: "Caution") do
|
|
555
|
+
# text "Be careful with this API."
|
|
556
|
+
# end
|
|
557
|
+
#
|
|
558
|
+
# @example
|
|
559
|
+
# callout("Saved successfully.", tone: :success)
|
|
560
|
+
def callout(content = nil, variant: :info, tone: nil, title: nil, **options, &block)
|
|
561
|
+
component = Components::Callout.new(variant: tone || variant, title: title, **options)
|
|
562
|
+
components << component
|
|
563
|
+
|
|
564
|
+
parent_components = components
|
|
565
|
+
self.components = []
|
|
566
|
+
text(content) if content
|
|
567
|
+
evaluate_dsl_block(block) if block
|
|
568
|
+
component.children = components
|
|
569
|
+
self.components = parent_components
|
|
570
|
+
component
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# Render a file-path-to-rationale mapping for pre-flight planning.
|
|
574
|
+
#
|
|
575
|
+
# @param files [Array<Hash>] Array of {path:, note:} hashes
|
|
576
|
+
# @example
|
|
577
|
+
# implementation_map(files: [
|
|
578
|
+
# { path: "lib/foo.rb", note: "Add the new method" }
|
|
579
|
+
# ])
|
|
580
|
+
def implementation_map(files: [], **options)
|
|
581
|
+
components << Components::ImplementationMap.new(files: files, **options)
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Render an architecture decision block with labeled option cards.
|
|
585
|
+
# The block is evaluated in DecisionBuilder scope — only `option(...)` is valid inside it.
|
|
586
|
+
# Other DSL helpers (text, mermaid, etc.) are not available inside the block.
|
|
587
|
+
#
|
|
588
|
+
# @param question [String] The decision question shown as a heading
|
|
589
|
+
# @yield Block of `option(id:, label:, detail:, recommended:)` calls
|
|
590
|
+
#
|
|
591
|
+
# @example
|
|
592
|
+
# decision(question: "Which database?") do
|
|
593
|
+
# option(id: :pg, label: "PostgreSQL", detail: "Full ACID", recommended: true)
|
|
594
|
+
# option(id: :sqlite, label: "SQLite", detail: "Zero-dep")
|
|
595
|
+
# end
|
|
596
|
+
def decision(question:, **options, &block)
|
|
597
|
+
component = Components::Decision.new(question: question, **options)
|
|
598
|
+
components << component
|
|
599
|
+
if block
|
|
600
|
+
builder = DecisionBuilder.new(component)
|
|
601
|
+
builder.instance_eval(&block)
|
|
602
|
+
end
|
|
603
|
+
component
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Builder context for decision component's option calls
|
|
607
|
+
class DecisionBuilder
|
|
608
|
+
def initialize(component)
|
|
609
|
+
@component = component
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def option(id:, label:, detail:, recommended: false)
|
|
613
|
+
@component.add_option(id: id, label: label, detail: detail, recommended: recommended)
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Render a wireframe surface block with raw HTML.
|
|
618
|
+
# CSS tokens (--wf-ink, --wf-muted, etc.) and helper classes
|
|
619
|
+
# (.wf-card, .wf-pill, .wf-muted, button.primary) are automatically
|
|
620
|
+
# scoped to .sw-wireframe-surface.
|
|
621
|
+
#
|
|
622
|
+
# @param html [String] Raw HTML fragment to render inside the wireframe surface
|
|
623
|
+
# @param surface [String] Surface type: browser, desktop, mobile, popover, panel
|
|
624
|
+
#
|
|
625
|
+
# @example
|
|
626
|
+
# wireframe_block(html: '<h1>Login</h1><button class="primary">Sign in</button>')
|
|
627
|
+
def wireframe_block(html: "", surface: "browser", **options)
|
|
628
|
+
components << Components::WireframeBlock.new(html: html, surface: surface, **options)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Render an HTML mockup wrapped in device chrome matching the surface type.
|
|
632
|
+
# Accepts a block that returns the HTML string to display inside the chrome frame.
|
|
633
|
+
# CSS tokens (--wf-ink, --wf-muted, etc.) and helper classes apply inside the frame.
|
|
634
|
+
#
|
|
635
|
+
# @param surface [Symbol, String] Device surface: :browser, :desktop, :mobile, :phone,
|
|
636
|
+
# :tablet, :popover, :card, :widget, :panel
|
|
637
|
+
#
|
|
638
|
+
# @example
|
|
639
|
+
# wireframe(surface: :browser) do
|
|
640
|
+
# "<h1>Login</h1><button class='primary'>Sign in</button>"
|
|
641
|
+
# end
|
|
642
|
+
def wireframe(surface: :browser, **options, &block)
|
|
643
|
+
html = block ? instance_exec(&block).to_s : ""
|
|
644
|
+
components << Components::Wireframe.new(html: html, surface: surface, **options)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Render a code block with line-number-pinned annotation bubbles in a side panel.
|
|
648
|
+
# Uses Prism.js for syntax highlighting (loaded once per page).
|
|
649
|
+
# Layout: code pane left, annotation panel right — annotations aligned to their target lines.
|
|
650
|
+
# Annotated lines receive a subtle left-border highlight. Close annotations stack without overlap.
|
|
651
|
+
# See +Components::AnnotatedCode+ for behaviour notes on per-line highlighting limits.
|
|
652
|
+
def annotated_code(language: nil, annotations: [], **options, &block)
|
|
653
|
+
component = Components::AnnotatedCode.new(language: language, annotations: annotations, **options)
|
|
654
|
+
component.code = block ? instance_exec(&block) : ""
|
|
655
|
+
components << component
|
|
656
|
+
component
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def diff(language: nil, **options, &block)
|
|
660
|
+
component = Components::DiffBlock.new(language: language, **options)
|
|
661
|
+
components << component
|
|
662
|
+
return component unless block
|
|
663
|
+
|
|
664
|
+
builder = DiffBlockBuilder.new
|
|
665
|
+
builder.instance_eval(&block)
|
|
666
|
+
component.before_code = builder.before_code
|
|
667
|
+
component.after_code = builder.after_code
|
|
668
|
+
component
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
class DiffBlockBuilder
|
|
672
|
+
attr_reader :before_code, :after_code
|
|
673
|
+
|
|
674
|
+
def initialize
|
|
675
|
+
@before_code = ""
|
|
676
|
+
@after_code = ""
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
def before(&block)
|
|
680
|
+
@before_code = block.call.to_s
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def after(&block)
|
|
684
|
+
@after_code = block.call.to_s
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def api_endpoint(method:, path:, description: nil, params: [], response: {}, **options)
|
|
689
|
+
component = Components::ApiEndpoint.new(
|
|
690
|
+
method: method, path: path, description: description,
|
|
691
|
+
params: params, response: response, **options
|
|
692
|
+
)
|
|
693
|
+
components << component
|
|
694
|
+
component
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# Render side-by-side comparison panels.
|
|
698
|
+
# Use `before { ... }` and `after { ... }` named blocks inside
|
|
699
|
+
# to populate each panel.
|
|
700
|
+
#
|
|
701
|
+
# @param before_label [String] Label for the "before" panel (default: "Before")
|
|
702
|
+
# @param after_label [String] Label for the "after" panel (default: "After")
|
|
703
|
+
# @param options [Hash] Additional options
|
|
704
|
+
# @yield Block containing `before { ... }` and `after { ... }` calls
|
|
705
|
+
#
|
|
706
|
+
# @example
|
|
707
|
+
# comparison(before_label: "Old", after_label: "New") do
|
|
708
|
+
# before { text "Version 1" }
|
|
709
|
+
# after { text "Version 2" }
|
|
710
|
+
# end
|
|
711
|
+
def comparison(before_label: "Before", after_label: "After", **options, &block)
|
|
712
|
+
component = Components::Comparison.new(
|
|
713
|
+
before_label: before_label, after_label: after_label, **options
|
|
714
|
+
)
|
|
715
|
+
components << component
|
|
716
|
+
return component unless block
|
|
717
|
+
|
|
718
|
+
# Capture before/after blocks using a builder context
|
|
719
|
+
builder = ComparisonBuilder.new(self)
|
|
720
|
+
builder.instance_eval(&block)
|
|
721
|
+
component.before_children = builder.before_components
|
|
722
|
+
component.after_children = builder.after_components
|
|
723
|
+
component
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# Builder context for comparison component's before/after blocks
|
|
727
|
+
class ComparisonBuilder
|
|
728
|
+
attr_reader :before_components, :after_components
|
|
729
|
+
|
|
730
|
+
def initialize(dsl)
|
|
731
|
+
@dsl = dsl
|
|
732
|
+
@before_components = []
|
|
733
|
+
@after_components = []
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def before(&block)
|
|
737
|
+
@before_components = capture_components(&block)
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def after(&block)
|
|
741
|
+
@after_components = capture_components(&block)
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
private
|
|
745
|
+
|
|
746
|
+
def capture_components(&block)
|
|
747
|
+
parent = @dsl.components
|
|
748
|
+
@dsl.components = []
|
|
749
|
+
@dsl.instance_eval(&block)
|
|
750
|
+
captured = @dsl.components
|
|
751
|
+
@dsl.components = parent
|
|
752
|
+
captured
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# =========================================
|
|
757
|
+
# CSS-only helpers (T13)
|
|
758
|
+
# =========================================
|
|
759
|
+
|
|
760
|
+
# Render a hero section with accent background tint and large padding.
|
|
761
|
+
#
|
|
762
|
+
# @param options [Hash] Additional options
|
|
763
|
+
# @yield Block of child components rendered inside the hero
|
|
764
|
+
#
|
|
765
|
+
# @example
|
|
766
|
+
# hero { header1 "Welcome" }
|
|
767
|
+
def hero(**options, &block)
|
|
768
|
+
with_container(Components::Hero.new(**options), &block)
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# Render a reading-optimized prose container (max-width ~65ch).
|
|
772
|
+
#
|
|
773
|
+
# @param dropcap [Boolean] Whether to enable dropcap on first paragraph (default: false)
|
|
774
|
+
# @param options [Hash] Additional options
|
|
775
|
+
# @yield Block of child components rendered inside the prose container
|
|
776
|
+
#
|
|
777
|
+
# @example
|
|
778
|
+
# prose(dropcap: true) { md "Long form text..." }
|
|
779
|
+
def prose(dropcap: false, **options, &block)
|
|
780
|
+
with_container(Components::Prose.new(dropcap: dropcap, **options), &block)
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
# Render a styled pullquote with optional attribution.
|
|
784
|
+
#
|
|
785
|
+
# @param text [String] The quote text
|
|
786
|
+
# @param attribution [String, nil] Attribution (e.g., "Author Name")
|
|
787
|
+
#
|
|
788
|
+
# @example
|
|
789
|
+
# pullquote "Design is not just what it looks like.", attribution: "Steve Jobs"
|
|
790
|
+
def pullquote(text, attribution: nil, **options)
|
|
791
|
+
components << Components::Pullquote.new(text, attribution: attribution, **options)
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# Render a monospace file tree display with color-coded status.
|
|
795
|
+
# Lines ending with [new], [modified], or [deleted] are color-coded.
|
|
796
|
+
#
|
|
797
|
+
# @param tree_text [String] Multi-line file tree text
|
|
798
|
+
#
|
|
799
|
+
# @example
|
|
800
|
+
# dir_tree "src/\n app.rb [modified]\n new_file.rb [new]"
|
|
801
|
+
def dir_tree(tree_text, **options)
|
|
802
|
+
components << Components::DirTree.new(tree_text, **options)
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# Render a color swatch legend (horizontal row of dots with labels).
|
|
806
|
+
#
|
|
807
|
+
# @param items [Array<Hash>] Array of { color: "#hex", label: "text" }
|
|
808
|
+
#
|
|
809
|
+
# @example
|
|
810
|
+
# legend items: [{ color: "#22c55e", label: "New" }, { color: "#f59e0b", label: "Modified" }]
|
|
811
|
+
def legend(items:, **options)
|
|
812
|
+
components << Components::Legend.new(items: items, **options)
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
# Render a vertical arrow connector between sections.
|
|
816
|
+
#
|
|
817
|
+
# @param label [String, nil] Optional label on the arrow
|
|
818
|
+
#
|
|
819
|
+
# @example
|
|
820
|
+
# flow_arrow label: "transforms into"
|
|
821
|
+
def flow_arrow(label: nil, **options)
|
|
822
|
+
components << Components::FlowArrow.new(label: label, **options)
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
# Render column count override buttons (1/2/3/4 columns).
|
|
826
|
+
# Uses JS to change grid-template-columns on the target element.
|
|
827
|
+
#
|
|
828
|
+
# @param target [String] CSS selector of the grid to control
|
|
829
|
+
# @param columns [Array<Integer>] Available column counts
|
|
830
|
+
#
|
|
831
|
+
# @example
|
|
832
|
+
# layout_toggle target: ".my-grid", columns: [1, 2, 3]
|
|
833
|
+
def layout_toggle(target: ".sw-layout-target", columns: [1, 2, 3, 4], **options)
|
|
834
|
+
components << Components::LayoutToggle.new(target: target, columns: columns, **options)
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
# @example
|
|
838
|
+
# copy_button "Copy summary", text: report_text
|
|
839
|
+
def copy_button(label = "Copy", text:, copied_label: "Copied!", **options)
|
|
840
|
+
components << Components::CopyButton.new(label, text: text, copied_label: copied_label, **options)
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
# =========================================
|
|
844
|
+
# Interactive components (render-only versions for FeedBuilder context)
|
|
845
|
+
# App overrides these with full callback-wiring implementations.
|
|
846
|
+
# =========================================
|
|
847
|
+
|
|
848
|
+
# Feed-context buttons carry the same identity guarantees as App#button:
|
|
849
|
+
# id: is an explicit override, key: gives content-stable identity, and
|
|
850
|
+
# repeat occurrences are auto-disambiguated (id: > key: > auto-derivation).
|
|
851
|
+
def button(label, key: nil, id: nil, **options, &block)
|
|
852
|
+
require 'digest/md5'
|
|
853
|
+
explicit_id = validate_scalar_key!(id, context: "button")
|
|
854
|
+
key = validate_scalar_key!(key, context: "button")
|
|
855
|
+
stable_id = if explicit_id
|
|
856
|
+
sanitize_explicit_id(explicit_id)
|
|
857
|
+
elsif block
|
|
858
|
+
id_input = key ? "#{label}:#{key}" : "#{label}:#{block.source_location.join(':')}"
|
|
859
|
+
Digest::MD5.hexdigest(id_input)[0..7]
|
|
860
|
+
else
|
|
861
|
+
@button_counter = (@button_counter || 0) + 1
|
|
862
|
+
@button_counter.to_s
|
|
863
|
+
end
|
|
864
|
+
btn = Components::Button.new(label, stable_id, **options, &block)
|
|
865
|
+
btn.id = disambiguate_component_id(btn.id, label: label, source_loc: block&.source_location)
|
|
866
|
+
components << btn
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
def select(key, choices, **options)
|
|
870
|
+
@_state[key] = options[:default] || "" unless @_state&.key?(key)
|
|
871
|
+
components << Components::Select.new(key, choices, **options)
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def expandable_card(key:, title:, subtitle: nil, badge_text: nil, badge_variant: :default,
|
|
875
|
+
status: nil, initially_expanded: false, **options, &block)
|
|
876
|
+
@_state[key] ||= initially_expanded if @_state
|
|
877
|
+
component = Components::ExpandableCard.new(
|
|
878
|
+
key: key, title: title, subtitle: subtitle,
|
|
879
|
+
badge_text: badge_text, badge_variant: badge_variant,
|
|
880
|
+
status: status, initially_expanded: initially_expanded,
|
|
881
|
+
**options
|
|
882
|
+
)
|
|
883
|
+
with_container(component, &block)
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
def show_toast(message, variant: :info, duration: nil)
|
|
887
|
+
return unless @_state
|
|
888
|
+
@_state[:_toasts] ||= []
|
|
889
|
+
toast_id = "toast_#{Time.now.to_f.to_s.gsub('.', '_')}_#{rand(1000)}"
|
|
890
|
+
toast = { id: toast_id, message: message, variant: variant }
|
|
891
|
+
toast[:duration] = duration if duration
|
|
892
|
+
@_state[:_toasts] << toast
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
private
|
|
896
|
+
|
|
897
|
+
# =========================================
|
|
898
|
+
# Interactive component identity (FAC-P0.1)
|
|
899
|
+
# =========================================
|
|
900
|
+
|
|
901
|
+
# Ensures a component's key/id is a stable scalar -- never a positional
|
|
902
|
+
# index or an arbitrary object's #inspect, which would silently break on
|
|
903
|
+
# any collection reorder.
|
|
904
|
+
def validate_scalar_key!(key, context:)
|
|
905
|
+
return key if key.nil?
|
|
906
|
+
unless key.is_a?(String) || key.is_a?(Symbol) || key.is_a?(Integer)
|
|
907
|
+
raise ArgumentError, "#{context}: key: must be a String, Symbol, or Integer, got #{key.class}"
|
|
908
|
+
end
|
|
909
|
+
key
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
# An explicit id: is used verbatim (it is the author's override), minus
|
|
913
|
+
# characters that aren't legal in an HTML id / CSS selector.
|
|
914
|
+
def sanitize_explicit_id(id)
|
|
915
|
+
id.to_s.gsub(/[^a-zA-Z0-9_-]+/, '_')
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
# Detects two interactive components resolving to the same id within a
|
|
919
|
+
# single build (typically same-label buttons in a loop, sharing block
|
|
920
|
+
# source_location). First occurrence keeps its id unchanged; each further
|
|
921
|
+
# occurrence gets a stable "-dup-N" suffix so it remains independently
|
|
922
|
+
# dispatchable -- no author intervention required.
|
|
923
|
+
#
|
|
924
|
+
# Occurrence-index ids are POSITION-stable, not CONTENT-stable: they hold
|
|
925
|
+
# steady across rerenders of the same list, but deleting or inserting an
|
|
926
|
+
# earlier item shifts the later suffixes. That is why the warning points
|
|
927
|
+
# at key:, which binds identity to the record instead of the slot.
|
|
928
|
+
#
|
|
929
|
+
# Under strict_ids the collision raises (dev) or warns (production) --
|
|
930
|
+
# after auto-disambiguation a residual collision is not reachable, so
|
|
931
|
+
# strict mode is really "tell me my ids are position-derived, loudly".
|
|
932
|
+
def disambiguate_component_id(candidate_id, label:, source_loc:)
|
|
933
|
+
occurrence = seen_component_ids[candidate_id]
|
|
934
|
+
seen_component_ids[candidate_id] = occurrence + 1
|
|
935
|
+
return candidate_id if occurrence.zero?
|
|
936
|
+
|
|
937
|
+
loc = source_loc ? Array(source_loc).join(':') : 'unknown location'
|
|
938
|
+
message = "StreamWeaver: duplicate component id for \"#{label}\" at #{loc} " \
|
|
939
|
+
"-- likely the same label/block in a loop. Pass a stable key:, e.g. " \
|
|
940
|
+
"button(#{label.inspect}, key: item.id) { ... }, for distinct, order-independent ids."
|
|
941
|
+
|
|
942
|
+
raise ArgumentError, message if strict_ids? && !StreamWeaver.production_env?
|
|
943
|
+
|
|
944
|
+
unless warned_duplicate_ids.include?(candidate_id)
|
|
945
|
+
warned_duplicate_ids << candidate_id
|
|
946
|
+
warn "#{message} Auto-assigned a unique id for now."
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
"#{candidate_id}-dup-#{occurrence + 1}"
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
# Occurrence table for auto-disambiguation. App overrides this with its
|
|
953
|
+
# pass-scoped render_state accumulator so every rebuild starts clean.
|
|
954
|
+
def seen_component_ids
|
|
955
|
+
@seen_component_ids ||= Hash.new(0)
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
def warned_duplicate_ids
|
|
959
|
+
@_warned_duplicate_ids ||= Set.new
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
def strict_ids?
|
|
963
|
+
StreamWeaver.strict_ids?
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
# Evaluates a user-supplied DSL block (FAC-P0.3). Arity-0 blocks keep the
|
|
967
|
+
# legacy instance_eval behavior (self inside the block is `receiver`).
|
|
968
|
+
# Arity>=1 blocks are called as a plain block.call(receiver) instead --
|
|
969
|
+
# instance_exec is deliberately not used, so the block keeps its own
|
|
970
|
+
# self/binding and can call methods on its enclosing object (e.g. a
|
|
971
|
+
# presenter) while still reaching the DSL via the yielded argument.
|
|
972
|
+
def evaluate_dsl_block(block, receiver = self)
|
|
973
|
+
return unless block
|
|
974
|
+
# instance_exec, not instance_eval: instance_eval yields the receiver to
|
|
975
|
+
# the block, which a zero-arity lambda -- the natural way to write
|
|
976
|
+
# `placeholder: -> { ... }` -- refuses to accept.
|
|
977
|
+
block.arity >= 1 ? block.call(receiver) : receiver.instance_exec(&block)
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
def with_container(component, &block)
|
|
981
|
+
components << component
|
|
982
|
+
return component unless block
|
|
983
|
+
|
|
984
|
+
parent_components = components
|
|
985
|
+
self.components = []
|
|
986
|
+
evaluate_dsl_block(block)
|
|
987
|
+
component.children = components
|
|
988
|
+
self.components = parent_components
|
|
989
|
+
component
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
def watch(key, &block); end
|
|
993
|
+
def on_start(&block); end
|
|
994
|
+
def after(seconds, &block); end
|
|
995
|
+
def every(seconds, &block); end
|
|
996
|
+
|
|
997
|
+
# Never implemented, and it reads exactly like the deferred-rendering verb
|
|
998
|
+
# an author would reach for -- so it raises instead of silently dropping the
|
|
999
|
+
# block it is given.
|
|
1000
|
+
def defer(*)
|
|
1001
|
+
raise NoMethodError, "defer is not a verb; use `fragment(:name, defer: true) { ... }`"
|
|
1002
|
+
end
|
|
1003
|
+
end
|
|
1004
|
+
end
|