From fa59ca3a51d0746dedc33290a79612141b51d9b8 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 10:18:00 -0400 Subject: [PATCH 01/21] Implement canonical widget catalog taxonomy --- ...em.canonical_widget_component_expansion.md | 101 +++++ .../canonical_widget_components/README.md | 48 +++ ...atalog-and-unified-ui-authoring-surface.md | 164 ++++++++ ...tation-compiler-and-hydration-alignment.md | 131 +++++++ ...e-components-and-iur-renderer-alignment.md | 121 ++++++ ...sktop-ui-and-terminal-ui-runtime-parity.md | 122 ++++++ ...s-documentation-tooling-and-conformance.md | 124 ++++++ .spec/specs/platform_runtimes.spec.md | 7 + .../unified-iur/widget_components.spec.md | 113 ++++++ .spec/specs/unified-iur/widgets.spec.md | 8 + .../unified-ui/widget_components.spec.md | 118 ++++++ .spec/specs/unified-ui/widgets.spec.md | 10 + .spec/specs/widget_component_behavior.spec.md | 135 +++++++ .../unified-ui/lib/unified_ui/reference.ex | 21 ++ packages/unified-ui/lib/unified_ui/tooling.ex | 18 +- .../lib/unified_ui/widget_components.ex | 356 ++++++++++++++++++ packages/unified-ui/lib/unified_ui/widgets.ex | 21 ++ .../widget_components_catalog_test.exs | 106 ++++++ 18 files changed, 1723 insertions(+), 1 deletion(-) create mode 100644 .spec/decisions/architecture/repo.ecosystem.canonical_widget_component_expansion.md create mode 100644 .spec/planning/canonical_widget_components/README.md create mode 100644 .spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md create mode 100644 .spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md create mode 100644 .spec/planning/canonical_widget_components/phase-03-live-ui-native-components-and-iur-renderer-alignment.md create mode 100644 .spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md create mode 100644 .spec/planning/canonical_widget_components/phase-05-examples-documentation-tooling-and-conformance.md create mode 100644 .spec/specs/unified-iur/widget_components.spec.md create mode 100644 .spec/specs/unified-ui/widget_components.spec.md create mode 100644 .spec/specs/widget_component_behavior.spec.md create mode 100644 packages/unified-ui/lib/unified_ui/widget_components.ex create mode 100644 packages/unified-ui/test/unified_ui/widget_components_catalog_test.exs diff --git a/.spec/decisions/architecture/repo.ecosystem.canonical_widget_component_expansion.md b/.spec/decisions/architecture/repo.ecosystem.canonical_widget_component_expansion.md new file mode 100644 index 00000000..055010bd --- /dev/null +++ b/.spec/decisions/architecture/repo.ecosystem.canonical_widget_component_expansion.md @@ -0,0 +1,101 @@ +--- +id: repo.ecosystem.canonical_widget_component_expansion +status: accepted +date: 2026-05-14 +affects: + - ecosystem.widget_component_behavior + - unified_ui.widgets + - unified_ui.widget_components + - unified_iur.widgets + - unified_iur.widget_components + - live_ui.native_widgets + - live_ui.iur_renderer + - elm_ui.native_widgets + - elm_ui.iur_renderer + - desktop_ui.native_widgets + - desktop_ui.iur_renderer + - terminal_ui.native_widgets + - terminal_ui.iur_renderer +--- + +# AshUi Widget Additions Become Canonical Widget Components + +## Context + +AshUi PRs 79 through 98 introduced a focused batch of reusable UI primitives +and one list-composition behavior. The batch covers editorial content, identity, +status, progress, forms, list rows, overlays, code and redline display, chat +composition, and row-repeat composition: + +- `inline_rich_text_heading` +- `disclosure` +- `phoenix_form` +- `kicker` +- `avatar` +- `presence_dot` +- `segmented_button_group` +- `list_item_multi_column` +- `artifact_row` +- `sticky_frosted_header` +- `pipeline_stepper_horizontal` +- `segmented_progress_bar` +- `workflow_stage_list_vertical` +- `meter_thin` +- `slide_over_panel` +- `event_callout` +- `redline_inline` +- `code_block_syntax_highlighted` +- `chat_composer` +- `ui_relationship repeat` + +Those additions were authored for AshUi, but their meaning is not specific to +AshUi. They are common application, document-workflow, and operational UI +patterns that belong in the UnifiedUi ecosystem as canonical authored widgets, +canonical IUR constructs, and native runtime components. + +The ecosystem must avoid importing AshUi implementation details directly. In +particular, names and behaviors that are host-specific in AshUi, such as +`phoenix_form`, need portable canonical meaning in UnifiedUi and UnifiedIUR +while still allowing LiveUi to provide the Phoenix-specific realization. + +## Decision + +1. The AshUi PR 79-98 widget batch is adopted as a canonical widget-component + expansion for the UnifiedUi ecosystem. +2. UnifiedUi shall expose authored equivalents for the batch through canonical + DSL widgets and composition behavior rather than as AshUi compatibility + shims. +3. UnifiedIUR shall represent the same widget-component meanings as canonical + renderer-independent data. +4. Runtime packages shall provide native component equivalents and IUR renderer + mappings for the expanded catalog while preserving their native runtime + ownership. +5. Host-specific AshUi names may be accepted as aliases where useful, but the + canonical contract shall use portable names when the AshUi name leaks a host + runtime. The `phoenix_form` meaning becomes a runtime-owned form shell in the + canonical contract; LiveUi may realize it through Phoenix and AshPhoenix + form integration. +6. `ui_relationship repeat` becomes a canonical list-repeat composition + behavior rather than a renderer-only widget. UnifiedUi authors the repeat + relationship, UnifiedIUR preserves repeat metadata or expanded children, and + renderers consume deterministic concrete child nodes. +7. Content safety, accessibility, and interaction meaning are part of the + canonical contract. Pre-tokenized code and redline text remain plain-text + inputs to be escaped by renderers, and selection, submit, change, send, + row-activation, step-navigation, disclosure, and panel-state behavior remain + canonical interaction descriptors. + +## Consequences + +- The ecosystem gains one portable widget-component catalog instead of leaving + these primitives stranded in an AshUi-specific contribution. +- UnifiedUi and UnifiedIUR must move together for every adopted widget and for + list-repeat composition behavior. +- LiveUi, ElmUi, DesktopUi, and TerminalUi each remain native widget libraries, + but they need parity plans for this expanded catalog. +- TerminalUi may degrade visual affordances such as frost, slide motion, + multi-column layout, and syntax color while preserving readable structure, + state, and interaction meaning. +- The implementation plan should phase the work by canonical contracts first, + then IUR/compiler behavior, then runtime component parity, and finally + examples, documentation, tooling, and conformance. diff --git a/.spec/planning/canonical_widget_components/README.md b/.spec/planning/canonical_widget_components/README.md new file mode 100644 index 00000000..e2157251 --- /dev/null +++ b/.spec/planning/canonical_widget_components/README.md @@ -0,0 +1,48 @@ +# Canonical Widget Components Planning + +This directory contains the phased implementation plan for the canonical +widget-component expansion derived from AshUi PRs 79 through 98. + +## Originating AshUi PR Set + +- PR 79: `inline_rich_text_heading` +- PR 80: `disclosure` +- PR 81: `phoenix_form`, represented canonically as a runtime-owned form shell +- PR 82: `kicker` +- PR 83: `avatar` +- PR 84: `presence_dot` +- PR 85: `segmented_button_group` +- PR 86: `list_item_multi_column` +- PR 87: `artifact_row` +- PR 88: `sticky_frosted_header` +- PR 89: `pipeline_stepper_horizontal` +- PR 90: `segmented_progress_bar` +- PR 91: `workflow_stage_list_vertical` +- PR 92: `meter_thin` +- PR 93: `slide_over_panel` +- PR 94: `event_callout` +- PR 95: `redline_inline` +- PR 96: `code_block_syntax_highlighted` +- PR 97: `chat_composer` +- PR 98: `ui_relationship repeat`, represented canonically as list-repeat composition behavior + +## Phase Files + +1. [Phase 1 - Canonical Catalog and UnifiedUi Authoring Surface](./phase-01-canonical-catalog-and-unified-ui-authoring-surface.md): define the portable catalog, canonical names, authored widget surface, and list-repeat DSL behavior. +2. [Phase 2 - UnifiedIUR Representation, Compiler, and Hydration Alignment](./phase-02-unified-iur-representation-compiler-and-hydration-alignment.md): represent the expanded catalog in UnifiedIUR and lower UnifiedUi declarations into deterministic canonical output. +3. [Phase 3 - LiveUi Native Components and IUR Renderer Alignment](./phase-03-live-ui-native-components-and-iur-renderer-alignment.md): implement the expanded catalog as LiveUi native widget components and map IUR input into the same components. +4. [Phase 4 - ElmUi, DesktopUi, and TerminalUi Runtime Parity](./phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md): add equivalent runtime support across the remaining runtime packages with explicit terminal degradation. +5. [Phase 5 - Examples, Documentation, Tooling, and Conformance](./phase-05-examples-documentation-tooling-and-conformance.md): add focused examples, guides, introspection, validation, traceability, and release-readiness checks. + +## Numbering + +- Phases: `N` +- Sections: `N.M` +- Tasks: `N.M.K` +- Subtasks: `N.M.K.L` + +Every phase, section, task, and subtask uses Markdown checkboxes. Every phase, +section, and task starts with a short description paragraph. Each phase ends +with an integration-testing section. Sections are intended to be practical +commit units during implementation unless a package-local change needs a +smaller commit boundary. diff --git a/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md b/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md new file mode 100644 index 00000000..39bb9266 --- /dev/null +++ b/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md @@ -0,0 +1,164 @@ +# Phase 1 - Canonical Catalog and UnifiedUi Authoring Surface + +Back to index: [README](./README.md) + +## Relevant Shared APIs / Interfaces +- `UnifiedUi.Dsl` +- `UnifiedUi.Compiler` +- `UnifiedUi.Signal` +- `UnifiedUi.Tooling` +- `UnifiedIUR.Element` +- `UnifiedIUR.Interaction` + +## Relevant Assumptions / Defaults +- The AshUi PR names are source references, not automatically canonical names. +- Host-neutral names are preferred when an AshUi name leaks a runtime, such as + `phoenix_form`. +- The expanded catalog must compile to UnifiedIUR rather than renderer-specific + output. +- List-repeat behavior is composition behavior, not a widget renderer escape + hatch. +- All work in this phase is not done. + +[ ] 1 Phase 1 - Canonical Catalog and UnifiedUi Authoring Surface + Define the portable catalog and authored DSL surface for the PR 79-98 widget + additions so UnifiedUi can express the new components and list-repeat + behavior without depending on AshUi or runtime package APIs. + + [x] 1.1 Section - Catalog Taxonomy and Canonical Naming + Establish the portable naming, semantic families, and compatibility policy + for the expanded widget-component catalog before adding authored DSL entry + points. + + [x] 1.1.1 Task - Define the canonical widget family taxonomy + Group the AshUi PR set into stable families that match how authors and + runtimes will reason about the catalog. + + [x] 1.1.1.1 Subtask - Classify content and identity widgets: `inline_rich_text_heading`, `kicker`, `avatar`, `presence_dot`, and `disclosure`. + [x] 1.1.1.2 Subtask - Classify form and control widgets: runtime-owned form shell, `segmented_button_group`, and `chat_composer`. + [x] 1.1.1.3 Subtask - Classify list, artifact, workflow, progress, layer, callout, redline, and code widgets into implementation families. + [x] 1.1.1.4 Subtask - Record the PR 79-98 source mapping in package docs or tooling output so the adoption history is discoverable. + + [x] 1.1.2 Task - Define canonical names and AshUi compatibility aliases + Keep the canonical surface portable while allowing explicit aliases where + that reduces migration friction. + + [x] 1.1.2.1 Subtask - Define the canonical name for the `phoenix_form` equivalent as a runtime-owned form shell. + [x] 1.1.2.2 Subtask - Decide which AshUi names can be accepted as authoring aliases without making AshUi part of the contract. + [x] 1.1.2.3 Subtask - Add validation diagnostics that identify deprecated or host-specific aliases and point authors to canonical names. + + [ ] 1.2 Section - Content, Identity, and Disclosure Authoring + Add authored DSL support for the passive and identity-oriented components + whose behavior is mostly content, state, accessibility, and child + composition. + + [ ] 1.2.1 Task - Implement rich heading and kicker authoring + Support editorial heading and eyebrow-label patterns with deterministic + content models. + + [ ] 1.2.1.1 Subtask - Author `inline_rich_text_heading` with heading level and inline `text` or `emphasis` segment validation. + [ ] 1.2.1.2 Subtask - Author `kicker` with ordered items and separator behavior. + [ ] 1.2.1.3 Subtask - Reject invalid heading levels, malformed segments, and non-string kicker items with actionable diagnostics. + + [ ] 1.2.2 Task - Implement avatar, presence, and disclosure authoring + Support identity display, small status signals, and native progressive + disclosure in the authored catalog. + + [ ] 1.2.2.1 Subtask - Author `avatar` with initials, optional image source, shape, size, variant, and accessible label. + [ ] 1.2.2.2 Subtask - Author `presence_dot` with state, size, and optional accessible label. + [ ] 1.2.2.3 Subtask - Author `disclosure` with summary text, initial open state, and child body content. + + [ ] 1.3 Section - Form, Control, and Composer Authoring + Add authored DSL support for controls that carry canonical interaction + meaning while leaving host-specific form lifecycle ownership to runtimes. + + [ ] 1.3.1 Task - Implement segmented button group authoring + Support single-selection segmented controls with option identity and + canonical selection intent. + + [ ] 1.3.1.1 Subtask - Author options with value and label fields. + [ ] 1.3.1.2 Subtask - Author active option state and optional disabled state. + [ ] 1.3.1.3 Subtask - Lower selection intent into canonical interaction descriptors rather than renderer-local event names. + + [ ] 1.3.2 Task - Implement runtime-owned form shell authoring + Represent the `phoenix_form` PR meaning portably without making Phoenix + the ecosystem contract. + + [ ] 1.3.2.1 Subtask - Author form fields, submit label, submit intent, change intent, validation state, and field attributes. + [ ] 1.3.2.2 Subtask - Document that runtime-owned form state is owned by the host runtime or application, not by canonical IUR. + [ ] 1.3.2.3 Subtask - Add LiveUi-facing metadata hooks for Phoenix or AshPhoenix integration without leaking them into the canonical contract. + + [ ] 1.3.3 Task - Implement chat composer authoring + Support multi-line composer authoring with text input, tool children, and + canonical send and change events. + + [ ] 1.3.3.1 Subtask - Author text value, placeholder, rows, disabled state, send label, and send intent. + [ ] 1.3.3.2 Subtask - Author optional tool-area and status children with deterministic ordering. + [ ] 1.3.3.3 Subtask - Validate that send and change interactions preserve canonical event meaning. + + [ ] 1.4 Section - Rows, Workflow, Layer, Callout, Redline, and Code Authoring + Add authored DSL support for the remaining visual and operational widget + families in the PR set. + + [ ] 1.4.1 Task - Implement row and artifact authoring + Support selectable or linkable row primitives for list and artifact + surfaces. + + [ ] 1.4.1.1 Subtask - Author `list_item_multi_column` with row identity, column template, active state, optional link target, and child cells. + [ ] 1.4.1.2 Subtask - Author `artifact_row` with title, meta, row identity, active state, optional link target, and trailing children. + [ ] 1.4.1.3 Subtask - Lower row activation into canonical interaction descriptors. + + [ ] 1.4.2 Task - Implement workflow and progress authoring + Support the stage, progress, and meter widgets needed by process and + workflow screens. + + [ ] 1.4.2.1 Subtask - Author `pipeline_stepper_horizontal` with ordered steps, active index, completed state, labels, and optional navigation intent. + [ ] 1.4.2.2 Subtask - Author `segmented_progress_bar` with segment weights, states, labels, and aggregate progress metadata. + [ ] 1.4.2.3 Subtask - Author `workflow_stage_list_vertical` and `meter_thin` with normalized values, labels, and state semantics. + + [ ] 1.4.3 Task - Implement layer, shell, callout, redline, and code authoring + Support shell and content-specialized widgets with portable structure and + safety rules. + + [ ] 1.4.3.1 Subtask - Author `sticky_frosted_header` with leading, title, and trailing positional children. + [ ] 1.4.3.2 Subtask - Author `slide_over_panel` as a non-modal layer with open state, size, label, and children. + [ ] 1.4.3.3 Subtask - Author `event_callout`, `redline_inline`, and `code_block_syntax_highlighted` with tone, segment, token, and text-safety metadata. + + [ ] 1.5 Section - List-Repeat Composition Authoring + Add the list-repeat behavior introduced by AshUi PR 98 as a canonical DSL + composition primitive. + + [ ] 1.5.1 Task - Define repeat composition syntax and validation + Establish how authors attach a child template to rows from a list + binding. + + [ ] 1.5.1.1 Subtask - Define the authored repeat directive shape and how it references a list binding. + [ ] 1.5.1.2 Subtask - Validate that repeat can only target list-like bindings and compatible child templates. + [ ] 1.5.1.3 Subtask - Validate row-scope binding references and reject unsupported deep or host-specific row access until explicitly supported. + + [ ] 1.5.2 Task - Expose repeat inspection and diagnostics + Make repeated composition visible and debuggable before runtime rendering. + + [ ] 1.5.2.1 Subtask - Add inspection output for repeat binding id, row scope, template identity, and generated identity strategy. + [ ] 1.5.2.2 Subtask - Add diagnostics for missing list bindings, malformed row fields, and repeat use on single-child composition. + [ ] 1.5.2.3 Subtask - Add authoring examples that show repeated artifact rows and repeated event callouts. + + [ ] 1.6 Section - Phase 1 Integration Tests + Validate the authored DSL surface, alias policy, diagnostics, and repeat + authoring before compiler and IUR work begins. + + [ ] 1.6.1 Task - Catalog authoring scenarios + Verify representative components from each family compile through the + authored DSL validation layer. + + [ ] 1.6.1.1 Subtask - Verify content, identity, row, workflow, layer, form, composer, redline, and code widgets are accepted with valid authored fields. + [ ] 1.6.1.2 Subtask - Verify malformed fields are rejected with diagnostics that name the canonical widget and field. + [ ] 1.6.1.3 Subtask - Verify host-specific aliases, if accepted, are reported as aliases rather than canonical names. + + [ ] 1.6.2 Task - Repeat authoring scenarios + Verify list-repeat authoring validates data shape and exposes useful + inspection output. + + [ ] 1.6.2.1 Subtask - Verify a repeated artifact-row template over a list binding validates successfully. + [ ] 1.6.2.2 Subtask - Verify repeat rejects non-list bindings and missing row-scope fields. + [ ] 1.6.2.3 Subtask - Verify inspection output shows repeat metadata deterministically. diff --git a/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md b/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md new file mode 100644 index 00000000..0092802c --- /dev/null +++ b/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md @@ -0,0 +1,131 @@ +# Phase 2 - UnifiedIUR Representation, Compiler, and Hydration Alignment + +Back to index: [README](./README.md) + +## Relevant Shared APIs / Interfaces +- `UnifiedUi.Compiler` +- `UnifiedUi.Tooling` +- `UnifiedIUR.Element` +- `UnifiedIUR.Interaction` +- `UnifiedIUR.Binding` +- `UnifiedIUR.Validation` +- `UnifiedIUR.Fixtures` + +## Relevant Assumptions / Defaults +- UnifiedIUR remains renderer-independent and does not carry LiveUi, Phoenix, + AshUi, Elm, desktop, or terminal implementation details. +- Redline and code content are plain text plus semantic metadata, not trusted + host markup. +- Repeat may be represented as metadata before hydration, but runtimes should + receive deterministic concrete child nodes. +- All work in this phase is not done. + +[ ] 2 Phase 2 - UnifiedIUR Representation, Compiler, and Hydration Alignment + Add canonical IUR representation and compiler lowering for the expanded + widget-component catalog and list-repeat composition behavior. + + [ ] 2.1 Section - UnifiedIUR Node Models and Content Contracts + Define the canonical element shapes, properties, children, and metadata for + every widget family in the expanded catalog. + + [ ] 2.1.1 Task - Implement content, identity, and disclosure IUR models + Represent passive content and identity widgets without losing + accessibility or state meaning. + + [ ] 2.1.1.1 Subtask - Represent inline rich heading levels and inline `text` or `emphasis` segments. + [ ] 2.1.1.2 Subtask - Represent kicker item order, separator behavior, avatar identity fields, and presence state. + [ ] 2.1.1.3 Subtask - Represent disclosure summary, initial open state, and child body content. + + [ ] 2.1.2 Task - Implement form, control, row, and composer IUR models + Represent interactive controls while keeping interaction descriptors + renderer-independent. + + [ ] 2.1.2.1 Subtask - Represent segmented control options, active state, disabled state, and selection intent. + [ ] 2.1.2.2 Subtask - Represent runtime-owned form fields, submit/change intent, labels, validation metadata, and host adapter hints. + [ ] 2.1.2.3 Subtask - Represent list rows, artifact rows, and chat composer children, row identity, active state, and event metadata. + + [ ] 2.1.3 Task - Implement progress, layer, callout, redline, and code IUR models + Represent the operational and text-specialized widgets with enough + structure for renderer parity. + + [ ] 2.1.3.1 Subtask - Represent steppers, segmented progress, vertical stages, and thin meters with normalized values and labels. + [ ] 2.1.3.2 Subtask - Represent sticky headers, slide-over panels, and event callouts with child placement, state, size, and tone metadata. + [ ] 2.1.3.3 Subtask - Represent redline and pre-tokenized code segments as plain text plus semantic segment or token types. + + [ ] 2.2 Section - Compiler Lowering and Canonical Interaction Descriptors + Lower UnifiedUi authored declarations into deterministic IUR nodes and + canonical interaction descriptors. + + [ ] 2.2.1 Task - Lower widget declarations into IUR + Connect the Phase 1 authoring surface to the new IUR models. + + [ ] 2.2.1.1 Subtask - Lower every expanded widget family to a canonical element type or semantic variant. + [ ] 2.2.1.2 Subtask - Normalize defaults such as open state, active state, progress ranges, token lists, and child ordering during compile. + [ ] 2.2.1.3 Subtask - Preserve canonical names in exported IUR while retaining optional alias diagnostics for authoring inputs. + + [ ] 2.2.2 Task - Lower interactions into renderer-independent descriptors + Ensure interactive widgets carry canonical event meaning rather than + runtime callback names. + + [ ] 2.2.2.1 Subtask - Lower selection, row activation, step navigation, submit, change, send, disclosure, panel, and inline action intent. + [ ] 2.2.2.2 Subtask - Preserve payload mapping for selected values, row ids, field values, composer text, and step ids. + [ ] 2.2.2.3 Subtask - Verify descriptors can be transported as Jido.Signal-compatible event meaning. + + [ ] 2.3 Section - List-Repeat Metadata and Hydration + Implement the deterministic row expansion behavior that turns repeat + composition into concrete child IUR nodes. + + [ ] 2.3.1 Task - Represent repeat metadata before expansion + Keep repeat composition inspectable in canonical IUR before row data is + applied. + + [ ] 2.3.1.1 Subtask - Represent repeat binding id, template identity, child slot, order, and row-scope binding metadata. + [ ] 2.3.1.2 Subtask - Validate repeat metadata against list binding descriptors. + [ ] 2.3.1.3 Subtask - Include repeat metadata in export and inspection output without requiring renderer participation. + + [ ] 2.3.2 Task - Expand repeated children deterministically + Hydrate repeated child templates into concrete IUR children before + runtime rendering. + + [ ] 2.3.2.1 Subtask - Define stable generated ids for row-expanded child instances. + [ ] 2.3.2.2 Subtask - Project row-scope binding values into child props or binding states. + [ ] 2.3.2.3 Subtask - Preserve child interactions and accessibility metadata for each repeated row. + + [ ] 2.4 Section - Safety, Accessibility, and Validation Fixtures + Provide shared fixtures and validation rules that prove the IUR contract + carries safety and accessibility meaning. + + [ ] 2.4.1 Task - Add text-safety and token validation + Ensure IUR accepts semantic text metadata without treating user-provided + text as trusted markup. + + [ ] 2.4.1.1 Subtask - Validate redline segment kinds and states. + [ ] 2.4.1.2 Subtask - Validate supported code token types while allowing plain text fallback tokens. + [ ] 2.4.1.3 Subtask - Add malicious-content fixtures for renderers to escape in host output. + + [ ] 2.4.2 Task - Add accessibility and state fixtures + Give runtime packages a shared target for roles, labels, progress state, + active state, and open state. + + [ ] 2.4.2.1 Subtask - Add fixtures for headings, labels, button pressed state, progress values, disclosure state, and panel labels. + [ ] 2.4.2.2 Subtask - Add fixture summaries that runtimes can use in parity tests. + [ ] 2.4.2.3 Subtask - Add validation that required accessible names are present where the canonical contract requires them. + + [ ] 2.5 Section - Phase 2 Integration Tests + Validate compiler lowering, IUR representation, repeat expansion, and + shared fixtures end to end. + + [ ] 2.5.1 Task - Compiler and IUR snapshot scenarios + Verify the expanded authored catalog compiles into deterministic IUR. + + [ ] 2.5.1.1 Subtask - Snapshot representative IUR for every expanded widget family. + [ ] 2.5.1.2 Subtask - Verify interaction descriptors are runtime-independent and preserve expected payload mappings. + [ ] 2.5.1.3 Subtask - Verify exported IUR contains canonical names and normalized defaults. + + [ ] 2.5.2 Task - Repeat hydration scenarios + Verify list-repeat metadata and row expansion preserve identity and row + data. + + [ ] 2.5.2.1 Subtask - Verify repeat metadata appears before hydration. + [ ] 2.5.2.2 Subtask - Verify hydrated children have stable ids, row values, and preserved interactions. + [ ] 2.5.2.3 Subtask - Verify empty lists produce deterministic empty child output without renderer errors. diff --git a/.spec/planning/canonical_widget_components/phase-03-live-ui-native-components-and-iur-renderer-alignment.md b/.spec/planning/canonical_widget_components/phase-03-live-ui-native-components-and-iur-renderer-alignment.md new file mode 100644 index 00000000..6c372074 --- /dev/null +++ b/.spec/planning/canonical_widget_components/phase-03-live-ui-native-components-and-iur-renderer-alignment.md @@ -0,0 +1,121 @@ +# Phase 3 - LiveUi Native Components and IUR Renderer Alignment + +Back to index: [README](./README.md) + +## Relevant Shared APIs / Interfaces +- `LiveUi.Widget` +- `LiveUi.Component` +- `LiveUi.Renderer` +- `LiveUi.Runtime.ScreenComponent` +- `LiveUi.Signal` +- `UnifiedIUR.Element` +- `UnifiedIUR.Interaction` + +## Relevant Assumptions / Defaults +- LiveUi widgets are LiveComponent-backed runtime units where a lifecycle or + event boundary is needed. +- Pure structural helpers may remain function components when they do not need + bounded widget state. +- Canonical IUR rendering targets the same native components used by direct + LiveUi usage. +- All work in this phase is not done. + +[ ] 3 Phase 3 - LiveUi Native Components and IUR Renderer Alignment + Implement the expanded widget-component catalog in LiveUi and make the + canonical renderer map IUR input into those same native components. + + [ ] 3.1 Section - LiveUi Component Module Backbone + Establish shared module layout, assigns contracts, and renderer dispatch + conventions for the expanded catalog. + + [ ] 3.1.1 Task - Define component modules and assigns contracts + Create the component boundaries and shared assign validation needed for + consistent implementation. + + [ ] 3.1.1.1 Subtask - Add component modules or grouped modules for content, identity, controls, rows, progress, layers, callouts, redline, code, and composer widgets. + [ ] 3.1.1.2 Subtask - Define assign contracts for canonical props, children, interaction descriptors, and accessibility metadata. + [ ] 3.1.1.3 Subtask - Ensure helper APIs delegate to component boundaries rather than creating parallel render implementations. + + [ ] 3.1.2 Task - Define shared rendering and style hooks + Keep styling token-driven and reusable across native and IUR-rendered + LiveUi paths. + + [ ] 3.1.2.1 Subtask - Define common class, token, state, and size hooks for the expanded catalog. + [ ] 3.1.2.2 Subtask - Avoid literal color, font, or spacing values in component output where theme tokens should apply. + [ ] 3.1.2.3 Subtask - Add component metadata used by tooling and focused examples. + + [ ] 3.2 Section - Content, Identity, Form, Control, and Composer Components + Implement LiveUi-native components for the widgets that are central to + content, identity, form, and messaging surfaces. + + [ ] 3.2.1 Task - Implement content, identity, and disclosure components + Realize the passive and stateful content widgets through LiveView-native + markup and component boundaries. + + [ ] 3.2.1.1 Subtask - Implement inline rich heading and kicker components with safe text segment rendering. + [ ] 3.2.1.2 Subtask - Implement avatar and presence dot components with accessible labels and theme variants. + [ ] 3.2.1.3 Subtask - Implement disclosure with native open-state rendering and child body composition. + + [ ] 3.2.2 Task - Implement form, segmented control, and composer components + Realize interactive controls while preserving canonical signal meaning. + + [ ] 3.2.2.1 Subtask - Implement segmented button group with pressed state and canonical selection event translation. + [ ] 3.2.2.2 Subtask - Implement runtime-owned form shell with LiveView and AshPhoenix integration hooks that remain LiveUi-local. + [ ] 3.2.2.3 Subtask - Implement chat composer with textarea, tool area, disabled state, send event, and change event handling. + + [ ] 3.3 Section - Row, Workflow, Layer, Callout, Redline, and Code Components + Implement the operational and document-workflow components used by list, + process, overlay, and text-review surfaces. + + [ ] 3.3.1 Task - Implement row and workflow components + Build components for selectable rows, artifact summaries, workflow + steppers, segmented progress, stage lists, and thin meters. + + [ ] 3.3.1.1 Subtask - Implement multi-column list row and artifact row with button and link variants. + [ ] 3.3.1.2 Subtask - Implement horizontal stepper and vertical stage list with active, done, and pending state. + [ ] 3.3.1.3 Subtask - Implement segmented progress and thin meter with normalized values and accessible progress metadata. + + [ ] 3.3.2 Task - Implement layer, callout, redline, and code components + Build components for shell surfaces and specialized text display. + + [ ] 3.3.2.1 Subtask - Implement sticky frosted header with fallback styling and positional children. + [ ] 3.3.2.2 Subtask - Implement slide-over panel as non-modal contextual layer with open state and size. + [ ] 3.3.2.3 Subtask - Implement event callout, redline inline, and pre-tokenized code block with safe text escaping. + + [ ] 3.4 Section - LiveUi IUR Renderer Convergence + Connect UnifiedIUR input for the expanded catalog to the same LiveUi native + components used directly. + + [ ] 3.4.1 Task - Add renderer dispatch for expanded widgets + Map each canonical IUR widget type into native LiveUi components. + + [ ] 3.4.1.1 Subtask - Add renderer clauses for every expanded widget family. + [ ] 3.4.1.2 Subtask - Map IUR props, children, accessibility metadata, and interaction descriptors into component assigns. + [ ] 3.4.1.3 Subtask - Ensure unknown or unsupported values produce diagnostics rather than silent fallback markup. + + [ ] 3.4.2 Task - Map canonical interactions into LiveView behavior + Preserve event meaning while translating into LiveView event mechanics. + + [ ] 3.4.2.1 Subtask - Translate selection, submit, change, send, row activation, step navigation, and inline actions into LiveUi signal handling. + [ ] 3.4.2.2 Subtask - Preserve canonical payload mapping for selected values, row ids, fields, composer text, and step ids. + [ ] 3.4.2.3 Subtask - Keep disclosure and slide-over local state bounded and subordinate to screen authority. + + [ ] 3.5 Section - Phase 3 Integration Tests + Validate LiveUi native component usage and IUR-rendered usage converge on + the same behavior and safety guarantees. + + [ ] 3.5.1 Task - Native component scenarios + Verify direct LiveUi usage covers representative widgets from every + expanded family. + + [ ] 3.5.1.1 Subtask - Verify each component renders required content, state, accessibility metadata, and children. + [ ] 3.5.1.2 Subtask - Verify interactive components emit the expected canonical signal meaning. + [ ] 3.5.1.3 Subtask - Verify redline and code components escape malicious text fixtures. + + [ ] 3.5.2 Task - IUR renderer scenarios + Verify canonical IUR renders through native LiveUi components rather than + renderer-only markup. + + [ ] 3.5.2.1 Subtask - Verify IUR fixtures for every expanded family render through component boundaries. + [ ] 3.5.2.2 Subtask - Verify repeated row IUR renders deterministic repeated child components. + [ ] 3.5.2.3 Subtask - Verify renderer and native component outputs preserve the same semantic state and event meaning. diff --git a/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md b/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md new file mode 100644 index 00000000..81ce25c7 --- /dev/null +++ b/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md @@ -0,0 +1,122 @@ +# Phase 4 - ElmUi, DesktopUi, and TerminalUi Runtime Parity + +Back to index: [README](./README.md) + +## Relevant Shared APIs / Interfaces +- `ElmUi.Renderer` +- `ElmUi.ServerRuntime` +- `ElmUi.FrontendRuntime` +- `DesktopUi.Renderer` +- `DesktopUi.Runtime` +- `TerminalUi.Renderer` +- `TerminalUi.Capabilities` +- `UnifiedIUR.Element` +- `UnifiedIUR.Interaction` + +## Relevant Assumptions / Defaults +- Runtime packages own their native widget APIs and do not become IUR-only + shells. +- Runtime renderers consume the shared UnifiedIUR fixtures created in Phase 2. +- TerminalUi preserves meaning through explicit degradation where visual effects + cannot be represented directly. +- All work in this phase is not done. + +[ ] 4 Phase 4 - ElmUi, DesktopUi, and TerminalUi Runtime Parity + Implement native and IUR-rendered equivalents for the expanded catalog in + ElmUi, DesktopUi, and TerminalUi with runtime-appropriate interaction and + degradation behavior. + + [ ] 4.1 Section - Shared Runtime Parity Fixtures + Establish shared fixture coverage and expected behavior summaries before + implementing runtime-specific widgets. + + [ ] 4.1.1 Task - Create runtime parity fixture suite + Provide common inputs that every runtime package can render and validate. + + [ ] 4.1.1.1 Subtask - Add fixture groups for content, identity, form, control, row, progress, layer, callout, redline, code, composer, and repeated rows. + [ ] 4.1.1.2 Subtask - Add expected semantic summaries for state, labels, interactions, and child structure. + [ ] 4.1.1.3 Subtask - Add malicious text fixtures for redline and code output safety across host runtimes. + + [ ] 4.1.2 Task - Define runtime parity acceptance criteria + Make clear when a runtime has native support, degraded support, or a + missing implementation. + + [ ] 4.1.2.1 Subtask - Define full, degraded, and unsupported statuses for every expanded widget family. + [ ] 4.1.2.2 Subtask - Define minimum interaction and accessibility behavior required even under degraded rendering. + [ ] 4.1.2.3 Subtask - Add parity reports that compare runtime coverage against the canonical fixture set. + + [ ] 4.2 Section - ElmUi Runtime Support + Implement the expanded catalog in the Phoenix-and-Elm runtime split while + preserving canonical IUR and signal meaning. + + [ ] 4.2.1 Task - Implement ElmUi native widget models + Add server-side and Elm frontend representations for the expanded widget + catalog. + + [ ] 4.2.1.1 Subtask - Add server-side widget records and validation for expanded catalog props, children, and interactions. + [ ] 4.2.1.2 Subtask - Add Elm frontend rendering and update messages for interactive widgets. + [ ] 4.2.1.3 Subtask - Add safe text output for redline and code token content. + + [ ] 4.2.2 Task - Implement ElmUi IUR renderer mappings + Map canonical IUR into ElmUi's native server and frontend runtime model. + + [ ] 4.2.2.1 Subtask - Add renderer mappings for every expanded widget family. + [ ] 4.2.2.2 Subtask - Translate canonical interactions into ElmUi message and Phoenix boundary events. + [ ] 4.2.2.3 Subtask - Verify repeated rows render as deterministic frontend children. + + [ ] 4.3 Section - DesktopUi Runtime Support + Implement desktop-native equivalents and IUR mappings with pointer, + keyboard, and platform behavior appropriate for SDL3-oriented rendering. + + [ ] 4.3.1 Task - Implement DesktopUi native widget models + Add desktop-native structures for the expanded widget catalog. + + [ ] 4.3.1.1 Subtask - Add native widget structs and style hooks for content, identity, controls, rows, progress, layers, callouts, redline, code, and composer surfaces. + [ ] 4.3.1.2 Subtask - Add keyboard and pointer handling for selection, row activation, step navigation, disclosure, slide-over, form, and composer widgets. + [ ] 4.3.1.3 Subtask - Add safe text layout and token rendering for redline and code widgets. + + [ ] 4.3.2 Task - Implement DesktopUi IUR renderer mappings + Map canonical IUR into DesktopUi native widgets and desktop signal + translation. + + [ ] 4.3.2.1 Subtask - Add renderer mappings for every expanded widget family. + [ ] 4.3.2.2 Subtask - Translate canonical interactions into desktop input and signal behavior. + [ ] 4.3.2.3 Subtask - Verify repeated rows produce stable widget identity across desktop rerenders. + + [ ] 4.4 Section - TerminalUi Runtime Support and Degradation + Implement terminal-native equivalents and explicit fallback behavior for + visual affordances that terminals cannot always render directly. + + [ ] 4.4.1 Task - Implement TerminalUi native widget models + Add terminal widgets and capability-aware rendering for the expanded + catalog. + + [ ] 4.4.1.1 Subtask - Add terminal-native models for content, identity, controls, rows, progress, layers, callouts, redline, code, and composer widgets. + [ ] 4.4.1.2 Subtask - Add keyboard-first behavior for selection, row activation, step navigation, disclosure, slide-over, form, and composer widgets. + [ ] 4.4.1.3 Subtask - Add terminal-safe redline and code rendering with color-depth and ASCII fallbacks. + + [ ] 4.4.2 Task - Implement TerminalUi IUR mappings and degradation reports + Map canonical IUR into terminal widgets and expose any degraded behavior + clearly. + + [ ] 4.4.2.1 Subtask - Add renderer mappings for every expanded widget family. + [ ] 4.4.2.2 Subtask - Define degradation for frost, slide animation, dense multi-column rows, color-rich progress, avatars, and syntax colors. + [ ] 4.4.2.3 Subtask - Add inspectable degradation summaries for runtime parity reports. + + [ ] 4.5 Section - Phase 4 Integration Tests + Validate runtime parity across ElmUi, DesktopUi, and TerminalUi using the + shared canonical fixtures and runtime-specific behavior checks. + + [ ] 4.5.1 Task - Runtime fixture parity scenarios + Verify every runtime consumes the shared expanded catalog fixtures. + + [ ] 4.5.1.1 Subtask - Verify ElmUi renders and updates fixture widgets through server and frontend runtime paths. + [ ] 4.5.1.2 Subtask - Verify DesktopUi renders fixture widgets with deterministic native widget identity and event translation. + [ ] 4.5.1.3 Subtask - Verify TerminalUi renders or degrades fixture widgets with explicit capability summaries. + + [ ] 4.5.2 Task - Cross-runtime behavior scenarios + Verify canonical meaning remains stable across runtime differences. + + [ ] 4.5.2.1 Subtask - Compare selection, submit, change, send, row activation, and step navigation signal meaning across runtimes. + [ ] 4.5.2.2 Subtask - Compare repeated row identity and row-scoped values across runtimes. + [ ] 4.5.2.3 Subtask - Compare redline and code safety fixtures across runtimes. diff --git a/.spec/planning/canonical_widget_components/phase-05-examples-documentation-tooling-and-conformance.md b/.spec/planning/canonical_widget_components/phase-05-examples-documentation-tooling-and-conformance.md new file mode 100644 index 00000000..4e99d5e9 --- /dev/null +++ b/.spec/planning/canonical_widget_components/phase-05-examples-documentation-tooling-and-conformance.md @@ -0,0 +1,124 @@ +# Phase 5 - Examples, Documentation, Tooling, and Conformance + +Back to index: [README](./README.md) + +## Relevant Shared APIs / Interfaces +- `examples/catalog.tsv` +- `mix spec.plancheck` +- `mix spec.traceability.generate` +- `mix spec.compliance` +- `UnifiedUi.Tooling` +- `UnifiedIUR.Tooling` +- Runtime package inspect and validate tasks + +## Relevant Assumptions / Defaults +- Examples are standalone focused apps under `examples//`. +- Generated traceability markdown mirrors are regenerated from JSON rather than + hand-edited. +- Conformance evidence remains separate from authored specs. +- All work in this phase is not done. + +[ ] 5 Phase 5 - Examples, Documentation, Tooling, and Conformance + Make the expanded widget-component catalog reviewable and maintainable + through focused examples, developer and user guidance, tooling, validation, + and conformance evidence. + + [ ] 5.1 Section - Focused Examples and Catalog Entries + Add standalone examples that exercise the expanded widget families without + reintroducing an aggregate demo application. + + [ ] 5.1.1 Task - Add focused widget-family examples + Provide examples that are small enough to validate and review but broad + enough to demonstrate realistic composition. + + [ ] 5.1.1.1 Subtask - Add a content and identity example covering rich heading, kicker, avatar, presence dot, and disclosure. + [ ] 5.1.1.2 Subtask - Add a workflow and progress example covering stepper, segmented progress, stage list, and thin meter. + [ ] 5.1.1.3 Subtask - Add a document-workflow example covering artifact row, event callout, redline, code block, slide-over panel, and chat composer. + [ ] 5.1.1.4 Subtask - Add a list-repeat example that renders repeated row templates from list binding data. + + [ ] 5.1.2 Task - Update example catalog and runtime targets + Make the new examples discoverable and runnable across relevant runtime + targets. + + [ ] 5.1.2.1 Subtask - Add examples to `examples/catalog.tsv` with canonical package and runtime target metadata. + [ ] 5.1.2.2 Subtask - Add runtime target coverage for LiveUi first and additional runtimes as Phase 4 support lands. + [ ] 5.1.2.3 Subtask - Add example validation checks that verify examples stay self-contained. + + [ ] 5.2 Section - Developer and User Documentation + Add guidance for authors, runtime implementers, and application users of + the expanded catalog. + + [ ] 5.2.1 Task - Write authoring and behavior guides + Explain the canonical mental model and the distinction between portable + widgets and runtime-specific host behavior. + + [ ] 5.2.1.1 Subtask - Document each expanded widget family, its canonical props, children, states, and interaction meanings. + [ ] 5.2.1.2 Subtask - Document runtime-owned form behavior and the relationship between canonical form shells and LiveUi Phoenix integration. + [ ] 5.2.1.3 Subtask - Document list-repeat composition, row-scope bindings, and deterministic repeated child identity. + + [ ] 5.2.2 Task - Write runtime implementation guidance + Help runtime maintainers implement parity without copying AshUi-specific + assumptions. + + [ ] 5.2.2.1 Subtask - Document native widget expectations for LiveUi, ElmUi, DesktopUi, and TerminalUi. + [ ] 5.2.2.2 Subtask - Document interaction translation requirements for each interactive widget family. + [ ] 5.2.2.3 Subtask - Document TerminalUi degradation rules and minimum meaning-preservation requirements. + + [ ] 5.3 Section - Tooling, Inspection, and Validation + Extend package tooling so maintainers can inspect, export, and validate the + expanded catalog and repeat behavior. + + [ ] 5.3.1 Task - Extend UnifiedUi and UnifiedIUR inspection + Make the expanded catalog visible in tooling output. + + [ ] 5.3.1.1 Subtask - Add inspect output for expanded widget props, children, interactions, accessibility metadata, and repeat metadata. + [ ] 5.3.1.2 Subtask - Add export output that preserves canonical names and normalized defaults. + [ ] 5.3.1.3 Subtask - Add validation output for alias use, malformed props, unsupported token types, and repeat errors. + + [ ] 5.3.2 Task - Extend runtime validation tools + Give each runtime package focused checks for widget parity. + + [ ] 5.3.2.1 Subtask - Add LiveUi validation for native component and IUR renderer parity. + [ ] 5.3.2.2 Subtask - Add ElmUi, DesktopUi, and TerminalUi validation against shared IUR fixtures. + [ ] 5.3.2.3 Subtask - Add terminal degradation validation for rich and limited capability profiles. + + [ ] 5.4 Section - Conformance and Traceability + Record package implementation evidence separately from the authored specs + and keep plan coverage machine-readable. + + [ ] 5.4.1 Task - Update planning traceability manifests + Connect the expanded widget-component requirements to implementation + phases without hand-editing generated mirrors. + + [ ] 5.4.1.1 Subtask - Add plan coverage entries for UnifiedUi widget components and list-repeat authoring. + [ ] 5.4.1.2 Subtask - Add plan coverage entries for UnifiedIUR representation, compiler lowering, and repeat hydration. + [ ] 5.4.1.3 Subtask - Add plan coverage entries for runtime parity, examples, tooling, and docs. + [ ] 5.4.1.4 Subtask - Regenerate traceability markdown mirrors through the owning Mix task. + + [ ] 5.4.2 Task - Update conformance evidence + Record implementation proof in package-local conformance manifests after + each package has real coverage. + + [ ] 5.4.2.1 Subtask - Add UnifiedUi and UnifiedIUR conformance entries for the expanded catalog and repeat behavior. + [ ] 5.4.2.2 Subtask - Add LiveUi conformance entries for native component and renderer parity. + [ ] 5.4.2.3 Subtask - Add ElmUi, DesktopUi, and TerminalUi conformance entries as runtime parity lands. + + [ ] 5.5 Section - Phase 5 Integration Tests + Validate examples, docs, tooling, traceability, and conformance evidence as + the final release-readiness pass for the expanded catalog. + + [ ] 5.5.1 Task - Example and documentation scenarios + Verify focused examples and guides stay aligned with the implemented + catalog. + + [ ] 5.5.1.1 Subtask - Run every focused example in its supported runtime target. + [ ] 5.5.1.2 Subtask - Verify example catalog metadata matches actual example directories and package targets. + [ ] 5.5.1.3 Subtask - Verify guides do not describe AshUi-specific names as canonical where portable names exist. + + [ ] 5.5.2 Task - Tooling and conformance scenarios + Verify the expanded catalog is visible through repository tooling and + conformance checks. + + [ ] 5.5.2.1 Subtask - Run package-local inspect, export, and validate commands for the expanded catalog fixtures. + [ ] 5.5.2.2 Subtask - Run `mix spec.plancheck` and traceability generation for affected packages. + [ ] 5.5.2.3 Subtask - Run `mix spec.compliance` for affected packages after conformance entries are added. diff --git a/.spec/specs/platform_runtimes.spec.md b/.spec/specs/platform_runtimes.spec.md index 09d566c2..4ba9bd60 100644 --- a/.spec/specs/platform_runtimes.spec.md +++ b/.spec/specs/platform_runtimes.spec.md @@ -17,6 +17,7 @@ surface: decisions: - repo.ecosystem.contract_model - repo.ecosystem.elm_ui_naming + - repo.ecosystem.canonical_widget_component_expansion ``` ## Requirements @@ -32,6 +33,11 @@ decisions: priority: must stability: stable +- id: ecosystem.platform_runtimes.expanded_widget_component_parity + statement: Each widget library shall provide native component equivalents and IUR renderer mappings for the expanded canonical widget-component catalog and list-repeat composition behavior, with explicit degradation where the runtime cannot realize a web-like visual affordance directly. + priority: must + stability: stable + - id: ecosystem.platform_runtimes.native_surface_usable_without_iur statement: Each widget library's native widgets, layering constructs, styling attributes, and local interaction model shall be usable directly without first loading canonical IUR. priority: must @@ -97,6 +103,7 @@ decisions: covers: - ecosystem.platform_runtimes.widget_libraries_independent - ecosystem.platform_runtimes.native_surface_covers_iur + - ecosystem.platform_runtimes.expanded_widget_component_parity - ecosystem.platform_runtimes.native_surface_usable_without_iur - ecosystem.platform_runtimes.iur_interpretation - ecosystem.platform_runtimes.elm_ui_runtime_split diff --git a/.spec/specs/unified-iur/widget_components.spec.md b/.spec/specs/unified-iur/widget_components.spec.md new file mode 100644 index 00000000..706d7c6e --- /dev/null +++ b/.spec/specs/unified-iur/widget_components.spec.md @@ -0,0 +1,113 @@ +# UnifiedIUR Widget Components + +This subject defines the canonical IUR representation for the widget-component +expansion derived from AshUi PRs 79 through 98. + +## Related General Specs + +- [Ecosystem Widget Component Behavior](../widget_component_behavior.spec.md) +- [UnifiedIUR Widgets](./widgets.spec.md) +- [UnifiedIUR Constructs](./constructs.spec.md) +- [UnifiedIUR Interactions](./interactions.spec.md) +- [UnifiedUi Widget Components](../unified-ui/widget_components.spec.md) + +```spec-meta +id: unified_iur.widget_components +kind: capability +status: active +summary: Canonical IUR contract for expanded widget components and list-repeat composition behavior derived from AshUi PRs 79 through 98. +surface: + - packages/unified_iur + - .spec/specs/unified-iur/widget_components.spec.md + - .spec/specs/unified-ui/widget_components.spec.md +decisions: + - repo.ecosystem.contract_model + - repo.ecosystem.canonical_widget_component_expansion +``` + +## Requirements + +```spec-requirements +- id: unified_iur.widget_components.canonical_node_types + statement: UnifiedIUR shall represent canonical node types or equivalent semantic variants for the expanded widget catalog, including rich headings, disclosure, runtime-owned forms, kicker labels, avatars, presence indicators, segmented controls, row primitives, progress and stage displays, shell surfaces, callouts, redlines, code blocks, chat composers, and repeated child composition. + priority: must + stability: stable + +- id: unified_iur.widget_components.content_models + statement: Each expanded widget representation shall preserve its required content model, including inline text segments, option lists, field lists, columns, title and meta text, trailing or tool children, stage lists, progress segments, redline segments, code token lists, and positional header children. + priority: must + stability: stable + +- id: unified_iur.widget_components.interaction_descriptors + statement: Expanded widget representations shall carry renderer-independent interaction descriptors for selection, submit, change, send, row activation, step navigation, disclosure state, slide-over state, and inline actions when those interactions are authored. + priority: must + stability: stable + +- id: unified_iur.widget_components.accessibility_and_state_metadata + statement: Expanded widget representations shall preserve accessibility and state metadata such as heading level, labels, aria-like names, active or pressed state, progress value ranges, disabled state, role intent, and open or closed state without forcing one runtime markup model. + priority: must + stability: stable + +- id: unified_iur.widget_components.text_safety_contract + statement: UnifiedIUR shall treat redline segment content and code token content as plain text plus semantic token metadata, leaving renderers responsible for safe host output and preventing canonical data from carrying trusted markup by default. + priority: must + stability: stable + +- id: unified_iur.widget_components.list_repeat_metadata + statement: UnifiedIUR shall preserve list-repeat composition metadata or deterministic expanded children so that runtimes receive one concrete child node per row with stable identity, row-scoped binding values, and canonical interaction descriptors. + priority: must + stability: stable + +- id: unified_iur.widget_components.runtime_mapping_completeness + statement: The expanded widget representations shall provide enough structure for LiveUi, ElmUi, DesktopUi, and TerminalUi renderers to map them into native widgets or documented capability-aware fallbacks without requiring AshUi-specific interpretation. + priority: must + stability: stable +``` + +## Scenarios + +```spec-scenarios +- id: unified_iur.widget_components.represent_expanded_widget_tree + covers: + - unified_iur.widget_components.canonical_node_types + - unified_iur.widget_components.content_models + - unified_iur.widget_components.interaction_descriptors + - unified_iur.widget_components.accessibility_and_state_metadata + - unified_iur.widget_components.text_safety_contract + - unified_iur.widget_components.runtime_mapping_completeness + given: + - UnifiedUi compiles a screen using the expanded widget-component catalog + when: + - The screen is represented as UnifiedIUR + then: + - The IUR preserves widget type, content, state, accessibility, interaction, and safety meaning for runtime rendering + +- id: unified_iur.widget_components.represent_repeated_rows + covers: + - unified_iur.widget_components.list_repeat_metadata + - unified_iur.widget_components.interaction_descriptors + - unified_iur.widget_components.runtime_mapping_completeness + given: + - A child template repeats over a list binding + when: + - UnifiedIUR stores or hydrates the composition + then: + - Runtime packages receive deterministic row children rather than an unresolved AshUi relationship directive +``` + +## Verification + +```spec-verification +- kind: source_file + target: .spec/specs/unified-iur/widget_components.spec.md + covers: + - unified_iur.widget_components.canonical_node_types + - unified_iur.widget_components.content_models + - unified_iur.widget_components.interaction_descriptors + - unified_iur.widget_components.accessibility_and_state_metadata + - unified_iur.widget_components.text_safety_contract + - unified_iur.widget_components.list_repeat_metadata + - unified_iur.widget_components.runtime_mapping_completeness + - unified_iur.widget_components.represent_expanded_widget_tree + - unified_iur.widget_components.represent_repeated_rows +``` diff --git a/.spec/specs/unified-iur/widgets.spec.md b/.spec/specs/unified-iur/widgets.spec.md index 06e479a5..57ebcc34 100644 --- a/.spec/specs/unified-iur/widgets.spec.md +++ b/.spec/specs/unified-iur/widgets.spec.md @@ -7,6 +7,7 @@ This subject defines the canonical widget families that `unified_iur` shall be a - [Architecture](../architecture.spec.md) - [DSL and IUR Symbiosis](../dsl_iur_symbiosis.spec.md) - [Platform Runtimes](../platform_runtimes.spec.md) +- [UnifiedIUR Widget Components](./widget_components.spec.md) ```spec-meta id: unified_iur.widgets @@ -19,6 +20,7 @@ surface: - .spec/specs/unified-ui/widgets.spec.md decisions: - repo.ecosystem.contract_model + - repo.ecosystem.canonical_widget_component_expansion ``` ## Requirements @@ -44,6 +46,11 @@ decisions: priority: must stability: stable +- id: unified_iur.widgets.expanded_widget_component_catalog + statement: `unified_iur` shall be able to represent the expanded canonical widget-component catalog and list-repeat composition behavior defined by `unified_iur.widget_components` without reducing those constructs to AshUi-specific or renderer-specific placeholders. + priority: must + stability: stable + - id: unified_iur.widgets.visualization statement: `unified_iur` shall be able to represent `gauge`, `sparkline`, `bar_chart`, `line_chart`, and `canvas` as canonical interchange widgets or drawing surfaces. priority: must @@ -70,6 +77,7 @@ decisions: - unified_iur.widgets.overlay_and_feedback - unified_iur.widgets.data_and_document_views - unified_iur.widgets.semantic_surface + - unified_iur.widgets.expanded_widget_component_catalog - unified_iur.widgets.visualization - unified_iur.widgets.operational_views - unified_iur.widgets.widget_semantics_preserved diff --git a/.spec/specs/unified-ui/widget_components.spec.md b/.spec/specs/unified-ui/widget_components.spec.md new file mode 100644 index 00000000..61d191b9 --- /dev/null +++ b/.spec/specs/unified-ui/widget_components.spec.md @@ -0,0 +1,118 @@ +# UnifiedUi Widget Components + +This subject defines the authored UnifiedUi surface for the canonical +widget-component expansion derived from AshUi PRs 79 through 98. + +## Related General Specs + +- [Ecosystem Widget Component Behavior](../widget_component_behavior.spec.md) +- [UnifiedUi Widgets](./widgets.spec.md) +- [UnifiedUi DSL](./dsl.spec.md) +- [UnifiedUi Compiler](./compiler.spec.md) +- [UnifiedIUR Widget Components](../unified-iur/widget_components.spec.md) + +```spec-meta +id: unified_ui.widget_components +kind: subsystem +status: active +summary: Authored DSL contract for canonical widget components and list-repeat composition behavior derived from AshUi PRs 79 through 98. +surface: + - packages/unified-ui + - .spec/specs/unified-ui/widget_components.spec.md + - .spec/specs/unified-iur/widget_components.spec.md +decisions: + - repo.ecosystem.contract_model + - repo.ecosystem.canonical_widget_component_expansion +``` + +## Requirements + +```spec-requirements +- id: unified_ui.widget_components.content_identity_and_disclosure_surface + statement: The DSL shall author content and identity components equivalent to `inline_rich_text_heading`, `kicker`, `avatar`, `presence_dot`, and `disclosure`, preserving heading level, inline emphasis segments, label clusters, identity imagery or initials, presence state, and disclosure open-state semantics. + priority: must + stability: stable + +- id: unified_ui.widget_components.form_control_and_composer_surface + statement: The DSL shall author form and composer controls equivalent to `segmented_button_group`, `phoenix_form` as a runtime-owned form shell, and `chat_composer`, preserving single-selection options, submit and change intent, textarea value and disabled state, tool-area children, and send intent. + priority: must + stability: stable + +- id: unified_ui.widget_components.row_and_artifact_surface + statement: The DSL shall author row-oriented components equivalent to `list_item_multi_column` and `artifact_row`, preserving selectable or linkable row identity, active state, column or title/meta structure, and trailing child content. + priority: must + stability: stable + +- id: unified_ui.widget_components.workflow_progress_and_status_surface + statement: The DSL shall author workflow and progress components equivalent to `pipeline_stepper_horizontal`, `segmented_progress_bar`, `workflow_stage_list_vertical`, and `meter_thin`, preserving stage order, done or active or pending state, weighted segment state, value normalization, labels, and optional navigation intent. + priority: must + stability: stable + +- id: unified_ui.widget_components.layer_shell_and_callout_surface + statement: The DSL shall author shell, layer, and callout components equivalent to `sticky_frosted_header`, `slide_over_panel`, and `event_callout`, preserving positional header slots, non-modal slide-over open state and sizing, callout tone, body, eyebrow, and inline action composition. + priority: must + stability: stable + +- id: unified_ui.widget_components.redline_and_code_surface + statement: The DSL shall author text-display components equivalent to `redline_inline` and `code_block_syntax_highlighted`, preserving redline keep, insert, delete, accepted, and rejected states plus pre-tokenized code language and token-type data without making the DSL a syntax highlighter. + priority: must + stability: stable + +- id: unified_ui.widget_components.list_repeat_authoring + statement: The DSL shall author list-repeat composition behavior that attaches a child template to a list binding, validates row-scope binding references, rejects repeat use against non-list composition, and compiles deterministic row-expanded canonical output. + priority: must + stability: stable + +- id: unified_ui.widget_components.portable_names_and_aliases + statement: The DSL shall document portable canonical names for host-neutral concepts and may provide AshUi-name aliases only when aliases do not make AshUi or a host runtime part of the canonical contract. + priority: should + stability: stable +``` + +## Scenarios + +```spec-scenarios +- id: unified_ui.widget_components.author_expanded_widget_screen + covers: + - unified_ui.widget_components.content_identity_and_disclosure_surface + - unified_ui.widget_components.form_control_and_composer_surface + - unified_ui.widget_components.row_and_artifact_surface + - unified_ui.widget_components.workflow_progress_and_status_surface + - unified_ui.widget_components.layer_shell_and_callout_surface + - unified_ui.widget_components.redline_and_code_surface + given: + - A developer authors a document-workflow or operational screen using the expanded widget catalog + when: + - The developer composes rows, progress, redlines, code, callouts, forms, and composer controls in UnifiedUi + then: + - The authored module expresses the screen through canonical DSL constructs without calling runtime-library widgets directly + +- id: unified_ui.widget_components.author_list_repeat + covers: + - unified_ui.widget_components.list_repeat_authoring + - unified_ui.widget_components.portable_names_and_aliases + given: + - A developer wants to render one artifact-row child per row in a list binding + when: + - The developer attaches repeat composition behavior to the child template + then: + - UnifiedUi validates the list binding and row-scope bindings before emitting canonical IUR +``` + +## Verification + +```spec-verification +- kind: source_file + target: .spec/specs/unified-ui/widget_components.spec.md + covers: + - unified_ui.widget_components.content_identity_and_disclosure_surface + - unified_ui.widget_components.form_control_and_composer_surface + - unified_ui.widget_components.row_and_artifact_surface + - unified_ui.widget_components.workflow_progress_and_status_surface + - unified_ui.widget_components.layer_shell_and_callout_surface + - unified_ui.widget_components.redline_and_code_surface + - unified_ui.widget_components.list_repeat_authoring + - unified_ui.widget_components.portable_names_and_aliases + - unified_ui.widget_components.author_expanded_widget_screen + - unified_ui.widget_components.author_list_repeat +``` diff --git a/.spec/specs/unified-ui/widgets.spec.md b/.spec/specs/unified-ui/widgets.spec.md index c1c2be8a..0fe5396f 100644 --- a/.spec/specs/unified-ui/widgets.spec.md +++ b/.spec/specs/unified-ui/widgets.spec.md @@ -10,6 +10,7 @@ This subject defines the canonical authored surface categories that the - [UnifiedUi Package](./package.spec.md) - [UnifiedUi DSL](./dsl.spec.md) - [UnifiedUi Compiler](./compiler.spec.md) +- [UnifiedUi Widget Components](./widget_components.spec.md) ```spec-meta id: unified_ui.widgets @@ -22,6 +23,7 @@ surface: - .spec/specs/unified-iur/widgets.spec.md decisions: - repo.ecosystem.contract_model + - repo.ecosystem.canonical_widget_component_expansion ``` ## Requirements @@ -47,6 +49,11 @@ decisions: priority: must stability: stable +- id: unified_ui.widgets.expanded_widget_component_surface + statement: The package shall author the expanded canonical widget-component catalog and list-repeat composition behavior defined by `unified_ui.widget_components` so AshUi PR 79-98 equivalents are available through the canonical DSL. + priority: must + stability: stable + - id: unified_ui.widgets.style_attribute_surface statement: The package shall author canonical styling attributes for typography, color, spacing, sizing, alignment, borders, background treatment, visibility, state variants, and theme-driven design tokens. priority: must @@ -72,6 +79,7 @@ decisions: - unified_ui.widgets.input_surface - unified_ui.widgets.layout_and_layer_surface - unified_ui.widgets.feedback_navigation_data_surface + - unified_ui.widgets.expanded_widget_component_surface - unified_ui.widgets.style_attribute_surface - unified_ui.widgets.canvas_surface - unified_ui.widgets.iur_surface_parity @@ -88,6 +96,7 @@ decisions: - unified_ui.widgets.input_surface - unified_ui.widgets.layout_and_layer_surface - unified_ui.widgets.feedback_navigation_data_surface + - unified_ui.widgets.expanded_widget_component_surface - unified_ui.widgets.style_attribute_surface - unified_ui.widgets.canvas_surface - unified_ui.widgets.iur_surface_parity @@ -109,6 +118,7 @@ decisions: - unified_ui.widgets.input_surface - unified_ui.widgets.layout_and_layer_surface - unified_ui.widgets.feedback_navigation_data_surface + - unified_ui.widgets.expanded_widget_component_surface - unified_ui.widgets.style_attribute_surface - unified_ui.widgets.canvas_surface - unified_ui.widgets.iur_surface_parity diff --git a/.spec/specs/widget_component_behavior.spec.md b/.spec/specs/widget_component_behavior.spec.md new file mode 100644 index 00000000..f1658151 --- /dev/null +++ b/.spec/specs/widget_component_behavior.spec.md @@ -0,0 +1,135 @@ +# Widget Component Behavior + +This subject defines the ecosystem-level behavior contract for the canonical +widget-component expansion derived from AshUi PRs 79 through 98. + +## Related General Specs + +- [Ecosystem Architecture](./architecture.spec.md) +- [DSL and IUR Symbiosis](./dsl_iur_symbiosis.spec.md) +- [Platform Runtimes](./platform_runtimes.spec.md) +- [Signal Transport](./signal_transport.spec.md) +- [UnifiedUi Widget Components](./unified-ui/widget_components.spec.md) +- [UnifiedIUR Widget Components](./unified-iur/widget_components.spec.md) + +```spec-meta +id: ecosystem.widget_component_behavior +kind: integration +status: active +summary: Cross-package behavior contract for the canonical widget-component catalog and list-repeat composition behavior derived from AshUi PRs 79 through 98. +surface: + - packages/unified-ui + - packages/unified_iur + - packages/live_ui + - packages/elm_ui + - packages/desktop_ui + - packages/terminal_ui + - .spec/specs/widget_component_behavior.spec.md +decisions: + - repo.ecosystem.contract_model + - repo.ecosystem.canonical_widget_component_expansion +``` + +## Requirements + +```spec-requirements +- id: ecosystem.widget_component_behavior.ash_ui_pr_79_98_equivalents + statement: The ecosystem shall provide canonical equivalents for the AshUi PR 79-98 additions, including inline rich text headings, disclosure, runtime-owned forms, kicker labels, avatars, presence indicators, segmented controls, multi-column list rows, artifact rows, sticky headers, workflow steppers, segmented progress, vertical stage lists, thin meters, slide-over panels, event callouts, inline redlines, pre-tokenized code blocks, chat composers, and list-repeat composition. + priority: must + stability: stable + +- id: ecosystem.widget_component_behavior.semantic_families + statement: The expanded widget-component catalog shall be grouped into portable semantic families for content and identity, form and composer controls, list and artifact rows, workflow and progress status, layer and shell surfaces, and code or redline text display. + priority: must + stability: stable + +- id: ecosystem.widget_component_behavior.host_specific_names_do_not_define_canonical_meaning + statement: Host-specific AshUi names or implementation details shall not define the canonical ecosystem meaning; for example, the AshUi `phoenix_form` concept shall be represented canonically as a runtime-owned form shell with LiveUi-specific Phoenix realization. + priority: must + stability: stable + +- id: ecosystem.widget_component_behavior.list_repeat_is_composition_behavior + statement: List-repeat support shall be treated as canonical composition behavior that binds a child template to rows from a list binding and produces deterministic child element instances before runtime rendering. + priority: must + stability: stable + +- id: ecosystem.widget_component_behavior.interaction_meaning_preserved + statement: Widget interactions shall preserve canonical event meaning for selection, submit, change, send, row activation, step navigation, disclosure state, panel state, and action affordances without embedding renderer-local callback names as the cross-package contract. + priority: must + stability: stable + +- id: ecosystem.widget_component_behavior.accessibility_and_safety_preserved + statement: Widget realizations shall preserve applicable accessibility roles, labels, state attributes, and text-safety rules, including escaping plain-text code and redline content before any runtime emits markup or host-native rich text. + priority: must + stability: stable + +- id: ecosystem.widget_component_behavior.runtime_native_and_iur_parity + statement: LiveUi, ElmUi, DesktopUi, and TerminalUi shall expose native equivalents for the expanded widget-component catalog and shall also map canonical UnifiedIUR input into those same native meanings. + priority: must + stability: stable + +- id: ecosystem.widget_component_behavior.capability_aware_degradation + statement: Runtime packages with limited display or interaction capabilities shall use explicit degradation policies for the expanded catalog while preserving core structure, state, accessibility, and interaction meaning. + priority: must + stability: stable +``` + +## Scenarios + +```spec-scenarios +- id: ecosystem.widget_component_behavior.render_document_workflow_surface + covers: + - ecosystem.widget_component_behavior.ash_ui_pr_79_98_equivalents + - ecosystem.widget_component_behavior.semantic_families + - ecosystem.widget_component_behavior.interaction_meaning_preserved + - ecosystem.widget_component_behavior.runtime_native_and_iur_parity + given: + - A document-workflow screen contains artifact rows, event callouts, redline text, code blocks, progress indicators, a slide-over panel, and a chat composer + when: + - The screen is authored in UnifiedUi and rendered through a runtime package + then: + - The runtime realizes the same canonical widget meanings without falling back to AshUi-specific widgets or renderer-local escape hatches + +- id: ecosystem.widget_component_behavior.expand_list_repeat_rows + covers: + - ecosystem.widget_component_behavior.list_repeat_is_composition_behavior + - ecosystem.widget_component_behavior.interaction_meaning_preserved + - ecosystem.widget_component_behavior.accessibility_and_safety_preserved + given: + - An authored child template is repeated over a list binding + when: + - The compiler and IUR hydration pipeline resolve the screen + then: + - The runtime receives deterministic child element instances with row-scoped data and canonical interactions already preserved + +- id: ecosystem.widget_component_behavior.degrade_to_terminal + covers: + - ecosystem.widget_component_behavior.runtime_native_and_iur_parity + - ecosystem.widget_component_behavior.capability_aware_degradation + - ecosystem.widget_component_behavior.accessibility_and_safety_preserved + given: + - The expanded catalog is rendered in a limited terminal profile + when: + - Visual effects such as frost, slide animation, color-rich progress, or multi-column spacing are unavailable + then: + - TerminalUi applies explicit readable fallbacks while preserving state, labels, actions, and event meaning +``` + +## Verification + +```spec-verification +- kind: source_file + target: .spec/specs/widget_component_behavior.spec.md + covers: + - ecosystem.widget_component_behavior.ash_ui_pr_79_98_equivalents + - ecosystem.widget_component_behavior.semantic_families + - ecosystem.widget_component_behavior.host_specific_names_do_not_define_canonical_meaning + - ecosystem.widget_component_behavior.list_repeat_is_composition_behavior + - ecosystem.widget_component_behavior.interaction_meaning_preserved + - ecosystem.widget_component_behavior.accessibility_and_safety_preserved + - ecosystem.widget_component_behavior.runtime_native_and_iur_parity + - ecosystem.widget_component_behavior.capability_aware_degradation + - ecosystem.widget_component_behavior.render_document_workflow_surface + - ecosystem.widget_component_behavior.expand_list_repeat_rows + - ecosystem.widget_component_behavior.degrade_to_terminal +``` diff --git a/packages/unified-ui/lib/unified_ui/reference.ex b/packages/unified-ui/lib/unified_ui/reference.ex index 385d7232..d1ccbb35 100644 --- a/packages/unified-ui/lib/unified_ui/reference.ex +++ b/packages/unified-ui/lib/unified_ui/reference.ex @@ -7,6 +7,7 @@ defmodule UnifiedUi.Reference do alias UnifiedUi.Examples alias UnifiedUi.Signal alias UnifiedUi.Theme + alias UnifiedUi.WidgetComponents @spec supported_sections() :: [atom()] def supported_sections do @@ -68,6 +69,26 @@ defmodule UnifiedUi.Reference do } end + @spec widget_component_families() :: %{WidgetComponents.family() => [atom()]} + def widget_component_families do + WidgetComponents.component_families() + end + + @spec widget_component_catalog() :: [WidgetComponents.component()] + def widget_component_catalog do + WidgetComponents.catalog() + end + + @spec widget_component_source_mapping() :: %{pos_integer() => map()} + def widget_component_source_mapping do + WidgetComponents.source_mapping() + end + + @spec widget_component_aliases() :: %{atom() => atom()} + def widget_component_aliases do + WidgetComponents.aliases() + end + @spec compiled_display_system_families() :: %{atom() => [atom()]} def compiled_display_system_families do %{ diff --git a/packages/unified-ui/lib/unified_ui/tooling.ex b/packages/unified-ui/lib/unified_ui/tooling.ex index 7521bc1b..4d9841b6 100644 --- a/packages/unified-ui/lib/unified_ui/tooling.ex +++ b/packages/unified-ui/lib/unified_ui/tooling.ex @@ -4,7 +4,7 @@ defmodule UnifiedUi.Tooling do and release review workflows. """ - alias UnifiedUi.{Compiler, Examples, Export, Info, Signals} + alias UnifiedUi.{Compiler, Examples, Export, Info, Signals, WidgetComponents} @shared_specs [ ".spec/specs/architecture.spec.md", @@ -81,6 +81,22 @@ defmodule UnifiedUi.Tooling do |> inspect_term() end + @spec widget_component_catalog() :: map() + def widget_component_catalog do + %{ + families: WidgetComponents.component_families(), + components: WidgetComponents.catalog(), + aliases: WidgetComponents.aliases(), + source_mapping: WidgetComponents.source_mapping() + } + end + + @spec widget_component_catalog_summary() :: String.t() + def widget_component_catalog_summary do + widget_component_catalog() + |> inspect_term() + end + @spec inspect_example(atom()) :: inspection_result() | :error def inspect_example(id) when is_atom(id) do with {:ok, example} <- Examples.example(id), diff --git a/packages/unified-ui/lib/unified_ui/widget_components.ex b/packages/unified-ui/lib/unified_ui/widget_components.ex new file mode 100644 index 00000000..5df729df --- /dev/null +++ b/packages/unified-ui/lib/unified_ui/widget_components.ex @@ -0,0 +1,356 @@ +defmodule UnifiedUi.WidgetComponents do + @moduledoc """ + Reference catalog for the canonical widget-component expansion. + + The catalog records the AshUi PR 79-98 source mapping, portable canonical + names, semantic families, and compatibility aliases before the DSL entities + are implemented. + """ + + @type family :: + :content_identity_and_disclosure + | :form_control_and_composer + | :row_and_artifact + | :workflow_progress_and_status + | :layer_shell_and_callout + | :redline_and_code + | :composition_behavior + + @type component :: %{ + required(:kind) => atom(), + required(:family) => family(), + required(:source) => source(), + required(:summary) => String.t(), + required(:aliases) => [atom()] + } + + @type source :: %{ + required(:system) => :ash_ui, + required(:pr) => pos_integer(), + required(:name) => atom() + } + + @type name_diagnostic :: %{ + required(:status) => :canonical | :alias | :unknown, + required(:name) => atom() | String.t(), + optional(:canonical) => atom(), + optional(:family) => family(), + required(:message) => String.t() + } + + @families [ + :content_identity_and_disclosure, + :form_control_and_composer, + :row_and_artifact, + :workflow_progress_and_status, + :layer_shell_and_callout, + :redline_and_code, + :composition_behavior + ] + + @components [ + %{ + kind: :inline_rich_text_heading, + family: :content_identity_and_disclosure, + source: %{system: :ash_ui, pr: 79, name: :inline_rich_text_heading}, + summary: "Semantic heading with inline text and emphasis segments.", + aliases: [] + }, + %{ + kind: :disclosure, + family: :content_identity_and_disclosure, + source: %{system: :ash_ui, pr: 80, name: :disclosure}, + summary: "Progressive disclosure region with summary, open state, and body children.", + aliases: [] + }, + %{ + kind: :runtime_form_shell, + family: :form_control_and_composer, + source: %{system: :ash_ui, pr: 81, name: :phoenix_form}, + summary: "Portable form shell whose runtime-owned form state is supplied by the host.", + aliases: [:phoenix_form] + }, + %{ + kind: :kicker, + family: :content_identity_and_disclosure, + source: %{system: :ash_ui, pr: 82, name: :kicker}, + summary: "Eyebrow label cluster with ordered items and separator behavior.", + aliases: [] + }, + %{ + kind: :avatar, + family: :content_identity_and_disclosure, + source: %{system: :ash_ui, pr: 83, name: :avatar}, + summary: "Identity display using initials or image source with size, shape, and variant.", + aliases: [] + }, + %{ + kind: :presence_dot, + family: :content_identity_and_disclosure, + source: %{system: :ash_ui, pr: 84, name: :presence_dot}, + summary: "Small state-driven status indicator with size and accessibility metadata.", + aliases: [] + }, + %{ + kind: :segmented_button_group, + family: :form_control_and_composer, + source: %{system: :ash_ui, pr: 85, name: :segmented_button_group}, + summary: "Single-selection segmented control with option values and labels.", + aliases: [] + }, + %{ + kind: :list_item_multi_column, + family: :row_and_artifact, + source: %{system: :ash_ui, pr: 86, name: :list_item_multi_column}, + summary: "Selectable or linkable multi-column row with child cell content.", + aliases: [] + }, + %{ + kind: :artifact_row, + family: :row_and_artifact, + source: %{system: :ash_ui, pr: 87, name: :artifact_row}, + summary: "Artifact row with title, meta text, row identity, and trailing content.", + aliases: [] + }, + %{ + kind: :sticky_frosted_header, + family: :layer_shell_and_callout, + source: %{system: :ash_ui, pr: 88, name: :sticky_frosted_header}, + summary: "Sticky shell header with leading, title, and trailing child positions.", + aliases: [] + }, + %{ + kind: :pipeline_stepper_horizontal, + family: :workflow_progress_and_status, + source: %{system: :ash_ui, pr: 89, name: :pipeline_stepper_horizontal}, + summary: "Horizontal workflow stepper with done, active, and pending step state.", + aliases: [] + }, + %{ + kind: :segmented_progress_bar, + family: :workflow_progress_and_status, + source: %{system: :ash_ui, pr: 90, name: :segmented_progress_bar}, + summary: "Weighted multi-segment progress bar with per-segment state.", + aliases: [] + }, + %{ + kind: :workflow_stage_list_vertical, + family: :workflow_progress_and_status, + source: %{system: :ash_ui, pr: 91, name: :workflow_stage_list_vertical}, + summary: "Vertical numbered workflow stage list with connector state.", + aliases: [] + }, + %{ + kind: :meter_thin, + family: :workflow_progress_and_status, + source: %{system: :ash_ui, pr: 92, name: :meter_thin}, + summary: "Compact progress meter with normalized value and optional label.", + aliases: [] + }, + %{ + kind: :slide_over_panel, + family: :layer_shell_and_callout, + source: %{system: :ash_ui, pr: 93, name: :slide_over_panel}, + summary: "Non-modal contextual side panel with open state, size, and children.", + aliases: [] + }, + %{ + kind: :event_callout, + family: :layer_shell_and_callout, + source: %{system: :ash_ui, pr: 94, name: :event_callout}, + summary: "Inline stream or status callout with tone, eyebrow, body, and action content.", + aliases: [] + }, + %{ + kind: :redline_inline, + family: :redline_and_code, + source: %{system: :ash_ui, pr: 95, name: :redline_inline}, + summary: + "Inline redline text display with keep, insert, delete, accepted, and rejected state.", + aliases: [] + }, + %{ + kind: :code_block_syntax_highlighted, + family: :redline_and_code, + source: %{system: :ash_ui, pr: 96, name: :code_block_syntax_highlighted}, + summary: "Pre-tokenized code block display with language and semantic token types.", + aliases: [] + }, + %{ + kind: :chat_composer, + family: :form_control_and_composer, + source: %{system: :ash_ui, pr: 97, name: :chat_composer}, + summary: "Multi-line composer with tool area, send intent, and change intent.", + aliases: [] + }, + %{ + kind: :list_repeat, + family: :composition_behavior, + source: %{system: :ash_ui, pr: 98, name: :ui_relationship_repeat}, + summary: + "Composition behavior that repeats a child template over rows from a list binding.", + aliases: [:ui_relationship_repeat, :repeat] + } + ] + + @component_by_kind Map.new(@components, &{&1.kind, &1}) + + @alias_to_kind Map.new( + for component <- @components, + alias_name <- component.aliases, + do: {alias_name, component.kind} + ) + + @known_binary_names MapSet.new( + Enum.map( + Map.keys(@component_by_kind) ++ Map.keys(@alias_to_kind), + &to_string/1 + ) + ) + + @spec families() :: [family()] + def families do + @families + end + + @spec catalog() :: [component()] + def catalog do + @components + end + + @spec kinds() :: [atom()] + def kinds do + Enum.map(@components, & &1.kind) + end + + @spec aliases() :: %{atom() => atom()} + def aliases do + @alias_to_kind + end + + @spec component(atom() | String.t()) :: {:ok, component()} | {:error, name_diagnostic()} + def component(name) do + with {:ok, kind} <- canonical_kind(name) do + {:ok, Map.fetch!(@component_by_kind, kind)} + end + end + + @spec component!(atom() | String.t()) :: component() + def component!(name) do + case component(name) do + {:ok, component} -> + component + + {:error, diagnostic} -> + raise ArgumentError, diagnostic.message + end + end + + @spec canonical_kind(atom() | String.t()) :: {:ok, atom()} | {:error, name_diagnostic()} + def canonical_kind(name) do + normalized = normalize_name(name) + + cond do + Map.has_key?(@component_by_kind, normalized) -> + {:ok, normalized} + + Map.has_key?(@alias_to_kind, normalized) -> + {:ok, Map.fetch!(@alias_to_kind, normalized)} + + true -> + {:error, name_diagnostic(name)} + end + end + + @spec canonical_kind!(atom() | String.t()) :: atom() + def canonical_kind!(name) do + case canonical_kind(name) do + {:ok, kind} -> + kind + + {:error, diagnostic} -> + raise ArgumentError, diagnostic.message + end + end + + @spec name_diagnostic(atom() | String.t()) :: name_diagnostic() + def name_diagnostic(name) do + normalized = normalize_name(name) + + cond do + Map.has_key?(@component_by_kind, normalized) -> + component = Map.fetch!(@component_by_kind, normalized) + + %{ + status: :canonical, + name: normalized, + canonical: normalized, + family: component.family, + message: "#{inspect(normalized)} is a canonical widget-component name." + } + + Map.has_key?(@alias_to_kind, normalized) -> + canonical = Map.fetch!(@alias_to_kind, normalized) + component = Map.fetch!(@component_by_kind, canonical) + + %{ + status: :alias, + name: normalized, + canonical: canonical, + family: component.family, + message: + "#{inspect(normalized)} is an AshUi compatibility alias; use #{inspect(canonical)} for canonical UnifiedUi authoring." + } + + true -> + %{ + status: :unknown, + name: normalized, + message: + "#{inspect(name)} is not part of the canonical widget-component catalog or AshUi compatibility aliases." + } + end + end + + @spec component_families() :: %{family() => [atom()]} + def component_families do + Map.new(@families, fn family -> + kinds = + @components + |> Enum.filter(&(&1.family == family)) + |> Enum.map(& &1.kind) + + {family, kinds} + end) + end + + @spec components_for_family(family()) :: [component()] + def components_for_family(family) do + Enum.filter(@components, &(&1.family == family)) + end + + @spec source_mapping() :: %{pos_integer() => map()} + def source_mapping do + Map.new(@components, fn component -> + source = component.source + + {source.pr, + %{ + source_name: source.name, + canonical_kind: component.kind, + family: component.family, + aliases: component.aliases + }} + end) + end + + defp normalize_name(name) when is_atom(name), do: name + + defp normalize_name(name) when is_binary(name) do + if MapSet.member?(@known_binary_names, name) do + String.to_existing_atom(name) + else + name + end + end +end diff --git a/packages/unified-ui/lib/unified_ui/widgets.ex b/packages/unified-ui/lib/unified_ui/widgets.ex index 8acb02e7..9fdd9d95 100644 --- a/packages/unified-ui/lib/unified_ui/widgets.ex +++ b/packages/unified-ui/lib/unified_ui/widgets.ex @@ -4,6 +4,7 @@ defmodule UnifiedUi.Widgets do """ alias UnifiedUi.Dsl.Entities.{Advanced, Data, Feedback, Foundational, Input, Navigation} + alias UnifiedUi.WidgetComponents @spec foundational_kinds() :: [atom()] def foundational_kinds do @@ -35,6 +36,26 @@ defmodule UnifiedUi.Widgets do Advanced.kinds() end + @spec component_catalog() :: [WidgetComponents.component()] + def component_catalog do + WidgetComponents.catalog() + end + + @spec component_families() :: %{WidgetComponents.family() => [atom()]} + def component_families do + WidgetComponents.component_families() + end + + @spec component_kinds() :: [atom()] + def component_kinds do + WidgetComponents.kinds() + end + + @spec component_aliases() :: %{atom() => atom()} + def component_aliases do + WidgetComponents.aliases() + end + @spec kinds() :: [atom()] def kinds do foundational_kinds() ++ diff --git a/packages/unified-ui/test/unified_ui/widget_components_catalog_test.exs b/packages/unified-ui/test/unified_ui/widget_components_catalog_test.exs new file mode 100644 index 00000000..6d225937 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/widget_components_catalog_test.exs @@ -0,0 +1,106 @@ +defmodule UnifiedUi.WidgetComponentsCatalogTest do + use ExUnit.Case, async: true + + alias UnifiedUi.WidgetComponents + + test "catalog groups the AshUi PR 79-98 additions into canonical families" do + assert WidgetComponents.families() == [ + :content_identity_and_disclosure, + :form_control_and_composer, + :row_and_artifact, + :workflow_progress_and_status, + :layer_shell_and_callout, + :redline_and_code, + :composition_behavior + ] + + assert WidgetComponents.component_families() == %{ + content_identity_and_disclosure: [ + :inline_rich_text_heading, + :disclosure, + :kicker, + :avatar, + :presence_dot + ], + form_control_and_composer: [ + :runtime_form_shell, + :segmented_button_group, + :chat_composer + ], + row_and_artifact: [:list_item_multi_column, :artifact_row], + workflow_progress_and_status: [ + :pipeline_stepper_horizontal, + :segmented_progress_bar, + :workflow_stage_list_vertical, + :meter_thin + ], + layer_shell_and_callout: [ + :sticky_frosted_header, + :slide_over_panel, + :event_callout + ], + redline_and_code: [:redline_inline, :code_block_syntax_highlighted], + composition_behavior: [:list_repeat] + } + end + + test "source mapping records every AshUi PR in order" do + source_mapping = WidgetComponents.source_mapping() + + assert Map.keys(source_mapping) == Enum.to_list(79..98) + assert source_mapping[79].canonical_kind == :inline_rich_text_heading + assert source_mapping[81].source_name == :phoenix_form + assert source_mapping[81].canonical_kind == :runtime_form_shell + assert source_mapping[98].source_name == :ui_relationship_repeat + assert source_mapping[98].canonical_kind == :list_repeat + end + + test "canonical name lookup accepts AshUi aliases with diagnostics" do + assert WidgetComponents.canonical_kind(:runtime_form_shell) == {:ok, :runtime_form_shell} + assert WidgetComponents.canonical_kind("runtime_form_shell") == {:ok, :runtime_form_shell} + assert WidgetComponents.canonical_kind(:phoenix_form) == {:ok, :runtime_form_shell} + assert WidgetComponents.canonical_kind("repeat") == {:ok, :list_repeat} + + assert WidgetComponents.name_diagnostic(:phoenix_form) == %{ + status: :alias, + name: :phoenix_form, + canonical: :runtime_form_shell, + family: :form_control_and_composer, + message: + ":phoenix_form is an AshUi compatibility alias; use :runtime_form_shell for canonical UnifiedUi authoring." + } + end + + test "unknown names produce diagnostics without allocating atoms for strings" do + assert {:error, diagnostic} = WidgetComponents.canonical_kind("not_in_catalog") + + assert diagnostic == %{ + status: :unknown, + name: "not_in_catalog", + message: + "\"not_in_catalog\" is not part of the canonical widget-component catalog or AshUi compatibility aliases." + } + end + + test "package reference and tooling expose the catalog" do + assert UnifiedUi.Widgets.component_kinds() == WidgetComponents.kinds() + + assert UnifiedUi.Widgets.component_aliases() == %{ + phoenix_form: :runtime_form_shell, + repeat: :list_repeat, + ui_relationship_repeat: :list_repeat + } + + assert UnifiedUi.Reference.widget_component_families() == + WidgetComponents.component_families() + + assert UnifiedUi.Reference.widget_component_source_mapping()[98].canonical_kind == + :list_repeat + + tooling_catalog = UnifiedUi.Tooling.widget_component_catalog() + + assert tooling_catalog.families == WidgetComponents.component_families() + assert tooling_catalog.aliases == WidgetComponents.aliases() + assert tooling_catalog.source_mapping[81].canonical_kind == :runtime_form_shell + end +end From 6ab7c28994718ef2b229a296740329d366d4f417 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 10:30:08 -0400 Subject: [PATCH 02/21] Implement content identity widget authoring --- ...atalog-and-unified-ui-authoring-surface.md | 18 +- .../unified-ui/lib/unified_ui/dsl/entities.ex | 7 +- .../dsl/entities/widget_components.ex | 76 ++++++++ .../unified-ui/lib/unified_ui/dsl/node.ex | 25 +++ .../lib/unified_ui/dsl/placement.ex | 8 +- .../lib/unified_ui/dsl/verifiers.ex | 4 +- .../verifiers/validate_widget_components.ex | 88 +++++++++ packages/unified-ui/lib/unified_ui/tooling.ex | 4 + packages/unified-ui/lib/unified_ui/widgets.ex | 9 +- .../advanced_widget_families_test.exs | 7 +- .../content_identity_components_test.exs | 173 ++++++++++++++++++ .../unified_ui/phase_2_integration_test.exs | 7 +- .../test/unified_ui/reference_test.exs | 3 +- 13 files changed, 411 insertions(+), 18 deletions(-) create mode 100644 packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex create mode 100644 packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex create mode 100644 packages/unified-ui/test/unified_ui/content_identity_components_test.exs diff --git a/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md b/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md index 39bb9266..20c161ee 100644 --- a/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md +++ b/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md @@ -47,26 +47,26 @@ Back to index: [README](./README.md) [x] 1.1.2.2 Subtask - Decide which AshUi names can be accepted as authoring aliases without making AshUi part of the contract. [x] 1.1.2.3 Subtask - Add validation diagnostics that identify deprecated or host-specific aliases and point authors to canonical names. - [ ] 1.2 Section - Content, Identity, and Disclosure Authoring + [x] 1.2 Section - Content, Identity, and Disclosure Authoring Add authored DSL support for the passive and identity-oriented components whose behavior is mostly content, state, accessibility, and child composition. - [ ] 1.2.1 Task - Implement rich heading and kicker authoring + [x] 1.2.1 Task - Implement rich heading and kicker authoring Support editorial heading and eyebrow-label patterns with deterministic content models. - [ ] 1.2.1.1 Subtask - Author `inline_rich_text_heading` with heading level and inline `text` or `emphasis` segment validation. - [ ] 1.2.1.2 Subtask - Author `kicker` with ordered items and separator behavior. - [ ] 1.2.1.3 Subtask - Reject invalid heading levels, malformed segments, and non-string kicker items with actionable diagnostics. + [x] 1.2.1.1 Subtask - Author `inline_rich_text_heading` with heading level and inline `text` or `emphasis` segment validation. + [x] 1.2.1.2 Subtask - Author `kicker` with ordered items and separator behavior. + [x] 1.2.1.3 Subtask - Reject invalid heading levels, malformed segments, and non-string kicker items with actionable diagnostics. - [ ] 1.2.2 Task - Implement avatar, presence, and disclosure authoring + [x] 1.2.2 Task - Implement avatar, presence, and disclosure authoring Support identity display, small status signals, and native progressive disclosure in the authored catalog. - [ ] 1.2.2.1 Subtask - Author `avatar` with initials, optional image source, shape, size, variant, and accessible label. - [ ] 1.2.2.2 Subtask - Author `presence_dot` with state, size, and optional accessible label. - [ ] 1.2.2.3 Subtask - Author `disclosure` with summary text, initial open state, and child body content. + [x] 1.2.2.1 Subtask - Author `avatar` with initials, optional image source, shape, size, variant, and accessible label. + [x] 1.2.2.2 Subtask - Author `presence_dot` with state, size, and optional accessible label. + [x] 1.2.2.3 Subtask - Author `disclosure` with summary text, initial open state, and child body content. [ ] 1.3 Section - Form, Control, and Composer Authoring Add authored DSL support for controls that carry canonical interaction diff --git a/packages/unified-ui/lib/unified_ui/dsl/entities.ex b/packages/unified-ui/lib/unified_ui/dsl/entities.ex index 0e72dcce..749f61d2 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/entities.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/entities.ex @@ -14,7 +14,8 @@ defmodule UnifiedUi.Dsl.Entities do Input, Layout, Navigation, - Overlay + Overlay, + WidgetComponents } @construct_families %{ @@ -24,7 +25,8 @@ defmodule UnifiedUi.Dsl.Entities do :navigation, :feedback, :data, - :operational + :operational, + :content_identity_and_disclosure ], layouts: [:container, :row, :column, :grid, :stack, :split, :viewport], layers: [:overlay, :absolute, :modal, :toast, :menu, :canvas], @@ -49,6 +51,7 @@ defmodule UnifiedUi.Dsl.Entities do Input.entities() ++ Navigation.entities() ++ Forms.top_level_entities() ++ + WidgetComponents.entities() ++ Layout.entities() ++ Data.entities() ++ Feedback.entities() ++ diff --git a/packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex b/packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex new file mode 100644 index 00000000..712c4aa0 --- /dev/null +++ b/packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex @@ -0,0 +1,76 @@ +defmodule UnifiedUi.Dsl.Entities.WidgetComponents do + @moduledoc false + + alias UnifiedUi.Dsl.Entities.Foundational + alias UnifiedUi.Dsl.EntitySchema + alias UnifiedUi.Dsl.Node + + @content_identity_family :content_identity_and_disclosure + + @spec entities() :: [Spark.Dsl.Entity.t()] + def entities do + [ + leaf(:inline_rich_text_heading, + level: [type: {:in, [:h1, :h2, :h3, :h4, :h5, :h6]}, required: false, default: :h1], + segments: [type: :any, required: true], + summary: [type: :string, required: false] + ), + disclosure_entity(), + leaf(:kicker, + items: [type: :any, required: true], + separator: [type: :string, required: false, default: "·"], + summary: [type: :string, required: false] + ), + leaf(:avatar, + initials: [type: :string, required: false], + image_source: [type: :string, required: false], + size: [type: {:in, [:small, :medium, :large]}, required: false, default: :medium], + shape: [type: {:in, [:round, :square]}, required: false, default: :round], + summary: [type: :string, required: false] + ), + leaf(:presence_dot, + state: [type: :atom, required: false, default: :quiet], + size: [type: {:in, [:small, :medium, :large]}, required: false, default: :medium], + summary: [type: :string, required: false] + ) + ] + end + + @spec content_identity_kinds() :: [atom()] + def content_identity_kinds do + Enum.map(entities(), & &1.name) + end + + @spec kinds() :: [atom()] + def kinds do + content_identity_kinds() + end + + defp leaf(name, extra_schema) do + %Spark.Dsl.Entity{ + name: name, + target: Node, + args: [:id], + identifier: :id, + auto_set_fields: [family: @content_identity_family, kind: name], + schema: EntitySchema.widget(extra_schema) + } + end + + defp disclosure_entity do + %Spark.Dsl.Entity{ + name: :disclosure, + target: Node, + args: [:id], + identifier: :id, + recursive_as: :children, + auto_set_fields: [family: @content_identity_family, kind: :disclosure], + entities: [children: Foundational.entities()], + schema: + EntitySchema.widget( + summary: [type: :string, required: true], + open?: [type: :boolean, required: false, default: false] + ) + } + end +end diff --git a/packages/unified-ui/lib/unified_ui/dsl/node.ex b/packages/unified-ui/lib/unified_ui/dsl/node.ex index c467fb58..1fbdfd81 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/node.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/node.ex @@ -15,6 +15,7 @@ defmodule UnifiedUi.Dsl.Node do | :overlay | :display | :canvas + | :content_identity_and_disclosure @type t :: %__MODULE__{ __identifier__: atom() | nil, @@ -140,6 +141,14 @@ defmodule UnifiedUi.Dsl.Node do divider_size: integer() | nil, divider_style: atom() | nil, operations: list() | nil, + level: atom() | nil, + segments: list() | nil, + separator: String.t() | nil, + initials: String.t() | nil, + image_source: String.t() | nil, + shape: atom() | nil, + state: atom() | nil, + open?: boolean() | nil, children: [t()] } @@ -266,6 +275,14 @@ defmodule UnifiedUi.Dsl.Node do divider_size: nil, divider_style: nil, operations: nil, + level: nil, + segments: nil, + separator: nil, + initials: nil, + image_source: nil, + shape: nil, + state: nil, + open?: nil, children: [] @spec summary(t()) :: map() @@ -320,6 +337,14 @@ defmodule UnifiedUi.Dsl.Node do secondary_ref: node.secondary_ref, ratio: node.ratio, operations: node.operations, + level: node.level, + segments: node.segments, + separator: node.separator, + initials: node.initials, + image_source: node.image_source, + shape: node.shape, + state: node.state, + open?: node.open?, current: node.current, minimum: node.minimum, maximum: node.maximum, diff --git a/packages/unified-ui/lib/unified_ui/dsl/placement.ex b/packages/unified-ui/lib/unified_ui/dsl/placement.ex index 99f61aaf..1bb6aa3b 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/placement.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/placement.ex @@ -84,7 +84,11 @@ defmodule UnifiedUi.Dsl.Placement do :file_input, :menu, :tabs, - :command_palette + :command_palette, + :inline_rich_text_heading, + :kicker, + :avatar, + :presence_dot ] @advanced_leaf_kinds [ @@ -113,7 +117,7 @@ defmodule UnifiedUi.Dsl.Placement do ] @layout_kinds [:box, :row, :column, :grid, :stack] - @container_kinds [:content, :form_builder, :field_group] + @container_kinds [:content, :form_builder, :field_group, :disclosure] @spec section_boundaries() :: %{atom() => [atom()]} def section_boundaries do diff --git a/packages/unified-ui/lib/unified_ui/dsl/verifiers.ex b/packages/unified-ui/lib/unified_ui/dsl/verifiers.ex index cf68beb6..83fa9659 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/verifiers.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/verifiers.ex @@ -7,7 +7,8 @@ defmodule UnifiedUi.Dsl.Verifiers do ValidateCompositionPlacement, ValidateAuthoringInvariants, ValidateRequiredSections, - ValidateThemesAndSignals + ValidateThemesAndSignals, + ValidateWidgetComponents } @spec all() :: [module()] @@ -16,6 +17,7 @@ defmodule UnifiedUi.Dsl.Verifiers do ValidateRequiredSections, ValidateAuthoringInvariants, ValidateCompositionPlacement, + ValidateWidgetComponents, ValidateThemesAndSignals ] end diff --git a/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex b/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex new file mode 100644 index 00000000..decaaac1 --- /dev/null +++ b/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex @@ -0,0 +1,88 @@ +defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do + @moduledoc false + + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias UnifiedUi.Dsl.Node + + @heading_segment_types [:text, :emphasis] + + @spec verify(map()) :: :ok | {:error, Spark.Error.DslError.t()} + def verify(dsl) do + module = Verifier.get_persisted(dsl, :module) + + dsl + |> Verifier.get_entities([:composition]) + |> Enum.filter(&match?(%Node{}, &1)) + |> flatten_nodes() + |> validate_nodes(module) + end + + defp validate_nodes(nodes, module) do + Enum.reduce_while(nodes, :ok, fn node, :ok -> + case validate_node(node) do + :ok -> + {:cont, :ok} + + {:error, path, message} -> + {:halt, {:error, %Spark.Error.DslError{module: module, path: path, message: message}}} + end + end) + end + + @doc false + @spec validate_node(Node.t()) :: :ok | {:error, [term()], String.t()} + def validate_node(%Node{kind: :inline_rich_text_heading, id: id, segments: segments}) do + if valid_heading_segments?(segments) do + :ok + else + {:error, [:composition, :inline_rich_text_heading, id], + "inline_rich_text_heading #{inspect(id)} segments must be a non-empty list of text or emphasis segment maps with string values"} + end + end + + def validate_node(%Node{kind: :kicker, id: id, items: items}) do + if is_list(items) and Enum.all?(items, &is_binary/1) do + :ok + else + {:error, [:composition, :kicker, id], + "kicker #{inspect(id)} items must be a list of strings"} + end + end + + def validate_node(_node), do: :ok + + defp valid_heading_segments?(segments) when is_list(segments) and segments != [] do + Enum.all?(segments, &valid_heading_segment?/1) + end + + defp valid_heading_segments?(_segments), do: false + + defp valid_heading_segment?(segment) when is_map(segment) or is_list(segment) do + segment = normalize_map(segment) + Map.get(segment, :type) in @heading_segment_types and is_binary(Map.get(segment, :value)) + end + + defp valid_heading_segment?(_segment), do: false + + defp normalize_map(segment) when is_map(segment) do + Map.new(segment, fn + {key, value} when is_binary(key) -> + {String.to_existing_atom(key), value} + + {key, value} -> + {key, value} + end) + rescue + ArgumentError -> segment + end + + defp normalize_map(segment) when is_list(segment), do: Map.new(segment) + + defp flatten_nodes(nodes) do + Enum.flat_map(nodes, fn %Node{children: children} = node -> + [node | flatten_nodes(children)] + end) + end +end diff --git a/packages/unified-ui/lib/unified_ui/tooling.ex b/packages/unified-ui/lib/unified_ui/tooling.ex index 4d9841b6..413f48fd 100644 --- a/packages/unified-ui/lib/unified_ui/tooling.ex +++ b/packages/unified-ui/lib/unified_ui/tooling.ex @@ -27,6 +27,10 @@ defmodule UnifiedUi.Tooling do ".spec/specs/unified-ui/widgets.spec.md", ".spec/specs/unified-ui/display_systems.spec.md" ], + content_identity_and_disclosure: [ + ".spec/specs/unified-ui/widgets.spec.md", + ".spec/specs/unified-ui/widget_components.spec.md" + ], layout: [".spec/specs/unified-ui/display_systems.spec.md"], display: [".spec/specs/unified-ui/display_systems.spec.md"], overlay: [".spec/specs/unified-ui/display_systems.spec.md"], diff --git a/packages/unified-ui/lib/unified_ui/widgets.ex b/packages/unified-ui/lib/unified_ui/widgets.ex index 9fdd9d95..af67b0d7 100644 --- a/packages/unified-ui/lib/unified_ui/widgets.ex +++ b/packages/unified-ui/lib/unified_ui/widgets.ex @@ -4,6 +4,7 @@ defmodule UnifiedUi.Widgets do """ alias UnifiedUi.Dsl.Entities.{Advanced, Data, Feedback, Foundational, Input, Navigation} + alias UnifiedUi.Dsl.Entities.WidgetComponents, as: WidgetComponentEntities alias UnifiedUi.WidgetComponents @spec foundational_kinds() :: [atom()] @@ -36,6 +37,11 @@ defmodule UnifiedUi.Widgets do Advanced.kinds() end + @spec content_identity_component_kinds() :: [atom()] + def content_identity_component_kinds do + WidgetComponentEntities.content_identity_kinds() + end + @spec component_catalog() :: [WidgetComponents.component()] def component_catalog do WidgetComponents.catalog() @@ -63,6 +69,7 @@ defmodule UnifiedUi.Widgets do navigation_kinds() ++ data_kinds() ++ feedback_kinds() ++ - advanced_kinds() + advanced_kinds() ++ + content_identity_component_kinds() end end diff --git a/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs b/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs index 88a36dd8..ec93848b 100644 --- a/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs +++ b/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs @@ -161,7 +161,12 @@ defmodule UnifiedUi.AdvancedWidgetFamiliesTest do :stream_widget, :process_monitor, :supervision_tree_viewer, - :cluster_dashboard + :cluster_dashboard, + :inline_rich_text_heading, + :disclosure, + :kicker, + :avatar, + :presence_dot ] end diff --git a/packages/unified-ui/test/unified_ui/content_identity_components_test.exs b/packages/unified-ui/test/unified_ui/content_identity_components_test.exs new file mode 100644 index 00000000..85b94885 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/content_identity_components_test.exs @@ -0,0 +1,173 @@ +defmodule UnifiedUi.ContentIdentityComponentsTest do + use ExUnit.Case, async: true + + alias Spark.Dsl.Extension + alias UnifiedUi.Dsl.Node + alias UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents + + defmodule ContentIdentityScreen do + use UnifiedUi.Dsl + + identity do + id(:content_identity_screen) + authored_ref([:examples, :content_identity_screen]) + end + + composition do + root(:content_identity_root) + mode(:screen) + + inline_rich_text_heading :headline do + level(:h2) + + segments([ + %{type: :text, value: "Canonical widgets"}, + %{type: :emphasis, value: " across runtimes"} + ]) + end + + kicker :eyebrow do + items(["Operator surface", "Workspace"]) + separator("·") + end + + avatar :operator_avatar do + initials("PC") + image_source("/images/pascal.png") + size(:large) + shape(:round) + accessibility_label("Pascal Charbonneau") + end + + presence_dot :operator_presence do + state(:live) + size(:small) + accessibility_label("Active") + end + + disclosure :advanced_options do + summary("Advanced options") + open?(true) + + text :advanced_copy do + value("Additional runtime settings") + end + end + end + end + + test "registers authored content identity component kinds" do + assert UnifiedUi.Widgets.content_identity_component_kinds() == [ + :inline_rich_text_heading, + :disclosure, + :kicker, + :avatar, + :presence_dot + ] + + assert :inline_rich_text_heading in UnifiedUi.Widgets.kinds() + assert :disclosure in UnifiedUi.Widgets.kinds() + end + + test "stores content identity components in the composition tree" do + [heading, kicker, avatar, presence, disclosure] = + Extension.get_entities(ContentIdentityScreen, [:composition]) + + assert {heading.family, heading.kind, heading.level} == + {:content_identity_and_disclosure, :inline_rich_text_heading, :h2} + + assert Enum.map(heading.segments, & &1.type) == [:text, :emphasis] + + assert {kicker.family, kicker.kind, kicker.separator} == + {:content_identity_and_disclosure, :kicker, "·"} + + assert {avatar.kind, avatar.initials, avatar.image_source, avatar.shape} == + {:avatar, "PC", "/images/pascal.png", :round} + + assert {presence.kind, presence.state, presence.size} == {:presence_dot, :live, :small} + assert {disclosure.kind, disclosure.open?} == {:disclosure, true} + assert Enum.map(disclosure.children, & &1.kind) == [:text] + end + + test "summarizes content identity components without a renderer runtime" do + assert UnifiedUi.Info.composition_summary(ContentIdentityScreen) == [ + %{ + id: :headline, + family: :content_identity_and_disclosure, + kind: :inline_rich_text_heading, + level: :h2, + segments: [ + %{type: :text, value: "Canonical widgets"}, + %{type: :emphasis, value: " across runtimes"} + ] + }, + %{ + id: :eyebrow, + family: :content_identity_and_disclosure, + kind: :kicker, + items: ["Operator surface", "Workspace"], + separator: "·" + }, + %{ + id: :operator_avatar, + family: :content_identity_and_disclosure, + kind: :avatar, + image_source: "/images/pascal.png", + initials: "PC", + shape: :round + }, + %{ + id: :operator_presence, + family: :content_identity_and_disclosure, + kind: :presence_dot, + state: :live + }, + %{ + id: :advanced_options, + family: :content_identity_and_disclosure, + kind: :disclosure, + open?: true, + summary: "Advanced options", + children: [ + %{ + id: :advanced_copy, + family: :foundational, + kind: :text, + role: :text, + value: "Additional runtime settings" + } + ] + } + ] + end + + test "tooling links the new family to the widget component specs" do + {:ok, report} = UnifiedUi.Tooling.inspect_module(ContentIdentityScreen) + + assert :content_identity_and_disclosure in report.construct_families + + assert ".spec/specs/unified-ui/widget_components.spec.md" in report.related_specs + end + + test "rejects malformed rich heading segments with an actionable diagnostic" do + assert {:error, [:composition, :inline_rich_text_heading, :headline], message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :inline_rich_text_heading, + id: :headline, + segments: [%{type: :delete, value: "Wrong segment"}] + }) + + assert message =~ "segments must be a non-empty list" + end + + test "rejects malformed kicker items with an actionable diagnostic" do + assert {:error, [:composition, :kicker, :bad_kicker], message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :kicker, + id: :bad_kicker, + items: ["Valid", :invalid] + }) + + assert message == "kicker :bad_kicker items must be a list of strings" + end +end diff --git a/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs b/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs index f054ab19..6d7897dc 100644 --- a/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs +++ b/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs @@ -296,7 +296,12 @@ defmodule UnifiedUi.Phase2IntegrationTest do :stream_widget, :process_monitor, :supervision_tree_viewer, - :cluster_dashboard + :cluster_dashboard, + :inline_rich_text_heading, + :disclosure, + :kicker, + :avatar, + :presence_dot ] assert UnifiedUi.Layout.kinds() == [:box, :row, :column, :grid, :stack] diff --git a/packages/unified-ui/test/unified_ui/reference_test.exs b/packages/unified-ui/test/unified_ui/reference_test.exs index 1d75f690..3ffe0ded 100644 --- a/packages/unified-ui/test/unified_ui/reference_test.exs +++ b/packages/unified-ui/test/unified_ui/reference_test.exs @@ -31,7 +31,8 @@ defmodule UnifiedUi.ReferenceTest do :navigation, :feedback, :data, - :operational + :operational, + :content_identity_and_disclosure ] assert UnifiedUi.Reference.construct_families().signals == [ From ed8e5c451da6111e49d2b0d07e481ce1b767d48f Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 10:42:24 -0400 Subject: [PATCH 03/21] Implement form control widget authoring --- ...atalog-and-unified-ui-authoring-surface.md | 26 +-- .../unified-ui/lib/unified_ui/dsl/entities.ex | 3 +- .../dsl/entities/widget_components.ex | 83 +++++++- .../unified-ui/lib/unified_ui/dsl/node.ex | 33 ++++ .../lib/unified_ui/dsl/placement.ex | 6 +- .../verifiers/validate_widget_components.ex | 77 ++++++++ packages/unified-ui/lib/unified_ui/tooling.ex | 5 + packages/unified-ui/lib/unified_ui/widgets.ex | 8 +- .../advanced_widget_families_test.exs | 5 +- .../form_control_components_test.exs | 184 ++++++++++++++++++ .../unified_ui/phase_2_integration_test.exs | 5 +- .../test/unified_ui/reference_test.exs | 3 +- 12 files changed, 410 insertions(+), 28 deletions(-) create mode 100644 packages/unified-ui/test/unified_ui/form_control_components_test.exs diff --git a/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md b/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md index 20c161ee..bec4c75e 100644 --- a/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md +++ b/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md @@ -68,33 +68,33 @@ Back to index: [README](./README.md) [x] 1.2.2.2 Subtask - Author `presence_dot` with state, size, and optional accessible label. [x] 1.2.2.3 Subtask - Author `disclosure` with summary text, initial open state, and child body content. - [ ] 1.3 Section - Form, Control, and Composer Authoring + [x] 1.3 Section - Form, Control, and Composer Authoring Add authored DSL support for controls that carry canonical interaction meaning while leaving host-specific form lifecycle ownership to runtimes. - [ ] 1.3.1 Task - Implement segmented button group authoring + [x] 1.3.1 Task - Implement segmented button group authoring Support single-selection segmented controls with option identity and canonical selection intent. - [ ] 1.3.1.1 Subtask - Author options with value and label fields. - [ ] 1.3.1.2 Subtask - Author active option state and optional disabled state. - [ ] 1.3.1.3 Subtask - Lower selection intent into canonical interaction descriptors rather than renderer-local event names. + [x] 1.3.1.1 Subtask - Author options with value and label fields. + [x] 1.3.1.2 Subtask - Author active option state and optional disabled state. + [x] 1.3.1.3 Subtask - Lower selection intent into canonical interaction descriptors rather than renderer-local event names. - [ ] 1.3.2 Task - Implement runtime-owned form shell authoring + [x] 1.3.2 Task - Implement runtime-owned form shell authoring Represent the `phoenix_form` PR meaning portably without making Phoenix the ecosystem contract. - [ ] 1.3.2.1 Subtask - Author form fields, submit label, submit intent, change intent, validation state, and field attributes. - [ ] 1.3.2.2 Subtask - Document that runtime-owned form state is owned by the host runtime or application, not by canonical IUR. - [ ] 1.3.2.3 Subtask - Add LiveUi-facing metadata hooks for Phoenix or AshPhoenix integration without leaking them into the canonical contract. + [x] 1.3.2.1 Subtask - Author form fields, submit label, submit intent, change intent, validation state, and field attributes. + [x] 1.3.2.2 Subtask - Document that runtime-owned form state is owned by the host runtime or application, not by canonical IUR. + [x] 1.3.2.3 Subtask - Add LiveUi-facing metadata hooks for Phoenix or AshPhoenix integration without leaking them into the canonical contract. - [ ] 1.3.3 Task - Implement chat composer authoring + [x] 1.3.3 Task - Implement chat composer authoring Support multi-line composer authoring with text input, tool children, and canonical send and change events. - [ ] 1.3.3.1 Subtask - Author text value, placeholder, rows, disabled state, send label, and send intent. - [ ] 1.3.3.2 Subtask - Author optional tool-area and status children with deterministic ordering. - [ ] 1.3.3.3 Subtask - Validate that send and change interactions preserve canonical event meaning. + [x] 1.3.3.1 Subtask - Author text value, placeholder, rows, disabled state, send label, and send intent. + [x] 1.3.3.2 Subtask - Author optional tool-area and status children with deterministic ordering. + [x] 1.3.3.3 Subtask - Validate that send and change interactions preserve canonical event meaning. [ ] 1.4 Section - Rows, Workflow, Layer, Callout, Redline, and Code Authoring Add authored DSL support for the remaining visual and operational widget diff --git a/packages/unified-ui/lib/unified_ui/dsl/entities.ex b/packages/unified-ui/lib/unified_ui/dsl/entities.ex index 749f61d2..240bc486 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/entities.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/entities.ex @@ -26,7 +26,8 @@ defmodule UnifiedUi.Dsl.Entities do :feedback, :data, :operational, - :content_identity_and_disclosure + :content_identity_and_disclosure, + :form_control_and_composer ], layouts: [:container, :row, :column, :grid, :stack, :split, :viewport], layers: [:overlay, :absolute, :modal, :toast, :menu, :canvas], diff --git a/packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex b/packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex index 712c4aa0..8b4ba9cb 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex @@ -6,29 +6,43 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do alias UnifiedUi.Dsl.Node @content_identity_family :content_identity_and_disclosure + @form_control_family :form_control_and_composer @spec entities() :: [Spark.Dsl.Entity.t()] def entities do + content_identity_entities() ++ form_control_entities() + end + + @spec content_identity_entities() :: [Spark.Dsl.Entity.t()] + def content_identity_entities do [ - leaf(:inline_rich_text_heading, + leaf( + :inline_rich_text_heading, + @content_identity_family, level: [type: {:in, [:h1, :h2, :h3, :h4, :h5, :h6]}, required: false, default: :h1], segments: [type: :any, required: true], summary: [type: :string, required: false] ), disclosure_entity(), - leaf(:kicker, + leaf( + :kicker, + @content_identity_family, items: [type: :any, required: true], separator: [type: :string, required: false, default: "·"], summary: [type: :string, required: false] ), - leaf(:avatar, + leaf( + :avatar, + @content_identity_family, initials: [type: :string, required: false], image_source: [type: :string, required: false], size: [type: {:in, [:small, :medium, :large]}, required: false, default: :medium], shape: [type: {:in, [:round, :square]}, required: false, default: :round], summary: [type: :string, required: false] ), - leaf(:presence_dot, + leaf( + :presence_dot, + @content_identity_family, state: [type: :atom, required: false, default: :quiet], size: [type: {:in, [:small, :medium, :large]}, required: false, default: :medium], summary: [type: :string, required: false] @@ -36,23 +50,53 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do ] end + @spec form_control_entities() :: [Spark.Dsl.Entity.t()] + def form_control_entities do + [ + leaf( + :segmented_button_group, + @form_control_family, + options: [type: :any, required: true], + active_value: [type: :any, required: false], + selection_intent: [type: :atom, required: false], + summary: [type: :string, required: false] + ), + leaf( + :runtime_form_shell, + @form_control_family, + fields: [type: :any, required: true], + submit_label: [type: :string, required: false], + submit_intent: [type: :atom, required: false], + change_intent: [type: :atom, required: false], + validation_state: [type: :atom, required: false], + summary: [type: :string, required: false] + ), + chat_composer_entity() + ] + end + @spec content_identity_kinds() :: [atom()] def content_identity_kinds do - Enum.map(entities(), & &1.name) + Enum.map(content_identity_entities(), & &1.name) + end + + @spec form_control_kinds() :: [atom()] + def form_control_kinds do + Enum.map(form_control_entities(), & &1.name) end @spec kinds() :: [atom()] def kinds do - content_identity_kinds() + content_identity_kinds() ++ form_control_kinds() end - defp leaf(name, extra_schema) do + defp leaf(name, family, extra_schema) do %Spark.Dsl.Entity{ name: name, target: Node, args: [:id], identifier: :id, - auto_set_fields: [family: @content_identity_family, kind: name], + auto_set_fields: [family: family, kind: name], schema: EntitySchema.widget(extra_schema) } end @@ -73,4 +117,27 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do ) } end + + defp chat_composer_entity do + %Spark.Dsl.Entity{ + name: :chat_composer, + target: Node, + args: [:id], + identifier: :id, + recursive_as: :children, + auto_set_fields: [family: @form_control_family, kind: :chat_composer], + entities: [children: Foundational.entities()], + schema: + EntitySchema.widget( + name: [type: :atom, required: false], + value: [type: :string, required: false], + placeholder: [type: :string, required: false], + rows: [type: :integer, required: false, default: 3], + send_label: [type: :string, required: false, default: "Send"], + send_intent: [type: :atom, required: true], + change_intent: [type: :atom, required: false], + summary: [type: :string, required: false] + ) + } + end end diff --git a/packages/unified-ui/lib/unified_ui/dsl/node.ex b/packages/unified-ui/lib/unified_ui/dsl/node.ex index 1fbdfd81..c1d73b7e 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/node.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/node.ex @@ -16,6 +16,7 @@ defmodule UnifiedUi.Dsl.Node do | :display | :canvas | :content_identity_and_disclosure + | :form_control_and_composer @type t :: %__MODULE__{ __identifier__: atom() | nil, @@ -149,6 +150,14 @@ defmodule UnifiedUi.Dsl.Node do shape: atom() | nil, state: atom() | nil, open?: boolean() | nil, + active_value: term(), + selection_intent: atom() | nil, + fields: list() | nil, + submit_label: String.t() | nil, + change_intent: atom() | nil, + validation_state: atom() | nil, + send_label: String.t() | nil, + send_intent: atom() | nil, children: [t()] } @@ -283,6 +292,14 @@ defmodule UnifiedUi.Dsl.Node do shape: nil, state: nil, open?: nil, + active_value: nil, + selection_intent: nil, + fields: nil, + submit_label: nil, + change_intent: nil, + validation_state: nil, + send_label: nil, + send_intent: nil, children: [] @spec summary(t()) :: map() @@ -291,6 +308,7 @@ defmodule UnifiedUi.Dsl.Node do id: node.id, family: node.family, kind: node.kind, + annotations: node.annotations, value: node.value, label: node.label, target: node.target, @@ -307,6 +325,7 @@ defmodule UnifiedUi.Dsl.Node do end, interaction_refs: node.interaction_refs, binding_refs: node.binding_refs, + submit_intent: summary_submit_intent(node), role: node.role, presentation: node.presentation, summary: node.summary, @@ -345,6 +364,14 @@ defmodule UnifiedUi.Dsl.Node do shape: node.shape, state: node.state, open?: node.open?, + active_value: node.active_value, + selection_intent: node.selection_intent, + fields: node.fields, + submit_label: node.submit_label, + change_intent: node.change_intent, + validation_state: node.validation_state, + send_label: node.send_label, + send_intent: node.send_intent, current: node.current, minimum: node.minimum, maximum: node.maximum, @@ -361,4 +388,10 @@ defmodule UnifiedUi.Dsl.Node do |> Enum.reject(fn {_key, value} -> value in [nil, []] end) |> Enum.into(%{}) end + + defp summary_submit_intent(%__MODULE__{kind: :runtime_form_shell, submit_intent: submit_intent}) do + submit_intent + end + + defp summary_submit_intent(_node), do: nil end diff --git a/packages/unified-ui/lib/unified_ui/dsl/placement.ex b/packages/unified-ui/lib/unified_ui/dsl/placement.ex index 1bb6aa3b..67e6e3b7 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/placement.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/placement.ex @@ -88,7 +88,9 @@ defmodule UnifiedUi.Dsl.Placement do :inline_rich_text_heading, :kicker, :avatar, - :presence_dot + :presence_dot, + :segmented_button_group, + :runtime_form_shell ] @advanced_leaf_kinds [ @@ -117,7 +119,7 @@ defmodule UnifiedUi.Dsl.Placement do ] @layout_kinds [:box, :row, :column, :grid, :stack] - @container_kinds [:content, :form_builder, :field_group, :disclosure] + @container_kinds [:content, :form_builder, :field_group, :disclosure, :chat_composer] @spec section_boundaries() :: %{atom() => [atom()]} def section_boundaries do diff --git a/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex b/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex index decaaac1..d08c356c 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex @@ -51,6 +51,39 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do end end + def validate_node(%Node{kind: :segmented_button_group, id: id, options: options}) do + if valid_options?(options) do + :ok + else + {:error, [:composition, :segmented_button_group, id], + "segmented_button_group #{inspect(id)} options must be a non-empty list of {value, label} tuples or maps with value and label"} + end + end + + def validate_node(%Node{kind: :runtime_form_shell, id: id, fields: fields}) do + if valid_form_fields?(fields) do + :ok + else + {:error, [:composition, :runtime_form_shell, id], + "runtime_form_shell #{inspect(id)} fields must be a non-empty list of maps with name and type"} + end + end + + def validate_node(%Node{kind: :chat_composer, id: id, rows: rows, send_intent: send_intent}) do + cond do + not is_atom(send_intent) -> + {:error, [:composition, :chat_composer, id], + "chat_composer #{inspect(id)} send_intent must be an atom"} + + not is_integer(rows) or rows < 1 -> + {:error, [:composition, :chat_composer, id], + "chat_composer #{inspect(id)} rows must be a positive integer"} + + true -> + :ok + end + end + def validate_node(_node), do: :ok defp valid_heading_segments?(segments) when is_list(segments) and segments != [] do @@ -66,6 +99,50 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do defp valid_heading_segment?(_segment), do: false + defp valid_options?(options) when is_list(options) and options != [] do + Enum.all?(options, fn + {value, label} -> + valid_scalar?(value) and is_binary(label) + + option when is_map(option) or is_list(option) -> + option = normalize_map(option) + valid_scalar?(Map.get(option, :value)) and is_binary(Map.get(option, :label)) + + _other -> + false + end) + end + + defp valid_options?(_options), do: false + + defp valid_form_fields?(fields) when is_list(fields) and fields != [] do + Enum.all?(fields, fn + field when is_map(field) or is_list(field) -> + field = normalize_map(field) + + valid_scalar?(Map.get(field, :name)) and valid_scalar?(Map.get(field, :type)) and + valid_field_attributes?(Map.get(field, :attributes)) + + _other -> + false + end) + end + + defp valid_form_fields?(_fields), do: false + + defp valid_field_attributes?(nil), do: true + defp valid_field_attributes?(attributes) when is_map(attributes), do: true + + defp valid_field_attributes?(attributes) when is_list(attributes) do + Keyword.keyword?(attributes) + end + + defp valid_field_attributes?(_attributes), do: false + + defp valid_scalar?(nil), do: false + defp valid_scalar?(value) when is_atom(value) or is_binary(value) or is_number(value), do: true + defp valid_scalar?(_value), do: false + defp normalize_map(segment) when is_map(segment) do Map.new(segment, fn {key, value} when is_binary(key) -> diff --git a/packages/unified-ui/lib/unified_ui/tooling.ex b/packages/unified-ui/lib/unified_ui/tooling.ex index 413f48fd..8d94257e 100644 --- a/packages/unified-ui/lib/unified_ui/tooling.ex +++ b/packages/unified-ui/lib/unified_ui/tooling.ex @@ -31,6 +31,11 @@ defmodule UnifiedUi.Tooling do ".spec/specs/unified-ui/widgets.spec.md", ".spec/specs/unified-ui/widget_components.spec.md" ], + form_control_and_composer: [ + ".spec/specs/unified-ui/widgets.spec.md", + ".spec/specs/unified-ui/signals.spec.md", + ".spec/specs/unified-ui/widget_components.spec.md" + ], layout: [".spec/specs/unified-ui/display_systems.spec.md"], display: [".spec/specs/unified-ui/display_systems.spec.md"], overlay: [".spec/specs/unified-ui/display_systems.spec.md"], diff --git a/packages/unified-ui/lib/unified_ui/widgets.ex b/packages/unified-ui/lib/unified_ui/widgets.ex index af67b0d7..56cf1ae0 100644 --- a/packages/unified-ui/lib/unified_ui/widgets.ex +++ b/packages/unified-ui/lib/unified_ui/widgets.ex @@ -42,6 +42,11 @@ defmodule UnifiedUi.Widgets do WidgetComponentEntities.content_identity_kinds() end + @spec form_control_component_kinds() :: [atom()] + def form_control_component_kinds do + WidgetComponentEntities.form_control_kinds() + end + @spec component_catalog() :: [WidgetComponents.component()] def component_catalog do WidgetComponents.catalog() @@ -70,6 +75,7 @@ defmodule UnifiedUi.Widgets do data_kinds() ++ feedback_kinds() ++ advanced_kinds() ++ - content_identity_component_kinds() + content_identity_component_kinds() ++ + form_control_component_kinds() end end diff --git a/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs b/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs index ec93848b..e77ee102 100644 --- a/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs +++ b/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs @@ -166,7 +166,10 @@ defmodule UnifiedUi.AdvancedWidgetFamiliesTest do :disclosure, :kicker, :avatar, - :presence_dot + :presence_dot, + :segmented_button_group, + :runtime_form_shell, + :chat_composer ] end diff --git a/packages/unified-ui/test/unified_ui/form_control_components_test.exs b/packages/unified-ui/test/unified_ui/form_control_components_test.exs new file mode 100644 index 00000000..3b56a8d2 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/form_control_components_test.exs @@ -0,0 +1,184 @@ +defmodule UnifiedUi.FormControlComponentsTest do + use ExUnit.Case, async: true + + alias Spark.Dsl.Extension + alias UnifiedUi.Dsl.Node + alias UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents + + defmodule FormControlScreen do + use UnifiedUi.Dsl + + identity do + id(:form_control_screen) + authored_ref([:examples, :form_control_screen]) + end + + composition do + root(:form_control_root) + mode(:screen) + + segmented_button_group :status_filter do + options([ + %{value: :all, label: "All"}, + %{value: :active, label: "Active"}, + %{value: :archived, label: "Archived", disabled?: true} + ]) + + active_value(:all) + selection_intent(:select_status) + disabled?(true) + end + + runtime_form_shell :sign_in_form do + fields([ + %{ + name: :email, + type: :email, + label: "Work email", + attributes: [required: true, autocomplete: "email"] + }, + %{name: :password, type: :password, label: "Password"} + ]) + + submit_label("Sign in") + submit_intent(:submit_sign_in) + change_intent(:validate_sign_in) + validation_state(:invalid) + annotations(live_ui: [adapter: :phoenix_form, integration: :ash_phoenix]) + end + + chat_composer :message_composer do + name(:message) + value("Draft message") + placeholder("Write a message") + rows(4) + send_label("Send") + send_intent(:send_message) + change_intent(:update_message) + disabled?(true) + + button :attach_tool do + label("Attach") + end + end + end + end + + test "registers authored form control and composer component kinds" do + assert UnifiedUi.Widgets.form_control_component_kinds() == [ + :segmented_button_group, + :runtime_form_shell, + :chat_composer + ] + + assert :segmented_button_group in UnifiedUi.Widgets.kinds() + assert :runtime_form_shell in UnifiedUi.Widgets.kinds() + assert :chat_composer in UnifiedUi.Widgets.kinds() + end + + test "stores form control and composer components in the composition tree" do + [segmented, form, composer] = Extension.get_entities(FormControlScreen, [:composition]) + + assert {segmented.family, segmented.kind, segmented.active_value, segmented.selection_intent, + segmented.disabled?} == + {:form_control_and_composer, :segmented_button_group, :all, :select_status, true} + + assert Enum.map(segmented.options, & &1.value) == [:all, :active, :archived] + + assert {form.family, form.kind, form.submit_label, form.submit_intent, form.change_intent, + form.validation_state} == + {:form_control_and_composer, :runtime_form_shell, "Sign in", :submit_sign_in, + :validate_sign_in, :invalid} + + assert form.annotations == [live_ui: [adapter: :phoenix_form, integration: :ash_phoenix]] + + assert Enum.map(form.fields, & &1.name) == [:email, :password] + + assert {composer.family, composer.kind, composer.name, composer.rows, composer.send_intent, + composer.disabled?} == + {:form_control_and_composer, :chat_composer, :message, 4, :send_message, true} + + assert Enum.map(composer.children, & &1.kind) == [:button] + end + + test "summarizes form control and composer components without a renderer runtime" do + assert UnifiedUi.Info.composition_summary(FormControlScreen) == [ + %{ + id: :status_filter, + family: :form_control_and_composer, + kind: :segmented_button_group, + active_value: :all, + selection_intent: :select_status + }, + %{ + id: :sign_in_form, + family: :form_control_and_composer, + kind: :runtime_form_shell, + annotations: [live_ui: [adapter: :phoenix_form, integration: :ash_phoenix]], + fields: [ + %{ + name: :email, + type: :email, + label: "Work email", + attributes: [required: true, autocomplete: "email"] + }, + %{name: :password, type: :password, label: "Password"} + ], + submit_label: "Sign in", + submit_intent: :submit_sign_in, + change_intent: :validate_sign_in, + validation_state: :invalid + }, + %{ + id: :message_composer, + family: :form_control_and_composer, + kind: :chat_composer, + value: "Draft message", + change_intent: :update_message, + send_label: "Send", + send_intent: :send_message, + children: [ + %{id: :attach_tool, family: :foundational, kind: :button, label: "Attach"} + ] + } + ] + end + + test "validates form control and composer field shapes" do + assert {:error, [:composition, :segmented_button_group, :filters], segmented_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :segmented_button_group, + id: :filters, + options: [%{value: :all}] + }) + + assert segmented_message =~ "options must be a non-empty list" + + assert {:error, [:composition, :runtime_form_shell, :form], form_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :runtime_form_shell, + id: :form, + fields: [%{name: :email}] + }) + + assert form_message =~ "fields must be a non-empty list" + + assert {:error, [:composition, :chat_composer, :composer], composer_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :chat_composer, + id: :composer, + rows: 0, + send_intent: :send + }) + + assert composer_message == "chat_composer :composer rows must be a positive integer" + end + + test "tooling links the form control family to widget and signal specs" do + {:ok, report} = UnifiedUi.Tooling.inspect_module(FormControlScreen) + + assert :form_control_and_composer in report.construct_families + assert ".spec/specs/unified-ui/widget_components.spec.md" in report.related_specs + assert ".spec/specs/unified-ui/signals.spec.md" in report.related_specs + end +end diff --git a/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs b/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs index 6d7897dc..dea27ccf 100644 --- a/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs +++ b/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs @@ -301,7 +301,10 @@ defmodule UnifiedUi.Phase2IntegrationTest do :disclosure, :kicker, :avatar, - :presence_dot + :presence_dot, + :segmented_button_group, + :runtime_form_shell, + :chat_composer ] assert UnifiedUi.Layout.kinds() == [:box, :row, :column, :grid, :stack] diff --git a/packages/unified-ui/test/unified_ui/reference_test.exs b/packages/unified-ui/test/unified_ui/reference_test.exs index 3ffe0ded..93a09600 100644 --- a/packages/unified-ui/test/unified_ui/reference_test.exs +++ b/packages/unified-ui/test/unified_ui/reference_test.exs @@ -32,7 +32,8 @@ defmodule UnifiedUi.ReferenceTest do :feedback, :data, :operational, - :content_identity_and_disclosure + :content_identity_and_disclosure, + :form_control_and_composer ] assert UnifiedUi.Reference.construct_families().signals == [ From 136c70780d0449523a3c54fa8c0df8eff45453e6 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 10:53:07 -0400 Subject: [PATCH 04/21] Implement operational widget authoring --- ...atalog-and-unified-ui-authoring-surface.md | 26 +- .../unified-ui/lib/unified_ui/dsl/entities.ex | 6 +- .../dsl/entities/widget_components.ex | 172 +++++++++- .../unified-ui/lib/unified_ui/dsl/node.ex | 74 ++++ .../lib/unified_ui/dsl/placement.ex | 21 +- .../verifiers/validate_widget_components.ex | 224 ++++++++++++ packages/unified-ui/lib/unified_ui/tooling.ex | 17 + packages/unified-ui/lib/unified_ui/widgets.ex | 26 +- .../advanced_widget_families_test.exs | 13 +- .../operational_widget_components_test.exs | 318 ++++++++++++++++++ .../unified_ui/phase_2_integration_test.exs | 13 +- .../test/unified_ui/reference_test.exs | 6 +- 12 files changed, 894 insertions(+), 22 deletions(-) create mode 100644 packages/unified-ui/test/unified_ui/operational_widget_components_test.exs diff --git a/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md b/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md index bec4c75e..6dd92131 100644 --- a/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md +++ b/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md @@ -96,33 +96,33 @@ Back to index: [README](./README.md) [x] 1.3.3.2 Subtask - Author optional tool-area and status children with deterministic ordering. [x] 1.3.3.3 Subtask - Validate that send and change interactions preserve canonical event meaning. - [ ] 1.4 Section - Rows, Workflow, Layer, Callout, Redline, and Code Authoring + [x] 1.4 Section - Rows, Workflow, Layer, Callout, Redline, and Code Authoring Add authored DSL support for the remaining visual and operational widget families in the PR set. - [ ] 1.4.1 Task - Implement row and artifact authoring + [x] 1.4.1 Task - Implement row and artifact authoring Support selectable or linkable row primitives for list and artifact surfaces. - [ ] 1.4.1.1 Subtask - Author `list_item_multi_column` with row identity, column template, active state, optional link target, and child cells. - [ ] 1.4.1.2 Subtask - Author `artifact_row` with title, meta, row identity, active state, optional link target, and trailing children. - [ ] 1.4.1.3 Subtask - Lower row activation into canonical interaction descriptors. + [x] 1.4.1.1 Subtask - Author `list_item_multi_column` with row identity, column template, active state, optional link target, and child cells. + [x] 1.4.1.2 Subtask - Author `artifact_row` with title, meta, row identity, active state, optional link target, and trailing children. + [x] 1.4.1.3 Subtask - Lower row activation into canonical interaction descriptors. - [ ] 1.4.2 Task - Implement workflow and progress authoring + [x] 1.4.2 Task - Implement workflow and progress authoring Support the stage, progress, and meter widgets needed by process and workflow screens. - [ ] 1.4.2.1 Subtask - Author `pipeline_stepper_horizontal` with ordered steps, active index, completed state, labels, and optional navigation intent. - [ ] 1.4.2.2 Subtask - Author `segmented_progress_bar` with segment weights, states, labels, and aggregate progress metadata. - [ ] 1.4.2.3 Subtask - Author `workflow_stage_list_vertical` and `meter_thin` with normalized values, labels, and state semantics. + [x] 1.4.2.1 Subtask - Author `pipeline_stepper_horizontal` with ordered steps, active index, completed state, labels, and optional navigation intent. + [x] 1.4.2.2 Subtask - Author `segmented_progress_bar` with segment weights, states, labels, and aggregate progress metadata. + [x] 1.4.2.3 Subtask - Author `workflow_stage_list_vertical` and `meter_thin` with normalized values, labels, and state semantics. - [ ] 1.4.3 Task - Implement layer, shell, callout, redline, and code authoring + [x] 1.4.3 Task - Implement layer, shell, callout, redline, and code authoring Support shell and content-specialized widgets with portable structure and safety rules. - [ ] 1.4.3.1 Subtask - Author `sticky_frosted_header` with leading, title, and trailing positional children. - [ ] 1.4.3.2 Subtask - Author `slide_over_panel` as a non-modal layer with open state, size, label, and children. - [ ] 1.4.3.3 Subtask - Author `event_callout`, `redline_inline`, and `code_block_syntax_highlighted` with tone, segment, token, and text-safety metadata. + [x] 1.4.3.1 Subtask - Author `sticky_frosted_header` with leading, title, and trailing positional children. + [x] 1.4.3.2 Subtask - Author `slide_over_panel` as a non-modal layer with open state, size, label, and children. + [x] 1.4.3.3 Subtask - Author `event_callout`, `redline_inline`, and `code_block_syntax_highlighted` with tone, segment, token, and text-safety metadata. [ ] 1.5 Section - List-Repeat Composition Authoring Add the list-repeat behavior introduced by AshUi PR 98 as a canonical DSL diff --git a/packages/unified-ui/lib/unified_ui/dsl/entities.ex b/packages/unified-ui/lib/unified_ui/dsl/entities.ex index 240bc486..9cecd56c 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/entities.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/entities.ex @@ -27,7 +27,11 @@ defmodule UnifiedUi.Dsl.Entities do :data, :operational, :content_identity_and_disclosure, - :form_control_and_composer + :form_control_and_composer, + :row_and_artifact, + :workflow_progress_and_status, + :layer_shell_and_callout, + :redline_and_code ], layouts: [:container, :row, :column, :grid, :stack, :split, :viewport], layers: [:overlay, :absolute, :modal, :toast, :menu, :canvas], diff --git a/packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex b/packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex index 8b4ba9cb..9901c8ca 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex @@ -7,10 +7,19 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do @content_identity_family :content_identity_and_disclosure @form_control_family :form_control_and_composer + @row_artifact_family :row_and_artifact + @workflow_family :workflow_progress_and_status + @layer_family :layer_shell_and_callout + @redline_code_family :redline_and_code @spec entities() :: [Spark.Dsl.Entity.t()] def entities do - content_identity_entities() ++ form_control_entities() + content_identity_entities() ++ + form_control_entities() ++ + row_artifact_entities() ++ + workflow_entities() ++ + layer_callout_entities() ++ + redline_code_entities() end @spec content_identity_entities() :: [Spark.Dsl.Entity.t()] @@ -75,6 +84,127 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do ] end + @spec row_artifact_entities() :: [Spark.Dsl.Entity.t()] + def row_artifact_entities do + [ + container( + :list_item_multi_column, + @row_artifact_family, + row_identity: [type: :any, required: true], + column_template: [type: :any, required: true], + active?: [type: :boolean, required: false, default: false], + link_target: [type: :string, required: false], + action_intent: [type: :atom, required: false], + summary: [type: :string, required: false] + ), + container( + :artifact_row, + @row_artifact_family, + title: [type: :string, required: true], + meta: [type: :any, required: false], + row_identity: [type: :any, required: true], + active?: [type: :boolean, required: false, default: false], + link_target: [type: :string, required: false], + action_intent: [type: :atom, required: false], + summary: [type: :string, required: false] + ) + ] + end + + @spec workflow_entities() :: [Spark.Dsl.Entity.t()] + def workflow_entities do + [ + leaf( + :pipeline_stepper_horizontal, + @workflow_family, + steps: [type: :any, required: true], + active_index: [type: :integer, required: false, default: 0], + completed_indices: [type: :any, required: false, default: []], + navigation_intent: [type: :atom, required: false], + summary: [type: :string, required: false] + ), + leaf( + :segmented_progress_bar, + @workflow_family, + segments: [type: :any, required: true], + aggregate_progress: [type: :any, required: false], + label: [type: :string, required: false], + summary: [type: :string, required: false] + ), + leaf( + :workflow_stage_list_vertical, + @workflow_family, + stages: [type: :any, required: true], + active_index: [type: :integer, required: false, default: 0], + summary: [type: :string, required: false] + ), + leaf( + :meter_thin, + @workflow_family, + current: [type: :any, required: true], + minimum: [type: :any, required: false, default: 0], + maximum: [type: :any, required: false, default: 100], + label: [type: :string, required: false], + state: [type: :atom, required: false], + summary: [type: :string, required: false] + ) + ] + end + + @spec layer_callout_entities() :: [Spark.Dsl.Entity.t()] + def layer_callout_entities do + [ + container( + :sticky_frosted_header, + @layer_family, + title: [type: :string, required: true], + leading: [type: :any, required: false, default: []], + trailing: [type: :any, required: false, default: []], + summary: [type: :string, required: false] + ), + container( + :slide_over_panel, + @layer_family, + open?: [type: :boolean, required: false, default: false], + size: [type: {:in, [:small, :medium, :large, :wide]}, required: false, default: :medium], + modal?: [type: :boolean, required: false, default: false], + dismiss_intent: [type: :atom, required: false], + summary: [type: :string, required: false] + ), + container( + :event_callout, + @layer_family, + tone: [type: :atom, required: false, default: :info], + eyebrow: [type: :string, required: false], + title: [type: :string, required: false], + message: [type: :string, required: true], + action_intent: [type: :atom, required: false], + summary: [type: :string, required: false] + ) + ] + end + + @spec redline_code_entities() :: [Spark.Dsl.Entity.t()] + def redline_code_entities do + [ + leaf( + :redline_inline, + @redline_code_family, + segments: [type: :any, required: true], + text_safety: [type: :atom, required: false, default: :plain_text], + summary: [type: :string, required: false] + ), + leaf( + :code_block_syntax_highlighted, + @redline_code_family, + language: [type: :any, required: true], + tokens: [type: :any, required: true], + text_safety: [type: :atom, required: false, default: :plain_text], + summary: [type: :string, required: false] + ) + ] + end + @spec content_identity_kinds() :: [atom()] def content_identity_kinds do Enum.map(content_identity_entities(), & &1.name) @@ -85,9 +215,34 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do Enum.map(form_control_entities(), & &1.name) end + @spec row_artifact_kinds() :: [atom()] + def row_artifact_kinds do + Enum.map(row_artifact_entities(), & &1.name) + end + + @spec workflow_kinds() :: [atom()] + def workflow_kinds do + Enum.map(workflow_entities(), & &1.name) + end + + @spec layer_callout_kinds() :: [atom()] + def layer_callout_kinds do + Enum.map(layer_callout_entities(), & &1.name) + end + + @spec redline_code_kinds() :: [atom()] + def redline_code_kinds do + Enum.map(redline_code_entities(), & &1.name) + end + @spec kinds() :: [atom()] def kinds do - content_identity_kinds() ++ form_control_kinds() + content_identity_kinds() ++ + form_control_kinds() ++ + row_artifact_kinds() ++ + workflow_kinds() ++ + layer_callout_kinds() ++ + redline_code_kinds() end defp leaf(name, family, extra_schema) do @@ -101,6 +256,19 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do } end + defp container(name, family, extra_schema) do + %Spark.Dsl.Entity{ + name: name, + target: Node, + args: [:id], + identifier: :id, + recursive_as: :children, + auto_set_fields: [family: family, kind: name], + entities: [children: Foundational.entities()], + schema: EntitySchema.widget(extra_schema) + } + end + defp disclosure_entity do %Spark.Dsl.Entity{ name: :disclosure, diff --git a/packages/unified-ui/lib/unified_ui/dsl/node.ex b/packages/unified-ui/lib/unified_ui/dsl/node.ex index c1d73b7e..53c16216 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/node.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/node.ex @@ -17,6 +17,10 @@ defmodule UnifiedUi.Dsl.Node do | :canvas | :content_identity_and_disclosure | :form_control_and_composer + | :row_and_artifact + | :workflow_progress_and_status + | :layer_shell_and_callout + | :redline_and_code @type t :: %__MODULE__{ __identifier__: atom() | nil, @@ -158,6 +162,22 @@ defmodule UnifiedUi.Dsl.Node do validation_state: atom() | nil, send_label: String.t() | nil, send_intent: atom() | nil, + row_identity: term(), + column_template: list() | nil, + active?: boolean() | nil, + link_target: String.t() | nil, + meta: term(), + steps: list() | nil, + active_index: integer() | nil, + completed_indices: list() | nil, + navigation_intent: atom() | nil, + aggregate_progress: map() | nil, + stages: list() | nil, + leading: list() | nil, + trailing: list() | nil, + language: atom() | String.t() | nil, + tokens: list() | nil, + text_safety: atom() | nil, children: [t()] } @@ -300,6 +320,22 @@ defmodule UnifiedUi.Dsl.Node do validation_state: nil, send_label: nil, send_intent: nil, + row_identity: nil, + column_template: nil, + active?: nil, + link_target: nil, + meta: nil, + steps: nil, + active_index: nil, + completed_indices: nil, + navigation_intent: nil, + aggregate_progress: nil, + stages: nil, + leading: nil, + trailing: nil, + language: nil, + tokens: nil, + text_safety: nil, children: [] @spec summary(t()) :: map() @@ -325,6 +361,7 @@ defmodule UnifiedUi.Dsl.Node do end, interaction_refs: node.interaction_refs, binding_refs: node.binding_refs, + action_intent: summary_action_intent(node), submit_intent: summary_submit_intent(node), role: node.role, presentation: node.presentation, @@ -340,6 +377,8 @@ defmodule UnifiedUi.Dsl.Node do content_ref: node.content_ref, target_ref: node.target_ref, trigger_ref: node.trigger_ref, + modal?: summary_modal(node), + dismiss_intent: summary_dismiss_intent(node), placement: node.placement, base_ref: node.base_ref, layer_refs: node.layer_refs, @@ -372,6 +411,22 @@ defmodule UnifiedUi.Dsl.Node do validation_state: node.validation_state, send_label: node.send_label, send_intent: node.send_intent, + row_identity: node.row_identity, + column_template: node.column_template, + active?: node.active?, + link_target: node.link_target, + meta: node.meta, + steps: node.steps, + active_index: node.active_index, + completed_indices: node.completed_indices, + navigation_intent: node.navigation_intent, + aggregate_progress: node.aggregate_progress, + stages: node.stages, + leading: node.leading, + trailing: node.trailing, + language: node.language, + tokens: node.tokens, + text_safety: node.text_safety, current: node.current, minimum: node.minimum, maximum: node.maximum, @@ -394,4 +449,23 @@ defmodule UnifiedUi.Dsl.Node do end defp summary_submit_intent(_node), do: nil + + defp summary_action_intent(%__MODULE__{kind: kind, action_intent: action_intent}) + when kind in [:list_item_multi_column, :artifact_row, :event_callout] do + action_intent + end + + defp summary_action_intent(_node), do: nil + + defp summary_modal(%__MODULE__{kind: :slide_over_panel, modal?: modal?}), do: modal? + defp summary_modal(_node), do: nil + + defp summary_dismiss_intent(%__MODULE__{ + kind: :slide_over_panel, + dismiss_intent: dismiss_intent + }) do + dismiss_intent + end + + defp summary_dismiss_intent(_node), do: nil end diff --git a/packages/unified-ui/lib/unified_ui/dsl/placement.ex b/packages/unified-ui/lib/unified_ui/dsl/placement.ex index 67e6e3b7..a4507209 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/placement.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/placement.ex @@ -90,7 +90,13 @@ defmodule UnifiedUi.Dsl.Placement do :avatar, :presence_dot, :segmented_button_group, - :runtime_form_shell + :runtime_form_shell, + :pipeline_stepper_horizontal, + :segmented_progress_bar, + :workflow_stage_list_vertical, + :meter_thin, + :redline_inline, + :code_block_syntax_highlighted ] @advanced_leaf_kinds [ @@ -119,7 +125,18 @@ defmodule UnifiedUi.Dsl.Placement do ] @layout_kinds [:box, :row, :column, :grid, :stack] - @container_kinds [:content, :form_builder, :field_group, :disclosure, :chat_composer] + @container_kinds [ + :content, + :form_builder, + :field_group, + :disclosure, + :chat_composer, + :list_item_multi_column, + :artifact_row, + :sticky_frosted_header, + :slide_over_panel, + :event_callout + ] @spec section_boundaries() :: %{atom() => [atom()]} def section_boundaries do diff --git a/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex b/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex index d08c356c..f98b7cb3 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex @@ -7,6 +7,7 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do alias UnifiedUi.Dsl.Node @heading_segment_types [:text, :emphasis] + @redline_states [:keep, :insert, :delete, :accepted, :rejected] @spec verify(map()) :: :ok | {:error, Spark.Error.DslError.t()} def verify(dsl) do @@ -84,6 +85,137 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do end end + def validate_node(%Node{ + kind: :list_item_multi_column, + id: id, + row_identity: row_identity, + column_template: column_template + }) do + cond do + not valid_scalar?(row_identity) -> + {:error, [:composition, :list_item_multi_column, id], + "list_item_multi_column #{inspect(id)} row_identity must be a non-empty scalar value"} + + not valid_column_template?(column_template) -> + {:error, [:composition, :list_item_multi_column, id], + "list_item_multi_column #{inspect(id)} column_template must be a non-empty list of column maps with id and label"} + + true -> + :ok + end + end + + def validate_node(%Node{kind: :artifact_row, id: id, row_identity: row_identity, title: title}) do + cond do + not is_binary(title) or title == "" -> + {:error, [:composition, :artifact_row, id], + "artifact_row #{inspect(id)} title must be a non-empty string"} + + not valid_scalar?(row_identity) -> + {:error, [:composition, :artifact_row, id], + "artifact_row #{inspect(id)} row_identity must be a non-empty scalar value"} + + true -> + :ok + end + end + + def validate_node(%Node{ + kind: :pipeline_stepper_horizontal, + id: id, + steps: steps, + active_index: active_index, + completed_indices: completed_indices + }) do + cond do + not valid_labeled_state_items?(steps) -> + {:error, [:composition, :pipeline_stepper_horizontal, id], + "pipeline_stepper_horizontal #{inspect(id)} steps must be a non-empty list of maps with id, label, and state"} + + not valid_index?(active_index, steps) -> + {:error, [:composition, :pipeline_stepper_horizontal, id], + "pipeline_stepper_horizontal #{inspect(id)} active_index must reference an existing step"} + + not valid_indices?(completed_indices, steps) -> + {:error, [:composition, :pipeline_stepper_horizontal, id], + "pipeline_stepper_horizontal #{inspect(id)} completed_indices must reference existing steps"} + + true -> + :ok + end + end + + def validate_node(%Node{kind: :segmented_progress_bar, id: id, segments: segments}) do + if valid_progress_segments?(segments) do + :ok + else + {:error, [:composition, :segmented_progress_bar, id], + "segmented_progress_bar #{inspect(id)} segments must be a non-empty list of maps with label and positive weight or value"} + end + end + + def validate_node(%Node{ + kind: :workflow_stage_list_vertical, + id: id, + stages: stages, + active_index: active_index + }) do + cond do + not valid_labeled_state_items?(stages) -> + {:error, [:composition, :workflow_stage_list_vertical, id], + "workflow_stage_list_vertical #{inspect(id)} stages must be a non-empty list of maps with id, label, and state"} + + not valid_index?(active_index, stages) -> + {:error, [:composition, :workflow_stage_list_vertical, id], + "workflow_stage_list_vertical #{inspect(id)} active_index must reference an existing stage"} + + true -> + :ok + end + end + + def validate_node(%Node{ + kind: :meter_thin, + id: id, + current: current, + minimum: min, + maximum: max + }) do + if valid_meter_range?(current, min, max) do + :ok + else + {:error, [:composition, :meter_thin, id], + "meter_thin #{inspect(id)} current, minimum, and maximum must be numeric and current must be within range"} + end + end + + def validate_node(%Node{kind: :slide_over_panel, id: id, modal?: modal?}) do + if modal? in [false, nil] do + :ok + else + {:error, [:composition, :slide_over_panel, id], + "slide_over_panel #{inspect(id)} must remain non-modal; use dialog for modal layers"} + end + end + + def validate_node(%Node{kind: :redline_inline, id: id, segments: segments}) do + if valid_redline_segments?(segments) do + :ok + else + {:error, [:composition, :redline_inline, id], + "redline_inline #{inspect(id)} segments must be a non-empty list of plain-text segment maps with supported state"} + end + end + + def validate_node(%Node{kind: :code_block_syntax_highlighted, id: id, tokens: tokens}) do + if valid_code_tokens?(tokens) do + :ok + else + {:error, [:composition, :code_block_syntax_highlighted, id], + "code_block_syntax_highlighted #{inspect(id)} tokens must be a non-empty list of plain-text token maps with type and text"} + end + end + def validate_node(_node), do: :ok defp valid_heading_segments?(segments) when is_list(segments) and segments != [] do @@ -130,6 +262,94 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do defp valid_form_fields?(_fields), do: false + defp valid_column_template?(columns) when is_list(columns) and columns != [] do + Enum.all?(columns, fn + column when is_map(column) or is_list(column) -> + column = normalize_map(column) + valid_scalar?(Map.get(column, :id)) and is_binary(Map.get(column, :label)) + + _other -> + false + end) + end + + defp valid_column_template?(_columns), do: false + + defp valid_labeled_state_items?(items) when is_list(items) and items != [] do + Enum.all?(items, fn + item when is_map(item) or is_list(item) -> + item = normalize_map(item) + + valid_scalar?(Map.get(item, :id)) and is_binary(Map.get(item, :label)) and + valid_scalar?(Map.get(item, :state)) + + _other -> + false + end) + end + + defp valid_labeled_state_items?(_items), do: false + + defp valid_progress_segments?(segments) when is_list(segments) and segments != [] do + Enum.all?(segments, fn + segment when is_map(segment) or is_list(segment) -> + segment = normalize_map(segment) + + is_binary(Map.get(segment, :label)) and + positive_number?(segment[:weight] || segment[:value]) + + _other -> + false + end) + end + + defp valid_progress_segments?(_segments), do: false + + defp valid_redline_segments?(segments) when is_list(segments) and segments != [] do + Enum.all?(segments, fn + segment when is_map(segment) or is_list(segment) -> + segment = normalize_map(segment) + Map.get(segment, :state) in @redline_states and is_binary(Map.get(segment, :text)) + + _other -> + false + end) + end + + defp valid_redline_segments?(_segments), do: false + + defp valid_code_tokens?(tokens) when is_list(tokens) and tokens != [] do + Enum.all?(tokens, fn + token when is_map(token) or is_list(token) -> + token = normalize_map(token) + valid_scalar?(Map.get(token, :type)) and is_binary(Map.get(token, :text)) + + _other -> + false + end) + end + + defp valid_code_tokens?(_tokens), do: false + + defp valid_index?(index, items) when is_integer(index) and is_list(items) do + index >= 0 and index < length(items) + end + + defp valid_index?(_index, _items), do: false + + defp valid_indices?(indices, items) when is_list(indices) do + Enum.all?(indices, &valid_index?(&1, items)) + end + + defp valid_indices?(_indices, _items), do: false + + defp valid_meter_range?(current, min, max) + when is_number(current) and is_number(min) and is_number(max) do + min <= max and current >= min and current <= max + end + + defp valid_meter_range?(_current, _min, _max), do: false + defp valid_field_attributes?(nil), do: true defp valid_field_attributes?(attributes) when is_map(attributes), do: true @@ -140,9 +360,13 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do defp valid_field_attributes?(_attributes), do: false defp valid_scalar?(nil), do: false + defp valid_scalar?(""), do: false defp valid_scalar?(value) when is_atom(value) or is_binary(value) or is_number(value), do: true defp valid_scalar?(_value), do: false + defp positive_number?(value) when is_number(value), do: value > 0 + defp positive_number?(_value), do: false + defp normalize_map(segment) when is_map(segment) do Map.new(segment, fn {key, value} when is_binary(key) -> diff --git a/packages/unified-ui/lib/unified_ui/tooling.ex b/packages/unified-ui/lib/unified_ui/tooling.ex index 8d94257e..6cf9992f 100644 --- a/packages/unified-ui/lib/unified_ui/tooling.ex +++ b/packages/unified-ui/lib/unified_ui/tooling.ex @@ -36,6 +36,23 @@ defmodule UnifiedUi.Tooling do ".spec/specs/unified-ui/signals.spec.md", ".spec/specs/unified-ui/widget_components.spec.md" ], + row_and_artifact: [ + ".spec/specs/unified-ui/widgets.spec.md", + ".spec/specs/unified-ui/signals.spec.md", + ".spec/specs/unified-ui/widget_components.spec.md" + ], + workflow_progress_and_status: [ + ".spec/specs/unified-ui/widgets.spec.md", + ".spec/specs/unified-ui/widget_components.spec.md" + ], + layer_shell_and_callout: [ + ".spec/specs/unified-ui/widgets.spec.md", + ".spec/specs/unified-ui/widget_components.spec.md" + ], + redline_and_code: [ + ".spec/specs/unified-ui/widgets.spec.md", + ".spec/specs/unified-ui/widget_components.spec.md" + ], layout: [".spec/specs/unified-ui/display_systems.spec.md"], display: [".spec/specs/unified-ui/display_systems.spec.md"], overlay: [".spec/specs/unified-ui/display_systems.spec.md"], diff --git a/packages/unified-ui/lib/unified_ui/widgets.ex b/packages/unified-ui/lib/unified_ui/widgets.ex index 56cf1ae0..46ca60d6 100644 --- a/packages/unified-ui/lib/unified_ui/widgets.ex +++ b/packages/unified-ui/lib/unified_ui/widgets.ex @@ -47,6 +47,26 @@ defmodule UnifiedUi.Widgets do WidgetComponentEntities.form_control_kinds() end + @spec row_artifact_component_kinds() :: [atom()] + def row_artifact_component_kinds do + WidgetComponentEntities.row_artifact_kinds() + end + + @spec workflow_component_kinds() :: [atom()] + def workflow_component_kinds do + WidgetComponentEntities.workflow_kinds() + end + + @spec layer_callout_component_kinds() :: [atom()] + def layer_callout_component_kinds do + WidgetComponentEntities.layer_callout_kinds() + end + + @spec redline_code_component_kinds() :: [atom()] + def redline_code_component_kinds do + WidgetComponentEntities.redline_code_kinds() + end + @spec component_catalog() :: [WidgetComponents.component()] def component_catalog do WidgetComponents.catalog() @@ -76,6 +96,10 @@ defmodule UnifiedUi.Widgets do feedback_kinds() ++ advanced_kinds() ++ content_identity_component_kinds() ++ - form_control_component_kinds() + form_control_component_kinds() ++ + row_artifact_component_kinds() ++ + workflow_component_kinds() ++ + layer_callout_component_kinds() ++ + redline_code_component_kinds() end end diff --git a/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs b/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs index e77ee102..327f7a0b 100644 --- a/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs +++ b/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs @@ -169,7 +169,18 @@ defmodule UnifiedUi.AdvancedWidgetFamiliesTest do :presence_dot, :segmented_button_group, :runtime_form_shell, - :chat_composer + :chat_composer, + :list_item_multi_column, + :artifact_row, + :pipeline_stepper_horizontal, + :segmented_progress_bar, + :workflow_stage_list_vertical, + :meter_thin, + :sticky_frosted_header, + :slide_over_panel, + :event_callout, + :redline_inline, + :code_block_syntax_highlighted ] end diff --git a/packages/unified-ui/test/unified_ui/operational_widget_components_test.exs b/packages/unified-ui/test/unified_ui/operational_widget_components_test.exs new file mode 100644 index 00000000..4857ae6a --- /dev/null +++ b/packages/unified-ui/test/unified_ui/operational_widget_components_test.exs @@ -0,0 +1,318 @@ +defmodule UnifiedUi.OperationalWidgetComponentsTest do + use ExUnit.Case, async: true + + alias Spark.Dsl.Extension + alias UnifiedUi.Dsl.Node + alias UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents + + defmodule OperationalComponentsScreen do + use UnifiedUi.Dsl + + identity do + id(:operational_components_screen) + authored_ref([:examples, :operational_components_screen]) + end + + composition do + root(:operational_components_root) + mode(:screen) + + list_item_multi_column :task_row do + row_identity("task-1") + column_template([%{id: :title, label: "Title"}, %{id: :owner, label: "Owner"}]) + active?(true) + link_target("/tasks/1") + action_intent(:open_task) + + text :task_title do + value("Review ADR") + end + + badge :task_owner do + value("PC") + end + end + + artifact_row :adr_artifact do + title("Widget component ADR") + meta(%{kind: :adr, status: :accepted}) + row_identity(:adr_widget_components) + active?(true) + link_target("/artifacts/widget-components") + action_intent(:open_artifact) + + button :artifact_action do + label("Open") + end + end + + pipeline_stepper_horizontal :release_steps do + steps([ + %{id: :draft, label: "Draft", state: :done}, + %{id: :review, label: "Review", state: :active}, + %{id: :ship, label: "Ship", state: :pending} + ]) + + active_index(1) + completed_indices([0]) + navigation_intent(:select_release_step) + end + + segmented_progress_bar :quality_bar do + segments([ + %{label: "Passing", weight: 8, state: :success}, + %{label: "Failing", weight: 1, state: :error} + ]) + + aggregate_progress(%{current: 8, maximum: 9}) + label("Scenario health") + end + + workflow_stage_list_vertical :review_stages do + stages([ + %{id: :authored, label: "Authored", state: :done}, + %{id: :implemented, label: "Implemented", state: :active} + ]) + + active_index(1) + end + + meter_thin :coverage_meter do + current(82.5) + minimum(0) + maximum(100) + label("Coverage") + state(:success) + end + + sticky_frosted_header :workspace_header do + title("Workspace") + leading([:back_button]) + trailing([:save_button]) + + button :save_button do + label("Save") + end + end + + slide_over_panel :details_panel do + accessibility_label("Details") + open?(true) + size(:wide) + dismiss_intent(:close_details) + + text :details_body do + value("Panel content") + end + end + + event_callout :incident_callout do + tone(:warning) + eyebrow("Deploy") + title("Delayed") + message("Rollout paused for review") + action_intent(:open_incident) + + button :callout_action do + label("Inspect") + end + end + + redline_inline :copy_redline do + segments([ + %{state: :keep, text: "Keep this "}, + %{state: :delete, text: "old"}, + %{state: :insert, text: "new"} + ]) + end + + code_block_syntax_highlighted :code_sample do + language(:elixir) + + tokens([ + %{type: :keyword, text: "defmodule"}, + %{type: :text, text: " Demo"} + ]) + end + end + end + + test "registers authored operational widget component kinds" do + assert UnifiedUi.Widgets.row_artifact_component_kinds() == [ + :list_item_multi_column, + :artifact_row + ] + + assert UnifiedUi.Widgets.workflow_component_kinds() == [ + :pipeline_stepper_horizontal, + :segmented_progress_bar, + :workflow_stage_list_vertical, + :meter_thin + ] + + assert UnifiedUi.Widgets.layer_callout_component_kinds() == [ + :sticky_frosted_header, + :slide_over_panel, + :event_callout + ] + + assert UnifiedUi.Widgets.redline_code_component_kinds() == [ + :redline_inline, + :code_block_syntax_highlighted + ] + end + + test "stores row, workflow, layer, callout, redline, and code components" do + entities = Extension.get_entities(OperationalComponentsScreen, [:composition]) + by_id = Map.new(entities, &{&1.id, &1}) + + assert {by_id.task_row.family, by_id.task_row.kind, by_id.task_row.row_identity, + by_id.task_row.active?, by_id.task_row.action_intent} == + {:row_and_artifact, :list_item_multi_column, "task-1", true, :open_task} + + assert Enum.map(by_id.task_row.children, & &1.kind) == [:text, :badge] + + assert {by_id.adr_artifact.family, by_id.adr_artifact.kind, by_id.adr_artifact.title, + by_id.adr_artifact.meta.status} == + {:row_and_artifact, :artifact_row, "Widget component ADR", :accepted} + + assert {by_id.release_steps.family, by_id.release_steps.kind, + by_id.release_steps.active_index, by_id.release_steps.navigation_intent} == + {:workflow_progress_and_status, :pipeline_stepper_horizontal, 1, + :select_release_step} + + assert {by_id.details_panel.family, by_id.details_panel.kind, by_id.details_panel.open?, + by_id.details_panel.modal?} == + {:layer_shell_and_callout, :slide_over_panel, true, false} + + assert {by_id.copy_redline.family, by_id.copy_redline.kind, by_id.copy_redline.text_safety} == + {:redline_and_code, :redline_inline, :plain_text} + + assert {by_id.code_sample.family, by_id.code_sample.kind, by_id.code_sample.language} == + {:redline_and_code, :code_block_syntax_highlighted, :elixir} + end + + test "summarizes operational components without a renderer runtime" do + summary = + OperationalComponentsScreen + |> UnifiedUi.Info.composition_summary() + |> Map.new(&{&1.id, &1}) + + assert %{ + family: :row_and_artifact, + kind: :list_item_multi_column, + row_identity: "task-1", + column_template: [%{id: :title, label: "Title"}, %{id: :owner, label: "Owner"}], + active?: true, + link_target: "/tasks/1", + action_intent: :open_task + } = summary.task_row + + assert Enum.map(summary.task_row.children, & &1.kind) == [:text, :badge] + + assert %{ + family: :workflow_progress_and_status, + kind: :pipeline_stepper_horizontal, + active_index: 1, + completed_indices: [0], + navigation_intent: :select_release_step + } = summary.release_steps + + assert Enum.map(summary.release_steps.steps, & &1.state) == [:done, :active, :pending] + + assert %{ + family: :layer_shell_and_callout, + kind: :slide_over_panel, + open?: true, + modal?: false, + dismiss_intent: :close_details + } = summary.details_panel + + assert %{ + family: :redline_and_code, + kind: :code_block_syntax_highlighted, + language: :elixir, + text_safety: :plain_text + } = summary.code_sample + end + + test "validates operational component field shapes" do + assert {:error, [:composition, :list_item_multi_column, :bad_row], row_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :list_item_multi_column, + id: :bad_row, + row_identity: "row-1", + column_template: [%{id: :title}] + }) + + assert row_message =~ "column_template must be a non-empty list" + + assert {:error, [:composition, :pipeline_stepper_horizontal, :bad_steps], step_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :pipeline_stepper_horizontal, + id: :bad_steps, + steps: [%{id: :draft, label: "Draft", state: :done}], + active_index: 2, + completed_indices: [] + }) + + assert step_message =~ "active_index must reference an existing step" + + assert {:error, [:composition, :segmented_progress_bar, :bad_progress], progress_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :segmented_progress_bar, + id: :bad_progress, + segments: [%{label: "Failing", weight: 0}] + }) + + assert progress_message =~ "positive weight or value" + + assert {:error, [:composition, :meter_thin, :bad_meter], meter_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :meter_thin, + id: :bad_meter, + current: 12, + minimum: 0, + maximum: 10 + }) + + assert meter_message =~ "current, minimum, and maximum must be numeric" + + assert {:error, [:composition, :slide_over_panel, :bad_panel], panel_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :slide_over_panel, + id: :bad_panel, + modal?: true + }) + + assert panel_message =~ "must remain non-modal" + + assert {:error, [:composition, :redline_inline, :bad_redline], redline_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :redline_inline, + id: :bad_redline, + segments: [%{state: :html, text: "bad"}] + }) + + assert redline_message =~ "supported state" + + assert {:error, [:composition, :code_block_syntax_highlighted, :bad_code], code_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :code_block_syntax_highlighted, + id: :bad_code, + tokens: [%{type: :keyword}] + }) + + assert code_message =~ "token maps with type and text" + end + + test "tooling links operational families to widget component specs" do + {:ok, report} = UnifiedUi.Tooling.inspect_module(OperationalComponentsScreen) + + assert :row_and_artifact in report.construct_families + assert :workflow_progress_and_status in report.construct_families + assert :layer_shell_and_callout in report.construct_families + assert :redline_and_code in report.construct_families + assert ".spec/specs/unified-ui/widget_components.spec.md" in report.related_specs + end +end diff --git a/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs b/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs index dea27ccf..664b18a8 100644 --- a/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs +++ b/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs @@ -304,7 +304,18 @@ defmodule UnifiedUi.Phase2IntegrationTest do :presence_dot, :segmented_button_group, :runtime_form_shell, - :chat_composer + :chat_composer, + :list_item_multi_column, + :artifact_row, + :pipeline_stepper_horizontal, + :segmented_progress_bar, + :workflow_stage_list_vertical, + :meter_thin, + :sticky_frosted_header, + :slide_over_panel, + :event_callout, + :redline_inline, + :code_block_syntax_highlighted ] assert UnifiedUi.Layout.kinds() == [:box, :row, :column, :grid, :stack] diff --git a/packages/unified-ui/test/unified_ui/reference_test.exs b/packages/unified-ui/test/unified_ui/reference_test.exs index 93a09600..8fdf2b78 100644 --- a/packages/unified-ui/test/unified_ui/reference_test.exs +++ b/packages/unified-ui/test/unified_ui/reference_test.exs @@ -33,7 +33,11 @@ defmodule UnifiedUi.ReferenceTest do :data, :operational, :content_identity_and_disclosure, - :form_control_and_composer + :form_control_and_composer, + :row_and_artifact, + :workflow_progress_and_status, + :layer_shell_and_callout, + :redline_and_code ] assert UnifiedUi.Reference.construct_families().signals == [ From a828890a54d2e2bee5d0e5074eede601939fca8b Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 11:03:40 -0400 Subject: [PATCH 05/21] Implement list repeat authoring --- ...atalog-and-unified-ui-authoring-surface.md | 18 +- .../unified-ui/lib/unified_ui/dsl/entities.ex | 3 +- .../dsl/entities/widget_components.ex | 92 ++++++- .../unified-ui/lib/unified_ui/dsl/node.ex | 16 ++ .../lib/unified_ui/dsl/placement.ex | 3 +- .../verifiers/validate_widget_components.ex | 94 ++++++- packages/unified-ui/lib/unified_ui/tooling.ex | 5 + packages/unified-ui/lib/unified_ui/widgets.ex | 8 +- .../advanced_widget_families_test.exs | 3 +- .../list_repeat_components_test.exs | 229 ++++++++++++++++++ .../unified_ui/phase_2_integration_test.exs | 3 +- .../test/unified_ui/reference_test.exs | 3 +- 12 files changed, 457 insertions(+), 20 deletions(-) create mode 100644 packages/unified-ui/test/unified_ui/list_repeat_components_test.exs diff --git a/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md b/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md index 6dd92131..0c18d248 100644 --- a/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md +++ b/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md @@ -124,24 +124,24 @@ Back to index: [README](./README.md) [x] 1.4.3.2 Subtask - Author `slide_over_panel` as a non-modal layer with open state, size, label, and children. [x] 1.4.3.3 Subtask - Author `event_callout`, `redline_inline`, and `code_block_syntax_highlighted` with tone, segment, token, and text-safety metadata. - [ ] 1.5 Section - List-Repeat Composition Authoring + [x] 1.5 Section - List-Repeat Composition Authoring Add the list-repeat behavior introduced by AshUi PR 98 as a canonical DSL composition primitive. - [ ] 1.5.1 Task - Define repeat composition syntax and validation + [x] 1.5.1 Task - Define repeat composition syntax and validation Establish how authors attach a child template to rows from a list binding. - [ ] 1.5.1.1 Subtask - Define the authored repeat directive shape and how it references a list binding. - [ ] 1.5.1.2 Subtask - Validate that repeat can only target list-like bindings and compatible child templates. - [ ] 1.5.1.3 Subtask - Validate row-scope binding references and reject unsupported deep or host-specific row access until explicitly supported. + [x] 1.5.1.1 Subtask - Define the authored repeat directive shape and how it references a list binding. + [x] 1.5.1.2 Subtask - Validate that repeat can only target list-like bindings and compatible child templates. + [x] 1.5.1.3 Subtask - Validate row-scope binding references and reject unsupported deep or host-specific row access until explicitly supported. - [ ] 1.5.2 Task - Expose repeat inspection and diagnostics + [x] 1.5.2 Task - Expose repeat inspection and diagnostics Make repeated composition visible and debuggable before runtime rendering. - [ ] 1.5.2.1 Subtask - Add inspection output for repeat binding id, row scope, template identity, and generated identity strategy. - [ ] 1.5.2.2 Subtask - Add diagnostics for missing list bindings, malformed row fields, and repeat use on single-child composition. - [ ] 1.5.2.3 Subtask - Add authoring examples that show repeated artifact rows and repeated event callouts. + [x] 1.5.2.1 Subtask - Add inspection output for repeat binding id, row scope, template identity, and generated identity strategy. + [x] 1.5.2.2 Subtask - Add diagnostics for missing list bindings, malformed row fields, and repeat use on single-child composition. + [x] 1.5.2.3 Subtask - Add authoring examples that show repeated artifact rows and repeated event callouts. [ ] 1.6 Section - Phase 1 Integration Tests Validate the authored DSL surface, alias policy, diagnostics, and repeat diff --git a/packages/unified-ui/lib/unified_ui/dsl/entities.ex b/packages/unified-ui/lib/unified_ui/dsl/entities.ex index 9cecd56c..90b6ab1b 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/entities.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/entities.ex @@ -31,7 +31,8 @@ defmodule UnifiedUi.Dsl.Entities do :row_and_artifact, :workflow_progress_and_status, :layer_shell_and_callout, - :redline_and_code + :redline_and_code, + :composition_behavior ], layouts: [:container, :row, :column, :grid, :stack, :split, :viewport], layers: [:overlay, :absolute, :modal, :toast, :menu, :canvas], diff --git a/packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex b/packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex index 9901c8ca..061eac8a 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/entities/widget_components.ex @@ -11,6 +11,7 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do @workflow_family :workflow_progress_and_status @layer_family :layer_shell_and_callout @redline_code_family :redline_and_code + @composition_behavior_family :composition_behavior @spec entities() :: [Spark.Dsl.Entity.t()] def entities do @@ -19,7 +20,8 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do row_artifact_entities() ++ workflow_entities() ++ layer_callout_entities() ++ - redline_code_entities() + redline_code_entities() ++ + composition_behavior_entities() end @spec content_identity_entities() :: [Spark.Dsl.Entity.t()] @@ -205,6 +207,11 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do ] end + @spec composition_behavior_entities() :: [Spark.Dsl.Entity.t()] + def composition_behavior_entities do + [list_repeat_entity()] + end + @spec content_identity_kinds() :: [atom()] def content_identity_kinds do Enum.map(content_identity_entities(), & &1.name) @@ -235,6 +242,11 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do Enum.map(redline_code_entities(), & &1.name) end + @spec composition_behavior_kinds() :: [atom()] + def composition_behavior_kinds do + Enum.map(composition_behavior_entities(), & &1.name) + end + @spec kinds() :: [atom()] def kinds do content_identity_kinds() ++ @@ -242,7 +254,8 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do row_artifact_kinds() ++ workflow_kinds() ++ layer_callout_kinds() ++ - redline_code_kinds() + redline_code_kinds() ++ + composition_behavior_kinds() end defp leaf(name, family, extra_schema) do @@ -308,4 +321,79 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do ) } end + + defp list_repeat_entity do + %Spark.Dsl.Entity{ + name: :list_repeat, + target: Node, + args: [:id], + identifier: :id, + recursive_as: :children, + auto_set_fields: [family: @composition_behavior_family, kind: :list_repeat], + entities: [ + children: [ + template_container( + :list_item_multi_column_template, + :list_item_multi_column, + @row_artifact_family, + row_identity: [type: :any, required: true], + column_template: [type: :any, required: true], + active?: [type: :boolean, required: false, default: false], + link_target: [type: :string, required: false], + action_intent: [type: :atom, required: false], + summary: [type: :string, required: false] + ), + template_container( + :artifact_row_template, + :artifact_row, + @row_artifact_family, + title: [type: :string, required: true], + meta: [type: :any, required: false], + row_identity: [type: :any, required: true], + active?: [type: :boolean, required: false, default: false], + link_target: [type: :string, required: false], + action_intent: [type: :atom, required: false], + summary: [type: :string, required: false] + ), + template_container( + :event_callout_template, + :event_callout, + @layer_family, + tone: [type: :atom, required: false, default: :info], + eyebrow: [type: :string, required: false], + title: [type: :string, required: false], + message: [type: :string, required: true], + action_intent: [type: :atom, required: false], + summary: [type: :string, required: false] + ) + ] + ], + schema: + EntitySchema.widget( + repeat_binding: [type: :atom, required: true], + row_scope: [type: :atom, required: false, default: :row], + row_fields: [type: :any, required: false, default: []], + template_identity: [type: :atom, required: false], + identity_strategy: [ + type: {:in, [:row_identity, :index, :stable_hash]}, + required: false, + default: :row_identity + ], + summary: [type: :string, required: false] + ) + } + end + + defp template_container(name, kind, family, extra_schema) do + %Spark.Dsl.Entity{ + name: name, + target: Node, + args: [:id], + identifier: :id, + recursive_as: :children, + auto_set_fields: [family: family, kind: kind], + entities: [children: Foundational.entities()], + schema: EntitySchema.widget(extra_schema) + } + end end diff --git a/packages/unified-ui/lib/unified_ui/dsl/node.ex b/packages/unified-ui/lib/unified_ui/dsl/node.ex index 53c16216..e6962442 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/node.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/node.ex @@ -21,6 +21,7 @@ defmodule UnifiedUi.Dsl.Node do | :workflow_progress_and_status | :layer_shell_and_callout | :redline_and_code + | :composition_behavior @type t :: %__MODULE__{ __identifier__: atom() | nil, @@ -178,6 +179,11 @@ defmodule UnifiedUi.Dsl.Node do language: atom() | String.t() | nil, tokens: list() | nil, text_safety: atom() | nil, + repeat_binding: atom() | nil, + row_scope: atom() | nil, + row_fields: list() | nil, + template_identity: atom() | nil, + identity_strategy: atom() | nil, children: [t()] } @@ -336,6 +342,11 @@ defmodule UnifiedUi.Dsl.Node do language: nil, tokens: nil, text_safety: nil, + repeat_binding: nil, + row_scope: nil, + row_fields: nil, + template_identity: nil, + identity_strategy: nil, children: [] @spec summary(t()) :: map() @@ -427,6 +438,11 @@ defmodule UnifiedUi.Dsl.Node do language: node.language, tokens: node.tokens, text_safety: node.text_safety, + repeat_binding: node.repeat_binding, + row_scope: node.row_scope, + row_fields: node.row_fields, + template_identity: node.template_identity, + identity_strategy: node.identity_strategy, current: node.current, minimum: node.minimum, maximum: node.maximum, diff --git a/packages/unified-ui/lib/unified_ui/dsl/placement.ex b/packages/unified-ui/lib/unified_ui/dsl/placement.ex index a4507209..ebdec182 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/placement.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/placement.ex @@ -135,7 +135,8 @@ defmodule UnifiedUi.Dsl.Placement do :artifact_row, :sticky_frosted_header, :slide_over_panel, - :event_callout + :event_callout, + :list_repeat ] @spec section_boundaries() :: %{atom() => [atom()]} diff --git a/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex b/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex index f98b7cb3..4a0643a3 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex @@ -4,6 +4,7 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do use Spark.Dsl.Verifier alias Spark.Dsl.Verifier + alias UnifiedUi.Binding alias UnifiedUi.Dsl.Node @heading_segment_types [:text, :emphasis] @@ -13,16 +14,22 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do def verify(dsl) do module = Verifier.get_persisted(dsl, :module) + bindings = + dsl + |> Verifier.get_entities([:signals]) + |> Enum.filter(&match?(%Binding{}, &1)) + |> Map.new(&{&1.id, &1}) + dsl |> Verifier.get_entities([:composition]) |> Enum.filter(&match?(%Node{}, &1)) |> flatten_nodes() - |> validate_nodes(module) + |> validate_nodes(module, bindings) end - defp validate_nodes(nodes, module) do + defp validate_nodes(nodes, module, bindings) do Enum.reduce_while(nodes, :ok, fn node, :ok -> - case validate_node(node) do + case validate_node_with_context(node, bindings) do :ok -> {:cont, :ok} @@ -32,6 +39,12 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do end) end + defp validate_node_with_context(node, bindings) do + with :ok <- validate_node(node) do + validate_repeat_binding(node, bindings) + end + end + @doc false @spec validate_node(Node.t()) :: :ok | {:error, [term()], String.t()} def validate_node(%Node{kind: :inline_rich_text_heading, id: id, segments: segments}) do @@ -216,8 +229,62 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do end end + def validate_node(%Node{ + kind: :list_repeat, + id: id, + repeat_binding: repeat_binding, + row_scope: row_scope, + row_fields: row_fields, + children: children + }) do + cond do + not is_atom(repeat_binding) -> + {:error, [:composition, :list_repeat, id], + "list_repeat #{inspect(id)} repeat_binding must reference a declared collection data_binding"} + + not is_atom(row_scope) -> + {:error, [:composition, :list_repeat, id], + "list_repeat #{inspect(id)} row_scope must be an atom"} + + not valid_row_fields?(row_fields) -> + {:error, [:composition, :list_repeat, id], + "list_repeat #{inspect(id)} row_fields must be shallow atom or string field names without host-specific path syntax"} + + length(children) != 1 -> + {:error, [:composition, :list_repeat, id], + "list_repeat #{inspect(id)} must declare exactly one child template"} + + not valid_repeat_template?(List.first(children)) -> + {:error, [:composition, :list_repeat, id], + "list_repeat #{inspect(id)} child template must be an artifact, row, or event-callout component"} + + true -> + :ok + end + end + def validate_node(_node), do: :ok + defp validate_repeat_binding( + %Node{kind: :list_repeat, id: id, repeat_binding: repeat_binding}, + bindings + ) do + cond do + not Map.has_key?(bindings, repeat_binding) -> + {:error, [:composition, :list_repeat, id], + "list_repeat #{inspect(id)} repeat_binding #{inspect(repeat_binding)} must reference a declared data_binding"} + + not Map.fetch!(bindings, repeat_binding).collection? -> + {:error, [:composition, :list_repeat, id], + "list_repeat #{inspect(id)} repeat_binding #{inspect(repeat_binding)} must reference a collection data_binding"} + + true -> + :ok + end + end + + defp validate_repeat_binding(_node, _bindings), do: :ok + defp valid_heading_segments?(segments) when is_list(segments) and segments != [] do Enum.all?(segments, &valid_heading_segment?/1) end @@ -331,6 +398,27 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do defp valid_code_tokens?(_tokens), do: false + defp valid_row_fields?(fields) when is_list(fields) do + Enum.all?(fields, fn + field when is_atom(field) -> + true + + field when is_binary(field) -> + field != "" and not String.contains?(field, [".", "[", "]", "->", "::"]) + + _other -> + false + end) + end + + defp valid_row_fields?(_fields), do: false + + defp valid_repeat_template?(%Node{kind: kind}) do + kind in [:artifact_row, :list_item_multi_column, :event_callout] + end + + defp valid_repeat_template?(_node), do: false + defp valid_index?(index, items) when is_integer(index) and is_list(items) do index >= 0 and index < length(items) end diff --git a/packages/unified-ui/lib/unified_ui/tooling.ex b/packages/unified-ui/lib/unified_ui/tooling.ex index 6cf9992f..8b36072b 100644 --- a/packages/unified-ui/lib/unified_ui/tooling.ex +++ b/packages/unified-ui/lib/unified_ui/tooling.ex @@ -53,6 +53,11 @@ defmodule UnifiedUi.Tooling do ".spec/specs/unified-ui/widgets.spec.md", ".spec/specs/unified-ui/widget_components.spec.md" ], + composition_behavior: [ + ".spec/specs/unified-ui/widgets.spec.md", + ".spec/specs/unified-ui/widget_components.spec.md", + ".spec/specs/unified-ui/signals.spec.md" + ], layout: [".spec/specs/unified-ui/display_systems.spec.md"], display: [".spec/specs/unified-ui/display_systems.spec.md"], overlay: [".spec/specs/unified-ui/display_systems.spec.md"], diff --git a/packages/unified-ui/lib/unified_ui/widgets.ex b/packages/unified-ui/lib/unified_ui/widgets.ex index 46ca60d6..00b96237 100644 --- a/packages/unified-ui/lib/unified_ui/widgets.ex +++ b/packages/unified-ui/lib/unified_ui/widgets.ex @@ -67,6 +67,11 @@ defmodule UnifiedUi.Widgets do WidgetComponentEntities.redline_code_kinds() end + @spec composition_behavior_component_kinds() :: [atom()] + def composition_behavior_component_kinds do + WidgetComponentEntities.composition_behavior_kinds() + end + @spec component_catalog() :: [WidgetComponents.component()] def component_catalog do WidgetComponents.catalog() @@ -100,6 +105,7 @@ defmodule UnifiedUi.Widgets do row_artifact_component_kinds() ++ workflow_component_kinds() ++ layer_callout_component_kinds() ++ - redline_code_component_kinds() + redline_code_component_kinds() ++ + composition_behavior_component_kinds() end end diff --git a/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs b/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs index 327f7a0b..fb9d41a0 100644 --- a/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs +++ b/packages/unified-ui/test/unified_ui/advanced_widget_families_test.exs @@ -180,7 +180,8 @@ defmodule UnifiedUi.AdvancedWidgetFamiliesTest do :slide_over_panel, :event_callout, :redline_inline, - :code_block_syntax_highlighted + :code_block_syntax_highlighted, + :list_repeat ] end diff --git a/packages/unified-ui/test/unified_ui/list_repeat_components_test.exs b/packages/unified-ui/test/unified_ui/list_repeat_components_test.exs new file mode 100644 index 00000000..e0fd59b4 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/list_repeat_components_test.exs @@ -0,0 +1,229 @@ +defmodule UnifiedUi.ListRepeatComponentsTest do + use ExUnit.Case, async: true + + alias Spark.Dsl.Extension + alias UnifiedUi.Dsl.Node + alias UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents + + defmodule RepeatScreen do + use UnifiedUi.Dsl + + identity do + id(:repeat_screen) + authored_ref([:examples, :repeat_screen]) + end + + signals do + data_binding do + id(:artifact_rows) + path([:artifacts]) + collection?(true) + end + + data_binding do + id(:event_rows) + path([:events]) + collection?(true) + end + end + + composition do + root(:repeat_root) + mode(:screen) + + list_repeat :artifact_repeat do + repeat_binding(:artifact_rows) + row_scope(:artifact) + row_fields([:id, :title, :status]) + template_identity(:artifact_row_template) + identity_strategy(:row_identity) + + artifact_row_template :artifact_template do + title("Artifact template") + meta(%{status: :row_scoped}) + row_identity(:id) + end + end + + list_repeat :event_repeat do + repeat_binding(:event_rows) + row_scope(:event) + row_fields([:id, :message, :tone]) + template_identity(:event_callout_template) + + event_callout_template :event_template do + tone(:info) + message("Event template") + end + end + end + end + + test "registers list-repeat as a composition behavior component" do + assert UnifiedUi.Widgets.composition_behavior_component_kinds() == [:list_repeat] + assert :list_repeat in UnifiedUi.Widgets.kinds() + assert :list_repeat in UnifiedUi.Widgets.component_kinds() + end + + test "stores repeat metadata and exactly one child template per repeat" do + [artifact_repeat, event_repeat] = Extension.get_entities(RepeatScreen, [:composition]) + + assert {artifact_repeat.family, artifact_repeat.kind, artifact_repeat.repeat_binding, + artifact_repeat.row_scope, artifact_repeat.identity_strategy} == + {:composition_behavior, :list_repeat, :artifact_rows, :artifact, :row_identity} + + assert Enum.map(artifact_repeat.children, & &1.kind) == [:artifact_row] + + assert {event_repeat.repeat_binding, event_repeat.row_fields, event_repeat.template_identity} == + {:event_rows, [:id, :message, :tone], :event_callout_template} + + assert Enum.map(event_repeat.children, & &1.kind) == [:event_callout] + end + + test "summarizes repeat inspection metadata deterministically" do + summary = + RepeatScreen + |> UnifiedUi.Info.composition_summary() + |> Map.new(&{&1.id, &1}) + + assert %{ + family: :composition_behavior, + kind: :list_repeat, + repeat_binding: :artifact_rows, + row_scope: :artifact, + row_fields: [:id, :title, :status], + template_identity: :artifact_row_template, + identity_strategy: :row_identity, + children: [ + %{ + id: :artifact_template, + family: :row_and_artifact, + kind: :artifact_row, + row_identity: :id + } + ] + } = summary.artifact_repeat + + assert %{ + repeat_binding: :event_rows, + row_scope: :event, + children: [%{kind: :event_callout, message: "Event template"}] + } = summary.event_repeat + end + + test "validates repeat structure before binding context" do + assert {:error, [:composition, :list_repeat, :bad_repeat], child_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :list_repeat, + id: :bad_repeat, + repeat_binding: :rows, + row_scope: :row, + row_fields: [:id], + children: [] + }) + + assert child_message =~ "must declare exactly one child template" + + assert {:error, [:composition, :list_repeat, :deep_repeat], field_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :list_repeat, + id: :deep_repeat, + repeat_binding: :rows, + row_scope: :row, + row_fields: ["user.name"], + children: [%Node{kind: :artifact_row}] + }) + + assert field_message =~ "without host-specific path syntax" + end + + test "rejects missing and non-collection repeat bindings at compile time" do + assert_compile_dsl_error( + """ + identity do + id(:missing_repeat_binding_screen) + authored_ref([:examples, :missing_repeat_binding_screen]) + end + + composition do + root(:missing_repeat_binding_root) + + list_repeat :missing_repeat do + repeat_binding(:missing_rows) + row_fields([:id]) + + artifact_row_template :template do + title("Template") + row_identity(:id) + end + end + end + """, + "repeat_binding :missing_rows must reference a declared data_binding" + ) + + assert_compile_dsl_error( + """ + identity do + id(:scalar_repeat_binding_screen) + authored_ref([:examples, :scalar_repeat_binding_screen]) + end + + signals do + data_binding do + id(:profile) + path([:profile]) + collection?(false) + end + end + + composition do + root(:scalar_repeat_binding_root) + + list_repeat :scalar_repeat do + repeat_binding(:profile) + row_fields([:id]) + + artifact_row_template :template do + title("Template") + row_identity(:id) + end + end + end + """, + "repeat_binding :profile must reference a collection data_binding" + ) + end + + test "tooling links repeat behavior to widget and signal specs" do + {:ok, report} = UnifiedUi.Tooling.inspect_module(RepeatScreen) + + assert :composition_behavior in report.construct_families + assert ".spec/specs/unified-ui/widget_components.spec.md" in report.related_specs + assert ".spec/specs/unified-ui/signals.spec.md" in report.related_specs + end + + defp compile_module(body) do + module_name = "Generated#{System.unique_integer([:positive])}" + + Code.compile_string(""" + defmodule UnifiedUi.ListRepeatComponentsTest.#{module_name} do + use UnifiedUi.Dsl + + #{body} + end + """) + end + + defp assert_compile_dsl_error(body, expected_message) do + {pid, ref} = spawn_monitor(fn -> compile_module(body) end) + + receive do + {:DOWN, ^ref, :process, ^pid, :normal} -> + flunk("expected authored module compilation to fail, but it succeeded") + + {:DOWN, ^ref, :process, ^pid, reason} -> + assert Exception.format_exit(reason) =~ expected_message + end + end +end diff --git a/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs b/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs index 664b18a8..98060ebd 100644 --- a/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs +++ b/packages/unified-ui/test/unified_ui/phase_2_integration_test.exs @@ -315,7 +315,8 @@ defmodule UnifiedUi.Phase2IntegrationTest do :slide_over_panel, :event_callout, :redline_inline, - :code_block_syntax_highlighted + :code_block_syntax_highlighted, + :list_repeat ] assert UnifiedUi.Layout.kinds() == [:box, :row, :column, :grid, :stack] diff --git a/packages/unified-ui/test/unified_ui/reference_test.exs b/packages/unified-ui/test/unified_ui/reference_test.exs index 8fdf2b78..a77f9f70 100644 --- a/packages/unified-ui/test/unified_ui/reference_test.exs +++ b/packages/unified-ui/test/unified_ui/reference_test.exs @@ -37,7 +37,8 @@ defmodule UnifiedUi.ReferenceTest do :row_and_artifact, :workflow_progress_and_status, :layer_shell_and_callout, - :redline_and_code + :redline_and_code, + :composition_behavior ] assert UnifiedUi.Reference.construct_families().signals == [ From 3892cf655854e05eaec221b5e5b749993b1384a7 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 11:06:37 -0400 Subject: [PATCH 06/21] Add widget component phase 1 integration tests --- ...atalog-and-unified-ui-authoring-surface.md | 22 +- ...et_components_phase_1_integration_test.exs | 227 ++++++++++++++++++ 2 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 packages/unified-ui/test/unified_ui/widget_components_phase_1_integration_test.exs diff --git a/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md b/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md index 0c18d248..117e222a 100644 --- a/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md +++ b/.spec/planning/canonical_widget_components/phase-01-canonical-catalog-and-unified-ui-authoring-surface.md @@ -18,9 +18,9 @@ Back to index: [README](./README.md) output. - List-repeat behavior is composition behavior, not a widget renderer escape hatch. -- All work in this phase is not done. +- All work in this phase is done. -[ ] 1 Phase 1 - Canonical Catalog and UnifiedUi Authoring Surface +[x] 1 Phase 1 - Canonical Catalog and UnifiedUi Authoring Surface Define the portable catalog and authored DSL surface for the PR 79-98 widget additions so UnifiedUi can express the new components and list-repeat behavior without depending on AshUi or runtime package APIs. @@ -143,22 +143,22 @@ Back to index: [README](./README.md) [x] 1.5.2.2 Subtask - Add diagnostics for missing list bindings, malformed row fields, and repeat use on single-child composition. [x] 1.5.2.3 Subtask - Add authoring examples that show repeated artifact rows and repeated event callouts. - [ ] 1.6 Section - Phase 1 Integration Tests + [x] 1.6 Section - Phase 1 Integration Tests Validate the authored DSL surface, alias policy, diagnostics, and repeat authoring before compiler and IUR work begins. - [ ] 1.6.1 Task - Catalog authoring scenarios + [x] 1.6.1 Task - Catalog authoring scenarios Verify representative components from each family compile through the authored DSL validation layer. - [ ] 1.6.1.1 Subtask - Verify content, identity, row, workflow, layer, form, composer, redline, and code widgets are accepted with valid authored fields. - [ ] 1.6.1.2 Subtask - Verify malformed fields are rejected with diagnostics that name the canonical widget and field. - [ ] 1.6.1.3 Subtask - Verify host-specific aliases, if accepted, are reported as aliases rather than canonical names. + [x] 1.6.1.1 Subtask - Verify content, identity, row, workflow, layer, form, composer, redline, and code widgets are accepted with valid authored fields. + [x] 1.6.1.2 Subtask - Verify malformed fields are rejected with diagnostics that name the canonical widget and field. + [x] 1.6.1.3 Subtask - Verify host-specific aliases, if accepted, are reported as aliases rather than canonical names. - [ ] 1.6.2 Task - Repeat authoring scenarios + [x] 1.6.2 Task - Repeat authoring scenarios Verify list-repeat authoring validates data shape and exposes useful inspection output. - [ ] 1.6.2.1 Subtask - Verify a repeated artifact-row template over a list binding validates successfully. - [ ] 1.6.2.2 Subtask - Verify repeat rejects non-list bindings and missing row-scope fields. - [ ] 1.6.2.3 Subtask - Verify inspection output shows repeat metadata deterministically. + [x] 1.6.2.1 Subtask - Verify a repeated artifact-row template over a list binding validates successfully. + [x] 1.6.2.2 Subtask - Verify repeat rejects non-list bindings and missing row-scope fields. + [x] 1.6.2.3 Subtask - Verify inspection output shows repeat metadata deterministically. diff --git a/packages/unified-ui/test/unified_ui/widget_components_phase_1_integration_test.exs b/packages/unified-ui/test/unified_ui/widget_components_phase_1_integration_test.exs new file mode 100644 index 00000000..52e247f5 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/widget_components_phase_1_integration_test.exs @@ -0,0 +1,227 @@ +defmodule UnifiedUi.WidgetComponentsPhase1IntegrationTest do + use ExUnit.Case, async: true + + alias UnifiedUi.Dsl.Node + alias UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents + alias UnifiedUi.WidgetComponents + + defmodule ExpandedWidgetSurface do + use UnifiedUi.Dsl + + identity do + id(:expanded_widget_surface) + authored_ref([:examples, :expanded_widget_surface]) + end + + signals do + data_binding do + id(:artifact_rows) + path([:artifacts]) + collection?(true) + end + end + + composition do + root(:expanded_widget_root) + mode(:screen) + + inline_rich_text_heading :headline do + level(:h2) + segments([%{type: :text, value: "Widget surface"}]) + end + + avatar :owner_avatar do + initials("PC") + accessibility_label("Pascal Charbonneau") + end + + runtime_form_shell :settings_form do + fields([%{name: :title, type: :text, label: "Title"}]) + submit_intent(:save_settings) + change_intent(:validate_settings) + end + + chat_composer :composer do + send_intent(:send_message) + end + + artifact_row :artifact_row do + title("Spec artifact") + row_identity(:spec_artifact) + action_intent(:open_artifact) + end + + pipeline_stepper_horizontal :workflow_steps do + steps([ + %{id: :authored, label: "Authored", state: :done}, + %{id: :implemented, label: "Implemented", state: :active} + ]) + + active_index(1) + end + + slide_over_panel :details_panel do + accessibility_label("Details") + open?(true) + end + + redline_inline :redline do + segments([%{state: :insert, text: "new text"}]) + end + + code_block_syntax_highlighted :code do + language(:elixir) + tokens([%{type: :keyword, text: "defmodule"}]) + end + + list_repeat :artifact_repeat do + repeat_binding(:artifact_rows) + row_fields([:id, :title]) + template_identity(:artifact_template) + + artifact_row_template :artifact_template do + title("Repeated artifact") + row_identity(:id) + end + end + end + end + + test "expanded widget families validate and inspect through the authored DSL" do + {:ok, report} = UnifiedUi.Tooling.inspect_module(ExpandedWidgetSurface) + + assert report.construct_families == [ + :composition_behavior, + :content_identity_and_disclosure, + :form_control_and_composer, + :layer_shell_and_callout, + :redline_and_code, + :row_and_artifact, + :workflow_progress_and_status + ] + + kinds = + ExpandedWidgetSurface + |> UnifiedUi.Info.composition_summary() + |> Enum.flat_map(&summary_kinds/1) + + for kind <- [ + :inline_rich_text_heading, + :avatar, + :runtime_form_shell, + :chat_composer, + :artifact_row, + :pipeline_stepper_horizontal, + :slide_over_panel, + :redline_inline, + :code_block_syntax_highlighted, + :list_repeat + ] do + assert kind in kinds + end + end + + test "malformed representative widget fields report canonical diagnostics" do + assert {:error, [:composition, :inline_rich_text_heading, :bad_heading], heading_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :inline_rich_text_heading, + id: :bad_heading, + segments: [%{type: :html, value: "bad"}] + }) + + assert heading_message =~ "inline_rich_text_heading" + + assert {:error, [:composition, :segmented_button_group, :bad_segmented], segmented_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :segmented_button_group, + id: :bad_segmented, + options: [] + }) + + assert segmented_message =~ "segmented_button_group" + + assert {:error, [:composition, :redline_inline, :bad_redline], redline_message} = + ValidateWidgetComponents.validate_node(%Node{ + kind: :redline_inline, + id: :bad_redline, + segments: [%{state: :markup, text: "bad"}] + }) + + assert redline_message =~ "redline_inline" + end + + test "host-specific aliases remain catalog diagnostics rather than authored canonical names" do + assert WidgetComponents.name_diagnostic(:phoenix_form) == %{ + status: :alias, + name: :phoenix_form, + canonical: :runtime_form_shell, + family: :form_control_and_composer, + message: + ":phoenix_form is an AshUi compatibility alias; use :runtime_form_shell for canonical UnifiedUi authoring." + } + end + + test "repeat rejects non-list bindings during authored validation" do + assert_compile_dsl_error( + """ + identity do + id(:invalid_repeat_screen) + authored_ref([:examples, :invalid_repeat_screen]) + end + + signals do + data_binding do + id(:profile) + path([:profile]) + collection?(false) + end + end + + composition do + root(:invalid_repeat_root) + + list_repeat :profile_repeat do + repeat_binding(:profile) + row_fields([:id]) + + artifact_row_template :template do + title("Template") + row_identity(:id) + end + end + end + """, + "repeat_binding :profile must reference a collection data_binding" + ) + end + + defp summary_kinds(%{kind: kind, children: children}) do + [kind | Enum.flat_map(children, &summary_kinds/1)] + end + + defp summary_kinds(%{kind: kind}), do: [kind] + + defp compile_module(body) do + module_name = "Generated#{System.unique_integer([:positive])}" + + Code.compile_string(""" + defmodule UnifiedUi.WidgetComponentsPhase1IntegrationTest.#{module_name} do + use UnifiedUi.Dsl + + #{body} + end + """) + end + + defp assert_compile_dsl_error(body, expected_message) do + {pid, ref} = spawn_monitor(fn -> compile_module(body) end) + + receive do + {:DOWN, ^ref, :process, ^pid, :normal} -> + flunk("expected authored module compilation to fail, but it succeeded") + + {:DOWN, ^ref, :process, ^pid, reason} -> + assert Exception.format_exit(reason) =~ expected_message + end + end +end From 7683b22668dcaf1fb97e5f047aa534c5be6c30c8 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 11:15:22 -0400 Subject: [PATCH 07/21] Implement UnifiedIUR component models --- ...tation-compiler-and-hydration-alignment.md | 26 +- .../unified-iur/widget_components.spec.md | 13 +- .spec/specs/unified-iur/widgets.spec.md | 9 +- .../unified_iur/lib/unified_iur/widgets.ex | 9 +- .../lib/unified_iur/widgets/components.ex | 569 ++++++++++++++++++ .../unified_iur/widgets/components_test.exs | 289 +++++++++ .../test/unified_iur/widgets_test.exs | 4 +- .../unified_iur/test/unified_iur_test.exs | 1 + 8 files changed, 903 insertions(+), 17 deletions(-) create mode 100644 packages/unified_iur/lib/unified_iur/widgets/components.ex create mode 100644 packages/unified_iur/test/unified_iur/widgets/components_test.exs diff --git a/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md b/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md index 0092802c..b392502e 100644 --- a/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md +++ b/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md @@ -24,33 +24,33 @@ Back to index: [README](./README.md) Add canonical IUR representation and compiler lowering for the expanded widget-component catalog and list-repeat composition behavior. - [ ] 2.1 Section - UnifiedIUR Node Models and Content Contracts + [x] 2.1 Section - UnifiedIUR Node Models and Content Contracts Define the canonical element shapes, properties, children, and metadata for every widget family in the expanded catalog. - [ ] 2.1.1 Task - Implement content, identity, and disclosure IUR models + [x] 2.1.1 Task - Implement content, identity, and disclosure IUR models Represent passive content and identity widgets without losing accessibility or state meaning. - [ ] 2.1.1.1 Subtask - Represent inline rich heading levels and inline `text` or `emphasis` segments. - [ ] 2.1.1.2 Subtask - Represent kicker item order, separator behavior, avatar identity fields, and presence state. - [ ] 2.1.1.3 Subtask - Represent disclosure summary, initial open state, and child body content. + [x] 2.1.1.1 Subtask - Represent inline rich heading levels and inline `text` or `emphasis` segments. + [x] 2.1.1.2 Subtask - Represent kicker item order, separator behavior, avatar identity fields, and presence state. + [x] 2.1.1.3 Subtask - Represent disclosure summary, initial open state, and child body content. - [ ] 2.1.2 Task - Implement form, control, row, and composer IUR models + [x] 2.1.2 Task - Implement form, control, row, and composer IUR models Represent interactive controls while keeping interaction descriptors renderer-independent. - [ ] 2.1.2.1 Subtask - Represent segmented control options, active state, disabled state, and selection intent. - [ ] 2.1.2.2 Subtask - Represent runtime-owned form fields, submit/change intent, labels, validation metadata, and host adapter hints. - [ ] 2.1.2.3 Subtask - Represent list rows, artifact rows, and chat composer children, row identity, active state, and event metadata. + [x] 2.1.2.1 Subtask - Represent segmented control options, active state, disabled state, and selection intent. + [x] 2.1.2.2 Subtask - Represent runtime-owned form fields, submit/change intent, labels, validation metadata, and host adapter hints. + [x] 2.1.2.3 Subtask - Represent list rows, artifact rows, and chat composer children, row identity, active state, and event metadata. - [ ] 2.1.3 Task - Implement progress, layer, callout, redline, and code IUR models + [x] 2.1.3 Task - Implement progress, layer, callout, redline, and code IUR models Represent the operational and text-specialized widgets with enough structure for renderer parity. - [ ] 2.1.3.1 Subtask - Represent steppers, segmented progress, vertical stages, and thin meters with normalized values and labels. - [ ] 2.1.3.2 Subtask - Represent sticky headers, slide-over panels, and event callouts with child placement, state, size, and tone metadata. - [ ] 2.1.3.3 Subtask - Represent redline and pre-tokenized code segments as plain text plus semantic segment or token types. + [x] 2.1.3.1 Subtask - Represent steppers, segmented progress, vertical stages, and thin meters with normalized values and labels. + [x] 2.1.3.2 Subtask - Represent sticky headers, slide-over panels, and event callouts with child placement, state, size, and tone metadata. + [x] 2.1.3.3 Subtask - Represent redline and pre-tokenized code segments as plain text plus semantic segment or token types. [ ] 2.2 Section - Compiler Lowering and Canonical Interaction Descriptors Lower UnifiedUi authored declarations into deterministic IUR nodes and diff --git a/.spec/specs/unified-iur/widget_components.spec.md b/.spec/specs/unified-iur/widget_components.spec.md index 706d7c6e..c5f81842 100644 --- a/.spec/specs/unified-iur/widget_components.spec.md +++ b/.spec/specs/unified-iur/widget_components.spec.md @@ -18,6 +18,7 @@ status: active summary: Canonical IUR contract for expanded widget components and list-repeat composition behavior derived from AshUi PRs 79 through 98. surface: - packages/unified_iur + - packages/unified_iur/lib/unified_iur/widgets/components.ex - .spec/specs/unified-iur/widget_components.spec.md - .spec/specs/unified-ui/widget_components.spec.md decisions: @@ -29,7 +30,7 @@ decisions: ```spec-requirements - id: unified_iur.widget_components.canonical_node_types - statement: UnifiedIUR shall represent canonical node types or equivalent semantic variants for the expanded widget catalog, including rich headings, disclosure, runtime-owned forms, kicker labels, avatars, presence indicators, segmented controls, row primitives, progress and stage displays, shell surfaces, callouts, redlines, code blocks, chat composers, and repeated child composition. + statement: UnifiedIUR shall expose canonical node types or equivalent semantic variants for the expanded widget catalog through `UnifiedIUR.Widgets.Components`, including rich headings, disclosure, runtime-owned forms, kicker labels, avatars, presence indicators, segmented controls, row primitives, progress and stage displays, shell surfaces, callouts, redlines, code blocks, chat composers, and repeated child composition. priority: must stability: stable @@ -98,6 +99,16 @@ decisions: ## Verification ```spec-verification +- kind: source_file + target: packages/unified_iur/lib/unified_iur/widgets/components.ex + covers: + - unified_iur.widget_components.canonical_node_types + - unified_iur.widget_components.content_models + - unified_iur.widget_components.interaction_descriptors + - unified_iur.widget_components.accessibility_and_state_metadata + - unified_iur.widget_components.text_safety_contract + - unified_iur.widget_components.runtime_mapping_completeness + - kind: source_file target: .spec/specs/unified-iur/widget_components.spec.md covers: diff --git a/.spec/specs/unified-iur/widgets.spec.md b/.spec/specs/unified-iur/widgets.spec.md index 57ebcc34..8d9c66ec 100644 --- a/.spec/specs/unified-iur/widgets.spec.md +++ b/.spec/specs/unified-iur/widgets.spec.md @@ -16,6 +16,7 @@ status: proposed summary: Canonical widget families representable in `unified_iur`. surface: - packages/unified_iur + - packages/unified_iur/lib/unified_iur/widgets/components.ex - .spec/specs/unified-iur/widgets.spec.md - .spec/specs/unified-ui/widgets.spec.md decisions: @@ -47,7 +48,7 @@ decisions: stability: stable - id: unified_iur.widgets.expanded_widget_component_catalog - statement: `unified_iur` shall be able to represent the expanded canonical widget-component catalog and list-repeat composition behavior defined by `unified_iur.widget_components` without reducing those constructs to AshUi-specific or renderer-specific placeholders. + statement: `unified_iur` shall expose expanded canonical widget-component constructors through `UnifiedIUR.Widgets.Components` and shall be able to represent the expanded catalog and list-repeat composition behavior defined by `unified_iur.widget_components` without reducing those constructs to AshUi-specific or renderer-specific placeholders. priority: must stability: stable @@ -70,6 +71,12 @@ decisions: ## Verification ```spec-verification +- kind: source_file + target: packages/unified_iur/lib/unified_iur/widgets/components.ex + covers: + - unified_iur.widgets.expanded_widget_component_catalog + - unified_iur.widgets.widget_semantics_preserved + - kind: source_file target: .spec/specs/unified-iur/widgets.spec.md covers: diff --git a/packages/unified_iur/lib/unified_iur/widgets.ex b/packages/unified_iur/lib/unified_iur/widgets.ex index 18350b2e..f20e448c 100644 --- a/packages/unified_iur/lib/unified_iur/widgets.ex +++ b/packages/unified_iur/lib/unified_iur/widgets.ex @@ -3,7 +3,7 @@ defmodule UnifiedIUR.Widgets do Reference surface for canonical widget constructors exposed by `UnifiedIUR`. """ - alias UnifiedIUR.Widgets.{Advanced, Data, Feedback, Foundational, Input, Navigation} + alias UnifiedIUR.Widgets.{Advanced, Components, Data, Feedback, Foundational, Input, Navigation} @foundational_kinds [ :text, @@ -46,6 +46,7 @@ defmodule UnifiedIUR.Widgets do @spec modules() :: %{ advanced: module(), + components: module(), data: module(), feedback: module(), foundational: module(), @@ -55,6 +56,7 @@ defmodule UnifiedIUR.Widgets do def modules do %{ advanced: Advanced, + components: Components, foundational: Foundational, input: Input, navigation: Navigation, @@ -92,4 +94,9 @@ defmodule UnifiedIUR.Widgets do def advanced_kinds do @advanced_kinds end + + @spec component_kinds() :: [atom()] + def component_kinds do + Components.kinds() + end end diff --git a/packages/unified_iur/lib/unified_iur/widgets/components.ex b/packages/unified_iur/lib/unified_iur/widgets/components.ex new file mode 100644 index 00000000..da39a4ab --- /dev/null +++ b/packages/unified_iur/lib/unified_iur/widgets/components.ex @@ -0,0 +1,569 @@ +defmodule UnifiedIUR.Widgets.Components do + @moduledoc """ + Canonical constructors for the expanded widget-component catalog. + + These constructors preserve the portable content, state, accessibility, and + safety shape for the AshUi PR 79-98 equivalents without embedding any runtime + package implementation model. + """ + + alias UnifiedIUR.Attachment + alias UnifiedIUR.Element + alias UnifiedIUR.Metadata + + # Spec traceability: + # unified_iur.widget_components.canonical_node_types + # unified_iur.widget_components.content_models + # unified_iur.widget_components.interaction_descriptors + # unified_iur.widget_components.accessibility_and_state_metadata + # unified_iur.widget_components.text_safety_contract + # unified_iur.widget_components.runtime_mapping_completeness + # unified_iur.widgets.expanded_widget_component_catalog + # unified_iur.widgets.widget_semantics_preserved + + @type opts :: keyword() | map() + + @content_identity_kinds [ + :inline_rich_text_heading, + :disclosure, + :kicker, + :avatar, + :presence_dot + ] + + @form_control_kinds [ + :segmented_button_group, + :runtime_form_shell, + :chat_composer + ] + + @row_artifact_kinds [ + :list_item_multi_column, + :artifact_row + ] + + @workflow_kinds [ + :pipeline_stepper_horizontal, + :segmented_progress_bar, + :workflow_stage_list_vertical, + :meter_thin + ] + + @layer_callout_kinds [ + :sticky_frosted_header, + :slide_over_panel, + :event_callout + ] + + @redline_code_kinds [ + :redline_inline, + :code_block_syntax_highlighted + ] + + @spec content_identity_kinds() :: [atom()] + def content_identity_kinds, do: @content_identity_kinds + + @spec form_control_kinds() :: [atom()] + def form_control_kinds, do: @form_control_kinds + + @spec row_artifact_kinds() :: [atom()] + def row_artifact_kinds, do: @row_artifact_kinds + + @spec workflow_kinds() :: [atom()] + def workflow_kinds, do: @workflow_kinds + + @spec layer_callout_kinds() :: [atom()] + def layer_callout_kinds, do: @layer_callout_kinds + + @spec redline_code_kinds() :: [atom()] + def redline_code_kinds, do: @redline_code_kinds + + @spec kinds() :: [atom()] + def kinds do + @content_identity_kinds ++ + @form_control_kinds ++ + @row_artifact_kinds ++ + @workflow_kinds ++ + @layer_callout_kinds ++ + @redline_code_kinds + end + + @spec inline_rich_text_heading(atom(), [keyword() | map()], opts()) :: Element.t() + def inline_rich_text_heading(level, segments, opts \\ []) + when is_atom(level) and is_list(segments) do + build_component( + :inline_rich_text_heading, + :content_identity_and_disclosure, + %{heading: %{level: level, segments: normalize_maps(segments)}}, + opts + ) + end + + @spec disclosure( + String.t(), + [Element.t() | Element.Child.t() | {atom(), Element.t()} | map()], + opts() + ) :: + Element.t() + def disclosure(summary, children \\ [], opts \\ []) + when is_binary(summary) and is_list(children) do + opts = normalize_opts(opts) + + build_component( + :disclosure, + :content_identity_and_disclosure, + %{disclosure: %{summary: summary, open?: option(opts, :open?, false)}}, + Map.put(opts, :children, children) + ) + end + + @spec kicker([String.t()], opts()) :: Element.t() + def kicker(items, opts \\ []) when is_list(items) do + opts = normalize_opts(opts) + + build_component( + :kicker, + :content_identity_and_disclosure, + %{kicker: %{items: items, separator: option(opts, :separator, "·")}}, + opts + ) + end + + @spec avatar(opts()) :: Element.t() + def avatar(opts \\ []) do + opts = normalize_opts(opts) + + build_component( + :avatar, + :content_identity_and_disclosure, + %{ + identity: + %{} + |> maybe_put(:initials, option(opts, :initials)) + |> maybe_put(:image_source, option(opts, :image_source)) + |> maybe_put(:size, option(opts, :size, :medium)) + |> maybe_put(:shape, option(opts, :shape, :round)) + }, + opts + ) + end + + @spec presence_dot(atom(), opts()) :: Element.t() + def presence_dot(state, opts \\ []) when is_atom(state) do + opts = normalize_opts(opts) + + build_component( + :presence_dot, + :content_identity_and_disclosure, + %{presence: %{state: state, size: option(opts, :size, :medium)}}, + opts + ) + end + + @spec segmented_button_group([keyword() | map()], opts()) :: Element.t() + def segmented_button_group(options, opts \\ []) when is_list(options) do + opts = normalize_opts(opts) + + build_component( + :segmented_button_group, + :form_control_and_composer, + %{ + selection: + %{ + presentation: :segmented_button_group, + multiple?: false, + options: normalize_options(options) + } + |> maybe_put(:active_value, option(opts, :active_value)) + |> maybe_put(:selection_intent, option(opts, :selection_intent)) + }, + opts + ) + end + + @spec runtime_form_shell([keyword() | map()], opts()) :: Element.t() + def runtime_form_shell(fields, opts \\ []) when is_list(fields) do + opts = normalize_opts(opts) + + build_component( + :runtime_form_shell, + :form_control_and_composer, + %{ + form: + %{ + fields: normalize_maps(fields) + } + |> maybe_put(:submit_label, option(opts, :submit_label)) + |> maybe_put(:submit_intent, option(opts, :submit_intent)) + |> maybe_put(:change_intent, option(opts, :change_intent)) + |> maybe_put(:validation_state, option(opts, :validation_state)) + |> maybe_put( + :host_adapter_hints, + normalize_optional_map(option(opts, :host_adapter_hints)) + ) + }, + opts + ) + end + + @spec chat_composer([Element.t() | Element.Child.t() | {atom(), Element.t()} | map()], opts()) :: + Element.t() + def chat_composer(children \\ [], opts \\ []) when is_list(children) do + opts = normalize_opts(opts) + + build_component( + :chat_composer, + :form_control_and_composer, + %{ + composer: + %{} + |> maybe_put(:name, option(opts, :name)) + |> maybe_put(:value, option(opts, :value)) + |> maybe_put(:placeholder, option(opts, :placeholder)) + |> maybe_put(:rows, option(opts, :rows, 3)) + |> maybe_put(:send_label, option(opts, :send_label, "Send")) + |> maybe_put(:send_intent, option(opts, :send_intent)) + |> maybe_put(:change_intent, option(opts, :change_intent)) + }, + Map.put(opts, :children, children) + ) + end + + @spec list_item_multi_column( + [Element.t() | Element.Child.t() | {atom(), Element.t()} | map()], + opts() + ) :: + Element.t() + def list_item_multi_column(children \\ [], opts \\ []) when is_list(children) do + opts = normalize_opts(opts) + + build_component( + :list_item_multi_column, + :row_and_artifact, + %{ + row: + common_row_attrs(opts) + |> maybe_put(:column_template, normalize_maps(option(opts, :column_template, []))) + }, + Map.put(opts, :children, children) + ) + end + + @spec artifact_row( + String.t(), + [Element.t() | Element.Child.t() | {atom(), Element.t()} | map()], + opts() + ) :: + Element.t() + def artifact_row(title, children \\ [], opts \\ []) + when is_binary(title) and is_list(children) do + opts = normalize_opts(opts) + + build_component( + :artifact_row, + :row_and_artifact, + %{ + artifact: + common_row_attrs(opts) + |> maybe_put(:title, title) + |> maybe_put(:meta, option(opts, :meta)) + }, + Map.put(opts, :children, children) + ) + end + + @spec pipeline_stepper_horizontal([keyword() | map()], opts()) :: Element.t() + def pipeline_stepper_horizontal(steps, opts \\ []) when is_list(steps) do + opts = normalize_opts(opts) + + build_component( + :pipeline_stepper_horizontal, + :workflow_progress_and_status, + %{ + workflow: + %{ + presentation: :pipeline_stepper_horizontal, + steps: normalize_maps(steps), + active_index: option(opts, :active_index, 0), + completed_indices: option(opts, :completed_indices, []) + } + |> maybe_put(:navigation_intent, option(opts, :navigation_intent)) + }, + opts + ) + end + + @spec segmented_progress_bar([keyword() | map()], opts()) :: Element.t() + def segmented_progress_bar(segments, opts \\ []) when is_list(segments) do + opts = normalize_opts(opts) + + build_component( + :segmented_progress_bar, + :workflow_progress_and_status, + %{ + progress: + %{ + presentation: :segmented_progress_bar, + segments: normalize_maps(segments) + } + |> maybe_put(:aggregate, normalize_optional_map(option(opts, :aggregate_progress))) + |> maybe_put(:label, option(opts, :label)) + }, + opts + ) + end + + @spec workflow_stage_list_vertical([keyword() | map()], opts()) :: Element.t() + def workflow_stage_list_vertical(stages, opts \\ []) when is_list(stages) do + opts = normalize_opts(opts) + + build_component( + :workflow_stage_list_vertical, + :workflow_progress_and_status, + %{ + workflow: %{ + presentation: :workflow_stage_list_vertical, + stages: normalize_maps(stages), + active_index: option(opts, :active_index, 0) + } + }, + opts + ) + end + + @spec meter_thin(number(), opts()) :: Element.t() + def meter_thin(current, opts \\ []) when is_number(current) do + opts = normalize_opts(opts) + + build_component( + :meter_thin, + :workflow_progress_and_status, + %{ + meter: + %{ + current: current, + minimum: option(opts, :minimum, 0), + maximum: option(opts, :maximum, 100) + } + |> maybe_put(:label, option(opts, :label)) + |> maybe_put(:state, option(opts, :state)) + }, + opts + ) + end + + @spec sticky_frosted_header( + [Element.t() | Element.Child.t() | {atom(), Element.t()} | map()], + opts() + ) :: + Element.t() + def sticky_frosted_header(children \\ [], opts \\ []) when is_list(children) do + opts = normalize_opts(opts) + + build_component( + :sticky_frosted_header, + :layer_shell_and_callout, + %{ + shell: + %{ + position: :sticky, + visual_effect: :frosted + } + |> maybe_put(:title, option(opts, :title)) + |> maybe_put(:leading, option(opts, :leading, [])) + |> maybe_put(:trailing, option(opts, :trailing, [])) + }, + Map.put(opts, :children, children) + ) + end + + @spec slide_over_panel( + [Element.t() | Element.Child.t() | {atom(), Element.t()} | map()], + opts() + ) :: + Element.t() + def slide_over_panel(children \\ [], opts \\ []) when is_list(children) do + opts = normalize_opts(opts) + + build_component( + :slide_over_panel, + :layer_shell_and_callout, + %{ + panel: + %{ + modal?: false, + open?: option(opts, :open?, false), + size: option(opts, :size, :medium) + } + |> maybe_put(:label, option(opts, :label, option(opts, :accessibility_label))) + |> maybe_put(:dismiss_intent, option(opts, :dismiss_intent)) + }, + Map.put(opts, :children, children) + ) + end + + @spec event_callout( + String.t(), + [Element.t() | Element.Child.t() | {atom(), Element.t()} | map()], + opts() + ) :: + Element.t() + def event_callout(message, children \\ [], opts \\ []) + when is_binary(message) and is_list(children) do + opts = normalize_opts(opts) + + build_component( + :event_callout, + :layer_shell_and_callout, + %{ + callout: + %{ + message: message, + tone: option(opts, :tone, :info) + } + |> maybe_put(:eyebrow, option(opts, :eyebrow)) + |> maybe_put(:title, option(opts, :title)) + |> maybe_put(:action_intent, option(opts, :action_intent)) + }, + Map.put(opts, :children, children) + ) + end + + @spec redline_inline([keyword() | map()], opts()) :: Element.t() + def redline_inline(segments, opts \\ []) when is_list(segments) do + opts = normalize_opts(opts) + + build_component( + :redline_inline, + :redline_and_code, + %{ + redline: %{segments: normalize_maps(segments)}, + text_safety: %{content: option(opts, :text_safety, :plain_text)} + }, + opts + ) + end + + @spec code_block_syntax_highlighted(atom() | String.t(), [keyword() | map()], opts()) :: + Element.t() + def code_block_syntax_highlighted(language, tokens, opts \\ []) + when (is_atom(language) or is_binary(language)) and is_list(tokens) do + opts = normalize_opts(opts) + + build_component( + :code_block_syntax_highlighted, + :redline_and_code, + %{ + code: %{language: language, tokens: normalize_maps(tokens)}, + text_safety: %{content: option(opts, :text_safety, :plain_text)} + }, + opts + ) + end + + defp build_component(kind, family, kind_attributes, opts) do + opts = normalize_opts(opts) + + Element.new(:widget, kind, + id: option(opts, :id), + metadata: normalize_metadata(opts), + attributes: + %{ + component: %{ + family: family, + kind: kind + } + } + |> merge_attributes(kind_attributes) + |> merge_attribute(:accessibility, normalize_accessibility(opts)) + |> merge_attribute(:state, normalize_state(opts)) + |> Attachment.merge(opts, + component: kind, + tone: option(opts, :tone), + local_style: option(opts, :style) + ), + children: option(opts, :children, []) + ) + end + + defp common_row_attrs(opts) do + %{} + |> maybe_put(:row_identity, option(opts, :row_identity)) + |> maybe_put(:active?, option(opts, :active?)) + |> maybe_put(:link_target, option(opts, :link_target)) + |> maybe_put(:action_intent, option(opts, :action_intent)) + end + + defp normalize_metadata(opts) do + opts + |> option(:metadata) + |> Metadata.merge(%{ + authored_ref: option(opts, :authored_ref), + description: option(opts, :description), + annotations: option(opts, :annotations, %{}), + tags: option(opts, :tags, []), + extra: option(opts, :extra, %{}) + }) + end + + defp normalize_accessibility(opts) do + opts + |> option(:accessibility, %{}) + |> normalize_map() + |> maybe_put(:label, option(opts, :accessibility_label)) + |> maybe_put(:description, option(opts, :accessibility_description)) + end + + defp normalize_state(opts) do + opts + |> option(:state, %{}) + |> normalize_state_map() + |> maybe_put(:disabled?, option(opts, :disabled?)) + |> maybe_put(:active?, option(opts, :active?)) + |> maybe_put(:open?, option(opts, :open?)) + end + + defp normalize_state_map(value) when is_map(value) or is_list(value), do: normalize_map(value) + defp normalize_state_map(_value), do: %{} + + defp normalize_options(options) do + Enum.map(options, fn option_value -> + option_value = normalize_opts(option_value) + + %{} + |> maybe_put(:value, option(option_value, :value)) + |> maybe_put(:label, option(option_value, :label)) + |> maybe_put(:disabled?, option(option_value, :disabled?)) + end) + end + + defp normalize_maps(values) when is_list(values) do + Enum.map(values, &normalize_map/1) + end + + defp normalize_maps(_values), do: [] + + defp normalize_optional_map(nil), do: nil + defp normalize_optional_map(value), do: normalize_map(value) + + defp normalize_map(nil), do: %{} + defp normalize_map(value) when is_map(value), do: Map.new(value) + defp normalize_map(value) when is_list(value), do: Enum.into(value, %{}) + + defp normalize_opts(opts) when is_list(opts), do: Enum.into(opts, %{}) + defp normalize_opts(opts) when is_map(opts), do: Map.new(opts) + + defp option(opts, key, default \\ nil) do + Map.get(opts, key, Map.get(opts, Atom.to_string(key), default)) + end + + defp merge_attributes(attributes, values) do + Enum.reduce(values, attributes, fn {key, value}, acc -> merge_attribute(acc, key, value) end) + end + + defp merge_attribute(attributes, _key, value) when value in [%{}, [], nil], do: attributes + defp merge_attribute(attributes, key, value), do: Map.put(attributes, key, value) + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) +end diff --git a/packages/unified_iur/test/unified_iur/widgets/components_test.exs b/packages/unified_iur/test/unified_iur/widgets/components_test.exs new file mode 100644 index 00000000..5904c4c1 --- /dev/null +++ b/packages/unified_iur/test/unified_iur/widgets/components_test.exs @@ -0,0 +1,289 @@ +defmodule UnifiedIUR.Widgets.ComponentsTest do + use ExUnit.Case, async: true + + alias UnifiedIUR.Element + alias UnifiedIUR.Widgets + alias UnifiedIUR.Widgets.{Components, Foundational} + + test "exposes expanded widget component constructor families" do + assert %{components: Components} = Widgets.modules() + + assert Components.content_identity_kinds() == [ + :inline_rich_text_heading, + :disclosure, + :kicker, + :avatar, + :presence_dot + ] + + assert Components.form_control_kinds() == [ + :segmented_button_group, + :runtime_form_shell, + :chat_composer + ] + + assert Components.row_artifact_kinds() == [:list_item_multi_column, :artifact_row] + + assert Components.workflow_kinds() == [ + :pipeline_stepper_horizontal, + :segmented_progress_bar, + :workflow_stage_list_vertical, + :meter_thin + ] + + assert Components.layer_callout_kinds() == [ + :sticky_frosted_header, + :slide_over_panel, + :event_callout + ] + + assert Components.redline_code_kinds() == [ + :redline_inline, + :code_block_syntax_highlighted + ] + + assert Widgets.component_kinds() == Components.kinds() + end + + test "represents content, identity, and disclosure components" do + heading = + Components.inline_rich_text_heading(:h2, [ + %{type: :text, value: "Canonical "}, + %{type: :emphasis, value: "widgets"} + ]) + + disclosure = + Components.disclosure("Advanced", [Foundational.text("Body")], + id: "details", + open?: true + ) + + kicker = Components.kicker(["Spec", "ADR"], separator: "/") + avatar = Components.avatar(initials: "PC", size: :small, accessibility_label: "Pascal") + presence = Components.presence_dot(:active, size: :small) + + assert %Element{ + kind: :inline_rich_text_heading, + attributes: %{ + component: %{family: :content_identity_and_disclosure}, + heading: %{level: :h2, segments: [%{type: :text}, %{type: :emphasis}]} + } + } = heading + + assert %Element{ + id: "details", + kind: :disclosure, + attributes: %{disclosure: %{summary: "Advanced", open?: true}}, + children: [%{slot: :default, element: %Element{kind: :text}}] + } = disclosure + + assert kicker.attributes.kicker == %{items: ["Spec", "ADR"], separator: "/"} + assert avatar.attributes.identity == %{initials: "PC", size: :small, shape: :round} + assert avatar.attributes.accessibility == %{label: "Pascal"} + assert presence.attributes.presence == %{state: :active, size: :small} + end + + test "represents form controls, rows, artifacts, and composer children" do + segmented = + Components.segmented_button_group( + [ + %{value: :all, label: "All"}, + %{value: :active, label: "Active", disabled?: true} + ], + active_value: :all, + selection_intent: :select_status, + disabled?: false + ) + + form = + Components.runtime_form_shell( + [%{name: :email, type: :email, attributes: [required: true]}], + submit_label: "Save", + submit_intent: :save, + change_intent: :validate, + validation_state: :invalid, + host_adapter_hints: %{live_ui: %{adapter: :phoenix_form}} + ) + + composer = + Components.chat_composer([Foundational.button("Attach")], + value: "Draft", + send_intent: :send_message + ) + + row = + Components.list_item_multi_column([Foundational.text("Title")], + row_identity: "row-1", + active?: true, + column_template: [%{id: :title, label: "Title"}], + action_intent: :open_row + ) + + artifact = + Components.artifact_row("ADR", [Foundational.button("Open")], + row_identity: :adr, + meta: %{status: :accepted} + ) + + assert segmented.attributes.selection == %{ + presentation: :segmented_button_group, + multiple?: false, + options: [ + %{value: :all, label: "All"}, + %{value: :active, label: "Active", disabled?: true} + ], + active_value: :all, + selection_intent: :select_status + } + + assert segmented.attributes.state == %{disabled?: false} + + assert form.attributes.form == %{ + fields: [%{name: :email, type: :email, attributes: [required: true]}], + submit_label: "Save", + submit_intent: :save, + change_intent: :validate, + validation_state: :invalid, + host_adapter_hints: %{live_ui: %{adapter: :phoenix_form}} + } + + assert composer.attributes.composer == %{ + value: "Draft", + rows: 3, + send_label: "Send", + send_intent: :send_message + } + + assert [%{element: %Element{kind: :button}}] = composer.children + + assert row.attributes.row == %{ + row_identity: "row-1", + active?: true, + action_intent: :open_row, + column_template: [%{id: :title, label: "Title"}] + } + + assert artifact.attributes.artifact == %{ + row_identity: :adr, + title: "ADR", + meta: %{status: :accepted} + } + end + + test "represents workflow, layer, callout, redline, and code components" do + stepper = + Components.pipeline_stepper_horizontal( + [ + %{id: :draft, label: "Draft", state: :done}, + %{id: :review, label: "Review", state: :active} + ], + active_index: 1, + completed_indices: [0], + navigation_intent: :select_step + ) + + progress = + Components.segmented_progress_bar( + [%{label: "Passing", weight: 8, state: :success}], + aggregate_progress: %{current: 8, maximum: 9}, + label: "Scenario health" + ) + + stages = + Components.workflow_stage_list_vertical( + [%{id: :authored, label: "Authored", state: :done}], + active_index: 0 + ) + + meter = Components.meter_thin(82.5, label: "Coverage", state: :success) + header = Components.sticky_frosted_header([], title: "Workspace", leading: [:back]) + + panel = + Components.slide_over_panel([Foundational.text("Panel")], + accessibility_label: "Details", + open?: true, + size: :wide, + dismiss_intent: :close_panel + ) + + callout = + Components.event_callout("Paused", [Foundational.button("Inspect")], + tone: :warning, + action_intent: :inspect_event + ) + + redline = Components.redline_inline([%{state: :insert, text: "new"}]) + + code = + Components.code_block_syntax_highlighted(:elixir, [ + %{type: :keyword, text: "defmodule"} + ]) + + assert stepper.attributes.workflow == %{ + presentation: :pipeline_stepper_horizontal, + steps: [ + %{id: :draft, label: "Draft", state: :done}, + %{id: :review, label: "Review", state: :active} + ], + active_index: 1, + completed_indices: [0], + navigation_intent: :select_step + } + + assert progress.attributes.progress == %{ + presentation: :segmented_progress_bar, + segments: [%{label: "Passing", weight: 8, state: :success}], + aggregate: %{current: 8, maximum: 9}, + label: "Scenario health" + } + + assert stages.attributes.workflow == %{ + presentation: :workflow_stage_list_vertical, + stages: [%{id: :authored, label: "Authored", state: :done}], + active_index: 0 + } + + assert meter.attributes.meter == %{ + current: 82.5, + minimum: 0, + maximum: 100, + label: "Coverage", + state: :success + } + + assert header.attributes.shell == %{ + position: :sticky, + visual_effect: :frosted, + title: "Workspace", + leading: [:back], + trailing: [] + } + + assert panel.attributes.panel == %{ + modal?: false, + open?: true, + size: :wide, + label: "Details", + dismiss_intent: :close_panel + } + + assert panel.attributes.accessibility == %{label: "Details"} + + assert callout.attributes.callout == %{ + message: "Paused", + tone: :warning, + action_intent: :inspect_event + } + + assert [%{element: %Element{kind: :button}}] = callout.children + assert redline.attributes.redline == %{segments: [%{state: :insert, text: "new"}]} + assert redline.attributes.text_safety == %{content: :plain_text} + + assert code.attributes.code == %{ + language: :elixir, + tokens: [%{type: :keyword, text: "defmodule"}] + } + + assert code.attributes.text_safety == %{content: :plain_text} + end +end diff --git a/packages/unified_iur/test/unified_iur/widgets_test.exs b/packages/unified_iur/test/unified_iur/widgets_test.exs index 14d6f8ce..3e94adee 100644 --- a/packages/unified_iur/test/unified_iur/widgets_test.exs +++ b/packages/unified_iur/test/unified_iur/widgets_test.exs @@ -2,11 +2,12 @@ defmodule UnifiedIUR.WidgetsTest do use ExUnit.Case, async: true alias UnifiedIUR.Widgets - alias UnifiedIUR.Widgets.{Advanced, Data, Feedback, Foundational, Input, Navigation} + alias UnifiedIUR.Widgets.{Advanced, Components, Data, Feedback, Foundational, Input, Navigation} test "exposes the foundational widget constructor family" do assert %{ advanced: Advanced, + components: Components, foundational: Foundational, input: Input, navigation: Navigation, @@ -51,5 +52,6 @@ defmodule UnifiedIUR.WidgetsTest do ] == Widgets.advanced_kinds() assert Widgets.advanced_kinds() == Advanced.kinds() + assert Widgets.component_kinds() == Components.kinds() end end diff --git a/packages/unified_iur/test/unified_iur_test.exs b/packages/unified_iur/test/unified_iur_test.exs index d0d56bd7..f12d9899 100644 --- a/packages/unified_iur/test/unified_iur_test.exs +++ b/packages/unified_iur/test/unified_iur_test.exs @@ -41,6 +41,7 @@ defmodule UnifiedIURTest do } = UnifiedIUR.Display.modules() assert %{ + components: UnifiedIUR.Widgets.Components, foundational: UnifiedIUR.Widgets.Foundational, input: UnifiedIUR.Widgets.Input } = UnifiedIUR.Widgets.modules() From d8b0f56e6d0acf8a9d8ec46d103e3a6eb771f2fa Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 11:24:16 -0400 Subject: [PATCH 08/21] Lower widget components into UnifiedIUR --- ...tation-compiler-and-hydration-alignment.md | 18 +- .spec/specs/unified-ui/compiler.spec.md | 18 ++ .../lib/unified_ui/compiler/pipeline.ex | 278 +++++++++++++++++- ...dget_components_compiler_lowering_test.exs | 241 +++++++++++++++ 4 files changed, 539 insertions(+), 16 deletions(-) create mode 100644 packages/unified-ui/test/unified_ui/widget_components_compiler_lowering_test.exs diff --git a/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md b/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md index b392502e..84501641 100644 --- a/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md +++ b/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md @@ -52,24 +52,24 @@ Back to index: [README](./README.md) [x] 2.1.3.2 Subtask - Represent sticky headers, slide-over panels, and event callouts with child placement, state, size, and tone metadata. [x] 2.1.3.3 Subtask - Represent redline and pre-tokenized code segments as plain text plus semantic segment or token types. - [ ] 2.2 Section - Compiler Lowering and Canonical Interaction Descriptors + [x] 2.2 Section - Compiler Lowering and Canonical Interaction Descriptors Lower UnifiedUi authored declarations into deterministic IUR nodes and canonical interaction descriptors. - [ ] 2.2.1 Task - Lower widget declarations into IUR + [x] 2.2.1 Task - Lower widget declarations into IUR Connect the Phase 1 authoring surface to the new IUR models. - [ ] 2.2.1.1 Subtask - Lower every expanded widget family to a canonical element type or semantic variant. - [ ] 2.2.1.2 Subtask - Normalize defaults such as open state, active state, progress ranges, token lists, and child ordering during compile. - [ ] 2.2.1.3 Subtask - Preserve canonical names in exported IUR while retaining optional alias diagnostics for authoring inputs. + [x] 2.2.1.1 Subtask - Lower every expanded widget family to a canonical element type or semantic variant. + [x] 2.2.1.2 Subtask - Normalize defaults such as open state, active state, progress ranges, token lists, and child ordering during compile. + [x] 2.2.1.3 Subtask - Preserve canonical names in exported IUR while retaining optional alias diagnostics for authoring inputs. - [ ] 2.2.2 Task - Lower interactions into renderer-independent descriptors + [x] 2.2.2 Task - Lower interactions into renderer-independent descriptors Ensure interactive widgets carry canonical event meaning rather than runtime callback names. - [ ] 2.2.2.1 Subtask - Lower selection, row activation, step navigation, submit, change, send, disclosure, panel, and inline action intent. - [ ] 2.2.2.2 Subtask - Preserve payload mapping for selected values, row ids, field values, composer text, and step ids. - [ ] 2.2.2.3 Subtask - Verify descriptors can be transported as Jido.Signal-compatible event meaning. + [x] 2.2.2.1 Subtask - Lower selection, row activation, step navigation, submit, change, send, disclosure, panel, and inline action intent. + [x] 2.2.2.2 Subtask - Preserve payload mapping for selected values, row ids, field values, composer text, and step ids. + [x] 2.2.2.3 Subtask - Verify descriptors can be transported as Jido.Signal-compatible event meaning. [ ] 2.3 Section - List-Repeat Metadata and Hydration Implement the deterministic row expansion behavior that turns repeat diff --git a/.spec/specs/unified-ui/compiler.spec.md b/.spec/specs/unified-ui/compiler.spec.md index bb897c53..1bf8f949 100644 --- a/.spec/specs/unified-ui/compiler.spec.md +++ b/.spec/specs/unified-ui/compiler.spec.md @@ -22,6 +22,7 @@ surface: decisions: - repo.ecosystem.contract_model - repo.ecosystem.canonical_navigation_boundary + - repo.ecosystem.canonical_widget_component_expansion ``` ## Requirements @@ -66,6 +67,16 @@ decisions: statement: The compiler shall lower stacked modal navigation intent by preserving `open_modal` and `close_modal` actions, symbolic modal targets, params, metadata, and transition ordering in canonical IUR descriptors without synthesizing renderer-specific modal containment, focus-trap, or host-router behavior. priority: must stability: stable + +- id: unified_ui.compiler.widget_component_lowering + statement: The compiler shall lower expanded widget-component DSL declarations into canonical `UnifiedIUR.Widgets.Components` nodes with normalized defaults, child ordering, content models, state, accessibility, and canonical names rather than generic authored placeholders. + priority: must + stability: stable + +- id: unified_ui.compiler.widget_component_interaction_lowering + statement: The compiler shall lower expanded widget-component selection, row activation, step navigation, submit, change, send, panel dismiss, and inline action intents into renderer-independent `UnifiedIUR.Interaction` descriptors with portable payload mappings. + priority: must + stability: stable ``` ## Scenarios @@ -81,6 +92,8 @@ decisions: - unified_ui.compiler.no_renderer_output_modes - unified_ui.compiler.navigation_transition_lowering - unified_ui.compiler.navigation_modal_stack_lowering + - unified_ui.compiler.widget_component_lowering + - unified_ui.compiler.widget_component_interaction_lowering given: - A developer authors a screen module with widgets, layout, style, theme, and interaction declarations when: @@ -98,6 +111,8 @@ decisions: - unified_ui.compiler.no_renderer_output_modes - unified_ui.compiler.navigation_transition_lowering - unified_ui.compiler.navigation_modal_stack_lowering + - unified_ui.compiler.widget_component_lowering + - unified_ui.compiler.widget_component_interaction_lowering given: - A developer needs to understand what canonical output a DSL module produces when: @@ -114,6 +129,7 @@ decisions: - unified_ui.compiler.no_renderer_output_modes - unified_ui.compiler.navigation_transition_lowering - unified_ui.compiler.navigation_modal_stack_lowering + - unified_ui.compiler.widget_component_interaction_lowering given: - A developer authors a modal flow where one modal can open another modal when: @@ -136,6 +152,8 @@ decisions: - unified_ui.compiler.no_renderer_output_modes - unified_ui.compiler.navigation_transition_lowering - unified_ui.compiler.navigation_modal_stack_lowering + - unified_ui.compiler.widget_component_lowering + - unified_ui.compiler.widget_component_interaction_lowering - unified_ui.compiler.compile_screen_to_iur - unified_ui.compiler.inspect_compiled_artifact - unified_ui.compiler.compile_stacked_modal_navigation diff --git a/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex b/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex index 27385f3e..b2bf1cf9 100644 --- a/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex +++ b/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex @@ -831,6 +831,173 @@ defmodule UnifiedUi.Compiler.Pipeline do common_opts(node, attachments, [:width, :height]) ) + :inline_rich_text_heading -> + Widgets.Components.inline_rich_text_heading( + node.level || :h1, + normalize_list(node.segments), + common_opts(node, attachments) + ) + + :disclosure -> + Widgets.Components.disclosure( + node.summary || "", + lower_children(node, context, visited), + common_opts(node, attachments, [:open?]) + ) + + :kicker -> + Widgets.Components.kicker( + List.wrap(node.items), + common_opts(node, attachments, [:separator]) + ) + + :avatar -> + Widgets.Components.avatar( + common_opts(node, attachments, [ + :initials, + :image_source, + :size, + :shape, + :accessibility_label, + :accessibility_description + ]) + ) + + :presence_dot -> + Widgets.Components.presence_dot( + node.state || :quiet, + common_opts(node, attachments, [:size, :accessibility_label]) + ) + + :segmented_button_group -> + Widgets.Components.segmented_button_group( + normalize_keyword_items(node.options), + common_opts(node, attachments, [:active_value, :selection_intent, :disabled?]) + ) + + :runtime_form_shell -> + Widgets.Components.runtime_form_shell( + normalize_list(node.fields), + common_opts(node, attachments, + submit_label: node.submit_label, + submit_intent: node.submit_intent, + change_intent: node.change_intent, + validation_state: node.validation_state, + host_adapter_hints: normalize_annotation_hints(node.annotations) + ) + ) + + :chat_composer -> + Widgets.Components.chat_composer( + lower_children(node, context, visited), + common_opts(node, attachments, [ + :name, + :value, + :placeholder, + :rows, + :send_label, + :send_intent, + :change_intent, + :disabled? + ]) + ) + + :list_item_multi_column -> + Widgets.Components.list_item_multi_column( + lower_children(node, context, visited), + common_opts(node, attachments, [ + :row_identity, + :column_template, + :active?, + :link_target, + :action_intent + ]) + ) + + :artifact_row -> + Widgets.Components.artifact_row( + node.title || "", + lower_children(node, context, visited), + common_opts(node, attachments, [ + :row_identity, + :active?, + :link_target, + :action_intent, + :meta + ]) + ) + + :pipeline_stepper_horizontal -> + Widgets.Components.pipeline_stepper_horizontal( + normalize_list(node.steps), + common_opts(node, attachments, [ + :active_index, + :completed_indices, + :navigation_intent + ]) + ) + + :segmented_progress_bar -> + Widgets.Components.segmented_progress_bar( + normalize_list(node.segments), + common_opts(node, attachments, [:aggregate_progress, :label]) + ) + + :workflow_stage_list_vertical -> + Widgets.Components.workflow_stage_list_vertical( + normalize_list(node.stages), + common_opts(node, attachments, [:active_index]) + ) + + :meter_thin -> + Widgets.Components.meter_thin( + node.current || 0, + common_opts(node, attachments, [:minimum, :maximum, :label, :state]) + ) + + :sticky_frosted_header -> + Widgets.Components.sticky_frosted_header( + lower_children(node, context, visited), + common_opts(node, attachments, [:title, :leading, :trailing]) + ) + + :slide_over_panel -> + Widgets.Components.slide_over_panel( + lower_children(node, context, visited), + common_opts(node, attachments, [ + :open?, + :size, + :dismiss_intent, + :accessibility_label, + :accessibility_description + ]) + ) + + :event_callout -> + Widgets.Components.event_callout( + node.message || "", + lower_children(node, context, visited), + common_opts(node, attachments, [ + :tone, + :eyebrow, + :title, + :action_intent + ]) + ) + + :redline_inline -> + Widgets.Components.redline_inline( + normalize_list(node.segments), + common_opts(node, attachments, [:text_safety]) + ) + + :code_block_syntax_highlighted -> + Widgets.Components.code_block_syntax_highlighted( + node.language || :plain_text, + normalize_list(node.tokens), + common_opts(node, attachments, [:text_safety]) + ) + other -> generic_element(element_type(node.family), other, node, attachments, %{ authored: %{ @@ -980,11 +1147,18 @@ defmodule UnifiedUi.Compiler.Pipeline do |> Enum.reject(&is_nil/1) fallback = - [] - |> maybe_prepend(default_action_interaction(node)) - |> maybe_prepend(default_submit_interaction(node)) + [ + default_action_interaction(node), + default_submit_interaction(node), + default_change_interaction(node), + default_selection_interaction(node), + default_step_navigation_interaction(node), + default_send_interaction(node), + default_dismiss_interaction(node) + ] + |> Enum.reject(&is_nil/1) - explicit ++ Enum.reverse(fallback) + explicit ++ fallback end defp default_action_interaction(%Node{action_intent: nil}), do: nil @@ -993,6 +1167,8 @@ defmodule UnifiedUi.Compiler.Pipeline do Interaction.click( intent: node.action_intent, element_id: node.id, + value: node.row_identity, + mapping: default_action_mapping(node), phase: :authored_default ) end @@ -1003,10 +1179,78 @@ defmodule UnifiedUi.Compiler.Pipeline do Interaction.submit( intent: node.submit_intent, element_id: node.id, + mapping: default_submit_mapping(node), + phase: :authored_default + ) + end + + defp default_change_interaction(%Node{change_intent: nil}), do: nil + + defp default_change_interaction(node) do + Interaction.change( + intent: node.change_intent, + element_id: node.id, + mapping: default_change_mapping(node), + phase: :authored_default + ) + end + + defp default_selection_interaction(%Node{selection_intent: nil}), do: nil + + defp default_selection_interaction(node) do + Interaction.selection( + intent: node.selection_intent, + element_id: node.id, + selection: node.active_value, + mapping: %{selected_value: :value}, + phase: :authored_default + ) + end + + defp default_step_navigation_interaction(%Node{navigation_intent: nil}), do: nil + + defp default_step_navigation_interaction(node) do + Interaction.navigation( + intent: node.navigation_intent, + element_id: node.id, + mapping: %{step_id: :id, step_index: :index}, phase: :authored_default ) end + defp default_send_interaction(%Node{send_intent: nil}), do: nil + + defp default_send_interaction(node) do + Interaction.submit( + intent: node.send_intent, + element_id: node.id, + mapping: %{message: :value}, + phase: :authored_default + ) + end + + defp default_dismiss_interaction(%Node{dismiss_intent: nil}), do: nil + + defp default_dismiss_interaction(node) do + Interaction.close( + intent: node.dismiss_intent, + element_id: node.id, + mapping: %{open?: false}, + phase: :authored_default + ) + end + + defp default_action_mapping(%Node{row_identity: nil}), do: nil + defp default_action_mapping(%Node{kind: kind}) when kind in [:event_callout, :button], do: nil + defp default_action_mapping(_node), do: %{row_identity: :row_identity} + + defp default_submit_mapping(%Node{kind: :runtime_form_shell}), do: %{fields: :field_values} + defp default_submit_mapping(_node), do: nil + + defp default_change_mapping(%Node{kind: :runtime_form_shell}), do: %{fields: :field_values} + defp default_change_mapping(%Node{kind: :chat_composer}), do: %{message: :value} + defp default_change_mapping(_node), do: %{value: :value} + defp compile_theme_attachment(node, context) do theme_id = node.theme_ref || context.default_theme @@ -1064,6 +1308,7 @@ defmodule UnifiedUi.Compiler.Pipeline do id: node.id, description: node.summary || node.description, authored_ref: node.authored_ref, + annotations: node.annotations, tags: node.tags, style: attachments.style, theme: attachments.theme, @@ -1352,6 +1597,28 @@ defmodule UnifiedUi.Compiler.Pipeline do Enum.map(series, &normalize_map/1) end + defp normalize_annotation_hints(value) when value in [nil, []], do: nil + + defp normalize_annotation_hints(value) do + value + |> normalize_keyword_tree() + |> compact_optional_map() + end + + defp normalize_keyword_tree(value) when is_map(value) do + Map.new(value, fn {key, nested} -> {key, normalize_keyword_tree(nested)} end) + end + + defp normalize_keyword_tree(value) when is_list(value) do + if Keyword.keyword?(value) do + Map.new(value, fn {key, nested} -> {key, normalize_keyword_tree(nested)} end) + else + Enum.map(value, &normalize_keyword_tree/1) + end + end + + defp normalize_keyword_tree(value), do: value + defp normalize_number_points(points) when is_list(points), do: points defp normalize_number_points(_other), do: [] @@ -1392,7 +1659,4 @@ defmodule UnifiedUi.Compiler.Pipeline do defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) - - defp maybe_prepend(list, nil), do: list - defp maybe_prepend(list, value), do: [value | list] end diff --git a/packages/unified-ui/test/unified_ui/widget_components_compiler_lowering_test.exs b/packages/unified-ui/test/unified_ui/widget_components_compiler_lowering_test.exs new file mode 100644 index 00000000..c5e3c53a --- /dev/null +++ b/packages/unified-ui/test/unified_ui/widget_components_compiler_lowering_test.exs @@ -0,0 +1,241 @@ +defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do + use ExUnit.Case, async: true + + alias UnifiedIUR.{Interaction, Tree} + alias UnifiedUi.Compiler + + defmodule ComponentCompilerScreen do + use UnifiedUi.Dsl + + identity do + id(:component_compiler_screen) + authored_ref([:tests, :component_compiler_screen]) + end + + composition do + root(:component_compiler_root) + mode(:screen) + + inline_rich_text_heading :headline do + level(:h2) + segments([%{type: :text, value: "Canonical widgets"}]) + end + + disclosure :details do + summary("Details") + open?(true) + + text :details_body do + value("Portable disclosure body") + end + end + + segmented_button_group :status_filter do + options([%{value: :all, label: "All"}, %{value: :active, label: "Active"}]) + active_value(:all) + selection_intent(:select_status) + end + + runtime_form_shell :settings_form do + fields([%{name: :title, type: :text, label: "Title"}]) + submit_label("Save") + submit_intent(:save_settings) + change_intent(:validate_settings) + validation_state(:invalid) + annotations(live_ui: [adapter: :phoenix_form]) + end + + chat_composer :composer do + name(:message) + value("Draft") + send_intent(:send_message) + change_intent(:update_message) + + button :attach_tool do + label("Attach") + end + end + + list_item_multi_column :task_row do + row_identity("task-1") + column_template([%{id: :title, label: "Title"}]) + active?(true) + action_intent(:open_task) + + text :task_title do + value("Review ADR") + end + end + + artifact_row :artifact do + title("Widget ADR") + row_identity(:adr) + meta(%{status: :accepted}) + end + + pipeline_stepper_horizontal :release_steps do + steps([%{id: :draft, label: "Draft", state: :done}]) + navigation_intent(:select_step) + end + + segmented_progress_bar :quality_bar do + segments([%{label: "Passing", weight: 8, state: :success}]) + aggregate_progress(%{current: 8, maximum: 9}) + label("Scenario health") + end + + workflow_stage_list_vertical :review_stages do + stages([%{id: :authored, label: "Authored", state: :done}]) + end + + meter_thin :coverage_meter do + current(82.5) + label("Coverage") + state(:success) + end + + sticky_frosted_header :workspace_header do + title("Workspace") + leading([:back_button]) + end + + slide_over_panel :details_panel do + accessibility_label("Details") + open?(true) + size(:wide) + dismiss_intent(:close_panel) + end + + event_callout :incident_callout do + tone(:warning) + message("Paused") + action_intent(:inspect_event) + end + + redline_inline :copy_redline do + segments([%{state: :insert, text: "new"}]) + end + + code_block_syntax_highlighted :code_sample do + language(:elixir) + tokens([%{type: :keyword, text: "defmodule"}]) + end + end + end + + test "lowers expanded widget components into canonical UnifiedIUR component nodes" do + iur = Compiler.iur!(ComponentCompilerScreen) + + heading = Tree.find_by_id(iur, :headline) + disclosure = Tree.find_by_id(iur, :details) + form = Tree.find_by_id(iur, :settings_form) + composer = Tree.find_by_id(iur, :composer) + row = Tree.find_by_id(iur, :task_row) + progress = Tree.find_by_id(iur, :quality_bar) + panel = Tree.find_by_id(iur, :details_panel) + code = Tree.find_by_id(iur, :code_sample) + + assert heading.kind == :inline_rich_text_heading + + assert heading.attributes.component == %{ + family: :content_identity_and_disclosure, + kind: :inline_rich_text_heading + } + + refute Map.has_key?(heading.attributes, :authored) + + assert heading.attributes.heading == %{ + level: :h2, + segments: [%{type: :text, value: "Canonical widgets"}] + } + + assert disclosure.attributes.disclosure == %{summary: "Details", open?: true} + assert [%{element: %{kind: :text}}] = disclosure.children + + assert form.attributes.form.host_adapter_hints == %{live_ui: %{adapter: :phoenix_form}} + + assert composer.attributes.composer == %{ + name: :message, + value: "Draft", + rows: 3, + send_label: "Send", + send_intent: :send_message, + change_intent: :update_message + } + + assert [%{element: %{kind: :button}}] = composer.children + + assert row.attributes.row == %{ + row_identity: "task-1", + active?: true, + action_intent: :open_task, + column_template: [%{id: :title, label: "Title"}] + } + + assert progress.attributes.progress == %{ + presentation: :segmented_progress_bar, + segments: [%{label: "Passing", weight: 8, state: :success}], + aggregate: %{current: 8, maximum: 9}, + label: "Scenario health" + } + + assert panel.attributes.panel == %{ + modal?: false, + open?: true, + size: :wide, + label: "Details", + dismiss_intent: :close_panel + } + + assert code.attributes.code == %{ + language: :elixir, + tokens: [%{type: :keyword, text: "defmodule"}] + } + end + + test "lowers component intents into renderer-independent interaction descriptors" do + iur = Compiler.iur!(ComponentCompilerScreen) + + segmented = Tree.find_by_id(iur, :status_filter) + form = Tree.find_by_id(iur, :settings_form) + composer = Tree.find_by_id(iur, :composer) + row = Tree.find_by_id(iur, :task_row) + stepper = Tree.find_by_id(iur, :release_steps) + panel = Tree.find_by_id(iur, :details_panel) + callout = Tree.find_by_id(iur, :incident_callout) + + assert [%Interaction{family: :selection, intent: :select_status} = selection] = + segmented.attributes.interactions + + assert selection.source == %{element_id: :status_filter} + assert selection.payload == %{selection: :all, mapping: %{selected_value: :value}} + + assert Enum.map(form.attributes.interactions, &{&1.family, &1.intent}) == [ + {:submit, :save_settings}, + {:change, :validate_settings} + ] + + assert Enum.map(composer.attributes.interactions, &{&1.family, &1.intent}) == [ + {:change, :update_message}, + {:submit, :send_message} + ] + + assert [%Interaction{family: :click, intent: :open_task} = row_action] = + row.attributes.interactions + + assert row_action.payload == %{value: "task-1", mapping: %{row_identity: :row_identity}} + + assert [%Interaction{family: :navigation, intent: :select_step} = step_navigation] = + stepper.attributes.interactions + + assert step_navigation.payload == %{mapping: %{step_id: :id, step_index: :index}} + + assert [%Interaction{family: :close, intent: :close_panel} = close_panel] = + panel.attributes.interactions + + assert close_panel.payload == %{mapping: %{open?: false}} + + assert [%Interaction{family: :click, intent: :inspect_event}] = + callout.attributes.interactions + end +end From 0d229d4f2ee31893d75bec9ee5cdf9c8ec780573 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 11:36:09 -0400 Subject: [PATCH 09/21] Hydrate list repeat component rows --- ...tation-compiler-and-hydration-alignment.md | 18 +- .../unified-iur/widget_components.spec.md | 1 + .../lib/unified_ui/compiler/pipeline.ex | 230 ++++++++++++++++++ .../list_repeat_compiler_hydration_test.exs | 165 +++++++++++++ .../lib/unified_iur/widgets/components.ex | 44 +++- .../unified_iur/widgets/components_test.exs | 38 +++ 6 files changed, 486 insertions(+), 10 deletions(-) create mode 100644 packages/unified-ui/test/unified_ui/list_repeat_compiler_hydration_test.exs diff --git a/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md b/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md index 84501641..d21bf9e0 100644 --- a/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md +++ b/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md @@ -71,25 +71,25 @@ Back to index: [README](./README.md) [x] 2.2.2.2 Subtask - Preserve payload mapping for selected values, row ids, field values, composer text, and step ids. [x] 2.2.2.3 Subtask - Verify descriptors can be transported as Jido.Signal-compatible event meaning. - [ ] 2.3 Section - List-Repeat Metadata and Hydration + [x] 2.3 Section - List-Repeat Metadata and Hydration Implement the deterministic row expansion behavior that turns repeat composition into concrete child IUR nodes. - [ ] 2.3.1 Task - Represent repeat metadata before expansion + [x] 2.3.1 Task - Represent repeat metadata before expansion Keep repeat composition inspectable in canonical IUR before row data is applied. - [ ] 2.3.1.1 Subtask - Represent repeat binding id, template identity, child slot, order, and row-scope binding metadata. - [ ] 2.3.1.2 Subtask - Validate repeat metadata against list binding descriptors. - [ ] 2.3.1.3 Subtask - Include repeat metadata in export and inspection output without requiring renderer participation. + [x] 2.3.1.1 Subtask - Represent repeat binding id, template identity, child slot, order, and row-scope binding metadata. + [x] 2.3.1.2 Subtask - Validate repeat metadata against list binding descriptors. + [x] 2.3.1.3 Subtask - Include repeat metadata in export and inspection output without requiring renderer participation. - [ ] 2.3.2 Task - Expand repeated children deterministically + [x] 2.3.2 Task - Expand repeated children deterministically Hydrate repeated child templates into concrete IUR children before runtime rendering. - [ ] 2.3.2.1 Subtask - Define stable generated ids for row-expanded child instances. - [ ] 2.3.2.2 Subtask - Project row-scope binding values into child props or binding states. - [ ] 2.3.2.3 Subtask - Preserve child interactions and accessibility metadata for each repeated row. + [x] 2.3.2.1 Subtask - Define stable generated ids for row-expanded child instances. + [x] 2.3.2.2 Subtask - Project row-scope binding values into child props or binding states. + [x] 2.3.2.3 Subtask - Preserve child interactions and accessibility metadata for each repeated row. [ ] 2.4 Section - Safety, Accessibility, and Validation Fixtures Provide shared fixtures and validation rules that prove the IUR contract diff --git a/.spec/specs/unified-iur/widget_components.spec.md b/.spec/specs/unified-iur/widget_components.spec.md index c5f81842..18e2f70d 100644 --- a/.spec/specs/unified-iur/widget_components.spec.md +++ b/.spec/specs/unified-iur/widget_components.spec.md @@ -75,6 +75,7 @@ decisions: - unified_iur.widget_components.interaction_descriptors - unified_iur.widget_components.accessibility_and_state_metadata - unified_iur.widget_components.text_safety_contract + - unified_iur.widget_components.list_repeat_metadata - unified_iur.widget_components.runtime_mapping_completeness given: - UnifiedUi compiles a screen using the expanded widget-component catalog diff --git a/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex b/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex index b2bf1cf9..f64ba216 100644 --- a/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex +++ b/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex @@ -998,6 +998,9 @@ defmodule UnifiedUi.Compiler.Pipeline do common_opts(node, attachments, [:text_safety]) ) + :list_repeat -> + lower_list_repeat(node, context, visited, attachments) + other -> generic_element(element_type(node.family), other, node, attachments, %{ authored: %{ @@ -1059,6 +1062,233 @@ defmodule UnifiedUi.Compiler.Pipeline do ) end + defp lower_list_repeat(node, context, visited, attachments) do + template_node = List.first(node.children) + template = if(template_node, do: lower_node(template_node, context, visited)) + binding = Map.get(context.binding_by_id, node.repeat_binding) + rows = repeat_rows(binding) + + hydrated_children = + rows + |> Enum.with_index() + |> Enum.map(fn {row, index} -> + Element.Child.new(:default, hydrate_repeat_template(template, node, row, index)) + end) + + opts = + node + |> common_opts(attachments, + repeat_binding: node.repeat_binding, + binding_ref: compile_binding_ref_value(node.repeat_binding, context.binding_by_id), + row_scope: node.row_scope, + row_fields: node.row_fields || [], + template_identity: node.template_identity || template_identity(template), + identity_strategy: node.identity_strategy, + hydrated?: true, + row_count: length(rows), + template: template_summary(template) + ) + |> Map.put(:children, hydrated_children) + + Widgets.Components.list_repeat(template, opts) + end + + defp repeat_rows(%IURBinding{default: rows}) when is_list(rows) do + Enum.map(rows, &normalize_repeat_row/1) + end + + defp repeat_rows(_binding), do: [] + + defp normalize_repeat_row(row) when is_map(row), do: normalize_keyword_tree(row) + + defp normalize_repeat_row(row) when is_list(row) do + if Keyword.keyword?(row), do: normalize_keyword_tree(row), else: %{value: row} + end + + defp normalize_repeat_row(row), do: %{value: row} + + defp hydrate_repeat_template(nil, _repeat_node, _row, _index), do: nil + + defp hydrate_repeat_template(template, repeat_node, row, index) do + identity = repeat_identity_value(repeat_node, template, row, index) + hydrate_repeat_element(template, repeat_node, row, index, identity, []) + end + + defp hydrate_repeat_element(nil, _repeat_node, _row, _index, _identity, _path), do: nil + + defp hydrate_repeat_element(element, repeat_node, row, index, identity, path) do + id = repeat_instance_id(repeat_node, identity, element.id, path) + + children = + element.children + |> Enum.with_index() + |> Enum.map(fn {child, child_index} -> + %{ + child + | element: + hydrate_repeat_element( + child.element, + repeat_node, + row, + index, + identity, + path ++ [child_index] + ) + } + end) + + %{ + element + | id: id, + attributes: + element.attributes + |> project_repeat_attributes(row, repeat_node) + |> put_repeat_instance_metadata(repeat_node, row, index) + |> retarget_interactions(id, row, repeat_node), + children: children + } + end + + defp repeat_instance_id(repeat_node, identity, nil, path) do + path_id = + path + |> Enum.map_join("_", &to_string/1) + |> case do + "" -> "item" + value -> value + end + + repeat_instance_id(repeat_node, identity, path_id, []) + end + + defp repeat_instance_id(repeat_node, identity, original_id, _path) do + Enum.map_join([repeat_node.id, identity, original_id], ":", &to_string/1) + end + + defp repeat_identity_value(%Node{identity_strategy: :index}, _template, _row, index), do: index + + defp repeat_identity_value(%Node{identity_strategy: :stable_hash}, _template, row, _index) do + :erlang.phash2(row) + end + + defp repeat_identity_value(_repeat_node, template, row, index) do + case template_row_identity(template) do + nil -> row_value(row, :id) || index + field when is_atom(field) or is_binary(field) -> row_value(row, field) || field + value -> value + end + end + + defp template_identity(nil), do: nil + defp template_identity(%Element{id: id}), do: id + + defp template_summary(nil), do: nil + + defp template_summary(%Element{} = template) do + %{ + id: template.id, + type: template.type, + kind: template.kind + } + end + + defp template_row_identity(nil), do: nil + + defp template_row_identity(%Element{attributes: attributes}) do + get_in(attributes, [:row, :row_identity]) || + get_in(attributes, [:artifact, :row_identity]) + end + + defp project_repeat_attributes(attributes, row, repeat_node) do + attributes + |> project_repeat_group(:row, row, repeat_node) + |> project_repeat_group(:artifact, row, repeat_node) + |> project_repeat_group(:callout, row, repeat_node) + end + + defp project_repeat_group(attributes, group, row, repeat_node) do + case Map.fetch(attributes, group) do + {:ok, value} -> Map.put(attributes, group, project_repeat_value(value, row, repeat_node)) + :error -> attributes + end + end + + defp project_repeat_value(value, row, repeat_node) when is_map(value) do + Map.new(value, fn {key, nested} -> + {key, project_repeat_value(nested, row, repeat_node)} + end) + end + + defp project_repeat_value(value, row, repeat_node) when is_list(value) do + Enum.map(value, &project_repeat_value(&1, row, repeat_node)) + end + + defp project_repeat_value(value, row, repeat_node) when is_atom(value) or is_binary(value) do + if repeat_field?(repeat_node, value) do + row_value(row, value) || value + else + value + end + end + + defp project_repeat_value(value, _row, _repeat_node), do: value + + defp put_repeat_instance_metadata(attributes, repeat_node, row, index) do + Map.put(attributes, :repeat_instance, %{ + source_repeat_id: repeat_node.id, + row_scope: repeat_node.row_scope || :row, + row_index: index, + values: select_repeat_values(row, repeat_node.row_fields || []) + }) + end + + defp retarget_interactions(attributes, id, row, repeat_node) do + case Map.fetch(attributes, :interactions) do + {:ok, interactions} -> + Map.put( + attributes, + :interactions, + Enum.map(interactions, &retarget_interaction(&1, id, row, repeat_node)) + ) + + :error -> + attributes + end + end + + defp retarget_interaction(%Interaction{} = interaction, id, row, repeat_node) do + %{ + interaction + | source: Map.put(interaction.source, :element_id, id), + payload: project_repeat_value(interaction.payload, row, repeat_node) + } + end + + defp retarget_interaction(interaction, _id, _row, _repeat_node), do: interaction + + defp repeat_field?(%Node{row_fields: row_fields}, field) do + Enum.any?(List.wrap(row_fields), &(to_string(&1) == to_string(field))) + end + + defp select_repeat_values(row, row_fields) do + row_fields + |> List.wrap() + |> Enum.reduce(%{}, fn field, acc -> + case row_value(row, field) do + nil -> acc + value -> Map.put(acc, field, value) + end + end) + end + + defp row_value(row, field) when is_atom(field) do + Map.get(row, field, Map.get(row, Atom.to_string(field))) + end + + defp row_value(row, field) when is_binary(field) do + Map.get(row, field) + end + defp overlay_message_content(node_id, message) do Widgets.Foundational.text(message, id: "#{node_id}_content") end diff --git a/packages/unified-ui/test/unified_ui/list_repeat_compiler_hydration_test.exs b/packages/unified-ui/test/unified_ui/list_repeat_compiler_hydration_test.exs new file mode 100644 index 00000000..93857b6f --- /dev/null +++ b/packages/unified-ui/test/unified_ui/list_repeat_compiler_hydration_test.exs @@ -0,0 +1,165 @@ +defmodule UnifiedUi.ListRepeatCompilerHydrationTest do + use ExUnit.Case, async: true + + alias UnifiedIUR.{Interaction, Tree} + alias UnifiedUi.Compiler + + defmodule HydratedRepeatScreen do + use UnifiedUi.Dsl + + identity do + id(:hydrated_repeat_screen) + authored_ref([:tests, :hydrated_repeat_screen]) + end + + signals do + data_binding do + id(:artifact_rows) + path([:artifacts]) + collection?(true) + + default([ + %{id: "adr-1", title: "Widget ADR", status: :accepted}, + %{id: "adr-2", title: "Compiler ADR", status: :draft} + ]) + end + end + + composition do + root(:hydrated_repeat_root) + mode(:screen) + + list_repeat :artifact_repeat do + repeat_binding(:artifact_rows) + row_scope(:artifact) + row_fields([:id, :title, :status]) + template_identity(:artifact_template) + identity_strategy(:row_identity) + + artifact_row_template :artifact_template do + title("Artifact") + meta(%{status: :status}) + row_identity(:id) + action_intent(:open_artifact) + + button :open_button do + label("Open") + end + end + end + end + end + + defmodule EmptyRepeatScreen do + use UnifiedUi.Dsl + + identity do + id(:empty_repeat_screen) + authored_ref([:tests, :empty_repeat_screen]) + end + + signals do + data_binding do + id(:empty_rows) + path([:empty]) + collection?(true) + default([]) + end + end + + composition do + root(:empty_repeat_root) + mode(:screen) + + list_repeat :empty_repeat do + repeat_binding(:empty_rows) + row_fields([:id]) + + artifact_row_template :empty_template do + title("Empty") + row_identity(:id) + end + end + end + end + + test "preserves repeat metadata and hydrates one stable child per binding row" do + iur = Compiler.iur!(HydratedRepeatScreen) + repeat = Tree.find_by_id(iur, :artifact_repeat) + + assert repeat.kind == :list_repeat + + assert repeat.attributes.repeat == %{ + binding_id: :artifact_rows, + binding_ref: %{ + kind: :binding_ref, + id: :artifact_rows, + name: :artifact_rows, + path: [:artifacts], + scope: [] + }, + row_scope: :artifact, + row_fields: [:id, :title, :status], + template_identity: :artifact_template, + identity_strategy: :row_identity, + child_slot: :default, + hydrated?: true, + row_count: 2, + template: %{id: :artifact_template, type: :widget, kind: :artifact_row} + } + + assert Enum.map(repeat.children, & &1.element.id) == [ + "artifact_repeat:adr-1:artifact_template", + "artifact_repeat:adr-2:artifact_template" + ] + + [first_child | _rest] = repeat.children + first = first_child.element + + assert first.attributes.artifact == %{ + row_identity: "adr-1", + title: "Artifact", + meta: %{status: :accepted}, + active?: false, + action_intent: :open_artifact + } + + assert first.attributes.repeat_instance == %{ + source_repeat_id: :artifact_repeat, + row_scope: :artifact, + row_index: 0, + values: %{id: "adr-1", title: "Widget ADR", status: :accepted} + } + + assert [%Interaction{family: :click, intent: :open_artifact} = action] = + first.attributes.interactions + + assert action.source == %{element_id: "artifact_repeat:adr-1:artifact_template"} + assert action.payload == %{value: "adr-1", mapping: %{row_identity: :row_identity}} + + assert [%{element: nested_button}] = first.children + assert nested_button.id == "artifact_repeat:adr-1:open_button" + + assert nested_button.attributes.repeat_instance.values == %{ + id: "adr-1", + title: "Widget ADR", + status: :accepted + } + end + + test "hydrates empty repeat bindings as deterministic empty child output" do + iur = Compiler.iur!(EmptyRepeatScreen) + repeat = Tree.find_by_id(iur, :empty_repeat) + + assert repeat.attributes.repeat.hydrated? == true + assert repeat.attributes.repeat.row_count == 0 + + assert repeat.attributes.repeat.template == %{ + id: :empty_template, + type: :widget, + kind: :artifact_row + } + + assert repeat.children == [] + end +end diff --git a/packages/unified_iur/lib/unified_iur/widgets/components.ex b/packages/unified_iur/lib/unified_iur/widgets/components.ex index da39a4ab..bab658d8 100644 --- a/packages/unified_iur/lib/unified_iur/widgets/components.ex +++ b/packages/unified_iur/lib/unified_iur/widgets/components.ex @@ -17,6 +17,7 @@ defmodule UnifiedIUR.Widgets.Components do # unified_iur.widget_components.interaction_descriptors # unified_iur.widget_components.accessibility_and_state_metadata # unified_iur.widget_components.text_safety_contract + # unified_iur.widget_components.list_repeat_metadata # unified_iur.widget_components.runtime_mapping_completeness # unified_iur.widgets.expanded_widget_component_catalog # unified_iur.widgets.widget_semantics_preserved @@ -60,6 +61,10 @@ defmodule UnifiedIUR.Widgets.Components do :code_block_syntax_highlighted ] + @composition_behavior_kinds [ + :list_repeat + ] + @spec content_identity_kinds() :: [atom()] def content_identity_kinds, do: @content_identity_kinds @@ -78,6 +83,9 @@ defmodule UnifiedIUR.Widgets.Components do @spec redline_code_kinds() :: [atom()] def redline_code_kinds, do: @redline_code_kinds + @spec composition_behavior_kinds() :: [atom()] + def composition_behavior_kinds, do: @composition_behavior_kinds + @spec kinds() :: [atom()] def kinds do @content_identity_kinds ++ @@ -85,7 +93,8 @@ defmodule UnifiedIUR.Widgets.Components do @row_artifact_kinds ++ @workflow_kinds ++ @layer_callout_kinds ++ - @redline_code_kinds + @redline_code_kinds ++ + @composition_behavior_kinds end @spec inline_rich_text_heading(atom(), [keyword() | map()], opts()) :: Element.t() @@ -461,6 +470,39 @@ defmodule UnifiedIUR.Widgets.Components do ) end + @spec list_repeat(Element.t() | nil, opts()) :: Element.t() + def list_repeat(template, opts \\ []) do + opts = normalize_opts(opts) + + children = + option( + opts, + :children, + if(template, do: [Element.Child.new(:template, template)], else: []) + ) + + build_component( + :list_repeat, + :composition_behavior, + %{ + repeat: + %{ + binding_id: option(opts, :repeat_binding), + row_scope: option(opts, :row_scope, :row), + row_fields: option(opts, :row_fields, []), + identity_strategy: option(opts, :identity_strategy, :row_identity), + child_slot: option(opts, :child_slot, :default), + hydrated?: option(opts, :hydrated?, false), + row_count: option(opts, :row_count, 0) + } + |> maybe_put(:binding_ref, normalize_optional_map(option(opts, :binding_ref))) + |> maybe_put(:template_identity, option(opts, :template_identity)) + |> maybe_put(:template, normalize_optional_map(option(opts, :template))) + }, + Map.put(opts, :children, children) + ) + end + defp build_component(kind, family, kind_attributes, opts) do opts = normalize_opts(opts) diff --git a/packages/unified_iur/test/unified_iur/widgets/components_test.exs b/packages/unified_iur/test/unified_iur/widgets/components_test.exs index 5904c4c1..ea046a1d 100644 --- a/packages/unified_iur/test/unified_iur/widgets/components_test.exs +++ b/packages/unified_iur/test/unified_iur/widgets/components_test.exs @@ -42,6 +42,7 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do :code_block_syntax_highlighted ] + assert Components.composition_behavior_kinds() == [:list_repeat] assert Widgets.component_kinds() == Components.kinds() end @@ -286,4 +287,41 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do assert code.attributes.text_safety == %{content: :plain_text} end + + test "represents list repeat metadata and hydrated row children" do + template = Components.artifact_row("Template", [], row_identity: :id) + + repeat = + Components.list_repeat(template, + repeat_binding: :artifact_rows, + binding_ref: %{kind: :binding_ref, id: :artifact_rows, path: [:artifacts]}, + row_scope: :artifact, + row_fields: [:id, :title], + template_identity: :artifact_template, + identity_strategy: :row_identity, + hydrated?: true, + row_count: 1, + template: %{id: :artifact_template, kind: :artifact_row}, + children: [Components.artifact_row("Hydrated", [], id: "artifact_repeat:a1:artifact")] + ) + + assert repeat.kind == :list_repeat + assert repeat.attributes.component == %{family: :composition_behavior, kind: :list_repeat} + + assert repeat.attributes.repeat == %{ + binding_id: :artifact_rows, + binding_ref: %{kind: :binding_ref, id: :artifact_rows, path: [:artifacts]}, + row_scope: :artifact, + row_fields: [:id, :title], + template_identity: :artifact_template, + identity_strategy: :row_identity, + child_slot: :default, + hydrated?: true, + row_count: 1, + template: %{id: :artifact_template, kind: :artifact_row} + } + + assert [%{slot: :default, element: %Element{id: "artifact_repeat:a1:artifact"}}] = + repeat.children + end end From acdf727816893a751962f8cad648587fde061829 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 11:41:37 -0400 Subject: [PATCH 10/21] Add widget component validation fixtures --- ...tation-compiler-and-hydration-alignment.md | 18 +- .../unified-iur/widget_components.spec.md | 2 + .../unified_iur/lib/unified_iur/extension.ex | 4 +- .../unified_iur/lib/unified_iur/fixtures.ex | 180 +++++++++++++++++- .../unified_iur/lib/unified_iur/validate.ex | 161 ++++++++++++++++ .../test/unified_iur/fixtures_test.exs | 15 +- .../test/unified_iur/validate_test.exs | 36 ++++ 7 files changed, 403 insertions(+), 13 deletions(-) diff --git a/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md b/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md index d21bf9e0..95e990e2 100644 --- a/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md +++ b/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md @@ -91,25 +91,25 @@ Back to index: [README](./README.md) [x] 2.3.2.2 Subtask - Project row-scope binding values into child props or binding states. [x] 2.3.2.3 Subtask - Preserve child interactions and accessibility metadata for each repeated row. - [ ] 2.4 Section - Safety, Accessibility, and Validation Fixtures + [x] 2.4 Section - Safety, Accessibility, and Validation Fixtures Provide shared fixtures and validation rules that prove the IUR contract carries safety and accessibility meaning. - [ ] 2.4.1 Task - Add text-safety and token validation + [x] 2.4.1 Task - Add text-safety and token validation Ensure IUR accepts semantic text metadata without treating user-provided text as trusted markup. - [ ] 2.4.1.1 Subtask - Validate redline segment kinds and states. - [ ] 2.4.1.2 Subtask - Validate supported code token types while allowing plain text fallback tokens. - [ ] 2.4.1.3 Subtask - Add malicious-content fixtures for renderers to escape in host output. + [x] 2.4.1.1 Subtask - Validate redline segment kinds and states. + [x] 2.4.1.2 Subtask - Validate supported code token types while allowing plain text fallback tokens. + [x] 2.4.1.3 Subtask - Add malicious-content fixtures for renderers to escape in host output. - [ ] 2.4.2 Task - Add accessibility and state fixtures + [x] 2.4.2 Task - Add accessibility and state fixtures Give runtime packages a shared target for roles, labels, progress state, active state, and open state. - [ ] 2.4.2.1 Subtask - Add fixtures for headings, labels, button pressed state, progress values, disclosure state, and panel labels. - [ ] 2.4.2.2 Subtask - Add fixture summaries that runtimes can use in parity tests. - [ ] 2.4.2.3 Subtask - Add validation that required accessible names are present where the canonical contract requires them. + [x] 2.4.2.1 Subtask - Add fixtures for headings, labels, button pressed state, progress values, disclosure state, and panel labels. + [x] 2.4.2.2 Subtask - Add fixture summaries that runtimes can use in parity tests. + [x] 2.4.2.3 Subtask - Add validation that required accessible names are present where the canonical contract requires them. [ ] 2.5 Section - Phase 2 Integration Tests Validate compiler lowering, IUR representation, repeat expansion, and diff --git a/.spec/specs/unified-iur/widget_components.spec.md b/.spec/specs/unified-iur/widget_components.spec.md index 18e2f70d..ae5030b5 100644 --- a/.spec/specs/unified-iur/widget_components.spec.md +++ b/.spec/specs/unified-iur/widget_components.spec.md @@ -18,6 +18,8 @@ status: active summary: Canonical IUR contract for expanded widget components and list-repeat composition behavior derived from AshUi PRs 79 through 98. surface: - packages/unified_iur + - packages/unified_iur/lib/unified_iur/fixtures.ex + - packages/unified_iur/lib/unified_iur/validate.ex - packages/unified_iur/lib/unified_iur/widgets/components.ex - .spec/specs/unified-iur/widget_components.spec.md - .spec/specs/unified-ui/widget_components.spec.md diff --git a/packages/unified_iur/lib/unified_iur/extension.ex b/packages/unified_iur/lib/unified_iur/extension.ex index 6b31482d..f0a1da39 100644 --- a/packages/unified_iur/lib/unified_iur/extension.ex +++ b/packages/unified_iur/lib/unified_iur/extension.ex @@ -58,6 +58,7 @@ defmodule UnifiedIUR.Extension do data_widgets: Widgets.data_view_kinds(), feedback_widgets: Widgets.feedback_kinds(), advanced_widgets: Widgets.advanced_kinds(), + component_widgets: Widgets.component_kinds(), form_constructs: Forms.kinds(), container_constructs: @container_constructs, layout_constructs: Layout.kinds(), @@ -75,7 +76,8 @@ defmodule UnifiedIUR.Extension do navigation: Widgets.navigation_kinds(), data: Widgets.data_view_kinds(), feedback: Widgets.feedback_kinds(), - advanced: Widgets.advanced_kinds() + advanced: Widgets.advanced_kinds(), + components: Widgets.component_kinds() }, display_systems: %{ forms: Forms.kinds(), diff --git a/packages/unified_iur/lib/unified_iur/fixtures.ex b/packages/unified_iur/lib/unified_iur/fixtures.ex index 9e506175..a80c32fe 100644 --- a/packages/unified_iur/lib/unified_iur/fixtures.ex +++ b/packages/unified_iur/lib/unified_iur/fixtures.ex @@ -14,7 +14,7 @@ defmodule UnifiedIUR.Fixtures do Layout } - alias UnifiedIUR.Widgets.{Advanced, Data, Feedback, Foundational, Input, Navigation} + alias UnifiedIUR.Widgets.{Advanced, Components, Data, Feedback, Foundational, Input, Navigation} @fixture_specs [ %{ @@ -86,6 +86,19 @@ defmodule UnifiedIUR.Fixtures do ], parity_obligations: [:advanced_widgets, :canvas_constructs, :feedback_widgets], snapshot: "advanced--operations_center.snapshot" + }, + %{ + id: "components--accessibility_and_safety", + category: :components, + description: + "Expanded widget components with accessibility names, state semantics, and plain-text safety fixtures.", + semantics: [ + "expanded widget component catalog coverage", + "redline and code plain-text safety", + "accessibility labels and progress state" + ], + parity_obligations: [:component_widgets, :accessibility_semantics, :text_safety], + snapshot: "components--accessibility_and_safety.snapshot" } ] @@ -883,6 +896,171 @@ defmodule UnifiedIUR.Fixtures do ) end + defp build_fixture("components--accessibility_and_safety") do + template = + Components.artifact_row("Repeated artifact", [], id: "repeat-template", row_identity: :id) + + Layout.column( + [ + {:content, + Components.inline_rich_text_heading( + :h2, + [ + %{ + type: :text, + value: "Unsafe-looking text is still text: " + }, + %{type: :emphasis, value: " portable"} + ], + id: "component-heading", + accessibility_label: "Component safety heading" + )}, + {:content, + Components.disclosure( + "Advanced component details", + [Foundational.text("Disclosure body", id: "disclosure-body")], + id: "component-disclosure", + open?: true + )}, + {:content, + Components.kicker(["Spec", "Runtime", "Safety"], + id: "component-kicker", + separator: "/" + )}, + {:content, + Components.avatar( + id: "component-avatar", + initials: "PC", + accessibility_label: "Pascal Charbonneau" + )}, + {:content, + Components.presence_dot(:active, id: "component-presence", accessibility_label: "Active")}, + {:content, + Components.segmented_button_group( + [ + %{value: :all, label: "All"}, + %{value: :active, label: "Active"} + ], + id: "component-segmented", + active_value: :all, + selection_intent: :select_status + )}, + {:content, + Components.runtime_form_shell( + [%{name: :email, type: :email, label: "Email"}], + id: "component-form", + submit_label: "Save", + submit_intent: :save, + change_intent: :validate, + validation_state: :valid + )}, + {:content, + Components.chat_composer( + [Foundational.button("Attach", id: "composer-attach")], + id: "component-composer", + value: "Draft", + send_intent: :send_message, + change_intent: :change_message + )}, + {:content, + Components.list_item_multi_column( + [Foundational.text("Row title", id: "row-title")], + id: "component-row", + row_identity: "row-1", + column_template: [%{id: :title, label: "Title"}], + active?: true, + action_intent: :open_row + )}, + {:content, + Components.artifact_row( + "Artifact", + [Foundational.button("Open", id: "artifact-open")], + id: "component-artifact", + row_identity: "artifact-1", + meta: %{status: :accepted} + )}, + {:content, + Components.pipeline_stepper_horizontal( + [ + %{id: :draft, label: "Draft", state: :done}, + %{id: :review, label: "Review", state: :active} + ], + id: "component-stepper", + active_index: 1, + completed_indices: [0], + navigation_intent: :select_step + )}, + {:content, + Components.segmented_progress_bar( + [%{label: "Passing", weight: 8, state: :success}], + id: "component-progress", + aggregate_progress: %{current: 8, maximum: 9}, + label: "Scenario health" + )}, + {:content, + Components.workflow_stage_list_vertical( + [%{id: :authored, label: "Authored", state: :done}], + id: "component-stages" + )}, + {:content, Components.meter_thin(82.5, id: "component-meter", label: "Coverage")}, + {:content, + Components.sticky_frosted_header( + [Foundational.button("Save", id: "header-save")], + id: "component-header", + title: "Workspace", + leading: [:back] + )}, + {:content, + Components.slide_over_panel( + [Foundational.text("Panel body", id: "panel-body")], + id: "component-panel", + accessibility_label: "Details panel", + open?: true, + size: :wide, + dismiss_intent: :close_panel + )}, + {:content, + Components.event_callout( + "Deployment paused", + [Foundational.button("Inspect", id: "callout-inspect")], + id: "component-callout", + tone: :warning, + action_intent: :inspect_event + )}, + {:content, + Components.redline_inline( + [ + %{state: :keep, text: "Keep "}, + %{state: :delete, text: "old"}, + %{state: :insert, text: ""} + ], + id: "component-redline" + )}, + {:content, + Components.code_block_syntax_highlighted( + :elixir, + [ + %{type: :keyword, text: "defmodule"}, + %{type: :text, text: " "} + ], + id: "component-code" + )}, + {:content, + Components.list_repeat(template, + id: "component-repeat", + repeat_binding: :artifact_rows, + row_scope: :artifact, + row_fields: [:id], + template_identity: :repeat_template, + hydrated?: true, + row_count: 0, + children: [] + )} + ], + id: "component-safety-fixture" + ) + end + defp attachment_family_report(fixtures, predicate) do covered_fixture_ids = fixtures diff --git a/packages/unified_iur/lib/unified_iur/validate.ex b/packages/unified_iur/lib/unified_iur/validate.ex index 023c552b..1c811e70 100644 --- a/packages/unified_iur/lib/unified_iur/validate.ex +++ b/packages/unified_iur/lib/unified_iur/validate.ex @@ -80,9 +80,43 @@ defmodule UnifiedIUR.Validate do construct_family: :interoperability, guidance: "Keep runtime-native structs out of canonical IUR and translate them at runtime-library boundaries." + }, + invalid_text_segment: %{ + construct_family: :widget_components, + guidance: "Represent redline content as plain text segments with supported semantic states." + }, + invalid_code_token: %{ + construct_family: :widget_components, + guidance: + "Represent code content as plain text tokens with supported token types or the :text fallback." + }, + missing_accessible_name: %{ + construct_family: :accessibility, + guidance: + "Provide a portable label or accessibility label for component surfaces that need an accessible name." + }, + invalid_progress_value: %{ + construct_family: :widget_components, + guidance: + "Keep progress and meter values numeric and within the canonical minimum and maximum range." } } + @redline_states [:keep, :insert, :delete, :accepted, :rejected] + @code_token_types [ + :text, + :keyword, + :identifier, + :string, + :number, + :comment, + :operator, + :punctuation, + :function, + :module, + :attribute + ] + @spec element(Element.t()) :: :ok | {:error, [Error.t()]} def element(%Element{} = element) do errors = @@ -90,6 +124,7 @@ defmodule UnifiedIUR.Validate do |> Kernel.++(validate_element_shape(element)) |> Kernel.++(validate_metadata(element.metadata)) |> Kernel.++(validate_attachments(element.attributes)) + |> Kernel.++(validate_component_contracts(element)) |> Kernel.++(validate_runtime_local_values(element.attributes, [:attributes])) |> Kernel.++(validate_children(element.children)) @@ -366,6 +401,119 @@ defmodule UnifiedIUR.Validate do defp validate_interaction_scope(errors, _attributes), do: errors + defp validate_component_contracts(%Element{kind: :redline_inline, attributes: attributes}) do + attributes + |> get_in([:redline, :segments]) + |> validate_redline_segments([:attributes, :redline, :segments]) + end + + defp validate_component_contracts(%Element{ + kind: :code_block_syntax_highlighted, + attributes: attributes + }) do + attributes + |> get_in([:code, :tokens]) + |> validate_code_tokens([:attributes, :code, :tokens]) + end + + defp validate_component_contracts(%Element{kind: :slide_over_panel, attributes: attributes}) do + label = get_in(attributes, [:panel, :label]) || get_in(attributes, [:accessibility, :label]) + + maybe_add( + [], + blank?(label), + Error.new( + :missing_accessible_name, + "slide_over_panel requires a label or accessibility label", + path: [:attributes, :panel, :label] + ) + ) + end + + defp validate_component_contracts(%Element{kind: :meter_thin, attributes: attributes}) do + meter = Map.get(attributes, :meter, %{}) + current = fetch(meter, :current) + minimum = fetch(meter, :minimum, 0) + maximum = fetch(meter, :maximum, 100) + + valid? = + is_number(current) and is_number(minimum) and is_number(maximum) and minimum <= current and + current <= maximum + + maybe_add( + [], + not valid?, + Error.new( + :invalid_progress_value, + "meter_thin current, minimum, and maximum must be numeric and in range", + path: [:attributes, :meter], + details: %{current: current, minimum: minimum, maximum: maximum} + ) + ) + end + + defp validate_component_contracts(_element), do: [] + + defp validate_redline_segments(segments, path) when is_list(segments) do + segments + |> Enum.with_index() + |> Enum.flat_map(fn {segment, index} -> + state = fetch(segment, :state) + text = fetch(segment, :text) + + maybe_add( + [], + state not in @redline_states or not is_binary(text), + Error.new( + :invalid_text_segment, + "redline segment must include plain text and a supported state", + path: path ++ [index], + details: %{state: state, text: inspect(text)} + ) + ) + end) + end + + defp validate_redline_segments(_segments, path) do + [ + Error.new( + :invalid_text_segment, + "redline segments must be a list", + path: path + ) + ] + end + + defp validate_code_tokens(tokens, path) when is_list(tokens) do + tokens + |> Enum.with_index() + |> Enum.flat_map(fn {token, index} -> + type = fetch(token, :type, :text) + text = fetch(token, :text) + + maybe_add( + [], + type not in @code_token_types or not is_binary(text), + Error.new( + :invalid_code_token, + "code token must include plain text and a supported token type", + path: path ++ [index], + details: %{type: type, text: inspect(text)} + ) + ) + end) + end + + defp validate_code_tokens(_tokens, path) do + [ + Error.new( + :invalid_code_token, + "code tokens must be a list", + path: path + ) + ] + end + defp validate_runtime_local_values(value, path) when is_list(value) do value |> Enum.with_index() @@ -425,6 +573,19 @@ defmodule UnifiedIUR.Validate do |> Enum.any?(&(&1 == prefix_parts)) end + defp fetch(source, key, default \\ nil) + + defp fetch(source, key, default) when is_map(source) do + Map.get(source, key, Map.get(source, Atom.to_string(key), default)) + end + + defp fetch(source, key, default) when is_list(source), + do: source |> Enum.into(%{}) |> fetch(key, default) + + defp fetch(_source, _key, default), do: default + + defp blank?(value), do: value in [nil, ""] + defp maybe_add(errors, true, error), do: errors ++ [error] defp maybe_add(errors, false, _error), do: errors diff --git a/packages/unified_iur/test/unified_iur/fixtures_test.exs b/packages/unified_iur/test/unified_iur/fixtures_test.exs index 231af4ef..9d5508c1 100644 --- a/packages/unified_iur/test/unified_iur/fixtures_test.exs +++ b/packages/unified_iur/test/unified_iur/fixtures_test.exs @@ -7,11 +7,19 @@ defmodule UnifiedIUR.FixturesTest do assert Fixtures.naming_rules() == %{ fixture_id_pattern: "category--scenario", snapshot_suffix: ".snapshot", - categories: [:foundational, :forms, :data, :display, :advanced] + categories: [:foundational, :forms, :data, :display, :advanced, :components] } assert Enum.all?(Fixtures.ids(), &Fixtures.valid_id?/1) - assert Enum.sort(Fixtures.categories()) == [:advanced, :data, :display, :forms, :foundational] + + assert Enum.sort(Fixtures.categories()) == [ + :advanced, + :components, + :data, + :display, + :forms, + :foundational + ] end test "loads fixture catalog entries with semantics, parity obligations, and canonical elements" do @@ -35,8 +43,11 @@ defmodule UnifiedIUR.FixturesTest do assert report.complete? assert "advanced--operations_center" in report.fixture_ids + assert "components--accessibility_and_safety" in report.fixture_ids assert :dialog in report.covered_kinds assert :canvas in report.covered_kinds + assert :redline_inline in report.covered_kinds + assert :list_repeat in report.covered_kinds assert :text_input in report.covered_kinds assert report.attachment_families.style_semantics.covered? assert report.attachment_families.theme_semantics.covered? diff --git a/packages/unified_iur/test/unified_iur/validate_test.exs b/packages/unified_iur/test/unified_iur/validate_test.exs index af8c5302..024677b4 100644 --- a/packages/unified_iur/test/unified_iur/validate_test.exs +++ b/packages/unified_iur/test/unified_iur/validate_test.exs @@ -2,6 +2,7 @@ defmodule UnifiedIUR.ValidateTest do use ExUnit.Case, async: true alias UnifiedIUR.{Binding, Element, Interaction, Style, Validate} + alias UnifiedIUR.Widgets.Components defmodule LiveUi.NativeButton do defstruct [:id] @@ -53,4 +54,39 @@ defmodule UnifiedIUR.ValidateTest do assert {:error, [error]} = Validate.element(element) assert error.code == :runtime_local_escape_hatch end + + test "validates redline and code component text safety shapes without rejecting plain text markup" do + safe_redline = + Components.redline_inline([ + %{state: :insert, text: ""} + ]) + + invalid_redline = + Components.redline_inline([ + %{state: :markup, text: "unsafe state"} + ]) + + invalid_code = + Components.code_block_syntax_highlighted(:elixir, [ + %{type: :html, text: "bad token type"} + ]) + + assert :ok = Validate.element(safe_redline) + assert {:error, [redline_error]} = Validate.element(invalid_redline) + assert redline_error.code == :invalid_text_segment + + assert {:error, [code_error]} = Validate.element(invalid_code) + assert code_error.code == :invalid_code_token + end + + test "validates required component accessible names and progress ranges" do + missing_panel_name = Components.slide_over_panel([], id: "panel") + invalid_meter = Components.meter_thin(120, minimum: 0, maximum: 100) + + assert {:error, [panel_error]} = Validate.element(missing_panel_name) + assert panel_error.code == :missing_accessible_name + + assert {:error, [meter_error]} = Validate.element(invalid_meter) + assert meter_error.code == :invalid_progress_value + end end From a1ff37755b4c85c5b347c5c6a40020fb15c4987b Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 11:55:07 -0400 Subject: [PATCH 11/21] Add widget component phase 2 integration tests --- ...tation-compiler-and-hydration-alignment.md | 22 +-- .../unified-ui/lib/unified_ui/examples.ex | 3 +- .../examples/operations_dashboard.ex | 15 ++ packages/unified-ui/lib/unified_ui/parity.ex | 2 + .../unified-ui/lib/unified_ui/reference.ex | 1 + .../unified_ui/compiler_inspection_test.exs | 1 + .../test/unified_ui/examples_test.exs | 2 + .../test/unified_ui/parity_test.exs | 1 + .../unified_ui/phase_6_integration_test.exs | 2 + .../test/unified_ui/tooling_test.exs | 2 + .../test/unified_ui/validation_test.exs | 1 + ...et_components_phase_2_integration_test.exs | 138 ++++++++++++++++++ 12 files changed, 178 insertions(+), 12 deletions(-) create mode 100644 packages/unified-ui/test/unified_ui/widget_components_phase_2_integration_test.exs diff --git a/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md b/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md index 95e990e2..95c8b5f9 100644 --- a/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md +++ b/.spec/planning/canonical_widget_components/phase-02-unified-iur-representation-compiler-and-hydration-alignment.md @@ -18,9 +18,9 @@ Back to index: [README](./README.md) host markup. - Repeat may be represented as metadata before hydration, but runtimes should receive deterministic concrete child nodes. -- All work in this phase is not done. +- All work in this phase is done. -[ ] 2 Phase 2 - UnifiedIUR Representation, Compiler, and Hydration Alignment +[x] 2 Phase 2 - UnifiedIUR Representation, Compiler, and Hydration Alignment Add canonical IUR representation and compiler lowering for the expanded widget-component catalog and list-repeat composition behavior. @@ -111,21 +111,21 @@ Back to index: [README](./README.md) [x] 2.4.2.2 Subtask - Add fixture summaries that runtimes can use in parity tests. [x] 2.4.2.3 Subtask - Add validation that required accessible names are present where the canonical contract requires them. - [ ] 2.5 Section - Phase 2 Integration Tests + [x] 2.5 Section - Phase 2 Integration Tests Validate compiler lowering, IUR representation, repeat expansion, and shared fixtures end to end. - [ ] 2.5.1 Task - Compiler and IUR snapshot scenarios + [x] 2.5.1 Task - Compiler and IUR snapshot scenarios Verify the expanded authored catalog compiles into deterministic IUR. - [ ] 2.5.1.1 Subtask - Snapshot representative IUR for every expanded widget family. - [ ] 2.5.1.2 Subtask - Verify interaction descriptors are runtime-independent and preserve expected payload mappings. - [ ] 2.5.1.3 Subtask - Verify exported IUR contains canonical names and normalized defaults. + [x] 2.5.1.1 Subtask - Snapshot representative IUR for every expanded widget family. + [x] 2.5.1.2 Subtask - Verify interaction descriptors are runtime-independent and preserve expected payload mappings. + [x] 2.5.1.3 Subtask - Verify exported IUR contains canonical names and normalized defaults. - [ ] 2.5.2 Task - Repeat hydration scenarios + [x] 2.5.2 Task - Repeat hydration scenarios Verify list-repeat metadata and row expansion preserve identity and row data. - [ ] 2.5.2.1 Subtask - Verify repeat metadata appears before hydration. - [ ] 2.5.2.2 Subtask - Verify hydrated children have stable ids, row values, and preserved interactions. - [ ] 2.5.2.3 Subtask - Verify empty lists produce deterministic empty child output without renderer errors. + [x] 2.5.2.1 Subtask - Verify repeat metadata appears before hydration. + [x] 2.5.2.2 Subtask - Verify hydrated children have stable ids, row values, and preserved interactions. + [x] 2.5.2.3 Subtask - Verify empty lists produce deterministic empty child output without renderer errors. diff --git a/packages/unified-ui/lib/unified_ui/examples.ex b/packages/unified-ui/lib/unified_ui/examples.ex index 0fae7c27..766e615f 100644 --- a/packages/unified-ui/lib/unified_ui/examples.ex +++ b/packages/unified-ui/lib/unified_ui/examples.ex @@ -56,11 +56,12 @@ defmodule UnifiedUi.Examples do category: :advanced_dashboard, scenario: :operational_visibility, module: OperationsDashboard, - constructs: [:data, :feedback, :advanced], + constructs: [:data, :feedback, :advanced, :components], parity_obligations: [ :data_widgets, :feedback_widgets, :advanced_widgets, + :component_widgets, :layout_constructs ], validation_purpose: [:docs, :coverage, :parity], diff --git a/packages/unified-ui/lib/unified_ui/examples/operations_dashboard.ex b/packages/unified-ui/lib/unified_ui/examples/operations_dashboard.ex index 39305614..6b48a516 100644 --- a/packages/unified-ui/lib/unified_ui/examples/operations_dashboard.ex +++ b/packages/unified-ui/lib/unified_ui/examples/operations_dashboard.ex @@ -85,5 +85,20 @@ defmodule UnifiedUi.Examples.OperationsDashboard do metrics(%{healthy: 1, degraded: 1}) end end + + inline_rich_text_heading :operations_heading do + level(:h2) + segments([%{type: :text, value: "Operations workflow"}]) + end + + segmented_progress_bar :deployment_health do + segments([ + %{label: "Healthy", weight: 8, state: :success}, + %{label: "Degraded", weight: 1, state: :warning} + ]) + + aggregate_progress(%{current: 8, maximum: 9}) + label("Deployment health") + end end end diff --git a/packages/unified-ui/lib/unified_ui/parity.ex b/packages/unified-ui/lib/unified_ui/parity.ex index bd275355..2f8b63bb 100644 --- a/packages/unified-ui/lib/unified_ui/parity.ex +++ b/packages/unified-ui/lib/unified_ui/parity.ex @@ -17,6 +17,7 @@ defmodule UnifiedUi.Parity do data_widgets: [atom()], feedback_widgets: [atom()], advanced_widgets: [atom()], + component_widgets: [atom()], form_constructs: [atom()], container_constructs: [atom()], layout_constructs: [atom()], @@ -44,6 +45,7 @@ defmodule UnifiedUi.Parity do data_widgets: widgets.data, feedback_widgets: widgets.feedback, advanced_widgets: widgets.advanced, + component_widgets: widgets.components, form_constructs: widgets.forms, container_constructs: widgets.container, layout_constructs: display.layout, diff --git a/packages/unified-ui/lib/unified_ui/reference.ex b/packages/unified-ui/lib/unified_ui/reference.ex index d1ccbb35..9a6ba59e 100644 --- a/packages/unified-ui/lib/unified_ui/reference.ex +++ b/packages/unified-ui/lib/unified_ui/reference.ex @@ -64,6 +64,7 @@ defmodule UnifiedUi.Reference do data: UnifiedIUR.Widgets.data_view_kinds(), feedback: UnifiedIUR.Widgets.feedback_kinds(), advanced: UnifiedIUR.Widgets.advanced_kinds(), + components: UnifiedIUR.Widgets.component_kinds(), forms: UnifiedIUR.Forms.kinds(), container: [:box] } diff --git a/packages/unified-ui/test/unified_ui/compiler_inspection_test.exs b/packages/unified-ui/test/unified_ui/compiler_inspection_test.exs index 98e08726..336c0a0f 100644 --- a/packages/unified-ui/test/unified_ui/compiler_inspection_test.exs +++ b/packages/unified-ui/test/unified_ui/compiler_inspection_test.exs @@ -205,6 +205,7 @@ defmodule UnifiedUi.CompilerInspectionTest do data: UnifiedIUR.Widgets.data_view_kinds(), feedback: UnifiedIUR.Widgets.feedback_kinds(), advanced: UnifiedIUR.Widgets.advanced_kinds(), + components: UnifiedIUR.Widgets.component_kinds(), forms: UnifiedIUR.Forms.kinds(), container: [:box] }, diff --git a/packages/unified-ui/test/unified_ui/examples_test.exs b/packages/unified-ui/test/unified_ui/examples_test.exs index 1b13ea3b..6b726338 100644 --- a/packages/unified-ui/test/unified_ui/examples_test.exs +++ b/packages/unified-ui/test/unified_ui/examples_test.exs @@ -86,6 +86,7 @@ defmodule UnifiedUi.ExamplesTest do constructs: [ :advanced, :canvas, + :components, :data, :display, :feedback, @@ -101,6 +102,7 @@ defmodule UnifiedUi.ExamplesTest do parity_obligations: [ :advanced_widgets, :canvas_constructs, + :component_widgets, :container_constructs, :data_widgets, :feedback_widgets, diff --git a/packages/unified-ui/test/unified_ui/parity_test.exs b/packages/unified-ui/test/unified_ui/parity_test.exs index 966f5453..81e2db88 100644 --- a/packages/unified-ui/test/unified_ui/parity_test.exs +++ b/packages/unified-ui/test/unified_ui/parity_test.exs @@ -12,6 +12,7 @@ defmodule UnifiedUi.ParityTest do data_widgets: Reference.compiled_widget_families().data, feedback_widgets: Reference.compiled_widget_families().feedback, advanced_widgets: Reference.compiled_widget_families().advanced, + component_widgets: Reference.compiled_widget_families().components, form_constructs: Reference.compiled_widget_families().forms, container_constructs: Reference.compiled_widget_families().container, layout_constructs: Reference.compiled_display_system_families().layout, diff --git a/packages/unified-ui/test/unified_ui/phase_6_integration_test.exs b/packages/unified-ui/test/unified_ui/phase_6_integration_test.exs index 3313aae2..a8985af2 100644 --- a/packages/unified-ui/test/unified_ui/phase_6_integration_test.exs +++ b/packages/unified-ui/test/unified_ui/phase_6_integration_test.exs @@ -51,9 +51,11 @@ defmodule UnifiedUi.Phase6IntegrationTest do assert diff.changes.widget_kinds.added == [ :cluster_dashboard, :gauge, + :inline_rich_text_heading, :log_viewer, :markdown_viewer, :process_monitor, + :segmented_progress_bar, :sparkline, :stream_widget, :table, diff --git a/packages/unified-ui/test/unified_ui/tooling_test.exs b/packages/unified-ui/test/unified_ui/tooling_test.exs index 7af6b8b6..00dc05f3 100644 --- a/packages/unified-ui/test/unified_ui/tooling_test.exs +++ b/packages/unified-ui/test/unified_ui/tooling_test.exs @@ -203,9 +203,11 @@ defmodule UnifiedUi.ToolingTest do assert diff.changes.widget_kinds.added == [ :cluster_dashboard, :gauge, + :inline_rich_text_heading, :log_viewer, :markdown_viewer, :process_monitor, + :segmented_progress_bar, :sparkline, :stream_widget, :table, diff --git a/packages/unified-ui/test/unified_ui/validation_test.exs b/packages/unified-ui/test/unified_ui/validation_test.exs index 0c83b6ab..f350caa6 100644 --- a/packages/unified-ui/test/unified_ui/validation_test.exs +++ b/packages/unified-ui/test/unified_ui/validation_test.exs @@ -30,6 +30,7 @@ defmodule UnifiedUi.ValidationTest do parity_obligations: [ :advanced_widgets, :canvas_constructs, + :component_widgets, :container_constructs, :data_widgets, :feedback_widgets, diff --git a/packages/unified-ui/test/unified_ui/widget_components_phase_2_integration_test.exs b/packages/unified-ui/test/unified_ui/widget_components_phase_2_integration_test.exs new file mode 100644 index 00000000..02e46de8 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/widget_components_phase_2_integration_test.exs @@ -0,0 +1,138 @@ +defmodule UnifiedUi.WidgetComponentsPhase2IntegrationTest do + use ExUnit.Case, async: true + + alias UnifiedIUR.{Export, Fixtures, Interaction, Interoperability, Tree, Validate} + alias UnifiedUi.Compiler + + defmodule EndToEndWidgetScreen do + use UnifiedUi.Dsl + + identity do + id(:widget_components_phase_2_screen) + authored_ref([:tests, :widget_components_phase_2_screen]) + end + + signals do + data_binding do + id(:artifact_rows) + path([:artifacts]) + collection?(true) + default([%{id: "adr-1", title: "Widget ADR", status: :accepted}]) + end + end + + composition do + root(:widget_components_phase_2_root) + mode(:screen) + + inline_rich_text_heading :headline do + level(:h2) + segments([%{type: :text, value: "Widget components"}]) + end + + segmented_button_group :status_filter do + options([%{value: :all, label: "All"}, %{value: :open, label: "Open"}]) + active_value(:all) + selection_intent(:select_status) + end + + runtime_form_shell :settings_form do + fields([%{name: :title, type: :text, label: "Title"}]) + submit_label("Save") + submit_intent(:save_settings) + change_intent(:validate_settings) + end + + slide_over_panel :details_panel do + accessibility_label("Details") + open?(true) + dismiss_intent(:close_details) + end + + redline_inline :copy_redline do + segments([%{state: :insert, text: ""}]) + end + + code_block_syntax_highlighted :code_sample do + language(:elixir) + tokens([%{type: :keyword, text: "defmodule"}, %{type: :text, text: " Demo"}]) + end + + list_repeat :artifact_repeat do + repeat_binding(:artifact_rows) + row_fields([:id, :title, :status]) + template_identity(:artifact_template) + + artifact_row_template :artifact_template do + title("Artifact") + row_identity(:id) + meta(%{status: :status}) + action_intent(:open_artifact) + end + end + end + end + + test "compiles expanded widget components into valid deterministic IUR snapshots" do + result = Compiler.compile!(EndToEndWidgetScreen) + listing = Compiler.listing(EndToEndWidgetScreen) + snapshot = Export.snapshot(result.iur) + + assert :ok = Validate.element(result.iur) + + for kind <- [ + :inline_rich_text_heading, + :segmented_button_group, + :runtime_form_shell, + :slide_over_panel, + :redline_inline, + :code_block_syntax_highlighted, + :list_repeat, + :artifact_row + ] do + assert kind in listing.compiled.widget_kinds + assert snapshot =~ to_string(kind) + end + + refute snapshot =~ "AshUi" + refute snapshot =~ "phx_" + end + + test "preserves interaction descriptors and repeat hydration end to end" do + iur = Compiler.iur!(EndToEndWidgetScreen) + + segmented = Tree.find_by_id(iur, :status_filter) + form = Tree.find_by_id(iur, :settings_form) + panel = Tree.find_by_id(iur, :details_panel) + repeat = Tree.find_by_id(iur, :artifact_repeat) + + assert [%Interaction{family: :selection, intent: :select_status}] = + segmented.attributes.interactions + + assert Enum.map(form.attributes.interactions, &{&1.family, &1.intent}) == [ + {:submit, :save_settings}, + {:change, :validate_settings} + ] + + assert [%Interaction{family: :close, intent: :close_details}] = panel.attributes.interactions + + assert repeat.attributes.repeat.hydrated? == true + assert repeat.attributes.repeat.row_count == 1 + assert [%{element: hydrated}] = repeat.children + assert hydrated.id == "artifact_repeat:adr-1:artifact_template" + assert hydrated.attributes.artifact.row_identity == "adr-1" + assert hydrated.attributes.artifact.meta == %{status: :accepted} + end + + test "component fixture gives runtimes shared validation and parity evidence" do + fixture = Fixtures.fixture!("components--accessibility_and_safety") + kinds = fixture.element |> Interoperability.walk() |> Enum.map(& &1.kind) + + assert :ok = Validate.element(fixture.element) + assert fixture.category == :components + assert "redline and code plain-text safety" in fixture.semantics + assert :redline_inline in kinds + assert :slide_over_panel in kinds + assert :list_repeat in kinds + end +end From e0b98fadd4d516f2b4a59cf37e0b187f60766c9f Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 12:04:03 -0400 Subject: [PATCH 12/21] Implement LiveUi widget component backbone --- ...e-components-and-iur-renderer-alignment.md | 18 +- packages/live_ui/lib/live_ui/component.ex | 16 +- packages/live_ui/lib/live_ui/widgets.ex | 22 +- .../live_ui/lib/live_ui/widgets/components.ex | 273 +++++++++++++++++ .../widgets/components/content_identity.ex | 209 +++++++++++++ .../widgets/components/form_control.ex | 143 +++++++++ .../live_ui/widgets/components/layer_text.ex | 239 +++++++++++++++ .../widgets/components/row_workflow.ex | 282 ++++++++++++++++++ .../lib/live_ui/widgets/components/support.ex | 91 ++++++ .../widget_components_backbone_test.exs | 54 ++++ 10 files changed, 1335 insertions(+), 12 deletions(-) create mode 100644 packages/live_ui/lib/live_ui/widgets/components.ex create mode 100644 packages/live_ui/lib/live_ui/widgets/components/content_identity.ex create mode 100644 packages/live_ui/lib/live_ui/widgets/components/form_control.ex create mode 100644 packages/live_ui/lib/live_ui/widgets/components/layer_text.ex create mode 100644 packages/live_ui/lib/live_ui/widgets/components/row_workflow.ex create mode 100644 packages/live_ui/lib/live_ui/widgets/components/support.ex create mode 100644 packages/live_ui/test/live_ui/widget_components_backbone_test.exs diff --git a/.spec/planning/canonical_widget_components/phase-03-live-ui-native-components-and-iur-renderer-alignment.md b/.spec/planning/canonical_widget_components/phase-03-live-ui-native-components-and-iur-renderer-alignment.md index 6c372074..266d6187 100644 --- a/.spec/planning/canonical_widget_components/phase-03-live-ui-native-components-and-iur-renderer-alignment.md +++ b/.spec/planning/canonical_widget_components/phase-03-live-ui-native-components-and-iur-renderer-alignment.md @@ -24,25 +24,25 @@ Back to index: [README](./README.md) Implement the expanded widget-component catalog in LiveUi and make the canonical renderer map IUR input into those same native components. - [ ] 3.1 Section - LiveUi Component Module Backbone + [x] 3.1 Section - LiveUi Component Module Backbone Establish shared module layout, assigns contracts, and renderer dispatch conventions for the expanded catalog. - [ ] 3.1.1 Task - Define component modules and assigns contracts + [x] 3.1.1 Task - Define component modules and assigns contracts Create the component boundaries and shared assign validation needed for consistent implementation. - [ ] 3.1.1.1 Subtask - Add component modules or grouped modules for content, identity, controls, rows, progress, layers, callouts, redline, code, and composer widgets. - [ ] 3.1.1.2 Subtask - Define assign contracts for canonical props, children, interaction descriptors, and accessibility metadata. - [ ] 3.1.1.3 Subtask - Ensure helper APIs delegate to component boundaries rather than creating parallel render implementations. + [x] 3.1.1.1 Subtask - Add component modules or grouped modules for content, identity, controls, rows, progress, layers, callouts, redline, code, and composer widgets. + [x] 3.1.1.2 Subtask - Define assign contracts for canonical props, children, interaction descriptors, and accessibility metadata. + [x] 3.1.1.3 Subtask - Ensure helper APIs delegate to component boundaries rather than creating parallel render implementations. - [ ] 3.1.2 Task - Define shared rendering and style hooks + [x] 3.1.2 Task - Define shared rendering and style hooks Keep styling token-driven and reusable across native and IUR-rendered LiveUi paths. - [ ] 3.1.2.1 Subtask - Define common class, token, state, and size hooks for the expanded catalog. - [ ] 3.1.2.2 Subtask - Avoid literal color, font, or spacing values in component output where theme tokens should apply. - [ ] 3.1.2.3 Subtask - Add component metadata used by tooling and focused examples. + [x] 3.1.2.1 Subtask - Define common class, token, state, and size hooks for the expanded catalog. + [x] 3.1.2.2 Subtask - Avoid literal color, font, or spacing values in component output where theme tokens should apply. + [x] 3.1.2.3 Subtask - Add component metadata used by tooling and focused examples. [ ] 3.2 Section - Content, Identity, Form, Control, and Composer Components Implement LiveUi-native components for the widgets that are central to diff --git a/packages/live_ui/lib/live_ui/component.ex b/packages/live_ui/lib/live_ui/component.ex index ce422716..486302d7 100644 --- a/packages/live_ui/lib/live_ui/component.ex +++ b/packages/live_ui/lib/live_ui/component.ex @@ -11,7 +11,19 @@ defmodule LiveUi.Component do @type assigns_contract :: [atom()] @type style_hook :: :tone | :variant | :state - @type event_surface :: :click | :navigate | :submit | :change | :selection | :command + @type event_surface :: + :click + | :navigate + | :submit + | :change + | :selection + | :command + | :send + | :disclosure + | :panel + | :row_activation + | :step_navigation + | :inline_action @callback metadata() :: Metadata.t() @callback render(map()) :: Phoenix.LiveView.Rendered.t() @@ -122,6 +134,7 @@ defmodule LiveUi.Component do path = Map.get(assigns, :path, []) widget_mode = runtime_mode(runtime_state) + widget_identity = widget_identity(module, widget_assigns, mode: widget_mode, @@ -183,6 +196,7 @@ defmodule LiveUi.Component do {:layout, :separator}, {:layout, :spacer} ] + structural = {family, name} in structural_primitives quote bind_quoted: [ diff --git a/packages/live_ui/lib/live_ui/widgets.ex b/packages/live_ui/lib/live_ui/widgets.ex index 6ef7843b..044673f7 100644 --- a/packages/live_ui/lib/live_ui/widgets.ex +++ b/packages/live_ui/lib/live_ui/widgets.ex @@ -13,19 +13,32 @@ defmodule LiveUi.Widgets do | :data | :operational | :display + | :components @type widget_module :: module() @spec families() :: [family()] def families do - [:content, :input, :navigation, :feedback, :layout, :overlay, :data, :operational, :display] + [ + :content, + :input, + :navigation, + :feedback, + :layout, + :overlay, + :data, + :operational, + :display, + :components + ] end @spec modules() :: [widget_module()] def modules do foundational_modules() ++ input_modules() ++ - navigation_modules() ++ advanced_modules() ++ overlay_modules() ++ display_modules() + navigation_modules() ++ + advanced_modules() ++ overlay_modules() ++ display_modules() ++ component_modules() end @spec metadata() :: [LiveUi.Component.Metadata.t()] @@ -63,6 +76,11 @@ defmodule LiveUi.Widgets do LiveUi.Widgets.Display.modules() end + @spec component_modules() :: [widget_module()] + def component_modules do + LiveUi.Widgets.Components.modules() + end + @spec namespace() :: module() def namespace, do: __MODULE__ end diff --git a/packages/live_ui/lib/live_ui/widgets/components.ex b/packages/live_ui/lib/live_ui/widgets/components.ex new file mode 100644 index 00000000..b540cb6b --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/components.ex @@ -0,0 +1,273 @@ +defmodule LiveUi.Widgets.Components do + @moduledoc """ + Native LiveUi component registry for the canonical widget-component catalog. + """ + + @type component_family :: + :content_identity + | :form_control + | :row_artifact + | :workflow_progress + | :layer_callout + | :redline_code + | :composition_behavior + + @type component_contract :: %{ + module: module(), + family: component_family(), + assigns: [atom()], + slots: [atom()], + events: [atom()], + local_state_keys: [atom()] + } + + @content_identity_modules [ + LiveUi.Widgets.Components.InlineRichTextHeading, + LiveUi.Widgets.Components.Disclosure, + LiveUi.Widgets.Components.Kicker, + LiveUi.Widgets.Components.Avatar, + LiveUi.Widgets.Components.PresenceDot + ] + + @form_control_modules [ + LiveUi.Widgets.Components.SegmentedButtonGroup, + LiveUi.Widgets.Components.RuntimeFormShell, + LiveUi.Widgets.Components.ChatComposer + ] + + @row_artifact_modules [ + LiveUi.Widgets.Components.ListItemMultiColumn, + LiveUi.Widgets.Components.ArtifactRow + ] + + @workflow_progress_modules [ + LiveUi.Widgets.Components.PipelineStepperHorizontal, + LiveUi.Widgets.Components.SegmentedProgressBar, + LiveUi.Widgets.Components.WorkflowStageListVertical, + LiveUi.Widgets.Components.MeterThin + ] + + @layer_callout_modules [ + LiveUi.Widgets.Components.StickyFrostedHeader, + LiveUi.Widgets.Components.SlideOverPanel, + LiveUi.Widgets.Components.EventCallout + ] + + @redline_code_modules [ + LiveUi.Widgets.Components.RedlineInline, + LiveUi.Widgets.Components.CodeBlockSyntaxHighlighted + ] + + @composition_behavior_modules [ + LiveUi.Widgets.Components.ListRepeat + ] + + @contracts %{ + inline_rich_text_heading: %{ + module: LiveUi.Widgets.Components.InlineRichTextHeading, + family: :content_identity, + assigns: [:level, :segments], + slots: [], + events: [], + local_state_keys: [] + }, + disclosure: %{ + module: LiveUi.Widgets.Components.Disclosure, + family: :content_identity, + assigns: [:summary, :open], + slots: [:inner_block], + events: [:disclosure], + local_state_keys: [:open] + }, + kicker: %{ + module: LiveUi.Widgets.Components.Kicker, + family: :content_identity, + assigns: [:items, :separator], + slots: [], + events: [], + local_state_keys: [] + }, + avatar: %{ + module: LiveUi.Widgets.Components.Avatar, + family: :content_identity, + assigns: [:initials, :image_source, :size, :shape, :label], + slots: [], + events: [], + local_state_keys: [] + }, + presence_dot: %{ + module: LiveUi.Widgets.Components.PresenceDot, + family: :content_identity, + assigns: [:presence, :size, :label], + slots: [], + events: [], + local_state_keys: [] + }, + segmented_button_group: %{ + module: LiveUi.Widgets.Components.SegmentedButtonGroup, + family: :form_control, + assigns: [:options, :active_value, :disabled, :label], + slots: [], + events: [:selection], + local_state_keys: [] + }, + runtime_form_shell: %{ + module: LiveUi.Widgets.Components.RuntimeFormShell, + family: :form_control, + assigns: [:fields, :submit_label, :validation_state, :host_adapter_hints], + slots: [:inner_block], + events: [:submit, :change], + local_state_keys: [] + }, + chat_composer: %{ + module: LiveUi.Widgets.Components.ChatComposer, + family: :form_control, + assigns: [:name, :value, :placeholder, :rows, :send_label, :disabled], + slots: [:tools], + events: [:send, :change], + local_state_keys: [] + }, + list_item_multi_column: %{ + module: LiveUi.Widgets.Components.ListItemMultiColumn, + family: :row_artifact, + assigns: [:row_identity, :columns, :active, :link_target], + slots: [:inner_block, :actions], + events: [:row_activation], + local_state_keys: [] + }, + artifact_row: %{ + module: LiveUi.Widgets.Components.ArtifactRow, + family: :row_artifact, + assigns: [:title, :meta, :row_identity, :active, :link_target], + slots: [:inner_block, :actions], + events: [:row_activation], + local_state_keys: [] + }, + pipeline_stepper_horizontal: %{ + module: LiveUi.Widgets.Components.PipelineStepperHorizontal, + family: :workflow_progress, + assigns: [:steps, :active_index, :completed_indices], + slots: [], + events: [:step_navigation], + local_state_keys: [] + }, + segmented_progress_bar: %{ + module: LiveUi.Widgets.Components.SegmentedProgressBar, + family: :workflow_progress, + assigns: [:segments, :aggregate_progress, :label], + slots: [], + events: [], + local_state_keys: [] + }, + workflow_stage_list_vertical: %{ + module: LiveUi.Widgets.Components.WorkflowStageListVertical, + family: :workflow_progress, + assigns: [:stages, :active_index], + slots: [], + events: [:step_navigation], + local_state_keys: [] + }, + meter_thin: %{ + module: LiveUi.Widgets.Components.MeterThin, + family: :workflow_progress, + assigns: [:current, :minimum, :maximum, :label], + slots: [], + events: [], + local_state_keys: [] + }, + sticky_frosted_header: %{ + module: LiveUi.Widgets.Components.StickyFrostedHeader, + family: :layer_callout, + assigns: [:title, :leading, :trailing], + slots: [:inner_block], + events: [], + local_state_keys: [] + }, + slide_over_panel: %{ + module: LiveUi.Widgets.Components.SlideOverPanel, + family: :layer_callout, + assigns: [:open, :size, :label], + slots: [:inner_block], + events: [:panel], + local_state_keys: [:open] + }, + event_callout: %{ + module: LiveUi.Widgets.Components.EventCallout, + family: :layer_callout, + assigns: [:message, :eyebrow, :title, :callout_tone], + slots: [:inner_block, :actions], + events: [:inline_action], + local_state_keys: [] + }, + redline_inline: %{ + module: LiveUi.Widgets.Components.RedlineInline, + family: :redline_code, + assigns: [:segments], + slots: [], + events: [], + local_state_keys: [] + }, + code_block_syntax_highlighted: %{ + module: LiveUi.Widgets.Components.CodeBlockSyntaxHighlighted, + family: :redline_code, + assigns: [:language, :tokens], + slots: [], + events: [], + local_state_keys: [] + }, + list_repeat: %{ + module: LiveUi.Widgets.Components.ListRepeat, + family: :composition_behavior, + assigns: [:repeat], + slots: [:inner_block], + events: [], + local_state_keys: [] + } + } + + @spec modules() :: [module()] + def modules do + content_identity_modules() ++ + form_control_modules() ++ + row_artifact_modules() ++ + workflow_progress_modules() ++ + layer_callout_modules() ++ + redline_code_modules() ++ + composition_behavior_modules() + end + + @spec content_identity_modules() :: [module()] + def content_identity_modules, do: @content_identity_modules + + @spec form_control_modules() :: [module()] + def form_control_modules, do: @form_control_modules + + @spec row_artifact_modules() :: [module()] + def row_artifact_modules, do: @row_artifact_modules + + @spec workflow_progress_modules() :: [module()] + def workflow_progress_modules, do: @workflow_progress_modules + + @spec layer_callout_modules() :: [module()] + def layer_callout_modules, do: @layer_callout_modules + + @spec redline_code_modules() :: [module()] + def redline_code_modules, do: @redline_code_modules + + @spec composition_behavior_modules() :: [module()] + def composition_behavior_modules, do: @composition_behavior_modules + + @spec contracts() :: %{atom() => component_contract()} + def contracts, do: @contracts + + @spec contract(atom()) :: component_contract() + def contract(kind), do: Map.fetch!(@contracts, kind) + + @spec module_for(atom()) :: module() | nil + def module_for(kind) do + case Map.fetch(@contracts, kind) do + {:ok, %{module: module}} -> module + :error -> nil + end + end +end diff --git a/packages/live_ui/lib/live_ui/widgets/components/content_identity.ex b/packages/live_ui/lib/live_ui/widgets/components/content_identity.ex new file mode 100644 index 00000000..38b56625 --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/components/content_identity.ex @@ -0,0 +1,209 @@ +defmodule LiveUi.Widgets.Components.InlineRichTextHeading do + @moduledoc """ + Native rich inline heading component backed by plain text segments. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :inline_rich_text_heading, + assigns: [:level, :segments] + + LiveUi.Component.common_attrs() + attr(:level, :string, default: "h2") + attr(:segments, :list, default: []) + + @impl true + def render(assigns) do + assigns = + assigns + |> assign(:heading_level, heading_level(assigns.level)) + |> assign( + :component_attrs, + Support.component_attrs(assigns, :inline_rich_text_heading, :content_identity) + ) + + ~H""" +
+ <%= Support.text(Support.fetch(segment, :value, Support.fetch(segment, :text))) %> +
+ """ + end + + defp heading_level("h" <> value), do: value + + defp heading_level(value) when value in [:h1, :h2, :h3, :h4, :h5, :h6], + do: value |> to_string() |> String.trim_leading("h") + + defp heading_level(value) when is_integer(value), do: value + defp heading_level(_value), do: 2 +end + +defmodule LiveUi.Widgets.Components.Disclosure do + @moduledoc """ + Native disclosure component with bounded open state metadata. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :disclosure, + slots: [:inner_block], + assigns: [:summary, :open], + events: [:disclosure], + local_state_keys: [:open] + + LiveUi.Component.common_attrs() + attr(:summary, :string, required: true) + attr(:open, :boolean, default: false) + slot(:inner_block) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :disclosure, :content_identity, %{ + "data-live-ui-open" => assigns.open + }) + ) + + ~H""" +
+ <%= @summary %> +
<%= render_slot(@inner_block) %>
+
+ """ + end +end + +defmodule LiveUi.Widgets.Components.Kicker do + @moduledoc """ + Native kicker label component for compact context trails. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :kicker, + assigns: [:items, :separator] + + LiveUi.Component.common_attrs() + attr(:items, :list, default: []) + attr(:separator, :string, default: "/") + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :kicker, :content_identity) + ) + + ~H""" +

+ <%= for {item, index} <- Enum.with_index(@items) do %> + <%= item %> + + <% end %> +

+ """ + end +end + +defmodule LiveUi.Widgets.Components.Avatar do + @moduledoc """ + Native avatar component with image and initials fallbacks. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :avatar, + assigns: [:initials, :image_source, :size, :shape, :label] + + LiveUi.Component.common_attrs() + attr(:initials, :string, default: nil) + attr(:image_source, :string, default: nil) + attr(:size, :string, default: "medium") + attr(:shape, :string, default: "round") + attr(:label, :string, default: nil) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :avatar, :content_identity, %{ + "data-live-ui-avatar-size" => assigns.size, + "data-live-ui-avatar-shape" => assigns.shape + }) + ) + + ~H""" + + + <%= @initials %> + + """ + end +end + +defmodule LiveUi.Widgets.Components.PresenceDot do + @moduledoc """ + Native presence indicator component with accessible state labels. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :presence_dot, + assigns: [:presence, :size, :label] + + LiveUi.Component.common_attrs() + attr(:presence, :string, default: "offline") + attr(:size, :string, default: "medium") + attr(:label, :string, default: nil) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :presence_dot, :content_identity, %{ + "data-live-ui-presence" => assigns.presence, + "data-live-ui-presence-size" => assigns.size + }) + ) + + ~H""" + + + + """ + end +end diff --git a/packages/live_ui/lib/live_ui/widgets/components/form_control.ex b/packages/live_ui/lib/live_ui/widgets/components/form_control.ex new file mode 100644 index 00000000..dc338afb --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/components/form_control.ex @@ -0,0 +1,143 @@ +defmodule LiveUi.Widgets.Components.SegmentedButtonGroup do + @moduledoc """ + Native segmented button group preserving canonical selection meaning. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :segmented_button_group, + assigns: [:options, :active_value, :disabled, :label], + events: [:selection] + + LiveUi.Component.common_attrs() + attr(:options, :list, default: []) + attr(:active_value, :any, default: nil) + attr(:disabled, :boolean, default: false) + attr(:label, :string, default: nil) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :segmented_button_group, :form_control, %{ + "data-live-ui-disabled" => assigns.disabled + }) + ) + + ~H""" +
+ +
+ """ + end +end + +defmodule LiveUi.Widgets.Components.RuntimeFormShell do + @moduledoc """ + LiveUi-owned form shell for Phoenix-local realization of canonical form meaning. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :runtime_form_shell, + slots: [:inner_block], + assigns: [:fields, :submit_label, :validation_state, :host_adapter_hints], + events: [:submit, :change] + + LiveUi.Component.common_attrs() + attr(:fields, :list, default: []) + attr(:submit_label, :string, default: "Submit") + attr(:validation_state, :string, default: nil) + attr(:host_adapter_hints, :map, default: %{}) + slot(:inner_block) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :runtime_form_shell, :form_control, %{ + "data-live-ui-validation-state" => assigns.validation_state, + "data-live-ui-host-adapter" => adapter_hint(assigns.host_adapter_hints) + }) + ) + + ~H""" +
+ + <%= render_slot(@inner_block) %> + +
+ """ + end + + defp adapter_hint(hints) do + hints + |> Support.fetch(:live_ui, %{}) + |> Support.fetch(:adapter) + end +end + +defmodule LiveUi.Widgets.Components.ChatComposer do + @moduledoc """ + Native chat composer with message text and tool slots. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :chat_composer, + slots: [:tools], + assigns: [:name, :value, :placeholder, :rows, :send_label, :disabled], + events: [:send, :change] + + LiveUi.Component.common_attrs() + attr(:name, :string, default: "message") + attr(:value, :string, default: "") + attr(:placeholder, :string, default: nil) + attr(:rows, :integer, default: 3) + attr(:send_label, :string, default: "Send") + attr(:disabled, :boolean, default: false) + slot(:tools) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :chat_composer, :form_control, %{ + "data-live-ui-disabled" => assigns.disabled + }) + ) + + ~H""" +
+ +
<%= render_slot(@tools) %>
+ +
+ """ + end +end diff --git a/packages/live_ui/lib/live_ui/widgets/components/layer_text.ex b/packages/live_ui/lib/live_ui/widgets/components/layer_text.ex new file mode 100644 index 00000000..8579cada --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/components/layer_text.ex @@ -0,0 +1,239 @@ +defmodule LiveUi.Widgets.Components.StickyFrostedHeader do + @moduledoc """ + Native sticky header component with token-addressable frosted styling. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :sticky_frosted_header, + slots: [:inner_block], + assigns: [:title, :leading, :trailing] + + LiveUi.Component.common_attrs() + attr(:title, :string, default: nil) + attr(:leading, :list, default: []) + attr(:trailing, :list, default: []) + slot(:inner_block) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :sticky_frosted_header, :layer_callout, %{ + "data-live-ui-position" => "sticky", + "data-live-ui-visual-effect" => "frosted" + }) + ) + + ~H""" +
+ +

<%= @title %>

+
<%= render_slot(@inner_block) %>
+ +
+ """ + end +end + +defmodule LiveUi.Widgets.Components.SlideOverPanel do + @moduledoc """ + Native non-modal slide-over panel. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :slide_over_panel, + slots: [:inner_block], + assigns: [:open, :size, :label], + events: [:panel], + local_state_keys: [:open] + + LiveUi.Component.common_attrs() + attr(:open, :boolean, default: false) + attr(:size, :string, default: "medium") + attr(:label, :string, default: nil) + slot(:inner_block) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :slide_over_panel, :layer_callout, %{ + "data-live-ui-open" => assigns.open, + "data-live-ui-panel-size" => assigns.size + }) + ) + + ~H""" + + """ + end +end + +defmodule LiveUi.Widgets.Components.EventCallout do + @moduledoc """ + Native event callout component for workflow annotations. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :event_callout, + slots: [:inner_block, :actions], + assigns: [:message, :eyebrow, :title, :callout_tone], + events: [:inline_action] + + LiveUi.Component.common_attrs() + attr(:message, :string, required: true) + attr(:eyebrow, :string, default: nil) + attr(:title, :string, default: nil) + attr(:callout_tone, :string, default: "info") + slot(:inner_block) + slot(:actions) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :event_callout, :layer_callout, %{ + "data-live-ui-callout-tone" => assigns.callout_tone + }) + ) + + ~H""" + + """ + end +end + +defmodule LiveUi.Widgets.Components.RedlineInline do + @moduledoc """ + Native inline redline renderer for plain text review segments. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :redline_inline, + assigns: [:segments] + + LiveUi.Component.common_attrs() + attr(:segments, :list, default: []) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :redline_inline, :redline_code) + ) + + ~H""" + + <%= Support.text(Support.fetch(segment, :text)) %> + + """ + end +end + +defmodule LiveUi.Widgets.Components.CodeBlockSyntaxHighlighted do + @moduledoc """ + Native pre-tokenized code block renderer for plain text code tokens. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :code_block_syntax_highlighted, + assigns: [:language, :tokens] + + LiveUi.Component.common_attrs() + attr(:language, :string, default: "text") + attr(:tokens, :list, default: []) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :code_block_syntax_highlighted, :redline_code, %{ + "data-live-ui-code-language" => assigns.language + }) + ) + + ~H""" +
<%= Support.text(Support.fetch(token, :text)) %>
+ """ + end +end + +defmodule LiveUi.Widgets.Components.ListRepeat do + @moduledoc """ + Native wrapper for already-hydrated list-repeat child output. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :list_repeat, + slots: [:inner_block], + assigns: [:repeat] + + LiveUi.Component.common_attrs() + attr(:repeat, :map, default: %{}) + slot(:inner_block) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :list_repeat, :composition_behavior, %{ + "data-live-ui-repeat-binding" => Support.fetch(assigns.repeat, :binding_id), + "data-live-ui-repeat-row-count" => Support.fetch(assigns.repeat, :row_count, 0) + }) + ) + + ~H""" +
+ <%= render_slot(@inner_block) %> +
+ """ + end +end diff --git a/packages/live_ui/lib/live_ui/widgets/components/row_workflow.ex b/packages/live_ui/lib/live_ui/widgets/components/row_workflow.ex new file mode 100644 index 00000000..40b6d05f --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/components/row_workflow.ex @@ -0,0 +1,282 @@ +defmodule LiveUi.Widgets.Components.ListItemMultiColumn do + @moduledoc """ + Native multi-column list row component. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :list_item_multi_column, + slots: [:inner_block, :actions], + assigns: [:row_identity, :columns, :active, :link_target], + events: [:row_activation] + + LiveUi.Component.common_attrs() + attr(:row_identity, :any, default: nil) + attr(:columns, :list, default: []) + attr(:active, :boolean, default: false) + attr(:link_target, :string, default: nil) + slot(:inner_block) + slot(:actions) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :list_item_multi_column, :row_artifact, %{ + "data-live-ui-row-id" => assigns.row_identity, + "data-live-ui-active" => assigns.active + }) + ) + + ~H""" +
+ <%= @link_target %> +
+ + <%= Support.label(column) %> + + <%= render_slot(@inner_block) %> +
+
<%= render_slot(@actions) %>
+
+ """ + end +end + +defmodule LiveUi.Widgets.Components.ArtifactRow do + @moduledoc """ + Native artifact row component for review and workflow lists. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :artifact_row, + slots: [:inner_block, :actions], + assigns: [:title, :meta, :row_identity, :active, :link_target], + events: [:row_activation] + + LiveUi.Component.common_attrs() + attr(:title, :string, required: true) + attr(:meta, :map, default: %{}) + attr(:row_identity, :any, default: nil) + attr(:active, :boolean, default: false) + attr(:link_target, :string, default: nil) + slot(:inner_block) + slot(:actions) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :artifact_row, :row_artifact, %{ + "data-live-ui-row-id" => assigns.row_identity, + "data-live-ui-active" => assigns.active + }) + ) + + ~H""" +
+

<%= @title %>

+
0}> + <%= for {key, value} <- Enum.sort_by(@meta, fn {key, _value} -> to_string(key) end) do %> +
<%= key %>
+
<%= value %>
+ <% end %> +
+
<%= render_slot(@inner_block) %>
+
<%= render_slot(@actions) %>
+
+ """ + end +end + +defmodule LiveUi.Widgets.Components.PipelineStepperHorizontal do + @moduledoc """ + Native horizontal workflow stepper. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :pipeline_stepper_horizontal, + assigns: [:steps, :active_index, :completed_indices], + events: [:step_navigation] + + LiveUi.Component.common_attrs() + attr(:steps, :list, default: []) + attr(:active_index, :integer, default: 0) + attr(:completed_indices, :list, default: []) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :pipeline_stepper_horizontal, :workflow_progress) + ) + + ~H""" +
    +
  1. + +
  2. +
+ """ + end + + defp step_state(step, index, active_index, completed_indices) do + Support.fetch(step, :state) || + cond do + index == active_index -> :active + index in completed_indices -> :done + true -> :pending + end + end +end + +defmodule LiveUi.Widgets.Components.SegmentedProgressBar do + @moduledoc """ + Native segmented progress component. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :segmented_progress_bar, + assigns: [:segments, :aggregate_progress, :label] + + LiveUi.Component.common_attrs() + attr(:segments, :list, default: []) + attr(:aggregate_progress, :map, default: %{}) + attr(:label, :string, default: nil) + + @impl true + def render(assigns) do + assigns = + assigns + |> assign(:current, Support.fetch(assigns.aggregate_progress, :current, 0)) + |> assign(:maximum, Support.fetch(assigns.aggregate_progress, :maximum, 100)) + |> assign( + :component_attrs, + Support.component_attrs(assigns, :segmented_progress_bar, :workflow_progress) + ) + + ~H""" +
+ <%= Support.label(segment) %> +
+ """ + end +end + +defmodule LiveUi.Widgets.Components.WorkflowStageListVertical do + @moduledoc """ + Native vertical workflow stage list. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :workflow_stage_list_vertical, + assigns: [:stages, :active_index], + events: [:step_navigation] + + LiveUi.Component.common_attrs() + attr(:stages, :list, default: []) + attr(:active_index, :integer, default: 0) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :workflow_stage_list_vertical, :workflow_progress) + ) + + ~H""" +
    +
  1. + <%= Support.label(stage) %> +
  2. +
+ """ + end +end + +defmodule LiveUi.Widgets.Components.MeterThin do + @moduledoc """ + Native thin meter component for compact status values. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :meter_thin, + assigns: [:current, :minimum, :maximum, :label] + + LiveUi.Component.common_attrs() + attr(:current, :float, default: 0.0) + attr(:minimum, :float, default: 0.0) + attr(:maximum, :float, default: 100.0) + attr(:label, :string, default: nil) + + @impl true + def render(assigns) do + assigns = + assigns + |> assign(:percent, Support.percentage(assigns.current, assigns.minimum, assigns.maximum)) + |> assign( + :component_attrs, + Support.component_attrs(assigns, :meter_thin, :workflow_progress) + ) + + ~H""" + <%= @label %> + """ + end +end diff --git a/packages/live_ui/lib/live_ui/widgets/components/support.ex b/packages/live_ui/lib/live_ui/widgets/components/support.ex new file mode 100644 index 00000000..690e4eda --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/components/support.ex @@ -0,0 +1,91 @@ +defmodule LiveUi.Widgets.Components.Support do + @moduledoc false + + alias LiveUi.BrowserAttrs + + @spec component_attrs(map(), atom(), atom(), map() | keyword()) :: map() + def component_attrs(assigns, kind, family, extra \\ %{}) do + base = + %{} + |> put_attr("data-live-ui-widget", kind) + |> put_attr("data-live-ui-component-family", family) + |> put_attr("data-live-ui-tone", Map.get(assigns, :tone)) + |> put_attr("data-live-ui-variant", Map.get(assigns, :variant)) + |> put_attr("data-live-ui-state", Map.get(assigns, :state)) + |> BrowserAttrs.merge(extra) + + BrowserAttrs.merge(Map.get(assigns, :rest, %{}), base) + end + + @spec fetch(map() | keyword() | nil, atom(), term()) :: term() + def fetch(source, key, default \\ nil) + def fetch(nil, _key, default), do: default + + def fetch(source, key, default) when is_list(source) do + source + |> Map.new() + |> fetch(key, default) + end + + def fetch(source, key, default) when is_map(source) do + Map.get(source, key, Map.get(source, Atom.to_string(key), default)) + end + + def text(value, default \\ "") + def text(nil, default), do: default + def text(value, _default), do: to_string(value) + + def atom_name(value, default \\ "") + def atom_name(nil, default), do: default + + def atom_name(value, _default) do + value + |> to_string() + |> String.replace("_", "-") + end + + def numeric(nil, default), do: default + def numeric(value, _default) when is_number(value), do: value + + def numeric(value, default) when is_binary(value) do + case Float.parse(value) do + {number, ""} -> number + _other -> default + end + end + + def numeric(_value, default), do: default + + def percentage(current, minimum, maximum) do + current = numeric(current, 0) + minimum = numeric(minimum, 0) + maximum = numeric(maximum, 100) + range = max(maximum - minimum, 1) + + ((current - minimum) / range * 100) + |> max(0) + |> min(100) + end + + def selected?(option, active_value) do + fetch(option, :selected?, false) || fetch(option, :selected, false) || + fetch(option, :value) == active_value + end + + def disabled?(value), do: fetch(value, :disabled?, fetch(value, :disabled, false)) + + def label(value, default \\ "") do + fetch(value, :label, fetch(value, :title, fetch(value, :value, default))) + |> text(default) + end + + def id_value(value, default) do + value + |> fetch(:id, fetch(value, :value, default)) + |> text(default) + end + + defp put_attr(attrs, _key, nil), do: attrs + defp put_attr(attrs, _key, ""), do: attrs + defp put_attr(attrs, key, value), do: Map.put(attrs, key, to_string(value)) +end diff --git a/packages/live_ui/test/live_ui/widget_components_backbone_test.exs b/packages/live_ui/test/live_ui/widget_components_backbone_test.exs new file mode 100644 index 00000000..e28dd602 --- /dev/null +++ b/packages/live_ui/test/live_ui/widget_components_backbone_test.exs @@ -0,0 +1,54 @@ +defmodule LiveUi.WidgetComponentsBackboneTest do + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest + + alias LiveUi.Component + alias LiveUi.Widgets.Components + + test "component registry covers the canonical widget-component catalog" do + canonical_kinds = UnifiedIUR.Widgets.Components.kinds() |> Enum.sort() + native_kinds = Components.contracts() |> Map.keys() |> Enum.sort() + + assert native_kinds == canonical_kinds + assert LiveUi.Widgets.component_modules() == Components.modules() + + for module <- Components.modules() do + assert module in LiveUi.Widgets.modules() + end + end + + test "component modules expose mountable boundaries and assign contracts" do + for {kind, contract} <- Components.contracts() do + metadata = Component.metadata(contract.module) + + assert metadata.name == kind + assert metadata.family == :components + assert metadata.mountable? + assert metadata.runtime_boundary == :live_component + assert metadata.component_module == Module.concat([contract.module, :Component]) + + for assign <- contract.assigns do + assert assign in metadata.assigns + end + + assert metadata.slots == contract.slots + assert metadata.events == contract.events + assert metadata.local_state_keys == contract.local_state_keys + end + end + + test "component helper delegates to the shared LiveComponent boundary" do + html = + render_component(&LiveUi.Widgets.Components.InlineRichTextHeading.component/1, %{ + id: "heading", + level: "h2", + segments: [%{type: :text, value: "Canonical components"}] + }) + + assert html =~ "data-live-ui-widget-boundary=\"inline_rich_text_heading\"" + assert html =~ "data-live-ui-widget=\"inline_rich_text_heading\"" + assert html =~ "data-live-ui-component-family=\"content_identity\"" + assert html =~ "Canonical components" + end +end From e23e4c20f07c29509ef72c52c8081c1249504a8b Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 12:06:56 -0400 Subject: [PATCH 13/21] Implement LiveUi content and form widget components --- ...e-components-and-iur-renderer-alignment.md | 18 +-- .../live_ui/lib/live_ui/widgets/components.ex | 16 ++- .../widgets/components/content_identity.ex | 14 ++- .../widgets/components/form_control.ex | 33 ++++-- .../lib/live_ui/widgets/components/support.ex | 8 ++ .../widget_components_content_form_test.exs | 112 ++++++++++++++++++ 6 files changed, 175 insertions(+), 26 deletions(-) create mode 100644 packages/live_ui/test/live_ui/widget_components_content_form_test.exs diff --git a/.spec/planning/canonical_widget_components/phase-03-live-ui-native-components-and-iur-renderer-alignment.md b/.spec/planning/canonical_widget_components/phase-03-live-ui-native-components-and-iur-renderer-alignment.md index 266d6187..d0b77af1 100644 --- a/.spec/planning/canonical_widget_components/phase-03-live-ui-native-components-and-iur-renderer-alignment.md +++ b/.spec/planning/canonical_widget_components/phase-03-live-ui-native-components-and-iur-renderer-alignment.md @@ -44,24 +44,24 @@ Back to index: [README](./README.md) [x] 3.1.2.2 Subtask - Avoid literal color, font, or spacing values in component output where theme tokens should apply. [x] 3.1.2.3 Subtask - Add component metadata used by tooling and focused examples. - [ ] 3.2 Section - Content, Identity, Form, Control, and Composer Components + [x] 3.2 Section - Content, Identity, Form, Control, and Composer Components Implement LiveUi-native components for the widgets that are central to content, identity, form, and messaging surfaces. - [ ] 3.2.1 Task - Implement content, identity, and disclosure components + [x] 3.2.1 Task - Implement content, identity, and disclosure components Realize the passive and stateful content widgets through LiveView-native markup and component boundaries. - [ ] 3.2.1.1 Subtask - Implement inline rich heading and kicker components with safe text segment rendering. - [ ] 3.2.1.2 Subtask - Implement avatar and presence dot components with accessible labels and theme variants. - [ ] 3.2.1.3 Subtask - Implement disclosure with native open-state rendering and child body composition. + [x] 3.2.1.1 Subtask - Implement inline rich heading and kicker components with safe text segment rendering. + [x] 3.2.1.2 Subtask - Implement avatar and presence dot components with accessible labels and theme variants. + [x] 3.2.1.3 Subtask - Implement disclosure with native open-state rendering and child body composition. - [ ] 3.2.2 Task - Implement form, segmented control, and composer components + [x] 3.2.2 Task - Implement form, segmented control, and composer components Realize interactive controls while preserving canonical signal meaning. - [ ] 3.2.2.1 Subtask - Implement segmented button group with pressed state and canonical selection event translation. - [ ] 3.2.2.2 Subtask - Implement runtime-owned form shell with LiveView and AshPhoenix integration hooks that remain LiveUi-local. - [ ] 3.2.2.3 Subtask - Implement chat composer with textarea, tool area, disabled state, send event, and change event handling. + [x] 3.2.2.1 Subtask - Implement segmented button group with pressed state and canonical selection event translation. + [x] 3.2.2.2 Subtask - Implement runtime-owned form shell with LiveView and AshPhoenix integration hooks that remain LiveUi-local. + [x] 3.2.2.3 Subtask - Implement chat composer with textarea, tool area, disabled state, send event, and change event handling. [ ] 3.3 Section - Row, Workflow, Layer, Callout, Redline, and Code Components Implement the operational and document-workflow components used by list, diff --git a/packages/live_ui/lib/live_ui/widgets/components.ex b/packages/live_ui/lib/live_ui/widgets/components.ex index b540cb6b..81d52eeb 100644 --- a/packages/live_ui/lib/live_ui/widgets/components.ex +++ b/packages/live_ui/lib/live_ui/widgets/components.ex @@ -106,7 +106,7 @@ defmodule LiveUi.Widgets.Components do segmented_button_group: %{ module: LiveUi.Widgets.Components.SegmentedButtonGroup, family: :form_control, - assigns: [:options, :active_value, :disabled, :label], + assigns: [:options, :active_value, :disabled, :label, :option_attrs], slots: [], events: [:selection], local_state_keys: [] @@ -114,7 +114,7 @@ defmodule LiveUi.Widgets.Components do runtime_form_shell: %{ module: LiveUi.Widgets.Components.RuntimeFormShell, family: :form_control, - assigns: [:fields, :submit_label, :validation_state, :host_adapter_hints], + assigns: [:fields, :submit_label, :validation_state, :host_adapter_hints, :form_attrs], slots: [:inner_block], events: [:submit, :change], local_state_keys: [] @@ -122,7 +122,17 @@ defmodule LiveUi.Widgets.Components do chat_composer: %{ module: LiveUi.Widgets.Components.ChatComposer, family: :form_control, - assigns: [:name, :value, :placeholder, :rows, :send_label, :disabled], + assigns: [ + :name, + :value, + :placeholder, + :rows, + :send_label, + :disabled, + :form_attrs, + :input_attrs, + :send_attrs + ], slots: [:tools], events: [:send, :change], local_state_keys: [] diff --git a/packages/live_ui/lib/live_ui/widgets/components/content_identity.ex b/packages/live_ui/lib/live_ui/widgets/components/content_identity.ex index 38b56625..4426959c 100644 --- a/packages/live_ui/lib/live_ui/widgets/components/content_identity.ex +++ b/packages/live_ui/lib/live_ui/widgets/components/content_identity.ex @@ -147,8 +147,9 @@ defmodule LiveUi.Widgets.Components.Avatar do @impl true def render(assigns) do assigns = - assign( - assigns, + assigns + |> assign(:resolved_label, assigns.label || assigns.initials || "Avatar") + |> assign( :component_attrs, Support.component_attrs(assigns, :avatar, :content_identity, %{ "data-live-ui-avatar-size" => assigns.size, @@ -157,7 +158,7 @@ defmodule LiveUi.Widgets.Components.Avatar do ) ~H""" - + <%= @initials %> @@ -185,8 +186,9 @@ defmodule LiveUi.Widgets.Components.PresenceDot do @impl true def render(assigns) do assigns = - assign( - assigns, + assigns + |> assign(:resolved_label, assigns.label || "Presence #{Support.text(assigns.presence)}") + |> assign( :component_attrs, Support.component_attrs(assigns, :presence_dot, :content_identity, %{ "data-live-ui-presence" => assigns.presence, @@ -198,7 +200,7 @@ defmodule LiveUi.Widgets.Components.PresenceDot do diff --git a/packages/live_ui/lib/live_ui/widgets/components/form_control.ex b/packages/live_ui/lib/live_ui/widgets/components/form_control.ex index dc338afb..6786b345 100644 --- a/packages/live_ui/lib/live_ui/widgets/components/form_control.ex +++ b/packages/live_ui/lib/live_ui/widgets/components/form_control.ex @@ -8,7 +8,7 @@ defmodule LiveUi.Widgets.Components.SegmentedButtonGroup do use LiveUi.Component, family: :components, name: :segmented_button_group, - assigns: [:options, :active_value, :disabled, :label], + assigns: [:options, :active_value, :disabled, :label, :option_attrs], events: [:selection] LiveUi.Component.common_attrs() @@ -16,6 +16,7 @@ defmodule LiveUi.Widgets.Components.SegmentedButtonGroup do attr(:active_value, :any, default: nil) attr(:disabled, :boolean, default: false) attr(:label, :string, default: nil) + attr(:option_attrs, :map, default: %{}) @impl true def render(assigns) do @@ -34,8 +35,9 @@ defmodule LiveUi.Widgets.Components.SegmentedButtonGroup do :for={option <- @options} type="button" disabled={@disabled || Support.disabled?(option)} - aria-pressed={Support.selected?(option, @active_value)} + aria-pressed={if Support.selected?(option, @active_value), do: "true", else: "false"} data-live-ui-segment-value={Support.text(Support.fetch(option, :value))} + {Map.merge(@option_attrs, Support.attrs(option))} ><%= Support.label(option) %> """ @@ -53,7 +55,7 @@ defmodule LiveUi.Widgets.Components.RuntimeFormShell do family: :components, name: :runtime_form_shell, slots: [:inner_block], - assigns: [:fields, :submit_label, :validation_state, :host_adapter_hints], + assigns: [:fields, :submit_label, :validation_state, :host_adapter_hints, :form_attrs], events: [:submit, :change] LiveUi.Component.common_attrs() @@ -61,6 +63,7 @@ defmodule LiveUi.Widgets.Components.RuntimeFormShell do attr(:submit_label, :string, default: "Submit") attr(:validation_state, :string, default: nil) attr(:host_adapter_hints, :map, default: %{}) + attr(:form_attrs, :map, default: %{}) slot(:inner_block) @impl true @@ -76,13 +79,14 @@ defmodule LiveUi.Widgets.Components.RuntimeFormShell do ) ~H""" -
+ <%= render_slot(@inner_block) %> @@ -109,7 +113,17 @@ defmodule LiveUi.Widgets.Components.ChatComposer do family: :components, name: :chat_composer, slots: [:tools], - assigns: [:name, :value, :placeholder, :rows, :send_label, :disabled], + assigns: [ + :name, + :value, + :placeholder, + :rows, + :send_label, + :disabled, + :form_attrs, + :input_attrs, + :send_attrs + ], events: [:send, :change] LiveUi.Component.common_attrs() @@ -119,6 +133,9 @@ defmodule LiveUi.Widgets.Components.ChatComposer do attr(:rows, :integer, default: 3) attr(:send_label, :string, default: "Send") attr(:disabled, :boolean, default: false) + attr(:form_attrs, :map, default: %{}) + attr(:input_attrs, :map, default: %{}) + attr(:send_attrs, :map, default: %{}) slot(:tools) @impl true @@ -133,10 +150,10 @@ defmodule LiveUi.Widgets.Components.ChatComposer do ) ~H""" - - + +
<%= render_slot(@tools) %>
- +
""" end diff --git a/packages/live_ui/lib/live_ui/widgets/components/support.ex b/packages/live_ui/lib/live_ui/widgets/components/support.ex index 690e4eda..c67227d8 100644 --- a/packages/live_ui/lib/live_ui/widgets/components/support.ex +++ b/packages/live_ui/lib/live_ui/widgets/components/support.ex @@ -74,6 +74,14 @@ defmodule LiveUi.Widgets.Components.Support do def disabled?(value), do: fetch(value, :disabled?, fetch(value, :disabled, false)) + def attrs(source) do + case fetch(source, :attrs, %{}) do + attrs when is_map(attrs) -> attrs + attrs when is_list(attrs) -> Map.new(attrs) + _other -> %{} + end + end + def label(value, default \\ "") do fetch(value, :label, fetch(value, :title, fetch(value, :value, default))) |> text(default) diff --git a/packages/live_ui/test/live_ui/widget_components_content_form_test.exs b/packages/live_ui/test/live_ui/widget_components_content_form_test.exs new file mode 100644 index 00000000..6870299b --- /dev/null +++ b/packages/live_ui/test/live_ui/widget_components_content_form_test.exs @@ -0,0 +1,112 @@ +defmodule LiveUi.WidgetComponentsContentFormTest do + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest + + defp slot_text(slot, text) do + %{__slot__: slot, inner_block: fn _, _ -> text end} + end + + test "content identity components render accessible safe native output" do + heading = + render_component(&LiveUi.Widgets.Components.InlineRichTextHeading.component/1, %{ + id: "heading", + level: "h2", + segments: [%{type: :text, value: "Safe "}] + }) + + avatar = + render_component(&LiveUi.Widgets.Components.Avatar.component/1, %{ + id: "avatar", + initials: "PC", + label: "Pascal Charbonneau", + size: "small" + }) + + presence = + render_component(&LiveUi.Widgets.Components.PresenceDot.component/1, %{ + id: "presence", + presence: "active" + }) + + disclosure = + render_component(&LiveUi.Widgets.Components.Disclosure.component/1, %{ + id: "details", + summary: "More", + open: true, + inner_block: [slot_text(:inner_block, "Expanded body")] + }) + + assert heading =~ "role=\"heading\"" + assert heading =~ "aria-level=\"2\"" + assert heading =~ "Safe <script>alert(1)</script>" + refute heading =~ "", + callout_tone: "warning", + action_label: "Inspect", + action_attrs: %{"phx-click" => "inspect_event"} + }) + + redline = + render_component(&LiveUi.Widgets.Components.RedlineInline.component/1, %{ + id: "redline", + segments: [%{state: :insert, text: ""}] + }) + + code = + render_component(&LiveUi.Widgets.Components.CodeBlockSyntaxHighlighted.component/1, %{ + id: "code", + language: "elixir", + tokens: [%{type: :keyword, text: ""}] + }) + + assert header =~ "data-live-ui-position=\"sticky\"" + assert header =~ "data-live-ui-visual-effect=\"frosted\"" + assert header =~ "Header body" + + assert panel =~ "aria-hidden=\"false\"" + assert panel =~ "data-live-ui-panel-size=\"wide\"" + assert panel =~ "Panel body" + + assert callout =~ "data-live-ui-callout-tone=\"warning\"" + assert callout =~ "<script>plain text</script>" + assert callout =~ "phx-click=\"inspect_event\"" + + assert redline =~ "data-live-ui-redline-state=\"insert\"" + assert redline =~ "<script>safe</script>" + refute redline =~ "", + [Foundational.button("Inspect")], + id: "callout", + tone: :warning, + interactions: [Interaction.click(intent: :inspect_event, element_id: "callout")] + ) + + redline = + Components.redline_inline([%{state: :insert, text: ""}], + id: "redline" + ) + + code = + Components.code_block_syntax_highlighted(:elixir, [%{type: :keyword, text: ""}], + id: "code" + ) + + html = + [panel, callout, redline, code] + |> Enum.map_join(fn element -> + render_component(&LiveUi.Renderer.render/1, %{ + element: element, + event_target: "#runtime-host" + }) + end) + + assert html =~ ~s(data-live-ui-widget-boundary="slide_over_panel") + assert html =~ ~s(aria-hidden="false") + assert html =~ ~s(phx-value-widget="slide_over_panel") + + assert html =~ ~s(data-live-ui-widget-boundary="event_callout") + assert html =~ ~s(data-live-ui-callout-tone="warning") + assert html =~ ~s(phx-click="canonical_interaction") + + assert html =~ "<script>plain text</script>" + assert html =~ "<script>safe</script>" + assert html =~ "<def>" + refute html =~ ""}] + }), + render_component(&LiveUi.Widgets.Components.CodeBlockSyntaxHighlighted.component/1, %{ + id: "code", + tokens: [%{type: :keyword, text: ""}] + }) + ] + |> Enum.join() + + for boundary <- [ + "inline_rich_text_heading", + "disclosure", + "kicker", + "avatar", + "presence_dot", + "segmented_button_group", + "runtime_form_shell", + "chat_composer", + "list_item_multi_column", + "artifact_row", + "pipeline_stepper_horizontal", + "segmented_progress_bar", + "workflow_stage_list_vertical", + "meter_thin", + "sticky_frosted_header", + "slide_over_panel", + "event_callout", + "redline_inline", + "code_block_syntax_highlighted" + ] do + assert native_html =~ ~s(data-live-ui-widget-boundary="#{boundary}") + end + + assert native_html =~ "<script>safe</script>" + assert native_html =~ "<def>" + refute native_html =~ ""}], + id: "repeat-redline" + ) + ] + ) + + html = + render_component(&LiveUi.Renderer.render/1, %{ + element: repeat, + event_target: "#runtime-host" + }) + + assert html =~ ~s(data-live-ui-widget-boundary="list_repeat") + assert html =~ ~s(data-live-ui-repeat-row-count="2") + assert html =~ "artifact-repeat:a1:artifact" + assert html =~ "artifact-repeat:a2:artifact" + assert html =~ ~s(phx-value-row_identity="a1") + assert html =~ ~s(phx-value-row_identity="a2") + assert html =~ "<script>remove</script>" + refute html =~ ""}], + id: "parity-redline" + ) + + fixture(:redline, element, + kinds: [:redline_inline], + safety: %{plain_text: true, malicious_text: ""} + ) + end + + defp build_fixture(:code) do + element = + Components.code_block_syntax_highlighted( + :elixir, + [%{type: :keyword, text: ""}], + id: "parity-code" + ) + + fixture(:code, element, + kinds: [:code_block_syntax_highlighted], + safety: %{plain_text: true, malicious_text: ""} + ) + end + + defp build_fixture(:composer) do + element = + Components.chat_composer( + [Foundational.button("Attach", id: "parity-composer-attach")], + id: "parity-composer", + value: "Draft", + send_label: "Send", + interactions: [ + Interaction.submit(intent: :send_message, element_id: "parity-composer"), + Interaction.change(intent: :update_message, element_id: "parity-composer") + ] + ) + + fixture(:composer, element, + kinds: [:chat_composer], + labels: ["Send", "Attach"], + state: %{value: "Draft"}, + interactions: [:submit, :change], + children: %{default: 1} + ) + end + + defp build_fixture(:repeated_rows) do + element = + Components.list_repeat(nil, + id: "parity-repeat", + repeat_binding: :artifacts, + hydrated?: true, + row_count: 2, + children: [ + Components.artifact_row("ADR 1", [], id: "parity-repeat:a1", row_identity: "a1"), + Components.artifact_row("ADR 2", [], id: "parity-repeat:a2", row_identity: "a2") + ] + ) + + fixture(:repeated_rows, element, + kinds: [:list_repeat, :artifact_row], + state: %{hydrated?: true, row_count: 2}, + children: %{default: 2} + ) + end + + defp fixture(group, element, expected) do + %{ + id: "components-runtime-parity--#{group}", + group: group, + element: element, + expected: Map.new(expected), + minimum_behavior: @minimum_behavior.full + } + end +end diff --git a/packages/unified_iur/test/unified_iur/runtime_parity_test.exs b/packages/unified_iur/test/unified_iur/runtime_parity_test.exs new file mode 100644 index 00000000..b8e146f5 --- /dev/null +++ b/packages/unified_iur/test/unified_iur/runtime_parity_test.exs @@ -0,0 +1,85 @@ +defmodule UnifiedIUR.RuntimeParityTest do + use ExUnit.Case, async: true + + alias UnifiedIUR.{Interoperability, RuntimeParity} + + test "exposes shared component runtime parity fixture groups" do + assert RuntimeParity.groups() == [ + :content, + :identity, + :form, + :control, + :row, + :progress, + :layer, + :callout, + :redline, + :code, + :composer, + :repeated_rows + ] + + fixtures = RuntimeParity.fixtures() + + assert Enum.map(fixtures, & &1.group) == RuntimeParity.groups() + assert Enum.all?(fixtures, &String.starts_with?(&1.id, "components-runtime-parity--")) + assert Enum.all?(fixtures, &(&1.minimum_behavior == RuntimeParity.minimum_behavior().full)) + end + + test "fixtures preserve semantic expectations, interactions, and text safety" do + form = RuntimeParity.fixture!(:form) + redline = RuntimeParity.fixture!(:redline) + code = RuntimeParity.fixture!("components-runtime-parity--code") + repeated = RuntimeParity.fixture!(:repeated_rows) + + assert form.expected.interactions == [:submit, :change] + assert form.expected.labels == ["Title", "Save"] + + assert redline.expected.safety == %{ + plain_text: true, + malicious_text: "" + } + + assert code.expected.safety == %{plain_text: true, malicious_text: ""} + + repeated_kinds = repeated.element |> Interoperability.walk() |> Enum.map(& &1.kind) + assert repeated.expected.state == %{hydrated?: true, row_count: 2} + assert Enum.count(repeated_kinds, &(&1 == :artifact_row)) == 2 + end + + test "coverage report classifies full, degraded, and unsupported runtime support" do + report = + RuntimeParity.coverage_report(:terminal_ui, [:inline_rich_text_heading, :redline_inline], + degraded_kinds: [:avatar] + ) + + assert report.runtime == :terminal_ui + refute report.complete? + assert report.degraded? + assert report.kinds.inline_rich_text_heading.status == :full + assert report.kinds.redline_inline.status == :full + assert report.kinds.avatar.status == :degraded + assert report.kinds.chat_composer.status == :unsupported + assert :chat_composer in report.unsupported_kinds + assert :avatar in report.degraded_kinds + + assert report.kinds.avatar.minimum_behavior == [ + :explicit_degradation, + :structure_preservation, + :state_preservation, + :accessibility_metadata, + :interaction_translation, + :safe_text_output + ] + end + + test "acceptance criteria enumerate required kinds and fixture groups" do + criteria = RuntimeParity.acceptance_criteria() + + assert criteria.statuses == [:full, :degraded, :unsupported] + assert criteria.required_fixture_groups == RuntimeParity.groups() + assert :redline in criteria.text_safety_groups + assert :composer in criteria.interaction_groups + assert :code_block_syntax_highlighted in criteria.required_widget_kinds + end +end From ad654c943a84d4c2bd77d504370ede07b6bbbca2 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 12:31:49 -0400 Subject: [PATCH 18/21] Implement ElmUi widget component parity --- ...sktop-ui-and-terminal-ui-runtime-parity.md | 18 +- packages/elm_ui/lib/elm_ui/renderer.ex | 3 +- .../elm_ui/lib/elm_ui/renderer/canonical.ex | 69 +- .../lib/elm_ui/server_runtime/render_model.ex | 115 ++- packages/elm_ui/lib/elm_ui/widget.ex | 46 +- packages/elm_ui/lib/elm_ui/widgets.ex | 134 +++- .../elm_ui/lib/elm_ui/widgets/components.ex | 695 ++++++++++++++++++ .../elm_ui/phase_one_integration_test.exs | 3 +- .../test/elm_ui/widget_components_test.exs | 187 +++++ 9 files changed, 1244 insertions(+), 26 deletions(-) create mode 100644 packages/elm_ui/lib/elm_ui/widgets/components.ex create mode 100644 packages/elm_ui/test/elm_ui/widget_components_test.exs diff --git a/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md b/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md index 4f399e3e..96aa17ec 100644 --- a/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md +++ b/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md @@ -45,24 +45,24 @@ Back to index: [README](./README.md) [x] 4.1.2.2 Subtask - Define minimum interaction and accessibility behavior required even under degraded rendering. [x] 4.1.2.3 Subtask - Add parity reports that compare runtime coverage against the canonical fixture set. - [ ] 4.2 Section - ElmUi Runtime Support + [x] 4.2 Section - ElmUi Runtime Support Implement the expanded catalog in the Phoenix-and-Elm runtime split while preserving canonical IUR and signal meaning. - [ ] 4.2.1 Task - Implement ElmUi native widget models + [x] 4.2.1 Task - Implement ElmUi native widget models Add server-side and Elm frontend representations for the expanded widget catalog. - [ ] 4.2.1.1 Subtask - Add server-side widget records and validation for expanded catalog props, children, and interactions. - [ ] 4.2.1.2 Subtask - Add Elm frontend rendering and update messages for interactive widgets. - [ ] 4.2.1.3 Subtask - Add safe text output for redline and code token content. + [x] 4.2.1.1 Subtask - Add server-side widget records and validation for expanded catalog props, children, and interactions. + [x] 4.2.1.2 Subtask - Add Elm frontend rendering and update messages for interactive widgets. + [x] 4.2.1.3 Subtask - Add safe text output for redline and code token content. - [ ] 4.2.2 Task - Implement ElmUi IUR renderer mappings + [x] 4.2.2 Task - Implement ElmUi IUR renderer mappings Map canonical IUR into ElmUi's native server and frontend runtime model. - [ ] 4.2.2.1 Subtask - Add renderer mappings for every expanded widget family. - [ ] 4.2.2.2 Subtask - Translate canonical interactions into ElmUi message and Phoenix boundary events. - [ ] 4.2.2.3 Subtask - Verify repeated rows render as deterministic frontend children. + [x] 4.2.2.1 Subtask - Add renderer mappings for every expanded widget family. + [x] 4.2.2.2 Subtask - Translate canonical interactions into ElmUi message and Phoenix boundary events. + [x] 4.2.2.3 Subtask - Verify repeated rows render as deterministic frontend children. [ ] 4.3 Section - DesktopUi Runtime Support Implement desktop-native equivalents and IUR mappings with pointer, diff --git a/packages/elm_ui/lib/elm_ui/renderer.ex b/packages/elm_ui/lib/elm_ui/renderer.ex index 491032c0..31a3ff79 100644 --- a/packages/elm_ui/lib/elm_ui/renderer.ex +++ b/packages/elm_ui/lib/elm_ui/renderer.ex @@ -34,7 +34,8 @@ defmodule ElmUi.Renderer do UnifiedIUR.Widgets.Feedback.kinds(), UnifiedIUR.Canvas.kinds(), UnifiedIUR.Widgets.Advanced.kinds(), - UnifiedIUR.Layer.kinds() + UnifiedIUR.Layer.kinds(), + UnifiedIUR.Widgets.Components.kinds() ] |> List.flatten() |> Enum.uniq() diff --git a/packages/elm_ui/lib/elm_ui/renderer/canonical.ex b/packages/elm_ui/lib/elm_ui/renderer/canonical.ex index 79a48b9b..b3a9b1d0 100644 --- a/packages/elm_ui/lib/elm_ui/renderer/canonical.ex +++ b/packages/elm_ui/lib/elm_ui/renderer/canonical.ex @@ -8,6 +8,9 @@ defmodule ElmUi.Renderer.Canonical do alias ElmUi.Renderer.Error alias ElmUi.Widgets + @component_kinds UnifiedIUR.Widgets.Components.kinds() + @component_kind_values @component_kinds ++ Enum.map(@component_kinds, &Atom.to_string/1) + @spec render(Element.t(), keyword()) :: {:ok, ElmUi.Widget.t()} | {:error, Error.t()} def render(%Element{} = element, _opts \\ []) do do_render(element) @@ -47,7 +50,8 @@ defmodule ElmUi.Renderer.Canonical do element.id, children, Keyword.merge(base_opts(element), - eyebrow: first_present([group_attr(element, :hero, :eyebrow), attr(element, :eyebrow)]), + eyebrow: + first_present([group_attr(element, :hero, :eyebrow), attr(element, :eyebrow)]), title: first_present([group_attr(element, :hero, :title), attr(element, :title)]), message: first_present([group_attr(element, :hero, :message), attr(element, :message)]), @@ -473,7 +477,10 @@ defmodule ElmUi.Renderer.Canonical do first_present([group_attr(element, :info_list, :items), attr(element, :items)], []), Keyword.merge(base_opts(element), ordered: - first_present([group_attr(element, :info_list, :ordered?), attr(element, :ordered)], false), + first_present( + [group_attr(element, :info_list, :ordered?), attr(element, :ordered)], + false + ), empty_state: first_present([ group_attr(element, :info_list, :empty_state), @@ -1278,6 +1285,22 @@ defmodule ElmUi.Renderer.Canonical do )} end + defp do_render(%Element{type: :widget, kind: kind} = element) + when kind in @component_kind_values do + with {:ok, slot_children} <- map_slot_children(element.children) do + kind = component_kind(kind) + + {:ok, + ElmUi.Widgets.Components.from_iur( + kind, + element.id, + normalize_component_attributes(kind, element.attributes), + slot_children, + base_opts(element) + )} + end + end + defp do_render(%Element{} = element) do {:error, Error.unsupported_kind(element, ElmUi.Renderer.supported_kinds())} end @@ -1327,6 +1350,18 @@ defmodule ElmUi.Renderer.Canonical do end end + defp map_slot_children(children) do + children + |> Enum.reduce_while({:ok, %{}}, fn child, {:ok, acc} -> + slot = child_slot_name(child) + + case render_child(child) do + {:ok, widget} -> {:cont, {:ok, Map.update(acc, slot, [widget], &(&1 ++ [widget]))}} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + defp required_slot_child(%Element{} = element, slot) do case slot_child(element, slot) do {:ok, nil} -> {:error, Error.missing_required_slot(element, slot)} @@ -1396,6 +1431,36 @@ defmodule ElmUi.Renderer.Canonical do defp render_child(%Child{element: %Element{} = element}), do: do_render(element) defp render_child(%Element{} = element), do: do_render(element) + defp child_slot_name(%Child{slot: slot}), do: normalize_key(slot) + defp child_slot_name(%Element{}), do: :default + + defp component_kind(kind) when is_atom(kind), do: kind + defp component_kind(kind) when is_binary(kind), do: String.to_atom(kind) + + defp normalize_component_attributes(kind, attributes) when is_map(attributes) do + kind = component_kind(kind) + + attributes + |> normalize_nested_map() + |> Map.put_new(:component, %{ + family: ElmUi.Widgets.Components.family_for_kind(kind), + kind: kind + }) + end + + defp normalize_nested_map(%_{} = struct), + do: struct |> Map.from_struct() |> normalize_nested_map() + + defp normalize_nested_map(map) when is_map(map) do + Map.new(map, fn {key, value} -> {normalize_key(key), normalize_nested_map(value)} end) + end + + defp normalize_nested_map(list) when is_list(list) do + Enum.map(list, &normalize_nested_map/1) + end + + defp normalize_nested_map(value), do: value + defp sparkline_values(series) when is_list(series) do case List.first(series) do %{values: values} when is_list(values) -> values diff --git a/packages/elm_ui/lib/elm_ui/server_runtime/render_model.ex b/packages/elm_ui/lib/elm_ui/server_runtime/render_model.ex index 125920f0..eff25af4 100644 --- a/packages/elm_ui/lib/elm_ui/server_runtime/render_model.ex +++ b/packages/elm_ui/lib/elm_ui/server_runtime/render_model.ex @@ -123,6 +123,26 @@ defmodule ElmUi.ServerRuntime.RenderModel do defp dom_tag(:cluster_dashboard), do: "section" defp dom_tag(:command_palette), do: "section" defp dom_tag(:supervision_tree_viewer), do: "section" + defp dom_tag(:inline_rich_text_heading), do: "hgroup" + defp dom_tag(:disclosure), do: "details" + defp dom_tag(:kicker), do: "p" + defp dom_tag(:avatar), do: "span" + defp dom_tag(:presence_dot), do: "span" + defp dom_tag(:segmented_button_group), do: "div" + defp dom_tag(:runtime_form_shell), do: "form" + defp dom_tag(:chat_composer), do: "form" + defp dom_tag(:list_item_multi_column), do: "article" + defp dom_tag(:artifact_row), do: "article" + defp dom_tag(:pipeline_stepper_horizontal), do: "nav" + defp dom_tag(:segmented_progress_bar), do: "div" + defp dom_tag(:workflow_stage_list_vertical), do: "ol" + defp dom_tag(:meter_thin), do: "meter" + defp dom_tag(:sticky_frosted_header), do: "header" + defp dom_tag(:slide_over_panel), do: "aside" + defp dom_tag(:event_callout), do: "aside" + defp dom_tag(:redline_inline), do: "span" + defp dom_tag(:code_block_syntax_highlighted), do: "pre" + defp dom_tag(:list_repeat), do: "div" defp dom_tag(_kind), do: "div" defp dom_role(:text), do: "text" @@ -180,6 +200,26 @@ defmodule ElmUi.ServerRuntime.RenderModel do defp dom_role(:cluster_dashboard), do: "region" defp dom_role(:command_palette), do: "combobox" defp dom_role(:supervision_tree_viewer), do: "tree" + defp dom_role(:inline_rich_text_heading), do: "heading" + defp dom_role(:disclosure), do: "group" + defp dom_role(:kicker), do: "note" + defp dom_role(:avatar), do: "img" + defp dom_role(:presence_dot), do: "status" + defp dom_role(:segmented_button_group), do: "radiogroup" + defp dom_role(:runtime_form_shell), do: "form" + defp dom_role(:chat_composer), do: "form" + defp dom_role(:list_item_multi_column), do: "listitem" + defp dom_role(:artifact_row), do: "listitem" + defp dom_role(:pipeline_stepper_horizontal), do: "navigation" + defp dom_role(:segmented_progress_bar), do: "progressbar" + defp dom_role(:workflow_stage_list_vertical), do: "list" + defp dom_role(:meter_thin), do: "meter" + defp dom_role(:sticky_frosted_header), do: "banner" + defp dom_role(:slide_over_panel), do: "complementary" + defp dom_role(:event_callout), do: "status" + defp dom_role(:redline_inline), do: "text" + defp dom_role(:code_block_syntax_highlighted), do: "code" + defp dom_role(:list_repeat), do: "list" defp dom_role(_kind), do: "presentation" defp dom_attributes(%Widget{} = widget) do @@ -196,12 +236,17 @@ defmodule ElmUi.ServerRuntime.RenderModel do |> maybe_put(:orientation, Map.get(widget.attributes, :orientation)) |> maybe_put(:active_item, Map.get(widget.attributes, :active_item)) |> maybe_put(:legend, Map.get(widget.attributes, :legend)) - |> maybe_put(:open, Map.get(widget.state, :open)) + |> maybe_put(:open, Map.get(widget.state, :open) || Map.get(widget.state, :open?)) |> maybe_put(:modal, Map.get(widget.attributes, :modal)) |> maybe_put(:title, Map.get(widget.attributes, :title)) |> maybe_put(:placement, Map.get(widget.attributes, :placement)) |> maybe_put(:viewport_ref, Map.get(widget.attributes, :viewport_ref)) |> maybe_put(:ratio, Map.get(widget.attributes, :ratio)) + |> maybe_put(:component_family, get_in(widget.attributes, [:component, :family])) + |> maybe_put(:aria_label, get_in(widget.attributes, [:accessibility, :label])) + |> maybe_put(:aria_description, get_in(widget.attributes, [:accessibility, :description])) + |> maybe_put(:text_safety, get_in(widget.attributes, [:text_safety, :content])) + |> maybe_put(:plain_text_output, plain_text_output?(widget)) |> maybe_put(:value, dom_value(widget)) |> maybe_put(:max, dom_max(widget)) end @@ -239,7 +284,18 @@ defmodule ElmUi.ServerRuntime.RenderModel do :stream_widget, :process_monitor, :command_palette, - :supervision_tree_viewer + :supervision_tree_viewer, + :disclosure, + :segmented_button_group, + :runtime_form_shell, + :chat_composer, + :list_item_multi_column, + :artifact_row, + :pipeline_stepper_horizontal, + :workflow_stage_list_vertical, + :slide_over_panel, + :event_callout, + :list_repeat ] end @@ -273,7 +329,17 @@ defmodule ElmUi.ServerRuntime.RenderModel do :stream_widget, :process_monitor, :command_palette, - :supervision_tree_viewer + :supervision_tree_viewer, + :disclosure, + :segmented_button_group, + :runtime_form_shell, + :chat_composer, + :list_item_multi_column, + :artifact_row, + :pipeline_stepper_horizontal, + :workflow_stage_list_vertical, + :slide_over_panel, + :event_callout ] end @@ -290,12 +356,36 @@ defmodule ElmUi.ServerRuntime.RenderModel do :radio_group, :select, :pick_list, - :command_palette + :command_palette, + :segmented_button_group, + :runtime_form_shell, + :chat_composer ] end defp navigable_kinds do - [:link, :menu, :tabs, :tree_view, :context_menu, :list] + [ + :link, + :menu, + :tabs, + :tree_view, + :context_menu, + :list, + :disclosure, + :list_item_multi_column, + :artifact_row, + :pipeline_stepper_horizontal, + :workflow_stage_list_vertical, + :list_repeat + ] + end + + defp dom_value(%Widget{kind: :meter_thin, attributes: attributes}) do + get_in(attributes, [:meter, :current]) + end + + defp dom_value(%Widget{kind: :segmented_progress_bar, attributes: attributes}) do + get_in(attributes, [:progress, :aggregate, :current]) end defp dom_value(%Widget{kind: :progress, attributes: attributes}) do @@ -318,10 +408,25 @@ defmodule ElmUi.ServerRuntime.RenderModel do Map.get(attributes, :max) end + defp dom_max(%Widget{kind: :meter_thin, attributes: attributes}) do + get_in(attributes, [:meter, :maximum]) + end + + defp dom_max(%Widget{kind: :segmented_progress_bar, attributes: attributes}) do + get_in(attributes, [:progress, :aggregate, :maximum]) + end + defp dom_max(%Widget{}) do nil end + defp plain_text_output?(%Widget{kind: kind, attributes: attributes}) + when kind in [:redline_inline, :code_block_syntax_highlighted] do + get_in(attributes, [:text_safety, :content]) == :plain_text + end + + defp plain_text_output?(%Widget{}), do: nil + defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) end diff --git a/packages/elm_ui/lib/elm_ui/widget.ex b/packages/elm_ui/lib/elm_ui/widget.ex index 8f0b1cf1..80a9abc8 100644 --- a/packages/elm_ui/lib/elm_ui/widget.ex +++ b/packages/elm_ui/lib/elm_ui/widget.ex @@ -15,6 +15,7 @@ defmodule ElmUi.Widget do | :document | :visualization | :operational + | :component @type t :: %__MODULE__{ id: String.t() | atom() | nil, @@ -45,7 +46,7 @@ defmodule ElmUi.Widget do @spec contract() :: map() def contract do %{ - metadata: [:label, :description, :role, :variant, :native_surface], + metadata: [:label, :description, :role, :variant, :native_surface, :component_family], state: [ :disabled, :selected, @@ -58,7 +59,10 @@ defmodule ElmUi.Widget do :streaming, :paused, :open, - :scrolled + :scrolled, + :disabled?, + :active?, + :open? ], slots: [ :default, @@ -94,7 +98,13 @@ defmodule ElmUi.Widget do :scroll, :resize, :open, - :dismiss + :dismiss, + :send, + :row_activation, + :step_navigation, + :inline_action, + :disclosure, + :panel ] } end @@ -219,7 +229,10 @@ defmodule ElmUi.Widget do do: :input def family_for(kind) when kind in [:tabs, :menu], do: :navigation - def family_for(kind) when kind in [:list, :table, :tree_view, :stat, :key_value, :info_list], do: :data + + def family_for(kind) when kind in [:list, :table, :tree_view, :stat, :key_value, :info_list], + do: :data + def family_for(kind) when kind in [:markdown_viewer, :log_viewer], do: :document def family_for(kind) when kind in [:status, :progress, :inline_feedback], do: :feedback @@ -237,6 +250,31 @@ defmodule ElmUi.Widget do ], do: :operational + def family_for(kind) + when kind in [ + :inline_rich_text_heading, + :disclosure, + :kicker, + :avatar, + :presence_dot, + :segmented_button_group, + :runtime_form_shell, + :chat_composer, + :list_item_multi_column, + :artifact_row, + :pipeline_stepper_horizontal, + :segmented_progress_bar, + :workflow_stage_list_vertical, + :meter_thin, + :sticky_frosted_header, + :slide_over_panel, + :event_callout, + :redline_inline, + :code_block_syntax_highlighted, + :list_repeat + ], + do: :component + def family_for(_kind), do: :content defp normalize_map(nil), do: %{} diff --git a/packages/elm_ui/lib/elm_ui/widgets.ex b/packages/elm_ui/lib/elm_ui/widgets.ex index 8ee7e98f..09f88cca 100644 --- a/packages/elm_ui/lib/elm_ui/widgets.ex +++ b/packages/elm_ui/lib/elm_ui/widgets.ex @@ -4,7 +4,8 @@ defmodule ElmUi.Widgets do """ alias ElmUi.Widget - alias ElmUi.Widgets.{Data, Feedback, Forms, Foundational, Input, Layered, Layout, Navigation} + alias ElmUi.Widgets.{Components, Data, Feedback, Forms, Foundational, Input, Layered, Layout} + alias ElmUi.Widgets.Navigation alias ElmUi.Widgets.{Operational, Visualization} @type family :: Widget.family() @@ -30,7 +31,8 @@ defmodule ElmUi.Widgets do Data, Feedback, Visualization, - Operational + Operational, + Components ] end @@ -46,7 +48,8 @@ defmodule ElmUi.Widgets do Data.kinds(), Feedback.kinds(), Visualization.kinds(), - Operational.kinds() + Operational.kinds(), + Components.kinds() ] |> List.flatten() |> Enum.uniq() @@ -77,7 +80,8 @@ defmodule ElmUi.Widgets do advanced_operational_widgets: :ready, display_system_widgets: :ready, layered_composition_widgets: :ready, - layered_runtime_diagnostics: :ready + layered_runtime_diagnostics: :ready, + widget_components: :ready } end @@ -446,6 +450,128 @@ defmodule ElmUi.Widgets do Operational.supervision_tree_viewer(id, nodes, opts) end + @spec inline_rich_text_heading(String.t() | atom(), atom(), [keyword() | map()], keyword()) :: + Widget.t() + def inline_rich_text_heading(id, level, segments, opts \\ []) do + Components.inline_rich_text_heading(id, level, segments, opts) + end + + @spec disclosure(String.t() | atom(), String.t(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def disclosure(id, summary, children \\ [], opts \\ []) do + Components.disclosure(id, summary, children, opts) + end + + @spec kicker(String.t() | atom(), [String.t()], keyword()) :: Widget.t() + def kicker(id, items, opts \\ []) do + Components.kicker(id, items, opts) + end + + @spec avatar(String.t() | atom(), keyword()) :: Widget.t() + def avatar(id, opts \\ []) do + Components.avatar(id, opts) + end + + @spec presence_dot(String.t() | atom(), atom(), keyword()) :: Widget.t() + def presence_dot(id, state, opts \\ []) do + Components.presence_dot(id, state, opts) + end + + @spec segmented_button_group(String.t() | atom(), [keyword() | map()], keyword()) :: Widget.t() + def segmented_button_group(id, options, opts \\ []) do + Components.segmented_button_group(id, options, opts) + end + + @spec runtime_form_shell(String.t() | atom(), [keyword() | map()], keyword()) :: Widget.t() + def runtime_form_shell(id, fields, opts \\ []) do + Components.runtime_form_shell(id, fields, opts) + end + + @spec chat_composer(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def chat_composer(id, children \\ [], opts \\ []) do + Components.chat_composer(id, children, opts) + end + + @spec list_item_multi_column(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def list_item_multi_column(id, children \\ [], opts \\ []) do + Components.list_item_multi_column(id, children, opts) + end + + @spec artifact_row(String.t() | atom(), String.t(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def artifact_row(id, title, children \\ [], opts \\ []) do + Components.artifact_row(id, title, children, opts) + end + + @spec pipeline_stepper_horizontal(String.t() | atom(), [keyword() | map()], keyword()) :: + Widget.t() + def pipeline_stepper_horizontal(id, steps, opts \\ []) do + Components.pipeline_stepper_horizontal(id, steps, opts) + end + + @spec segmented_progress_bar(String.t() | atom(), [keyword() | map()], keyword()) :: + Widget.t() + def segmented_progress_bar(id, segments, opts \\ []) do + Components.segmented_progress_bar(id, segments, opts) + end + + @spec workflow_stage_list_vertical(String.t() | atom(), [keyword() | map()], keyword()) :: + Widget.t() + def workflow_stage_list_vertical(id, stages, opts \\ []) do + Components.workflow_stage_list_vertical(id, stages, opts) + end + + @spec meter_thin(String.t() | atom(), number(), keyword()) :: Widget.t() + def meter_thin(id, current, opts \\ []) do + Components.meter_thin(id, current, opts) + end + + @spec sticky_frosted_header(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def sticky_frosted_header(id, children \\ [], opts \\ []) do + Components.sticky_frosted_header(id, children, opts) + end + + @spec slide_over_panel(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def slide_over_panel(id, children \\ [], opts \\ []) do + Components.slide_over_panel(id, children, opts) + end + + @spec event_callout( + String.t() | atom(), + String.t(), + [Widget.t() | map() | keyword()], + keyword() + ) :: + Widget.t() + def event_callout(id, message, children \\ [], opts \\ []) do + Components.event_callout(id, message, children, opts) + end + + @spec redline_inline(String.t() | atom(), [keyword() | map()], keyword()) :: Widget.t() + def redline_inline(id, segments, opts \\ []) do + Components.redline_inline(id, segments, opts) + end + + @spec code_block_syntax_highlighted( + String.t() | atom(), + atom() | String.t(), + [keyword() | map()], + keyword() + ) :: Widget.t() + def code_block_syntax_highlighted(id, language, tokens, opts \\ []) do + Components.code_block_syntax_highlighted(id, language, tokens, opts) + end + + @spec list_repeat(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def list_repeat(id, children \\ [], opts \\ []) do + Components.list_repeat(id, children, opts) + end + @spec screen(String.t() | atom(), String.t(), [Widget.t()], keyword()) :: map() def screen(id, title, children, opts \\ []) do %{ diff --git a/packages/elm_ui/lib/elm_ui/widgets/components.ex b/packages/elm_ui/lib/elm_ui/widgets/components.ex new file mode 100644 index 00000000..ea703584 --- /dev/null +++ b/packages/elm_ui/lib/elm_ui/widgets/components.ex @@ -0,0 +1,695 @@ +defmodule ElmUi.Widgets.Components do + @moduledoc """ + Native `elm_ui` component models for the canonical widget-component catalog. + + These constructors are directly usable by ElmUi callers while preserving the + same semantic attribute shape that canonical IUR renderers consume. + """ + + alias ElmUi.Widgets.Builder + + @content_identity_kinds [ + :inline_rich_text_heading, + :disclosure, + :kicker, + :avatar, + :presence_dot + ] + + @form_control_kinds [ + :segmented_button_group, + :runtime_form_shell, + :chat_composer + ] + + @row_artifact_kinds [ + :list_item_multi_column, + :artifact_row + ] + + @workflow_progress_kinds [ + :pipeline_stepper_horizontal, + :segmented_progress_bar, + :workflow_stage_list_vertical, + :meter_thin + ] + + @layer_callout_kinds [ + :sticky_frosted_header, + :slide_over_panel, + :event_callout + ] + + @redline_code_kinds [ + :redline_inline, + :code_block_syntax_highlighted + ] + + @composition_behavior_kinds [ + :list_repeat + ] + + @component_families %{ + inline_rich_text_heading: :content_identity, + disclosure: :content_identity, + kicker: :content_identity, + avatar: :content_identity, + presence_dot: :content_identity, + segmented_button_group: :form_control, + runtime_form_shell: :form_control, + chat_composer: :form_control, + list_item_multi_column: :row_artifact, + artifact_row: :row_artifact, + pipeline_stepper_horizontal: :workflow_progress, + segmented_progress_bar: :workflow_progress, + workflow_stage_list_vertical: :workflow_progress, + meter_thin: :workflow_progress, + sticky_frosted_header: :layer_callout, + slide_over_panel: :layer_callout, + event_callout: :layer_callout, + redline_inline: :redline_code, + code_block_syntax_highlighted: :redline_code, + list_repeat: :composition_behavior + } + + @kinds @content_identity_kinds ++ + @form_control_kinds ++ + @row_artifact_kinds ++ + @workflow_progress_kinds ++ + @layer_callout_kinds ++ + @redline_code_kinds ++ + @composition_behavior_kinds + + @spec kinds() :: [atom()] + def kinds, do: @kinds + + @spec family_for_kind(atom() | String.t()) :: atom() | nil + def family_for_kind(kind) when is_binary(kind), + do: kind |> String.to_atom() |> family_for_kind() + + def family_for_kind(kind), do: Map.get(@component_families, kind) + + @spec content_identity_kinds() :: [atom()] + def content_identity_kinds, do: @content_identity_kinds + + @spec form_control_kinds() :: [atom()] + def form_control_kinds, do: @form_control_kinds + + @spec row_artifact_kinds() :: [atom()] + def row_artifact_kinds, do: @row_artifact_kinds + + @spec workflow_progress_kinds() :: [atom()] + def workflow_progress_kinds, do: @workflow_progress_kinds + + @spec layer_callout_kinds() :: [atom()] + def layer_callout_kinds, do: @layer_callout_kinds + + @spec redline_code_kinds() :: [atom()] + def redline_code_kinds, do: @redline_code_kinds + + @spec composition_behavior_kinds() :: [atom()] + def composition_behavior_kinds, do: @composition_behavior_kinds + + @spec inline_rich_text_heading( + String.t() | atom(), + atom(), + [keyword() | map()], + keyword() | map() + ) :: + ElmUi.Widget.t() + def inline_rich_text_heading(id, level, segments, opts \\ []) + when is_atom(level) and is_list(segments) do + component_widget( + :inline_rich_text_heading, + id, + %{heading: %{level: level, segments: normalize_maps(segments)}}, + [], + opts + ) + end + + @spec disclosure( + String.t() | atom(), + String.t(), + [ElmUi.Widget.t() | map() | keyword()], + keyword() | map() + ) :: + ElmUi.Widget.t() + def disclosure(id, summary, children \\ [], opts \\ []) when is_binary(summary) do + opts = options(opts) + + component_widget( + :disclosure, + id, + %{disclosure: %{summary: summary, open?: Builder.option(opts, :open?, false)}}, + children, + opts, + on_open: :open, + on_close: :close + ) + end + + @spec kicker(String.t() | atom(), [String.t()], keyword() | map()) :: ElmUi.Widget.t() + def kicker(id, items, opts \\ []) when is_list(items) do + opts = options(opts) + + component_widget( + :kicker, + id, + %{kicker: %{items: items, separator: Builder.option(opts, :separator, "·")}}, + [], + opts + ) + end + + @spec avatar(String.t() | atom(), keyword() | map()) :: ElmUi.Widget.t() + def avatar(id, opts \\ []) do + opts = options(opts) + + component_widget( + :avatar, + id, + %{ + identity: + %{} + |> maybe_put(:initials, Builder.option(opts, :initials)) + |> maybe_put(:image_source, Builder.option(opts, :image_source)) + |> maybe_put(:size, Builder.option(opts, :size, :medium)) + |> maybe_put(:shape, Builder.option(opts, :shape, :round)) + }, + [], + opts + ) + end + + @spec presence_dot(String.t() | atom(), atom(), keyword() | map()) :: ElmUi.Widget.t() + def presence_dot(id, state, opts \\ []) when is_atom(state) do + opts = options(opts) + + component_widget( + :presence_dot, + id, + %{presence: %{state: state, size: Builder.option(opts, :size, :medium)}}, + [], + opts + ) + end + + @spec segmented_button_group(String.t() | atom(), [keyword() | map()], keyword() | map()) :: + ElmUi.Widget.t() + def segmented_button_group(id, options_list, opts \\ []) when is_list(options_list) do + opts = options(opts) + + component_widget( + :segmented_button_group, + id, + %{ + selection: + %{ + presentation: :segmented_button_group, + multiple?: false, + options: normalize_options(options_list) + } + |> maybe_put(:active_value, Builder.option(opts, :active_value)) + |> maybe_put(:selection_intent, Builder.option(opts, :selection_intent)) + }, + [], + opts, + on_select: :selection, + on_selection: :selection, + on_change: :change + ) + end + + @spec runtime_form_shell(String.t() | atom(), [keyword() | map()], keyword() | map()) :: + ElmUi.Widget.t() + def runtime_form_shell(id, fields, opts \\ []) when is_list(fields) do + opts = options(opts) + + component_widget( + :runtime_form_shell, + id, + %{ + form: + %{ + fields: normalize_maps(fields) + } + |> maybe_put(:submit_label, Builder.option(opts, :submit_label)) + |> maybe_put(:submit_intent, Builder.option(opts, :submit_intent)) + |> maybe_put(:change_intent, Builder.option(opts, :change_intent)) + |> maybe_put(:validation_state, Builder.option(opts, :validation_state)) + |> maybe_put( + :host_adapter_hints, + normalize_optional_map(Builder.option(opts, :host_adapter_hints)) + ) + }, + Builder.option(opts, :children, []), + opts, + on_submit: :submit, + on_change: :change + ) + end + + @spec chat_composer( + String.t() | atom(), + [ElmUi.Widget.t() | map() | keyword()], + keyword() | map() + ) :: + ElmUi.Widget.t() + def chat_composer(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :chat_composer, + id, + %{ + composer: + %{} + |> maybe_put(:name, Builder.option(opts, :name)) + |> maybe_put(:value, Builder.option(opts, :value)) + |> maybe_put(:placeholder, Builder.option(opts, :placeholder)) + |> maybe_put(:rows, Builder.option(opts, :rows, 3)) + |> maybe_put(:send_label, Builder.option(opts, :send_label, "Send")) + |> maybe_put(:send_intent, Builder.option(opts, :send_intent)) + |> maybe_put(:change_intent, Builder.option(opts, :change_intent)) + }, + children, + opts, + on_send: :submit, + on_submit: :submit, + on_change: :change + ) + end + + @spec list_item_multi_column( + String.t() | atom(), + [ElmUi.Widget.t() | map() | keyword()], + keyword() | map() + ) :: + ElmUi.Widget.t() + def list_item_multi_column(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :list_item_multi_column, + id, + %{ + row: + common_row_attrs(opts) + |> maybe_put( + :column_template, + normalize_maps(Builder.option(opts, :column_template, [])) + ) + }, + children, + opts, + on_activate: :click, + on_click: :click, + on_select: :selection + ) + end + + @spec artifact_row( + String.t() | atom(), + String.t(), + [ElmUi.Widget.t() | map() | keyword()], + keyword() | map() + ) :: + ElmUi.Widget.t() + def artifact_row(id, title, children \\ [], opts \\ []) + when is_binary(title) and is_list(children) do + opts = options(opts) + + component_widget( + :artifact_row, + id, + %{ + artifact: + common_row_attrs(opts) + |> maybe_put(:title, title) + |> maybe_put(:meta, Builder.option(opts, :meta)) + }, + children, + opts, + on_activate: :click, + on_click: :click, + on_select: :selection + ) + end + + @spec pipeline_stepper_horizontal(String.t() | atom(), [keyword() | map()], keyword() | map()) :: + ElmUi.Widget.t() + def pipeline_stepper_horizontal(id, steps, opts \\ []) when is_list(steps) do + opts = options(opts) + + component_widget( + :pipeline_stepper_horizontal, + id, + %{ + workflow: + %{ + presentation: :pipeline_stepper_horizontal, + steps: normalize_maps(steps), + active_index: Builder.option(opts, :active_index, 0), + completed_indices: Builder.option(opts, :completed_indices, []) + } + |> maybe_put(:navigation_intent, Builder.option(opts, :navigation_intent)) + }, + [], + opts, + on_step: :navigation, + on_navigate: :navigation + ) + end + + @spec segmented_progress_bar(String.t() | atom(), [keyword() | map()], keyword() | map()) :: + ElmUi.Widget.t() + def segmented_progress_bar(id, segments, opts \\ []) when is_list(segments) do + opts = options(opts) + + component_widget( + :segmented_progress_bar, + id, + %{ + progress: + %{ + presentation: :segmented_progress_bar, + segments: normalize_maps(segments) + } + |> maybe_put( + :aggregate, + normalize_optional_map(Builder.option(opts, :aggregate_progress)) + ) + |> maybe_put(:label, Builder.option(opts, :label)) + }, + [], + opts + ) + end + + @spec workflow_stage_list_vertical(String.t() | atom(), [keyword() | map()], keyword() | map()) :: + ElmUi.Widget.t() + def workflow_stage_list_vertical(id, stages, opts \\ []) when is_list(stages) do + opts = options(opts) + + component_widget( + :workflow_stage_list_vertical, + id, + %{ + workflow: %{ + presentation: :workflow_stage_list_vertical, + stages: normalize_maps(stages), + active_index: Builder.option(opts, :active_index, 0) + } + }, + [], + opts, + on_step: :navigation, + on_navigate: :navigation + ) + end + + @spec meter_thin(String.t() | atom(), number(), keyword() | map()) :: ElmUi.Widget.t() + def meter_thin(id, current, opts \\ []) when is_number(current) do + opts = options(opts) + + component_widget( + :meter_thin, + id, + %{ + meter: + %{ + current: current, + minimum: Builder.option(opts, :minimum, 0), + maximum: Builder.option(opts, :maximum, 100) + } + |> maybe_put(:label, Builder.option(opts, :label)) + |> maybe_put(:state, Builder.option(opts, :state)) + }, + [], + opts + ) + end + + @spec sticky_frosted_header( + String.t() | atom(), + [ElmUi.Widget.t() | map() | keyword()], + keyword() | map() + ) :: + ElmUi.Widget.t() + def sticky_frosted_header(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :sticky_frosted_header, + id, + %{ + shell: + %{ + position: :sticky, + visual_effect: :frosted + } + |> maybe_put(:title, Builder.option(opts, :title)) + |> maybe_put(:leading, Builder.option(opts, :leading, [])) + |> maybe_put(:trailing, Builder.option(opts, :trailing, [])) + }, + children, + opts + ) + end + + @spec slide_over_panel( + String.t() | atom(), + [ElmUi.Widget.t() | map() | keyword()], + keyword() | map() + ) :: + ElmUi.Widget.t() + def slide_over_panel(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :slide_over_panel, + id, + %{ + panel: + %{ + modal?: false, + open?: Builder.option(opts, :open?, false), + size: Builder.option(opts, :size, :medium) + } + |> maybe_put( + :label, + Builder.option(opts, :label, Builder.option(opts, :accessibility_label)) + ) + |> maybe_put(:dismiss_intent, Builder.option(opts, :dismiss_intent)) + }, + children, + opts, + on_open: :open, + on_close: :close, + on_dismiss: :close + ) + end + + @spec event_callout( + String.t() | atom(), + String.t(), + [ElmUi.Widget.t() | map() | keyword()], + keyword() | map() + ) :: + ElmUi.Widget.t() + def event_callout(id, message, children \\ [], opts \\ []) + when is_binary(message) and is_list(children) do + opts = options(opts) + + component_widget( + :event_callout, + id, + %{ + callout: + %{ + message: message, + tone: Builder.option(opts, :tone, :info) + } + |> maybe_put(:eyebrow, Builder.option(opts, :eyebrow)) + |> maybe_put(:title, Builder.option(opts, :title)) + |> maybe_put(:action_intent, Builder.option(opts, :action_intent)) + }, + children, + opts, + on_action: :click, + on_click: :click + ) + end + + @spec redline_inline(String.t() | atom(), [keyword() | map()], keyword() | map()) :: + ElmUi.Widget.t() + def redline_inline(id, segments, opts \\ []) when is_list(segments) do + opts = options(opts) + + component_widget( + :redline_inline, + id, + %{ + redline: %{segments: normalize_maps(segments)}, + text_safety: %{content: Builder.option(opts, :text_safety, :plain_text)} + }, + [], + opts + ) + end + + @spec code_block_syntax_highlighted( + String.t() | atom(), + atom() | String.t(), + [keyword() | map()], + keyword() | map() + ) :: + ElmUi.Widget.t() + def code_block_syntax_highlighted(id, language, tokens, opts \\ []) + when (is_atom(language) or is_binary(language)) and is_list(tokens) do + opts = options(opts) + + component_widget( + :code_block_syntax_highlighted, + id, + %{ + code: %{language: language, tokens: normalize_maps(tokens)}, + text_safety: %{content: Builder.option(opts, :text_safety, :plain_text)} + }, + [], + opts + ) + end + + @spec list_repeat( + String.t() | atom(), + [ElmUi.Widget.t() | map() | keyword()], + keyword() | map() + ) :: + ElmUi.Widget.t() + def list_repeat(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :list_repeat, + id, + %{ + repeat: %{ + binding_id: Builder.option(opts, :repeat_binding), + row_scope: Builder.option(opts, :row_scope, :row), + row_fields: Builder.option(opts, :row_fields, []), + identity_strategy: Builder.option(opts, :identity_strategy, :row_identity), + child_slot: Builder.option(opts, :child_slot, :default), + hydrated?: Builder.option(opts, :hydrated?, false), + row_count: Builder.option(opts, :row_count, length(children)) + } + }, + children, + opts + ) + end + + @spec from_iur( + atom(), + String.t() | atom() | nil, + map(), + %{optional(atom() | String.t()) => [ElmUi.Widget.t()]}, + keyword() | map() + ) :: ElmUi.Widget.t() + def from_iur(kind, id, attributes, slot_children, opts \\ []) + when is_atom(kind) and is_map(attributes) and is_map(slot_children) do + opts = options(opts) + component_family = family_for_kind(kind) + + Builder.widget(kind, + id: id, + family: :component, + attributes: attributes, + slot_children: slot_children, + state: Builder.option(opts, :state, component_state(attributes)), + styles: Builder.option(opts, :styles, %{}), + events: Builder.option(opts, :events, %{}), + metadata: + Builder.option(opts, :metadata, %{}) + |> Map.merge(%{ + native_surface: :component, + component_family: component_family + }) + ) + end + + defp component_widget(kind, id, attributes, children, opts, event_shorthand \\ []) do + opts = options(Map.put(options(opts), :id, id)) + component_family = family_for_kind(kind) + + Builder.widget(kind, + id: id, + family: :component, + attributes: + attributes + |> Map.put_new(:component, %{family: component_family, kind: kind}) + |> merge_optional_attribute(:accessibility, accessibility(opts)) + |> merge_optional_attribute(:state, component_state(opts)), + slot_children: Builder.slot_map([{:default, children}]), + state: component_state(opts), + styles: Builder.styles(opts), + events: Builder.events(opts, event_shorthand), + metadata: + Builder.metadata(opts, %{ + native_surface: :component, + component_family: component_family + }) + ) + end + + defp common_row_attrs(opts) do + %{} + |> maybe_put(:row_identity, Builder.option(opts, :row_identity)) + |> maybe_put(:active?, Builder.option(opts, :active?)) + |> maybe_put(:link_target, Builder.option(opts, :link_target)) + |> maybe_put(:action_intent, Builder.option(opts, :action_intent)) + end + + defp component_state(opts) when is_map(opts) do + opts + |> Builder.state([:disabled, :selected, :active, :open, :focused, :editing, :loading]) + |> maybe_put(:disabled?, Builder.option(opts, :disabled?)) + |> maybe_put(:active?, Builder.option(opts, :active?)) + |> maybe_put(:open?, Builder.option(opts, :open?)) + end + + defp accessibility(opts) do + %{} + |> maybe_put(:label, Builder.option(opts, :accessibility_label)) + |> maybe_put(:description, Builder.option(opts, :accessibility_description)) + |> maybe_put(:role, Builder.option(opts, :accessibility_role)) + end + + defp normalize_options(options) do + Enum.map(options, fn option -> + option = options(option) + + %{} + |> maybe_put(:value, Builder.option(option, :value)) + |> maybe_put(:label, Builder.option(option, :label)) + |> maybe_put(:disabled?, Builder.option(option, :disabled?)) + end) + end + + defp normalize_maps(values) when is_list(values), do: Enum.map(values, &options/1) + defp normalize_maps(_values), do: [] + + defp normalize_optional_map(nil), do: nil + defp normalize_optional_map(value), do: options(value) + + defp options(opts), do: Builder.options(opts) + + defp merge_optional_attribute(attributes, _key, value) when value in [%{}, [], nil], + do: attributes + + defp merge_optional_attribute(attributes, key, value), do: Map.put(attributes, key, value) + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) +end diff --git a/packages/elm_ui/test/elm_ui/phase_one_integration_test.exs b/packages/elm_ui/test/elm_ui/phase_one_integration_test.exs index 71e9a601..571a2371 100644 --- a/packages/elm_ui/test/elm_ui/phase_one_integration_test.exs +++ b/packages/elm_ui/test/elm_ui/phase_one_integration_test.exs @@ -72,7 +72,8 @@ defmodule ElmUi.PhaseOneIntegrationTest do :description, :role, :variant, - :native_surface + :native_surface, + :component_family ] assert info.bridge.boundaries == [:hydration_envelope, :event_envelope, :acknowledgement] diff --git a/packages/elm_ui/test/elm_ui/widget_components_test.exs b/packages/elm_ui/test/elm_ui/widget_components_test.exs new file mode 100644 index 00000000..3dbfaa9a --- /dev/null +++ b/packages/elm_ui/test/elm_ui/widget_components_test.exs @@ -0,0 +1,187 @@ +defmodule ElmUi.WidgetComponentsTest do + use ExUnit.Case, async: true + + alias UnifiedIUR.{Element, RuntimeParity} + + test "native component constructors expose the expanded catalog" do + widgets = [ + ElmUi.Widgets.inline_rich_text_heading("heading", :h2, [ + %{type: :text, value: "Runtime parity"} + ]), + ElmUi.Widgets.disclosure( + "details", + "More", + [ + ElmUi.Widgets.kicker("kicker", ["Spec", "Runtime"]) + ], open?: true), + ElmUi.Widgets.avatar("avatar", initials: "PC", accessibility_label: "Pascal"), + ElmUi.Widgets.presence_dot("presence", :active, accessibility_label: "Active"), + ElmUi.Widgets.segmented_button_group( + "segment", + [%{value: :open, label: "Open"}, %{value: :closed, label: "Closed"}], + active_value: :open, + on_select: %{intent: :select_status} + ), + ElmUi.Widgets.runtime_form_shell( + "form", + [%{name: :title, type: :text, label: "Title"}], + submit_label: "Save", + host_adapter_hints: %{phoenix: %{adapter: :form}}, + on_submit: %{intent: :save_form}, + on_change: %{intent: :validate_form} + ), + ElmUi.Widgets.chat_composer( + "composer", + [ElmUi.Widgets.button("attach", "Attach")], + value: "Draft", + on_send: %{intent: :send_message}, + on_change: %{intent: :update_message} + ), + ElmUi.Widgets.artifact_row( + "artifact", + "ADR", + [ElmUi.Widgets.button("open", "Open")], + row_identity: "adr-1", + meta: %{status: :accepted}, + on_activate: %{intent: :open_artifact} + ), + ElmUi.Widgets.pipeline_stepper_horizontal( + "stepper", + [%{id: :draft, label: "Draft"}, %{id: :review, label: "Review"}], + active_index: 1, + on_step: %{intent: :select_step} + ), + ElmUi.Widgets.segmented_progress_bar( + "progress", + [%{id: :a, state: :done}, %{id: :b, state: :active}], + aggregate_progress: %{current: 1, maximum: 2} + ), + ElmUi.Widgets.workflow_stage_list_vertical( + "stages", + [%{id: :authored, label: "Authored"}, %{id: :implemented, label: "Implemented"}] + ), + ElmUi.Widgets.meter_thin("meter", 35, maximum: 100), + ElmUi.Widgets.sticky_frosted_header("header", [ + ElmUi.Widgets.text("header-title", "Header") + ]), + ElmUi.Widgets.slide_over_panel( + "panel", + [ + ElmUi.Widgets.text("panel-body", "Body") + ], open?: true), + ElmUi.Widgets.event_callout( + "callout", + "Paused", + [ + ElmUi.Widgets.button("inspect", "Inspect") + ], tone: :warning), + ElmUi.Widgets.redline_inline("redline", [%{state: :insert, text: ""}]), + ElmUi.Widgets.code_block_syntax_highlighted( + "code", + :elixir, + [%{type: :keyword, text: ""}] + ), + ElmUi.Widgets.list_repeat( + "repeat", + [ + ElmUi.Widgets.artifact_row("repeat:a1", "ADR 1") + ], repeat_binding: :artifacts, hydrated?: true) + ] + + assert ElmUi.Widgets.Components in ElmUi.Widgets.modules() + assert ElmUi.Widgets.validation_state().widget_components == :ready + + assert RuntimeParity.acceptance_criteria().required_widget_kinds -- ElmUi.Widgets.kinds() == + [] + + assert ElmUi.Renderer.required_canonical_kinds() -- ElmUi.Widgets.kinds() == [] + assert Enum.all?(widgets, &(&1.family == :component)) + + segment = Enum.find(widgets, &(&1.kind == :segmented_button_group)) + form = Enum.find(widgets, &(&1.kind == :runtime_form_shell)) + composer = Enum.find(widgets, &(&1.kind == :chat_composer)) + repeat = Enum.find(widgets, &(&1.kind == :list_repeat)) + + assert segment.events.selection.intent == :select_status + assert form.events.submit.intent == :save_form + assert form.events.change.intent == :validate_form + assert composer.events.submit.intent == :send_message + assert composer.slot_children.default |> hd() |> Map.fetch!(:kind) == :button + assert repeat.attributes.repeat.row_count == 1 + end + + test "canonical runtime parity fixtures map into ElmUi native component widgets" do + Enum.each(RuntimeParity.fixtures(), fn fixture -> + assert {:ok, widget} = ElmUi.Renderer.render(fixture.element) + assert widget.kind == fixture.element.kind + assert widget.family == :component + assert widget.metadata.component_family + assert get_in(widget.attributes, [:component, :kind]) == widget.kind + + expected_children = Map.get(fixture.expected, :children, %{}) + + Enum.each(expected_children, fn {slot, count} -> + assert length(Map.get(widget.slot_children, slot, [])) == count + end) + end) + end + + test "component fixtures hydrate through the Phoenix and Elm split runtime" do + screen = + Element.new(:layout, :column, + id: "component-parity-screen", + children: Enum.map(RuntimeParity.fixtures(), & &1.element) + ) + + assert {:ok, runtime_state} = + ElmUi.Runtime.mount_iur_screen(screen, + runtime_id: "elm-component-parity", + title: "Component Parity" + ) + + assert {:ok, model} = ElmUi.Runtime.hydrate_frontend(runtime_state) + + assert find_node(model.tree, "parity-form").role == "form" + assert find_node(model.tree, "parity-control").role == "radiogroup" + assert find_node(model.tree, "parity-repeat").role == "list" + assert find_node(model.tree, "parity-composer").browser.editable? + end + + test "redline and code component text remains plain text data in browser realization" do + screen = + Element.new(:layout, :column, + id: "safe-text-screen", + children: [ + RuntimeParity.fixture!(:redline).element, + RuntimeParity.fixture!(:code).element + ] + ) + + assert {:ok, runtime_state} = ElmUi.Runtime.mount_iur_screen(screen) + assert {:ok, model} = ElmUi.Runtime.hydrate_frontend(runtime_state) + + redline = find_node(model.tree, "parity-redline") + code = find_node(model.tree, "parity-code") + + assert redline.attrs.plain_text_output + assert code.attrs.plain_text_output + + assert redline.attributes.redline.segments == [ + %{state: :insert, text: ""} + ] + + assert code.attributes.code.tokens == [%{type: :keyword, text: ""}] + refute Map.has_key?(redline.attributes, :trusted_html) + refute Map.has_key?(code.attributes, :trusted_html) + end + + defp find_node(%{id: id} = node, id), do: node + + defp find_node(node, id) when is_map(node) do + node.slots + |> Enum.flat_map(& &1.children) + |> Enum.find_value(&find_node(&1, id)) + end + + defp find_node(nil, _id), do: nil +end From 7675fb7a27ac0dc2909f7b5dc4566d8c5740e83b Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 12:45:19 -0400 Subject: [PATCH 19/21] Implement DesktopUi widget component parity --- ...sktop-ui-and-terminal-ui-runtime-parity.md | 18 +- .../desktop_ui/lib/desktop_ui/renderer.ex | 9 +- .../lib/desktop_ui/renderer/mapper.ex | 309 ++++++-- .../lib/desktop_ui/sdl3/render_plan.ex | 223 +++++- .../desktop_ui/lib/desktop_ui/validate.ex | 5 +- packages/desktop_ui/lib/desktop_ui/widget.ex | 49 +- packages/desktop_ui/lib/desktop_ui/widgets.ex | 132 +++- .../lib/desktop_ui/widgets/components.ex | 685 ++++++++++++++++++ .../phase_eleven_integration_test.exs | 132 +++- .../desktop_ui/widget_components_test.exs | 197 +++++ 10 files changed, 1637 insertions(+), 122 deletions(-) create mode 100644 packages/desktop_ui/lib/desktop_ui/widgets/components.ex create mode 100644 packages/desktop_ui/test/desktop_ui/widget_components_test.exs diff --git a/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md b/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md index 96aa17ec..dd0feaa7 100644 --- a/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md +++ b/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md @@ -64,24 +64,24 @@ Back to index: [README](./README.md) [x] 4.2.2.2 Subtask - Translate canonical interactions into ElmUi message and Phoenix boundary events. [x] 4.2.2.3 Subtask - Verify repeated rows render as deterministic frontend children. - [ ] 4.3 Section - DesktopUi Runtime Support + [x] 4.3 Section - DesktopUi Runtime Support Implement desktop-native equivalents and IUR mappings with pointer, keyboard, and platform behavior appropriate for SDL3-oriented rendering. - [ ] 4.3.1 Task - Implement DesktopUi native widget models + [x] 4.3.1 Task - Implement DesktopUi native widget models Add desktop-native structures for the expanded widget catalog. - [ ] 4.3.1.1 Subtask - Add native widget structs and style hooks for content, identity, controls, rows, progress, layers, callouts, redline, code, and composer surfaces. - [ ] 4.3.1.2 Subtask - Add keyboard and pointer handling for selection, row activation, step navigation, disclosure, slide-over, form, and composer widgets. - [ ] 4.3.1.3 Subtask - Add safe text layout and token rendering for redline and code widgets. + [x] 4.3.1.1 Subtask - Add native widget structs and style hooks for content, identity, controls, rows, progress, layers, callouts, redline, code, and composer surfaces. + [x] 4.3.1.2 Subtask - Add keyboard and pointer handling for selection, row activation, step navigation, disclosure, slide-over, form, and composer widgets. + [x] 4.3.1.3 Subtask - Add safe text layout and token rendering for redline and code widgets. - [ ] 4.3.2 Task - Implement DesktopUi IUR renderer mappings + [x] 4.3.2 Task - Implement DesktopUi IUR renderer mappings Map canonical IUR into DesktopUi native widgets and desktop signal translation. - [ ] 4.3.2.1 Subtask - Add renderer mappings for every expanded widget family. - [ ] 4.3.2.2 Subtask - Translate canonical interactions into desktop input and signal behavior. - [ ] 4.3.2.3 Subtask - Verify repeated rows produce stable widget identity across desktop rerenders. + [x] 4.3.2.1 Subtask - Add renderer mappings for every expanded widget family. + [x] 4.3.2.2 Subtask - Translate canonical interactions into desktop input and signal behavior. + [x] 4.3.2.3 Subtask - Verify repeated rows produce stable widget identity across desktop rerenders. [ ] 4.4 Section - TerminalUi Runtime Support and Degradation Implement terminal-native equivalents and explicit fallback behavior for diff --git a/packages/desktop_ui/lib/desktop_ui/renderer.ex b/packages/desktop_ui/lib/desktop_ui/renderer.ex index 93177c5e..bd03350b 100644 --- a/packages/desktop_ui/lib/desktop_ui/renderer.ex +++ b/packages/desktop_ui/lib/desktop_ui/renderer.ex @@ -23,9 +23,8 @@ defmodule DesktopUi.Renderer do end @doc """ - All 45 canonical IUR widget kinds supported by the desktop_ui renderer. - Each kind has dedicated native widget mapping, draw kind handling, and - SDL3 rendering implementation. + Canonical IUR widget kinds supported by the desktop_ui renderer. + Each kind maps into the native widget model and SDL3 render plan semantics. """ @spec supported_kinds() :: [atom()] def supported_kinds do @@ -73,6 +72,7 @@ defmodule DesktopUi.Renderer do :dialog, :inline_feedback, :progress, + :sparkline, :status, :toast, # Operational (7) @@ -96,6 +96,9 @@ defmodule DesktopUi.Renderer do # Container (1) :window ] + |> Kernel.++(UnifiedIUR.Widgets.Components.kinds()) + |> Enum.uniq() + |> Enum.sort() end @spec validation_state() :: atom() diff --git a/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex b/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex index 7fa347d3..1b7f60bc 100644 --- a/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex +++ b/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex @@ -8,6 +8,9 @@ defmodule DesktopUi.Renderer.Mapper do alias UnifiedIUR.Element alias UnifiedIUR.Element.Child + @component_kinds UnifiedIUR.Widgets.Components.kinds() + @component_kind_values @component_kinds ++ Enum.map(@component_kinds, &Atom.to_string/1) + @spec map(Element.t(), keyword()) :: {:ok, Widget.t()} | {:error, Error.t()} def map(element, opts \\ []) @@ -146,7 +149,11 @@ defmodule DesktopUi.Renderer.Mapper do Keyword.merge( base_opts(element), size: first_present([group_attr(element, :badge, :size), attr(element, :size)], :md), - variant: first_present([group_attr(element, :badge, :variant), attr(element, :variant)], :default) + variant: + first_present( + [group_attr(element, :badge, :variant), attr(element, :variant)], + :default + ) ) )} end @@ -165,7 +172,8 @@ defmodule DesktopUi.Renderer.Mapper do subheadline: first_present([group_attr(element, :hero, :subheadline), attr(element, :subheadline)]), image: first_present([group_attr(element, :hero, :image), attr(element, :image)]), - actions: first_present([group_attr(element, :hero, :actions), attr(element, :actions)], []) + actions: + first_present([group_attr(element, :hero, :actions), attr(element, :actions)], []) ) )} end @@ -263,27 +271,40 @@ defmodule DesktopUi.Renderer.Mapper do min: attr(element, :min), max: attr(element, :max), step: first_present([attr(element, :step), group_attr(element, :input, :step)], 1), - placeholder: first_present([attr(element, :placeholder), group_attr(element, :input, :placeholder)], ""), + placeholder: + first_present( + [attr(element, :placeholder), group_attr(element, :input, :placeholder)], + "" + ), on_change: interaction_payload(element, :change) ) )} end - defp map_element(%Element{type: :widget, kind: kind} = element) when kind in [:slider, "slider"] do + defp map_element(%Element{type: :widget, kind: kind} = element) + when kind in [:slider, "slider"] do {:ok, DesktopUi.Widgets.slider( element.id, - Keyword.merge( - base_opts(element), - value: first_present([attr(element, :value), binding_value(element)], 0), - binding: binding_name(element), - min: attr(element, :min), - max: attr(element, :max), - step: first_present([attr(element, :step), group_attr(element, :input, :step)], 1), - show_value: first_present([attr(element, :show_value), group_attr(element, :input, :show_value)], true), - orientation: first_present([attr(element, :orientation), group_attr(element, :input, :orientation)], :horizontal), - on_change: interaction_payload(element, :change) - ) + Keyword.merge( + base_opts(element), + value: first_present([attr(element, :value), binding_value(element)], 0), + binding: binding_name(element), + min: attr(element, :min), + max: attr(element, :max), + step: first_present([attr(element, :step), group_attr(element, :input, :step)], 1), + show_value: + first_present( + [attr(element, :show_value), group_attr(element, :input, :show_value)], + true + ), + orientation: + first_present( + [attr(element, :orientation), group_attr(element, :input, :orientation)], + :horizontal + ), + on_change: interaction_payload(element, :change) + ) )} end @@ -298,7 +319,11 @@ defmodule DesktopUi.Renderer.Mapper do binding: binding_name(element), min: attr(element, :min), max: attr(element, :max), - placeholder: first_present([attr(element, :placeholder), group_attr(element, :input, :placeholder)], "YYYY-MM-DD"), + placeholder: + first_present( + [attr(element, :placeholder), group_attr(element, :input, :placeholder)], + "YYYY-MM-DD" + ), on_change: interaction_payload(element, :change) ) )} @@ -313,8 +338,13 @@ defmodule DesktopUi.Renderer.Mapper do base_opts(element), value: first_present([attr(element, :value), binding_value(element)]), binding: binding_name(element), - format: first_present([attr(element, :format), group_attr(element, :input, :format)], :"24h"), - placeholder: first_present([attr(element, :placeholder), group_attr(element, :input, :placeholder)], "HH:MM"), + format: + first_present([attr(element, :format), group_attr(element, :input, :format)], :"24h"), + placeholder: + first_present( + [attr(element, :placeholder), group_attr(element, :input, :placeholder)], + "HH:MM" + ), on_change: interaction_payload(element, :change) ) )} @@ -330,8 +360,16 @@ defmodule DesktopUi.Renderer.Mapper do value: first_present([attr(element, :value), binding_value(element)]), binding: binding_name(element), accept: first_present([attr(element, :accept), group_attr(element, :input, :accept)]), - multiple: first_present([attr(element, :multiple), group_attr(element, :input, :multiple)], false), - placeholder: first_present([attr(element, :placeholder), group_attr(element, :input, :placeholder)], "Choose file..."), + multiple: + first_present( + [attr(element, :multiple), group_attr(element, :input, :multiple)], + false + ), + placeholder: + first_present( + [attr(element, :placeholder), group_attr(element, :input, :placeholder)], + "Choose file..." + ), on_change: interaction_payload(element, :change) ) )} @@ -347,9 +385,21 @@ defmodule DesktopUi.Renderer.Mapper do base_opts(element), selected: first_present([attr(element, :selected), binding_value(element)]), binding: binding_name(element), - searchable: first_present([attr(element, :searchable), group_attr(element, :selection, :searchable)], true), - multiple: first_present([attr(element, :multiple), group_attr(element, :selection, :multiple)], false), - placeholder: first_present([attr(element, :placeholder), group_attr(element, :selection, :placeholder)], "Select..."), + searchable: + first_present( + [attr(element, :searchable), group_attr(element, :selection, :searchable)], + true + ), + multiple: + first_present( + [attr(element, :multiple), group_attr(element, :selection, :multiple)], + false + ), + placeholder: + first_present( + [attr(element, :placeholder), group_attr(element, :selection, :placeholder)], + "Select..." + ), on_select: interaction_payload(element, :selection) ) )} @@ -476,12 +526,25 @@ defmodule DesktopUi.Renderer.Mapper do Keyword.merge( base_opts(element), value: first_present([attr(element, :value), binding_value(element)]), - label: first_present([attr(element, :label), group_attr(element, :stat, :label), label_text(element)]), + label: + first_present([ + attr(element, :label), + group_attr(element, :stat, :label), + label_text(element) + ]), unit: first_present([attr(element, :unit), group_attr(element, :stat, :unit)]), trend: first_present([attr(element, :trend), group_attr(element, :stat, :trend)]), - previous_value: first_present([attr(element, :previous_value), group_attr(element, :stat, :previous_value)]), + previous_value: + first_present([ + attr(element, :previous_value), + group_attr(element, :stat, :previous_value) + ]), size: first_present([attr(element, :size), group_attr(element, :stat, :size)], :md), - variant: first_present([attr(element, :variant), group_attr(element, :stat, :variant)], :default) + variant: + first_present( + [attr(element, :variant), group_attr(element, :stat, :variant)], + :default + ) ) )} end @@ -495,9 +558,14 @@ defmodule DesktopUi.Renderer.Mapper do base_opts(element), key: first_present([attr(element, :key), group_attr(element, :key_value, :key)]), value: first_present([attr(element, :value), binding_value(element)]), - align: first_present([attr(element, :align), group_attr(element, :key_value, :align)], :left), + align: + first_present([attr(element, :align), group_attr(element, :key_value, :align)], :left), size: first_present([attr(element, :size), group_attr(element, :key_value, :size)], :md), - variant: first_present([attr(element, :variant), group_attr(element, :key_value, :variant)], :default) + variant: + first_present( + [attr(element, :variant), group_attr(element, :key_value, :variant)], + :default + ) ) )} end @@ -511,9 +579,21 @@ defmodule DesktopUi.Renderer.Mapper do Keyword.merge( base_opts(element), size: first_present([attr(element, :size), group_attr(element, :info_list, :size)], :md), - variant: first_present([attr(element, :variant), group_attr(element, :info_list, :variant)], :default), - show_icons: first_present([attr(element, :show_icons), group_attr(element, :info_list, :show_icons)], true), - compact: first_present([attr(element, :compact), group_attr(element, :info_list, :compact)], false) + variant: + first_present( + [attr(element, :variant), group_attr(element, :info_list, :variant)], + :default + ), + show_icons: + first_present( + [attr(element, :show_icons), group_attr(element, :info_list, :show_icons)], + true + ), + compact: + first_present( + [attr(element, :compact), group_attr(element, :info_list, :compact)], + false + ) ) )} end @@ -585,12 +665,33 @@ defmodule DesktopUi.Renderer.Mapper do element.id, Keyword.merge( base_opts(element), - message: first_present([attr(element, :message), attr(element, :content), label_text(element)]), - severity: first_present([attr(element, :severity), group_attr(element, :feedback, :severity)], :info), - placement: first_present([attr(element, :placement), group_attr(element, :feedback, :placement)], :bottom), - dismissible: first_present([attr(element, :dismissible), group_attr(element, :feedback, :dismissible)], true), - auto_hide: first_present([attr(element, :auto_hide), group_attr(element, :feedback, :auto_hide)], true), - timeout_ms: first_present([attr(element, :timeout_ms), group_attr(element, :feedback, :timeout_ms)], 3_000), + message: + first_present([attr(element, :message), attr(element, :content), label_text(element)]), + severity: + first_present( + [attr(element, :severity), group_attr(element, :feedback, :severity)], + :info + ), + placement: + first_present( + [attr(element, :placement), group_attr(element, :feedback, :placement)], + :bottom + ), + dismissible: + first_present( + [attr(element, :dismissible), group_attr(element, :feedback, :dismissible)], + true + ), + auto_hide: + first_present( + [attr(element, :auto_hide), group_attr(element, :feedback, :auto_hide)], + true + ), + timeout_ms: + first_present( + [attr(element, :timeout_ms), group_attr(element, :feedback, :timeout_ms)], + 3_000 + ), on_close: interaction_payload(element, :close) ) )} @@ -604,9 +705,15 @@ defmodule DesktopUi.Renderer.Mapper do label_text(element, "Status"), Keyword.merge( base_opts(element), - status: first_present([attr(element, :status), group_attr(element, :status, :state)], :idle), - severity: first_present([attr(element, :severity), group_attr(element, :status, :severity)], :info), - active: first_present([attr(element, :active), group_attr(element, :status, :active)], true), + status: + first_present([attr(element, :status), group_attr(element, :status, :state)], :idle), + severity: + first_present( + [attr(element, :severity), group_attr(element, :status, :severity)], + :info + ), + active: + first_present([attr(element, :active), group_attr(element, :status, :active)], true), icon: first_present([attr(element, :icon), group_attr(element, :status, :icon)]) ) )} @@ -676,8 +783,16 @@ defmodule DesktopUi.Renderer.Mapper do color: first_present([attr(element, :color), group_attr(element, :chart, :color)]), width: attr(element, :width), height: attr(element, :height), - show_area: first_present([attr(element, :show_area), group_attr(element, :chart, :show_area)], true), - show_dots: first_present([attr(element, :show_dots), group_attr(element, :chart, :show_dots)], false) + show_area: + first_present( + [attr(element, :show_area), group_attr(element, :chart, :show_area)], + true + ), + show_dots: + first_present( + [attr(element, :show_dots), group_attr(element, :chart, :show_dots)], + false + ) ) )} end @@ -773,13 +888,24 @@ defmodule DesktopUi.Renderer.Mapper do element.id, Keyword.merge( base_opts(element), - entries: first_present([attr(element, :entries), group_attr(element, :data, :entries)], []), - follow: first_present([attr(element, :follow), group_attr(element, :stream, :follow)], true), + entries: + first_present([attr(element, :entries), group_attr(element, :data, :entries)], []), + follow: + first_present([attr(element, :follow), group_attr(element, :stream, :follow)], true), filter: first_present([attr(element, :filter), group_attr(element, :stream, :filter)]), level: first_present([attr(element, :level), group_attr(element, :stream, :level)]), - line_limit: first_present([attr(element, :line_limit), group_attr(element, :stream, :line_limit)], 1000), - streaming: first_present([attr(element, :streaming), group_attr(element, :stream, :streaming)], true), - paused: first_present([attr(element, :paused), group_attr(element, :stream, :paused)], false), + line_limit: + first_present( + [attr(element, :line_limit), group_attr(element, :stream, :line_limit)], + 1000 + ), + streaming: + first_present( + [attr(element, :streaming), group_attr(element, :stream, :streaming)], + true + ), + paused: + first_present([attr(element, :paused), group_attr(element, :stream, :paused)], false), on_pause: interaction_payload(element, :pause), on_resume: interaction_payload(element, :resume), on_clear: interaction_payload(element, :clear), @@ -795,11 +921,20 @@ defmodule DesktopUi.Renderer.Mapper do element.id, Keyword.merge( base_opts(element), - tree: first_present([attr(element, :tree), group_attr(element, :supervision, :tree)], []), + tree: + first_present([attr(element, :tree), group_attr(element, :supervision, :tree)], []), query: first_present([attr(element, :query), group_attr(element, :supervision, :query)]), - application: first_present([attr(element, :application), group_attr(element, :supervision, :application)]), + application: + first_present([ + attr(element, :application), + group_attr(element, :supervision, :application) + ]), selection_binding: binding_name(element), - expanded: first_present([attr(element, :expanded), group_attr(element, :supervision, :expanded)], []), + expanded: + first_present( + [attr(element, :expanded), group_attr(element, :supervision, :expanded)], + [] + ), selected: first_present([attr(element, :selected), binding_value(element)]), on_select: interaction_payload(element, :selection), on_expand: interaction_payload(element, :expand), @@ -934,7 +1069,8 @@ defmodule DesktopUi.Renderer.Mapper do {:ok, DesktopUi.Layout.box( element.id, - [], # Children will be added via slot_children + # Children will be added via slot_children + [], Keyword.merge( base_opts(element), # Container attributes @@ -965,7 +1101,8 @@ defmodule DesktopUi.Renderer.Mapper do {:ok, DesktopUi.Layout.grid( element.id, - [], # Children will be added via slot_children + # Children will be added via slot_children + [], Keyword.merge( base_opts(element), # Grid dimensions @@ -1042,6 +1179,23 @@ defmodule DesktopUi.Renderer.Mapper do )} end + defp map_element(%Element{type: :widget, kind: kind} = element) + when kind in @component_kind_values do + kind = component_kind(kind) + attributes = normalize_component_attributes(kind, element.attributes) + + {:ok, + DesktopUi.Widgets.Components.from_iur( + kind, + element.id, + attributes, + Keyword.merge(base_opts(element), + state: Map.get(attributes, :state, %{}), + events: component_events(element) + ) + )} + end + defp map_element(%Element{} = element) do {:error, Error.new(:unsupported_canonical_construct, %{ @@ -1204,6 +1358,57 @@ defmodule DesktopUi.Renderer.Mapper do end end + defp component_events(%Element{} = element) do + element + |> attr(:interactions) + |> List.wrap() + |> Enum.map(&normalize_nested_map/1) + |> Enum.reduce(%{}, fn interaction, acc -> + case map_get(interaction, :family) || map_get(interaction, :kind) do + nil -> + acc + + family -> + Map.put(acc, normalize_key(family), Map.delete(interaction, :family)) + end + end) + end + + defp component_kind(kind) when is_atom(kind), do: kind + defp component_kind(kind) when is_binary(kind), do: String.to_atom(kind) + + defp normalize_component_attributes(kind, attributes) do + kind = component_kind(kind) + + attributes + |> normalize_nested_map() + |> Map.put_new(:component, %{ + family: DesktopUi.Widgets.Components.family_for_kind(kind), + kind: kind + }) + end + + defp normalize_nested_map(%_{} = struct), + do: struct |> Map.from_struct() |> normalize_nested_map() + + defp normalize_nested_map(map) when is_map(map) do + Map.new(map, fn {key, value} -> {normalize_key(key), normalize_nested_map(value)} end) + end + + defp normalize_nested_map(list) when is_list(list), do: Enum.map(list, &normalize_nested_map/1) + defp normalize_nested_map(value), do: value + + defp map_get(map, key, default \\ nil) when is_map(map) do + cond do + Map.has_key?(map, key) -> Map.get(map, key) + Map.has_key?(map, Atom.to_string(key)) -> Map.get(map, Atom.to_string(key)) + true -> default + end + end + + defp normalize_key(key) when is_binary(key), do: String.to_atom(key) + defp normalize_key(key), do: key + defp content_text(element, fallback) do first_present([attr(element, :content), attr(element, :text), label_text(element)], fallback) end diff --git a/packages/desktop_ui/lib/desktop_ui/sdl3/render_plan.ex b/packages/desktop_ui/lib/desktop_ui/sdl3/render_plan.ex index 21d943a2..a0fd4963 100644 --- a/packages/desktop_ui/lib/desktop_ui/sdl3/render_plan.ex +++ b/packages/desktop_ui/lib/desktop_ui/sdl3/render_plan.ex @@ -90,7 +90,7 @@ defmodule DesktopUi.Sdl3.RenderPlan do widget_complete_draw_operations: true, iur_widget_coverage: :complete, supported_iur_kinds: length(DesktopUi.Renderer.supported_kinds()), - validation_state: :iur_renderer_complete + validation_state: :render_plan_ready }, diagnostics: %{ window_count: length(windows), @@ -301,12 +301,14 @@ defmodule DesktopUi.Sdl3.RenderPlan do total_gap_height = row_gap * max(trunc(row_count) - 1, 0) cell_width = max((bounds.width - total_gap_width) / columns, 32) - cell_height = if rows do - max((bounds.height - total_gap_height) / row_count, 32) - else - # Calculate based on available height divided by rows needed - max((bounds.height - total_gap_height) / calculated_rows, 32) - end + + cell_height = + if rows do + max((bounds.height - total_gap_height) / row_count, 32) + else + # Calculate based on available height divided by rows needed + max((bounds.height - total_gap_height) / calculated_rows, 32) + end specs = Enum.with_index(children) @@ -325,8 +327,8 @@ defmodule DesktopUi.Sdl3.RenderPlan do units: :logical } - {child, Map.get(source_index, normalize_id(child.id), source_stub(child)), - child_bounds, inherited_clip} + {child, Map.get(source_index, normalize_id(child.id), source_stub(child)), child_bounds, + inherited_clip} end) specs @@ -543,7 +545,18 @@ defmodule DesktopUi.Sdl3.RenderPlan do inset_bounds(bounds, padding + 18, 52, padding + 18, padding + 18) kind - when kind in [:overlay, :viewport, :scroll_region, :scroll_bar, :content, :column, :row, :split_pane, :box, :grid] -> + when kind in [ + :overlay, + :viewport, + :scroll_region, + :scroll_bar, + :content, + :column, + :row, + :split_pane, + :box, + :grid + ] -> inset_bounds(bounds, padding + 12, padding + 12, padding + 12, padding + 12) :canvas_surface -> @@ -612,6 +625,12 @@ defmodule DesktopUi.Sdl3.RenderPlan do name: node.attributes[:icon] } + kind when kind in [:redline_inline, :code_block_syntax_highlighted] -> + %{ + kind: :plain_text_tokens, + text_safety: get_in(node.attributes, [:text_safety, :content]) + } + _other -> %{} end @@ -632,6 +651,8 @@ defmodule DesktopUi.Sdl3.RenderPlan do navigation_intent: event_intent(Map.get(events, :navigation)), window_identity: Map.get(source.metadata, :window_identity), overlay_role: Map.get(source.metadata, :overlay_role), + component_family: Map.get(source.metadata, :component_family), + component_kind: Map.get(source.metadata, :component_kind), selection_mode: Map.get(source.metadata, :selection_mode, Map.get(source.attributes, :selection_mode)) } @@ -643,14 +664,71 @@ defmodule DesktopUi.Sdl3.RenderPlan do defp draw_content(node) do cond do - is_binary(node.attributes[:content]) -> node.attributes[:content] - is_binary(node.attributes[:label]) -> node.attributes[:label] - is_binary(node.attributes[:window_title]) -> node.attributes[:window_title] - is_binary(node.attributes[:message]) -> node.attributes[:message] - is_binary(node.attributes[:query]) -> node.attributes[:query] - is_binary(node.attributes[:alt]) -> node.attributes[:alt] - is_binary(node.attributes[:placeholder]) -> node.attributes[:placeholder] - true -> to_string(node.kind) + is_binary(node.attributes[:content]) -> + node.attributes[:content] + + is_binary(node.attributes[:label]) -> + node.attributes[:label] + + is_binary(node.attributes[:window_title]) -> + node.attributes[:window_title] + + is_binary(node.attributes[:message]) -> + node.attributes[:message] + + is_binary(node.attributes[:query]) -> + node.attributes[:query] + + is_binary(node.attributes[:alt]) -> + node.attributes[:alt] + + is_binary(node.attributes[:placeholder]) -> + node.attributes[:placeholder] + + is_binary(get_in(node.attributes, [:disclosure, :summary])) -> + get_in(node.attributes, [:disclosure, :summary]) + + is_binary(get_in(node.attributes, [:identity, :initials])) -> + get_in(node.attributes, [:identity, :initials]) + + is_binary(get_in(node.attributes, [:form, :submit_label])) -> + get_in(node.attributes, [:form, :submit_label]) + + is_binary(get_in(node.attributes, [:composer, :value])) -> + get_in(node.attributes, [:composer, :value]) + + is_binary(get_in(node.attributes, [:artifact, :title])) -> + get_in(node.attributes, [:artifact, :title]) + + is_binary(get_in(node.attributes, [:shell, :title])) -> + get_in(node.attributes, [:shell, :title]) + + is_binary(get_in(node.attributes, [:panel, :label])) -> + get_in(node.attributes, [:panel, :label]) + + is_binary(get_in(node.attributes, [:callout, :message])) -> + get_in(node.attributes, [:callout, :message]) + + node.kind == :inline_rich_text_heading -> + joined_text_segments(get_in(node.attributes, [:heading, :segments])) + + node.kind == :kicker -> + node.attributes |> get_in([:kicker, :items]) |> List.wrap() |> Enum.join(" ") + + node.kind == :presence_dot -> + node.attributes |> get_in([:presence, :state]) |> to_string() + + node.kind == :redline_inline -> + joined_text_segments(get_in(node.attributes, [:redline, :segments])) + + node.kind == :code_block_syntax_highlighted -> + joined_text_segments(get_in(node.attributes, [:code, :tokens])) + + node.kind == :list_repeat -> + "rows: #{get_in(node.attributes, [:repeat, :row_count]) || length(node.children)}" + + true -> + to_string(node.kind) end end @@ -703,10 +781,48 @@ defmodule DesktopUi.Sdl3.RenderPlan do defp draw_kind(:context_menu), do: :context_menu_surface defp draw_kind(:canvas_surface), do: :canvas_surface defp draw_kind(:absolute), do: :positioned_fragment + defp draw_kind(:inline_rich_text_heading), do: :rich_heading_text + defp draw_kind(:disclosure), do: :disclosure_surface + defp draw_kind(:kicker), do: :kicker_text + defp draw_kind(:avatar), do: :avatar_surface + defp draw_kind(:presence_dot), do: :presence_indicator + defp draw_kind(:segmented_button_group), do: :segmented_control + defp draw_kind(:runtime_form_shell), do: :runtime_form_surface + defp draw_kind(:chat_composer), do: :composer_control + defp draw_kind(:list_item_multi_column), do: :multi_column_row + defp draw_kind(:artifact_row), do: :artifact_row + defp draw_kind(:pipeline_stepper_horizontal), do: :pipeline_stepper + defp draw_kind(:segmented_progress_bar), do: :segmented_progress + defp draw_kind(:workflow_stage_list_vertical), do: :workflow_stage_list + defp draw_kind(:meter_thin), do: :thin_meter + defp draw_kind(:sticky_frosted_header), do: :sticky_header_surface + defp draw_kind(:slide_over_panel), do: :slide_over_panel_surface + defp draw_kind(:event_callout), do: :event_callout_surface + defp draw_kind(:redline_inline), do: :redline_text + defp draw_kind(:code_block_syntax_highlighted), do: :code_block_text + defp draw_kind(:list_repeat), do: :repeat_surface defp draw_kind(_kind), do: :container_surface defp preferred_height(node) do cond do + node.kind == :inline_rich_text_heading -> 44 + node.kind == :disclosure -> if(node.state[:open] || node.state[:open?], do: 96, else: 42) + node.kind == :kicker -> 24 + node.kind in [:avatar, :presence_dot] -> 40 + node.kind == :segmented_button_group -> 44 + node.kind == :runtime_form_shell -> 72 + field_count(node) * 48 + node.kind == :chat_composer -> 84 + node.kind in [:list_item_multi_column, :artifact_row] -> 56 + node.kind == :pipeline_stepper_horizontal -> 64 + node.kind == :segmented_progress_bar -> 20 + node.kind == :workflow_stage_list_vertical -> 36 + stage_count(node) * 32 + node.kind == :meter_thin -> 12 + node.kind == :sticky_frosted_header -> 58 + node.kind == :slide_over_panel -> 300 + node.kind == :event_callout -> 72 + node.kind == :redline_inline -> 28 + node.kind == :code_block_syntax_highlighted -> max(64, 32 + token_count(node) * 18) + node.kind == :list_repeat -> 32 + max(length(node.children), row_count(node)) * 56 node.kind in [:text, :label] -> 28 node.kind == :badge -> badge_height(node) node.kind == :hero -> 180 @@ -768,6 +884,7 @@ defmodule DesktopUi.Sdl3.RenderPlan do defp pick_list_height(node) do base = 46 + if node.attributes[:open] || node.attributes[:focused] do base + min(option_count(node), 6) * 36 else @@ -778,6 +895,7 @@ defmodule DesktopUi.Sdl3.RenderPlan do defp tree_view_height(node) do base = 64 node_count = item_count(node) + if node_count > 0 do base + min(node_count, 8) * 28 else @@ -807,6 +925,7 @@ defmodule DesktopUi.Sdl3.RenderPlan do defp info_list_height(node) do base = 32 item_count = item_count(node) + if item_count > 0 do base + min(item_count, 10) * 24 else @@ -852,6 +971,7 @@ defmodule DesktopUi.Sdl3.RenderPlan do defp supervision_tree_height(node) do base = 80 node_count = item_count(node) + if node_count > 0 do base + min(node_count, 10) * 32 else @@ -926,6 +1046,34 @@ defmodule DesktopUi.Sdl3.RenderPlan do node.kind == :status -> min(120, available_width) + node.kind == :inline_rich_text_heading -> + min(520, available_width) + + node.kind in [ + :disclosure, + :runtime_form_shell, + :chat_composer, + :slide_over_panel, + :event_callout, + :code_block_syntax_highlighted, + :list_repeat + ] -> + min(520, available_width) + + node.kind in [:list_item_multi_column, :artifact_row] -> + min(460, available_width) + + node.kind in [ + :pipeline_stepper_horizontal, + :workflow_stage_list_vertical, + :segmented_progress_bar, + :meter_thin + ] -> + min(360, available_width) + + node.kind in [:kicker, :avatar, :presence_dot, :redline_inline] -> + min(max(String.length(draw_content(node)) * 12, 72), available_width) + node.kind == :progress -> min(160, available_width) @@ -1014,6 +1162,22 @@ defmodule DesktopUi.Sdl3.RenderPlan do |> List.wrap() |> length() + defp field_count(node), + do: node.attributes |> get_in([:form, :fields]) |> List.wrap() |> length() + + defp stage_count(node) do + stages = get_in(node.attributes, [:workflow, :stages]) + steps = get_in(node.attributes, [:workflow, :steps]) + length(List.wrap(stages || steps || [])) + end + + defp token_count(node) do + tokens = + get_in(node.attributes, [:code, :tokens]) || get_in(node.attributes, [:redline, :segments]) + + length(List.wrap(tokens)) + end + defp row_count(node), do: (node.attributes[:rows] || node.attributes[:entries] || node.attributes[:processes] || []) @@ -1069,11 +1233,30 @@ defmodule DesktopUi.Sdl3.RenderPlan do defp truthy?(value), do: value not in [false, nil, :idle, :closed] defp numeric_value(node) do - node.attributes[:value] || node.state[:value] || node.state[:progress] || 0 + node.attributes[:value] || node.state[:value] || node.state[:progress] || + get_in(node.attributes, [:meter, :current]) || + get_in(node.attributes, [:progress, :aggregate, :current]) || 0 end defp max_value(node) do - node.attributes[:max] || node.attributes[:total] || 100 + node.attributes[:max] || node.attributes[:total] || + get_in(node.attributes, [:meter, :maximum]) || + get_in(node.attributes, [:progress, :aggregate, :maximum]) || 100 + end + + defp joined_text_segments(nil), do: "" + + defp joined_text_segments(segments) do + segments + |> List.wrap() + |> Enum.map(fn + %{text: text} -> text + %{"text" => text} -> text + %{value: value} -> value + %{"value" => value} -> value + segment -> to_string(segment) + end) + |> Enum.join("") end defp logical_bounds(index) do diff --git a/packages/desktop_ui/lib/desktop_ui/validate.ex b/packages/desktop_ui/lib/desktop_ui/validate.ex index a1084b78..bb39d254 100644 --- a/packages/desktop_ui/lib/desktop_ui/validate.ex +++ b/packages/desktop_ui/lib/desktop_ui/validate.ex @@ -822,10 +822,9 @@ defmodule DesktopUi.Validate do defp traceability_includes_direct_prefix?(_result, _prefix), do: false - # Verifies that all 45 canonical IUR widget kinds are supported by the renderer. - # Returns true when the renderer supports the complete set of IUR widget kinds. + # Returns true when the renderer supports the complete package widget surface. defp iur_widget_coverage_complete? do - expected_count = 45 + expected_count = length(DesktopUi.Widgets.kinds()) actual_count = length(DesktopUi.Renderer.supported_kinds()) actual_count >= expected_count and diff --git a/packages/desktop_ui/lib/desktop_ui/widget.ex b/packages/desktop_ui/lib/desktop_ui/widget.ex index 1661212d..fa2a33dc 100644 --- a/packages/desktop_ui/lib/desktop_ui/widget.ex +++ b/packages/desktop_ui/lib/desktop_ui/widget.ex @@ -15,6 +15,7 @@ defmodule DesktopUi.Widget do | :visualization | :operational | :layer + | :component @type t :: %__MODULE__{ id: String.t() | atom() | nil, @@ -64,7 +65,9 @@ defmodule DesktopUi.Widget do :overlay_lifecycle, :positioning_mode, :interaction_route, - :window_identity + :window_identity, + :component_family, + :component_kind ], state: [ :disabled, @@ -80,7 +83,10 @@ defmodule DesktopUi.Widget do :progress, :severity, :streaming, - :paused + :paused, + :disabled?, + :active?, + :open? ], bindings: [ :value, @@ -130,7 +136,10 @@ defmodule DesktopUi.Widget do :processes, :summary, :query, - :entries + :entries, + :component, + :accessibility, + :text_safety ], styles: [ :fg, @@ -164,7 +173,13 @@ defmodule DesktopUi.Widget do :expand, :dismiss, :command, - :navigation + :navigation, + :send, + :row_activation, + :step_navigation, + :inline_action, + :disclosure, + :panel ] } end @@ -255,6 +270,32 @@ defmodule DesktopUi.Widget do def family_for(kind) when kind in [:text_input, :checkbox, :radio_group, :select], do: :input def family_for(kind) when kind in [:menu, :tabs, :breadcrumbs, :list], do: :navigation def family_for(kind) when kind in [:status], do: :feedback + + def family_for(kind) + when kind in [ + :inline_rich_text_heading, + :disclosure, + :kicker, + :avatar, + :presence_dot, + :segmented_button_group, + :runtime_form_shell, + :chat_composer, + :list_item_multi_column, + :artifact_row, + :pipeline_stepper_horizontal, + :segmented_progress_bar, + :workflow_stage_list_vertical, + :meter_thin, + :sticky_frosted_header, + :slide_over_panel, + :event_callout, + :redline_inline, + :code_block_syntax_highlighted, + :list_repeat + ], + do: :component + def family_for(_kind), do: :content defp normalize_map(nil), do: %{} diff --git a/packages/desktop_ui/lib/desktop_ui/widgets.ex b/packages/desktop_ui/lib/desktop_ui/widgets.ex index 385c25f8..98806ae5 100644 --- a/packages/desktop_ui/lib/desktop_ui/widgets.ex +++ b/packages/desktop_ui/lib/desktop_ui/widgets.ex @@ -4,7 +4,7 @@ defmodule DesktopUi.Widgets do """ alias DesktopUi.Widget - alias DesktopUi.Widgets.{Builder, Data, Feedback, Foundational, Input, Navigation} + alias DesktopUi.Widgets.{Builder, Components, Data, Feedback, Foundational, Input, Navigation} alias DesktopUi.Widgets.{Operational, Visualization} @spec families() :: [Widget.family()] @@ -27,7 +27,8 @@ defmodule DesktopUi.Widgets do Data, Feedback, Visualization, - Operational + Operational, + Components ] end @@ -41,6 +42,7 @@ defmodule DesktopUi.Widgets do Feedback.kinds(), Visualization.kinds(), Operational.kinds(), + Components.kinds(), [:column, :row, :sparkline, :stack, :status, :window] ] |> List.flatten() @@ -72,7 +74,8 @@ defmodule DesktopUi.Widgets do advanced_operational_widgets: :ready, advanced_window_metadata: :ready, slot_contracts: :ready, - style_contracts: :ready + style_contracts: :ready, + widget_components: :ready } end @@ -374,4 +377,127 @@ defmodule DesktopUi.Widgets do def window_command(id, label, opts \\ []) do Operational.window_command(id, label, opts) end + + @spec inline_rich_text_heading(String.t() | atom(), atom(), [map() | keyword()], keyword()) :: + Widget.t() + def inline_rich_text_heading(id, level, segments, opts \\ []) do + Components.inline_rich_text_heading(id, level, segments, opts) + end + + @spec disclosure(String.t() | atom(), String.t(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def disclosure(id, summary, children \\ [], opts \\ []) do + Components.disclosure(id, summary, children, opts) + end + + @spec kicker(String.t() | atom(), [String.t()], keyword()) :: Widget.t() + def kicker(id, items, opts \\ []) do + Components.kicker(id, items, opts) + end + + @spec avatar(String.t() | atom(), keyword()) :: Widget.t() + def avatar(id, opts \\ []) do + Components.avatar(id, opts) + end + + @spec presence_dot(String.t() | atom(), atom(), keyword()) :: Widget.t() + def presence_dot(id, state, opts \\ []) do + Components.presence_dot(id, state, opts) + end + + @spec segmented_button_group(String.t() | atom(), [map() | keyword()], keyword()) :: + Widget.t() + def segmented_button_group(id, options, opts \\ []) do + Components.segmented_button_group(id, options, opts) + end + + @spec runtime_form_shell(String.t() | atom(), [map() | keyword()], keyword()) :: Widget.t() + def runtime_form_shell(id, fields, opts \\ []) do + Components.runtime_form_shell(id, fields, opts) + end + + @spec chat_composer(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def chat_composer(id, children \\ [], opts \\ []) do + Components.chat_composer(id, children, opts) + end + + @spec list_item_multi_column(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def list_item_multi_column(id, children \\ [], opts \\ []) do + Components.list_item_multi_column(id, children, opts) + end + + @spec artifact_row(String.t() | atom(), String.t(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def artifact_row(id, title, children \\ [], opts \\ []) do + Components.artifact_row(id, title, children, opts) + end + + @spec pipeline_stepper_horizontal(String.t() | atom(), [map() | keyword()], keyword()) :: + Widget.t() + def pipeline_stepper_horizontal(id, steps, opts \\ []) do + Components.pipeline_stepper_horizontal(id, steps, opts) + end + + @spec segmented_progress_bar(String.t() | atom(), [map() | keyword()], keyword()) :: + Widget.t() + def segmented_progress_bar(id, segments, opts \\ []) do + Components.segmented_progress_bar(id, segments, opts) + end + + @spec workflow_stage_list_vertical(String.t() | atom(), [map() | keyword()], keyword()) :: + Widget.t() + def workflow_stage_list_vertical(id, stages, opts \\ []) do + Components.workflow_stage_list_vertical(id, stages, opts) + end + + @spec meter_thin(String.t() | atom(), number(), keyword()) :: Widget.t() + def meter_thin(id, current, opts \\ []) do + Components.meter_thin(id, current, opts) + end + + @spec sticky_frosted_header(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def sticky_frosted_header(id, children \\ [], opts \\ []) do + Components.sticky_frosted_header(id, children, opts) + end + + @spec slide_over_panel(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def slide_over_panel(id, children \\ [], opts \\ []) do + Components.slide_over_panel(id, children, opts) + end + + @spec event_callout( + String.t() | atom(), + String.t(), + [Widget.t() | map() | keyword()], + keyword() + ) :: + Widget.t() + def event_callout(id, message, children \\ [], opts \\ []) do + Components.event_callout(id, message, children, opts) + end + + @spec redline_inline(String.t() | atom(), [map() | keyword()], keyword()) :: Widget.t() + def redline_inline(id, segments, opts \\ []) do + Components.redline_inline(id, segments, opts) + end + + @spec code_block_syntax_highlighted( + String.t() | atom(), + atom() | String.t(), + [map() | keyword()], + keyword() + ) :: Widget.t() + def code_block_syntax_highlighted(id, language, tokens, opts \\ []) do + Components.code_block_syntax_highlighted(id, language, tokens, opts) + end + + @spec list_repeat(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def list_repeat(id, children \\ [], opts \\ []) do + Components.list_repeat(id, children, opts) + end end diff --git a/packages/desktop_ui/lib/desktop_ui/widgets/components.ex b/packages/desktop_ui/lib/desktop_ui/widgets/components.ex new file mode 100644 index 00000000..0b8884e4 --- /dev/null +++ b/packages/desktop_ui/lib/desktop_ui/widgets/components.ex @@ -0,0 +1,685 @@ +defmodule DesktopUi.Widgets.Components do + @moduledoc """ + Desktop-native widget models for the expanded canonical component catalog. + """ + + alias DesktopUi.Widget + + @content_identity_kinds [ + :inline_rich_text_heading, + :disclosure, + :kicker, + :avatar, + :presence_dot + ] + + @form_control_kinds [ + :segmented_button_group, + :runtime_form_shell, + :chat_composer + ] + + @row_artifact_kinds [:list_item_multi_column, :artifact_row] + + @workflow_progress_kinds [ + :pipeline_stepper_horizontal, + :segmented_progress_bar, + :workflow_stage_list_vertical, + :meter_thin + ] + + @layer_callout_kinds [ + :sticky_frosted_header, + :slide_over_panel, + :event_callout + ] + + @redline_code_kinds [:redline_inline, :code_block_syntax_highlighted] + @composition_behavior_kinds [:list_repeat] + + @component_families %{ + inline_rich_text_heading: :content_identity, + disclosure: :content_identity, + kicker: :content_identity, + avatar: :content_identity, + presence_dot: :content_identity, + segmented_button_group: :form_control, + runtime_form_shell: :form_control, + chat_composer: :form_control, + list_item_multi_column: :row_artifact, + artifact_row: :row_artifact, + pipeline_stepper_horizontal: :workflow_progress, + segmented_progress_bar: :workflow_progress, + workflow_stage_list_vertical: :workflow_progress, + meter_thin: :workflow_progress, + sticky_frosted_header: :layer_callout, + slide_over_panel: :layer_callout, + event_callout: :layer_callout, + redline_inline: :redline_code, + code_block_syntax_highlighted: :redline_code, + list_repeat: :composition_behavior + } + + @focusable_kinds [ + :disclosure, + :segmented_button_group, + :runtime_form_shell, + :chat_composer, + :list_item_multi_column, + :artifact_row, + :pipeline_stepper_horizontal, + :workflow_stage_list_vertical, + :slide_over_panel, + :event_callout + ] + + @kinds @content_identity_kinds ++ + @form_control_kinds ++ + @row_artifact_kinds ++ + @workflow_progress_kinds ++ + @layer_callout_kinds ++ + @redline_code_kinds ++ + @composition_behavior_kinds + + @spec kinds() :: [atom()] + def kinds, do: @kinds + + @spec family_for_kind(atom() | String.t()) :: atom() | nil + def family_for_kind(kind) when is_binary(kind), + do: kind |> String.to_atom() |> family_for_kind() + + def family_for_kind(kind), do: Map.get(@component_families, kind) + + @spec inline_rich_text_heading( + String.t() | atom(), + atom(), + [keyword() | map()], + keyword() | map() + ) :: + Widget.t() + def inline_rich_text_heading(id, level, segments, opts \\ []) + when is_atom(level) and is_list(segments) do + component_widget( + :inline_rich_text_heading, + id, + %{heading: %{level: level, segments: normalize_maps(segments)}}, + [], + opts + ) + end + + @spec disclosure( + String.t() | atom(), + String.t(), + [Widget.t() | map() | keyword()], + keyword() | map() + ) :: + Widget.t() + def disclosure(id, summary, children \\ [], opts \\ []) when is_binary(summary) do + opts = options(opts) + + component_widget( + :disclosure, + id, + %{disclosure: %{summary: summary, open?: option(opts, :open?, false)}}, + children, + opts, + on_open: :open, + on_close: :close + ) + end + + @spec kicker(String.t() | atom(), [String.t()], keyword() | map()) :: Widget.t() + def kicker(id, items, opts \\ []) when is_list(items) do + opts = options(opts) + + component_widget( + :kicker, + id, + %{kicker: %{items: items, separator: option(opts, :separator, "·")}}, + [], + opts + ) + end + + @spec avatar(String.t() | atom(), keyword() | map()) :: Widget.t() + def avatar(id, opts \\ []) do + opts = options(opts) + + component_widget( + :avatar, + id, + %{ + identity: + %{} + |> maybe_put(:initials, option(opts, :initials)) + |> maybe_put(:image_source, option(opts, :image_source)) + |> maybe_put(:size, option(opts, :size, :medium)) + |> maybe_put(:shape, option(opts, :shape, :round)) + }, + [], + opts + ) + end + + @spec presence_dot(String.t() | atom(), atom(), keyword() | map()) :: Widget.t() + def presence_dot(id, state, opts \\ []) when is_atom(state) do + opts = options(opts) + + component_widget( + :presence_dot, + id, + %{presence: %{state: state, size: option(opts, :size, :medium)}}, + [], + opts + ) + end + + @spec segmented_button_group(String.t() | atom(), [keyword() | map()], keyword() | map()) :: + Widget.t() + def segmented_button_group(id, options_list, opts \\ []) when is_list(options_list) do + opts = options(opts) + + component_widget( + :segmented_button_group, + id, + %{ + selection: + %{ + presentation: :segmented_button_group, + multiple?: false, + options: normalize_options(options_list) + } + |> maybe_put(:active_value, option(opts, :active_value)) + |> maybe_put(:selection_intent, option(opts, :selection_intent)) + }, + [], + opts, + on_select: :selection, + on_change: :change + ) + end + + @spec runtime_form_shell(String.t() | atom(), [keyword() | map()], keyword() | map()) :: + Widget.t() + def runtime_form_shell(id, fields, opts \\ []) when is_list(fields) do + opts = options(opts) + + component_widget( + :runtime_form_shell, + id, + %{ + form: + %{fields: normalize_maps(fields)} + |> maybe_put(:submit_label, option(opts, :submit_label)) + |> maybe_put(:submit_intent, option(opts, :submit_intent)) + |> maybe_put(:change_intent, option(opts, :change_intent)) + |> maybe_put(:validation_state, option(opts, :validation_state)) + |> maybe_put( + :host_adapter_hints, + normalize_optional_map(option(opts, :host_adapter_hints)) + ) + }, + option(opts, :children, []), + opts, + on_submit: :submit, + on_change: :change + ) + end + + @spec chat_composer(String.t() | atom(), [Widget.t() | map() | keyword()], keyword() | map()) :: + Widget.t() + def chat_composer(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :chat_composer, + id, + %{ + composer: + %{} + |> maybe_put(:name, option(opts, :name)) + |> maybe_put(:value, option(opts, :value)) + |> maybe_put(:placeholder, option(opts, :placeholder)) + |> maybe_put(:rows, option(opts, :rows, 3)) + |> maybe_put(:send_label, option(opts, :send_label, "Send")) + |> maybe_put(:send_intent, option(opts, :send_intent)) + |> maybe_put(:change_intent, option(opts, :change_intent)) + }, + children, + opts, + on_send: :submit, + on_submit: :submit, + on_change: :change + ) + end + + @spec list_item_multi_column( + String.t() | atom(), + [Widget.t() | map() | keyword()], + keyword() | map() + ) :: + Widget.t() + def list_item_multi_column(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :list_item_multi_column, + id, + %{ + row: + common_row_attrs(opts) + |> maybe_put(:column_template, normalize_maps(option(opts, :column_template, []))) + }, + children, + opts, + on_activate: :click, + on_click: :click, + on_select: :selection + ) + end + + @spec artifact_row( + String.t() | atom(), + String.t(), + [Widget.t() | map() | keyword()], + keyword() | map() + ) :: + Widget.t() + def artifact_row(id, title, children \\ [], opts \\ []) + when is_binary(title) and is_list(children) do + opts = options(opts) + + component_widget( + :artifact_row, + id, + %{ + artifact: + common_row_attrs(opts) + |> maybe_put(:title, title) + |> maybe_put(:meta, option(opts, :meta)) + }, + children, + opts, + on_activate: :click, + on_click: :click, + on_select: :selection + ) + end + + @spec pipeline_stepper_horizontal(String.t() | atom(), [keyword() | map()], keyword() | map()) :: + Widget.t() + def pipeline_stepper_horizontal(id, steps, opts \\ []) when is_list(steps) do + opts = options(opts) + + component_widget( + :pipeline_stepper_horizontal, + id, + %{ + workflow: + %{ + presentation: :pipeline_stepper_horizontal, + steps: normalize_maps(steps), + active_index: option(opts, :active_index, 0), + completed_indices: option(opts, :completed_indices, []) + } + |> maybe_put(:navigation_intent, option(opts, :navigation_intent)) + }, + [], + opts, + on_step: :navigation, + on_navigate: :navigation + ) + end + + @spec segmented_progress_bar(String.t() | atom(), [keyword() | map()], keyword() | map()) :: + Widget.t() + def segmented_progress_bar(id, segments, opts \\ []) when is_list(segments) do + opts = options(opts) + + component_widget( + :segmented_progress_bar, + id, + %{ + progress: + %{presentation: :segmented_progress_bar, segments: normalize_maps(segments)} + |> maybe_put(:aggregate, normalize_optional_map(option(opts, :aggregate_progress))) + |> maybe_put(:label, option(opts, :label)) + }, + [], + opts + ) + end + + @spec workflow_stage_list_vertical(String.t() | atom(), [keyword() | map()], keyword() | map()) :: + Widget.t() + def workflow_stage_list_vertical(id, stages, opts \\ []) when is_list(stages) do + opts = options(opts) + + component_widget( + :workflow_stage_list_vertical, + id, + %{ + workflow: %{ + presentation: :workflow_stage_list_vertical, + stages: normalize_maps(stages), + active_index: option(opts, :active_index, 0) + } + }, + [], + opts, + on_step: :navigation, + on_navigate: :navigation + ) + end + + @spec meter_thin(String.t() | atom(), number(), keyword() | map()) :: Widget.t() + def meter_thin(id, current, opts \\ []) when is_number(current) do + opts = options(opts) + + component_widget( + :meter_thin, + id, + %{ + meter: + %{ + current: current, + minimum: option(opts, :minimum, 0), + maximum: option(opts, :maximum, 100) + } + |> maybe_put(:label, option(opts, :label)) + |> maybe_put(:state, option(opts, :state)) + }, + [], + opts + ) + end + + @spec sticky_frosted_header( + String.t() | atom(), + [Widget.t() | map() | keyword()], + keyword() | map() + ) :: + Widget.t() + def sticky_frosted_header(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :sticky_frosted_header, + id, + %{ + shell: + %{position: :sticky, visual_effect: :frosted} + |> maybe_put(:title, option(opts, :title)) + |> maybe_put(:leading, option(opts, :leading, [])) + |> maybe_put(:trailing, option(opts, :trailing, [])) + }, + children, + opts + ) + end + + @spec slide_over_panel(String.t() | atom(), [Widget.t() | map() | keyword()], keyword() | map()) :: + Widget.t() + def slide_over_panel(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :slide_over_panel, + id, + %{ + panel: + %{modal?: false, open?: option(opts, :open?, false), size: option(opts, :size, :medium)} + |> maybe_put(:label, option(opts, :label, option(opts, :accessibility_label))) + |> maybe_put(:dismiss_intent, option(opts, :dismiss_intent)) + }, + children, + opts, + on_open: :open, + on_close: :close, + on_dismiss: :close + ) + end + + @spec event_callout( + String.t() | atom(), + String.t(), + [Widget.t() | map() | keyword()], + keyword() | map() + ) :: + Widget.t() + def event_callout(id, message, children \\ [], opts \\ []) + when is_binary(message) and is_list(children) do + opts = options(opts) + + component_widget( + :event_callout, + id, + %{ + callout: + %{message: message, tone: option(opts, :tone, :info)} + |> maybe_put(:eyebrow, option(opts, :eyebrow)) + |> maybe_put(:title, option(opts, :title)) + |> maybe_put(:action_intent, option(opts, :action_intent)) + }, + children, + opts, + on_action: :click, + on_click: :click + ) + end + + @spec redline_inline(String.t() | atom(), [keyword() | map()], keyword() | map()) :: Widget.t() + def redline_inline(id, segments, opts \\ []) when is_list(segments) do + opts = options(opts) + + component_widget( + :redline_inline, + id, + %{ + redline: %{segments: normalize_maps(segments)}, + text_safety: %{content: option(opts, :text_safety, :plain_text)} + }, + [], + opts + ) + end + + @spec code_block_syntax_highlighted( + String.t() | atom(), + atom() | String.t(), + [keyword() | map()], + keyword() | map() + ) :: + Widget.t() + def code_block_syntax_highlighted(id, language, tokens, opts \\ []) + when (is_atom(language) or is_binary(language)) and is_list(tokens) do + opts = options(opts) + + component_widget( + :code_block_syntax_highlighted, + id, + %{ + code: %{language: language, tokens: normalize_maps(tokens)}, + text_safety: %{content: option(opts, :text_safety, :plain_text)} + }, + [], + opts + ) + end + + @spec list_repeat(String.t() | atom(), [Widget.t() | map() | keyword()], keyword() | map()) :: + Widget.t() + def list_repeat(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :list_repeat, + id, + %{ + repeat: %{ + binding_id: option(opts, :repeat_binding), + row_scope: option(opts, :row_scope, :row), + row_fields: option(opts, :row_fields, []), + identity_strategy: option(opts, :identity_strategy, :row_identity), + child_slot: option(opts, :child_slot, :default), + hydrated?: option(opts, :hydrated?, false), + row_count: option(opts, :row_count, length(children)) + } + }, + children, + opts + ) + end + + @spec from_iur(atom(), String.t() | atom(), map(), keyword() | map()) :: Widget.t() + def from_iur(kind, id, attributes, opts \\ []) when is_atom(kind) and is_map(attributes) do + opts = options(opts) + family = family_for_kind(kind) + + Widget.new(kind, + id: id, + family: :component, + metadata: + metadata(opts, kind, family) + |> Map.merge(normalize_map(option(opts, :metadata, %{}))), + state: option(opts, :state, component_state(attributes)), + bindings: normalize_map(option(opts, :bindings, %{})), + attributes: attributes, + styles: component_styles(kind, family, opts), + events: normalize_map(option(opts, :events, %{})) + ) + end + + defp component_widget(kind, id, attributes, children, opts, event_shorthand \\ []) do + opts = options(opts) + family = family_for_kind(kind) + + Widget.new(kind, + id: id, + family: :component, + metadata: metadata(opts, kind, family), + state: component_state(opts), + attributes: + attributes + |> Map.put_new(:component, %{family: family, kind: kind}) + |> merge_optional_attribute(:accessibility, accessibility(opts)) + |> merge_optional_attribute(:state, component_state(opts)), + styles: component_styles(kind, family, opts), + events: events(opts, event_shorthand), + slot_children: %{default: normalize_children(children)} + ) + end + + defp metadata(opts, kind, family) do + %{ + native_surface: :component, + component_family: family, + component_kind: kind, + focusable: option(opts, :focusable, kind in @focusable_kinds), + interaction_route: :desktop_widget_component + } + |> maybe_put(:label, option(opts, :accessibility_label, option(opts, :label))) + |> maybe_put(:description, option(opts, :description)) + |> maybe_put(:role, option(opts, :role)) + end + + defp component_styles(kind, family, opts) do + opts + |> option(:styles, %{}) + |> normalize_map() + |> Map.put_new(:component_kind, kind) + |> Map.put_new(:component_family, family) + end + + defp component_state(source) when is_map(source) do + source + |> option(:state, %{}) + |> normalize_map() + |> maybe_put(:disabled, option(source, :disabled)) + |> maybe_put(:disabled?, option(source, :disabled?)) + |> maybe_put(:active?, option(source, :active?)) + |> maybe_put(:open?, option(source, :open?)) + |> maybe_put(:open, option(source, :open)) + end + + defp common_row_attrs(opts) do + %{} + |> maybe_put(:row_identity, option(opts, :row_identity)) + |> maybe_put(:active?, option(opts, :active?)) + |> maybe_put(:link_target, option(opts, :link_target)) + |> maybe_put(:action_intent, option(opts, :action_intent)) + end + + defp accessibility(opts) do + %{} + |> maybe_put(:label, option(opts, :accessibility_label)) + |> maybe_put(:description, option(opts, :accessibility_description)) + end + + defp normalize_options(options) do + Enum.map(options, fn option -> + option = options(option) + + %{} + |> maybe_put(:value, option(option, :value)) + |> maybe_put(:label, option(option, :label)) + |> maybe_put(:disabled?, option(option, :disabled?)) + end) + end + + defp normalize_maps(values) when is_list(values), do: Enum.map(values, &normalize_map/1) + defp normalize_maps(_values), do: [] + + defp normalize_optional_map(nil), do: nil + defp normalize_optional_map(value), do: normalize_map(value) + + defp normalize_children(children) do + Enum.map(List.wrap(children), fn + %Widget{} = child -> + child + + child when is_map(child) -> + Widget.new(Map.get(child, :kind, :text), child) + + child when is_list(child) -> + child |> Map.new() |> then(&Widget.new(Map.get(&1, :kind, :text), &1)) + end) + end + + defp events(opts, shorthand) do + base = opts |> option(:events, %{}) |> normalize_map() + + Enum.reduce(shorthand, base, fn {key, event_name}, acc -> + maybe_put(acc, event_name, option(opts, key)) + end) + end + + defp options(value) when is_map(value), do: Map.new(value) + defp options(value) when is_list(value), do: Map.new(value) + + defp option(map, key, default \\ nil) when is_map(map) do + Map.get(map, key, Map.get(map, Atom.to_string(key), default)) + end + + defp normalize_map(nil), do: %{} + defp normalize_map(%_{} = struct), do: struct |> Map.from_struct() |> normalize_map() + + defp normalize_map(map) when is_map(map), + do: Map.new(map, fn {key, value} -> {key, normalize_value(value)} end) + + defp normalize_map(list) when is_list(list), do: list |> Map.new() |> normalize_map() + + defp normalize_value(%_{} = struct), do: struct |> Map.from_struct() |> normalize_map() + defp normalize_value(map) when is_map(map), do: normalize_map(map) + defp normalize_value(list) when is_list(list), do: Enum.map(list, &normalize_value/1) + defp normalize_value(value), do: value + + defp merge_optional_attribute(attributes, _key, value) when value in [%{}, [], nil], + do: attributes + + defp merge_optional_attribute(attributes, key, value), do: Map.put(attributes, key, value) + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) +end diff --git a/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs b/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs index 79f33b20..ebde1a59 100644 --- a/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs +++ b/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs @@ -8,31 +8,97 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do @moduletag :phase_eleven - @iur_widget_count 45 + @iur_widget_count 77 @all_iur_kinds MapSet.new([ - # Foundational (13) - :badge, :button, :command, :content, :hero, :icon, :image, :label, :link, :separator, :spacer, - :text, :toggle, - # Input (10) - :checkbox, :date_input, :file_input, :numeric_input, :pick_list, :radio_group, :select, :slider, - :text_input, :time_input, - # Navigation (4) - :breadcrumbs, :list, :menu, :tabs, - # Data (7) - :inspector, :info_list, :key_value, :markdown_viewer, :stat, :table, :tree_view, - # Feedback (6) - :alert_dialog, :dialog, :inline_feedback, :progress, :status, :toast, - # Operational (7) - :cluster_dashboard, :command_palette, :log_viewer, :process_monitor, :stream_widget, - :supervision_tree_viewer, :window_command, - # Visualization (5) - :bar_chart, :canvas, :gauge, :line_chart, :timeline, - # Layout & Structure (3) - :column, :row, :stack, - # Container (1) - :window - ]) + # Foundational (13) + :badge, + :button, + :command, + :content, + :hero, + :icon, + :image, + :label, + :link, + :separator, + :spacer, + :text, + :toggle, + # Input (10) + :checkbox, + :date_input, + :file_input, + :numeric_input, + :pick_list, + :radio_group, + :select, + :slider, + :text_input, + :time_input, + # Navigation (4) + :breadcrumbs, + :list, + :menu, + :tabs, + # Data (7) + :inspector, + :info_list, + :key_value, + :markdown_viewer, + :stat, + :table, + :tree_view, + # Feedback (6) + :alert_dialog, + :dialog, + :inline_feedback, + :progress, + :sparkline, + :status, + :toast, + # Operational (7) + :cluster_dashboard, + :command_palette, + :log_viewer, + :process_monitor, + :stream_widget, + :supervision_tree_viewer, + :window_command, + # Visualization (5) + :bar_chart, + :canvas, + :gauge, + :line_chart, + :timeline, + # Layout & Structure (3) + :column, + :row, + :stack, + # Container (1) + :window, + # Canonical widget components (20) + :inline_rich_text_heading, + :disclosure, + :kicker, + :avatar, + :presence_dot, + :segmented_button_group, + :runtime_form_shell, + :chat_composer, + :list_item_multi_column, + :artifact_row, + :pipeline_stepper_horizontal, + :segmented_progress_bar, + :workflow_stage_list_vertical, + :meter_thin, + :sticky_frosted_header, + :slide_over_panel, + :event_callout, + :redline_inline, + :code_block_syntax_highlighted, + :list_repeat + ]) setup_all do {:ok, capabilities: ensure_visible_runner_capabilities()} @@ -79,7 +145,9 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do describe "11.2 Form input completeness scenarios" do test "numeric_input, toggle, radio_group, select, pick_list render with proper structure" do - numeric_widget = DesktopUi.Widgets.numeric_input("test-numeric", value: 42, min: 0, max: 100) + numeric_widget = + DesktopUi.Widgets.numeric_input("test-numeric", value: 42, min: 0, max: 100) + assert numeric_widget.kind == :numeric_input assert numeric_widget.state.value == 42 assert numeric_widget.attributes.min == 0 @@ -141,7 +209,13 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do supported = Renderer.supported_kinds() |> MapSet.new() input_kinds = [ - :numeric_input, :slider, :date_input, :time_input, :file_input, :pick_list, :radio_group, + :numeric_input, + :slider, + :date_input, + :time_input, + :file_input, + :pick_list, + :radio_group, :select ] @@ -248,7 +322,7 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do "Expected all 45 IUR kinds to be supported, but missing: #{inspect(MapSet.to_list(missing_kinds))}" end - test "renderer.supported_kinds returns exactly 45 kinds" do + test "renderer.supported_kinds returns the complete expanded kind set" do count = length(Renderer.supported_kinds()) assert count >= @iur_widget_count, @@ -282,7 +356,7 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do assert plan.presentation.iur_widget_coverage == :complete assert plan.presentation.supported_iur_kinds >= @iur_widget_count - assert plan.presentation.validation_state == :iur_renderer_complete + assert plan.presentation.validation_state == :render_plan_ready end test "validation passes with full IUR widget coverage" do @@ -317,7 +391,9 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do DesktopUi.Widgets.column("phase11-content", [ # Foundational DesktopUi.Widgets.badge("p11-badge", "Complete", variant: :success), - DesktopUi.Widgets.hero("p11-hero", "Phase 11", subheadline: "IUR Widget Completeness"), + DesktopUi.Widgets.hero("p11-hero", "Phase 11", + subheadline: "IUR Widget Completeness" + ), DesktopUi.Widgets.separator("p11-sep"), # Input DesktopUi.Widgets.numeric_input("p11-numeric", value: 50, min: 0, max: 100), diff --git a/packages/desktop_ui/test/desktop_ui/widget_components_test.exs b/packages/desktop_ui/test/desktop_ui/widget_components_test.exs new file mode 100644 index 00000000..315a3b89 --- /dev/null +++ b/packages/desktop_ui/test/desktop_ui/widget_components_test.exs @@ -0,0 +1,197 @@ +defmodule DesktopUi.WidgetComponentsTest do + use ExUnit.Case, async: true + + alias UnifiedIUR.{Element, RuntimeParity} + + test "native component constructors expose desktop widget models" do + widgets = [ + DesktopUi.Widgets.inline_rich_text_heading("heading", :h2, [ + %{type: :text, value: "Runtime parity"} + ]), + DesktopUi.Widgets.disclosure( + "details", + "More", + [ + DesktopUi.Widgets.kicker("kicker", ["Spec", "Runtime"]) + ], + open?: true + ), + DesktopUi.Widgets.avatar("avatar", initials: "PC", accessibility_label: "Pascal"), + DesktopUi.Widgets.presence_dot("presence", :active, accessibility_label: "Active"), + DesktopUi.Widgets.segmented_button_group( + "segment", + [%{value: :open, label: "Open"}, %{value: :closed, label: "Closed"}], + active_value: :open, + on_select: %{intent: :select_status} + ), + DesktopUi.Widgets.runtime_form_shell( + "form", + [%{name: :title, type: :text, label: "Title"}], + submit_label: "Save", + on_submit: %{intent: :save_form}, + on_change: %{intent: :validate_form} + ), + DesktopUi.Widgets.chat_composer( + "composer", + [DesktopUi.Widgets.button("attach", "Attach")], + value: "Draft", + on_send: %{intent: :send_message} + ), + DesktopUi.Widgets.artifact_row( + "artifact", + "ADR", + [DesktopUi.Widgets.button("open", "Open")], + row_identity: "adr-1", + on_activate: %{intent: :open_artifact} + ), + DesktopUi.Widgets.pipeline_stepper_horizontal( + "stepper", + [%{id: :draft, label: "Draft"}, %{id: :review, label: "Review"}], + on_step: %{intent: :select_step} + ), + DesktopUi.Widgets.segmented_progress_bar( + "progress", + [%{id: :a, state: :done}, %{id: :b, state: :active}], + aggregate_progress: %{current: 1, maximum: 2} + ), + DesktopUi.Widgets.workflow_stage_list_vertical( + "stages", + [%{id: :authored, label: "Authored"}, %{id: :implemented, label: "Implemented"}] + ), + DesktopUi.Widgets.meter_thin("meter", 35), + DesktopUi.Widgets.sticky_frosted_header("header", [ + DesktopUi.Widgets.text("header-title", "Header") + ]), + DesktopUi.Widgets.slide_over_panel( + "panel", + [ + DesktopUi.Widgets.text("panel-body", "Body") + ], + open?: true + ), + DesktopUi.Widgets.event_callout("callout", "Paused", [ + DesktopUi.Widgets.button("inspect", "Inspect") + ]), + DesktopUi.Widgets.redline_inline("redline", [ + %{state: :insert, text: ""} + ]), + DesktopUi.Widgets.code_block_syntax_highlighted( + "code", + :elixir, + [%{type: :keyword, text: ""}] + ), + DesktopUi.Widgets.list_repeat( + "repeat", + [ + DesktopUi.Widgets.artifact_row("repeat:a1", "ADR 1") + ], + repeat_binding: :artifacts, + hydrated?: true + ) + ] + + assert DesktopUi.Widgets.Components in DesktopUi.Widgets.modules() + assert DesktopUi.Widgets.validation_state().widget_components == :ready + + assert RuntimeParity.acceptance_criteria().required_widget_kinds -- DesktopUi.Widgets.kinds() == + [] + + assert RuntimeParity.acceptance_criteria().required_widget_kinds -- + DesktopUi.Renderer.supported_kinds() == [] + + assert Enum.all?(widgets, &(&1.family == :component)) + + segment = Enum.find(widgets, &(&1.kind == :segmented_button_group)) + composer = Enum.find(widgets, &(&1.kind == :chat_composer)) + repeat = Enum.find(widgets, &(&1.kind == :list_repeat)) + + assert segment.events.selection.intent == :select_status + assert composer.events.submit.intent == :send_message + assert composer.metadata.focusable + assert repeat.attributes.repeat.row_count == 1 + end + + test "canonical runtime parity fixtures map into DesktopUi component widgets" do + Enum.each(RuntimeParity.fixtures(), fn fixture -> + assert {:ok, widget} = DesktopUi.Renderer.render(fixture.element) + assert widget.kind == fixture.element.kind + assert widget.family == :component + assert widget.metadata.component_family + assert get_in(widget.attributes, [:component, :kind]) == widget.kind + + Enum.each(Map.get(fixture.expected, :children, %{}), fn {slot, count} -> + assert length(Map.get(widget.slot_children, slot, [])) == count + end) + end) + end + + test "component fixtures mount through shared desktop runtime and render plan" do + screen = + Element.new(:layout, :column, + id: "component-parity-screen", + children: Enum.map(RuntimeParity.fixtures(), & &1.element) + ) + + assert {:ok, runtime_state} = + DesktopUi.Runtime.mount_iur_screen(screen, + screen_id: "component-parity", + title: "Component Parity", + platform_target: :linux + ) + + assert runtime_state.source_kind == :canonical + assert "parity-control" in runtime_state.realization.focus_order + assert runtime_state.realization.event_targets["parity-control"] == [:selection] + + assert {:ok, plan} = DesktopUi.Sdl3.RenderPlan.build(runtime_state) + + assert plan.diagnostics.draw_kind_counts.segmented_control == 1 + assert plan.diagnostics.draw_kind_counts.artifact_row >= 3 + assert plan.diagnostics.draw_kind_counts.code_block_text == 1 + assert find_operation(plan, "parity-repeat").draw_kind == :repeat_surface + end + + test "component interactions and safe text keep canonical desktop meaning" do + screen = + Element.new(:layout, :column, + id: "component-interactions", + children: [ + RuntimeParity.fixture!(:control).element, + RuntimeParity.fixture!(:redline).element, + RuntimeParity.fixture!(:code).element + ] + ) + + assert {:ok, runtime_state} = + DesktopUi.Runtime.mount_iur_screen(screen, + screen_id: "component-interactions", + platform_target: :linux + ) + + assert {:ok, _next_state, route} = + DesktopUi.Runtime.dispatch_widget_interaction( + runtime_state, + "parity-control", + :selection, + intent: :select_status + ) + + assert route.route == :canonical_boundary + assert route.translation.signal.type == "desktop_ui.selection.select_status" + + assert {:ok, plan} = DesktopUi.Sdl3.RenderPlan.build(runtime_state) + redline = find_operation(plan, "parity-redline") + code = find_operation(plan, "parity-code") + + assert redline.resource.text_safety == :plain_text + assert code.resource.text_safety == :plain_text + assert redline.content == "" + assert code.content == "" + end + + defp find_operation(plan, widget_id) do + plan.windows + |> Enum.flat_map(& &1.draw_operations) + |> Enum.find(&(&1.widget_id == widget_id)) + end +end From 6ef2e633aa23569d524378f9c51b4c15f9a59a2f Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 12:55:53 -0400 Subject: [PATCH 20/21] Implement TerminalUi widget component parity --- ...sktop-ui-and-terminal-ui-runtime-parity.md | 18 +- .../lib/terminal_ui/capabilities.ex | 10 +- .../lib/terminal_ui/degradation.ex | 27 + .../terminal_ui/lib/terminal_ui/renderer.ex | 8 + .../lib/terminal_ui/renderer/mapper.ex | 168 ++++- .../lib/terminal_ui/runtime/realization.ex | 80 +- .../terminal_ui/lib/terminal_ui/widget.ex | 40 +- .../terminal_ui/lib/terminal_ui/widgets.ex | 131 +++- .../lib/terminal_ui/widgets/components.ex | 684 ++++++++++++++++++ .../advanced_widget_families_test.exs | 3 +- .../foundational_widget_families_test.exs | 3 +- .../test/terminal_ui/native_widgets_test.exs | 3 +- .../terminal_ui/widget_components_test.exs | 98 +++ .../terminal_ui/test/terminal_ui_test.exs | 1 + 14 files changed, 1251 insertions(+), 23 deletions(-) create mode 100644 packages/terminal_ui/lib/terminal_ui/widgets/components.ex create mode 100644 packages/terminal_ui/test/terminal_ui/widget_components_test.exs diff --git a/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md b/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md index dd0feaa7..7ca1ebd6 100644 --- a/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md +++ b/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md @@ -83,25 +83,25 @@ Back to index: [README](./README.md) [x] 4.3.2.2 Subtask - Translate canonical interactions into desktop input and signal behavior. [x] 4.3.2.3 Subtask - Verify repeated rows produce stable widget identity across desktop rerenders. - [ ] 4.4 Section - TerminalUi Runtime Support and Degradation + [x] 4.4 Section - TerminalUi Runtime Support and Degradation Implement terminal-native equivalents and explicit fallback behavior for visual affordances that terminals cannot always render directly. - [ ] 4.4.1 Task - Implement TerminalUi native widget models + [x] 4.4.1 Task - Implement TerminalUi native widget models Add terminal widgets and capability-aware rendering for the expanded catalog. - [ ] 4.4.1.1 Subtask - Add terminal-native models for content, identity, controls, rows, progress, layers, callouts, redline, code, and composer widgets. - [ ] 4.4.1.2 Subtask - Add keyboard-first behavior for selection, row activation, step navigation, disclosure, slide-over, form, and composer widgets. - [ ] 4.4.1.3 Subtask - Add terminal-safe redline and code rendering with color-depth and ASCII fallbacks. + [x] 4.4.1.1 Subtask - Add terminal-native models for content, identity, controls, rows, progress, layers, callouts, redline, code, and composer widgets. + [x] 4.4.1.2 Subtask - Add keyboard-first behavior for selection, row activation, step navigation, disclosure, slide-over, form, and composer widgets. + [x] 4.4.1.3 Subtask - Add terminal-safe redline and code rendering with color-depth and ASCII fallbacks. - [ ] 4.4.2 Task - Implement TerminalUi IUR mappings and degradation reports + [x] 4.4.2 Task - Implement TerminalUi IUR mappings and degradation reports Map canonical IUR into terminal widgets and expose any degraded behavior clearly. - [ ] 4.4.2.1 Subtask - Add renderer mappings for every expanded widget family. - [ ] 4.4.2.2 Subtask - Define degradation for frost, slide animation, dense multi-column rows, color-rich progress, avatars, and syntax colors. - [ ] 4.4.2.3 Subtask - Add inspectable degradation summaries for runtime parity reports. + [x] 4.4.2.1 Subtask - Add renderer mappings for every expanded widget family. + [x] 4.4.2.2 Subtask - Define degradation for frost, slide animation, dense multi-column rows, color-rich progress, avatars, and syntax colors. + [x] 4.4.2.3 Subtask - Add inspectable degradation summaries for runtime parity reports. [ ] 4.5 Section - Phase 4 Integration Tests Validate runtime parity across ElmUi, DesktopUi, and TerminalUi using the diff --git a/packages/terminal_ui/lib/terminal_ui/capabilities.ex b/packages/terminal_ui/lib/terminal_ui/capabilities.ex index a082885c..04d6148e 100644 --- a/packages/terminal_ui/lib/terminal_ui/capabilities.ex +++ b/packages/terminal_ui/lib/terminal_ui/capabilities.ex @@ -94,7 +94,12 @@ defmodule TerminalUi.Capabilities do required_categories: [:backend, :color, :unicode, :resize, :terminal], optional_categories: [:mouse, :paste, :layering, :positioning, :canvas], degradation_profiles: profiles(), - bounded_variation: [:overlay_presentation, :positioned_canvas_rendering, :glyph_fallback] + bounded_variation: [ + :overlay_presentation, + :positioned_canvas_rendering, + :glyph_fallback, + :component_visual_fallback + ] } end @@ -135,7 +140,8 @@ defmodule TerminalUi.Capabilities do :overlay_presentation, :positioned_canvas_rendering, :context_menu_presentation, - :glyph_fallback + :glyph_fallback, + :component_visual_fallback ] else [] diff --git a/packages/terminal_ui/lib/terminal_ui/degradation.ex b/packages/terminal_ui/lib/terminal_ui/degradation.ex index ad169279..79add324 100644 --- a/packages/terminal_ui/lib/terminal_ui/degradation.ex +++ b/packages/terminal_ui/lib/terminal_ui/degradation.ex @@ -9,6 +9,17 @@ defmodule TerminalUi.Degradation do @menu_kinds [:context_menu, :command_palette] @canvas_kinds [:canvas, :canvas_surface, :absolute, :positioned] @scroll_kinds [:viewport, :scroll_region] + @component_degradations %{ + avatar: :initials_avatar, + presence_dot: :text_presence, + list_item_multi_column: :stacked_row, + segmented_progress_bar: :ascii_progress, + sticky_frosted_header: :plain_header, + slide_over_panel: :inline_panel, + event_callout: :inline_callout, + redline_inline: :plain_redline_tokens, + code_block_syntax_highlighted: :plain_code_tokens + } @spec modules() :: [module()] def modules, do: [__MODULE__] @@ -36,6 +47,7 @@ defmodule TerminalUi.Degradation do menu_mode: fallback_for_kind(:context_menu, snapshot), canvas_mode: fallback_for_kind(:canvas, snapshot), scroll_mode: fallback_for_kind(:viewport, snapshot), + component_modes: component_summary(snapshot), allowed_variation: TerminalUi.Capabilities.allowed_variation(snapshot) } end @@ -66,11 +78,25 @@ defmodule TerminalUi.Degradation do widget.kind in @scroll_kinds -> :paged_scroll + widget.family == :component -> + Map.get(@component_degradations, widget.kind) + true -> nil end end + @spec component_summary(map() | keyword()) :: map() + def component_summary(snapshot_or_opts \\ []) do + snapshot = normalize_snapshot(snapshot_or_opts) + + if snapshot.backend_mode == :tty do + @component_degradations + else + %{} + end + end + @spec diagnostics(map() | keyword()) :: map() def diagnostics(snapshot_or_opts \\ []) do snapshot = normalize_snapshot(snapshot_or_opts) @@ -80,6 +106,7 @@ defmodule TerminalUi.Degradation do responsibilities: responsibilities(), profile: snapshot.degradation_profile, plan: plan(snapshot), + component_degradations: component_summary(snapshot), bounded_semantics: %{ shared_runtime: true, transport_shared: true, diff --git a/packages/terminal_ui/lib/terminal_ui/renderer.ex b/packages/terminal_ui/lib/terminal_ui/renderer.ex index 38c580a2..f57b0f5c 100644 --- a/packages/terminal_ui/lib/terminal_ui/renderer.ex +++ b/packages/terminal_ui/lib/terminal_ui/renderer.ex @@ -34,6 +34,7 @@ defmodule TerminalUi.Renderer do UnifiedIUR.Widgets.Feedback.kinds(), UnifiedIUR.Canvas.kinds(), UnifiedIUR.Widgets.Advanced.kinds(), + UnifiedIUR.Widgets.Components.kinds(), UnifiedIUR.Layer.kinds() ] |> List.flatten() @@ -46,6 +47,7 @@ defmodule TerminalUi.Renderer do [ :absolute, :alert_dialog, + :badge, :bar_chart, :box, :button, @@ -62,11 +64,15 @@ defmodule TerminalUi.Renderer do :field_group, :file_input, :form_builder, + :form_field, :gauge, :grid, + :hero, :icon, :image, + :info_list, :inline_feedback, + :key_value, :label, :line_chart, :link, @@ -90,6 +96,7 @@ defmodule TerminalUi.Renderer do :spacer, :split_pane, :stack, + :stat, :status, :stream_widget, :supervision_tree_viewer, @@ -103,6 +110,7 @@ defmodule TerminalUi.Renderer do :tree_view, :viewport ] + |> Kernel.++(UnifiedIUR.Widgets.Components.kinds()) |> Enum.uniq() |> Enum.sort() end diff --git a/packages/terminal_ui/lib/terminal_ui/renderer/mapper.ex b/packages/terminal_ui/lib/terminal_ui/renderer/mapper.ex index 23d6df9f..07055128 100644 --- a/packages/terminal_ui/lib/terminal_ui/renderer/mapper.ex +++ b/packages/terminal_ui/lib/terminal_ui/renderer/mapper.ex @@ -7,6 +7,20 @@ defmodule TerminalUi.Renderer.Mapper do alias UnifiedIUR.{Binding, Element, Interaction} alias UnifiedIUR.Element.Child + @component_kinds UnifiedIUR.Widgets.Components.kinds() + @component_kind_values @component_kinds ++ Enum.map(@component_kinds, &Atom.to_string/1) + @component_event_keys %{ + click: :activate, + selection: :select, + submit: :submit, + change: :change, + navigation: :navigation, + command: :command, + close: :close, + open: :expand, + focus: :focus + } + @spec map(Element.t(), keyword()) :: {:ok, TerminalUi.Widget.t()} | {:error, Error.t()} def map(element, opts \\ []) @@ -20,6 +34,26 @@ defmodule TerminalUi.Renderer.Mapper do end end + defp do_map(%Element{type: :widget, kind: kind} = element) + when kind in @component_kind_values do + kind = component_kind(kind) + attributes = normalize_component_attributes(kind, element.attributes) + + with {:ok, children} <- map_children(default_children(element)) do + {:ok, + TerminalUi.Widgets.Components.from_iur( + kind, + element.id, + attributes, + Keyword.merge(base_opts(element), + state: Map.get(attributes, :state, %{}), + events: component_events(element), + children: children + ) + )} + end + end + defp do_map(%Element{type: :widget, kind: kind} = element) when kind in [:text, "text"] do {:ok, TerminalUi.Widgets.text( @@ -82,6 +116,31 @@ defmodule TerminalUi.Renderer.Mapper do )} end + defp do_map(%Element{type: :widget, kind: kind} = element) when kind in [:badge, "badge"] do + {:ok, + TerminalUi.Widgets.label( + element.id, + content_text(element, "Badge"), + Keyword.merge(base_opts(element), + role: :badge, + variant: first_present([group_attr(element, :style, :variant), attr(element, :variant)]) + ) + )} + end + + defp do_map(%Element{type: :widget, kind: kind} = element) when kind in [:hero, "hero"] do + with {:ok, children} <- map_children(default_children(element)) do + {:ok, + TerminalUi.Widgets.container( + element.id, + children, + Keyword.merge(base_opts(element), + label: first_present([group_attr(element, :hero, :title), label_text(element, nil)]) + ) + )} + end + end + defp do_map(%Element{type: :widget, kind: kind} = element) when kind in [:link, "link"] do {:ok, TerminalUi.Widgets.link( @@ -396,6 +455,53 @@ defmodule TerminalUi.Renderer.Mapper do )} end + defp do_map(%Element{type: :widget, kind: kind} = element) when kind in [:stat, "stat"] do + stat = group_attr(element, :stat, :value) + title = group_attr(element, :stat, :title) + message = group_attr(element, :stat, :message) + + {:ok, + TerminalUi.Widgets.status( + element.id, + [title, stat, message] + |> Enum.reject(&is_nil/1) + |> Enum.map(&to_string/1) + |> Enum.join(" "), + base_opts(element) + )} + end + + defp do_map(%Element{type: :widget, kind: kind} = element) + when kind in [:key_value, "key_value"] do + label = + first_present([group_attr(element, :key_value, :label), label_text(element, nil)], "Key") + + value = group_attr(element, :key_value, :value) + + {:ok, + TerminalUi.Widgets.label( + element.id, + "#{label}: #{value}", + Keyword.merge(base_opts(element), role: :key_value) + )} + end + + defp do_map(%Element{type: :widget, kind: kind} = element) + when kind in [:info_list, "info_list"] do + items = + element + |> group_attr(:info_list, :items) + |> List.wrap() + |> Enum.map(fn item -> + item = normalize_nested_map(item) + label = map_get(item, :label, map_get(item, :key, "Item")) + value = map_get(item, :value) + %{id: map_get(item, :id, label), label: "#{label}: #{value}", value: value} + end) + + {:ok, TerminalUi.Widgets.list(element.id, items, base_opts(element))} + end + defp do_map(%Element{type: :widget, kind: kind} = element) when kind in [:tree_view, "tree_view"] do {:ok, @@ -868,7 +974,8 @@ defmodule TerminalUi.Renderer.Mapper do end defp do_map(%Element{type: type, kind: kind} = element) - when type in [:composite, "composite"] and kind in [:field, "field"] do + when type in [:composite, "composite"] and + kind in [:field, "field", :form_field, "form_field"] do with {:ok, control} <- map_required_child(element, [:control]), {:ok, label} <- map_optional_child(element, [:label]), {:ok, help} <- map_optional_child(element, [:help]) do @@ -1211,6 +1318,65 @@ defmodule TerminalUi.Renderer.Mapper do end end + defp component_events(%Element{} = element) do + element + |> attr(:interactions) + |> List.wrap() + |> Enum.map(&normalize_nested_map/1) + |> Enum.reduce(%{}, fn interaction, acc -> + case map_get(interaction, :family) || map_get(interaction, :kind) do + nil -> + acc + + family -> + family = normalize_key(family) + event_key = Map.get(@component_event_keys, family, family) + + payload = + interaction + |> Map.delete(:family) + |> Map.delete(:kind) + + Map.put(acc, event_key, payload) + end + end) + end + + defp component_kind(kind) when is_atom(kind), do: kind + defp component_kind(kind) when is_binary(kind), do: String.to_atom(kind) + + defp normalize_component_attributes(kind, attributes) do + kind = component_kind(kind) + + attributes + |> normalize_nested_map() + |> Map.put_new(:component, %{ + family: TerminalUi.Widgets.Components.family_for_kind(kind), + kind: kind + }) + end + + defp normalize_nested_map(%_{} = struct), + do: struct |> Map.from_struct() |> normalize_nested_map() + + defp normalize_nested_map(map) when is_map(map) do + Map.new(map, fn {key, value} -> {normalize_key(key), normalize_nested_map(value)} end) + end + + defp normalize_nested_map(list) when is_list(list), do: Enum.map(list, &normalize_nested_map/1) + defp normalize_nested_map(value), do: value + + defp map_get(map, key, default \\ nil) when is_map(map) do + cond do + Map.has_key?(map, key) -> Map.get(map, key) + Map.has_key?(map, Atom.to_string(key)) -> Map.get(map, Atom.to_string(key)) + true -> default + end + end + + defp normalize_key(key) when is_binary(key), do: String.to_atom(key) + defp normalize_key(key), do: key + defp content_text(element, default) do first_present( [group_attr(element, :content, :text), attr(element, :text), attr(element, :content)], diff --git a/packages/terminal_ui/lib/terminal_ui/runtime/realization.ex b/packages/terminal_ui/lib/terminal_ui/runtime/realization.ex index bd3005e7..15c3c7b3 100644 --- a/packages/terminal_ui/lib/terminal_ui/runtime/realization.ex +++ b/packages/terminal_ui/lib/terminal_ui/runtime/realization.ex @@ -181,15 +181,89 @@ defmodule TerminalUi.Runtime.Realization do |> Enum.map(fn current -> %{ widget_id: current.id, - content: - current.attributes[:content] || current.attributes[:label] || current.label || - to_string(current.kind), + content: cell_content(current), kind: current.kind, family: current.family } end) end + defp cell_content(node) do + cond do + is_binary(node.attributes[:content]) -> + node.attributes[:content] + + is_binary(node.attributes[:label]) -> + node.attributes[:label] + + is_binary(node.attributes[:message]) -> + node.attributes[:message] + + is_binary(get_in(node.attributes, [:disclosure, :summary])) -> + get_in(node.attributes, [:disclosure, :summary]) + + is_binary(get_in(node.attributes, [:identity, :initials])) -> + get_in(node.attributes, [:identity, :initials]) + + is_binary(get_in(node.attributes, [:form, :submit_label])) -> + get_in(node.attributes, [:form, :submit_label]) + + is_binary(get_in(node.attributes, [:composer, :value])) -> + get_in(node.attributes, [:composer, :value]) + + is_binary(get_in(node.attributes, [:artifact, :title])) -> + get_in(node.attributes, [:artifact, :title]) + + is_binary(get_in(node.attributes, [:shell, :title])) -> + get_in(node.attributes, [:shell, :title]) + + is_binary(get_in(node.attributes, [:panel, :label])) -> + get_in(node.attributes, [:panel, :label]) + + is_binary(get_in(node.attributes, [:callout, :message])) -> + get_in(node.attributes, [:callout, :message]) + + node.kind == :inline_rich_text_heading -> + joined_text_segments(get_in(node.attributes, [:heading, :segments])) + + node.kind == :kicker -> + node.attributes |> get_in([:kicker, :items]) |> List.wrap() |> Enum.join(" ") + + node.kind == :presence_dot -> + node.attributes |> get_in([:presence, :state]) |> to_string() + + node.kind == :redline_inline -> + joined_text_segments(get_in(node.attributes, [:redline, :segments])) + + node.kind == :code_block_syntax_highlighted -> + joined_text_segments(get_in(node.attributes, [:code, :tokens])) + + node.kind == :list_repeat -> + "rows: #{get_in(node.attributes, [:repeat, :row_count]) || length(node.children)}" + + is_binary(node.label) -> + node.label + + true -> + to_string(node.kind) + end + end + + defp joined_text_segments(nil), do: "" + + defp joined_text_segments(segments) do + segments + |> List.wrap() + |> Enum.map(fn + %{text: text} -> text + %{"text" => text} -> text + %{value: value} -> value + %{"value" => value} -> value + segment -> to_string(segment) + end) + |> Enum.join("") + end + defp validation_state_for(tree) do if collect_layers(tree) == [] and collect_viewport_regions(tree) == [] do :foundational_ready diff --git a/packages/terminal_ui/lib/terminal_ui/widget.ex b/packages/terminal_ui/lib/terminal_ui/widget.ex index af4aba4a..d9c3897f 100644 --- a/packages/terminal_ui/lib/terminal_ui/widget.ex +++ b/packages/terminal_ui/lib/terminal_ui/widget.ex @@ -3,6 +3,29 @@ defmodule TerminalUi.Widget do Native renderer-facing widget representation for `terminal_ui`. """ + @component_kinds [ + :inline_rich_text_heading, + :disclosure, + :kicker, + :avatar, + :presence_dot, + :segmented_button_group, + :runtime_form_shell, + :chat_composer, + :list_item_multi_column, + :artifact_row, + :pipeline_stepper_horizontal, + :segmented_progress_bar, + :workflow_stage_list_vertical, + :meter_thin, + :sticky_frosted_header, + :slide_over_panel, + :event_callout, + :redline_inline, + :code_block_syntax_highlighted, + :list_repeat + ] + @type family :: :content | :action @@ -13,6 +36,7 @@ defmodule TerminalUi.Widget do | :feedback | :visualization | :operational + | :component @type t :: %__MODULE__{ id: String.t() | atom() | nil, @@ -63,7 +87,11 @@ defmodule TerminalUi.Widget do :capability_profile, :degradation_strategy, :theme, - :style_refs + :style_refs, + :component_family, + :component_kind, + :interaction_route, + :text_safety ], state: [ :disabled, @@ -121,7 +149,13 @@ defmodule TerminalUi.Widget do :filter, :paginate, :expand, - :close + :close, + :send, + :row_activation, + :step_navigation, + :inline_action, + :disclosure, + :panel ] } end @@ -222,6 +256,8 @@ defmodule TerminalUi.Widget do ], do: :operational + def family_for(kind) when kind in @component_kinds, do: :component + def family_for(_kind), do: :content defp normalize_map(nil), do: %{} diff --git a/packages/terminal_ui/lib/terminal_ui/widgets.ex b/packages/terminal_ui/lib/terminal_ui/widgets.ex index d509e279..e9af68b0 100644 --- a/packages/terminal_ui/lib/terminal_ui/widgets.ex +++ b/packages/terminal_ui/lib/terminal_ui/widgets.ex @@ -4,7 +4,8 @@ defmodule TerminalUi.Widgets do """ alias TerminalUi.Widget - alias TerminalUi.Widgets.{Builder, Data, Feedback, Forms, Foundational, Input, Navigation} + alias TerminalUi.Widgets.{Builder, Components, Data, Feedback, Forms, Foundational, Input} + alias TerminalUi.Widgets.Navigation alias TerminalUi.Widgets.{Operational, Visualization} @type family :: Widget.family() @@ -29,7 +30,8 @@ defmodule TerminalUi.Widgets do Data, Feedback, Visualization, - Operational + Operational, + Components ] end @@ -44,6 +46,7 @@ defmodule TerminalUi.Widgets do Feedback.kinds(), Visualization.kinds(), Operational.kinds(), + Components.kinds(), [:container, :column, :row, :stack, :dialog] ] |> List.flatten() @@ -72,7 +75,8 @@ defmodule TerminalUi.Widgets do advanced_feedback_widgets: :ready, advanced_visualization_widgets: :ready, advanced_operational_widgets: :ready, - advanced_degradation_metadata: :ready + advanced_degradation_metadata: :ready, + widget_components: :ready } end @@ -371,4 +375,125 @@ defmodule TerminalUi.Widgets do styles: Builder.styles(opts) ) end + + @spec inline_rich_text_heading(String.t() | atom(), atom(), [keyword() | map()], keyword()) :: + Widget.t() + def inline_rich_text_heading(id, level, segments, opts \\ []) do + Components.inline_rich_text_heading(id, level, segments, opts) + end + + @spec disclosure(String.t() | atom(), String.t(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def disclosure(id, summary, children \\ [], opts \\ []) do + Components.disclosure(id, summary, children, opts) + end + + @spec kicker(String.t() | atom(), [String.t()], keyword()) :: Widget.t() + def kicker(id, items, opts \\ []) do + Components.kicker(id, items, opts) + end + + @spec avatar(String.t() | atom(), keyword()) :: Widget.t() + def avatar(id, opts \\ []) do + Components.avatar(id, opts) + end + + @spec presence_dot(String.t() | atom(), atom(), keyword()) :: Widget.t() + def presence_dot(id, state, opts \\ []) do + Components.presence_dot(id, state, opts) + end + + @spec segmented_button_group(String.t() | atom(), [keyword() | map()], keyword()) :: Widget.t() + def segmented_button_group(id, options, opts \\ []) do + Components.segmented_button_group(id, options, opts) + end + + @spec runtime_form_shell(String.t() | atom(), [keyword() | map()], keyword()) :: Widget.t() + def runtime_form_shell(id, fields, opts \\ []) do + Components.runtime_form_shell(id, fields, opts) + end + + @spec chat_composer(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def chat_composer(id, children \\ [], opts \\ []) do + Components.chat_composer(id, children, opts) + end + + @spec list_item_multi_column(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def list_item_multi_column(id, children \\ [], opts \\ []) do + Components.list_item_multi_column(id, children, opts) + end + + @spec artifact_row(String.t() | atom(), String.t(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def artifact_row(id, title, children \\ [], opts \\ []) do + Components.artifact_row(id, title, children, opts) + end + + @spec pipeline_stepper_horizontal(String.t() | atom(), [keyword() | map()], keyword()) :: + Widget.t() + def pipeline_stepper_horizontal(id, steps, opts \\ []) do + Components.pipeline_stepper_horizontal(id, steps, opts) + end + + @spec segmented_progress_bar(String.t() | atom(), [keyword() | map()], keyword()) :: Widget.t() + def segmented_progress_bar(id, segments, opts \\ []) do + Components.segmented_progress_bar(id, segments, opts) + end + + @spec workflow_stage_list_vertical(String.t() | atom(), [keyword() | map()], keyword()) :: + Widget.t() + def workflow_stage_list_vertical(id, stages, opts \\ []) do + Components.workflow_stage_list_vertical(id, stages, opts) + end + + @spec meter_thin(String.t() | atom(), number(), keyword()) :: Widget.t() + def meter_thin(id, current, opts \\ []) do + Components.meter_thin(id, current, opts) + end + + @spec sticky_frosted_header(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def sticky_frosted_header(id, children \\ [], opts \\ []) do + Components.sticky_frosted_header(id, children, opts) + end + + @spec slide_over_panel(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def slide_over_panel(id, children \\ [], opts \\ []) do + Components.slide_over_panel(id, children, opts) + end + + @spec event_callout( + String.t() | atom(), + String.t(), + [Widget.t() | map() | keyword()], + keyword() + ) :: + Widget.t() + def event_callout(id, message, children \\ [], opts \\ []) do + Components.event_callout(id, message, children, opts) + end + + @spec redline_inline(String.t() | atom(), [keyword() | map()], keyword()) :: Widget.t() + def redline_inline(id, segments, opts \\ []) do + Components.redline_inline(id, segments, opts) + end + + @spec code_block_syntax_highlighted( + String.t() | atom(), + atom() | String.t(), + [keyword() | map()], + keyword() + ) :: Widget.t() + def code_block_syntax_highlighted(id, language, tokens, opts \\ []) do + Components.code_block_syntax_highlighted(id, language, tokens, opts) + end + + @spec list_repeat(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def list_repeat(id, children \\ [], opts \\ []) do + Components.list_repeat(id, children, opts) + end end diff --git a/packages/terminal_ui/lib/terminal_ui/widgets/components.ex b/packages/terminal_ui/lib/terminal_ui/widgets/components.ex new file mode 100644 index 00000000..6bd798d8 --- /dev/null +++ b/packages/terminal_ui/lib/terminal_ui/widgets/components.ex @@ -0,0 +1,684 @@ +defmodule TerminalUi.Widgets.Components do + @moduledoc """ + Terminal-native widget models for the expanded canonical component catalog. + """ + + alias TerminalUi.Widget + + @content_identity_kinds [ + :inline_rich_text_heading, + :disclosure, + :kicker, + :avatar, + :presence_dot + ] + + @form_control_kinds [ + :segmented_button_group, + :runtime_form_shell, + :chat_composer + ] + + @row_artifact_kinds [:list_item_multi_column, :artifact_row] + + @workflow_progress_kinds [ + :pipeline_stepper_horizontal, + :segmented_progress_bar, + :workflow_stage_list_vertical, + :meter_thin + ] + + @layer_callout_kinds [ + :sticky_frosted_header, + :slide_over_panel, + :event_callout + ] + + @redline_code_kinds [:redline_inline, :code_block_syntax_highlighted] + @composition_behavior_kinds [:list_repeat] + + @component_families %{ + inline_rich_text_heading: :content_identity, + disclosure: :content_identity, + kicker: :content_identity, + avatar: :content_identity, + presence_dot: :content_identity, + segmented_button_group: :form_control, + runtime_form_shell: :form_control, + chat_composer: :form_control, + list_item_multi_column: :row_artifact, + artifact_row: :row_artifact, + pipeline_stepper_horizontal: :workflow_progress, + segmented_progress_bar: :workflow_progress, + workflow_stage_list_vertical: :workflow_progress, + meter_thin: :workflow_progress, + sticky_frosted_header: :layer_callout, + slide_over_panel: :layer_callout, + event_callout: :layer_callout, + redline_inline: :redline_code, + code_block_syntax_highlighted: :redline_code, + list_repeat: :composition_behavior + } + + @focusable_kinds [ + :disclosure, + :segmented_button_group, + :runtime_form_shell, + :chat_composer, + :list_item_multi_column, + :artifact_row, + :pipeline_stepper_horizontal, + :workflow_stage_list_vertical, + :slide_over_panel, + :event_callout + ] + + @terminal_degradations %{ + avatar: :initials_avatar, + presence_dot: :text_presence, + list_item_multi_column: :stacked_row, + segmented_progress_bar: :ascii_progress, + sticky_frosted_header: :plain_header, + slide_over_panel: :inline_panel, + event_callout: :inline_callout, + redline_inline: :plain_redline_tokens, + code_block_syntax_highlighted: :plain_code_tokens + } + + @kinds @content_identity_kinds ++ + @form_control_kinds ++ + @row_artifact_kinds ++ + @workflow_progress_kinds ++ + @layer_callout_kinds ++ + @redline_code_kinds ++ + @composition_behavior_kinds + + @spec kinds() :: [atom()] + def kinds, do: @kinds + + @spec degraded_kinds() :: [atom()] + def degraded_kinds, do: Map.keys(@terminal_degradations) |> Enum.sort() + + @spec family_for_kind(atom() | String.t()) :: atom() | nil + def family_for_kind(kind) when is_binary(kind), + do: kind |> String.to_atom() |> family_for_kind() + + def family_for_kind(kind), do: Map.get(@component_families, kind) + + @spec terminal_degradation_for(atom() | String.t()) :: atom() | nil + def terminal_degradation_for(kind) when is_binary(kind), + do: kind |> String.to_atom() |> terminal_degradation_for() + + def terminal_degradation_for(kind), do: Map.get(@terminal_degradations, kind) + + @spec inline_rich_text_heading(String.t() | atom(), atom(), [keyword() | map()], keyword()) :: + Widget.t() + def inline_rich_text_heading(id, level, segments, opts \\ []) + when is_atom(level) and is_list(segments) do + component_widget( + :inline_rich_text_heading, + id, + %{heading: %{level: level, segments: normalize_maps(segments)}}, + [], + opts + ) + end + + @spec disclosure(String.t() | atom(), String.t(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def disclosure(id, summary, children \\ [], opts \\ []) when is_binary(summary) do + opts = options(opts) + + component_widget( + :disclosure, + id, + %{disclosure: %{summary: summary, open?: option(opts, :open?, false)}}, + children, + opts, + on_open: :expand, + on_close: :close + ) + end + + @spec kicker(String.t() | atom(), [String.t()], keyword()) :: Widget.t() + def kicker(id, items, opts \\ []) when is_list(items) do + opts = options(opts) + + component_widget( + :kicker, + id, + %{kicker: %{items: items, separator: option(opts, :separator, "|")}}, + [], + opts + ) + end + + @spec avatar(String.t() | atom(), keyword()) :: Widget.t() + def avatar(id, opts \\ []) do + opts = options(opts) + + component_widget( + :avatar, + id, + %{ + identity: + %{} + |> maybe_put(:initials, option(opts, :initials)) + |> maybe_put(:image_source, option(opts, :image_source)) + |> maybe_put(:size, option(opts, :size, :medium)) + |> maybe_put(:shape, option(opts, :shape, :round)) + }, + [], + opts + ) + end + + @spec presence_dot(String.t() | atom(), atom(), keyword()) :: Widget.t() + def presence_dot(id, state, opts \\ []) when is_atom(state) do + opts = options(opts) + + component_widget( + :presence_dot, + id, + %{presence: %{state: state, size: option(opts, :size, :medium)}}, + [], + opts + ) + end + + @spec segmented_button_group(String.t() | atom(), [keyword() | map()], keyword()) :: Widget.t() + def segmented_button_group(id, options_list, opts \\ []) when is_list(options_list) do + opts = options(opts) + + component_widget( + :segmented_button_group, + id, + %{ + selection: + %{ + presentation: :segmented_button_group, + multiple?: false, + options: normalize_options(options_list) + } + |> maybe_put(:active_value, option(opts, :active_value)) + |> maybe_put(:selection_intent, option(opts, :selection_intent)) + }, + [], + opts, + on_select: :select, + on_selection: :select, + on_change: :change + ) + end + + @spec runtime_form_shell(String.t() | atom(), [keyword() | map()], keyword()) :: Widget.t() + def runtime_form_shell(id, fields, opts \\ []) when is_list(fields) do + opts = options(opts) + + component_widget( + :runtime_form_shell, + id, + %{ + form: + %{fields: normalize_maps(fields)} + |> maybe_put(:submit_label, option(opts, :submit_label)) + |> maybe_put(:submit_intent, option(opts, :submit_intent)) + |> maybe_put(:change_intent, option(opts, :change_intent)) + |> maybe_put(:validation_state, option(opts, :validation_state)) + |> maybe_put( + :host_adapter_hints, + normalize_optional_map(option(opts, :host_adapter_hints)) + ) + }, + option(opts, :children, []), + opts, + on_submit: :submit, + on_change: :change + ) + end + + @spec chat_composer(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def chat_composer(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :chat_composer, + id, + %{ + composer: + %{} + |> maybe_put(:name, option(opts, :name)) + |> maybe_put(:value, option(opts, :value)) + |> maybe_put(:placeholder, option(opts, :placeholder)) + |> maybe_put(:rows, option(opts, :rows, 3)) + |> maybe_put(:send_label, option(opts, :send_label, "Send")) + |> maybe_put(:send_intent, option(opts, :send_intent)) + |> maybe_put(:change_intent, option(opts, :change_intent)) + }, + children, + opts, + on_send: :submit, + on_submit: :submit, + on_change: :change + ) + end + + @spec list_item_multi_column(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def list_item_multi_column(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :list_item_multi_column, + id, + %{ + row: + common_row_attrs(opts) + |> maybe_put(:column_template, normalize_maps(option(opts, :column_template, []))) + }, + children, + opts, + on_activate: :activate, + on_click: :activate, + on_select: :select + ) + end + + @spec artifact_row(String.t() | atom(), String.t(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def artifact_row(id, title, children \\ [], opts \\ []) + when is_binary(title) and is_list(children) do + opts = options(opts) + + component_widget( + :artifact_row, + id, + %{ + artifact: + common_row_attrs(opts) + |> maybe_put(:title, title) + |> maybe_put(:meta, option(opts, :meta)) + }, + children, + opts, + on_activate: :activate, + on_click: :activate, + on_select: :select + ) + end + + @spec pipeline_stepper_horizontal(String.t() | atom(), [keyword() | map()], keyword()) :: + Widget.t() + def pipeline_stepper_horizontal(id, steps, opts \\ []) when is_list(steps) do + opts = options(opts) + + component_widget( + :pipeline_stepper_horizontal, + id, + %{ + workflow: + %{ + presentation: :pipeline_stepper_horizontal, + steps: normalize_maps(steps), + active_index: option(opts, :active_index, 0), + completed_indices: option(opts, :completed_indices, []) + } + |> maybe_put(:navigation_intent, option(opts, :navigation_intent)) + }, + [], + opts, + on_step: :navigation, + on_navigate: :navigation + ) + end + + @spec segmented_progress_bar(String.t() | atom(), [keyword() | map()], keyword()) :: Widget.t() + def segmented_progress_bar(id, segments, opts \\ []) when is_list(segments) do + opts = options(opts) + + component_widget( + :segmented_progress_bar, + id, + %{ + progress: + %{presentation: :segmented_progress_bar, segments: normalize_maps(segments)} + |> maybe_put(:aggregate, normalize_optional_map(option(opts, :aggregate_progress))) + |> maybe_put(:label, option(opts, :label)) + }, + [], + opts + ) + end + + @spec workflow_stage_list_vertical(String.t() | atom(), [keyword() | map()], keyword()) :: + Widget.t() + def workflow_stage_list_vertical(id, stages, opts \\ []) when is_list(stages) do + opts = options(opts) + + component_widget( + :workflow_stage_list_vertical, + id, + %{ + workflow: %{ + presentation: :workflow_stage_list_vertical, + stages: normalize_maps(stages), + active_index: option(opts, :active_index, 0) + } + }, + [], + opts, + on_step: :navigation, + on_navigate: :navigation + ) + end + + @spec meter_thin(String.t() | atom(), number(), keyword()) :: Widget.t() + def meter_thin(id, current, opts \\ []) when is_number(current) do + opts = options(opts) + + component_widget( + :meter_thin, + id, + %{ + meter: + %{ + current: current, + minimum: option(opts, :minimum, 0), + maximum: option(opts, :maximum, 100) + } + |> maybe_put(:label, option(opts, :label)) + |> maybe_put(:state, option(opts, :state)) + }, + [], + opts + ) + end + + @spec sticky_frosted_header(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def sticky_frosted_header(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :sticky_frosted_header, + id, + %{ + shell: + %{position: :sticky, visual_effect: :frosted} + |> maybe_put(:title, option(opts, :title)) + |> maybe_put(:leading, option(opts, :leading, [])) + |> maybe_put(:trailing, option(opts, :trailing, [])) + }, + children, + opts + ) + end + + @spec slide_over_panel(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def slide_over_panel(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :slide_over_panel, + id, + %{ + panel: + %{modal?: false, open?: option(opts, :open?, false), size: option(opts, :size, :medium)} + |> maybe_put(:label, option(opts, :label, option(opts, :accessibility_label))) + |> maybe_put(:dismiss_intent, option(opts, :dismiss_intent)) + }, + children, + opts, + on_open: :expand, + on_close: :close, + on_dismiss: :close + ) + end + + @spec event_callout( + String.t() | atom(), + String.t(), + [Widget.t() | map() | keyword()], + keyword() + ) :: + Widget.t() + def event_callout(id, message, children \\ [], opts \\ []) + when is_binary(message) and is_list(children) do + opts = options(opts) + + component_widget( + :event_callout, + id, + %{ + callout: + %{message: message, tone: option(opts, :tone, :info)} + |> maybe_put(:eyebrow, option(opts, :eyebrow)) + |> maybe_put(:title, option(opts, :title)) + |> maybe_put(:action_intent, option(opts, :action_intent)) + }, + children, + opts, + on_action: :activate, + on_click: :activate + ) + end + + @spec redline_inline(String.t() | atom(), [keyword() | map()], keyword()) :: Widget.t() + def redline_inline(id, segments, opts \\ []) when is_list(segments) do + opts = options(opts) + + component_widget( + :redline_inline, + id, + %{ + redline: %{segments: normalize_maps(segments)}, + text_safety: %{content: option(opts, :text_safety, :plain_text)} + }, + [], + opts + ) + end + + @spec code_block_syntax_highlighted( + String.t() | atom(), + atom() | String.t(), + [keyword() | map()], + keyword() + ) :: Widget.t() + def code_block_syntax_highlighted(id, language, tokens, opts \\ []) + when (is_atom(language) or is_binary(language)) and is_list(tokens) do + opts = options(opts) + + component_widget( + :code_block_syntax_highlighted, + id, + %{ + code: %{language: language, tokens: normalize_maps(tokens)}, + text_safety: %{content: option(opts, :text_safety, :plain_text)} + }, + [], + opts + ) + end + + @spec list_repeat(String.t() | atom(), [Widget.t() | map() | keyword()], keyword()) :: + Widget.t() + def list_repeat(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :list_repeat, + id, + %{ + repeat: %{ + binding_id: option(opts, :repeat_binding), + row_scope: option(opts, :row_scope, :row), + row_fields: option(opts, :row_fields, []), + identity_strategy: option(opts, :identity_strategy, :row_identity), + child_slot: option(opts, :child_slot, :default), + hydrated?: option(opts, :hydrated?, false), + row_count: option(opts, :row_count, length(children)) + } + }, + children, + opts + ) + end + + @spec from_iur(atom(), String.t() | atom(), map(), keyword() | map()) :: Widget.t() + def from_iur(kind, id, attributes, opts \\ []) when is_atom(kind) and is_map(attributes) do + opts = options(opts) + family = family_for_kind(kind) + + Widget.new(kind, + id: id, + family: :component, + metadata: + metadata(opts, kind, family) + |> Map.merge(normalize_map(option(opts, :metadata, %{}))), + state: option(opts, :state, component_state(attributes)), + bindings: normalize_map(option(opts, :bindings, %{})), + attributes: attributes, + styles: component_styles(kind, family, opts), + events: normalize_map(option(opts, :events, %{})), + slot_children: %{default: normalize_children(option(opts, :children, []))} + ) + end + + defp component_widget(kind, id, attributes, children, opts, event_shorthand \\ []) do + opts = options(opts) + family = family_for_kind(kind) + state = component_state(opts) + + Widget.new(kind, + id: id, + family: :component, + metadata: metadata(opts, kind, family), + state: state, + attributes: + attributes + |> Map.put_new(:component, %{family: family, kind: kind}) + |> merge_optional_attribute(:accessibility, accessibility(opts)) + |> merge_optional_attribute(:state, state), + styles: component_styles(kind, family, opts), + events: events(opts, event_shorthand), + slot_children: %{default: normalize_children(children)} + ) + end + + defp metadata(opts, kind, family) do + %{ + native_surface: :component, + component_family: family, + component_kind: kind, + focusable: option(opts, :focusable, kind in @focusable_kinds), + interaction_route: :terminal_widget_component, + degradation_strategy: option(opts, :degradation_strategy, terminal_degradation_for(kind)) + } + |> maybe_put(:label, option(opts, :accessibility_label, option(opts, :label))) + |> maybe_put(:description, option(opts, :description)) + |> maybe_put(:role, option(opts, :role)) + end + + defp component_styles(kind, family, opts) do + opts + |> option(:styles, %{}) + |> normalize_map() + |> Map.put_new(:component_kind, kind) + |> Map.put_new(:component_family, family) + |> maybe_put(:degradation, terminal_degradation_for(kind)) + end + + defp component_state(source) when is_map(source) do + source + |> option(:state, %{}) + |> normalize_map() + |> maybe_put(:disabled, option(source, :disabled)) + |> maybe_put(:disabled?, option(source, :disabled?)) + |> maybe_put(:active?, option(source, :active?)) + |> maybe_put(:open?, option(source, :open?)) + |> maybe_put(:open, option(source, :open)) + end + + defp common_row_attrs(opts) do + %{} + |> maybe_put(:row_identity, option(opts, :row_identity)) + |> maybe_put(:active?, option(opts, :active?)) + |> maybe_put(:link_target, option(opts, :link_target)) + |> maybe_put(:action_intent, option(opts, :action_intent)) + end + + defp accessibility(opts) do + %{} + |> maybe_put(:label, option(opts, :accessibility_label)) + |> maybe_put(:description, option(opts, :accessibility_description)) + end + + defp normalize_options(values) do + Enum.map(values, fn value -> + value = options(value) + + %{} + |> maybe_put(:value, option(value, :value)) + |> maybe_put(:label, option(value, :label)) + |> maybe_put(:disabled?, option(value, :disabled?)) + end) + end + + defp normalize_maps(values) when is_list(values), do: Enum.map(values, &normalize_map/1) + defp normalize_maps(_values), do: [] + + defp normalize_optional_map(nil), do: nil + defp normalize_optional_map(value), do: normalize_map(value) + + defp normalize_children(children) do + Enum.map(List.wrap(children), fn + %Widget{} = child -> + child + + child when is_map(child) -> + Widget.new(Map.get(child, :kind, :text), child) + + child when is_list(child) -> + child |> Map.new() |> then(&Widget.new(Map.get(&1, :kind, :text), &1)) + end) + end + + defp events(opts, shorthand) do + base = opts |> option(:events, %{}) |> normalize_map() + + Enum.reduce(shorthand, base, fn {key, event_name}, acc -> + maybe_put(acc, event_name, option(opts, key)) + end) + end + + defp options(value) when is_map(value), do: Map.new(value) + defp options(value) when is_list(value), do: Map.new(value) + + defp option(map, key, default \\ nil) when is_map(map) do + Map.get(map, key, Map.get(map, Atom.to_string(key), default)) + end + + defp normalize_map(nil), do: %{} + defp normalize_map(%_{} = struct), do: struct |> Map.from_struct() |> normalize_map() + + defp normalize_map(map) when is_map(map), + do: Map.new(map, fn {key, value} -> {key, normalize_value(value)} end) + + defp normalize_map(list) when is_list(list), do: list |> Map.new() |> normalize_map() + + defp normalize_value(%_{} = struct), do: struct |> Map.from_struct() |> normalize_map() + defp normalize_value(map) when is_map(map), do: normalize_map(map) + defp normalize_value(list) when is_list(list), do: Enum.map(list, &normalize_value/1) + defp normalize_value(value), do: value + + defp merge_optional_attribute(attributes, _key, value) when value in [%{}, [], nil], + do: attributes + + defp merge_optional_attribute(attributes, key, value), do: Map.put(attributes, key, value) + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) +end diff --git a/packages/terminal_ui/test/terminal_ui/advanced_widget_families_test.exs b/packages/terminal_ui/test/terminal_ui/advanced_widget_families_test.exs index 323e3237..0c50a030 100644 --- a/packages/terminal_ui/test/terminal_ui/advanced_widget_families_test.exs +++ b/packages/terminal_ui/test/terminal_ui/advanced_widget_families_test.exs @@ -92,7 +92,8 @@ defmodule TerminalUi.AdvancedWidgetFamiliesTest do TerminalUi.Widgets.Data, TerminalUi.Widgets.Feedback, TerminalUi.Widgets.Visualization, - TerminalUi.Widgets.Operational + TerminalUi.Widgets.Operational, + TerminalUi.Widgets.Components ] assert :data in TerminalUi.Widgets.families() diff --git a/packages/terminal_ui/test/terminal_ui/foundational_widget_families_test.exs b/packages/terminal_ui/test/terminal_ui/foundational_widget_families_test.exs index 998c5f1d..4cc3dd2f 100644 --- a/packages/terminal_ui/test/terminal_ui/foundational_widget_families_test.exs +++ b/packages/terminal_ui/test/terminal_ui/foundational_widget_families_test.exs @@ -113,7 +113,8 @@ defmodule TerminalUi.FoundationalWidgetFamiliesTest do TerminalUi.Widgets.Data, TerminalUi.Widgets.Feedback, TerminalUi.Widgets.Visualization, - TerminalUi.Widgets.Operational + TerminalUi.Widgets.Operational, + TerminalUi.Widgets.Components ] assert :action in TerminalUi.Widgets.families() diff --git a/packages/terminal_ui/test/terminal_ui/native_widgets_test.exs b/packages/terminal_ui/test/terminal_ui/native_widgets_test.exs index 1c330e9f..0ee3c8f9 100644 --- a/packages/terminal_ui/test/terminal_ui/native_widgets_test.exs +++ b/packages/terminal_ui/test/terminal_ui/native_widgets_test.exs @@ -35,7 +35,8 @@ defmodule TerminalUi.NativeWidgetsTest do TerminalUi.Widgets.Data, TerminalUi.Widgets.Feedback, TerminalUi.Widgets.Visualization, - TerminalUi.Widgets.Operational + TerminalUi.Widgets.Operational, + TerminalUi.Widgets.Components ] assert :action in TerminalUi.Widgets.families() diff --git a/packages/terminal_ui/test/terminal_ui/widget_components_test.exs b/packages/terminal_ui/test/terminal_ui/widget_components_test.exs new file mode 100644 index 00000000..3a67bf15 --- /dev/null +++ b/packages/terminal_ui/test/terminal_ui/widget_components_test.exs @@ -0,0 +1,98 @@ +defmodule TerminalUi.WidgetComponentsTest do + use ExUnit.Case, async: true + + alias TerminalUi.Renderer + alias TerminalUi.Widgets.Components + alias UnifiedIUR.RuntimeParity + + test "native component constructors expose terminal models and fallback metadata" do + composer = + TerminalUi.Widgets.chat_composer("composer", [], + value: "Draft", + on_send: %{intent: :send_message} + ) + + header = + TerminalUi.Widgets.sticky_frosted_header( + "header", + [TerminalUi.Widgets.text("title", "Operations")], + title: "Operations" + ) + + redline = + TerminalUi.Widgets.redline_inline("redline", [ + %{state: :insert, text: ""} + ]) + + assert composer.kind == :chat_composer + assert composer.family == :component + assert composer.metadata.focusable + assert composer.events.submit == %{intent: :send_message} + + assert header.metadata.degradation_strategy == :plain_header + assert header.styles.degradation == :plain_header + + assert redline.attributes.text_safety == %{content: :plain_text} + assert redline.metadata.degradation_strategy == :plain_redline_tokens + assert TerminalUi.Widgets.validation_state().widget_components == :ready + end + + test "canonical parity fixtures map into TerminalUi component widgets" do + coverage = + RuntimeParity.coverage_report(:terminal_ui, Renderer.supported_kinds(), + degraded_kinds: Components.degraded_kinds() + ) + + assert coverage.complete? + assert coverage.degraded? + assert :sticky_frosted_header in coverage.degraded_kinds + + for fixture <- RuntimeParity.fixtures() do + assert {:ok, widget} = Renderer.render(fixture.element) + + rendered_kinds = + widget + |> flatten_widgets() + |> Enum.map(& &1.kind) + + for expected_kind <- fixture.expected.kinds do + assert expected_kind in rendered_kinds + end + end + end + + test "component fixtures mount through the shared terminal runtime with explicit fallbacks" do + for fixture <- RuntimeParity.fixtures() do + assert {:ok, runtime_state} = + TerminalUi.Runtime.mount_iur_screen(fixture.element, backend_mode: :tty) + + assert runtime_state.source_kind == :canonical + assert runtime_state.realization.diagnostics.capability_profile == :fallback_terminal + assert runtime_state.realization.cell_surface != [] + + fallback_kinds = + runtime_state.realization.fallbacks + |> Enum.map(& &1.kind) + + assert Enum.all?(fallback_kinds, &(&1 in Components.degraded_kinds())) + end + end + + test "component degradation summaries preserve terminal fallback meaning" do + tty_summary = TerminalUi.Degradation.component_summary(backend_mode: :tty) + raw_summary = TerminalUi.Degradation.component_summary(backend_mode: :raw) + + assert tty_summary.avatar == :initials_avatar + assert tty_summary.segmented_progress_bar == :ascii_progress + assert tty_summary.code_block_syntax_highlighted == :plain_code_tokens + assert raw_summary == %{} + + assert :component_visual_fallback in TerminalUi.Capabilities.allowed_variation( + TerminalUi.Capabilities.snapshot(backend_mode: :tty) + ) + end + + defp flatten_widgets(widget) do + [widget | Enum.flat_map(widget.children, &flatten_widgets/1)] + end +end diff --git a/packages/terminal_ui/test/terminal_ui_test.exs b/packages/terminal_ui/test/terminal_ui_test.exs index c828d434..554e4557 100644 --- a/packages/terminal_ui/test/terminal_ui_test.exs +++ b/packages/terminal_ui/test/terminal_ui_test.exs @@ -112,6 +112,7 @@ defmodule TerminalUiTest do :advanced_capability_continuity, :advanced_continuity, :foundational_continuity, + :modal_stack_navigation_review, :normalized_input_profiles, :styled_continuity_review, :styled_degradation_review, From f9f70391571cad5fc193e598ccf573270b98f26f Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 13:03:02 -0400 Subject: [PATCH 21/21] Add widget component runtime parity integration gate --- ...sktop-ui-and-terminal-ui-runtime-parity.md | 22 ++++++------- .../widget_component_runtime_parity_test.exs | 32 +++++++++++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 packages/unified_iur/test/unified_iur/widget_component_runtime_parity_test.exs diff --git a/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md b/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md index 7ca1ebd6..b9178c30 100644 --- a/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md +++ b/.spec/planning/canonical_widget_components/phase-04-elm-ui-desktop-ui-and-terminal-ui-runtime-parity.md @@ -19,9 +19,9 @@ Back to index: [README](./README.md) - Runtime renderers consume the shared UnifiedIUR fixtures created in Phase 2. - TerminalUi preserves meaning through explicit degradation where visual effects cannot be represented directly. -- All work in this phase is not done. +- All work in this phase is done. -[ ] 4 Phase 4 - ElmUi, DesktopUi, and TerminalUi Runtime Parity +[x] 4 Phase 4 - ElmUi, DesktopUi, and TerminalUi Runtime Parity Implement native and IUR-rendered equivalents for the expanded catalog in ElmUi, DesktopUi, and TerminalUi with runtime-appropriate interaction and degradation behavior. @@ -103,20 +103,20 @@ Back to index: [README](./README.md) [x] 4.4.2.2 Subtask - Define degradation for frost, slide animation, dense multi-column rows, color-rich progress, avatars, and syntax colors. [x] 4.4.2.3 Subtask - Add inspectable degradation summaries for runtime parity reports. - [ ] 4.5 Section - Phase 4 Integration Tests + [x] 4.5 Section - Phase 4 Integration Tests Validate runtime parity across ElmUi, DesktopUi, and TerminalUi using the shared canonical fixtures and runtime-specific behavior checks. - [ ] 4.5.1 Task - Runtime fixture parity scenarios + [x] 4.5.1 Task - Runtime fixture parity scenarios Verify every runtime consumes the shared expanded catalog fixtures. - [ ] 4.5.1.1 Subtask - Verify ElmUi renders and updates fixture widgets through server and frontend runtime paths. - [ ] 4.5.1.2 Subtask - Verify DesktopUi renders fixture widgets with deterministic native widget identity and event translation. - [ ] 4.5.1.3 Subtask - Verify TerminalUi renders or degrades fixture widgets with explicit capability summaries. + [x] 4.5.1.1 Subtask - Verify ElmUi renders and updates fixture widgets through server and frontend runtime paths. + [x] 4.5.1.2 Subtask - Verify DesktopUi renders fixture widgets with deterministic native widget identity and event translation. + [x] 4.5.1.3 Subtask - Verify TerminalUi renders or degrades fixture widgets with explicit capability summaries. - [ ] 4.5.2 Task - Cross-runtime behavior scenarios + [x] 4.5.2 Task - Cross-runtime behavior scenarios Verify canonical meaning remains stable across runtime differences. - [ ] 4.5.2.1 Subtask - Compare selection, submit, change, send, row activation, and step navigation signal meaning across runtimes. - [ ] 4.5.2.2 Subtask - Compare repeated row identity and row-scoped values across runtimes. - [ ] 4.5.2.3 Subtask - Compare redline and code safety fixtures across runtimes. + [x] 4.5.2.1 Subtask - Compare selection, submit, change, send, row activation, and step navigation signal meaning across runtimes. + [x] 4.5.2.2 Subtask - Compare repeated row identity and row-scoped values across runtimes. + [x] 4.5.2.3 Subtask - Compare redline and code safety fixtures across runtimes. diff --git a/packages/unified_iur/test/unified_iur/widget_component_runtime_parity_test.exs b/packages/unified_iur/test/unified_iur/widget_component_runtime_parity_test.exs new file mode 100644 index 00000000..04562294 --- /dev/null +++ b/packages/unified_iur/test/unified_iur/widget_component_runtime_parity_test.exs @@ -0,0 +1,32 @@ +defmodule UnifiedIUR.WidgetComponentRuntimeParityTest do + use ExUnit.Case, async: false + + @moduletag timeout: 300_000 + + @suites [ + {"ElmUi", "packages/elm_ui", "test/elm_ui/widget_components_test.exs"}, + {"DesktopUi", "packages/desktop_ui", "test/desktop_ui/widget_components_test.exs"}, + {"TerminalUi", "packages/terminal_ui", "test/terminal_ui/widget_components_test.exs"} + ] + + test "phase 4 runtime parity suites pass for every renderer package" do + workspace_root = Path.expand("../../../..", __DIR__) + + for {runtime, package_path, test_path} <- @suites do + package_root = Path.join(workspace_root, package_path) + + {output, status} = + System.cmd("mix", ["test", test_path], + cd: package_root, + env: [{"MIX_ENV", "test"}], + stderr_to_stdout: true + ) + + assert status == 0, """ + #{runtime} runtime parity suite failed. + + #{output} + """ + end + end +end