From d73b91e8d8169cbc3b0856043e00fdc01499bee0 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Sat, 25 Apr 2026 14:56:28 -0400 Subject: [PATCH 1/4] Implement canonical navigation phase 2 section 2.1 --- ...ecosystem.canonical_navigation_boundary.md | 64 +++++ ...fied-iur-and-signal-transport-alignment.md | 104 ++++++++ .spec/specs/unified-ui/compiler.spec.md | 7 + .spec/specs/unified-ui/signals.spec.md | 15 +- .../lib/unified_ui/compiler/pipeline.ex | 33 ++- .../test/unified_ui/compiler_test.exs | 244 ++++++++++++++++++ 6 files changed, 465 insertions(+), 2 deletions(-) create mode 100644 .spec/decisions/architecture/repo.ecosystem.canonical_navigation_boundary.md create mode 100644 .spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md diff --git a/.spec/decisions/architecture/repo.ecosystem.canonical_navigation_boundary.md b/.spec/decisions/architecture/repo.ecosystem.canonical_navigation_boundary.md new file mode 100644 index 00000000..c32315d6 --- /dev/null +++ b/.spec/decisions/architecture/repo.ecosystem.canonical_navigation_boundary.md @@ -0,0 +1,64 @@ +--- +id: repo.ecosystem.canonical_navigation_boundary +status: accepted +date: 2026-04-24 +affects: + - ecosystem.signal_transport + - unified_ui.signals + - unified_ui.compiler + - unified_iur.interactions + - live_ui.runtime + - elm_ui.server_runtime + - desktop_ui.runtime + - terminal_ui.runtime +--- + +# Canonical Navigation Is Authored as Screen-Transition Intent + +## Context + +The ecosystem already treats navigation as a canonical interaction family, but +the runtime libraries do not share one host model. Web runtimes can integrate +with page or route transitions, the desktop runtime owns screen navigation +inside windows, and the terminal runtime may need screen replacement or bounded +section changes without any URL model at all. + +The authored DSL and canonical IUR therefore need a durable navigation boundary +that can preserve cross-runtime meaning without importing browser-route syntax, +runtime module identities, or host-specific router APIs into the authored +surface. + +## Decision + +1. Canonical navigation remains part of the authored interaction and signal + surface rather than becoming a dedicated routing subsystem inside + `unified_ui`. +2. When authored navigation changes the active top-level UI surface, it is + expressed as canonical screen-transition intent using transition actions + such as `navigate_to`, `replace_with`, `go_back`, `go_forward`, + `open_modal`, and `close_modal`. +3. Canonical transition targets are symbolic screen identifiers plus optional + params and metadata rather than URLs, host-router names, or runtime-module + references. +4. Local navigation-like interactions such as tab changes or other in-screen + destination changes may remain canonical interaction descriptors without + being forced into browser-route semantics. +5. Runtime libraries translate canonical navigation intent into their own host + models: + - `live_ui` and `elm_ui` may resolve canonical screen transitions through + host page or route integration + - `desktop_ui` resolves them through screen registry and navigation + controller primitives + - `terminal_ui` resolves them through terminal-appropriate screen + replacement, modal transitions, or bounded section/history behavior + +## Consequences + +- `UnifiedUi` can author portable navigation meaning without becoming a router. +- `UnifiedIUR` must preserve canonical navigation descriptors in a + renderer-independent shape. +- Runtime libraries may integrate with their own host navigation systems, but + those host systems remain runtime concerns rather than authored DSL + concerns. +- Browser-style route syntax is not the cross-runtime navigation contract of + the ecosystem. diff --git a/.spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md b/.spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md new file mode 100644 index 00000000..be602bcc --- /dev/null +++ b/.spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md @@ -0,0 +1,104 @@ +# Phase 2 - Compiler, UnifiedIUR, and Signal Transport Alignment + +Back to index: [README](./README.md) + +## Relevant Shared APIs / Interfaces +- `UnifiedUi.Compiler` +- `UnifiedUi.Compiler.Pipeline` +- `UnifiedIUR` +- `Jido.Signal` +- package inspection and export surfaces + +## Relevant Assumptions / Defaults +- `unified_ui` remains the authored source of canonical navigation intent and + `unified_iur` remains the renderer-independent interchange boundary. +- Navigation lowering must preserve transition action, symbolic screen target, + modal target, params, and source-context meaning without introducing + host-specific route syntax. +- The shared signal transport contract continues to use `Jido.Signal` with + CloudEvents-compatible semantics at package boundaries. + +[ ] 2 Phase 2 - Compiler, UnifiedIUR, and Signal Transport Alignment + Implement the canonical lowering, interchange representation, and transport + semantics that carry screen-transition intent from authored `UnifiedUi` + modules to runtime libraries. + + [x] 2.1 Section - UnifiedUi Compiler Navigation Lowering + Implement compiler support that lowers authored navigation transitions into + stable canonical descriptors. + + [x] 2.1.1 Task - Lower authored navigation into canonical descriptors + Extend the compiler pipeline so authored screen transitions become + portable canonical interaction data. + + [x] 2.1.1.1 Subtask - Lower navigation actions, symbolic screen targets, modal targets, params, and source-context fields into canonical interaction descriptors. + [x] 2.1.1.2 Subtask - Preserve payload mapping and binding references for navigation transitions without introducing renderer-local callback logic. + [x] 2.1.1.3 Subtask - Ensure canonical lowering stays deterministic across equivalent authored modules so navigation diffs remain review-friendly. + + [x] 2.1.2 Task - Preserve compatibility for non-transition interactions + Keep existing canonical interaction lowering intact while introducing the + new screen-transition model. + + [x] 2.1.2.1 Subtask - Ensure non-navigation interaction families continue to compile without depending on the new transition fields. + [x] 2.1.2.2 Subtask - Ensure local navigation-like descriptors that do not change the top-level screen remain representable without being forced into the screen-transition shape. + [x] 2.1.2.3 Subtask - Add normalization rules that keep older generic target-intent usage reviewable while preserving the newer canonical transition contract. + + [ ] 2.2 Section - UnifiedIUR Interaction Representation + Implement the canonical `unified_iur` representation needed for runtimes to + consume screen-transition meaning without host-router assumptions. + + [ ] 2.2.1 Task - Extend canonical interaction descriptors + Add the renderer-independent fields and invariants needed for canonical + screen transitions. + + [ ] 2.2.1.1 Subtask - Extend the canonical interaction model to represent transition action, symbolic screen target, modal target, params, and related metadata. + [ ] 2.2.1.2 Subtask - Keep canonical interaction storage renderer-independent and free from browser path syntax, host-router names, or runtime-module references. + [ ] 2.2.1.3 Subtask - Define how targetless navigation actions, such as `go_back` or `close_modal`, are represented without inventing fake screen ids. + + [ ] 2.2.2 Task - Update canonical inspection, export, and fixture support + Make the new canonical navigation representation visible and testable + through package tooling. + + [ ] 2.2.2.1 Subtask - Update inspect and export surfaces so canonical navigation descriptors print their transition fields clearly. + [ ] 2.2.2.2 Subtask - Add canonical fixtures that exercise ordinary transitions, replacement transitions, history traversal, and modal transitions. + [ ] 2.2.2.3 Subtask - Ensure serialized or review-friendly output stays stable enough for diff-oriented tooling and conformance snapshots. + + [ ] 2.3 Section - Shared Signal Transport Alignment + Implement the shared transport semantics that move canonical screen + transitions across runtime package boundaries. + + [ ] 2.3.1 Task - Define canonical boundary signal shape for transitions + Establish how canonical screen transitions are represented when they cross + package boundaries as `Jido.Signal` values. + + [ ] 2.3.1.1 Subtask - Define the event-family and payload expectations for transition actions, symbolic screen targets, modal targets, and params. + [ ] 2.3.1.2 Subtask - Define how targetless actions, such as `go_back`, `go_forward`, and `close_modal`, are encoded without host-specific routing assumptions. + [ ] 2.3.1.3 Subtask - Define how runtimes receive canonical transition data while keeping their local native signal models free to differ internally. + + [ ] 2.3.2 Task - Add shared transport validation and fixtures + Provide the shared validation and reference fixtures that runtime + implementers can consume consistently. + + [ ] 2.3.2.1 Subtask - Add validation for malformed transition payloads, leaked router syntax, and missing required canonical fields. + [ ] 2.3.2.2 Subtask - Add shared transport fixtures that web, desktop, and terminal runtimes can use to prove canonical transition fidelity. + [ ] 2.3.2.3 Subtask - Add review-oriented transport summaries that make it obvious which transition action and symbolic screen target crossed the package boundary. + + [ ] 2.4 Section - Phase 2 Integration Tests + Validate canonical lowering, `unified_iur` representation, and boundary + transport behavior end to end before runtime-specific mapping begins. + + [ ] 2.4.1 Task - Compiler and canonical descriptor scenarios + Verify authored transitions compile into stable canonical descriptors that + carry the required cross-runtime meaning. + + [ ] 2.4.1.1 Subtask - Verify authored screen transitions lower into canonical interaction descriptors with action, symbolic screen target, modal target, params, and payload mapping preserved. + [ ] 2.4.1.2 Subtask - Verify targetless transitions compile without fake screen ids or host-router placeholders. + [ ] 2.4.1.3 Subtask - Verify canonical descriptor output remains deterministic across equivalent authored modules and review exports. + + [ ] 2.4.2 Task - Shared transport boundary scenarios + Verify canonical transition meaning survives the package-boundary signal + contract without route leakage. + + [ ] 2.4.2.1 Subtask - Verify `Jido.Signal` boundary fixtures preserve transition action, symbolic screen target, and params where applicable. + [ ] 2.4.2.2 Subtask - Verify malformed transition envelopes and leaked route syntax fail with shared validation diagnostics. + [ ] 2.4.2.3 Subtask - Verify runtimes can consume the shared transition fixtures without requiring browser-only fields or runtime-module identifiers. diff --git a/.spec/specs/unified-ui/compiler.spec.md b/.spec/specs/unified-ui/compiler.spec.md index 4eb60b30..94165e5c 100644 --- a/.spec/specs/unified-ui/compiler.spec.md +++ b/.spec/specs/unified-ui/compiler.spec.md @@ -21,6 +21,7 @@ surface: - .spec/specs/unified-ui/compiler.spec.md decisions: - repo.ecosystem.contract_model + - repo.ecosystem.canonical_navigation_boundary ``` ## Requirements @@ -55,6 +56,11 @@ decisions: statement: The package shall not define platform-specific compile targets for `elm_ui`, `live_ui`, or `desktop_ui`; renderer libraries consume canonical IUR instead. priority: must stability: stable + +- id: unified_ui.compiler.navigation_transition_lowering + statement: The compiler shall lower authored screen-transition navigation intent into canonical `unified_iur` interaction descriptors that preserve transition action, symbolic screen target, modal target, and params without embedding host-router semantics. + priority: must + stability: stable ``` ## Scenarios @@ -83,6 +89,7 @@ decisions: - unified_ui.compiler.runtime_independent_bindings - unified_ui.compiler.introspection_surface - unified_ui.compiler.no_renderer_output_modes + - unified_ui.compiler.navigation_transition_lowering - unified_ui.compiler.compile_screen_to_iur - unified_ui.compiler.inspect_compiled_artifact ``` diff --git a/.spec/specs/unified-ui/signals.spec.md b/.spec/specs/unified-ui/signals.spec.md index 3f5e3cd8..ea46fe76 100644 --- a/.spec/specs/unified-ui/signals.spec.md +++ b/.spec/specs/unified-ui/signals.spec.md @@ -21,6 +21,7 @@ surface: - .spec/specs/unified-ui/signals.spec.md decisions: - repo.ecosystem.contract_model + - repo.ecosystem.canonical_navigation_boundary ``` ## Requirements @@ -50,6 +51,16 @@ decisions: statement: The authored signal surface shall not require `elm_ui`, `live_ui`, or `desktop_ui` local event names, local payload keys, or local transport envelopes in authored modules. priority: must stability: stable + +- id: unified_ui.signals.navigation_transition_actions + statement: The authored navigation interaction surface shall support canonical screen-transition actions such as `navigate_to`, `replace_with`, `go_back`, `go_forward`, `open_modal`, and `close_modal` without requiring host-router syntax. + priority: must + stability: stable + +- id: unified_ui.signals.navigation_symbolic_screen_targets + statement: When a navigation interaction changes the active top-level surface, the authoring model shall express the target as a symbolic screen identifier with optional params or metadata rather than URLs, Phoenix route helpers, runtime modules, or browser-history instructions. + priority: must + stability: stable ``` ## Scenarios @@ -61,7 +72,7 @@ decisions: then: The package produces canonical signal descriptors that runtime libraries can translate without the author naming renderer-local events - id: unified_ui.signals.author_navigation_intent - given: A developer authors a navigation interaction such as opening a dialog, changing a tab, or invoking a command palette action + given: A developer authors a navigation interaction such as opening a dialog, changing a tab, or transitioning to another screen when: The interaction is declared in the DSL then: The package records canonical event meaning and payload mapping without coupling the author to one renderer runtime ``` @@ -77,6 +88,8 @@ decisions: - unified_ui.signals.standard_interaction_families - unified_ui.signals.validation_and_introspection - unified_ui.signals.no_runtime_local_event_leakage + - unified_ui.signals.navigation_transition_actions + - unified_ui.signals.navigation_symbolic_screen_targets - unified_ui.signals.author_form_interaction - unified_ui.signals.author_navigation_intent ``` diff --git a/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex b/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex index c3fa3ec8..fff6aaa4 100644 --- a/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex +++ b/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex @@ -351,12 +351,13 @@ defmodule UnifiedUi.Compiler.Pipeline do defp compile_interaction(interaction, binding_by_id) do interaction = Signal.new(interaction) + compiled_target = compile_target_intent(interaction, binding_by_id) Interaction.new(%{ family: interaction.family, intent: interaction.intent, source: normalize_map(interaction.source_context), - target: compile_payload_map(interaction.target_intent, binding_by_id), + target: compiled_target, payload: compile_payload_map(interaction.payload_mapping, binding_by_id), metadata: %{ summary: interaction.summary, @@ -367,6 +368,33 @@ defmodule UnifiedUi.Compiler.Pipeline do }) end + defp compile_target_intent(%Signal{} = interaction, binding_by_id) do + target_intent = compile_payload_map(interaction.target_intent, binding_by_id) + + case {interaction.family, Signal.navigation_target_kind(interaction)} do + {:navigation, kind} + when kind in [ + :screen_transition, + :replace_transition, + :history_transition, + :modal_transition + ] -> + %{ + navigation: + %{} + |> maybe_put(:kind, kind) + |> maybe_put(:action, Map.get(target_intent, :action)) + |> maybe_put(:screen, Map.get(target_intent, :screen)) + |> maybe_put(:modal, Map.get(target_intent, :modal)) + |> maybe_put(:params, compact_optional_map(Map.get(target_intent, :params))) + |> maybe_put(:metadata, compact_optional_map(Map.get(target_intent, :metadata))) + } + + _other -> + target_intent + end + end + defp interaction_identifier(interaction) do interaction.metadata[:authored_id] || interaction.intent end @@ -1353,6 +1381,9 @@ defmodule UnifiedUi.Compiler.Pipeline do |> Map.new() end + defp compact_optional_map(nil), do: nil + defp compact_optional_map(map), do: compact_map(map) + defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) diff --git a/packages/unified-ui/test/unified_ui/compiler_test.exs b/packages/unified-ui/test/unified_ui/compiler_test.exs index e90a12ba..e782d2a7 100644 --- a/packages/unified-ui/test/unified_ui/compiler_test.exs +++ b/packages/unified-ui/test/unified_ui/compiler_test.exs @@ -100,6 +100,103 @@ defmodule UnifiedUi.CompilerTest do end end + defmodule NavigationWorkspace do + use UnifiedUi.Dsl + + identity do + id(:navigation_workspace) + title("Navigation Workspace") + authored_ref([:tests, :navigation_workspace]) + end + + composition do + root(:navigation_workspace_root) + mode(:screen) + + tabs :dashboard_tabs do + items(overview: "Overview", activity: "Activity") + active_item(:overview) + interaction_refs([:navigate_activity]) + end + + button :settings_link do + label("Settings") + interaction_refs([:open_settings_screen]) + end + + button :replace_home do + label("Home") + interaction_refs([:replace_home_screen]) + end + + button :back_button do + label("Back") + interaction_refs([:go_back_history]) + end + + button :open_settings_modal_button do + label("Open settings modal") + interaction_refs([:open_settings_modal]) + end + end + + signals do + namespace(:workspace) + + data_binding do + id(:active_tab) + path([:navigation, :active_tab]) + scope([:screen]) + default(:overview) + end + + interaction do + id(:navigate_activity) + family(:navigation) + intent(:navigate_activity) + source_context(element_id: :dashboard_tabs) + target_intent(binding: :active_tab, destination: :activity) + payload_mapping(tab: binding_ref(:active_tab), destination: :activity) + end + + interaction do + id(:open_settings_screen) + family(:navigation) + intent(:open_settings_screen) + source_context(element_id: :settings_link, scope: :screen) + target_intent(action: :navigate_to, screen: :settings, params: %{tab: :profile}) + payload_mapping(tab: :profile) + end + + interaction do + id(:replace_home_screen) + family(:navigation) + intent(:replace_home_screen) + source_context(element_id: :replace_home, scope: :screen) + target_intent(action: :replace_with, screen: :home, params: %{source: :launcher}) + payload_mapping(source: :launcher) + end + + interaction do + id(:go_back_history) + family(:navigation) + intent(:go_back_history) + source_context(element_id: :back_button, scope: :screen) + target_intent(action: :go_back, metadata: %{source: :header}) + payload_mapping(source: :header) + end + + interaction do + id(:open_settings_modal) + family(:navigation) + intent(:open_settings_modal) + source_context(element_id: :open_settings_modal_button, scope: :screen) + target_intent(action: :open_modal, modal: :settings_dialog, params: %{source: :button}) + payload_mapping(source: :button) + end + end + end + test "compiles authored modules into canonical result structs and root IUR output" do {:ok, result} = Compiler.compile(CompiledScreen) @@ -281,4 +378,151 @@ defmodule UnifiedUi.CompilerTest do } } end + + test "lowers canonical navigation transitions into stable interaction target descriptors" do + {:ok, result} = Compiler.compile(NavigationWorkspace) + {:ok, result_again} = Compiler.compile(NavigationWorkspace) + + assert result.interactions == result_again.interactions + assert Reference.snapshot(result.iur) == Reference.snapshot(result_again.iur) + + assert result.trace.interaction_by_id[:navigate_activity] == %UnifiedIUR.Interaction{ + family: :navigation, + intent: :navigate_activity, + source: %{element_id: :dashboard_tabs}, + target: %{ + binding: %{ + id: :active_tab, + kind: :binding_ref, + name: :active_tab, + path: [:navigation, :active_tab], + scope: [:screen] + }, + destination: :activity + }, + payload: %{ + destination: :activity, + tab: %{ + id: :active_tab, + kind: :binding_ref, + name: :active_tab, + path: [:navigation, :active_tab], + scope: [:screen] + } + }, + metadata: %{authored_id: :navigate_activity, binding_refs: [], summary: nil} + } + + assert result.trace.interaction_by_id[:open_settings_screen] == %UnifiedIUR.Interaction{ + family: :navigation, + intent: :open_settings_screen, + source: %{element_id: :settings_link, scope: :screen}, + target: %{ + navigation: %{ + action: :navigate_to, + kind: :screen_transition, + params: %{tab: :profile}, + screen: :settings + } + }, + payload: %{tab: :profile}, + metadata: %{authored_id: :open_settings_screen, binding_refs: [], summary: nil} + } + + assert result.trace.interaction_by_id[:replace_home_screen] == %UnifiedIUR.Interaction{ + family: :navigation, + intent: :replace_home_screen, + source: %{element_id: :replace_home, scope: :screen}, + target: %{ + navigation: %{ + action: :replace_with, + kind: :replace_transition, + params: %{source: :launcher}, + screen: :home + } + }, + payload: %{source: :launcher}, + metadata: %{authored_id: :replace_home_screen, binding_refs: [], summary: nil} + } + + assert result.trace.interaction_by_id[:go_back_history] == %UnifiedIUR.Interaction{ + family: :navigation, + intent: :go_back_history, + source: %{element_id: :back_button, scope: :screen}, + target: %{ + navigation: %{ + action: :go_back, + kind: :history_transition, + metadata: %{source: :header} + } + }, + payload: %{source: :header}, + metadata: %{authored_id: :go_back_history, binding_refs: [], summary: nil} + } + + assert result.trace.interaction_by_id[:open_settings_modal] == %UnifiedIUR.Interaction{ + family: :navigation, + intent: :open_settings_modal, + source: %{element_id: :open_settings_modal_button, scope: :screen}, + target: %{ + navigation: %{ + action: :open_modal, + kind: :modal_transition, + modal: :settings_dialog, + params: %{source: :button} + } + }, + payload: %{source: :button}, + metadata: %{authored_id: :open_settings_modal, binding_refs: [], summary: nil} + } + + assert Enum.map(result.iur.attributes.interactions, &{&1.intent, &1.target}) == [ + {:navigate_activity, + %{ + binding: %{ + id: :active_tab, + kind: :binding_ref, + name: :active_tab, + path: [:navigation, :active_tab], + scope: [:screen] + }, + destination: :activity + }}, + {:open_settings_screen, + %{ + navigation: %{ + action: :navigate_to, + kind: :screen_transition, + params: %{tab: :profile}, + screen: :settings + } + }}, + {:replace_home_screen, + %{ + navigation: %{ + action: :replace_with, + kind: :replace_transition, + params: %{source: :launcher}, + screen: :home + } + }}, + {:go_back_history, + %{ + navigation: %{ + action: :go_back, + kind: :history_transition, + metadata: %{source: :header} + } + }}, + {:open_settings_modal, + %{ + navigation: %{ + action: :open_modal, + kind: :modal_transition, + modal: :settings_dialog, + params: %{source: :button} + } + }} + ] + end end From 301a09367315250c85020d7a9c5cdf922ed07be9 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Sat, 25 Apr 2026 15:03:11 -0400 Subject: [PATCH 2/4] Implement canonical navigation phase 2 section 2.2 --- ...fied-iur-and-signal-transport-alignment.md | 18 +- .spec/specs/unified-iur/interactions.spec.md | 13 ++ .spec/specs/unified-iur/tooling.spec.md | 6 + .../unified_iur/lib/unified_iur/export.ex | 41 +++- .../unified_iur/lib/unified_iur/fixtures.ex | 146 ++++++++++++- .../unified_iur/lib/unified_iur/inspect.ex | 43 +++- .../lib/unified_iur/interaction.ex | 195 +++++++++++++++--- .../unified_iur/lib/unified_iur/reference.ex | 7 + .../test/unified_iur/export_test.exs | 14 ++ .../test/unified_iur/fixtures_test.exs | 35 +++- .../test/unified_iur/inspect_test.exs | 17 ++ .../test/unified_iur/interaction_test.exs | 82 ++++++++ .../test/unified_iur/reference_test.exs | 28 +++ 13 files changed, 605 insertions(+), 40 deletions(-) diff --git a/.spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md b/.spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md index be602bcc..ea3af936 100644 --- a/.spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md +++ b/.spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md @@ -43,25 +43,25 @@ Back to index: [README](./README.md) [x] 2.1.2.2 Subtask - Ensure local navigation-like descriptors that do not change the top-level screen remain representable without being forced into the screen-transition shape. [x] 2.1.2.3 Subtask - Add normalization rules that keep older generic target-intent usage reviewable while preserving the newer canonical transition contract. - [ ] 2.2 Section - UnifiedIUR Interaction Representation + [x] 2.2 Section - UnifiedIUR Interaction Representation Implement the canonical `unified_iur` representation needed for runtimes to consume screen-transition meaning without host-router assumptions. - [ ] 2.2.1 Task - Extend canonical interaction descriptors + [x] 2.2.1 Task - Extend canonical interaction descriptors Add the renderer-independent fields and invariants needed for canonical screen transitions. - [ ] 2.2.1.1 Subtask - Extend the canonical interaction model to represent transition action, symbolic screen target, modal target, params, and related metadata. - [ ] 2.2.1.2 Subtask - Keep canonical interaction storage renderer-independent and free from browser path syntax, host-router names, or runtime-module references. - [ ] 2.2.1.3 Subtask - Define how targetless navigation actions, such as `go_back` or `close_modal`, are represented without inventing fake screen ids. + [x] 2.2.1.1 Subtask - Extend the canonical interaction model to represent transition action, symbolic screen target, modal target, params, and related metadata. + [x] 2.2.1.2 Subtask - Keep canonical interaction storage renderer-independent and free from browser path syntax, host-router names, or runtime-module references. + [x] 2.2.1.3 Subtask - Define how targetless navigation actions, such as `go_back` or `close_modal`, are represented without inventing fake screen ids. - [ ] 2.2.2 Task - Update canonical inspection, export, and fixture support + [x] 2.2.2 Task - Update canonical inspection, export, and fixture support Make the new canonical navigation representation visible and testable through package tooling. - [ ] 2.2.2.1 Subtask - Update inspect and export surfaces so canonical navigation descriptors print their transition fields clearly. - [ ] 2.2.2.2 Subtask - Add canonical fixtures that exercise ordinary transitions, replacement transitions, history traversal, and modal transitions. - [ ] 2.2.2.3 Subtask - Ensure serialized or review-friendly output stays stable enough for diff-oriented tooling and conformance snapshots. + [x] 2.2.2.1 Subtask - Update inspect and export surfaces so canonical navigation descriptors print their transition fields clearly. + [x] 2.2.2.2 Subtask - Add canonical fixtures that exercise ordinary transitions, replacement transitions, history traversal, and modal transitions. + [x] 2.2.2.3 Subtask - Ensure serialized or review-friendly output stays stable enough for diff-oriented tooling and conformance snapshots. [ ] 2.3 Section - Shared Signal Transport Alignment Implement the shared transport semantics that move canonical screen diff --git a/.spec/specs/unified-iur/interactions.spec.md b/.spec/specs/unified-iur/interactions.spec.md index 1f9839b5..28294ac3 100644 --- a/.spec/specs/unified-iur/interactions.spec.md +++ b/.spec/specs/unified-iur/interactions.spec.md @@ -20,6 +20,7 @@ surface: - .spec/specs/unified-iur/interactions.spec.md decisions: - repo.ecosystem.contract_model + - repo.ecosystem.canonical_navigation_boundary ``` ## Requirements @@ -49,6 +50,16 @@ decisions: statement: The package shall represent dynamic data references, bound values, and authored dependency relationships needed for runtime libraries to reconstruct current UI meaning from canonical IUR. priority: must stability: stable + +- id: unified_iur.interactions.navigation_transition_representation + statement: The package shall represent canonical screen-transition descriptors including transition action, symbolic screen target, modal target, and params in a renderer-independent form. + priority: must + stability: stable + +- id: unified_iur.interactions.no_host_router_assumptions + statement: Canonical navigation descriptors shall not encode browser path syntax, host-router names, or runtime-module references as the cross-runtime navigation contract. + priority: must + stability: stable ``` ## Scenarios @@ -71,5 +82,7 @@ decisions: - unified_iur.interactions.renderer_independent_payload_mapping - unified_iur.interactions.standard_interaction_families - unified_iur.interactions.data_binding_representation + - unified_iur.interactions.navigation_transition_representation + - unified_iur.interactions.no_host_router_assumptions - unified_iur.interactions.form_submission_descriptor ``` diff --git a/.spec/specs/unified-iur/tooling.spec.md b/.spec/specs/unified-iur/tooling.spec.md index 5b7482b1..fac3454c 100644 --- a/.spec/specs/unified-iur/tooling.spec.md +++ b/.spec/specs/unified-iur/tooling.spec.md @@ -44,6 +44,11 @@ decisions: statement: The package shall document its canonical construct families, metadata model, styling and theming model, interaction descriptor model, and runtime-library interoperability expectations as part of package development. priority: must stability: stable + +- id: unified_iur.tooling.navigation_transition_review_surfaces + statement: The package shall expose maintained fixtures and inspection or export workflows that make canonical navigation transition descriptors reviewable without requiring a runtime library or host router. + priority: must + stability: stable ``` ## Scenarios @@ -65,5 +70,6 @@ decisions: - unified_iur.tooling.introspection_helpers - unified_iur.tooling.validation_workflow - unified_iur.tooling.documentation_surface + - unified_iur.tooling.navigation_transition_review_surfaces - unified_iur.tooling.inspect_canonical_fixture ``` diff --git a/packages/unified_iur/lib/unified_iur/export.ex b/packages/unified_iur/lib/unified_iur/export.ex index 1a8d6321..534044ae 100644 --- a/packages/unified_iur/lib/unified_iur/export.ex +++ b/packages/unified_iur/lib/unified_iur/export.ex @@ -3,9 +3,10 @@ defmodule UnifiedIUR.Export do Stable export helpers for canonical fixtures, snapshots, and diff reports. """ - alias UnifiedIUR.{Fixtures, Inspect, Normalize, Reference, Validate} + alias UnifiedIUR.{Fixtures, Inspect, Interaction, Normalize, Reference, Validate} @type export_format :: :fixture | :snapshot | :diagnostics | :tree + @type navigation_export_format :: :fixture | :snapshot | :inspection @spec fixture(String.t(), export_format()) :: {:ok, String.t()} | :error def fixture(id, format \\ :fixture) when is_binary(id) do @@ -41,6 +42,37 @@ defmodule UnifiedIUR.Export do end end + @spec navigation_fixture(String.t(), navigation_export_format()) :: {:ok, String.t()} | :error + def navigation_fixture(id, format \\ :fixture) when is_binary(id) do + case Fixtures.navigation_fixture(id) do + {:ok, fixture} -> + output = + case format do + :fixture -> + serialize_term( + id: fixture.id, + description: fixture.description, + semantics: fixture.semantics, + snapshot_path: fixture.snapshot_path, + snapshot: Reference.snapshot_interaction(fixture.interaction) + ) + + :snapshot -> + snapshot_interaction(fixture.interaction) + + :inspection -> + fixture.interaction + |> Inspect.interaction() + |> serialize_term() + end + + {:ok, output} + + :error -> + :error + end + end + @spec snapshot(UnifiedIUR.Element.t() | map() | keyword()) :: String.t() def snapshot(input) do input @@ -48,6 +80,13 @@ defmodule UnifiedIUR.Export do |> serialize_term() end + @spec snapshot_interaction(Interaction.t() | map() | keyword()) :: String.t() + def snapshot_interaction(input) do + input + |> Reference.snapshot_interaction() + |> serialize_term() + end + @spec diagnostics(UnifiedIUR.Element.t() | map() | keyword()) :: String.t() def diagnostics(input) do input diff --git a/packages/unified_iur/lib/unified_iur/fixtures.ex b/packages/unified_iur/lib/unified_iur/fixtures.ex index 27d99347..cc61fb6d 100644 --- a/packages/unified_iur/lib/unified_iur/fixtures.ex +++ b/packages/unified_iur/lib/unified_iur/fixtures.ex @@ -3,7 +3,17 @@ defmodule UnifiedIUR.Fixtures do Maintained reference fixtures for the canonical `UnifiedIUR` surface. """ - alias UnifiedIUR.{Canvas, Container, Extension, Forms, Interoperability, Layer, Layout} + alias UnifiedIUR.{ + Canvas, + Container, + Extension, + Forms, + Interaction, + Interoperability, + Layer, + Layout + } + alias UnifiedIUR.Widgets.{Advanced, Data, Feedback, Foundational, Input, Navigation} @fixture_specs [ @@ -79,6 +89,52 @@ defmodule UnifiedIUR.Fixtures do } ] + @navigation_fixture_specs [ + %{ + id: "screen_transition--settings_profile", + description: + "Canonical top-level screen transition targeting the settings screen with portable params.", + semantics: [ + "screen transition action vocabulary", + "symbolic screen identifiers", + "portable params and payload mapping" + ], + snapshot: "fixtures/navigation/screen_transition--settings_profile.snapshot" + }, + %{ + id: "replace_transition--home", + description: "Canonical screen replacement that does not assume browser-route semantics.", + semantics: [ + "replacement transition action vocabulary", + "symbolic screen identifiers", + "review-friendly replacement params" + ], + snapshot: "fixtures/navigation/replace_transition--home.snapshot" + }, + %{ + id: "history_transition--back", + description: + "Canonical targetless history traversal using portable action meaning without fake screen ids.", + semantics: [ + "targetless history traversal", + "portable history transition meaning", + "metadata without browser-history APIs" + ], + snapshot: "fixtures/navigation/history_transition--back.snapshot" + }, + %{ + id: "modal_transition--settings_dialog", + description: + "Canonical modal transition carrying a symbolic modal target and portable params.", + semantics: [ + "modal transition action vocabulary", + "symbolic modal identifiers", + "portable params and metadata" + ], + snapshot: "fixtures/navigation/modal_transition--settings_dialog.snapshot" + } + ] + @spec ids() :: [String.t()] def ids do Enum.map(@fixture_specs, & &1.id) @@ -192,11 +248,99 @@ defmodule UnifiedIUR.Fixtures do "fixtures/#{id}.snapshot" end + @spec navigation_ids() :: [String.t()] + def navigation_ids do + Enum.map(@navigation_fixture_specs, & &1.id) + end + + @spec navigation_catalog() :: [map()] + def navigation_catalog do + Enum.map( + @navigation_fixture_specs, + &Map.put(&1, :snapshot_path, navigation_snapshot_name(&1.id)) + ) + end + + @spec navigation_fixture(String.t()) :: {:ok, map()} | :error + def navigation_fixture(id) when is_binary(id) do + case Enum.find(@navigation_fixture_specs, &(&1.id == id)) do + nil -> + :error + + spec -> + {:ok, + spec + |> Map.put(:snapshot_path, navigation_snapshot_name(spec.id)) + |> Map.put(:interaction, build_navigation_fixture(spec.id))} + end + end + + @spec navigation_fixture!(String.t()) :: map() + def navigation_fixture!(id) do + case navigation_fixture(id) do + {:ok, fixture} -> fixture + :error -> raise ArgumentError, "unknown navigation fixture #{inspect(id)}" + end + end + + @spec navigation_snapshot_name(String.t()) :: String.t() + def navigation_snapshot_name(id) do + "fixtures/navigation/#{id}.snapshot" + end + @spec valid_id?(String.t()) :: boolean() def valid_id?(id) do String.match?(id, ~r/^[a-z0-9_]+--[a-z0-9_]+$/) end + defp build_navigation_fixture("screen_transition--settings_profile") do + Interaction.navigation_transition( + intent: :open_settings_screen, + element_id: "settings-link", + scope: :screen, + action: :navigate_to, + screen: :settings, + params: %{tab: :profile}, + mapping: %{origin: :workspace} + ) + end + + defp build_navigation_fixture("replace_transition--home") do + Interaction.navigation_transition( + intent: :replace_home_screen, + element_id: "home-link", + scope: :screen, + action: :replace_with, + screen: :home, + params: %{source: :command_palette}, + mapping: %{source: :command_palette} + ) + end + + defp build_navigation_fixture("history_transition--back") do + Interaction.navigation_transition( + intent: :go_back_history, + element_id: "back-button", + scope: :screen, + action: :go_back, + metadata: %{source: :header}, + mapping: %{source: :header} + ) + end + + defp build_navigation_fixture("modal_transition--settings_dialog") do + Interaction.navigation_transition( + intent: :open_settings_modal, + element_id: "settings-dialog-button", + scope: :screen, + action: :open_modal, + modal: :settings_dialog, + params: %{mode: :advanced}, + metadata: %{surface: :workspace}, + mapping: %{surface: :workspace} + ) + end + defp build_fixture("foundational--workspace_chrome") do semantic_hero = Foundational.hero( diff --git a/packages/unified_iur/lib/unified_iur/inspect.ex b/packages/unified_iur/lib/unified_iur/inspect.ex index 53402707..28911419 100644 --- a/packages/unified_iur/lib/unified_iur/inspect.ex +++ b/packages/unified_iur/lib/unified_iur/inspect.ex @@ -7,6 +7,7 @@ defmodule UnifiedIUR.Inspect do Element, Extension, Fixtures, + Interaction, Interoperability, Normalize, Reference, @@ -38,6 +39,23 @@ defmodule UnifiedIUR.Inspect do end end + @spec navigation_fixture(String.t()) :: {:ok, map()} | :error + def navigation_fixture(id) when is_binary(id) do + case Fixtures.navigation_fixture(id) do + {:ok, fixture} -> + {:ok, + fixture.interaction + |> interaction() + |> Map.put(:fixture_id, fixture.id) + |> Map.put(:description, fixture.description) + |> Map.put(:semantics, fixture.semantics) + |> Map.put(:snapshot_path, fixture.snapshot_path)} + + :error -> + :error + end + end + @spec element(Element.t() | map() | keyword()) :: inspection_report() def element(input) do element = Normalize.element!(input) @@ -57,6 +75,21 @@ defmodule UnifiedIUR.Inspect do } end + @spec interaction(Interaction.t() | map() | keyword()) :: map() + def interaction(input) do + interaction = Interaction.new(input) + + %{ + family: interaction.family, + intent: interaction.intent, + source: interaction.source, + target: interaction.target, + navigation: Interaction.navigation_descriptor(interaction), + payload: interaction.payload, + metadata: interaction.metadata + } + end + @spec render_tree(Element.t() | map() | keyword()) :: String.t() def render_tree(input) do input @@ -118,13 +151,13 @@ defmodule UnifiedIUR.Inspect do element.attributes |> Map.get(:interactions, []) |> Enum.map(fn interaction -> - %{ + interaction + |> interaction() + |> Map.merge(%{ id: element.id, kind: element.kind, - family: interaction.family, - intent: interaction.intent, - interaction: interaction - } + interaction: Interaction.new(interaction) + }) end) end) end diff --git a/packages/unified_iur/lib/unified_iur/interaction.ex b/packages/unified_iur/lib/unified_iur/interaction.ex index f6d130b6..107af6ad 100644 --- a/packages/unified_iur/lib/unified_iur/interaction.ex +++ b/packages/unified_iur/lib/unified_iur/interaction.ex @@ -14,6 +14,29 @@ defmodule UnifiedIUR.Interaction do | :navigation | :command + @type navigation_action :: + :navigate_to + | :replace_with + | :go_back + | :go_forward + | :open_modal + | :close_modal + + @type navigation_kind :: + :screen_transition + | :replace_transition + | :history_transition + | :modal_transition + + @type navigation_descriptor :: %{ + optional(:kind) => navigation_kind(), + optional(:action) => navigation_action() | atom() | String.t(), + optional(:screen) => atom() | String.t(), + optional(:modal) => atom() | String.t(), + optional(:params) => map(), + optional(:metadata) => map() + } + @type t :: %__MODULE__{ family: family(), intent: atom() | String.t() | nil, @@ -31,12 +54,36 @@ defmodule UnifiedIUR.Interaction do metadata: %{} @families [:click, :change, :submit, :open, :close, :selection, :focus, :navigation, :command] + @navigation_actions [ + :navigate_to, + :replace_with, + :go_back, + :go_forward, + :open_modal, + :close_modal + ] + @navigation_kinds [ + :screen_transition, + :replace_transition, + :history_transition, + :modal_transition + ] @spec families() :: [family()] def families do @families end + @spec navigation_actions() :: [navigation_action()] + def navigation_actions do + @navigation_actions + end + + @spec navigation_kinds() :: [navigation_kind()] + def navigation_kinds do + @navigation_kinds + end + @spec new(keyword() | map() | t()) :: t() def new(%__MODULE__{} = interaction), do: normalize(interaction) def new(interaction) when is_list(interaction), do: interaction |> Enum.into(%{}) |> new() @@ -46,7 +93,7 @@ defmodule UnifiedIUR.Interaction do family: fetch(interaction, :family, :click), intent: fetch(interaction, :intent), source: normalize_map(fetch(interaction, :source, %{})), - target: normalize_map(fetch(interaction, :target, %{})), + target: normalize_target(fetch(interaction, :target, %{})), payload: normalize_map(fetch(interaction, :payload, %{})), metadata: normalize_map(fetch(interaction, :metadata, %{})) } @@ -76,36 +123,43 @@ defmodule UnifiedIUR.Interaction do @spec navigation(keyword() | map()) :: t() def navigation(opts \\ []), do: build(:navigation, opts) + @spec navigation_transition(keyword() | map()) :: t() + def navigation_transition(opts \\ []), do: build(:navigation, opts) + @spec command(keyword() | map()) :: t() def command(opts \\ []), do: build(:command, opts) + @spec navigation_descriptor(t() | map() | keyword() | nil) :: navigation_descriptor() | nil + def navigation_descriptor(%__MODULE__{} = interaction) do + interaction.target + |> fetch(:navigation) + |> navigation_descriptor() + end + + def navigation_descriptor(nil), do: nil + + def navigation_descriptor(descriptor) when is_list(descriptor), + do: descriptor |> Enum.into(%{}) |> navigation_descriptor() + + def navigation_descriptor(descriptor) when is_map(descriptor) do + descriptor = + descriptor + |> unwrap_navigation_descriptor() + |> normalize_navigation_descriptor() + + if descriptor == %{}, do: nil, else: descriptor + end + defp build(family, opts) do opts = normalize_map(opts) %__MODULE__{ family: family, intent: fetch(opts, :intent), - source: - %{} - |> maybe_put(:element_id, fetch(opts, :element_id)) - |> maybe_put(:slot, fetch(opts, :slot)) - |> maybe_put(:scope, fetch(opts, :scope)), - target: - %{} - |> maybe_put(:path, fetch(opts, :path)) - |> maybe_put(:entity, fetch(opts, :entity)) - |> maybe_put(:binding, fetch(opts, :binding)), - payload: - %{} - |> maybe_put(:mapping, normalize_optional_map(fetch(opts, :mapping))) - |> maybe_put(:value, fetch(opts, :value)) - |> maybe_put(:selection, fetch(opts, :selection)) - |> maybe_put(:command, fetch(opts, :command)), - metadata: - %{} - |> maybe_put(:phase, fetch(opts, :phase)) - |> maybe_put(:propagation, fetch(opts, :propagation)) - |> maybe_put(:transient?, fetch(opts, :transient?)) + source: build_source(opts), + target: build_target(family, opts), + payload: build_payload(opts), + metadata: build_metadata(opts) } end @@ -114,12 +168,98 @@ defmodule UnifiedIUR.Interaction do family: interaction.family, intent: interaction.intent, source: normalize_map(interaction.source), - target: normalize_map(interaction.target), + target: normalize_target(interaction.target), payload: normalize_map(interaction.payload), metadata: normalize_map(interaction.metadata) } end + defp build_source(opts) do + %{} + |> maybe_put(:element_id, fetch(opts, :element_id)) + |> maybe_put(:slot, fetch(opts, :slot)) + |> maybe_put(:scope, fetch(opts, :scope)) + end + + defp build_target(:navigation, opts) do + %{} + |> maybe_put(:path, fetch(opts, :path)) + |> maybe_put(:entity, fetch(opts, :entity)) + |> maybe_put(:binding, fetch(opts, :binding)) + |> maybe_put(:navigation, navigation_descriptor(opts)) + end + + defp build_target(_family, opts) do + %{} + |> maybe_put(:path, fetch(opts, :path)) + |> maybe_put(:entity, fetch(opts, :entity)) + |> maybe_put(:binding, fetch(opts, :binding)) + end + + defp build_payload(opts) do + %{} + |> maybe_put(:mapping, normalize_optional_map(fetch(opts, :mapping))) + |> maybe_put(:value, fetch(opts, :value)) + |> maybe_put(:selection, fetch(opts, :selection)) + |> maybe_put(:command, fetch(opts, :command)) + end + + defp build_metadata(opts) do + %{} + |> maybe_put(:phase, fetch(opts, :phase)) + |> maybe_put(:propagation, fetch(opts, :propagation)) + |> maybe_put(:transient?, fetch(opts, :transient?)) + end + + defp normalize_target(target) do + target = normalize_map(target) + + case navigation_descriptor(target) do + nil -> + target + + descriptor -> + target + |> Map.delete(:navigation) + |> Map.delete("navigation") + |> Map.put(:navigation, descriptor) + end + end + + defp unwrap_navigation_descriptor(descriptor) do + case fetch(descriptor, :navigation) do + nil -> normalize_map(descriptor) + nested -> normalize_map(nested) + end + end + + defp normalize_navigation_descriptor(descriptor) do + action = fetch(descriptor, :action) + kind = fetch(descriptor, :kind, infer_navigation_kind(action)) + + %{} + |> maybe_put(:kind, kind) + |> maybe_put(:action, action) + |> maybe_put(:screen, fetch(descriptor, :screen)) + |> maybe_put(:modal, fetch(descriptor, :modal)) + |> maybe_put(:params, normalize_optional_non_empty_map(fetch(descriptor, :params))) + |> maybe_put(:metadata, normalize_optional_non_empty_map(fetch(descriptor, :metadata))) + end + + defp infer_navigation_kind(:navigate_to), do: :screen_transition + defp infer_navigation_kind("navigate_to"), do: :screen_transition + defp infer_navigation_kind(:replace_with), do: :replace_transition + defp infer_navigation_kind("replace_with"), do: :replace_transition + defp infer_navigation_kind(:go_back), do: :history_transition + defp infer_navigation_kind("go_back"), do: :history_transition + defp infer_navigation_kind(:go_forward), do: :history_transition + defp infer_navigation_kind("go_forward"), do: :history_transition + defp infer_navigation_kind(:open_modal), do: :modal_transition + defp infer_navigation_kind("open_modal"), do: :modal_transition + defp infer_navigation_kind(:close_modal), do: :modal_transition + defp infer_navigation_kind("close_modal"), do: :modal_transition + defp infer_navigation_kind(_action), do: nil + defp normalize_map(nil), do: %{} defp normalize_map(map) when is_map(map), do: Map.new(map) defp normalize_map(list) when is_list(list), do: Enum.into(list, %{}) @@ -127,6 +267,15 @@ defmodule UnifiedIUR.Interaction do defp normalize_optional_map(nil), do: nil defp normalize_optional_map(map), do: normalize_map(map) + defp normalize_optional_non_empty_map(nil), do: nil + + defp normalize_optional_non_empty_map(map) do + case normalize_map(map) do + empty when empty == %{} -> nil + normalized -> normalized + end + end + defp fetch(source, key, default \\ nil) do Map.get(source, key, Map.get(source, Atom.to_string(key), default)) end diff --git a/packages/unified_iur/lib/unified_iur/reference.ex b/packages/unified_iur/lib/unified_iur/reference.ex index 715579eb..0a4f6a62 100644 --- a/packages/unified_iur/lib/unified_iur/reference.ex +++ b/packages/unified_iur/lib/unified_iur/reference.ex @@ -74,6 +74,13 @@ defmodule UnifiedIUR.Reference do |> snapshot_value() end + @spec snapshot_interaction(Interaction.t() | map() | keyword()) :: keyword() + def snapshot_interaction(input) do + input + |> Interaction.new() + |> snapshot_value() + end + @spec equivalent?(Element.t() | map() | keyword(), Element.t() | map() | keyword()) :: boolean() def equivalent?(left, right) do snapshot(left) == snapshot(right) diff --git a/packages/unified_iur/test/unified_iur/export_test.exs b/packages/unified_iur/test/unified_iur/export_test.exs index 03058b9b..6da9e4bd 100644 --- a/packages/unified_iur/test/unified_iur/export_test.exs +++ b/packages/unified_iur/test/unified_iur/export_test.exs @@ -15,6 +15,20 @@ defmodule UnifiedIUR.ExportTest do assert {:ok, snapshot_export} = Export.fixture("forms--profile_editor", :snapshot) assert snapshot_export =~ "kind: :form_builder" assert snapshot_export =~ "name: :profile" + + assert {:ok, navigation_export} = + Export.navigation_fixture("screen_transition--settings_profile") + + assert navigation_export =~ "screen_transition--settings_profile" + assert navigation_export =~ "action: :navigate_to" + assert navigation_export =~ "screen: :settings" + + assert {:ok, navigation_snapshot} = + Export.navigation_fixture("history_transition--back", :snapshot) + + assert navigation_snapshot =~ "action: :go_back" + assert navigation_snapshot =~ "kind: :history_transition" + refute navigation_snapshot =~ "screen:" end test "exports diagnostics and diff reports for maintainers" do diff --git a/packages/unified_iur/test/unified_iur/fixtures_test.exs b/packages/unified_iur/test/unified_iur/fixtures_test.exs index ac2365ba..ad8a4532 100644 --- a/packages/unified_iur/test/unified_iur/fixtures_test.exs +++ b/packages/unified_iur/test/unified_iur/fixtures_test.exs @@ -1,7 +1,7 @@ defmodule UnifiedIUR.FixturesTest do use ExUnit.Case, async: true - alias UnifiedIUR.{Fixtures, Interoperability} + alias UnifiedIUR.{Fixtures, Interaction, Interoperability} test "exposes stable fixture ids, categories, and naming conventions" do assert Fixtures.naming_rules() == %{ @@ -47,4 +47,37 @@ defmodule UnifiedIUR.FixturesTest do category_report.missing == [] end) end + + test "exposes dedicated canonical navigation transition fixtures" do + assert Fixtures.navigation_ids() == [ + "screen_transition--settings_profile", + "replace_transition--home", + "history_transition--back", + "modal_transition--settings_dialog" + ] + + assert {:ok, history_fixture} = Fixtures.navigation_fixture("history_transition--back") + + assert history_fixture.snapshot_path == + "fixtures/navigation/history_transition--back.snapshot" + + assert "targetless history traversal" in history_fixture.semantics + + assert %Interaction{ + family: :navigation, + intent: :go_back_history, + target: %{navigation: %{action: :go_back, kind: :history_transition}} + } = history_fixture.interaction + + refute Map.has_key?(Interaction.navigation_descriptor(history_fixture.interaction), :screen) + + assert %{ + id: "modal_transition--settings_dialog", + snapshot_path: "fixtures/navigation/modal_transition--settings_dialog.snapshot" + } = + Enum.find( + Fixtures.navigation_catalog(), + &(&1.id == "modal_transition--settings_dialog") + ) + end end diff --git a/packages/unified_iur/test/unified_iur/inspect_test.exs b/packages/unified_iur/test/unified_iur/inspect_test.exs index 1e7483c0..913ba616 100644 --- a/packages/unified_iur/test/unified_iur/inspect_test.exs +++ b/packages/unified_iur/test/unified_iur/inspect_test.exs @@ -48,4 +48,21 @@ defmodule UnifiedIUR.InspectTest do } = Inspect.extension_metadata() end + + test "inspects canonical navigation fixtures and surfaces navigation summaries" do + assert {:ok, report} = Inspect.navigation_fixture("screen_transition--settings_profile") + + assert report.fixture_id == "screen_transition--settings_profile" + assert report.intent == :open_settings_screen + + assert report.navigation == %{ + action: :navigate_to, + kind: :screen_transition, + params: %{tab: :profile}, + screen: :settings + } + + assert report.target == %{navigation: report.navigation} + assert "symbolic screen identifiers" in report.semantics + end end diff --git a/packages/unified_iur/test/unified_iur/interaction_test.exs b/packages/unified_iur/test/unified_iur/interaction_test.exs index 5fd9657c..f2998652 100644 --- a/packages/unified_iur/test/unified_iur/interaction_test.exs +++ b/packages/unified_iur/test/unified_iur/interaction_test.exs @@ -10,6 +10,12 @@ defmodule UnifiedIUR.InteractionTest do assert [:click, :change, :submit, :open, :close, :selection, :focus, :navigation, :command] == Interaction.families() + + assert [:navigate_to, :replace_with, :go_back, :go_forward, :open_modal, :close_modal] == + Interaction.navigation_actions() + + assert [:screen_transition, :replace_transition, :history_transition, :modal_transition] == + Interaction.navigation_kinds() end test "builds standard interaction descriptor families with canonical source, target, and payload metadata" do @@ -58,6 +64,82 @@ defmodule UnifiedIUR.InteractionTest do } = command end + test "normalizes canonical navigation transition descriptors without fake screen ids" do + screen_transition = + Interaction.navigation_transition( + intent: :open_settings_screen, + element_id: "settings-link", + scope: :screen, + action: :navigate_to, + screen: :settings, + params: %{tab: :profile} + ) + + history_transition = + Interaction.navigation_transition( + intent: :go_back_history, + element_id: "back-button", + scope: :screen, + action: :go_back, + metadata: %{source: :header} + ) + + replacement = + Interaction.new(%{ + family: :navigation, + intent: :replace_home_screen, + target: %{ + "navigation" => %{ + "action" => "replace_with", + "screen" => :home, + "params" => %{"source" => :launcher} + } + } + }) + + assert %Interaction{ + family: :navigation, + intent: :open_settings_screen, + source: %{element_id: "settings-link", scope: :screen}, + target: %{ + navigation: %{ + action: :navigate_to, + kind: :screen_transition, + params: %{tab: :profile}, + screen: :settings + } + } + } = screen_transition + + assert Interaction.navigation_descriptor(screen_transition) == %{ + action: :navigate_to, + kind: :screen_transition, + params: %{tab: :profile}, + screen: :settings + } + + assert %Interaction{ + intent: :go_back_history, + target: %{ + navigation: %{ + action: :go_back, + kind: :history_transition, + metadata: %{source: :header} + } + } + } = history_transition + + refute Map.has_key?(Interaction.navigation_descriptor(history_transition), :screen) + refute Map.has_key?(Interaction.navigation_descriptor(history_transition), :modal) + + assert Interaction.navigation_descriptor(replacement) == %{ + action: "replace_with", + kind: :replace_transition, + params: %{"source" => :launcher}, + screen: :home + } + end + test "builds bindings with source paths, dependencies, and derived-value metadata" do binding = Binding.new( diff --git a/packages/unified_iur/test/unified_iur/reference_test.exs b/packages/unified_iur/test/unified_iur/reference_test.exs index 6b4aecee..26364871 100644 --- a/packages/unified_iur/test/unified_iur/reference_test.exs +++ b/packages/unified_iur/test/unified_iur/reference_test.exs @@ -111,4 +111,32 @@ defmodule UnifiedIUR.ReferenceTest do assert [%{path: [:children, 0, :element, :id], left: "save-button", right: "deploy-button"}] = Reference.shape_diff(left, right) end + + test "produces deterministic snapshots for canonical navigation interactions" do + interaction = + UnifiedIUR.Interaction.navigation_transition( + intent: :open_settings_screen, + element_id: "settings-link", + scope: :screen, + action: :navigate_to, + screen: :settings, + params: %{tab: :profile} + ) + + assert Reference.snapshot_interaction(interaction) == [ + family: :navigation, + intent: :open_settings_screen, + source: [element_id: "settings-link", scope: :screen], + target: [ + navigation: [ + action: :navigate_to, + kind: :screen_transition, + params: [tab: :profile], + screen: :settings + ] + ], + payload: [], + metadata: [] + ] + end end From 84e327b3544e0b15b186d3fb8c24609b17d9268d Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Sat, 25 Apr 2026 15:09:36 -0400 Subject: [PATCH 3/4] Implement canonical navigation phase 2 section 2.3 --- ...fied-iur-and-signal-transport-alignment.md | 18 +- .spec/specs/signal_transport.spec.md | 14 + .../lib/unified_iur/interactions.ex | 5 +- .../lib/unified_iur/interactions/transport.ex | 356 ++++++++++++++++++ .../test/unified_iur/interaction_test.exs | 7 +- .../interactions_transport_test.exs | 85 +++++ 6 files changed, 473 insertions(+), 12 deletions(-) create mode 100644 packages/unified_iur/lib/unified_iur/interactions/transport.ex create mode 100644 packages/unified_iur/test/unified_iur/interactions_transport_test.exs diff --git a/.spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md b/.spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md index ea3af936..b44b5da8 100644 --- a/.spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md +++ b/.spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md @@ -63,25 +63,25 @@ Back to index: [README](./README.md) [x] 2.2.2.2 Subtask - Add canonical fixtures that exercise ordinary transitions, replacement transitions, history traversal, and modal transitions. [x] 2.2.2.3 Subtask - Ensure serialized or review-friendly output stays stable enough for diff-oriented tooling and conformance snapshots. - [ ] 2.3 Section - Shared Signal Transport Alignment + [x] 2.3 Section - Shared Signal Transport Alignment Implement the shared transport semantics that move canonical screen transitions across runtime package boundaries. - [ ] 2.3.1 Task - Define canonical boundary signal shape for transitions + [x] 2.3.1 Task - Define canonical boundary signal shape for transitions Establish how canonical screen transitions are represented when they cross package boundaries as `Jido.Signal` values. - [ ] 2.3.1.1 Subtask - Define the event-family and payload expectations for transition actions, symbolic screen targets, modal targets, and params. - [ ] 2.3.1.2 Subtask - Define how targetless actions, such as `go_back`, `go_forward`, and `close_modal`, are encoded without host-specific routing assumptions. - [ ] 2.3.1.3 Subtask - Define how runtimes receive canonical transition data while keeping their local native signal models free to differ internally. + [x] 2.3.1.1 Subtask - Define the event-family and payload expectations for transition actions, symbolic screen targets, modal targets, and params. + [x] 2.3.1.2 Subtask - Define how targetless actions, such as `go_back`, `go_forward`, and `close_modal`, are encoded without host-specific routing assumptions. + [x] 2.3.1.3 Subtask - Define how runtimes receive canonical transition data while keeping their local native signal models free to differ internally. - [ ] 2.3.2 Task - Add shared transport validation and fixtures + [x] 2.3.2 Task - Add shared transport validation and fixtures Provide the shared validation and reference fixtures that runtime implementers can consume consistently. - [ ] 2.3.2.1 Subtask - Add validation for malformed transition payloads, leaked router syntax, and missing required canonical fields. - [ ] 2.3.2.2 Subtask - Add shared transport fixtures that web, desktop, and terminal runtimes can use to prove canonical transition fidelity. - [ ] 2.3.2.3 Subtask - Add review-oriented transport summaries that make it obvious which transition action and symbolic screen target crossed the package boundary. + [x] 2.3.2.1 Subtask - Add validation for malformed transition payloads, leaked router syntax, and missing required canonical fields. + [x] 2.3.2.2 Subtask - Add shared transport fixtures that web, desktop, and terminal runtimes can use to prove canonical transition fidelity. + [x] 2.3.2.3 Subtask - Add review-oriented transport summaries that make it obvious which transition action and symbolic screen target crossed the package boundary. [ ] 2.4 Section - Phase 2 Integration Tests Validate canonical lowering, `unified_iur` representation, and boundary diff --git a/.spec/specs/signal_transport.spec.md b/.spec/specs/signal_transport.spec.md index fb4c24cd..bc69986a 100644 --- a/.spec/specs/signal_transport.spec.md +++ b/.spec/specs/signal_transport.spec.md @@ -9,6 +9,7 @@ status: active summary: Shared Jido.Signal and CloudEvents-compatible boundary contract across the DSL, IUR consumers, and runtime libraries with native signal translation. surface: - packages/unified-ui + - packages/unified_iur - packages/live_ui - packages/elm_ui - packages/desktop_ui @@ -16,6 +17,7 @@ surface: - .spec/specs/signal_transport.spec.md decisions: - repo.ecosystem.contract_model + - repo.ecosystem.canonical_navigation_boundary - repo.ecosystem.elm_ui_naming ``` @@ -61,6 +63,16 @@ decisions: statement: Renderer-specific local state and native signal mechanics may vary by library, but cross-package event meanings shall remain canonical at the signal contract boundary. priority: must stability: stable + +- id: ecosystem.signal_transport.navigation_transition_meaning + statement: Navigation interactions that cross ecosystem package boundaries shall preserve canonical screen-transition meaning, including transition action, symbolic screen target when applicable, and params, without requiring browser-route syntax or runtime-specific identifiers. + priority: must + stability: stable + +- id: ecosystem.signal_transport.shared_transition_validation_and_fixtures + statement: The ecosystem shall expose shared canonical transition fixtures, validation rules, and review summaries that runtime packages can consume consistently when transporting screen transitions across package boundaries. + priority: must + stability: stable ``` ## Exceptions @@ -91,4 +103,6 @@ decisions: - ecosystem.signal_transport.terminal_bridge - ecosystem.signal_transport.native_signal_models_allowed - ecosystem.signal_transport.local_state_not_contract + - ecosystem.signal_transport.navigation_transition_meaning + - ecosystem.signal_transport.shared_transition_validation_and_fixtures ``` diff --git a/packages/unified_iur/lib/unified_iur/interactions.ex b/packages/unified_iur/lib/unified_iur/interactions.ex index 42cbe981..7cfe67ee 100644 --- a/packages/unified_iur/lib/unified_iur/interactions.ex +++ b/packages/unified_iur/lib/unified_iur/interactions.ex @@ -3,11 +3,12 @@ defmodule UnifiedIUR.Interactions do Reference surface for canonical interaction descriptors and binding metadata. """ - @spec modules() :: %{binding: module(), interaction: module()} + @spec modules() :: %{binding: module(), interaction: module(), transport: module()} def modules do %{ interaction: UnifiedIUR.Interaction, - binding: UnifiedIUR.Binding + binding: UnifiedIUR.Binding, + transport: UnifiedIUR.Interactions.Transport } end end diff --git a/packages/unified_iur/lib/unified_iur/interactions/transport.ex b/packages/unified_iur/lib/unified_iur/interactions/transport.ex new file mode 100644 index 00000000..397f8ab8 --- /dev/null +++ b/packages/unified_iur/lib/unified_iur/interactions/transport.ex @@ -0,0 +1,356 @@ +defmodule UnifiedIUR.Interactions.Transport do + @moduledoc """ + Shared boundary-fixture and validation helpers for canonical navigation + transitions crossing runtime package boundaries. + """ + + alias UnifiedIUR.{Fixtures, Interaction} + + @extension_key :unified_iur_boundary + @summary_key :unified_iur_boundary_summary + @forbidden_navigation_keys [ + :route, + :path, + :url, + :uri, + :router, + :helper, + :module, + :runtime_module, + :live_action + ] + @navigation_actions Interaction.navigation_actions() + + @type boundary_descriptor :: %{ + family: :navigation, + intent: atom() | String.t() | nil, + source_context: map(), + target: map(), + metadata: map() + } + + @type review_summary :: %{ + family: :navigation, + intent: atom() | String.t() | nil, + action: atom() | String.t() | nil, + screen: atom() | String.t() | nil, + modal: atom() | String.t() | nil, + params?: boolean(), + targetless?: boolean() + } + + @spec extension_key() :: atom() + def extension_key, do: @extension_key + + @spec summary_key() :: atom() + def summary_key, do: @summary_key + + @spec forbidden_navigation_keys() :: [atom()] + def forbidden_navigation_keys, do: @forbidden_navigation_keys + + @spec boundary_fixture_ids() :: [String.t()] + def boundary_fixture_ids do + Fixtures.navigation_ids() + end + + @spec boundary_fixtures() :: [map()] + def boundary_fixtures do + Enum.map(boundary_fixture_ids(), &boundary_fixture!/1) + end + + @spec boundary_fixture(String.t()) :: {:ok, map()} | :error + def boundary_fixture(id) when is_binary(id) do + case Fixtures.navigation_fixture(id) do + {:ok, fixture} -> + descriptor = boundary_descriptor(fixture.interaction) + summary = summarize_boundary_descriptor(descriptor) + + {:ok, + %{ + id: fixture.id, + description: fixture.description, + semantics: fixture.semantics, + snapshot_path: fixture.snapshot_path, + signal_data: fixture.interaction.payload, + extensions: boundary_extensions(fixture.interaction), + descriptor: descriptor, + summary: summary, + interaction: fixture.interaction + }} + + :error -> + :error + end + end + + @spec boundary_fixture!(String.t()) :: map() + def boundary_fixture!(id) do + case boundary_fixture(id) do + {:ok, fixture} -> fixture + :error -> raise ArgumentError, "unknown boundary fixture #{inspect(id)}" + end + end + + @spec boundary_descriptor(Interaction.t() | map() | keyword()) :: boundary_descriptor() + def boundary_descriptor(input) do + interaction = Interaction.new(input) + + %{ + family: :navigation, + intent: interaction.intent, + source_context: normalize_map(interaction.source), + target: normalize_map(interaction.target), + metadata: normalize_map(interaction.metadata) + } + end + + @spec boundary_extensions(Interaction.t() | map() | keyword()) :: map() + def boundary_extensions(input) do + descriptor = boundary_descriptor(input) + + %{ + @extension_key => descriptor, + @summary_key => summarize_boundary_descriptor(descriptor) + } + end + + @spec summarize_boundary_descriptor(boundary_descriptor() | map() | keyword()) :: + review_summary() + def summarize_boundary_descriptor(descriptor) do + descriptor = normalize_descriptor(descriptor) + navigation = Interaction.navigation_descriptor(descriptor.target) || %{} + + %{ + family: :navigation, + intent: descriptor.intent, + action: Map.get(navigation, :action), + screen: Map.get(navigation, :screen), + modal: Map.get(navigation, :modal), + params?: navigation_params?(navigation), + targetless?: targetless_navigation?(navigation) + } + end + + @spec validate_boundary_fixture(map()) :: :ok | {:error, term()} + def validate_boundary_fixture(fixture) when is_map(fixture) do + with :ok <- validate_boundary_payload(Map.get(fixture, :signal_data)), + :ok <- validate_boundary_extensions(Map.get(fixture, :extensions)) do + :ok + end + end + + @spec validate_boundary_payload(map() | nil) :: :ok | {:error, term()} + def validate_boundary_payload(payload) when is_map(payload) or is_nil(payload), do: :ok + + def validate_boundary_payload(payload), + do: {:error, {:invalid_boundary_payload, payload}} + + @spec validate_boundary_extensions(map() | keyword()) :: :ok | {:error, term()} + def validate_boundary_extensions(extensions) when is_map(extensions) or is_list(extensions) do + extensions = normalize_map(extensions) + + descriptor = + Map.get(extensions, @extension_key) || Map.get(extensions, Atom.to_string(@extension_key)) + + summary = + Map.get(extensions, @summary_key) || Map.get(extensions, Atom.to_string(@summary_key)) + + with %{} <- descriptor || {:error, :missing_boundary_descriptor}, + :ok <- validate_boundary_descriptor(descriptor), + :ok <- validate_summary(summary, descriptor) do + :ok + else + {:error, _reason} = error -> error + _other -> {:error, :missing_boundary_descriptor} + end + end + + def validate_boundary_extensions(extensions), + do: {:error, {:invalid_boundary_extensions, extensions}} + + @spec validate_boundary_descriptor(boundary_descriptor() | map() | keyword()) :: + :ok | {:error, term()} + def validate_boundary_descriptor(descriptor) do + descriptor = normalize_descriptor(descriptor) + navigation = descriptor.target |> raw_navigation_target() |> normalize_map() + + with :ok <- validate_descriptor_family(descriptor.family), + :ok <- validate_required_maps(descriptor), + :ok <- validate_forbidden_navigation_keys(navigation), + %{} <- + normalized_navigation(descriptor.target) || {:error, :missing_navigation_descriptor}, + :ok <- validate_navigation_descriptor(normalized_navigation(descriptor.target)) do + :ok + else + {:error, _reason} = error -> error + _other -> {:error, :missing_navigation_descriptor} + end + end + + defp validate_descriptor_family(:navigation), do: :ok + defp validate_descriptor_family(family), do: {:error, {:invalid_boundary_family, family}} + + defp validate_required_maps(descriptor) do + with :ok <- validate_map_field(descriptor.source_context, :source_context), + :ok <- validate_map_field(descriptor.target, :target), + :ok <- validate_map_field(descriptor.metadata, :metadata) do + :ok + end + end + + defp validate_map_field(value, _field) when is_map(value), do: :ok + defp validate_map_field(value, field), do: {:error, {:invalid_field, field, value}} + + defp validate_forbidden_navigation_keys(navigation) do + leaked = + navigation + |> Map.keys() + |> Enum.filter(&forbidden_navigation_key?/1) + + if leaked == [] do + :ok + else + {:error, {:forbidden_navigation_keys, leaked}} + end + end + + defp validate_navigation_descriptor(nil), do: {:error, :missing_navigation_descriptor} + + defp validate_navigation_descriptor(descriptor) do + descriptor = normalize_map(descriptor) + action = Map.get(descriptor, :action) + + with :ok <- validate_action(action), + :ok <- validate_action_targets(action, descriptor), + :ok <- validate_optional_map(Map.get(descriptor, :params), :params), + :ok <- validate_optional_map(Map.get(descriptor, :metadata), :metadata) do + :ok + end + end + + defp validate_action(action) when action in @navigation_actions, do: :ok + + defp validate_action(action) when is_binary(action) do + if action in Enum.map(@navigation_actions, &Atom.to_string/1) do + :ok + else + {:error, {:invalid_navigation_action, action}} + end + end + + defp validate_action(action), do: {:error, {:invalid_navigation_action, action}} + + defp validate_action_targets(action, descriptor) when action in [:navigate_to, "navigate_to"] do + require_field(descriptor, :screen) + end + + defp validate_action_targets(action, descriptor) + when action in [:replace_with, "replace_with"] do + require_field(descriptor, :screen) + end + + defp validate_action_targets(action, descriptor) when action in [:open_modal, "open_modal"] do + require_field(descriptor, :modal) + end + + defp validate_action_targets(action, descriptor) + when action in [:go_back, "go_back", :go_forward, "go_forward"] do + reject_fields(descriptor, [:screen, :modal]) + end + + defp validate_action_targets(_action, descriptor) do + reject_fields(descriptor, [:screen]) + end + + defp require_field(descriptor, field) do + case Map.get(descriptor, field) do + nil -> {:error, {:missing_field, field}} + "" -> {:error, {:missing_field, field}} + _value -> :ok + end + end + + defp reject_fields(descriptor, fields) do + unexpected = + Enum.filter(fields, fn field -> + case Map.get(descriptor, field) do + nil -> false + "" -> false + _value -> true + end + end) + + if unexpected == [] do + :ok + else + {:error, {:unexpected_fields, unexpected}} + end + end + + defp validate_optional_map(nil, _field), do: :ok + defp validate_optional_map(value, _field) when is_map(value), do: :ok + defp validate_optional_map(value, field), do: {:error, {:invalid_field, field, value}} + + defp validate_summary(nil, descriptor) do + {:error, {:missing_boundary_summary, summarize_boundary_descriptor(descriptor)}} + end + + defp validate_summary(summary, descriptor) when is_map(summary) do + if normalize_map(summary) == summarize_boundary_descriptor(descriptor) do + :ok + else + {:error, {:invalid_boundary_summary, summary}} + end + end + + defp validate_summary(summary, _descriptor), do: {:error, {:invalid_boundary_summary, summary}} + + defp normalize_descriptor(descriptor) do + descriptor = normalize_map(descriptor) + + %{ + family: Map.get(descriptor, :family) || Map.get(descriptor, "family"), + intent: Map.get(descriptor, :intent) || Map.get(descriptor, "intent"), + source_context: + normalize_map( + Map.get(descriptor, :source_context) || Map.get(descriptor, "source_context") || %{} + ), + target: normalize_map(Map.get(descriptor, :target) || Map.get(descriptor, "target") || %{}), + metadata: + normalize_map(Map.get(descriptor, :metadata) || Map.get(descriptor, "metadata") || %{}) + } + end + + defp normalized_navigation(target) do + Interaction.navigation_descriptor(target) + end + + defp raw_navigation_target(target) do + target = normalize_map(target) + Map.get(target, :navigation) || Map.get(target, "navigation") || %{} + end + + defp navigation_params?(navigation) do + navigation + |> Map.get(:params, %{}) + |> normalize_map() + |> Kernel.!=(%{}) + end + + defp targetless_navigation?(navigation) do + is_nil(Map.get(navigation, :screen)) and is_nil(Map.get(navigation, :modal)) + end + + defp forbidden_navigation_key?(key) when is_atom(key), + do: key in @forbidden_navigation_keys + + defp forbidden_navigation_key?(key) when is_binary(key) do + key in Enum.map(@forbidden_navigation_keys, &Atom.to_string/1) + end + + defp forbidden_navigation_key?(_key), do: false + + defp normalize_map(map) when is_map(map), do: Map.new(map) + defp normalize_map(list) when is_list(list), do: Enum.into(list, %{}) + defp normalize_map(_value), do: %{} +end diff --git a/packages/unified_iur/test/unified_iur/interaction_test.exs b/packages/unified_iur/test/unified_iur/interaction_test.exs index f2998652..ff84b511 100644 --- a/packages/unified_iur/test/unified_iur/interaction_test.exs +++ b/packages/unified_iur/test/unified_iur/interaction_test.exs @@ -6,7 +6,12 @@ defmodule UnifiedIUR.InteractionTest do alias UnifiedIUR.Interactions test "exposes canonical interaction and binding modules" do - assert %{interaction: Interaction, binding: Binding} = Interactions.modules() + assert %{ + interaction: Interaction, + binding: Binding, + transport: UnifiedIUR.Interactions.Transport + } = + Interactions.modules() assert [:click, :change, :submit, :open, :close, :selection, :focus, :navigation, :command] == Interaction.families() diff --git a/packages/unified_iur/test/unified_iur/interactions_transport_test.exs b/packages/unified_iur/test/unified_iur/interactions_transport_test.exs new file mode 100644 index 00000000..feb00dac --- /dev/null +++ b/packages/unified_iur/test/unified_iur/interactions_transport_test.exs @@ -0,0 +1,85 @@ +defmodule UnifiedIUR.InteractionsTransportTest do + use ExUnit.Case, async: true + + alias UnifiedIUR.Interactions.Transport + + test "exposes maintained shared boundary fixtures and review summaries" do + assert Transport.boundary_fixture_ids() == [ + "screen_transition--settings_profile", + "replace_transition--home", + "history_transition--back", + "modal_transition--settings_dialog" + ] + + assert {:ok, fixture} = Transport.boundary_fixture("screen_transition--settings_profile") + + assert fixture.signal_data == %{mapping: %{origin: :workspace}} + + assert fixture.summary == %{ + family: :navigation, + intent: :open_settings_screen, + action: :navigate_to, + screen: :settings, + modal: nil, + params?: true, + targetless?: false + } + + assert fixture.extensions == %{ + unified_iur_boundary: fixture.descriptor, + unified_iur_boundary_summary: fixture.summary + } + + assert :ok = Transport.validate_boundary_fixture(fixture) + end + + test "keeps targetless history transitions portable without fake screen ids" do + fixture = Transport.boundary_fixture!("history_transition--back") + + assert fixture.summary.targetless? + + assert fixture.descriptor.target == %{ + navigation: %{ + action: :go_back, + kind: :history_transition, + metadata: %{source: :header} + } + } + + assert :ok = Transport.validate_boundary_extensions(fixture.extensions) + end + + test "rejects leaked router syntax and missing required canonical fields" do + assert {:error, {:forbidden_navigation_keys, [:route]}} = + Transport.validate_boundary_extensions(%{ + unified_iur_boundary: %{ + family: :navigation, + intent: :open_settings_screen, + source_context: %{element_id: "settings-link"}, + target: %{navigation: %{action: :navigate_to, route: "/settings"}}, + metadata: %{} + }, + unified_iur_boundary_summary: %{} + }) + + assert {:error, {:missing_field, :screen}} = + Transport.validate_boundary_extensions(%{ + unified_iur_boundary: %{ + family: :navigation, + intent: :open_settings_screen, + source_context: %{element_id: "settings-link"}, + target: %{navigation: %{action: :navigate_to}}, + metadata: %{} + }, + unified_iur_boundary_summary: %{ + family: :navigation, + intent: :open_settings_screen, + action: :navigate_to, + screen: nil, + modal: nil, + params?: false, + targetless?: true + } + }) + end +end From 9f88ae68d6d65edf5e1394407e1bf5e9d02ddcfd Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Sat, 25 Apr 2026 15:15:37 -0400 Subject: [PATCH 4/4] Implement canonical navigation phase 2 section 2.4 --- ...fied-iur-and-signal-transport-alignment.md | 20 ++--- ..._navigation_transport_integration_test.exs | 28 ++++++ ..._navigation_transport_integration_test.exs | 26 ++++++ ..._navigation_transport_integration_test.exs | 30 +++++++ .../canonical_navigation_integration_test.exs | 46 +++++++++- ...l_navigation_boundary_integration_test.exs | 88 +++++++++++++++++++ 6 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 packages/desktop_ui/test/desktop_ui/canonical_navigation_transport_integration_test.exs create mode 100644 packages/live_ui/test/live_ui/canonical_navigation_transport_integration_test.exs create mode 100644 packages/terminal_ui/test/terminal_ui/canonical_navigation_transport_integration_test.exs create mode 100644 packages/unified_iur/test/unified_iur/canonical_navigation_boundary_integration_test.exs diff --git a/.spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md b/.spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md index b44b5da8..a6a2f65d 100644 --- a/.spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md +++ b/.spec/planning/canonical_navigation/phase-02-compiler-unified-iur-and-signal-transport-alignment.md @@ -18,7 +18,7 @@ Back to index: [README](./README.md) - The shared signal transport contract continues to use `Jido.Signal` with CloudEvents-compatible semantics at package boundaries. -[ ] 2 Phase 2 - Compiler, UnifiedIUR, and Signal Transport Alignment +[x] 2 Phase 2 - Compiler, UnifiedIUR, and Signal Transport Alignment Implement the canonical lowering, interchange representation, and transport semantics that carry screen-transition intent from authored `UnifiedUi` modules to runtime libraries. @@ -83,22 +83,22 @@ Back to index: [README](./README.md) [x] 2.3.2.2 Subtask - Add shared transport fixtures that web, desktop, and terminal runtimes can use to prove canonical transition fidelity. [x] 2.3.2.3 Subtask - Add review-oriented transport summaries that make it obvious which transition action and symbolic screen target crossed the package boundary. - [ ] 2.4 Section - Phase 2 Integration Tests + [x] 2.4 Section - Phase 2 Integration Tests Validate canonical lowering, `unified_iur` representation, and boundary transport behavior end to end before runtime-specific mapping begins. - [ ] 2.4.1 Task - Compiler and canonical descriptor scenarios + [x] 2.4.1 Task - Compiler and canonical descriptor scenarios Verify authored transitions compile into stable canonical descriptors that carry the required cross-runtime meaning. - [ ] 2.4.1.1 Subtask - Verify authored screen transitions lower into canonical interaction descriptors with action, symbolic screen target, modal target, params, and payload mapping preserved. - [ ] 2.4.1.2 Subtask - Verify targetless transitions compile without fake screen ids or host-router placeholders. - [ ] 2.4.1.3 Subtask - Verify canonical descriptor output remains deterministic across equivalent authored modules and review exports. + [x] 2.4.1.1 Subtask - Verify authored screen transitions lower into canonical interaction descriptors with action, symbolic screen target, modal target, params, and payload mapping preserved. + [x] 2.4.1.2 Subtask - Verify targetless transitions compile without fake screen ids or host-router placeholders. + [x] 2.4.1.3 Subtask - Verify canonical descriptor output remains deterministic across equivalent authored modules and review exports. - [ ] 2.4.2 Task - Shared transport boundary scenarios + [x] 2.4.2 Task - Shared transport boundary scenarios Verify canonical transition meaning survives the package-boundary signal contract without route leakage. - [ ] 2.4.2.1 Subtask - Verify `Jido.Signal` boundary fixtures preserve transition action, symbolic screen target, and params where applicable. - [ ] 2.4.2.2 Subtask - Verify malformed transition envelopes and leaked route syntax fail with shared validation diagnostics. - [ ] 2.4.2.3 Subtask - Verify runtimes can consume the shared transition fixtures without requiring browser-only fields or runtime-module identifiers. + [x] 2.4.2.1 Subtask - Verify `Jido.Signal` boundary fixtures preserve transition action, symbolic screen target, and params where applicable. + [x] 2.4.2.2 Subtask - Verify malformed transition envelopes and leaked route syntax fail with shared validation diagnostics. + [x] 2.4.2.3 Subtask - Verify runtimes can consume the shared transition fixtures without requiring browser-only fields or runtime-module identifiers. diff --git a/packages/desktop_ui/test/desktop_ui/canonical_navigation_transport_integration_test.exs b/packages/desktop_ui/test/desktop_ui/canonical_navigation_transport_integration_test.exs new file mode 100644 index 00000000..0543ba5c --- /dev/null +++ b/packages/desktop_ui/test/desktop_ui/canonical_navigation_transport_integration_test.exs @@ -0,0 +1,28 @@ +defmodule DesktopUi.CanonicalNavigationTransportIntegrationTest do + use ExUnit.Case, async: true + + alias Jido.Signal + alias DesktopUi.Transport + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport + + test "consumes shared modal-transition fixtures without runtime-module identifiers" do + fixture = BoundaryTransport.boundary_fixture!("modal_transition--settings_dialog") + + assert {:ok, translation} = + Transport.from_interaction( + fixture.interaction, + platform_target: :linux, + widget_id: "settings-trigger", + runtime_id: "desktop-ui:workspace", + screen: "workspace", + payload: fixture.signal_data + ) + + assert :ok = BoundaryTransport.validate_boundary_fixture(fixture) + assert translation.target == fixture.descriptor.target + assert %Signal{} = translation.signal + assert translation.signal.data == fixture.signal_data + assert translation.signal.extensions.desktop_ui_target == fixture.descriptor.target + refute translation.signal.extensions.desktop_ui_target.navigation[:module] + end +end diff --git a/packages/live_ui/test/live_ui/canonical_navigation_transport_integration_test.exs b/packages/live_ui/test/live_ui/canonical_navigation_transport_integration_test.exs new file mode 100644 index 00000000..368380e1 --- /dev/null +++ b/packages/live_ui/test/live_ui/canonical_navigation_transport_integration_test.exs @@ -0,0 +1,26 @@ +defmodule LiveUi.CanonicalNavigationTransportIntegrationTest do + use ExUnit.Case, async: true + + alias Jido.Signal + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport + + test "consumes shared screen-transition fixtures without browser-only route fields" do + fixture = BoundaryTransport.boundary_fixture!("screen_transition--settings_profile") + + assert {:ok, translation} = + LiveUi.Signals.from_interaction( + fixture.interaction, + screen: :workspace, + mode: :screen, + boundary: :boundary, + payload: fixture.signal_data + ) + + assert :ok = BoundaryTransport.validate_boundary_fixture(fixture) + assert translation.target == fixture.descriptor.target + assert %Signal{} = translation.signal + assert translation.signal.data == fixture.signal_data + assert translation.signal.extensions.live_ui_target == fixture.descriptor.target + refute translation.signal.extensions.live_ui_target.navigation[:route] + end +end diff --git a/packages/terminal_ui/test/terminal_ui/canonical_navigation_transport_integration_test.exs b/packages/terminal_ui/test/terminal_ui/canonical_navigation_transport_integration_test.exs new file mode 100644 index 00000000..d84329bd --- /dev/null +++ b/packages/terminal_ui/test/terminal_ui/canonical_navigation_transport_integration_test.exs @@ -0,0 +1,30 @@ +defmodule TerminalUi.CanonicalNavigationTransportIntegrationTest do + use ExUnit.Case, async: true + + alias Jido.Signal + alias TerminalUi.Transport + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport + + test "consumes shared history-transition fixtures without fake screen identifiers" do + fixture = BoundaryTransport.boundary_fixture!("history_transition--back") + + assert {:ok, translation} = + Transport.from_interaction( + fixture.interaction, + backend_mode: :raw, + input_family: :key, + widget_id: "history-button", + runtime_id: "terminal-ui:workspace", + screen: "workspace", + payload: fixture.signal_data + ) + + assert :ok = BoundaryTransport.validate_boundary_fixture(fixture) + assert translation.target == fixture.descriptor.target + assert %Signal{} = translation.signal + assert translation.signal.data == fixture.signal_data + assert translation.signal.extensions.terminal_ui_target == fixture.descriptor.target + refute Map.has_key?(translation.target.navigation, :screen) + refute Map.has_key?(translation.target.navigation, :modal) + end +end diff --git a/packages/unified-ui/test/unified_ui/canonical_navigation_integration_test.exs b/packages/unified-ui/test/unified_ui/canonical_navigation_integration_test.exs index ef8d2bf4..8e02bf34 100644 --- a/packages/unified-ui/test/unified_ui/canonical_navigation_integration_test.exs +++ b/packages/unified-ui/test/unified_ui/canonical_navigation_integration_test.exs @@ -1,7 +1,7 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do use ExUnit.Case, async: true - alias UnifiedUi.{Export, Signals, Tooling} + alias UnifiedUi.{Compiler, Export, Signals, Tooling} defmodule CanonicalNavigationScreen do use UnifiedUi.Dsl @@ -377,6 +377,50 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do refute guide =~ "target_intent(binding: :active_tab, route: :activity)" end + test "compiles canonical navigation screens into stable IUR descriptors and deterministic review exports" do + assert {:ok, first_result} = Compiler.compile(CanonicalNavigationScreen) + assert {:ok, second_result} = Compiler.compile(CanonicalNavigationScreen) + assert {:ok, first_snapshot} = Export.module(CanonicalNavigationScreen, :snapshot) + assert {:ok, second_snapshot} = Export.module(CanonicalNavigationScreen, :snapshot) + + assert first_result.interactions == second_result.interactions + assert first_snapshot == second_snapshot + + targets = + Map.new(first_result.interactions, fn interaction -> + {interaction.intent, interaction.target} + end) + + assert targets[:open_settings_screen] == %{ + navigation: %{ + action: :navigate_to, + kind: :screen_transition, + params: %{tab: :profile}, + screen: :settings + } + } + + assert targets[:go_back_history] == %{ + navigation: %{ + action: :go_back, + kind: :history_transition, + metadata: %{source: :header} + } + } + + assert targets[:close_settings_modal] == %{ + navigation: %{ + action: :close_modal, + kind: :modal_transition, + metadata: %{reason: :done}, + modal: :settings_dialog + } + } + + refute first_snapshot =~ "route:" + refute first_snapshot =~ "\"/settings\"" + end + defp compile_module(body) do module_name = "Generated#{System.unique_integer([:positive])}" diff --git a/packages/unified_iur/test/unified_iur/canonical_navigation_boundary_integration_test.exs b/packages/unified_iur/test/unified_iur/canonical_navigation_boundary_integration_test.exs new file mode 100644 index 00000000..3eb1915b --- /dev/null +++ b/packages/unified_iur/test/unified_iur/canonical_navigation_boundary_integration_test.exs @@ -0,0 +1,88 @@ +defmodule UnifiedIUR.CanonicalNavigationBoundaryIntegrationTest do + use ExUnit.Case, async: true + + alias UnifiedIUR.Interactions.Transport + + test "shared boundary fixtures preserve canonical transition meaning deterministically" do + first = + Transport.boundary_fixtures() + |> Enum.map(&Map.take(&1, [:id, :signal_data, :extensions, :summary])) + + second = + Transport.boundary_fixtures() + |> Enum.map(&Map.take(&1, [:id, :signal_data, :extensions, :summary])) + + assert first == second + + assert Enum.all?( + Transport.boundary_fixtures(), + &(Transport.validate_boundary_fixture(&1) == :ok) + ) + + assert Enum.find(first, &(&1.id == "screen_transition--settings_profile")) == %{ + id: "screen_transition--settings_profile", + signal_data: %{mapping: %{origin: :workspace}}, + extensions: %{ + unified_iur_boundary: %{ + family: :navigation, + intent: :open_settings_screen, + source_context: %{element_id: "settings-link", scope: :screen}, + target: %{ + navigation: %{ + action: :navigate_to, + kind: :screen_transition, + params: %{tab: :profile}, + screen: :settings + } + }, + metadata: %{} + }, + unified_iur_boundary_summary: %{ + family: :navigation, + intent: :open_settings_screen, + action: :navigate_to, + screen: :settings, + modal: nil, + params?: true, + targetless?: false + } + }, + summary: %{ + family: :navigation, + intent: :open_settings_screen, + action: :navigate_to, + screen: :settings, + modal: nil, + params?: true, + targetless?: false + } + } + end + + test "shared boundary validation rejects malformed payloads and leaked route syntax" do + assert {:error, {:invalid_boundary_payload, "oops"}} = + Transport.validate_boundary_payload("oops") + + assert {:error, {:forbidden_navigation_keys, [:route]}} = + Transport.validate_boundary_extensions(%{ + unified_iur_boundary: %{ + family: :navigation, + intent: :open_settings_screen, + source_context: %{element_id: "settings-link"}, + target: %{ + navigation: %{action: :navigate_to, screen: :settings, route: "/settings"} + }, + metadata: %{} + }, + unified_iur_boundary_summary: %{ + family: :navigation, + intent: :open_settings_screen, + action: :navigate_to, + screen: :settings, + modal: nil, + params?: false, + targetless?: false + } + }) + end +end