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,2711 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module StreamWeaver
|
|
6
|
+
# Component classes for UI elements
|
|
7
|
+
module Components
|
|
8
|
+
# Shared callback behavior for form components
|
|
9
|
+
module Callbacks
|
|
10
|
+
attr_reader :on_change, :on_blur, :debounce
|
|
11
|
+
|
|
12
|
+
def execute_on_change(state, value)
|
|
13
|
+
on_change&.call(state, value)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def execute_on_blur(state, value)
|
|
17
|
+
on_blur&.call(state, value)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def init_callbacks(on_change: nil, on_blur: nil, debounce: nil)
|
|
23
|
+
@on_change = on_change
|
|
24
|
+
@on_blur = on_blur
|
|
25
|
+
@debounce = debounce
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Base component class that all components inherit from
|
|
30
|
+
class Base
|
|
31
|
+
# Class-level macro: declare inline CSS for this component.
|
|
32
|
+
# Multiple calls accumulate; all strings are emitted once per class per page.
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# css ".my-banner { color: red }"
|
|
36
|
+
def self.css(string)
|
|
37
|
+
@component_css_strings ||= []
|
|
38
|
+
@component_css_strings << string
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Class-level macro: declare a CSS file to serve alongside this component.
|
|
42
|
+
# The file is served via the /sw-asset/ route (registered once at declaration time).
|
|
43
|
+
#
|
|
44
|
+
# @param path [String] Absolute path to the CSS file
|
|
45
|
+
def self.css_path(path)
|
|
46
|
+
@component_css_path = path
|
|
47
|
+
ComponentAssets.register_file(path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Class-level macro: declare a JS file to serve alongside this component.
|
|
51
|
+
#
|
|
52
|
+
# @param path [String] Absolute path to the JS file
|
|
53
|
+
def self.js_path(path)
|
|
54
|
+
@component_js_path = path
|
|
55
|
+
ComponentAssets.register_file(path)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.component_css_strings
|
|
59
|
+
@component_css_strings || []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.component_css_path
|
|
63
|
+
@component_css_path
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.component_js_path
|
|
67
|
+
@component_js_path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def initialize(**options)
|
|
71
|
+
@options = options
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Render the component using Phlex view
|
|
75
|
+
#
|
|
76
|
+
# @param view [Phlex::HTML] The Phlex view instance
|
|
77
|
+
# @param state [Hash] The current state hash
|
|
78
|
+
# @raise [NotImplementedError] if not implemented by subclass
|
|
79
|
+
def render(view, state)
|
|
80
|
+
raise NotImplementedError, "#{self.class} must implement #render"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Return the state key for this component (if applicable)
|
|
84
|
+
#
|
|
85
|
+
# @return [Symbol, nil] The state key or nil
|
|
86
|
+
def key
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Return child components (if applicable)
|
|
91
|
+
#
|
|
92
|
+
# @return [Array] Array of child components
|
|
93
|
+
def children
|
|
94
|
+
[]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Register interactive callbacks with the given registry.
|
|
98
|
+
# Default: no-op — most components have no callbacks.
|
|
99
|
+
# Interactive components override this to self-register.
|
|
100
|
+
#
|
|
101
|
+
# @param registry [Hash] Mutable hash mapping dom_id => callable
|
|
102
|
+
def register_callbacks(registry)
|
|
103
|
+
# Default: no interactive callbacks. Override in interactive components.
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class Fragment < Base
|
|
108
|
+
attr_accessor :children
|
|
109
|
+
attr_reader :name, :id, :deferred, :lazy
|
|
110
|
+
|
|
111
|
+
def initialize(name, id, deferred: false, lazy: false)
|
|
112
|
+
@name = name.to_sym
|
|
113
|
+
@id = id
|
|
114
|
+
@deferred = deferred
|
|
115
|
+
@lazy = lazy
|
|
116
|
+
@children = []
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def render(view, state)
|
|
120
|
+
view.with_fragment(id) do
|
|
121
|
+
view.div(id: id) do
|
|
122
|
+
deferred ? render_deferred(view, state) : render_children(view, state)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# The auto-fetch attributes ride an inner wrapper rather than the
|
|
130
|
+
# container so a later full-container morph replaces the trigger element
|
|
131
|
+
# instead of reusing an already-initialized one, which is what re-arms
|
|
132
|
+
# htmx's `load` trigger. Its own id keeps the morph from matching it
|
|
133
|
+
# positionally against materialized content.
|
|
134
|
+
# `intersect once` is IntersectionObserver-backed, so it fires on whatever
|
|
135
|
+
# makes the element visible and never fires for one with no layout box --
|
|
136
|
+
# Turbo's `loading="lazy"` rule. `revealed`, the other candidate, reads
|
|
137
|
+
# getBoundingClientRect() and so counts a display:none element as revealed
|
|
138
|
+
# (docs/research/2026-08-22-lazy-fragments-trigger-decision.md).
|
|
139
|
+
def render_deferred(view, state)
|
|
140
|
+
attrs = view.adapter.htmx_attrs(view.adapter.url("/update"), view: view, loading: false,
|
|
141
|
+
sw_deferred: true,
|
|
142
|
+
"hx-trigger" => lazy ? "intersect once" : "load")
|
|
143
|
+
classes = ["sw-fragment-deferred", ("sw-fragment-lazy" if lazy)].compact.join(" ")
|
|
144
|
+
view.div(id: "#{id}--deferred", class: classes, "aria-busy" => "true", **attrs) do
|
|
145
|
+
render_children(view, state)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def render_children(view, state)
|
|
150
|
+
children.each { |child| child.render(view, state) }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# TextField component for single-line text input
|
|
155
|
+
class TextField < Base
|
|
156
|
+
include Callbacks
|
|
157
|
+
attr_reader :key
|
|
158
|
+
|
|
159
|
+
# @param key [Symbol] The state key
|
|
160
|
+
# @param on_change [Proc, nil] Callback when value changes: ->(state, value) { ... }
|
|
161
|
+
# @param on_blur [Proc, nil] Callback when field loses focus: ->(state, value) { ... }
|
|
162
|
+
# @param debounce [Integer, nil] Milliseconds to wait before triggering on_change
|
|
163
|
+
# @param options [Hash] Options (e.g., placeholder)
|
|
164
|
+
def initialize(key, on_change: nil, on_blur: nil, debounce: nil, **options)
|
|
165
|
+
@key = key
|
|
166
|
+
@options = options
|
|
167
|
+
init_callbacks(on_change: on_change, on_blur: on_blur, debounce: debounce)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def render(view, state)
|
|
171
|
+
view.adapter.render_text_field(view, @key, callback_options, state)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def callback_options
|
|
177
|
+
@options.merge(on_change: on_change, on_blur: on_blur, debounce: debounce)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# TextArea component for multi-line text input
|
|
182
|
+
class TextArea < Base
|
|
183
|
+
include Callbacks
|
|
184
|
+
attr_reader :key
|
|
185
|
+
|
|
186
|
+
# @param key [Symbol] The state key
|
|
187
|
+
# @param on_change [Proc, nil] Callback when value changes: ->(state, value) { ... }
|
|
188
|
+
# @param on_blur [Proc, nil] Callback when field loses focus: ->(state, value) { ... }
|
|
189
|
+
# @param debounce [Integer, nil] Milliseconds to wait before triggering on_change
|
|
190
|
+
# @param options [Hash] Options (e.g., placeholder, rows)
|
|
191
|
+
def initialize(key, on_change: nil, on_blur: nil, debounce: nil, **options)
|
|
192
|
+
@key = key
|
|
193
|
+
@options = options
|
|
194
|
+
init_callbacks(on_change: on_change, on_blur: on_blur, debounce: debounce)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def render(view, state)
|
|
198
|
+
view.adapter.render_text_area(view, @key, callback_options, state)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
def callback_options
|
|
204
|
+
@options.merge(on_change: on_change, on_blur: on_blur, debounce: debounce)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# DateField component for native date input, state-bound like TextField.
|
|
209
|
+
# Value is stored/read as an ISO 8601 string ("YYYY-MM-DD").
|
|
210
|
+
class DateField < Base
|
|
211
|
+
include Callbacks
|
|
212
|
+
attr_reader :key
|
|
213
|
+
|
|
214
|
+
# @param key [Symbol] The state key
|
|
215
|
+
# @param label [String, nil] Optional label rendered above the input
|
|
216
|
+
# @param min [String, nil] Minimum selectable date (ISO 8601)
|
|
217
|
+
# @param max [String, nil] Maximum selectable date (ISO 8601)
|
|
218
|
+
# @param on_change [Proc, nil] Callback when value changes: ->(state, value) { ... }
|
|
219
|
+
# @param on_blur [Proc, nil] Callback when field loses focus: ->(state, value) { ... }
|
|
220
|
+
# @param debounce [Integer, nil] Milliseconds to wait before triggering on_change
|
|
221
|
+
# @param options [Hash] Additional options (e.g. submit: false)
|
|
222
|
+
def initialize(key, label: nil, min: nil, max: nil, on_change: nil, on_blur: nil, debounce: nil, **options)
|
|
223
|
+
@key = key
|
|
224
|
+
@options = options.merge(label: label, min: min, max: max)
|
|
225
|
+
init_callbacks(on_change: on_change, on_blur: on_blur, debounce: debounce)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def render(view, state)
|
|
229
|
+
view.adapter.render_date_field(view, @key, callback_options, state)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Coerce an ISO 8601 date string (as stored in state) to a Date.
|
|
233
|
+
# Returns nil for blank or unparsable input instead of raising.
|
|
234
|
+
#
|
|
235
|
+
# @param value [String, nil]
|
|
236
|
+
# @return [Date, nil]
|
|
237
|
+
def self.to_date(value)
|
|
238
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
239
|
+
|
|
240
|
+
Date.iso8601(value.to_s)
|
|
241
|
+
rescue ArgumentError
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
private
|
|
246
|
+
|
|
247
|
+
def callback_options
|
|
248
|
+
@options.merge(on_change: on_change, on_blur: on_blur, debounce: debounce)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Button component that executes actions on click
|
|
253
|
+
class Button < Base
|
|
254
|
+
attr_reader :id, :modal_context, :options
|
|
255
|
+
|
|
256
|
+
# @param label [String] Button label
|
|
257
|
+
# @param stable_id [String] Stable ID suffix (hash of source location or counter)
|
|
258
|
+
# @param options [Hash] Options (e.g., style: :primary or :secondary, modal_context: {key: :name})
|
|
259
|
+
# @param block [Proc] Action block to execute
|
|
260
|
+
def initialize(label, stable_id, **options, &block)
|
|
261
|
+
@label = label
|
|
262
|
+
@action = block
|
|
263
|
+
@modal_context = options.delete(:modal_context)
|
|
264
|
+
@options = options
|
|
265
|
+
# stable_id is derived from source location (for buttons with blocks) or counter (for blockless)
|
|
266
|
+
@button_id = "btn_#{label.downcase.gsub(/[^a-z0-9]+/, '_')}_#{stable_id}"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Fill in options the author didn't already set (e.g. a table action
|
|
270
|
+
# cell defaulting to size: :sm, variant: :quiet). Explicit @options
|
|
271
|
+
# values always win.
|
|
272
|
+
#
|
|
273
|
+
# @param defaults [Hash]
|
|
274
|
+
def apply_default_options(defaults)
|
|
275
|
+
@options = defaults.merge(@options)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def render(view, state)
|
|
279
|
+
# Delegate to adapter - no framework knowledge in component
|
|
280
|
+
view.adapter.render_button(view, @button_id, @label, @options, @modal_context)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Execute the button's action block
|
|
284
|
+
#
|
|
285
|
+
# @param state [Hash] The current state
|
|
286
|
+
def execute(state)
|
|
287
|
+
@action.call(state) if @action
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def id
|
|
291
|
+
@button_id
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Reassign id after duplicate-id auto-disambiguation (FAC-P0.1) -- see
|
|
295
|
+
# App#disambiguate_component_id.
|
|
296
|
+
def id=(new_id)
|
|
297
|
+
@button_id = new_id
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def register_callbacks(registry)
|
|
301
|
+
registry[id] = @action if @action
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Text component for displaying literal content (no markdown parsing)
|
|
306
|
+
class Text < Base
|
|
307
|
+
TONES = %i[muted caption error success].freeze
|
|
308
|
+
|
|
309
|
+
attr_reader :tone, :options
|
|
310
|
+
|
|
311
|
+
# @param content [String, Proc] The text content (can be a proc for dynamic content)
|
|
312
|
+
# @param tone [Symbol, nil] Visual tone -- :muted, :caption, :error, :success (03
|
|
313
|
+
# honorable mention: hand-coded hex/padding divs standing in for text variants)
|
|
314
|
+
# @param options [Hash] :class/:style passthrough (stream_weaver-1lo)
|
|
315
|
+
def initialize(content, tone: nil, **options)
|
|
316
|
+
@content = content
|
|
317
|
+
@tone = TONES.include?(tone) ? tone : nil
|
|
318
|
+
@options = options
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def render(view, state)
|
|
322
|
+
content = @content.is_a?(Proc) ? @content.call(state) : @content
|
|
323
|
+
if @tone
|
|
324
|
+
view.adapter.render_text(view, content.to_s, @tone, @options)
|
|
325
|
+
else
|
|
326
|
+
classes = [@options[:class]].compact
|
|
327
|
+
attrs = {}
|
|
328
|
+
attrs[:class] = classes.join(" ") unless classes.empty?
|
|
329
|
+
attrs[:style] = @options[:style] if @options[:style]
|
|
330
|
+
view.p(**attrs) { content.to_s }
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# AppHeader component for app header bars (full-width header with brand/actions)
|
|
336
|
+
class AppHeader < Base
|
|
337
|
+
attr_accessor :children
|
|
338
|
+
attr_reader :title, :subtitle, :variant
|
|
339
|
+
|
|
340
|
+
# @param title [String] The header title
|
|
341
|
+
# @param subtitle [String, nil] Optional subtitle
|
|
342
|
+
# @param variant [Symbol] Style variant (:dark, :light, :primary)
|
|
343
|
+
def initialize(title, subtitle: nil, variant: :dark)
|
|
344
|
+
@title = title
|
|
345
|
+
@subtitle = subtitle
|
|
346
|
+
@variant = variant
|
|
347
|
+
@children = []
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def render(view, state)
|
|
351
|
+
view.adapter.render_app_header(view, self, state)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Div component for layout containers with optional hover support
|
|
356
|
+
class Div < Base
|
|
357
|
+
attr_accessor :children
|
|
358
|
+
attr_reader :hover_class
|
|
359
|
+
|
|
360
|
+
# @param options [Hash] Options (e.g., class: "container")
|
|
361
|
+
# @option options [String] :hover_class CSS class to add on hover (client-side)
|
|
362
|
+
def initialize(hover_class: nil, **options)
|
|
363
|
+
@hover_class = hover_class
|
|
364
|
+
@options = options
|
|
365
|
+
@children = []
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def render(view, state)
|
|
369
|
+
view.adapter.render_div(view, self, state)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Checkbox component for boolean input
|
|
374
|
+
class Checkbox < Base
|
|
375
|
+
include Callbacks
|
|
376
|
+
attr_reader :key, :options
|
|
377
|
+
|
|
378
|
+
# @param key [Symbol] The state key
|
|
379
|
+
# @param label [String] The label text
|
|
380
|
+
# @param on_change [Proc, nil] Callback when checkbox changes: ->(state, value) { ... }
|
|
381
|
+
# @param options [Hash] Additional options
|
|
382
|
+
def initialize(key, label, on_change: nil, **options)
|
|
383
|
+
@key = key
|
|
384
|
+
@label = label
|
|
385
|
+
@options = options
|
|
386
|
+
init_callbacks(on_change: on_change)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def render(view, state)
|
|
390
|
+
view.adapter.render_checkbox(view, @key, @label, @options.merge(on_change: on_change), state)
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Select component for dropdown selection
|
|
395
|
+
class Select < Base
|
|
396
|
+
include Callbacks
|
|
397
|
+
attr_reader :key
|
|
398
|
+
|
|
399
|
+
# @param key [Symbol] The state key
|
|
400
|
+
# @param choices [Array<String>] The available choices
|
|
401
|
+
# @param on_change [Proc, nil] Callback when selection changes: ->(state, value) { ... }
|
|
402
|
+
# @param options [Hash] Additional options
|
|
403
|
+
def initialize(key, choices, on_change: nil, **options)
|
|
404
|
+
@key = key
|
|
405
|
+
@choices = choices
|
|
406
|
+
@options = options
|
|
407
|
+
init_callbacks(on_change: on_change)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def render(view, state)
|
|
411
|
+
view.adapter.render_select(view, @key, @choices, @options.merge(on_change: on_change), state)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# RadioGroup component for single-choice selection (radio buttons)
|
|
416
|
+
# Unlike Select, radio buttons show all options at once and have no pre-selected value
|
|
417
|
+
class RadioGroup < Base
|
|
418
|
+
attr_reader :key
|
|
419
|
+
|
|
420
|
+
# @param key [Symbol] The state key
|
|
421
|
+
# @param choices [Array<String>] The available choices
|
|
422
|
+
# @param options [Hash] Additional options (e.g., placeholder)
|
|
423
|
+
def initialize(key, choices, **options)
|
|
424
|
+
@key = key
|
|
425
|
+
@choices = choices
|
|
426
|
+
@options = options
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def render(view, state)
|
|
430
|
+
# Delegate to adapter - no framework knowledge in component
|
|
431
|
+
view.adapter.render_radio_group(view, @key, @choices, @options, state)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Card component for visual grouping of content
|
|
436
|
+
class Card < Base
|
|
437
|
+
VALID_DEPTHS = %i[hero elevated default recessed glass].freeze
|
|
438
|
+
|
|
439
|
+
attr_accessor :children
|
|
440
|
+
attr_reader :depth, :accent, :label
|
|
441
|
+
|
|
442
|
+
# @param depth [Symbol] Depth tier (:hero, :elevated, :default, :recessed, :glass)
|
|
443
|
+
# @param accent [Symbol, String, nil] Accent color for left border (:a, :b, :c or CSS color)
|
|
444
|
+
# @param label [String, nil] Corner label text (e.g. "RISK", "NEW")
|
|
445
|
+
# @param options [Hash] Options (e.g., class: "question-card")
|
|
446
|
+
def initialize(depth: nil, accent: nil, label: nil, **options)
|
|
447
|
+
@depth = depth
|
|
448
|
+
@accent = accent
|
|
449
|
+
@label = label
|
|
450
|
+
@options = options
|
|
451
|
+
@children = []
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def render(view, state)
|
|
455
|
+
# "card" is the legacy, unprefixed hook (still emitted for
|
|
456
|
+
# back-compat -- deprecated, removed at 1.0); "sw-card" is the
|
|
457
|
+
# documented stable hook (stream_weaver-oeo / stream_weaver-lyb).
|
|
458
|
+
classes = ["card", "sw-card"]
|
|
459
|
+
classes << "sw-card--#{@depth}" if @depth && VALID_DEPTHS.include?(@depth)
|
|
460
|
+
classes << "sw-card--accent-#{@accent}" if @accent.is_a?(Symbol)
|
|
461
|
+
classes << "sw-card--accent" if @accent && !@accent.is_a?(Symbol)
|
|
462
|
+
classes << @options[:class] if @options[:class]
|
|
463
|
+
attrs = { class: classes.join(" ") }
|
|
464
|
+
|
|
465
|
+
# Build inline style for custom accent and/or label positioning
|
|
466
|
+
styles = []
|
|
467
|
+
styles << "border-left-color: #{@accent};" if @accent && !@accent.is_a?(Symbol)
|
|
468
|
+
styles << "position: relative;" if @label
|
|
469
|
+
styles << @options[:style] if @options[:style]
|
|
470
|
+
attrs[:style] = styles.join(" ") unless styles.empty?
|
|
471
|
+
|
|
472
|
+
view.div(**attrs) do
|
|
473
|
+
if @label
|
|
474
|
+
view.span(class: "sw-card__label") { @label }
|
|
475
|
+
end
|
|
476
|
+
@children.each { |child| child.render(view, state) }
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# CardHeader component for card header section
|
|
482
|
+
class CardHeader < Base
|
|
483
|
+
attr_accessor :children
|
|
484
|
+
attr_reader :content, :badge, :meta
|
|
485
|
+
|
|
486
|
+
# @param content [String, nil] Optional string content (renders as h4)
|
|
487
|
+
# @param options [Hash] Additional options
|
|
488
|
+
# @option options [String] :badge Optional badge text (e.g. "C1") rendered before the title
|
|
489
|
+
# @option options [String] :meta Optional right-aligned meta text
|
|
490
|
+
def initialize(content = nil, **options)
|
|
491
|
+
@content = content
|
|
492
|
+
@options = options
|
|
493
|
+
@badge = options[:badge]
|
|
494
|
+
@meta = options[:meta]
|
|
495
|
+
@children = []
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def render(view, state)
|
|
499
|
+
badged = @badge || @meta
|
|
500
|
+
# "card-header" is the legacy, unprefixed hook (still emitted for
|
|
501
|
+
# back-compat -- deprecated, removed at 1.0); "sw-card-header" is
|
|
502
|
+
# the documented stable hook (stream_weaver-oeo / stream_weaver-lyb).
|
|
503
|
+
classes = ["card-header", "sw-card-header"]
|
|
504
|
+
classes << "card-header--badged" if badged
|
|
505
|
+
classes << @options[:class] if @options[:class]
|
|
506
|
+
attrs = { class: classes.join(" ") }
|
|
507
|
+
attrs[:style] = @options[:style] if @options[:style]
|
|
508
|
+
|
|
509
|
+
view.div(**attrs) do
|
|
510
|
+
view.span(class: "card-header__badge") { @badge } if @badge
|
|
511
|
+
if @content
|
|
512
|
+
badged ? view.h4(class: "card-header__title") { @content } : view.h4 { @content }
|
|
513
|
+
end
|
|
514
|
+
view.span(class: "card-header__meta") { @meta } if @meta
|
|
515
|
+
@children.each { |child| child.render(view, state) }
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# CardBody component for card main content section
|
|
521
|
+
class CardBody < Base
|
|
522
|
+
attr_accessor :children
|
|
523
|
+
|
|
524
|
+
# @param options [Hash] Additional options
|
|
525
|
+
def initialize(**options)
|
|
526
|
+
@options = options
|
|
527
|
+
@children = []
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def render(view, state)
|
|
531
|
+
# "card-body" is the legacy, unprefixed hook (still emitted for
|
|
532
|
+
# back-compat -- deprecated, removed at 1.0); "sw-card-body" is the
|
|
533
|
+
# documented stable hook (stream_weaver-oeo / stream_weaver-lyb).
|
|
534
|
+
classes = ["card-body", "sw-card-body"]
|
|
535
|
+
classes << @options[:class] if @options[:class]
|
|
536
|
+
attrs = { class: classes.join(" ") }
|
|
537
|
+
attrs[:style] = @options[:style] if @options[:style]
|
|
538
|
+
|
|
539
|
+
view.div(**attrs) do
|
|
540
|
+
@children.each { |child| child.render(view, state) }
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# CardFooter component for card footer section (typically for actions)
|
|
546
|
+
class CardFooter < Base
|
|
547
|
+
attr_accessor :children
|
|
548
|
+
|
|
549
|
+
# @param options [Hash] Additional options
|
|
550
|
+
def initialize(**options)
|
|
551
|
+
@options = options
|
|
552
|
+
@children = []
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def render(view, state)
|
|
556
|
+
# "card-footer" is the legacy, unprefixed hook (still emitted for
|
|
557
|
+
# back-compat -- deprecated, removed at 1.0); "sw-card-footer" is
|
|
558
|
+
# the documented stable hook (stream_weaver-oeo / stream_weaver-lyb).
|
|
559
|
+
classes = ["card-footer", "sw-card-footer"]
|
|
560
|
+
classes << @options[:class] if @options[:class]
|
|
561
|
+
attrs = { class: classes.join(" ") }
|
|
562
|
+
attrs[:style] = @options[:style] if @options[:style]
|
|
563
|
+
|
|
564
|
+
view.div(**attrs) do
|
|
565
|
+
@children.each { |child| child.render(view, state) }
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# VStack component for vertical stacking with spacing
|
|
571
|
+
class VStack < Base
|
|
572
|
+
attr_reader :spacing, :align, :divider, :options
|
|
573
|
+
attr_accessor :children
|
|
574
|
+
|
|
575
|
+
def initialize(spacing: :md, align: nil, divider: false, **options)
|
|
576
|
+
@spacing = spacing
|
|
577
|
+
@align = align
|
|
578
|
+
@divider = divider
|
|
579
|
+
@options = options
|
|
580
|
+
@children = []
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def render(view, state)
|
|
584
|
+
view.adapter.render_vstack(view, self, state)
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# HStack component for horizontal stacking with spacing
|
|
589
|
+
class HStack < Base
|
|
590
|
+
attr_reader :spacing, :align, :justify, :divider, :options
|
|
591
|
+
attr_accessor :children
|
|
592
|
+
|
|
593
|
+
def initialize(spacing: :sm, align: nil, justify: nil, divider: false, **options)
|
|
594
|
+
@spacing = spacing
|
|
595
|
+
@align = align
|
|
596
|
+
@justify = justify
|
|
597
|
+
@divider = divider
|
|
598
|
+
@options = options
|
|
599
|
+
@children = []
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def render(view, state)
|
|
603
|
+
view.adapter.render_hstack(view, self, state)
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# Grid component for responsive grid layouts
|
|
608
|
+
class Grid < Base
|
|
609
|
+
attr_reader :columns, :gap, :template, :template_areas, :template_rows, :template_columns, :options
|
|
610
|
+
attr_accessor :children
|
|
611
|
+
|
|
612
|
+
def initialize(columns: 3, gap: :md, template: nil, template_areas: nil, template_rows: nil, template_columns: nil, **options)
|
|
613
|
+
@columns = columns
|
|
614
|
+
@gap = gap
|
|
615
|
+
@template = template
|
|
616
|
+
@template_areas = template_areas
|
|
617
|
+
@template_rows = template_rows
|
|
618
|
+
@template_columns = template_columns
|
|
619
|
+
@options = options
|
|
620
|
+
@children = []
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def render(view, state)
|
|
624
|
+
view.adapter.render_grid(view, self, state)
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# GridArea — sets grid-area on its container div, for use inside named-area grids.
|
|
629
|
+
class GridArea < Base
|
|
630
|
+
attr_reader :area_name, :options
|
|
631
|
+
attr_accessor :children
|
|
632
|
+
|
|
633
|
+
def initialize(area_name, **options)
|
|
634
|
+
@area_name = area_name.to_s
|
|
635
|
+
@options = options
|
|
636
|
+
@children = []
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def render(view, state)
|
|
640
|
+
view.adapter.render_grid_area(view, self, state)
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# Sticky — wraps content in a position:sticky container.
|
|
645
|
+
class Sticky < Base
|
|
646
|
+
attr_reader :top, :bottom, :left, :right, :z_index, :options
|
|
647
|
+
attr_accessor :children
|
|
648
|
+
|
|
649
|
+
def initialize(top: nil, bottom: nil, left: nil, right: nil, z_index: nil, **options)
|
|
650
|
+
@top = top
|
|
651
|
+
@bottom = bottom
|
|
652
|
+
@left = left
|
|
653
|
+
@right = right
|
|
654
|
+
@z_index = z_index
|
|
655
|
+
@options = options
|
|
656
|
+
@children = []
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def render(view, state)
|
|
660
|
+
view.adapter.render_sticky(view, self, state)
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Overlay — wraps content in a position:absolute overlay.
|
|
665
|
+
class Overlay < Base
|
|
666
|
+
attr_reader :z, :pointer_events, :options
|
|
667
|
+
attr_accessor :children
|
|
668
|
+
|
|
669
|
+
def initialize(z: 1, pointer_events: nil, **options)
|
|
670
|
+
@z = z
|
|
671
|
+
@pointer_events = pointer_events
|
|
672
|
+
@options = options
|
|
673
|
+
@children = []
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def render(view, state)
|
|
677
|
+
view.adapter.render_overlay(view, self, state)
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Fullbleed — escapes parent max-width constraints for a full-width region.
|
|
682
|
+
class Fullbleed < Base
|
|
683
|
+
attr_reader :options
|
|
684
|
+
attr_accessor :children
|
|
685
|
+
|
|
686
|
+
def initialize(**options)
|
|
687
|
+
@options = options
|
|
688
|
+
@children = []
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def render(view, state)
|
|
692
|
+
view.adapter.render_fullbleed(view, self, state)
|
|
693
|
+
end
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# Phrase component for plain text within lesson content
|
|
697
|
+
class Phrase < Base
|
|
698
|
+
attr_reader :options
|
|
699
|
+
|
|
700
|
+
# @param content [String] The text content
|
|
701
|
+
# @param options [Hash] :class/:style passthrough (stream_weaver-1lo)
|
|
702
|
+
def initialize(content, **options)
|
|
703
|
+
@content = content
|
|
704
|
+
@options = options
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def render(view, state)
|
|
708
|
+
classes = [@options[:class]].compact
|
|
709
|
+
attrs = {}
|
|
710
|
+
attrs[:class] = classes.join(" ") unless classes.empty?
|
|
711
|
+
attrs[:style] = @options[:style] if @options[:style]
|
|
712
|
+
view.span(**attrs) { @content }
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Term component for hoverable glossary terms with tooltips
|
|
717
|
+
class Term < Base
|
|
718
|
+
attr_reader :term_key
|
|
719
|
+
|
|
720
|
+
# @param term_key [String] The term to display (also used as glossary key)
|
|
721
|
+
# @param options [Hash] Options (e.g., display: "alternate text")
|
|
722
|
+
def initialize(term_key, **options)
|
|
723
|
+
@term_key = term_key
|
|
724
|
+
@options = options
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
def render(view, state)
|
|
728
|
+
# Delegate to adapter - no framework knowledge in component
|
|
729
|
+
view.adapter.render_term(view, @term_key, @options, state)
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
# LessonText component for interactive educational content with glossary tooltips
|
|
734
|
+
class LessonText < Base
|
|
735
|
+
attr_accessor :children
|
|
736
|
+
attr_reader :glossary
|
|
737
|
+
|
|
738
|
+
# @param glossary [Hash] Glossary definitions {term => {simple:, detailed:}}
|
|
739
|
+
# @param options [Hash] Additional options
|
|
740
|
+
def initialize(glossary: {}, **options)
|
|
741
|
+
@glossary = glossary
|
|
742
|
+
@options = options
|
|
743
|
+
@children = []
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
def render(view, state)
|
|
747
|
+
# Delegate to adapter - no framework knowledge in component
|
|
748
|
+
view.adapter.render_lesson_text(view, @glossary, @children, @options, state)
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
# ScrollBox component for scrollable content with max-height
|
|
753
|
+
class ScrollBox < Base
|
|
754
|
+
attr_reader :max_height, :options
|
|
755
|
+
attr_accessor :children
|
|
756
|
+
|
|
757
|
+
def initialize(max_height: "300px", **options)
|
|
758
|
+
@max_height = max_height
|
|
759
|
+
@options = options
|
|
760
|
+
@children = []
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def render(view, state)
|
|
764
|
+
view.adapter.render_scroll_box(view, self, state)
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
# Collapsible component for expandable/collapsible content sections
|
|
769
|
+
class Collapsible < Base
|
|
770
|
+
attr_reader :label, :expanded, :subtitle, :badge_text, :badge_variant, :options
|
|
771
|
+
attr_accessor :children
|
|
772
|
+
|
|
773
|
+
# @param label [String] The header label text
|
|
774
|
+
# @param expanded [Boolean] Whether to start expanded (default: false)
|
|
775
|
+
# @param subtitle [String, nil] Optional subtitle shown next to the label
|
|
776
|
+
# @param badge_text [String, nil] Optional badge text (e.g., "5 activities")
|
|
777
|
+
# @param badge_variant [Symbol] Badge color variant
|
|
778
|
+
# @param options [Hash] Additional options
|
|
779
|
+
def initialize(label, expanded: false, subtitle: nil, badge_text: nil, badge_variant: :default, **options)
|
|
780
|
+
@label = label
|
|
781
|
+
@expanded = expanded
|
|
782
|
+
@subtitle = subtitle
|
|
783
|
+
@badge_text = badge_text
|
|
784
|
+
@badge_variant = badge_variant
|
|
785
|
+
@options = options
|
|
786
|
+
@children = []
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def render(view, state)
|
|
790
|
+
view.adapter.render_collapsible(view, self, state)
|
|
791
|
+
end
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# Accordion container -- groups AccordionSection children. Native
|
|
795
|
+
# <details>/<summary>, no state key, no JS (03 gap #7).
|
|
796
|
+
class Accordion < Base
|
|
797
|
+
attr_accessor :children
|
|
798
|
+
|
|
799
|
+
def initialize(**options)
|
|
800
|
+
@options = options
|
|
801
|
+
@children = []
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
def render(view, state)
|
|
805
|
+
view.adapter.render_accordion(view, @children, @options, state)
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
# Single disclosure panel within an Accordion.
|
|
810
|
+
class AccordionSection < Base
|
|
811
|
+
attr_accessor :children
|
|
812
|
+
attr_reader :title, :open
|
|
813
|
+
|
|
814
|
+
# @param title [String] Always-visible summary text
|
|
815
|
+
# @param open [Boolean] Whether the panel starts expanded (default: false)
|
|
816
|
+
# @param options [Hash] Additional options
|
|
817
|
+
def initialize(title, open: false, **options)
|
|
818
|
+
@title = title
|
|
819
|
+
@open = open
|
|
820
|
+
@options = options
|
|
821
|
+
@children = []
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
def render(view, state)
|
|
825
|
+
view.adapter.render_accordion_section(view, self, state)
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
# Semantic accent tones shared by Lane headers and BoardCard accents --
|
|
830
|
+
# reuse the existing --sw-success/warning/error/info theme tokens (FAC-8mj
|
|
831
|
+
# tyrion parity: per-lane color identity -- gold/red/green -- had no
|
|
832
|
+
# first-class option before this) rather than inventing app-specific
|
|
833
|
+
# color names. An app that wants tyrion's exact gold/red/green still
|
|
834
|
+
# picks the closest tone (:warning/:error/:success) and layers its own
|
|
835
|
+
# CSS on top for the exact hue.
|
|
836
|
+
BOARD_TONES = %i[neutral success warning error info].freeze
|
|
837
|
+
|
|
838
|
+
# Static Kanban board -- groups Lane children. No drag-and-drop (03 gap
|
|
839
|
+
# #9); a future primitive can add it without changing this shape.
|
|
840
|
+
class Board < Base
|
|
841
|
+
attr_accessor :children
|
|
842
|
+
attr_reader :options
|
|
843
|
+
|
|
844
|
+
# @option pinned_headers [Boolean] keep lane header bands visible while
|
|
845
|
+
# the board scrolls vertically
|
|
846
|
+
def initialize(pinned_headers: false, **options)
|
|
847
|
+
@pinned_headers = pinned_headers
|
|
848
|
+
@options = options.merge(pinned_headers: pinned_headers)
|
|
849
|
+
@children = []
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
def pinned_headers? = @pinned_headers
|
|
853
|
+
|
|
854
|
+
def render(view, state)
|
|
855
|
+
view.adapter.render_board(view, @children, @options, state)
|
|
856
|
+
end
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
# Single column within a Board. Children are typically BoardCards, but
|
|
860
|
+
# any component is accepted directly (e.g. an empty-state text).
|
|
861
|
+
#
|
|
862
|
+
# @option tone [Symbol] one of BOARD_TONES -- colors the lane header band
|
|
863
|
+
# @option subtitle [String] small text under the title (e.g. "In Progress")
|
|
864
|
+
# @option icon [String] emoji/glyph, or a URL/path (local_asset or
|
|
865
|
+
# /sw-asset/... http(s)/data URI) rendered as an image -- shown in the
|
|
866
|
+
# header before the title (stream_weaver-oeo, replaces the
|
|
867
|
+
# title-prefix workaround design-parity-fights.md catalogued)
|
|
868
|
+
class Lane < Base
|
|
869
|
+
attr_accessor :children
|
|
870
|
+
attr_reader :title, :tone, :subtitle, :icon, :options
|
|
871
|
+
|
|
872
|
+
def initialize(title, tone: nil, subtitle: nil, icon: nil, **options)
|
|
873
|
+
@title = title
|
|
874
|
+
@tone = tone if BOARD_TONES.include?(tone)
|
|
875
|
+
@subtitle = subtitle
|
|
876
|
+
@icon = icon
|
|
877
|
+
@options = options
|
|
878
|
+
@children = []
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# Card count for the lane header -- derived from children so it can
|
|
882
|
+
# never drift from what's actually rendered (Forrest's Law: anything
|
|
883
|
+
# that can happen automatically, must).
|
|
884
|
+
def count = @children.size
|
|
885
|
+
|
|
886
|
+
def render(view, state)
|
|
887
|
+
view.adapter.render_lane(view, self, state)
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
# A single card within a Lane.
|
|
892
|
+
#
|
|
893
|
+
# @option tone [Symbol] one of BOARD_TONES -- left-border accent color
|
|
894
|
+
class BoardCard < Base
|
|
895
|
+
attr_accessor :children
|
|
896
|
+
attr_reader :tone, :options
|
|
897
|
+
|
|
898
|
+
def initialize(tone: nil, **options)
|
|
899
|
+
@tone = tone if BOARD_TONES.include?(tone)
|
|
900
|
+
@options = options
|
|
901
|
+
@children = []
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
def render(view, state)
|
|
905
|
+
view.adapter.render_board_card(view, @children, @options.merge(tone: @tone), state)
|
|
906
|
+
end
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
# ScoreTable component for displaying metrics with color-coded scores
|
|
910
|
+
class ScoreTable < Base
|
|
911
|
+
# @param scores [Array<Hash>] Array of {label:, value:, max:} hashes
|
|
912
|
+
# @param options [Hash] Additional options
|
|
913
|
+
def initialize(scores:, **options)
|
|
914
|
+
@scores = scores
|
|
915
|
+
@options = options
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
def render(view, state)
|
|
919
|
+
view.adapter.render_score_table(view, @scores, @options, state)
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
# Built-in formatters for table cell values
|
|
924
|
+
module TableFormatters
|
|
925
|
+
FORMATTERS = {
|
|
926
|
+
date: ->(v) { v.respond_to?(:strftime) ? v.strftime("%b %d, %Y") : v.to_s },
|
|
927
|
+
datetime: ->(v) { v.respond_to?(:strftime) ? v.strftime("%b %d, %Y %l:%M %p") : v.to_s },
|
|
928
|
+
currency: ->(v) { "$#{format_number(v.to_f, 2)}" },
|
|
929
|
+
number: ->(v) { format_number(v.to_f, 0) },
|
|
930
|
+
percent: ->(v) { "#{(v.to_f * 100).round}%" }
|
|
931
|
+
}.freeze
|
|
932
|
+
|
|
933
|
+
def self.format_number(num, decimals)
|
|
934
|
+
parts = format("%.#{decimals}f", num).split(".")
|
|
935
|
+
parts[0] = parts[0].reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
936
|
+
decimals.positive? ? parts.join(".") : parts[0]
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
def self.apply(value, format_spec)
|
|
940
|
+
return value.to_s if format_spec.nil?
|
|
941
|
+
|
|
942
|
+
formatter = format_spec.is_a?(Proc) ? format_spec : FORMATTERS[format_spec]
|
|
943
|
+
formatter ? formatter.call(value) : value.to_s
|
|
944
|
+
end
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
# Column definition for table DSL
|
|
948
|
+
class TableColumn
|
|
949
|
+
attr_reader :key, :header, :format, :align, :style, :sort_value, :id_style
|
|
950
|
+
|
|
951
|
+
def initialize(key, header: nil, format: nil, align: nil, style: nil, sort_value: nil, id_style: nil, &block)
|
|
952
|
+
@key = key
|
|
953
|
+
@header = header || key.to_s.split("_").map(&:capitalize).join(" ")
|
|
954
|
+
@format = format
|
|
955
|
+
@align = align
|
|
956
|
+
@style = style
|
|
957
|
+
@sort_value = sort_value
|
|
958
|
+
@id_style = id_style
|
|
959
|
+
@value_block = block
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
# @param item [Object] The row's item
|
|
963
|
+
# @param index [Integer, nil] The row's index
|
|
964
|
+
# @param app [StreamWeaver::App, nil] When given, the block is executed via
|
|
965
|
+
# app.instance_exec so it can call component-builder DSL methods
|
|
966
|
+
# (button, badge, hstack, ...); anything it builds is captured and
|
|
967
|
+
# returned as an Array<Components::Base> instead of a formatted scalar
|
|
968
|
+
# (FAC-P2.1 decision 1). Without an app, behavior is unchanged.
|
|
969
|
+
def extract_value(item, index = nil, app: nil, fragment: nil)
|
|
970
|
+
return extract_component_cell(item, index, app, fragment) if app && @value_block
|
|
971
|
+
|
|
972
|
+
raw = if @value_block
|
|
973
|
+
@value_block.arity == 2 ? @value_block.call(item, index) : @value_block.call(item)
|
|
974
|
+
elsif item.respond_to?(@key)
|
|
975
|
+
item.send(@key)
|
|
976
|
+
elsif item.respond_to?(:[])
|
|
977
|
+
item[@key] || item[@key.to_s]
|
|
978
|
+
else
|
|
979
|
+
nil
|
|
980
|
+
end
|
|
981
|
+
TableFormatters.apply(raw, @format)
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
private
|
|
985
|
+
|
|
986
|
+
def extract_component_cell(item, index, app, fragment)
|
|
987
|
+
parent_components = app.components
|
|
988
|
+
pushed_fragment = fragment && app.render_state.fragment_stack.last != fragment
|
|
989
|
+
app.render_state.fragment_stack << fragment if pushed_fragment
|
|
990
|
+
app.components = []
|
|
991
|
+
raw = @value_block.arity == 2 ? app.instance_exec(item, index, &@value_block) : app.instance_exec(item, &@value_block)
|
|
992
|
+
built = app.components
|
|
993
|
+
app.components = parent_components
|
|
994
|
+
|
|
995
|
+
built.any? ? built : TableFormatters.apply(raw, @format)
|
|
996
|
+
ensure
|
|
997
|
+
app.render_state.fragment_stack.pop if pushed_fragment
|
|
998
|
+
end
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
# Table component for displaying tabular data with smart data inference
|
|
1002
|
+
# @example Basic usage (original API)
|
|
1003
|
+
# table headers: ["Name", "Size"], rows: [["app.rb", "12kb"], ["cli.rb", "8kb"]]
|
|
1004
|
+
# @example Array of hashes (auto-infer headers)
|
|
1005
|
+
# table data: [{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }]
|
|
1006
|
+
# @example Hash of arrays
|
|
1007
|
+
# table data: { name: ["Alice", "Bob"], age: [30, 25] }
|
|
1008
|
+
# @example File loading
|
|
1009
|
+
# table file: "users.yaml", path: "data.users"
|
|
1010
|
+
# @example State binding
|
|
1011
|
+
# table data: :users
|
|
1012
|
+
# @example Column DSL
|
|
1013
|
+
# table users do
|
|
1014
|
+
# column :name
|
|
1015
|
+
# column :balance, format: :currency, align: :right
|
|
1016
|
+
# end
|
|
1017
|
+
class Table < Base
|
|
1018
|
+
attr_reader :columns, :resolved_rows, :headers, :rows, :markdown
|
|
1019
|
+
# Deterministic DOM id assigned by the `table` DSL method (display_dsl.rb)
|
|
1020
|
+
# for column-DSL tables, used to build stable `<tr id="#{dom_id}-row-#{key}">`
|
|
1021
|
+
# ids that survive across rebuilds (see that method for why -- FAC row-granular
|
|
1022
|
+
# narrowing, stream_weaver-95k). nil for tables that don't need row addressing.
|
|
1023
|
+
attr_accessor :dom_id
|
|
1024
|
+
|
|
1025
|
+
# Lazily resolves and memoizes a row's key, so column blocks that never
|
|
1026
|
+
# build a button never pay the cost (or the ArgumentError) of an
|
|
1027
|
+
# unresolvable row_key (FAC-P2.1 decision 3).
|
|
1028
|
+
class RowKeyThunk
|
|
1029
|
+
def initialize(&resolver)
|
|
1030
|
+
@resolver = resolver
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
def value
|
|
1034
|
+
return @value if defined?(@value)
|
|
1035
|
+
@value = @resolver.call
|
|
1036
|
+
end
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
def key
|
|
1040
|
+
@data
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1043
|
+
def children
|
|
1044
|
+
@children || []
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
def initialize(data = nil, headers: nil, rows: nil, file: nil, path: nil,
|
|
1048
|
+
striped: false, bordered: false, hoverable: true, compact: false,
|
|
1049
|
+
sortable: false, sticky_header: false, markdown: false, caption: nil,
|
|
1050
|
+
alternating: false, scrollable: false, hover: false, row_key: nil, id_column: nil, **options, &block)
|
|
1051
|
+
@data = data
|
|
1052
|
+
@headers = headers
|
|
1053
|
+
@rows = rows
|
|
1054
|
+
@file = file
|
|
1055
|
+
@path = path
|
|
1056
|
+
@striped = striped
|
|
1057
|
+
@bordered = bordered
|
|
1058
|
+
@hoverable = hoverable
|
|
1059
|
+
@compact = compact
|
|
1060
|
+
@sortable = sortable
|
|
1061
|
+
@sticky_header = sticky_header
|
|
1062
|
+
@markdown = markdown
|
|
1063
|
+
@caption = caption
|
|
1064
|
+
@alternating = alternating
|
|
1065
|
+
@scrollable = scrollable
|
|
1066
|
+
@hover = hover
|
|
1067
|
+
@row_key_proc = row_key
|
|
1068
|
+
@id_column = id_column
|
|
1069
|
+
@options = options
|
|
1070
|
+
@columns = []
|
|
1071
|
+
@transform_block = nil
|
|
1072
|
+
|
|
1073
|
+
if block_given?
|
|
1074
|
+
if block.arity == 1
|
|
1075
|
+
# Transform block: table file: "data.yaml" do |data| data.map {...} end
|
|
1076
|
+
@transform_block = block
|
|
1077
|
+
else
|
|
1078
|
+
# Column DSL block
|
|
1079
|
+
instance_eval(&block)
|
|
1080
|
+
end
|
|
1081
|
+
end
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
# Column DSL method
|
|
1085
|
+
def column(key, header: nil, format: nil, align: nil, style: nil, sort_value: nil, id_style: nil, &block)
|
|
1086
|
+
@columns << TableColumn.new(key, header: header, format: format, align: align, style: style, sort_value: sort_value, id_style: id_style, &block)
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
# Resolves headers/rows (and, for the column DSL, cell components/sort
|
|
1090
|
+
# values/row ids) once. Called eagerly by the `table` DSL method with the
|
|
1091
|
+
# owning app so component cells and their buttons exist before dispatch
|
|
1092
|
+
# ever runs (FAC-P2.1 decision 4) -- and lazily, without an app, from
|
|
1093
|
+
# #render for Table instances built directly (legacy/scalar-only path).
|
|
1094
|
+
def resolve!(app, state, fragment: nil)
|
|
1095
|
+
return self if @resolved
|
|
1096
|
+
|
|
1097
|
+
resolved = resolve_data(state, app, fragment)
|
|
1098
|
+
@resolved_headers = resolved[:headers]
|
|
1099
|
+
@resolved_rows = resolved[:rows]
|
|
1100
|
+
@sort_values = resolved[:sort_values] || []
|
|
1101
|
+
@component_columns = resolved[:component_columns] || []
|
|
1102
|
+
@row_ids = resolved[:row_ids] || []
|
|
1103
|
+
@cell_styles = resolved[:cell_styles] || []
|
|
1104
|
+
@children = @resolved_rows.flat_map { |row| row.flat_map { |cell| cell.is_a?(Array) ? cell : [] } }
|
|
1105
|
+
@resolved = true
|
|
1106
|
+
self
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
def render(view, state)
|
|
1110
|
+
resolve!(nil, state) unless @resolved
|
|
1111
|
+
view.adapter.render_table(view, @resolved_headers, @resolved_rows, table_options, state)
|
|
1112
|
+
end
|
|
1113
|
+
|
|
1114
|
+
def register_callbacks(registry)
|
|
1115
|
+
# Sort is client-side only. Only supported when @data is a Symbol (state-bound key).
|
|
1116
|
+
# Direct-data tables (headers:/rows: without state key) cannot sort — @data would be nil.
|
|
1117
|
+
# Server-paginated sort requires app-level state + re-query; this only sorts in-memory rows.
|
|
1118
|
+
# Sort state uses string keys to avoid collision with update_state, which symbolizes all keys.
|
|
1119
|
+
return unless @sortable && @data.is_a?(Symbol)
|
|
1120
|
+
col_count = @headers ? @headers.length : Array(@columns).length
|
|
1121
|
+
col_count.times do |col_index|
|
|
1122
|
+
registry["#{key}_sort_#{col_index}"] = ->(state) {
|
|
1123
|
+
if state["#{key}_sort_col"] == col_index
|
|
1124
|
+
state["#{key}_sort_dir"] = state["#{key}_sort_dir"] == :asc ? :desc : :asc
|
|
1125
|
+
else
|
|
1126
|
+
state["#{key}_sort_col"] = col_index
|
|
1127
|
+
state["#{key}_sort_dir"] = :asc
|
|
1128
|
+
end
|
|
1129
|
+
}
|
|
1130
|
+
end
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
private
|
|
1134
|
+
|
|
1135
|
+
def resolve_data(state, app = nil, fragment = nil)
|
|
1136
|
+
raw = raw_data(state)
|
|
1137
|
+
normalize(raw, app, fragment)
|
|
1138
|
+
end
|
|
1139
|
+
|
|
1140
|
+
def raw_data(state)
|
|
1141
|
+
return file_data if @file
|
|
1142
|
+
return state[@data] if @data.is_a?(Symbol) && @rows.nil?
|
|
1143
|
+
return @data if @data && !@data.is_a?(Symbol)
|
|
1144
|
+
{ headers: @headers || [], rows: @rows || [] }
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
def file_data
|
|
1148
|
+
raw = load_file(@file)
|
|
1149
|
+
@transform_block ? @transform_block.call(raw) : extract_path(raw, @path)
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
def load_file(path)
|
|
1153
|
+
require "yaml"
|
|
1154
|
+
require "json"
|
|
1155
|
+
expanded = File.expand_path(path)
|
|
1156
|
+
|
|
1157
|
+
case File.extname(expanded).downcase
|
|
1158
|
+
when ".yaml", ".yml" then YAML.safe_load_file(expanded, symbolize_names: true, permitted_classes: [Symbol, Date, Time])
|
|
1159
|
+
when ".json" then JSON.parse(File.read(expanded), symbolize_names: true)
|
|
1160
|
+
else raise ArgumentError, "Unsupported file type: #{path}. Use .yaml, .yml, or .json"
|
|
1161
|
+
end
|
|
1162
|
+
end
|
|
1163
|
+
|
|
1164
|
+
def extract_path(data, path)
|
|
1165
|
+
return data unless path
|
|
1166
|
+
|
|
1167
|
+
path.split(".").reduce(data) do |obj, key|
|
|
1168
|
+
break if obj.nil?
|
|
1169
|
+
key.match?(/\A-?\d+\z/) ? obj[key.to_i] : obj.fetch(key.to_sym) { obj[key] }
|
|
1170
|
+
end
|
|
1171
|
+
end
|
|
1172
|
+
|
|
1173
|
+
def normalize(data, app = nil, fragment = nil)
|
|
1174
|
+
return data if data.is_a?(Hash) && data.key?(:headers) && data.key?(:rows)
|
|
1175
|
+
|
|
1176
|
+
case data
|
|
1177
|
+
when Array
|
|
1178
|
+
normalize_array(data, app, fragment)
|
|
1179
|
+
when Hash
|
|
1180
|
+
normalize_hash_of_arrays(data)
|
|
1181
|
+
else
|
|
1182
|
+
{ headers: [], rows: [] }
|
|
1183
|
+
end
|
|
1184
|
+
end
|
|
1185
|
+
|
|
1186
|
+
def normalize_array(data, app, fragment)
|
|
1187
|
+
return { headers: [], rows: [] } if data.empty?
|
|
1188
|
+
|
|
1189
|
+
first = data.first
|
|
1190
|
+
if @columns.any?
|
|
1191
|
+
# Column DSL - works for Hash rows and for any object the columns'
|
|
1192
|
+
# keys/blocks know how to read (structs, records, ...).
|
|
1193
|
+
build_column_rows(data, app, fragment)
|
|
1194
|
+
elsif first.is_a?(Hash)
|
|
1195
|
+
keys = first.keys
|
|
1196
|
+
headers = keys.map { |k| k.to_s.split("_").map(&:capitalize).join(" ") }
|
|
1197
|
+
rows = data.map { |item| keys.map { |k| (item[k] || item[k.to_s]).to_s } }
|
|
1198
|
+
{ headers: headers, rows: rows }
|
|
1199
|
+
elsif first.is_a?(Array)
|
|
1200
|
+
# Array of arrays - original format, no headers unless provided
|
|
1201
|
+
{ headers: @headers || [], rows: data }
|
|
1202
|
+
else
|
|
1203
|
+
# Simple array - single column
|
|
1204
|
+
{ headers: @headers || ["Value"], rows: data.map { |v| [v.to_s] } }
|
|
1205
|
+
end
|
|
1206
|
+
end
|
|
1207
|
+
|
|
1208
|
+
def build_column_rows(data, app, fragment)
|
|
1209
|
+
headers = @columns.map(&:header)
|
|
1210
|
+
rows = []
|
|
1211
|
+
sort_values = []
|
|
1212
|
+
row_ids = []
|
|
1213
|
+
cell_styles = []
|
|
1214
|
+
|
|
1215
|
+
data.each_with_index do |item, idx|
|
|
1216
|
+
row_key_thunk = RowKeyThunk.new { resolve_row_key(item) }
|
|
1217
|
+
had_render_state = app.respond_to?(:render_state)
|
|
1218
|
+
saved_thunk = app.render_state.current_row_key_thunk if had_render_state
|
|
1219
|
+
app.render_state.current_row_key_thunk = row_key_thunk if had_render_state
|
|
1220
|
+
|
|
1221
|
+
rows << @columns.map { |col| col.extract_value(item, idx, app: app, fragment: fragment) }
|
|
1222
|
+
sort_values << @columns.map { |col| col.sort_value ? col.sort_value.call(item) : nil }
|
|
1223
|
+
cell_styles << @columns.map { |col| resolve_col_style(col, item) }
|
|
1224
|
+
row_ids << row_dom_key(item)
|
|
1225
|
+
|
|
1226
|
+
app.render_state.current_row_key_thunk = saved_thunk if had_render_state
|
|
1227
|
+
end
|
|
1228
|
+
|
|
1229
|
+
component_columns = @columns.each_index.map { |ci| rows.any? { |row| row[ci].is_a?(Array) } }
|
|
1230
|
+
|
|
1231
|
+
{ headers: headers, rows: rows, sort_values: sort_values, component_columns: component_columns, row_ids: row_ids,
|
|
1232
|
+
cell_styles: cell_styles }
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
# Resolves a column's `style:` escape hatch for one row -- a static
|
|
1236
|
+
# String applies to every row, a Proc is called per-row with the item
|
|
1237
|
+
# (FAC table cell style escape hatches, stream_weaver-act).
|
|
1238
|
+
def resolve_col_style(col, item)
|
|
1239
|
+
return nil unless col&.style
|
|
1240
|
+
col.style.is_a?(Proc) ? col.style.call(item) : col.style
|
|
1241
|
+
end
|
|
1242
|
+
|
|
1243
|
+
# @raise [ArgumentError] when no row_key: proc was given and the item has
|
|
1244
|
+
# neither #id nor a :id/"id" Hash key (FAC-P2.1 decision 3). Only
|
|
1245
|
+
# invoked when something actually needs the key (a button built inside
|
|
1246
|
+
# a cell, or DOM row-id assignment) -- rows whose cells stay scalar
|
|
1247
|
+
# never pay for this.
|
|
1248
|
+
def resolve_row_key(item)
|
|
1249
|
+
return @row_key_proc.call(item) if @row_key_proc
|
|
1250
|
+
return item.id if item.respond_to?(:id)
|
|
1251
|
+
if item.is_a?(Hash)
|
|
1252
|
+
return item[:id] if item.key?(:id)
|
|
1253
|
+
return item["id"] if item.key?("id")
|
|
1254
|
+
end
|
|
1255
|
+
|
|
1256
|
+
raise ArgumentError,
|
|
1257
|
+
"table: cannot derive a row_key for #{item.inspect} -- pass row_key: ->(item) { ... } " \
|
|
1258
|
+
"to `table`, or give each item an #id method or :id/\"id\" key."
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1261
|
+
# DOM row ids are best-effort: an unresolvable row_key just means no
|
|
1262
|
+
# <tr id> is assigned, never a raise (FAC-P2.1 decision 7).
|
|
1263
|
+
def row_dom_key(item)
|
|
1264
|
+
resolve_row_key(item)
|
|
1265
|
+
rescue ArgumentError
|
|
1266
|
+
nil
|
|
1267
|
+
end
|
|
1268
|
+
|
|
1269
|
+
def normalize_hash_of_arrays(data)
|
|
1270
|
+
# Hash of arrays: { name: ["Alice", "Bob"], age: [30, 25] }
|
|
1271
|
+
keys = data.keys
|
|
1272
|
+
headers = keys.map { |k| k.to_s.split("_").map(&:capitalize).join(" ") }
|
|
1273
|
+
max_len = data.values.map(&:length).max || 0
|
|
1274
|
+
rows = (0...max_len).map do |i|
|
|
1275
|
+
keys.map { |k| (data[k][i] || "").to_s }
|
|
1276
|
+
end
|
|
1277
|
+
{ headers: headers, rows: rows }
|
|
1278
|
+
end
|
|
1279
|
+
|
|
1280
|
+
def table_options
|
|
1281
|
+
@options.merge(
|
|
1282
|
+
key: @data,
|
|
1283
|
+
striped: @striped,
|
|
1284
|
+
bordered: @bordered,
|
|
1285
|
+
hoverable: @hoverable,
|
|
1286
|
+
compact: @compact,
|
|
1287
|
+
sortable: @sortable,
|
|
1288
|
+
sticky_header: @sticky_header,
|
|
1289
|
+
markdown: @markdown,
|
|
1290
|
+
caption: @caption,
|
|
1291
|
+
columns: @columns,
|
|
1292
|
+
alternating: @alternating,
|
|
1293
|
+
scrollable: @scrollable,
|
|
1294
|
+
hover: @hover,
|
|
1295
|
+
sort_values: @sort_values || [],
|
|
1296
|
+
component_columns: @component_columns || [],
|
|
1297
|
+
row_ids: @row_ids || [],
|
|
1298
|
+
cell_styles: @cell_styles || [],
|
|
1299
|
+
dom_id: @dom_id,
|
|
1300
|
+
id_column: @id_column
|
|
1301
|
+
)
|
|
1302
|
+
end
|
|
1303
|
+
# #table_options is a pure derived-data reader (no side effects); the
|
|
1304
|
+
# adapter calls it, and InteractionRunner's row-narrowing (stream_weaver-95k)
|
|
1305
|
+
# needs it too to compare a table's pre/post-mutation row identity.
|
|
1306
|
+
public :table_options
|
|
1307
|
+
end
|
|
1308
|
+
|
|
1309
|
+
# Markdown component for rendering markdown-formatted content
|
|
1310
|
+
class Markdown < Base
|
|
1311
|
+
attr_reader :content, :options
|
|
1312
|
+
|
|
1313
|
+
# @param content [String, Proc] The markdown content (can be a proc for dynamic content)
|
|
1314
|
+
# @param options [Hash] :class/:style passthrough (stream_weaver-1lo)
|
|
1315
|
+
def initialize(content, **options)
|
|
1316
|
+
@content = content
|
|
1317
|
+
@options = options
|
|
1318
|
+
end
|
|
1319
|
+
|
|
1320
|
+
def render(view, state)
|
|
1321
|
+
content = @content.is_a?(Proc) ? @content.call(state) : @content
|
|
1322
|
+
view.adapter.render_markdown(view, content.to_s, state, @options)
|
|
1323
|
+
end
|
|
1324
|
+
end
|
|
1325
|
+
|
|
1326
|
+
# Header component for semantic headers (h1-h6)
|
|
1327
|
+
class Header < Base
|
|
1328
|
+
attr_reader :level, :options
|
|
1329
|
+
|
|
1330
|
+
# @param content [String, Proc] The header text (can be a proc for dynamic content)
|
|
1331
|
+
# @param level [Integer] Header level (1-6, default: 2)
|
|
1332
|
+
# @param options [Hash] :class/:style passthrough (stream_weaver-1lo)
|
|
1333
|
+
def initialize(content, level: 2, **options)
|
|
1334
|
+
@content = content
|
|
1335
|
+
@level = level.clamp(1, 6)
|
|
1336
|
+
@options = options
|
|
1337
|
+
end
|
|
1338
|
+
|
|
1339
|
+
def render(view, state)
|
|
1340
|
+
content = @content.is_a?(Proc) ? @content.call(state) : @content
|
|
1341
|
+
view.adapter.render_header(view, content.to_s, @level, state, @options)
|
|
1342
|
+
end
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1345
|
+
# CheckboxGroup component for multi-select with select all/none
|
|
1346
|
+
# State is stored as an array of selected values
|
|
1347
|
+
class CheckboxGroup < Base
|
|
1348
|
+
attr_reader :key
|
|
1349
|
+
attr_accessor :children
|
|
1350
|
+
|
|
1351
|
+
# @param key [Symbol] The state key (stores array of selected values)
|
|
1352
|
+
# @param options [Hash] Options including select_all, select_none labels
|
|
1353
|
+
def initialize(key, **options)
|
|
1354
|
+
@key = key
|
|
1355
|
+
@options = options
|
|
1356
|
+
@children = []
|
|
1357
|
+
end
|
|
1358
|
+
|
|
1359
|
+
def render(view, state)
|
|
1360
|
+
view.adapter.render_checkbox_group(view, @key, @children, @options, state)
|
|
1361
|
+
end
|
|
1362
|
+
end
|
|
1363
|
+
|
|
1364
|
+
# CheckboxItem component - individual item within a CheckboxGroup
|
|
1365
|
+
class CheckboxItem < Base
|
|
1366
|
+
attr_reader :value
|
|
1367
|
+
attr_accessor :children
|
|
1368
|
+
|
|
1369
|
+
# @param value [String] The value added to the group's array when checked
|
|
1370
|
+
def initialize(value)
|
|
1371
|
+
@value = value
|
|
1372
|
+
@children = []
|
|
1373
|
+
end
|
|
1374
|
+
end
|
|
1375
|
+
|
|
1376
|
+
# StatusBadge component for visual match indicators
|
|
1377
|
+
# Displays: 🟢 Strong / 🟡 Maybe / 🔴 Skip with reasoning
|
|
1378
|
+
class StatusBadge < Base
|
|
1379
|
+
# @param status [Symbol] One of :strong, :maybe, :skip
|
|
1380
|
+
# @param reasoning [String] Explanation text
|
|
1381
|
+
def initialize(status, reasoning)
|
|
1382
|
+
@status = status
|
|
1383
|
+
@reasoning = reasoning
|
|
1384
|
+
end
|
|
1385
|
+
|
|
1386
|
+
def render(view, state)
|
|
1387
|
+
view.adapter.render_status_badge(view, @status, @reasoning, state)
|
|
1388
|
+
end
|
|
1389
|
+
end
|
|
1390
|
+
|
|
1391
|
+
# TagButtons component for quick-select tag groups
|
|
1392
|
+
# Single-select: clicking a tag selects it (and deselects others)
|
|
1393
|
+
class TagButtons < Base
|
|
1394
|
+
attr_reader :key
|
|
1395
|
+
|
|
1396
|
+
# @param key [Symbol] The state key for selected tag
|
|
1397
|
+
# @param tags [Array<String>] The available tag labels
|
|
1398
|
+
# @param options [Hash] Options (e.g., style: :destructive)
|
|
1399
|
+
def initialize(key, tags, **options)
|
|
1400
|
+
@key = key
|
|
1401
|
+
@tags = tags
|
|
1402
|
+
@options = options
|
|
1403
|
+
end
|
|
1404
|
+
|
|
1405
|
+
def render(view, state)
|
|
1406
|
+
view.adapter.render_tag_buttons(view, @key, @tags, @options, state)
|
|
1407
|
+
end
|
|
1408
|
+
end
|
|
1409
|
+
|
|
1410
|
+
# ChipGroup component for tag/chip multi-select bound to a state array.
|
|
1411
|
+
# multi: true (default) stores an Array of selected values; multi: false
|
|
1412
|
+
# stores a single scalar (radio-style exclusive selection).
|
|
1413
|
+
class ChipGroup < Base
|
|
1414
|
+
attr_reader :key, :choices, :multi, :options
|
|
1415
|
+
|
|
1416
|
+
# @param key [Symbol] The state key (Array when multi, scalar otherwise)
|
|
1417
|
+
# @param choices [Array<String>, Array<Array(String, Object)>] Chip labels,
|
|
1418
|
+
# or [label, value] pairs
|
|
1419
|
+
# @param multi [Boolean] Multi-select (Array state) vs single-select (default: true)
|
|
1420
|
+
# @param options [Hash] Additional options (e.g. submit: false)
|
|
1421
|
+
def initialize(key, choices = [], multi: true, **options)
|
|
1422
|
+
@key = key
|
|
1423
|
+
@choices = choices
|
|
1424
|
+
@multi = multi
|
|
1425
|
+
@options = options
|
|
1426
|
+
end
|
|
1427
|
+
|
|
1428
|
+
def render(view, state)
|
|
1429
|
+
view.adapter.render_chip_group(view, self, state)
|
|
1430
|
+
end
|
|
1431
|
+
end
|
|
1432
|
+
|
|
1433
|
+
# ExternalLinkButton component - opens URL and optionally submits form
|
|
1434
|
+
class ExternalLinkButton < Base
|
|
1435
|
+
# @param label [String] Button label
|
|
1436
|
+
# @param url [String] URL to open in new tab
|
|
1437
|
+
# @param submit [Boolean] Whether to also submit the form (default: false)
|
|
1438
|
+
def initialize(label, url:, submit: false)
|
|
1439
|
+
@label = label
|
|
1440
|
+
@url = url
|
|
1441
|
+
@submit = submit
|
|
1442
|
+
end
|
|
1443
|
+
|
|
1444
|
+
def render(view, state)
|
|
1445
|
+
view.adapter.render_external_link_button(view, @label, @url, @submit, state)
|
|
1446
|
+
end
|
|
1447
|
+
end
|
|
1448
|
+
|
|
1449
|
+
# Columns component for multi-column layouts
|
|
1450
|
+
# Contains Column children for flexible sidebar/content arrangements
|
|
1451
|
+
class Columns < Base
|
|
1452
|
+
attr_accessor :children
|
|
1453
|
+
attr_reader :widths
|
|
1454
|
+
|
|
1455
|
+
# @param widths [Array<String>, nil] Optional column widths (e.g., ['30%', '70%'])
|
|
1456
|
+
# @param options [Hash] Additional options (e.g., gap)
|
|
1457
|
+
def initialize(widths: nil, **options)
|
|
1458
|
+
@widths = widths
|
|
1459
|
+
@options = options
|
|
1460
|
+
@children = []
|
|
1461
|
+
end
|
|
1462
|
+
|
|
1463
|
+
def render(view, state)
|
|
1464
|
+
view.adapter.render_columns(view, @widths, @children, @options, state)
|
|
1465
|
+
end
|
|
1466
|
+
end
|
|
1467
|
+
|
|
1468
|
+
# Column component - individual column within a Columns container
|
|
1469
|
+
class Column < Base
|
|
1470
|
+
attr_accessor :children, :width
|
|
1471
|
+
|
|
1472
|
+
# @param options [Hash] Options (e.g., class for additional styling)
|
|
1473
|
+
def initialize(**options)
|
|
1474
|
+
@options = options
|
|
1475
|
+
@children = []
|
|
1476
|
+
@width = nil # Set by parent Columns during render
|
|
1477
|
+
end
|
|
1478
|
+
|
|
1479
|
+
def render(view, state)
|
|
1480
|
+
view.adapter.render_column(view, @width, @children, @options, state)
|
|
1481
|
+
end
|
|
1482
|
+
end
|
|
1483
|
+
|
|
1484
|
+
# Form component for deferred submission forms
|
|
1485
|
+
# Groups multiple form elements together, uses client-side only state until submission,
|
|
1486
|
+
# and sends all values in a single HTMX POST on submit.
|
|
1487
|
+
class Form < Base
|
|
1488
|
+
attr_reader :name, :submit_label, :cancel_label, :submit_action
|
|
1489
|
+
attr_accessor :children
|
|
1490
|
+
|
|
1491
|
+
# @param name [Symbol] The form name (used as state key, e.g., :edit_person)
|
|
1492
|
+
# @param options [Hash] Additional options
|
|
1493
|
+
def initialize(name, **options)
|
|
1494
|
+
@name = name
|
|
1495
|
+
@options = options
|
|
1496
|
+
@children = []
|
|
1497
|
+
@submit_label = nil
|
|
1498
|
+
@cancel_label = nil
|
|
1499
|
+
@submit_action = nil
|
|
1500
|
+
end
|
|
1501
|
+
|
|
1502
|
+
# Set the submit button configuration
|
|
1503
|
+
# @param label [String] Button label text
|
|
1504
|
+
# @param block [Proc] Action to execute on submit (receives form_values hash)
|
|
1505
|
+
def set_submit(label, &block)
|
|
1506
|
+
@submit_label = label
|
|
1507
|
+
@submit_action = block
|
|
1508
|
+
end
|
|
1509
|
+
|
|
1510
|
+
# Set the cancel button configuration
|
|
1511
|
+
# @param label [String] Button label text
|
|
1512
|
+
def set_cancel(label)
|
|
1513
|
+
@cancel_label = label
|
|
1514
|
+
end
|
|
1515
|
+
|
|
1516
|
+
# Execute the submit action block
|
|
1517
|
+
# @param state [Hash] Current state hash
|
|
1518
|
+
# @param form_values [Hash] The submitted form values
|
|
1519
|
+
def execute_submit(state, form_values)
|
|
1520
|
+
@submit_action&.call(form_values)
|
|
1521
|
+
end
|
|
1522
|
+
|
|
1523
|
+
def render(view, state)
|
|
1524
|
+
view.adapter.render_form(view, @name, @children, @submit_label, @cancel_label, @options, state)
|
|
1525
|
+
end
|
|
1526
|
+
end
|
|
1527
|
+
|
|
1528
|
+
# =========================================
|
|
1529
|
+
# Navigation Components
|
|
1530
|
+
# =========================================
|
|
1531
|
+
|
|
1532
|
+
# Tabs container component for tabbed navigation
|
|
1533
|
+
# Contains Tab children, manages active tab state via state key
|
|
1534
|
+
class Tabs < Base
|
|
1535
|
+
attr_reader :key, :variant, :lazy, :url, :options
|
|
1536
|
+
attr_accessor :children
|
|
1537
|
+
|
|
1538
|
+
# @param key [Symbol] The state key for active tab index
|
|
1539
|
+
# @param variant [Symbol] Visual variant (:line, :enclosed, :soft-rounded)
|
|
1540
|
+
# @param lazy [Boolean] When true, only render active tab content and morph on switch
|
|
1541
|
+
# @param url [Boolean] When true, the active tab is reflected in the URL
|
|
1542
|
+
# @param options [Hash] Additional options
|
|
1543
|
+
def initialize(key, variant: :line, lazy: false, url: false, **options)
|
|
1544
|
+
@key = key
|
|
1545
|
+
@variant = variant
|
|
1546
|
+
@lazy = lazy
|
|
1547
|
+
@url = url
|
|
1548
|
+
@options = options
|
|
1549
|
+
@children = []
|
|
1550
|
+
end
|
|
1551
|
+
|
|
1552
|
+
def render(view, state)
|
|
1553
|
+
view.adapter.render_tabs(view, self, state)
|
|
1554
|
+
end
|
|
1555
|
+
|
|
1556
|
+
def register_callbacks(registry)
|
|
1557
|
+
children.each_with_index do |_tab, index|
|
|
1558
|
+
registry["#{@key}_tab_#{index}"] = ->(state) { state[@key] = index }
|
|
1559
|
+
end
|
|
1560
|
+
end
|
|
1561
|
+
end
|
|
1562
|
+
|
|
1563
|
+
# Tab component - individual tab within a Tabs container
|
|
1564
|
+
class Tab < Base
|
|
1565
|
+
attr_reader :label, :options
|
|
1566
|
+
attr_accessor :children
|
|
1567
|
+
|
|
1568
|
+
# @param label [String] The tab label text
|
|
1569
|
+
# @param options [Hash] Additional options
|
|
1570
|
+
def initialize(label, **options)
|
|
1571
|
+
@label = label
|
|
1572
|
+
@options = options
|
|
1573
|
+
@children = []
|
|
1574
|
+
end
|
|
1575
|
+
end
|
|
1576
|
+
|
|
1577
|
+
# Breadcrumbs container component for navigation trail
|
|
1578
|
+
class Breadcrumbs < Base
|
|
1579
|
+
attr_reader :separator, :options
|
|
1580
|
+
attr_accessor :children
|
|
1581
|
+
|
|
1582
|
+
# @param separator [String] Separator character between crumbs (default: "/")
|
|
1583
|
+
# @param options [Hash] Additional options
|
|
1584
|
+
def initialize(separator: "/", **options)
|
|
1585
|
+
@separator = separator
|
|
1586
|
+
@options = options
|
|
1587
|
+
@children = []
|
|
1588
|
+
end
|
|
1589
|
+
|
|
1590
|
+
def render(view, state)
|
|
1591
|
+
view.adapter.render_breadcrumbs(view, self, state)
|
|
1592
|
+
end
|
|
1593
|
+
end
|
|
1594
|
+
|
|
1595
|
+
# Crumb component - individual item within Breadcrumbs
|
|
1596
|
+
class Crumb < Base
|
|
1597
|
+
attr_reader :label, :href, :options
|
|
1598
|
+
|
|
1599
|
+
# @param label [String] The crumb text
|
|
1600
|
+
# @param href [String, nil] Optional link URL (nil for current/last crumb)
|
|
1601
|
+
# @param options [Hash] Additional options
|
|
1602
|
+
def initialize(label, href: nil, **options)
|
|
1603
|
+
@label = label
|
|
1604
|
+
@href = href
|
|
1605
|
+
@options = options
|
|
1606
|
+
end
|
|
1607
|
+
end
|
|
1608
|
+
|
|
1609
|
+
# Dropdown container component for menus
|
|
1610
|
+
class Dropdown < Base
|
|
1611
|
+
attr_reader :options
|
|
1612
|
+
attr_accessor :trigger_component, :menu_component
|
|
1613
|
+
|
|
1614
|
+
# @param options [Hash] Additional options
|
|
1615
|
+
def initialize(**options)
|
|
1616
|
+
@options = options
|
|
1617
|
+
@trigger_component = nil
|
|
1618
|
+
@menu_component = nil
|
|
1619
|
+
end
|
|
1620
|
+
|
|
1621
|
+
# Union of trigger/menu, so component-tree walkers (button/menu_item lookup
|
|
1622
|
+
# in server.rb) that only know about `children` can still find the trigger
|
|
1623
|
+
# button and menu_items nested inside a dropdown's trigger/menu blocks.
|
|
1624
|
+
# Same fix as AppShell#children -- see that class for the full rationale.
|
|
1625
|
+
def children
|
|
1626
|
+
[trigger_component, menu_component].compact
|
|
1627
|
+
end
|
|
1628
|
+
|
|
1629
|
+
def render(view, state)
|
|
1630
|
+
view.adapter.render_dropdown(view, self, state)
|
|
1631
|
+
end
|
|
1632
|
+
end
|
|
1633
|
+
|
|
1634
|
+
# DropdownTrigger component - the clickable element that opens the menu
|
|
1635
|
+
class DropdownTrigger < Base
|
|
1636
|
+
attr_accessor :children
|
|
1637
|
+
|
|
1638
|
+
def initialize
|
|
1639
|
+
@children = []
|
|
1640
|
+
end
|
|
1641
|
+
end
|
|
1642
|
+
|
|
1643
|
+
# Menu component - the dropdown menu container
|
|
1644
|
+
class Menu < Base
|
|
1645
|
+
attr_reader :options
|
|
1646
|
+
attr_accessor :children
|
|
1647
|
+
|
|
1648
|
+
# @param options [Hash] Additional options
|
|
1649
|
+
def initialize(**options)
|
|
1650
|
+
@options = options
|
|
1651
|
+
@children = []
|
|
1652
|
+
end
|
|
1653
|
+
end
|
|
1654
|
+
|
|
1655
|
+
# MenuItem component - individual item within a Menu
|
|
1656
|
+
class MenuItem < Base
|
|
1657
|
+
attr_reader :label, :style, :action, :options
|
|
1658
|
+
|
|
1659
|
+
# @param label [String] The menu item text
|
|
1660
|
+
# @param style [Symbol] Visual style (:default, :destructive)
|
|
1661
|
+
# @param options [Hash] Additional options
|
|
1662
|
+
# @param block [Proc] Action to execute on click
|
|
1663
|
+
def initialize(label, style: :default, **options, &block)
|
|
1664
|
+
@label = label
|
|
1665
|
+
@style = style
|
|
1666
|
+
@action = block
|
|
1667
|
+
@options = options
|
|
1668
|
+
end
|
|
1669
|
+
|
|
1670
|
+
# The DSL's `menu_item` method sets @id via instance_variable_set (see
|
|
1671
|
+
# App#menu_item) rather than passing it through the constructor -- expose
|
|
1672
|
+
# it so find_button_recursive can match on it the same way it matches
|
|
1673
|
+
# Components::Button#id.
|
|
1674
|
+
def id
|
|
1675
|
+
@id
|
|
1676
|
+
end
|
|
1677
|
+
|
|
1678
|
+
def execute(state)
|
|
1679
|
+
@action&.call(state)
|
|
1680
|
+
end
|
|
1681
|
+
end
|
|
1682
|
+
|
|
1683
|
+
# MenuDivider component - visual separator between menu items
|
|
1684
|
+
class MenuDivider < Base
|
|
1685
|
+
def render(view, state)
|
|
1686
|
+
view.hr(class: "sw-menu-divider")
|
|
1687
|
+
end
|
|
1688
|
+
end
|
|
1689
|
+
|
|
1690
|
+
# =========================================
|
|
1691
|
+
# Link / Navbar / NavItem Components
|
|
1692
|
+
# =========================================
|
|
1693
|
+
|
|
1694
|
+
class Link < Base
|
|
1695
|
+
attr_reader :label, :href, :options
|
|
1696
|
+
|
|
1697
|
+
def initialize(label, href:, **options)
|
|
1698
|
+
@label = label
|
|
1699
|
+
@href = href
|
|
1700
|
+
@options = options
|
|
1701
|
+
end
|
|
1702
|
+
|
|
1703
|
+
def render(view, state)
|
|
1704
|
+
view.adapter.render_link(view, self, state)
|
|
1705
|
+
end
|
|
1706
|
+
end
|
|
1707
|
+
|
|
1708
|
+
# Wraps arbitrary composed content as a single click target -- either a
|
|
1709
|
+
# named-action dispatcher (wired exactly like a named-action button:
|
|
1710
|
+
# token, fragment context, loading indicator on the wrapper) or a plain
|
|
1711
|
+
# navigation link (routed pages). See App#clickable, which builds the
|
|
1712
|
+
# action_token/wrapper_id the same way App#button builds a named-action
|
|
1713
|
+
# button's (stream_weaver-1lo).
|
|
1714
|
+
class Clickable < Base
|
|
1715
|
+
attr_accessor :children
|
|
1716
|
+
attr_reader :href, :options
|
|
1717
|
+
|
|
1718
|
+
# @param href [String, nil] Plain-navigation form -- renders a real <a>.
|
|
1719
|
+
# Mutually exclusive with the action: form (see App#clickable).
|
|
1720
|
+
# @param wrapper_id [String, nil] Stable id for the action: form
|
|
1721
|
+
# @param options [Hash] :class/:style, plus (action: form only)
|
|
1722
|
+
# :action_token/:updates/:primary
|
|
1723
|
+
def initialize(href: nil, wrapper_id: nil, **options)
|
|
1724
|
+
@href = href
|
|
1725
|
+
@wrapper_id = wrapper_id
|
|
1726
|
+
@options = options
|
|
1727
|
+
@children = []
|
|
1728
|
+
end
|
|
1729
|
+
|
|
1730
|
+
def id
|
|
1731
|
+
@wrapper_id
|
|
1732
|
+
end
|
|
1733
|
+
|
|
1734
|
+
def id=(new_id)
|
|
1735
|
+
@wrapper_id = new_id
|
|
1736
|
+
end
|
|
1737
|
+
|
|
1738
|
+
def render(view, state)
|
|
1739
|
+
view.adapter.render_clickable(view, self, state)
|
|
1740
|
+
end
|
|
1741
|
+
end
|
|
1742
|
+
|
|
1743
|
+
class Navbar < Base
|
|
1744
|
+
attr_accessor :children
|
|
1745
|
+
attr_reader :options
|
|
1746
|
+
|
|
1747
|
+
def initialize(**options)
|
|
1748
|
+
@options = options
|
|
1749
|
+
@children = []
|
|
1750
|
+
end
|
|
1751
|
+
|
|
1752
|
+
def render(view, state)
|
|
1753
|
+
view.adapter.render_navbar(view, self, state)
|
|
1754
|
+
end
|
|
1755
|
+
end
|
|
1756
|
+
|
|
1757
|
+
# App-chrome header bar: brand (icon/glyph + wordmark), breadcrumb
|
|
1758
|
+
# trail, and trailing content (block children -- e.g. badge/pill
|
|
1759
|
+
# status). Replaces the hand-rolled tc-topbar div/phrase soup both
|
|
1760
|
+
# tyrion parity slices needed (stream_weaver-oeo, design-parity-
|
|
1761
|
+
# fights.md finding #6).
|
|
1762
|
+
#
|
|
1763
|
+
# @option icon [String] emoji/glyph, or a URL/path rendered as an
|
|
1764
|
+
# image (same detection as Lane#icon)
|
|
1765
|
+
# @option wordmark [String] brand name text
|
|
1766
|
+
# @option breadcrumbs [Array<String>] trail items; the last one is
|
|
1767
|
+
# marked active
|
|
1768
|
+
class Topbar < Base
|
|
1769
|
+
attr_accessor :children
|
|
1770
|
+
attr_reader :icon, :wordmark, :breadcrumbs, :options
|
|
1771
|
+
|
|
1772
|
+
def initialize(icon: nil, wordmark: nil, breadcrumbs: [], **options)
|
|
1773
|
+
@icon = icon
|
|
1774
|
+
@wordmark = wordmark
|
|
1775
|
+
@breadcrumbs = Array(breadcrumbs)
|
|
1776
|
+
@options = options
|
|
1777
|
+
@children = []
|
|
1778
|
+
end
|
|
1779
|
+
|
|
1780
|
+
def render(view, state)
|
|
1781
|
+
view.adapter.render_topbar(view, self, state)
|
|
1782
|
+
end
|
|
1783
|
+
end
|
|
1784
|
+
|
|
1785
|
+
class NavItem < Base
|
|
1786
|
+
attr_reader :label, :href, :close, :options
|
|
1787
|
+
|
|
1788
|
+
# @option close [Boolean, String] decorative tab-close chrome. true uses
|
|
1789
|
+
# the conventional multiplication sign; a String supplies the glyph.
|
|
1790
|
+
def initialize(label, href: nil, active: false, close: false, **options)
|
|
1791
|
+
@label = label
|
|
1792
|
+
@href = href
|
|
1793
|
+
@active = active
|
|
1794
|
+
@close = close
|
|
1795
|
+
@options = options
|
|
1796
|
+
end
|
|
1797
|
+
|
|
1798
|
+
def active? = @active
|
|
1799
|
+
|
|
1800
|
+
def close_label
|
|
1801
|
+
return nil unless @close
|
|
1802
|
+
|
|
1803
|
+
@close == true ? "×" : @close.to_s
|
|
1804
|
+
end
|
|
1805
|
+
|
|
1806
|
+
def render(view, state)
|
|
1807
|
+
view.adapter.render_nav_item(view, self, state)
|
|
1808
|
+
end
|
|
1809
|
+
end
|
|
1810
|
+
|
|
1811
|
+
# =========================================
|
|
1812
|
+
# Modal Components
|
|
1813
|
+
# =========================================
|
|
1814
|
+
|
|
1815
|
+
# Modal container component for dialog overlays
|
|
1816
|
+
# State key: :{key}_open controls visibility (true/false)
|
|
1817
|
+
class Modal < Base
|
|
1818
|
+
attr_reader :key, :title, :size, :options
|
|
1819
|
+
attr_accessor :children, :footer_component
|
|
1820
|
+
|
|
1821
|
+
# @param key [Symbol] State key for modal (opens when state[:"#{key}_open"] is true)
|
|
1822
|
+
# @param title [String, nil] Optional modal title
|
|
1823
|
+
# @param size [Symbol] Modal size (:sm, :md, :lg, :xl) - default :md
|
|
1824
|
+
# @param options [Hash] Additional options
|
|
1825
|
+
def initialize(key, title: nil, size: :md, **options)
|
|
1826
|
+
@key = key
|
|
1827
|
+
@title = title
|
|
1828
|
+
@size = size
|
|
1829
|
+
@options = options
|
|
1830
|
+
@children = []
|
|
1831
|
+
@footer_component = nil
|
|
1832
|
+
end
|
|
1833
|
+
|
|
1834
|
+
def render(view, state)
|
|
1835
|
+
view.adapter.render_modal(view, self, state)
|
|
1836
|
+
end
|
|
1837
|
+
|
|
1838
|
+
def register_callbacks(registry)
|
|
1839
|
+
return unless footer_component
|
|
1840
|
+
# footer_component (ModalFooter) is not in children — traverse its children explicitly.
|
|
1841
|
+
# ModalFooter is a plain container; its children are the interactive components (buttons).
|
|
1842
|
+
Array(footer_component.children).each { |c| c.register_callbacks(registry) }
|
|
1843
|
+
end
|
|
1844
|
+
end
|
|
1845
|
+
|
|
1846
|
+
# ModalFooter component - footer section with action buttons
|
|
1847
|
+
class ModalFooter < Base
|
|
1848
|
+
attr_accessor :children
|
|
1849
|
+
|
|
1850
|
+
def initialize(**options)
|
|
1851
|
+
@options = options
|
|
1852
|
+
@children = []
|
|
1853
|
+
end
|
|
1854
|
+
end
|
|
1855
|
+
|
|
1856
|
+
# =========================================
|
|
1857
|
+
# Feedback Components
|
|
1858
|
+
# =========================================
|
|
1859
|
+
|
|
1860
|
+
# Alert component for static feedback messages
|
|
1861
|
+
# Displays contextual messages with variant styling
|
|
1862
|
+
class Alert < Base
|
|
1863
|
+
attr_reader :variant, :title, :dismissible
|
|
1864
|
+
attr_accessor :children
|
|
1865
|
+
|
|
1866
|
+
# @param variant [Symbol] Alert type (:info, :success, :warning, :error)
|
|
1867
|
+
# @param title [String, nil] Optional alert title
|
|
1868
|
+
# @param dismissible [Boolean] Whether alert can be dismissed (default: false)
|
|
1869
|
+
# @param options [Hash] Additional options
|
|
1870
|
+
def initialize(variant: :info, title: nil, dismissible: false, **options)
|
|
1871
|
+
@variant = variant
|
|
1872
|
+
@title = title
|
|
1873
|
+
@dismissible = dismissible
|
|
1874
|
+
@options = options
|
|
1875
|
+
@children = []
|
|
1876
|
+
end
|
|
1877
|
+
|
|
1878
|
+
def render(view, state)
|
|
1879
|
+
view.adapter.render_alert(view, self, state)
|
|
1880
|
+
end
|
|
1881
|
+
end
|
|
1882
|
+
|
|
1883
|
+
# ToastContainer component for displaying multiple stacked notifications
|
|
1884
|
+
# Renders all active toasts from state[:_toasts] array
|
|
1885
|
+
class ToastContainer < Base
|
|
1886
|
+
attr_reader :position, :duration
|
|
1887
|
+
|
|
1888
|
+
# @param position [Symbol] Screen position (:top_right, :top_left, :bottom_right, :bottom_left)
|
|
1889
|
+
# @param duration [Integer] Default auto-dismiss duration in milliseconds (0 = no auto-dismiss)
|
|
1890
|
+
# @param options [Hash] Additional options
|
|
1891
|
+
def initialize(position: :top_right, duration: 5000, **options)
|
|
1892
|
+
@position = position
|
|
1893
|
+
@duration = duration
|
|
1894
|
+
@options = options
|
|
1895
|
+
end
|
|
1896
|
+
|
|
1897
|
+
def render(view, state)
|
|
1898
|
+
view.adapter.render_toast_container(view, self, state)
|
|
1899
|
+
end
|
|
1900
|
+
end
|
|
1901
|
+
|
|
1902
|
+
# ProgressBar component for visual progress indication
|
|
1903
|
+
class ProgressBar < Base
|
|
1904
|
+
attr_reader :value, :max, :variant, :show_label, :animated
|
|
1905
|
+
|
|
1906
|
+
# @param value [Integer, Symbol] Current value (0-100) or state key
|
|
1907
|
+
# @param max [Integer] Maximum value (default: 100)
|
|
1908
|
+
# @param variant [Symbol] Style (:default, :success, :warning, :error)
|
|
1909
|
+
# @param show_label [Boolean] Show percentage label (default: false)
|
|
1910
|
+
# @param animated [Boolean] Show animation (default: false)
|
|
1911
|
+
# @param options [Hash] Additional options
|
|
1912
|
+
def initialize(value:, max: 100, variant: :default, show_label: false, animated: false, **options)
|
|
1913
|
+
@value = value
|
|
1914
|
+
@max = max
|
|
1915
|
+
@variant = variant
|
|
1916
|
+
@show_label = show_label
|
|
1917
|
+
@animated = animated
|
|
1918
|
+
@options = options
|
|
1919
|
+
end
|
|
1920
|
+
|
|
1921
|
+
def render(view, state)
|
|
1922
|
+
# Resolve value from state if it's a symbol
|
|
1923
|
+
actual_value = @value.is_a?(Symbol) ? (state[@value] || 0) : @value
|
|
1924
|
+
view.adapter.render_progress_bar(view, actual_value, @max, @variant, @show_label, @animated, @options, state)
|
|
1925
|
+
end
|
|
1926
|
+
end
|
|
1927
|
+
|
|
1928
|
+
# Spinner component for loading states
|
|
1929
|
+
class Spinner < Base
|
|
1930
|
+
attr_reader :size, :label
|
|
1931
|
+
|
|
1932
|
+
# @param size [Symbol] Spinner size (:sm, :md, :lg)
|
|
1933
|
+
# @param label [String, nil] Optional loading text
|
|
1934
|
+
# @param options [Hash] Additional options
|
|
1935
|
+
def initialize(size: :md, label: nil, **options)
|
|
1936
|
+
@size = size
|
|
1937
|
+
@label = label
|
|
1938
|
+
@options = options
|
|
1939
|
+
end
|
|
1940
|
+
|
|
1941
|
+
def render(view, state)
|
|
1942
|
+
view.adapter.render_spinner(view, @size, @label, @options, state)
|
|
1943
|
+
end
|
|
1944
|
+
end
|
|
1945
|
+
|
|
1946
|
+
# CanvasContinue marker - tells JavaScript to show spinner instead of "close window"
|
|
1947
|
+
# Used in multi-phase canvas flows where more content is coming
|
|
1948
|
+
class CanvasContinue < Base
|
|
1949
|
+
attr_reader :message
|
|
1950
|
+
|
|
1951
|
+
# @param message [String] Message to show while processing
|
|
1952
|
+
def initialize(message: "Processing...")
|
|
1953
|
+
@message = message
|
|
1954
|
+
end
|
|
1955
|
+
|
|
1956
|
+
def render(view, state)
|
|
1957
|
+
view.adapter.render_canvas_continue(view, @message, state)
|
|
1958
|
+
end
|
|
1959
|
+
end
|
|
1960
|
+
|
|
1961
|
+
# ThemeSwitcher component for runtime theme selection
|
|
1962
|
+
# Renders a dropdown to switch between available themes
|
|
1963
|
+
class ThemeSwitcher < Base
|
|
1964
|
+
attr_reader :position, :show_label
|
|
1965
|
+
|
|
1966
|
+
# Built-in themes (for backwards compatibility)
|
|
1967
|
+
THEMES = [
|
|
1968
|
+
{ id: :default, label: "Default", description: "Warm Industrial" },
|
|
1969
|
+
{ id: :dashboard, label: "Dashboard", description: "Data Dense" },
|
|
1970
|
+
{ id: :document, label: "Document", description: "Reading Mode" }
|
|
1971
|
+
].freeze
|
|
1972
|
+
|
|
1973
|
+
# @param position [Symbol] Position (:inline, :fixed_top_right)
|
|
1974
|
+
# @param show_label [Boolean] Show "Theme:" label
|
|
1975
|
+
# @param options [Hash] Additional options
|
|
1976
|
+
def initialize(position: :inline, show_label: true, **options)
|
|
1977
|
+
@position = position
|
|
1978
|
+
@show_label = show_label
|
|
1979
|
+
@options = options
|
|
1980
|
+
end
|
|
1981
|
+
|
|
1982
|
+
# Get all available themes (built-in + custom registered)
|
|
1983
|
+
def themes
|
|
1984
|
+
StreamWeaver.all_themes_for_switcher
|
|
1985
|
+
end
|
|
1986
|
+
|
|
1987
|
+
def render(view, state)
|
|
1988
|
+
view.adapter.render_theme_switcher(view, self, state)
|
|
1989
|
+
end
|
|
1990
|
+
end
|
|
1991
|
+
|
|
1992
|
+
# ThemeToggle component for visual skills auto-mode theme switching.
|
|
1993
|
+
# Manages data-sw-theme attribute on <html>, <meta name="theme-color">,
|
|
1994
|
+
# and localStorage persistence.
|
|
1995
|
+
#
|
|
1996
|
+
# Unlike ThemeSwitcher (which handles theme _selection_ among presets),
|
|
1997
|
+
# ThemeToggle handles dark/light/auto _mode_ switching.
|
|
1998
|
+
#
|
|
1999
|
+
# sw- CSS classes:
|
|
2000
|
+
# sw-theme-toggle - toggle button container
|
|
2001
|
+
# sw-theme-toggle--auto - when in auto mode
|
|
2002
|
+
# sw-theme-toggle__btn - the button element
|
|
2003
|
+
# sw-theme-toggle__icon - sun/moon icon span
|
|
2004
|
+
class ThemeToggle < Base
|
|
2005
|
+
attr_reader :mode, :hotkey, :persist
|
|
2006
|
+
|
|
2007
|
+
# @param mode [Symbol] Initial mode (:dark, :light, :auto)
|
|
2008
|
+
# @param hotkey [String, nil] Keyboard shortcut (e.g. "mod+shift+l")
|
|
2009
|
+
# @param persist [Boolean] Persist preference in localStorage
|
|
2010
|
+
# @param options [Hash] Additional options
|
|
2011
|
+
def initialize(mode: :auto, hotkey: nil, persist: true, **options)
|
|
2012
|
+
@mode = mode
|
|
2013
|
+
@hotkey = hotkey
|
|
2014
|
+
@persist = persist
|
|
2015
|
+
@options = options
|
|
2016
|
+
end
|
|
2017
|
+
|
|
2018
|
+
def render(view, state)
|
|
2019
|
+
view.adapter.render_theme_toggle(view, self, state)
|
|
2020
|
+
end
|
|
2021
|
+
end
|
|
2022
|
+
|
|
2023
|
+
# ThemePreset component for applying curated theme presets.
|
|
2024
|
+
# Injects a Google Fonts <link> tag and a <style> block with
|
|
2025
|
+
# CSS custom property overrides for both light and dark modes.
|
|
2026
|
+
#
|
|
2027
|
+
# This is a non-visual "head-level" component: it renders CSS
|
|
2028
|
+
# infrastructure, not visible DOM elements.
|
|
2029
|
+
#
|
|
2030
|
+
# @example DSL usage
|
|
2031
|
+
# theme_preset :editorial
|
|
2032
|
+
# theme_preset :warm
|
|
2033
|
+
class ThemePreset < Base
|
|
2034
|
+
attr_reader :preset_name, :preset
|
|
2035
|
+
|
|
2036
|
+
# @param name [Symbol] Preset name (:editorial, :technical, :warm, :minimal, :terminal)
|
|
2037
|
+
# @param options [Hash] Additional options
|
|
2038
|
+
# @raise [ArgumentError] if preset name is not recognized
|
|
2039
|
+
def initialize(name, **options)
|
|
2040
|
+
@preset_name = name.to_sym
|
|
2041
|
+
@preset = Theme::Presets.get(@preset_name)
|
|
2042
|
+
raise ArgumentError, "Unknown theme preset: #{name}. Available: #{Theme::Presets.available.join(', ')}" unless @preset
|
|
2043
|
+
@options = options
|
|
2044
|
+
end
|
|
2045
|
+
|
|
2046
|
+
def render(view, state)
|
|
2047
|
+
view.adapter.render_theme_preset(view, self, state)
|
|
2048
|
+
end
|
|
2049
|
+
end
|
|
2050
|
+
|
|
2051
|
+
# =========================================
|
|
2052
|
+
# Chart Components
|
|
2053
|
+
# =========================================
|
|
2054
|
+
|
|
2055
|
+
# Shared functionality for all chart types
|
|
2056
|
+
class ChartBase < Base
|
|
2057
|
+
attr_reader :options
|
|
2058
|
+
|
|
2059
|
+
def initialize(data: nil, file: nil, path: nil, labels: nil, values: nil, **options, &block)
|
|
2060
|
+
@data = data
|
|
2061
|
+
@file = file
|
|
2062
|
+
@path = path
|
|
2063
|
+
@labels = labels
|
|
2064
|
+
@values = values
|
|
2065
|
+
@transform_block = block
|
|
2066
|
+
@options = options
|
|
2067
|
+
end
|
|
2068
|
+
|
|
2069
|
+
def resolve_data(state)
|
|
2070
|
+
normalize(raw_data(state))
|
|
2071
|
+
end
|
|
2072
|
+
|
|
2073
|
+
private
|
|
2074
|
+
|
|
2075
|
+
def raw_data(state)
|
|
2076
|
+
return file_data if @file
|
|
2077
|
+
return state[@data] if @data.is_a?(Symbol)
|
|
2078
|
+
return @data if @data
|
|
2079
|
+
{ labels: @labels || [], values: @values || [] }
|
|
2080
|
+
end
|
|
2081
|
+
|
|
2082
|
+
def file_data
|
|
2083
|
+
raw = load_file(@file)
|
|
2084
|
+
@transform_block ? @transform_block.call(raw) : extract_path(raw, @path)
|
|
2085
|
+
end
|
|
2086
|
+
|
|
2087
|
+
def load_file(path)
|
|
2088
|
+
require 'yaml'
|
|
2089
|
+
require 'json'
|
|
2090
|
+
expanded = File.expand_path(path)
|
|
2091
|
+
|
|
2092
|
+
case File.extname(expanded).downcase
|
|
2093
|
+
when '.yaml', '.yml' then YAML.safe_load_file(expanded, symbolize_names: true, permitted_classes: [Symbol, Date, Time])
|
|
2094
|
+
when '.json' then JSON.parse(File.read(expanded), symbolize_names: true)
|
|
2095
|
+
else raise ArgumentError, "Unsupported file type: #{path}. Use .yaml, .yml, or .json"
|
|
2096
|
+
end
|
|
2097
|
+
end
|
|
2098
|
+
|
|
2099
|
+
def extract_path(data, path)
|
|
2100
|
+
return data unless path
|
|
2101
|
+
|
|
2102
|
+
path.split('.').reduce(data) do |obj, key|
|
|
2103
|
+
break if obj.nil?
|
|
2104
|
+
key.match?(/\A-?\d+\z/) ? obj[key.to_i] : obj.fetch(key.to_sym) { obj[key] }
|
|
2105
|
+
end
|
|
2106
|
+
end
|
|
2107
|
+
|
|
2108
|
+
def normalize(data)
|
|
2109
|
+
case data
|
|
2110
|
+
when Hash then { labels: data.keys.map(&:to_s), values: data.values }
|
|
2111
|
+
when Array then normalize_array(data)
|
|
2112
|
+
else { labels: [], values: [] }
|
|
2113
|
+
end
|
|
2114
|
+
end
|
|
2115
|
+
|
|
2116
|
+
def normalize_array(data)
|
|
2117
|
+
return { labels: data.map { _1[:label] }, values: data.map { _1[:value] } } if labeled_array?(data)
|
|
2118
|
+
{ labels: data.each_index.map(&:to_s), values: data }
|
|
2119
|
+
end
|
|
2120
|
+
|
|
2121
|
+
def labeled_array?(data)
|
|
2122
|
+
data.first.is_a?(Hash) && data.first.key?(:label)
|
|
2123
|
+
end
|
|
2124
|
+
end
|
|
2125
|
+
|
|
2126
|
+
class BarChart < ChartBase
|
|
2127
|
+
def render(view, state)
|
|
2128
|
+
view.adapter.render_bar_chart(view, self, state)
|
|
2129
|
+
end
|
|
2130
|
+
end
|
|
2131
|
+
|
|
2132
|
+
class LineChart < ChartBase
|
|
2133
|
+
def render(view, state)
|
|
2134
|
+
view.adapter.render_line_chart(view, self, state)
|
|
2135
|
+
end
|
|
2136
|
+
end
|
|
2137
|
+
|
|
2138
|
+
class PieChart < ChartBase
|
|
2139
|
+
def render(view, state)
|
|
2140
|
+
view.adapter.render_pie_chart(view, self, state)
|
|
2141
|
+
end
|
|
2142
|
+
end
|
|
2143
|
+
|
|
2144
|
+
class StackedBarChart < ChartBase
|
|
2145
|
+
def render(view, state)
|
|
2146
|
+
view.adapter.render_stacked_bar_chart(view, self, state)
|
|
2147
|
+
end
|
|
2148
|
+
|
|
2149
|
+
def resolve_data(state)
|
|
2150
|
+
normalize_stacked(raw_data(state))
|
|
2151
|
+
end
|
|
2152
|
+
|
|
2153
|
+
private
|
|
2154
|
+
|
|
2155
|
+
def normalize_stacked(data)
|
|
2156
|
+
case data
|
|
2157
|
+
when Array then normalize_array_to_stacked(data)
|
|
2158
|
+
when Hash then normalize_hash_to_stacked(data)
|
|
2159
|
+
else { labels: [], series: {} }
|
|
2160
|
+
end
|
|
2161
|
+
end
|
|
2162
|
+
|
|
2163
|
+
def normalize_hash_to_stacked(data)
|
|
2164
|
+
return normalize_series_hash(data) if data.values.first.is_a?(Array)
|
|
2165
|
+
normalize_single_hash(data)
|
|
2166
|
+
end
|
|
2167
|
+
|
|
2168
|
+
def normalize_series_hash(data)
|
|
2169
|
+
labels = (0...data.values.first.length).map(&:to_s)
|
|
2170
|
+
{ labels: labels, series: data.transform_keys(&:to_s) }
|
|
2171
|
+
end
|
|
2172
|
+
|
|
2173
|
+
def normalize_single_hash(data)
|
|
2174
|
+
{ labels: data.keys.map(&:to_s), series: { "Value" => data.values } }
|
|
2175
|
+
end
|
|
2176
|
+
|
|
2177
|
+
def normalize_array_to_stacked(data)
|
|
2178
|
+
return { labels: [], series: {} } if data.empty?
|
|
2179
|
+
|
|
2180
|
+
first = data.first
|
|
2181
|
+
return normalize_labeled_records(data) if first.is_a?(Hash) && first.key?(:label)
|
|
2182
|
+
|
|
2183
|
+
{ labels: data.each_index.map(&:to_s), series: { "Value" => data } }
|
|
2184
|
+
end
|
|
2185
|
+
|
|
2186
|
+
def normalize_labeled_records(data)
|
|
2187
|
+
labels = data.map { _1[:label].to_s }
|
|
2188
|
+
series_keys = data.first.keys.reject { _1 == :label }.map(&:to_s)
|
|
2189
|
+
series = series_keys.to_h { |key| [key, data.map { _1[key.to_sym] || _1[key] || 0 }] }
|
|
2190
|
+
{ labels: labels, series: series }
|
|
2191
|
+
end
|
|
2192
|
+
end
|
|
2193
|
+
|
|
2194
|
+
# =========================================
|
|
2195
|
+
# Dashboard Components (Cabinet Control style)
|
|
2196
|
+
# =========================================
|
|
2197
|
+
|
|
2198
|
+
# StatusDot component for colored status indicators
|
|
2199
|
+
# Displays a small colored dot with optional glow effect and label
|
|
2200
|
+
class StatusDot < Base
|
|
2201
|
+
attr_reader :status, :pulse, :size, :label
|
|
2202
|
+
|
|
2203
|
+
# Status colors: red (alert), yellow (warning), green (ok), gray (inactive)
|
|
2204
|
+
STATUSES = %i[red yellow green gray].freeze
|
|
2205
|
+
SIZES = %i[sm md lg].freeze
|
|
2206
|
+
|
|
2207
|
+
# @param status [Symbol] Status color (:red, :yellow, :green, :gray)
|
|
2208
|
+
# @param pulse [Boolean] Whether to animate with pulse effect (default: false)
|
|
2209
|
+
# @param size [Symbol] Size (:sm, :md, :lg) - default :md
|
|
2210
|
+
# @param label [String, nil] Optional label to display below the dot
|
|
2211
|
+
# @param options [Hash] Additional options
|
|
2212
|
+
def initialize(status: :gray, pulse: false, size: :md, label: nil, **options)
|
|
2213
|
+
@status = STATUSES.include?(status.to_sym) ? status.to_sym : :gray
|
|
2214
|
+
@pulse = pulse
|
|
2215
|
+
@size = SIZES.include?(size.to_sym) ? size.to_sym : :md
|
|
2216
|
+
@label = label
|
|
2217
|
+
@options = options
|
|
2218
|
+
end
|
|
2219
|
+
|
|
2220
|
+
def render(view, state)
|
|
2221
|
+
view.adapter.render_status_dot(view, self, state)
|
|
2222
|
+
end
|
|
2223
|
+
end
|
|
2224
|
+
|
|
2225
|
+
# Badge component for small count/label indicators
|
|
2226
|
+
# Displays pill-shaped badges with variant colors
|
|
2227
|
+
class Badge < Base
|
|
2228
|
+
attr_reader :text, :variant, :size
|
|
2229
|
+
|
|
2230
|
+
VARIANTS = %i[default danger warning success info].freeze
|
|
2231
|
+
SIZES = %i[sm md].freeze
|
|
2232
|
+
|
|
2233
|
+
# @param text [String] Badge text/count
|
|
2234
|
+
# @param variant [Symbol] Color variant (:default, :danger, :warning, :success, :info)
|
|
2235
|
+
# @param size [Symbol] Size (:sm, :md) - default :sm
|
|
2236
|
+
# @param options [Hash] Additional options
|
|
2237
|
+
def initialize(text, variant: :default, size: :sm, **options)
|
|
2238
|
+
@text = text.to_s
|
|
2239
|
+
@variant = VARIANTS.include?(variant.to_sym) ? variant.to_sym : :default
|
|
2240
|
+
@size = SIZES.include?(size.to_sym) ? size.to_sym : :sm
|
|
2241
|
+
@options = options
|
|
2242
|
+
end
|
|
2243
|
+
|
|
2244
|
+
def render(view, state)
|
|
2245
|
+
view.adapter.render_badge(view, self, state)
|
|
2246
|
+
end
|
|
2247
|
+
end
|
|
2248
|
+
|
|
2249
|
+
# StatDisplay component for large metric numbers with labels
|
|
2250
|
+
# Displays a prominent value with a small label below
|
|
2251
|
+
class StatDisplay < Base
|
|
2252
|
+
attr_reader :value, :label, :color, :size
|
|
2253
|
+
|
|
2254
|
+
COLORS = %i[default blue purple green red yellow].freeze
|
|
2255
|
+
SIZES = %i[sm md lg].freeze
|
|
2256
|
+
|
|
2257
|
+
# @param value [String, Integer] The main value to display
|
|
2258
|
+
# @param label [String] Label text below the value
|
|
2259
|
+
# @param color [Symbol] Value color (:default, :blue, :purple, :green, :red, :yellow)
|
|
2260
|
+
# @param size [Symbol] Size (:sm, :md, :lg) - default :md
|
|
2261
|
+
# @param options [Hash] Additional options
|
|
2262
|
+
def initialize(value:, label:, color: :blue, size: :md, **options)
|
|
2263
|
+
@value = value.to_s
|
|
2264
|
+
@label = label
|
|
2265
|
+
@color = COLORS.include?(color.to_sym) ? color.to_sym : :default
|
|
2266
|
+
@size = SIZES.include?(size.to_sym) ? size.to_sym : :md
|
|
2267
|
+
@options = options
|
|
2268
|
+
end
|
|
2269
|
+
|
|
2270
|
+
def render(view, state)
|
|
2271
|
+
view.adapter.render_stat_display(view, self, state)
|
|
2272
|
+
end
|
|
2273
|
+
end
|
|
2274
|
+
|
|
2275
|
+
# TypeTag component for activity type badges
|
|
2276
|
+
# Colored pills showing type like RESEARCH, TASK, ESCALATION
|
|
2277
|
+
class TypeTag < Base
|
|
2278
|
+
attr_reader :type_name, :custom_color
|
|
2279
|
+
|
|
2280
|
+
# Predefined types with colors
|
|
2281
|
+
TYPES = {
|
|
2282
|
+
research: :blue,
|
|
2283
|
+
task: :green,
|
|
2284
|
+
escalation: :red,
|
|
2285
|
+
communication: :purple,
|
|
2286
|
+
warning: :yellow,
|
|
2287
|
+
info: :gray
|
|
2288
|
+
}.freeze
|
|
2289
|
+
|
|
2290
|
+
# @param type_name [Symbol, String] The type (:research, :task, :escalation, etc.) or custom text
|
|
2291
|
+
# @param color [Symbol, nil] Override color for custom types
|
|
2292
|
+
# @param options [Hash] Additional options
|
|
2293
|
+
def initialize(type_name, color: nil, **options)
|
|
2294
|
+
@type_name = type_name.to_s.downcase
|
|
2295
|
+
@custom_color = color
|
|
2296
|
+
@options = options
|
|
2297
|
+
end
|
|
2298
|
+
|
|
2299
|
+
def color
|
|
2300
|
+
return @custom_color if @custom_color
|
|
2301
|
+
TYPES[@type_name.to_sym] || :gray
|
|
2302
|
+
end
|
|
2303
|
+
|
|
2304
|
+
def display_text
|
|
2305
|
+
@type_name.upcase
|
|
2306
|
+
end
|
|
2307
|
+
|
|
2308
|
+
def render(view, state)
|
|
2309
|
+
view.adapter.render_type_tag(view, self, state)
|
|
2310
|
+
end
|
|
2311
|
+
end
|
|
2312
|
+
|
|
2313
|
+
# PulseIndicator component for animated status with label
|
|
2314
|
+
# Shows a pulsing dot with accompanying text
|
|
2315
|
+
class PulseIndicator < Base
|
|
2316
|
+
attr_reader :color, :label
|
|
2317
|
+
|
|
2318
|
+
COLORS = %i[green red yellow blue].freeze
|
|
2319
|
+
|
|
2320
|
+
# @param color [Symbol] Dot color (:green, :red, :yellow, :blue) - default :green
|
|
2321
|
+
# @param label [String] Status text next to the dot
|
|
2322
|
+
# @param options [Hash] Additional options
|
|
2323
|
+
def initialize(color: :green, label: nil, **options)
|
|
2324
|
+
@color = COLORS.include?(color.to_sym) ? color.to_sym : :green
|
|
2325
|
+
@label = label
|
|
2326
|
+
@options = options
|
|
2327
|
+
end
|
|
2328
|
+
|
|
2329
|
+
def render(view, state)
|
|
2330
|
+
view.adapter.render_pulse_indicator(view, self, state)
|
|
2331
|
+
end
|
|
2332
|
+
end
|
|
2333
|
+
|
|
2334
|
+
# PriorityItem component for escalation-style items
|
|
2335
|
+
# Items with priority-colored left border
|
|
2336
|
+
class PriorityItem < Base
|
|
2337
|
+
attr_reader :priority, :title, :description, :meta_left, :meta_right
|
|
2338
|
+
attr_accessor :children
|
|
2339
|
+
|
|
2340
|
+
PRIORITIES = %i[critical urgent high normal low].freeze
|
|
2341
|
+
|
|
2342
|
+
# @param priority [Symbol] Priority level (:critical, :urgent, :high, :normal, :low)
|
|
2343
|
+
# @param title [String] Item title
|
|
2344
|
+
# @param description [String, nil] Optional description text
|
|
2345
|
+
# @param meta_left [String, nil] Left-side metadata (e.g., secretary name)
|
|
2346
|
+
# @param meta_right [String, nil] Right-side metadata (e.g., action link)
|
|
2347
|
+
# @param options [Hash] Additional options
|
|
2348
|
+
def initialize(priority: :normal, title:, description: nil, meta_left: nil, meta_right: nil, **options)
|
|
2349
|
+
@priority = PRIORITIES.include?(priority.to_sym) ? priority.to_sym : :normal
|
|
2350
|
+
@title = title
|
|
2351
|
+
@description = description
|
|
2352
|
+
@meta_left = meta_left
|
|
2353
|
+
@meta_right = meta_right
|
|
2354
|
+
@options = options
|
|
2355
|
+
@children = []
|
|
2356
|
+
end
|
|
2357
|
+
|
|
2358
|
+
def render(view, state)
|
|
2359
|
+
view.adapter.render_priority_item(view, self, state)
|
|
2360
|
+
end
|
|
2361
|
+
end
|
|
2362
|
+
|
|
2363
|
+
# ActivityItem component for activity feed items
|
|
2364
|
+
# Shows time, title, summary, and type badge
|
|
2365
|
+
class ActivityItem < Base
|
|
2366
|
+
attr_reader :time, :title, :summary, :type
|
|
2367
|
+
|
|
2368
|
+
# @param time [String] Time display (e.g., "15:00")
|
|
2369
|
+
# @param title [String] Activity title
|
|
2370
|
+
# @param summary [String, nil] Optional summary text
|
|
2371
|
+
# @param type [Symbol, nil] Activity type for TypeTag (:research, :task, etc.)
|
|
2372
|
+
# @param options [Hash] Additional options
|
|
2373
|
+
def initialize(time:, title:, summary: nil, type: nil, **options)
|
|
2374
|
+
@time = time
|
|
2375
|
+
@title = title
|
|
2376
|
+
@summary = summary
|
|
2377
|
+
@type = type
|
|
2378
|
+
@options = options
|
|
2379
|
+
end
|
|
2380
|
+
|
|
2381
|
+
def render(view, state)
|
|
2382
|
+
view.adapter.render_activity_item(view, self, state)
|
|
2383
|
+
end
|
|
2384
|
+
end
|
|
2385
|
+
|
|
2386
|
+
# =========================================
|
|
2387
|
+
# Layout Components (Cabinet Control style)
|
|
2388
|
+
# =========================================
|
|
2389
|
+
|
|
2390
|
+
# AppShell component for two-column app layouts
|
|
2391
|
+
# Provides a main content area with optional fixed sidebar
|
|
2392
|
+
class AppShell < Base
|
|
2393
|
+
attr_reader :sidebar_width, :sidebar_position, :gap, :options
|
|
2394
|
+
attr_accessor :main_children, :sidebar_children
|
|
2395
|
+
|
|
2396
|
+
POSITIONS = %i[left right].freeze
|
|
2397
|
+
|
|
2398
|
+
# @param sidebar_width [String] CSS width for sidebar (default: "320px")
|
|
2399
|
+
# @param sidebar_position [Symbol] Sidebar position (:left, :right) - default :right
|
|
2400
|
+
# @param gap [String] Gap between main and sidebar (default: "1.5rem")
|
|
2401
|
+
# @param options [Hash] Additional options
|
|
2402
|
+
def initialize(sidebar_width: "320px", sidebar_position: :right, gap: "1.5rem", **options)
|
|
2403
|
+
@sidebar_width = sidebar_width
|
|
2404
|
+
@sidebar_position = POSITIONS.include?(sidebar_position.to_sym) ? sidebar_position.to_sym : :right
|
|
2405
|
+
@gap = gap
|
|
2406
|
+
@options = options
|
|
2407
|
+
@main_children = []
|
|
2408
|
+
@sidebar_children = []
|
|
2409
|
+
end
|
|
2410
|
+
|
|
2411
|
+
# Union of main/sidebar content, so component-tree walkers (button/form/input
|
|
2412
|
+
# lookup in server.rb) that only know about `children` can still find buttons,
|
|
2413
|
+
# forms, and inputs nested inside an app_shell's main/sidebar blocks.
|
|
2414
|
+
def children
|
|
2415
|
+
main_children + sidebar_children
|
|
2416
|
+
end
|
|
2417
|
+
|
|
2418
|
+
def render(view, state)
|
|
2419
|
+
view.adapter.render_app_shell(view, self, state)
|
|
2420
|
+
end
|
|
2421
|
+
end
|
|
2422
|
+
|
|
2423
|
+
# Sidebar component for fixed sidebar content
|
|
2424
|
+
# Used within AppShell to define sidebar content
|
|
2425
|
+
class Sidebar < Base
|
|
2426
|
+
attr_reader :header, :sticky, :options
|
|
2427
|
+
attr_accessor :children
|
|
2428
|
+
|
|
2429
|
+
# @param header [String, nil] Optional header text for sidebar
|
|
2430
|
+
# @param sticky [Boolean] Whether sidebar content is sticky (default: true)
|
|
2431
|
+
# @param options [Hash] Additional options
|
|
2432
|
+
def initialize(header: nil, sticky: true, **options)
|
|
2433
|
+
@header = header
|
|
2434
|
+
@sticky = sticky
|
|
2435
|
+
@options = options
|
|
2436
|
+
@children = []
|
|
2437
|
+
end
|
|
2438
|
+
|
|
2439
|
+
def render(view, state)
|
|
2440
|
+
view.adapter.render_sidebar(view, self, state)
|
|
2441
|
+
end
|
|
2442
|
+
end
|
|
2443
|
+
|
|
2444
|
+
# MainContent component for main content area in AppShell
|
|
2445
|
+
# Used within AppShell to define main content
|
|
2446
|
+
class MainContent < Base
|
|
2447
|
+
attr_accessor :children
|
|
2448
|
+
|
|
2449
|
+
# @param options [Hash] Additional options
|
|
2450
|
+
def initialize(**options)
|
|
2451
|
+
@options = options
|
|
2452
|
+
@children = []
|
|
2453
|
+
end
|
|
2454
|
+
|
|
2455
|
+
def render(view, state)
|
|
2456
|
+
view.adapter.render_main_content(view, self, state)
|
|
2457
|
+
end
|
|
2458
|
+
end
|
|
2459
|
+
|
|
2460
|
+
# ExpandableCard component for cards that expand/collapse
|
|
2461
|
+
# Displays header always, body toggles on click
|
|
2462
|
+
class ExpandableCard < Base
|
|
2463
|
+
attr_reader :key, :title, :subtitle, :badge_text, :badge_variant, :status, :initially_expanded, :extra_classes
|
|
2464
|
+
attr_accessor :children, :header_children
|
|
2465
|
+
|
|
2466
|
+
# @param key [Symbol] State key for expanded state
|
|
2467
|
+
# @param title [String] Card title (always visible)
|
|
2468
|
+
# @param subtitle [String, nil] Optional subtitle
|
|
2469
|
+
# @param badge_text [String, nil] Optional badge text (e.g., "5 activities")
|
|
2470
|
+
# @param badge_variant [Symbol] Badge color variant
|
|
2471
|
+
# @param status [Symbol, nil] Status indicator color (:red, :yellow, :green, :gray)
|
|
2472
|
+
# @param initially_expanded [Boolean] Whether card starts expanded (default: false)
|
|
2473
|
+
# @param extra_classes [String, nil] Additional CSS classes for the card container
|
|
2474
|
+
# @param options [Hash] Additional options
|
|
2475
|
+
def initialize(key:, title:, subtitle: nil, badge_text: nil, badge_variant: :default,
|
|
2476
|
+
status: nil, initially_expanded: false, extra_classes: nil, **options)
|
|
2477
|
+
@key = key
|
|
2478
|
+
@title = title
|
|
2479
|
+
@subtitle = subtitle
|
|
2480
|
+
@badge_text = badge_text
|
|
2481
|
+
@badge_variant = badge_variant
|
|
2482
|
+
@status = status
|
|
2483
|
+
@initially_expanded = initially_expanded
|
|
2484
|
+
@extra_classes = extra_classes
|
|
2485
|
+
@options = options
|
|
2486
|
+
@children = []
|
|
2487
|
+
@header_children = []
|
|
2488
|
+
end
|
|
2489
|
+
|
|
2490
|
+
def render(view, state)
|
|
2491
|
+
view.adapter.render_expandable_card(view, self, state)
|
|
2492
|
+
end
|
|
2493
|
+
end
|
|
2494
|
+
|
|
2495
|
+
# CodeEditor component for syntax-highlighted code display/editing
|
|
2496
|
+
# Uses CodeMirror 5 with hx-preserve to survive HTMX swaps
|
|
2497
|
+
class CodeEditor < Base
|
|
2498
|
+
attr_reader :key, :language, :readonly, :height, :options
|
|
2499
|
+
|
|
2500
|
+
# Supported languages (CodeMirror 5 modes)
|
|
2501
|
+
LANGUAGES = {
|
|
2502
|
+
ruby: { mode: 'ruby', mime: 'text/x-ruby' },
|
|
2503
|
+
javascript: { mode: 'javascript', mime: 'text/javascript' },
|
|
2504
|
+
html: { mode: 'htmlmixed', mime: 'text/html' },
|
|
2505
|
+
css: { mode: 'css', mime: 'text/css' },
|
|
2506
|
+
markdown: { mode: 'markdown', mime: 'text/x-markdown' },
|
|
2507
|
+
json: { mode: 'javascript', mime: 'application/json' }
|
|
2508
|
+
}.freeze
|
|
2509
|
+
|
|
2510
|
+
# @param key [Symbol] State key for the editor content
|
|
2511
|
+
# @param language [Symbol] Syntax highlighting language (:ruby, :javascript, etc.)
|
|
2512
|
+
# @param readonly [Boolean] Whether the editor is read-only
|
|
2513
|
+
# @param height [String] CSS height value (default: "400px")
|
|
2514
|
+
# @param options [Hash] Additional options
|
|
2515
|
+
def initialize(key, language: :ruby, readonly: true, height: "400px", **options)
|
|
2516
|
+
@key = key
|
|
2517
|
+
@language = language.to_sym
|
|
2518
|
+
@readonly = readonly
|
|
2519
|
+
@height = height
|
|
2520
|
+
@options = options
|
|
2521
|
+
end
|
|
2522
|
+
|
|
2523
|
+
def language_config
|
|
2524
|
+
LANGUAGES[@language] || LANGUAGES[:ruby]
|
|
2525
|
+
end
|
|
2526
|
+
|
|
2527
|
+
def render(view, state)
|
|
2528
|
+
view.adapter.render_code_editor(view, self, state)
|
|
2529
|
+
end
|
|
2530
|
+
end
|
|
2531
|
+
|
|
2532
|
+
# =========================================
|
|
2533
|
+
# CSS-Only Helpers (T13)
|
|
2534
|
+
# Thin wrappers around divs with sw- CSS classes.
|
|
2535
|
+
# Per DHH: "a div with a CSS class is still a div."
|
|
2536
|
+
# =========================================
|
|
2537
|
+
|
|
2538
|
+
# Hero section -- large padded area with accent background tint
|
|
2539
|
+
class Hero < Base
|
|
2540
|
+
attr_accessor :children
|
|
2541
|
+
|
|
2542
|
+
def initialize(**options)
|
|
2543
|
+
@options = options
|
|
2544
|
+
@children = []
|
|
2545
|
+
end
|
|
2546
|
+
|
|
2547
|
+
def render(view, state)
|
|
2548
|
+
view.adapter.render_hero(view, self, state)
|
|
2549
|
+
end
|
|
2550
|
+
end
|
|
2551
|
+
|
|
2552
|
+
# Prose container -- reading-optimized text (max-width ~65ch, comfortable line-height)
|
|
2553
|
+
class Prose < Base
|
|
2554
|
+
attr_accessor :children
|
|
2555
|
+
attr_reader :dropcap
|
|
2556
|
+
|
|
2557
|
+
def initialize(dropcap: false, **options)
|
|
2558
|
+
@dropcap = dropcap
|
|
2559
|
+
@options = options
|
|
2560
|
+
@children = []
|
|
2561
|
+
end
|
|
2562
|
+
|
|
2563
|
+
def render(view, state)
|
|
2564
|
+
view.adapter.render_prose(view, self, state)
|
|
2565
|
+
end
|
|
2566
|
+
end
|
|
2567
|
+
|
|
2568
|
+
# Pullquote -- highlighted quotation with optional attribution
|
|
2569
|
+
class Pullquote < Base
|
|
2570
|
+
attr_reader :text, :attribution
|
|
2571
|
+
|
|
2572
|
+
def initialize(text, attribution: nil, **options)
|
|
2573
|
+
@text = text
|
|
2574
|
+
@attribution = attribution
|
|
2575
|
+
@options = options
|
|
2576
|
+
end
|
|
2577
|
+
|
|
2578
|
+
def render(view, state)
|
|
2579
|
+
view.adapter.render_pullquote(view, self, state)
|
|
2580
|
+
end
|
|
2581
|
+
end
|
|
2582
|
+
|
|
2583
|
+
# DirTree -- monospace file tree display with color-coded status
|
|
2584
|
+
# Status markers: [new] (green), [modified] (amber), [deleted] (red)
|
|
2585
|
+
class DirTree < Base
|
|
2586
|
+
attr_reader :tree_text
|
|
2587
|
+
|
|
2588
|
+
def initialize(tree_text, **options)
|
|
2589
|
+
@tree_text = tree_text
|
|
2590
|
+
@options = options
|
|
2591
|
+
end
|
|
2592
|
+
|
|
2593
|
+
# Parse lines and annotate with status
|
|
2594
|
+
def parsed_lines
|
|
2595
|
+
@tree_text.lines.map do |line|
|
|
2596
|
+
stripped = line.rstrip
|
|
2597
|
+
if stripped =~ /\[new\]\s*$/i
|
|
2598
|
+
{ text: stripped.sub(/\s*\[new\]\s*$/i, ''), status: :new }
|
|
2599
|
+
elsif stripped =~ /\[modified\]\s*$/i
|
|
2600
|
+
{ text: stripped.sub(/\s*\[modified\]\s*$/i, ''), status: :modified }
|
|
2601
|
+
elsif stripped =~ /\[deleted\]\s*$/i
|
|
2602
|
+
{ text: stripped.sub(/\s*\[deleted\]\s*$/i, ''), status: :deleted }
|
|
2603
|
+
else
|
|
2604
|
+
{ text: stripped, status: nil }
|
|
2605
|
+
end
|
|
2606
|
+
end
|
|
2607
|
+
end
|
|
2608
|
+
|
|
2609
|
+
def render(view, state)
|
|
2610
|
+
view.adapter.render_dir_tree(view, self, state)
|
|
2611
|
+
end
|
|
2612
|
+
end
|
|
2613
|
+
|
|
2614
|
+
# Legend -- horizontal row of color dots with labels
|
|
2615
|
+
class Legend < Base
|
|
2616
|
+
attr_reader :items
|
|
2617
|
+
|
|
2618
|
+
# @param items [Array<Hash>] Array of { color: "#hex", label: "text" }
|
|
2619
|
+
def initialize(items:, **options)
|
|
2620
|
+
@items = items
|
|
2621
|
+
@options = options
|
|
2622
|
+
end
|
|
2623
|
+
|
|
2624
|
+
def render(view, state)
|
|
2625
|
+
view.adapter.render_legend(view, self, state)
|
|
2626
|
+
end
|
|
2627
|
+
end
|
|
2628
|
+
|
|
2629
|
+
# FlowArrow -- vertical arrow connector between sections
|
|
2630
|
+
class FlowArrow < Base
|
|
2631
|
+
attr_reader :label
|
|
2632
|
+
|
|
2633
|
+
def initialize(label: nil, **options)
|
|
2634
|
+
@label = label
|
|
2635
|
+
@options = options
|
|
2636
|
+
end
|
|
2637
|
+
|
|
2638
|
+
def render(view, state)
|
|
2639
|
+
view.adapter.render_flow_arrow(view, self, state)
|
|
2640
|
+
end
|
|
2641
|
+
end
|
|
2642
|
+
|
|
2643
|
+
# LayoutToggle -- column count override buttons (1/2/3/4)
|
|
2644
|
+
class LayoutToggle < Base
|
|
2645
|
+
attr_reader :target, :columns
|
|
2646
|
+
|
|
2647
|
+
# @param target [String] CSS selector of the grid to control
|
|
2648
|
+
# @param columns [Array<Integer>] Available column counts (default: [1, 2, 3, 4])
|
|
2649
|
+
def initialize(target: ".sw-layout-target", columns: [1, 2, 3, 4], **options)
|
|
2650
|
+
@target = target
|
|
2651
|
+
@columns = columns
|
|
2652
|
+
@options = options
|
|
2653
|
+
end
|
|
2654
|
+
|
|
2655
|
+
def render(view, state)
|
|
2656
|
+
view.adapter.render_layout_toggle(view, self, state)
|
|
2657
|
+
end
|
|
2658
|
+
end
|
|
2659
|
+
|
|
2660
|
+
# CopyButton -- clipboard-copy trigger for a fixed text payload
|
|
2661
|
+
class CopyButton < Base
|
|
2662
|
+
attr_reader :label, :text, :copied_label, :options
|
|
2663
|
+
|
|
2664
|
+
# @param label [String] Button label before copy
|
|
2665
|
+
# @param text [String] Text to copy to the clipboard
|
|
2666
|
+
# @param copied_label [String] Label shown briefly after a successful copy
|
|
2667
|
+
def initialize(label = "Copy", text:, copied_label: "Copied!", **options)
|
|
2668
|
+
@label = label
|
|
2669
|
+
@text = text
|
|
2670
|
+
@copied_label = copied_label
|
|
2671
|
+
@options = options
|
|
2672
|
+
end
|
|
2673
|
+
|
|
2674
|
+
def render(view, state)
|
|
2675
|
+
view.adapter.render_copy_button(view, self, state)
|
|
2676
|
+
end
|
|
2677
|
+
end
|
|
2678
|
+
end
|
|
2679
|
+
end
|
|
2680
|
+
|
|
2681
|
+
# Load component files from components/ directory
|
|
2682
|
+
require_relative "components/code_block"
|
|
2683
|
+
require_relative "components/image_block"
|
|
2684
|
+
require_relative "components/mermaid"
|
|
2685
|
+
require_relative "components/keyboard_shortcuts"
|
|
2686
|
+
require_relative "components/slide_container"
|
|
2687
|
+
require_relative "components/sidebar_toc"
|
|
2688
|
+
require_relative "components/callout"
|
|
2689
|
+
require_relative "components/doc_header"
|
|
2690
|
+
require_relative "components/comparison"
|
|
2691
|
+
require_relative "components/implementation_map"
|
|
2692
|
+
require_relative "components/decision"
|
|
2693
|
+
require_relative "components/wireframe_block"
|
|
2694
|
+
require_relative "components/wireframe"
|
|
2695
|
+
require_relative "components/annotated_code"
|
|
2696
|
+
require_relative "components/diff_block"
|
|
2697
|
+
require_relative "components/api_endpoint"
|
|
2698
|
+
require_relative "components/pipeline"
|
|
2699
|
+
require_relative "components/kpi_dashboard"
|
|
2700
|
+
require_relative "components/chart"
|
|
2701
|
+
require_relative "components/timeline_event"
|
|
2702
|
+
require_relative "components/deck/design_deck"
|
|
2703
|
+
require_relative "components/deck/deck_slide"
|
|
2704
|
+
require_relative "components/deck/deck_option"
|
|
2705
|
+
require_relative "components/deck/deck_state"
|
|
2706
|
+
require_relative "components/deck/deck_summary"
|
|
2707
|
+
require_relative "components/deck/generate_more_controls"
|
|
2708
|
+
require_relative "components/deck/skeleton_placeholder"
|
|
2709
|
+
require_relative "components/deck/model_selector"
|
|
2710
|
+
require_relative "components/deck/confirmation_bar"
|
|
2711
|
+
require_relative "components/deck/close_overlay"
|