From f60c397b10a00d80a807d2309069df8c0f2a91d9 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Sat, 25 Apr 2026 13:06:59 -0400 Subject: [PATCH 1/4] Implement canonical navigation phase 1 section 1.1 --- ...igation-surface-and-descriptor-backbone.md | 105 +++++++++ packages/unified-ui/lib/unified_ui/signal.ex | 123 ++++++++++- packages/unified-ui/lib/unified_ui/signals.ex | 27 +++ .../unified_ui/phase_4_integration_test.exs | 4 +- .../test/unified_ui/signals_test.exs | 206 +++++++++++++++++- 5 files changed, 456 insertions(+), 9 deletions(-) create mode 100644 .spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md diff --git a/.spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md b/.spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md new file mode 100644 index 00000000..5f3b60c7 --- /dev/null +++ b/.spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md @@ -0,0 +1,105 @@ +# Phase 1 - UnifiedUi Authored Navigation Surface and Descriptor Backbone + +Back to index: [README](./README.md) + +## Relevant Shared APIs / Interfaces +- `UnifiedUi.Signal` +- `UnifiedUi.Dsl` +- `UnifiedUi.Dsl.Sections.Signals` +- `UnifiedUi.Compiler` +- `UnifiedUi.Tooling` + +## Relevant Assumptions / Defaults +- Canonical navigation remains inside the `signals` authoring surface rather + than becoming a new top-level `routing` section. +- Authored screen transitions use canonical actions such as `navigate_to`, + `replace_with`, `go_back`, `go_forward`, `open_modal`, and `close_modal`. +- Top-level screen transitions target symbolic screen ids with optional params + and metadata rather than URLs or host-router helpers. +- In-screen destination changes may remain authored as local navigation intent + without being treated as host-style route changes. + +[ ] 1 Phase 1 - UnifiedUi Authored Navigation Surface and Descriptor Backbone + Implement the authored screen-transition model inside `unified_ui` so + developers can declare cross-runtime navigation intent without leaking host + routing syntax into the DSL. + + [x] 1.1 Section - Authored Navigation Action Vocabulary + Implement the canonical navigation vocabulary and target-intent shape that + developers use when declaring top-level screen transitions. + + [x] 1.1.1 Task - Define the canonical authored transition fields + Establish the authored descriptor fields and allowed action set for + top-level screen-transition intent. + + [x] 1.1.1.1 Subtask - Define how `action`, `screen`, `params`, `metadata`, and modal-oriented target fields appear in authored `target_intent` values. + [x] 1.1.1.2 Subtask - Define which actions require a symbolic `screen` target and which actions, such as `go_back` or `close_modal`, are targetless. + [x] 1.1.1.3 Subtask - Define how modal transitions and replacement transitions are distinguished from ordinary history-push transitions. + + [x] 1.1.2 Task - Distinguish screen transitions from in-screen navigation + Keep local destination changes available without conflating them with + cross-screen transition semantics. + + [x] 1.1.2.1 Subtask - Define the authored distinction between in-screen destination updates, such as tab or section changes, and top-level screen transitions. + [x] 1.1.2.2 Subtask - Ensure canonical navigation examples demonstrate both local destination changes and top-level screen transitions without blurring their semantics. + [x] 1.1.2.3 Subtask - Define how existing generic interaction descriptors continue to work when no top-level screen transition is intended. + + [ ] 1.2 Section - Validation, Diagnostics, and Introspection + Implement the validation and inspection surfaces that keep the authored + navigation contract explicit, deterministic, and reviewable. + + [ ] 1.2.1 Task - Validate authored screen-transition intent + Reject malformed or host-specific navigation declarations at authoring + time. + + [ ] 1.2.1.1 Subtask - Reject URLs, Phoenix route helpers, browser-history instructions, and runtime-module identifiers in canonical screen-transition declarations. + [ ] 1.2.1.2 Subtask - Reject malformed action names, missing required screen targets, and invalid modal-target combinations. + [ ] 1.2.1.3 Subtask - Emit actionable diagnostics that explain whether the author attempted a top-level screen transition, a local destination change, or an unsupported host-specific route declaration. + + [ ] 1.2.2 Task - Expose navigation inspection helpers + Make authored navigation intent visible through package tooling before a + runtime is involved. + + [ ] 1.2.2.1 Subtask - Update inspection helpers to show canonical navigation actions, symbolic screen targets, params, and modal-oriented targets. + [ ] 1.2.2.2 Subtask - Expose helper surfaces that list the supported navigation actions and their required authored fields. + [ ] 1.2.2.3 Subtask - Ensure navigation descriptors remain deterministic in inspection and export output so diffs stay stable and review-friendly. + + [ ] 1.3 Section - Author-Facing Examples and Guidance + Implement maintained examples and guidance that teach developers how to use + the new authored navigation contract correctly. + + [ ] 1.3.1 Task - Add maintained navigation authoring examples + Provide canonical examples that demonstrate the supported authored + navigation patterns. + + [ ] 1.3.1.1 Subtask - Add an example that shows an in-screen destination change, such as a tab switch, without using screen-transition fields. + [ ] 1.3.1.2 Subtask - Add an example that shows a top-level screen transition using a symbolic screen id and params. + [ ] 1.3.1.3 Subtask - Add an example that shows modal open and close transitions as canonical navigation actions. + + [ ] 1.3.2 Task - Update foundational navigation guidance + Document the authored mental model so developers understand what the DSL + owns and what runtimes still own. + + [ ] 1.3.2.1 Subtask - Explain that `UnifiedUi` owns screen-transition intent rather than router tables or URL semantics. + [ ] 1.3.2.2 Subtask - Explain the difference between `screen` targets, local destinations, and host-runtime route resolution. + [ ] 1.3.2.3 Subtask - Explain how authored canonical navigation remains portable across web, desktop, and terminal runtimes. + + [ ] 1.4 Section - Phase 1 Integration Tests + Validate the authored navigation surface, diagnostics, and inspection + output end to end inside `unified_ui`. + + [ ] 1.4.1 Task - Authored descriptor and validation scenarios + Verify `unified_ui` accepts valid canonical transitions and rejects + host-specific navigation leakage deterministically. + + [ ] 1.4.1.1 Subtask - Verify authored `navigate_to`, `replace_with`, `go_back`, `go_forward`, `open_modal`, and `close_modal` descriptors validate successfully with the expected field requirements. + [ ] 1.4.1.2 Subtask - Verify screen-transition descriptors reject URLs, route helpers, browser-history directives, and runtime-module identifiers. + [ ] 1.4.1.3 Subtask - Verify invalid action and target combinations fail with actionable diagnostics that distinguish malformed screen transitions from local destination changes. + + [ ] 1.4.2 Task - Inspection and example scenarios + Verify maintained examples and tooling reflect canonical navigation + intent clearly before runtime mapping begins. + + [ ] 1.4.2.1 Subtask - Verify inspection and export helpers report navigation action, symbolic screen target, params, and modal target fields deterministically. + [ ] 1.4.2.2 Subtask - Verify the maintained examples cover in-screen destination changes, top-level screen transitions, and modal transitions. + [ ] 1.4.2.3 Subtask - Verify foundational guidance stays aligned with the actual authored navigation surface and does not describe host-router semantics as part of the DSL. diff --git a/packages/unified-ui/lib/unified_ui/signal.ex b/packages/unified-ui/lib/unified_ui/signal.ex index 98c9a8cb..90c2f2ad 100644 --- a/packages/unified-ui/lib/unified_ui/signal.ex +++ b/packages/unified-ui/lib/unified_ui/signal.ex @@ -5,6 +5,28 @@ defmodule UnifiedUi.Signal do alias UnifiedUi.Binding + @type navigation_transition_action :: + :navigate_to + | :replace_with + | :go_back + | :go_forward + | :open_modal + | :close_modal + + @type navigation_target_kind :: + :screen_transition + | :replace_transition + | :history_transition + | :modal_transition + | :local_destination + | :generic + + @type navigation_action_contract :: %{ + kind: navigation_target_kind(), + required_fields: [atom()], + optional_fields: [atom()] + } + @type family :: :click | :change @@ -30,6 +52,41 @@ defmodule UnifiedUi.Signal do } @families [:click, :change, :submit, :open, :close, :focus, :selection, :navigation, :command] + @navigation_action_contracts [ + navigate_to: %{ + kind: :screen_transition, + required_fields: [:screen], + optional_fields: [:params, :metadata] + }, + replace_with: %{ + kind: :replace_transition, + required_fields: [:screen], + optional_fields: [:params, :metadata] + }, + go_back: %{ + kind: :history_transition, + required_fields: [], + optional_fields: [:metadata] + }, + go_forward: %{ + kind: :history_transition, + required_fields: [], + optional_fields: [:metadata] + }, + open_modal: %{ + kind: :modal_transition, + required_fields: [:modal], + optional_fields: [:params, :metadata] + }, + close_modal: %{ + kind: :modal_transition, + required_fields: [], + optional_fields: [:modal, :metadata] + } + ] + @navigation_actions Keyword.keys(@navigation_action_contracts) + @navigation_transition_fields [:action, :screen, :modal, :params, :metadata] + @local_navigation_fields [:binding, :destination] defstruct __identifier__: nil, id: nil, @@ -45,6 +102,33 @@ defmodule UnifiedUi.Signal do @spec families() :: [family()] def families, do: @families + @spec navigation_actions() :: [navigation_transition_action()] + def navigation_actions, do: @navigation_actions + + @spec navigation_action_contracts() :: %{ + navigation_transition_action() => navigation_action_contract() + } + def navigation_action_contracts, do: Map.new(@navigation_action_contracts) + + @spec navigation_transition_fields() :: [atom()] + def navigation_transition_fields, do: @navigation_transition_fields + + @spec local_navigation_fields() :: [atom()] + def local_navigation_fields, do: @local_navigation_fields + + @spec navigation_target_kind(t() | keyword() | map()) :: navigation_target_kind() + def navigation_target_kind(%__MODULE__{target_intent: target_intent}), + do: do_navigation_target_kind(target_intent) + + def navigation_target_kind(signal) when is_list(signal), + do: signal |> Enum.into(%{}) |> navigation_target_kind() + + def navigation_target_kind(%{target_intent: target_intent}), + do: do_navigation_target_kind(target_intent) + + def navigation_target_kind(target_intent) when is_map(target_intent), + do: do_navigation_target_kind(target_intent) + @spec new(keyword() | map() | t()) :: t() def new(%__MODULE__{} = signal), do: normalize(signal) def new(signal) when is_list(signal), do: signal |> Enum.into(%{}) |> new() @@ -55,7 +139,7 @@ defmodule UnifiedUi.Signal do family: fetch(signal, :family, :click), intent: fetch(signal, :intent), source_context: signal |> fetch(:source_context, %{}) |> normalize_map(), - target_intent: signal |> fetch(:target_intent, %{}) |> normalize_map(), + target_intent: signal |> fetch(:target_intent, %{}) |> normalize_target_intent(), payload_mapping: signal |> fetch(:payload_mapping, %{}) |> normalize_map(), binding_refs: signal |> fetch(:binding_refs, []) |> normalize_binding_refs(), summary: fetch(signal, :summary), @@ -86,13 +170,29 @@ defmodule UnifiedUi.Signal do %__MODULE__{ signal | source_context: normalize_map(signal.source_context), - target_intent: normalize_map(signal.target_intent), + target_intent: normalize_target_intent(signal.target_intent), payload_mapping: normalize_map(signal.payload_mapping), binding_refs: normalize_binding_refs(signal.binding_refs), metadata: normalize_map(signal.metadata) } end + defp do_navigation_target_kind(target_intent) do + target_intent = normalize_target_intent(target_intent) + + case navigation_action_contracts()[fetch(target_intent, :action)] do + %{kind: kind} -> + kind + + nil -> + if fetch(target_intent, :binding) != nil and fetch(target_intent, :destination) != nil do + :local_destination + else + :generic + end + end + end + defp normalize_binding_refs(refs) do refs |> List.wrap() @@ -108,6 +208,25 @@ defmodule UnifiedUi.Signal do end) end + defp normalize_target_intent(target_intent) do + target_intent + |> normalize_map() + |> normalize_nested_target_field(:params) + |> normalize_nested_target_field(:metadata) + end + + defp normalize_nested_target_field(values, key) do + case fetch(values, key) do + nested when is_map(nested) or is_list(nested) -> + values + |> Map.delete(Atom.to_string(key)) + |> Map.put(key, normalize_map(nested)) + + _other -> + values + end + end + 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, %{}) diff --git a/packages/unified-ui/lib/unified_ui/signals.ex b/packages/unified-ui/lib/unified_ui/signals.ex index 48fc1384..5aeaa127 100644 --- a/packages/unified-ui/lib/unified_ui/signals.ex +++ b/packages/unified-ui/lib/unified_ui/signals.ex @@ -11,6 +11,33 @@ defmodule UnifiedUi.Signals do Signal.families() end + @spec navigation_actions() :: [Signal.navigation_transition_action()] + def navigation_actions do + Signal.navigation_actions() + end + + @spec navigation_action_contracts() :: %{ + Signal.navigation_transition_action() => Signal.navigation_action_contract() + } + def navigation_action_contracts do + Signal.navigation_action_contracts() + end + + @spec navigation_transition_fields() :: [atom()] + def navigation_transition_fields do + Signal.navigation_transition_fields() + end + + @spec local_navigation_fields() :: [atom()] + def local_navigation_fields do + Signal.local_navigation_fields() + end + + @spec navigation_target_kind(Signal.t() | map() | keyword()) :: Signal.navigation_target_kind() + def navigation_target_kind(signal) do + Signal.navigation_target_kind(signal) + end + @spec bindings(module()) :: [Binding.t()] def bindings(module) when is_atom(module) do module diff --git a/packages/unified-ui/test/unified_ui/phase_4_integration_test.exs b/packages/unified-ui/test/unified_ui/phase_4_integration_test.exs index 2a3a3c4e..93c7f2d5 100644 --- a/packages/unified-ui/test/unified_ui/phase_4_integration_test.exs +++ b/packages/unified-ui/test/unified_ui/phase_4_integration_test.exs @@ -286,7 +286,7 @@ defmodule UnifiedUi.Phase4IntegrationTest do family(:navigation) intent(:navigate_dashboard) source_context(element_id: :dashboard_tabs) - target_intent(binding: :active_tab, route: :activity) + target_intent(binding: :active_tab, destination: :activity) payload_mapping(tab: binding_ref(:active_tab), destination: :activity) end @@ -479,7 +479,7 @@ defmodule UnifiedUi.Phase4IntegrationTest do family: :navigation, intent: :navigate_dashboard, source_context: %{element_id: :dashboard_tabs}, - target_intent: %{binding: :active_tab, route: :activity}, + target_intent: %{binding: :active_tab, destination: :activity}, payload_mapping: %{ tab: %{kind: :binding_ref, id: :active_tab}, destination: :activity diff --git a/packages/unified-ui/test/unified_ui/signals_test.exs b/packages/unified-ui/test/unified_ui/signals_test.exs index d34e47e5..dbfca3a3 100644 --- a/packages/unified-ui/test/unified_ui/signals_test.exs +++ b/packages/unified-ui/test/unified_ui/signals_test.exs @@ -24,7 +24,19 @@ defmodule UnifiedUi.SignalsTest do button :save_button do label("Save") - interaction_refs([:profile_submit, :open_commands]) + + interaction_refs([ + :profile_submit, + :open_commands, + :open_settings_screen, + :open_settings_modal + ]) + end + + tabs :dashboard_tabs do + items(profile: "Profile", activity: "Activity") + active_item(:profile) + interaction_refs([:navigate_activity]) end end @@ -40,6 +52,13 @@ defmodule UnifiedUi.SignalsTest do derived(%{source: :form_builder}) end + data_binding do + id(:active_tab) + path([:navigation, :active_tab]) + scope([:screen]) + default(:profile) + end + interaction do id(:profile_change) family(:change) @@ -67,16 +86,63 @@ defmodule UnifiedUi.SignalsTest do target_intent(command: :workspace_palette) payload_mapping(source: :keyboard_shortcut) end + + interaction do + id(:navigate_activity) + family(:navigation) + intent(:navigate_profile_workspace) + source_context(element_id: :dashboard_tabs, scope: :screen) + 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: :save_button, scope: :screen) + + target_intent( + action: :navigate_to, + screen: :settings, + params: [tab: :profile], + metadata: [source: :save_button] + ) + + payload_mapping(tab: :profile) + end + + interaction do + id(:open_settings_modal) + family(:navigation) + intent(:open_settings_modal) + source_context(element_id: :save_button, scope: :screen) + + target_intent( + action: :open_modal, + modal: :settings_dialog, + params: [tab: :profile], + metadata: [source: :save_button] + ) + + payload_mapping(tab: :profile) + end end end test "authors canonical bindings and interactions through the signals section" do - assert Enum.map(Signals.bindings(SignalWorkspace), & &1.id) == [:profile_form_data] + assert Enum.map(Signals.bindings(SignalWorkspace), & &1.id) == [ + :profile_form_data, + :active_tab + ] assert Enum.map(Signals.interactions(SignalWorkspace), & &1.id) == [ :profile_change, :profile_submit, - :open_commands + :open_commands, + :navigate_activity, + :open_settings_screen, + :open_settings_modal ] end @@ -93,6 +159,58 @@ defmodule UnifiedUi.SignalsTest do :command ] + assert Signals.navigation_actions() == [ + :navigate_to, + :replace_with, + :go_back, + :go_forward, + :open_modal, + :close_modal + ] + + assert Signals.navigation_transition_fields() == [ + :action, + :screen, + :modal, + :params, + :metadata + ] + + assert Signals.local_navigation_fields() == [:binding, :destination] + + assert Signals.navigation_action_contracts() == %{ + navigate_to: %{ + kind: :screen_transition, + required_fields: [:screen], + optional_fields: [:params, :metadata] + }, + replace_with: %{ + kind: :replace_transition, + required_fields: [:screen], + optional_fields: [:params, :metadata] + }, + go_back: %{ + kind: :history_transition, + required_fields: [], + optional_fields: [:metadata] + }, + go_forward: %{ + kind: :history_transition, + required_fields: [], + optional_fields: [:metadata] + }, + open_modal: %{ + kind: :modal_transition, + required_fields: [:modal], + optional_fields: [:params, :metadata] + }, + close_modal: %{ + kind: :modal_transition, + required_fields: [], + optional_fields: [:modal, :metadata] + } + } + assert Signals.module_summary(SignalWorkspace) == %{ namespace: :workspace, default_target: :session, @@ -116,6 +234,13 @@ defmodule UnifiedUi.SignalsTest do default: %{display_name: "", role: :member}, collection?: false, derived: %{source: :form_builder} + }, + %{ + id: :active_tab, + path: [:navigation, :active_tab], + scope: [:screen], + default: :profile, + collection?: false } ], interactions: [ @@ -149,18 +274,89 @@ defmodule UnifiedUi.SignalsTest do source_context: %{element_id: :save_button}, target_intent: %{command: :workspace_palette}, payload_mapping: %{source: :keyboard_shortcut} + }, + %{ + id: :navigate_activity, + family: :navigation, + intent: :navigate_profile_workspace, + source_context: %{element_id: :dashboard_tabs, scope: :screen}, + target_intent: %{binding: :active_tab, destination: :activity}, + payload_mapping: %{ + tab: %{kind: :binding_ref, id: :active_tab}, + destination: :activity + } + }, + %{ + id: :open_settings_screen, + family: :navigation, + intent: :open_settings_screen, + source_context: %{element_id: :save_button, scope: :screen}, + target_intent: %{ + action: :navigate_to, + screen: :settings, + params: %{tab: :profile}, + metadata: %{source: :save_button} + }, + payload_mapping: %{tab: :profile} + }, + %{ + id: :open_settings_modal, + family: :navigation, + intent: :open_settings_modal, + source_context: %{element_id: :save_button, scope: :screen}, + target_intent: %{ + action: :open_modal, + modal: :settings_dialog, + params: %{tab: :profile}, + metadata: %{source: :save_button} + }, + payload_mapping: %{tab: :profile} } ] } end + test "distinguishes local destinations from top-level screen transitions" do + [ + profile_change, + profile_submit, + open_commands, + navigate_activity, + open_settings_screen, + open_settings_modal + ] = + Signals.interactions(SignalWorkspace) + + assert Signals.navigation_target_kind(profile_change) == :generic + assert Signals.navigation_target_kind(profile_submit) == :generic + assert Signals.navigation_target_kind(open_commands) == :generic + assert Signals.navigation_target_kind(navigate_activity) == :local_destination + assert Signals.navigation_target_kind(open_settings_screen) == :screen_transition + assert Signals.navigation_target_kind(open_settings_modal) == :modal_transition + + assert Signals.navigation_target_kind(%{ + target_intent: %{action: :replace_with, screen: :login} + }) == + :replace_transition + + assert Signals.navigation_target_kind(%{target_intent: %{action: :go_back}}) == + :history_transition + end + test "attaches authored signal and binding references to composition nodes" do - [profile_form, save_button] = Info.composition_nodes(SignalWorkspace) + [profile_form, save_button, dashboard_tabs] = Info.composition_nodes(SignalWorkspace) assert profile_form.binding_refs == [:profile_form_data] assert profile_form.interaction_refs == [:profile_change, :profile_submit] - assert save_button.interaction_refs == [:profile_submit, :open_commands] + assert save_button.interaction_refs == [ + :profile_submit, + :open_commands, + :open_settings_screen, + :open_settings_modal + ] + + assert dashboard_tabs.interaction_refs == [:navigate_activity] end test "exposes canonical signal families through the reference surface" do From aa5b13664861949b2b969538b5c65809d51dbd32 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Sat, 25 Apr 2026 13:14:59 -0400 Subject: [PATCH 2/4] Implement canonical navigation phase 1 section 1.2 --- ...igation-surface-and-descriptor-backbone.md | 18 +- .../verifiers/validate_themes_and_signals.ex | 297 ++++++++++++++++++ .../unified-ui/lib/unified_ui/reference.ex | 18 ++ packages/unified-ui/lib/unified_ui/tooling.ex | 13 +- .../test/unified_ui/reference_test.exs | 46 +++ .../theme_signal_validation_test.exs | 114 +++++++ .../test/unified_ui/tooling_test.exs | 52 +++ 7 files changed, 547 insertions(+), 11 deletions(-) diff --git a/.spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md b/.spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md index 5f3b60c7..35e7a276 100644 --- a/.spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md +++ b/.spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md @@ -44,25 +44,25 @@ Back to index: [README](./README.md) [x] 1.1.2.2 Subtask - Ensure canonical navigation examples demonstrate both local destination changes and top-level screen transitions without blurring their semantics. [x] 1.1.2.3 Subtask - Define how existing generic interaction descriptors continue to work when no top-level screen transition is intended. - [ ] 1.2 Section - Validation, Diagnostics, and Introspection + [x] 1.2 Section - Validation, Diagnostics, and Introspection Implement the validation and inspection surfaces that keep the authored navigation contract explicit, deterministic, and reviewable. - [ ] 1.2.1 Task - Validate authored screen-transition intent + [x] 1.2.1 Task - Validate authored screen-transition intent Reject malformed or host-specific navigation declarations at authoring time. - [ ] 1.2.1.1 Subtask - Reject URLs, Phoenix route helpers, browser-history instructions, and runtime-module identifiers in canonical screen-transition declarations. - [ ] 1.2.1.2 Subtask - Reject malformed action names, missing required screen targets, and invalid modal-target combinations. - [ ] 1.2.1.3 Subtask - Emit actionable diagnostics that explain whether the author attempted a top-level screen transition, a local destination change, or an unsupported host-specific route declaration. + [x] 1.2.1.1 Subtask - Reject URLs, Phoenix route helpers, browser-history instructions, and runtime-module identifiers in canonical screen-transition declarations. + [x] 1.2.1.2 Subtask - Reject malformed action names, missing required screen targets, and invalid modal-target combinations. + [x] 1.2.1.3 Subtask - Emit actionable diagnostics that explain whether the author attempted a top-level screen transition, a local destination change, or an unsupported host-specific route declaration. - [ ] 1.2.2 Task - Expose navigation inspection helpers + [x] 1.2.2 Task - Expose navigation inspection helpers Make authored navigation intent visible through package tooling before a runtime is involved. - [ ] 1.2.2.1 Subtask - Update inspection helpers to show canonical navigation actions, symbolic screen targets, params, and modal-oriented targets. - [ ] 1.2.2.2 Subtask - Expose helper surfaces that list the supported navigation actions and their required authored fields. - [ ] 1.2.2.3 Subtask - Ensure navigation descriptors remain deterministic in inspection and export output so diffs stay stable and review-friendly. + [x] 1.2.2.1 Subtask - Update inspection helpers to show canonical navigation actions, symbolic screen targets, params, and modal-oriented targets. + [x] 1.2.2.2 Subtask - Expose helper surfaces that list the supported navigation actions and their required authored fields. + [x] 1.2.2.3 Subtask - Ensure navigation descriptors remain deterministic in inspection and export output so diffs stay stable and review-friendly. [ ] 1.3 Section - Author-Facing Examples and Guidance Implement maintained examples and guidance that teach developers how to use diff --git a/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_themes_and_signals.ex b/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_themes_and_signals.ex index 997c457c..b87992c7 100644 --- a/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_themes_and_signals.ex +++ b/packages/unified-ui/lib/unified_ui/dsl/verifiers/validate_themes_and_signals.ex @@ -40,6 +40,34 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateThemesAndSignals do ] @renderer_local_sources [:phoenix, :live_view, :elm, :dom, :js, :sdl] + @host_route_keys [ + :route, + :path, + :url, + :uri, + :href, + :to, + :router, + :router_ref, + :route_helper, + :history, + :browser_history, + :push_patch, + :push_navigate, + "route", + "path", + "url", + "uri", + "href", + "to", + "router", + "router_ref", + "route_helper", + "history", + "browser_history", + "push_patch", + "push_navigate" + ] @allowed_state_keys MapSet.new(Style.component_states()) @spec verify(map()) :: :ok | {:error, Spark.Error.DslError.t()} @@ -510,6 +538,7 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateThemesAndSignals do interaction.id, :payload_mapping ]), + :ok <- validate_navigation_interaction(module, interaction), :ok <- validate_binding_refs(module, interaction.id, interaction.binding_refs, context, [ :signals, @@ -545,6 +574,175 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateThemesAndSignals do defp validate_interaction_target(_module, _interaction), do: :ok + defp validate_navigation_interaction(_module, %Signal{family: family}) + when family != :navigation, + do: :ok + + defp validate_navigation_interaction(module, %Signal{id: id, target_intent: target_intent}) do + with :ok <- validate_host_route_keys(module, id, target_intent), + :ok <- validate_navigation_target_shape(module, id, target_intent) do + :ok + end + end + + defp validate_host_route_keys(_module, _id, values) when values in [%{}, nil], do: :ok + + defp validate_host_route_keys(module, id, values) do + case find_host_route_key(values) do + nil -> + :ok + + bad_key -> + dsl_error( + module, + [:signals, :interaction, id, :target_intent], + "canonical navigation must not declare host-route key #{inspect(bad_key)}; use symbolic :screen targets for top-level transitions or :destination for local navigation instead" + ) + end + end + + defp validate_navigation_target_shape(module, id, target_intent) do + action = target_fetch(target_intent, :action) + contract = Signal.navigation_action_contracts()[action] + + cond do + contract != nil -> + with :ok <- + validate_navigation_required_fields(module, id, target_intent, action, contract), + :ok <- + validate_navigation_allowed_fields(module, id, target_intent, action, contract), + :ok <- validate_navigation_symbolic_targets(module, id, target_intent, contract) do + :ok + end + + action != nil -> + dsl_error( + module, + [:signals, :interaction, id, :target_intent], + "unsupported navigation action #{inspect(action)}; expected one of #{inspect(Signal.navigation_actions())}" + ) + + Signal.navigation_target_kind(target_intent) == :local_destination -> + with :ok <- validate_navigation_allowed_fields(module, id, target_intent, nil, nil), + :ok <- validate_navigation_symbolic_destination(module, id, target_intent) do + :ok + end + + true -> + dsl_error( + module, + [:signals, :interaction, id, :target_intent], + "navigation interaction must declare either a local destination pair (:binding and :destination) or a supported transition action #{inspect(Signal.navigation_actions())}" + ) + end + end + + defp validate_navigation_required_fields(module, id, target_intent, action, contract) do + missing = + Enum.reject(contract.required_fields, fn field -> + target_fetch(target_intent, field) != nil + end) + + case missing do + [] -> + :ok + + _missing -> + dsl_error( + module, + [:signals, :interaction, id, :target_intent], + "navigation action #{inspect(action)} requires fields #{inspect(contract.required_fields)}" + ) + end + end + + defp validate_navigation_allowed_fields(module, id, target_intent, action, contract) do + keys = target_keys(target_intent) + + allowed_fields = + case {action, contract} do + {nil, nil} -> + [:binding, :destination] + + {_action, %{required_fields: required_fields, optional_fields: optional_fields}} -> + [:action | required_fields ++ optional_fields] + end + + case keys -- allowed_fields do + [] -> + :ok + + disallowed -> + dsl_error( + module, + [:signals, :interaction, id, :target_intent], + "navigation target_intent uses unsupported fields #{inspect(disallowed)} for #{navigation_shape_label(action, contract)}" + ) + end + end + + defp validate_navigation_symbolic_targets(module, id, target_intent, contract) do + fields = + case contract.kind do + kind when kind in [:screen_transition, :replace_transition] -> [:screen] + :modal_transition -> [:modal] + :history_transition -> [] + end + + Enum.reduce_while(fields, :ok, fn field, :ok -> + case validate_symbolic_navigation_identifier( + module, + id, + field, + target_fetch(target_intent, field) + ) do + :ok -> {:cont, :ok} + error -> {:halt, error} + end + end) + end + + defp validate_navigation_symbolic_destination(module, id, target_intent) do + validate_symbolic_navigation_identifier( + module, + id, + :destination, + target_fetch(target_intent, :destination) + ) + end + + defp validate_symbolic_navigation_identifier(_module, _id, _field, nil), do: :ok + + defp validate_symbolic_navigation_identifier(module, id, field, value) + when is_atom(value) or is_binary(value) do + cond do + runtime_module_identifier?(value) -> + dsl_error( + module, + [:signals, :interaction, id, :target_intent, field], + "navigation #{field} must be a symbolic identifier and must not reference a runtime module" + ) + + url_like_identifier?(value) -> + dsl_error( + module, + [:signals, :interaction, id, :target_intent, field], + "navigation #{field} must be a symbolic identifier and must not use URL or path syntax" + ) + + true -> + :ok + end + end + + defp validate_symbolic_navigation_identifier(module, id, field, value) do + dsl_error( + module, + [:signals, :interaction, id, :target_intent, field], + "navigation #{field} must be a symbolic identifier, got #{inspect(value)}" + ) + end + defp validate_runtime_local_keys(_module, values, _path) when values in [%{}, nil], do: :ok defp validate_runtime_local_keys(module, values, path) do @@ -752,6 +950,105 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateThemesAndSignals do end) end + defp find_host_route_key(values) when is_map(values) do + values + |> Enum.find_value(fn {key, value} -> + cond do + key in @host_route_keys -> + key + + is_map(value) or is_list(value) -> + find_host_route_key(value) + + true -> + nil + end + end) + end + + defp find_host_route_key(values) when is_list(values) do + Enum.find_value(values, fn + {key, value} -> + cond do + key in @host_route_keys -> key + is_map(value) or is_list(value) -> find_host_route_key(value) + true -> nil + end + + value when is_map(value) or is_list(value) -> + find_host_route_key(value) + + _value -> + nil + end) + end + + defp target_fetch(source, key) do + Map.get(source, key, Map.get(source, Atom.to_string(key))) + end + + defp target_keys(values) when is_map(values) do + values + |> Map.keys() + |> Enum.map(&normalize_target_key/1) + |> Enum.uniq() + end + + defp navigation_shape_label(nil, nil), do: "a local navigation destination" + + defp navigation_shape_label(action, _contract), + do: "navigation action #{inspect(action)}" + + defp runtime_module_identifier?(value) when is_atom(value) do + value + |> Atom.to_string() + |> String.starts_with?("Elixir.") + end + + defp runtime_module_identifier?(_value), do: false + + defp url_like_identifier?(value) when is_atom(value) do + value + |> Atom.to_string() + |> url_like_identifier?() + end + + defp url_like_identifier?(value) when is_binary(value) do + String.starts_with?(value, "/") or + String.starts_with?(value, "http://") or + String.starts_with?(value, "https://") + end + + defp url_like_identifier?(_value), do: false + + defp normalize_target_key(key) when is_binary(key) do + case key do + "action" -> :action + "screen" -> :screen + "modal" -> :modal + "params" -> :params + "metadata" -> :metadata + "binding" -> :binding + "destination" -> :destination + "route" -> :route + "path" -> :path + "url" -> :url + "uri" -> :uri + "href" -> :href + "to" -> :to + "router" -> :router + "router_ref" -> :router_ref + "route_helper" -> :route_helper + "history" -> :history + "browser_history" -> :browser_history + "push_patch" -> :push_patch + "push_navigate" -> :push_navigate + other -> other + end + end + + defp normalize_target_key(key), do: key + defp ref_kind(:interaction_refs), do: "interactions" defp ref_kind(:binding_refs), do: "bindings" diff --git a/packages/unified-ui/lib/unified_ui/reference.ex b/packages/unified-ui/lib/unified_ui/reference.ex index 24f291b2..c615d139 100644 --- a/packages/unified-ui/lib/unified_ui/reference.ex +++ b/packages/unified-ui/lib/unified_ui/reference.ex @@ -128,6 +128,24 @@ defmodule UnifiedUi.Reference do Signal.families() end + @spec navigation_actions() :: [Signal.navigation_transition_action()] + def navigation_actions do + Signal.navigation_actions() + end + + @spec navigation_contract() :: %{ + transition_fields: [atom()], + local_navigation_fields: [atom()], + actions: %{Signal.navigation_transition_action() => Signal.navigation_action_contract()} + } + def navigation_contract do + %{ + transition_fields: Signal.navigation_transition_fields(), + local_navigation_fields: Signal.local_navigation_fields(), + actions: Signal.navigation_action_contracts() + } + end + @spec compiled_signal_families() :: [UnifiedIUR.Interaction.family()] def compiled_signal_families do UnifiedIUR.Interaction.families() diff --git a/packages/unified-ui/lib/unified_ui/tooling.ex b/packages/unified-ui/lib/unified_ui/tooling.ex index b9a73fe4..9a0469a8 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} + alias UnifiedUi.{Compiler, Examples, Export, Info, Signals} @shared_specs [ ".spec/specs/architecture.spec.md", @@ -202,7 +202,8 @@ defmodule UnifiedUi.Tooling do "related examples: #{inspect(diagnostics.related_examples)}", "related specs: #{inspect(diagnostics.related_specs)}", "signal families: #{inspect(diagnostics.signal_coverage.families)}", - "binding names: #{inspect(diagnostics.signal_coverage.binding_names)}" + "binding names: #{inspect(diagnostics.signal_coverage.binding_names)}", + "navigation target kinds: #{inspect(diagnostics.signal_coverage.interaction_target_kinds)}" ] |> Enum.join("\n") end @@ -321,6 +322,14 @@ defmodule UnifiedUi.Tooling do |> Enum.map(& &1.family) |> Enum.uniq() |> Enum.sort(), + interaction_target_kinds: + signal_catalog.interactions + |> Enum.map(fn interaction -> + {interaction.id, Signals.navigation_target_kind(interaction)} + end) + |> Enum.into(%{}), + navigation_actions: Signals.navigation_actions(), + navigation_contract: UnifiedUi.Reference.navigation_contract(), target_bindings: signal_catalog.interactions |> Enum.flat_map(fn interaction -> diff --git a/packages/unified-ui/test/unified_ui/reference_test.exs b/packages/unified-ui/test/unified_ui/reference_test.exs index 4e803248..10d2ece1 100644 --- a/packages/unified-ui/test/unified_ui/reference_test.exs +++ b/packages/unified-ui/test/unified_ui/reference_test.exs @@ -84,5 +84,51 @@ defmodule UnifiedUi.ReferenceTest do :disabled, :active ] + + assert UnifiedUi.Reference.navigation_actions() == [ + :navigate_to, + :replace_with, + :go_back, + :go_forward, + :open_modal, + :close_modal + ] + + assert UnifiedUi.Reference.navigation_contract() == %{ + transition_fields: [:action, :screen, :modal, :params, :metadata], + local_navigation_fields: [:binding, :destination], + actions: %{ + navigate_to: %{ + kind: :screen_transition, + required_fields: [:screen], + optional_fields: [:params, :metadata] + }, + replace_with: %{ + kind: :replace_transition, + required_fields: [:screen], + optional_fields: [:params, :metadata] + }, + go_back: %{ + kind: :history_transition, + required_fields: [], + optional_fields: [:metadata] + }, + go_forward: %{ + kind: :history_transition, + required_fields: [], + optional_fields: [:metadata] + }, + open_modal: %{ + kind: :modal_transition, + required_fields: [:modal], + optional_fields: [:params, :metadata] + }, + close_modal: %{ + kind: :modal_transition, + required_fields: [], + optional_fields: [:modal, :metadata] + } + } + } end end diff --git a/packages/unified-ui/test/unified_ui/theme_signal_validation_test.exs b/packages/unified-ui/test/unified_ui/theme_signal_validation_test.exs index 8778e0bf..acdb9a97 100644 --- a/packages/unified-ui/test/unified_ui/theme_signal_validation_test.exs +++ b/packages/unified-ui/test/unified_ui/theme_signal_validation_test.exs @@ -185,6 +185,120 @@ defmodule UnifiedUi.ThemeSignalValidationTest do ) end + test "rejects host-route leakage and malformed canonical navigation targets" do + assert_compile_dsl_error( + """ + identity do + id(:host_route_navigation_screen) + end + + composition do + root(:host_route_navigation_root) + end + + signals do + interaction do + id(:navigate_activity) + family(:navigation) + intent(:navigate_activity) + target_intent(binding: :active_tab, route: :activity) + end + end + """, + "canonical navigation must not declare host-route key :route" + ) + + assert_compile_dsl_error( + """ + identity do + id(:missing_screen_target_screen) + end + + composition do + root(:missing_screen_target_root) + end + + signals do + interaction do + id(:open_settings) + family(:navigation) + intent(:open_settings) + target_intent(action: :navigate_to) + end + end + """, + "navigation action :navigate_to requires fields [:screen]" + ) + + assert_compile_dsl_error( + """ + identity do + id(:invalid_modal_navigation_screen) + end + + composition do + root(:invalid_modal_navigation_root) + end + + signals do + interaction do + id(:open_settings) + family(:navigation) + intent(:open_settings) + target_intent(action: :open_modal, modal: :settings_dialog, screen: :settings) + end + end + """, + "unsupported fields [:screen] for navigation action :open_modal" + ) + end + + test "rejects url-like and runtime-module navigation identifiers" do + assert_compile_dsl_error( + """ + identity do + id(:url_navigation_screen) + end + + composition do + root(:url_navigation_root) + end + + signals do + interaction do + id(:open_settings) + family(:navigation) + intent(:open_settings) + target_intent(action: :navigate_to, screen: \"/settings\") + end + end + """, + "navigation screen must be a symbolic identifier and must not use URL or path syntax" + ) + + assert_compile_dsl_error( + """ + identity do + id(:module_navigation_screen) + end + + composition do + root(:module_navigation_root) + end + + signals do + interaction do + id(:open_settings) + family(:navigation) + intent(:open_settings) + target_intent(action: :navigate_to, screen: UnifiedUi.Signal) + end + end + """, + "navigation screen must be a symbolic identifier and must not reference a runtime module" + ) + end + defp compile_module(body) do module_name = "Generated#{System.unique_integer([:positive])}" diff --git a/packages/unified-ui/test/unified_ui/tooling_test.exs b/packages/unified-ui/test/unified_ui/tooling_test.exs index 3cb5836d..c1124005 100644 --- a/packages/unified-ui/test/unified_ui/tooling_test.exs +++ b/packages/unified-ui/test/unified_ui/tooling_test.exs @@ -34,6 +34,57 @@ defmodule UnifiedUi.ToolingTest do :open_settings ], families: [:change, :command, :navigation, :open, :submit], + interaction_target_kinds: %{ + filters_change: :generic, + filters_submit: :generic, + navigate_activity: :local_destination, + open_commands: :generic, + open_settings: :generic + }, + navigation_actions: [ + :navigate_to, + :replace_with, + :go_back, + :go_forward, + :open_modal, + :close_modal + ], + navigation_contract: %{ + transition_fields: [:action, :screen, :modal, :params, :metadata], + local_navigation_fields: [:binding, :destination], + actions: %{ + navigate_to: %{ + kind: :screen_transition, + required_fields: [:screen], + optional_fields: [:params, :metadata] + }, + replace_with: %{ + kind: :replace_transition, + required_fields: [:screen], + optional_fields: [:params, :metadata] + }, + go_back: %{ + kind: :history_transition, + required_fields: [], + optional_fields: [:metadata] + }, + go_forward: %{ + kind: :history_transition, + required_fields: [], + optional_fields: [:metadata] + }, + open_modal: %{ + kind: :modal_transition, + required_fields: [:modal], + optional_fields: [:params, :metadata] + }, + close_modal: %{ + kind: :modal_transition, + required_fields: [], + optional_fields: [:modal, :metadata] + } + } + }, target_bindings: [:active_tab, :filters] } @@ -101,5 +152,6 @@ defmodule UnifiedUi.ToolingTest do assert rendered =~ "status: ok" assert rendered =~ "related specs:" assert rendered =~ "signal families:" + assert rendered =~ "navigation target kinds:" end end From 715fa1fe193120bf1970334150dc9e60ef2778dd Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Sat, 25 Apr 2026 13:20:43 -0400 Subject: [PATCH 3/4] Implement canonical navigation phase 1 section 1.3 --- ...igation-surface-and-descriptor-backbone.md | 18 +++---- .../docs/user/bindings-and-interactions.md | 52 ++++++++++++++++--- .../examples/themed_signal_workspace.ex | 40 +++++++++++--- .../test/unified_ui/tooling_test.exs | 10 ++-- .../test/unified_ui/validation_test.exs | 4 +- 5 files changed, 97 insertions(+), 27 deletions(-) diff --git a/.spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md b/.spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md index 35e7a276..45004d73 100644 --- a/.spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md +++ b/.spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md @@ -64,25 +64,25 @@ Back to index: [README](./README.md) [x] 1.2.2.2 Subtask - Expose helper surfaces that list the supported navigation actions and their required authored fields. [x] 1.2.2.3 Subtask - Ensure navigation descriptors remain deterministic in inspection and export output so diffs stay stable and review-friendly. - [ ] 1.3 Section - Author-Facing Examples and Guidance + [x] 1.3 Section - Author-Facing Examples and Guidance Implement maintained examples and guidance that teach developers how to use the new authored navigation contract correctly. - [ ] 1.3.1 Task - Add maintained navigation authoring examples + [x] 1.3.1 Task - Add maintained navigation authoring examples Provide canonical examples that demonstrate the supported authored navigation patterns. - [ ] 1.3.1.1 Subtask - Add an example that shows an in-screen destination change, such as a tab switch, without using screen-transition fields. - [ ] 1.3.1.2 Subtask - Add an example that shows a top-level screen transition using a symbolic screen id and params. - [ ] 1.3.1.3 Subtask - Add an example that shows modal open and close transitions as canonical navigation actions. + [x] 1.3.1.1 Subtask - Add an example that shows an in-screen destination change, such as a tab switch, without using screen-transition fields. + [x] 1.3.1.2 Subtask - Add an example that shows a top-level screen transition using a symbolic screen id and params. + [x] 1.3.1.3 Subtask - Add an example that shows modal open and close transitions as canonical navigation actions. - [ ] 1.3.2 Task - Update foundational navigation guidance + [x] 1.3.2 Task - Update foundational navigation guidance Document the authored mental model so developers understand what the DSL owns and what runtimes still own. - [ ] 1.3.2.1 Subtask - Explain that `UnifiedUi` owns screen-transition intent rather than router tables or URL semantics. - [ ] 1.3.2.2 Subtask - Explain the difference between `screen` targets, local destinations, and host-runtime route resolution. - [ ] 1.3.2.3 Subtask - Explain how authored canonical navigation remains portable across web, desktop, and terminal runtimes. + [x] 1.3.2.1 Subtask - Explain that `UnifiedUi` owns screen-transition intent rather than router tables or URL semantics. + [x] 1.3.2.2 Subtask - Explain the difference between `screen` targets, local destinations, and host-runtime route resolution. + [x] 1.3.2.3 Subtask - Explain how authored canonical navigation remains portable across web, desktop, and terminal runtimes. [ ] 1.4 Section - Phase 1 Integration Tests Validate the authored navigation surface, diagnostics, and inspection diff --git a/packages/unified-ui/docs/user/bindings-and-interactions.md b/packages/unified-ui/docs/user/bindings-and-interactions.md index 85d0a0a8..0435b391 100644 --- a/packages/unified-ui/docs/user/bindings-and-interactions.md +++ b/packages/unified-ui/docs/user/bindings-and-interactions.md @@ -106,6 +106,22 @@ The canonical interaction families currently supported are: - `:navigation` - `:command` +## Canonical Navigation Model + +`UnifiedUi` owns portable navigation intent, not host-router configuration. + +- Use `binding` plus `destination` when the user stays inside the current + screen and only a local section, tab, or panel changes. +- Use `action` plus `screen` when the user transitions to another top-level + screen. +- Use `action` plus `modal` when the user opens or closes a modal surface. +- Keep URL paths, Phoenix route helpers, browser-history directives, and + runtime module names out of `target_intent`. + +Runtimes still own resolution. A web runtime may map `screen: :settings` to a +route, `desktop_ui` may map it to a registered window-local screen, and +`terminal_ui` may map it to a screen swap or bounded history state. + ## Common Patterns ### Form Change and Submit @@ -121,7 +137,7 @@ interaction do end ``` -### Navigation +### In-Screen Navigation ```elixir interaction do @@ -129,22 +145,44 @@ interaction do family(:navigation) intent(:navigate_dashboard) source_context(element_id: :dashboard_tabs) - target_intent(binding: :active_tab, route: :activity) + target_intent(binding: :active_tab, destination: :activity) payload_mapping(tab: binding_ref(:active_tab), destination: :activity) end ``` -### Overlay Open +### Screen Transition + +```elixir +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 +``` + +### Modal Transitions ```elixir interaction do id(:open_settings) - family(:open) - intent(:open_settings) - source_context(element_id: :open_settings_button) - target_intent(overlay: :settings_dialog) + family(:navigation) + intent(:open_settings_modal) + source_context(element_id: :open_settings_button, scope: :screen) + target_intent(action: :open_modal, modal: :settings_dialog, params: %{source: :button}) payload_mapping(source: :button) end + +interaction do + id(:close_settings_modal) + family(:navigation) + intent(:close_settings_modal) + source_context(element_id: :close_settings_button, scope: :screen) + target_intent(action: :close_modal, modal: :settings_dialog, metadata: %{reason: :done}) + payload_mapping(reason: :done) +end ``` ## Canonical, Not Renderer-Local diff --git a/packages/unified-ui/lib/unified_ui/examples/themed_signal_workspace.ex b/packages/unified-ui/lib/unified_ui/examples/themed_signal_workspace.ex index 4548f502..9d353ee6 100644 --- a/packages/unified-ui/lib/unified_ui/examples/themed_signal_workspace.ex +++ b/packages/unified-ui/lib/unified_ui/examples/themed_signal_workspace.ex @@ -121,6 +121,11 @@ defmodule UnifiedUi.Examples.ThemedSignalWorkspace do value("Workspace settings") variant(:headline) end + + button :close_settings_button do + label("Close settings modal") + interaction_refs([:close_settings_modal]) + end end row :workspace_shell do @@ -173,6 +178,11 @@ defmodule UnifiedUi.Examples.ThemedSignalWorkspace do interaction_refs([:navigate_activity]) end + button :open_settings_screen_button do + label("Go to settings screen") + interaction_refs([:open_settings_screen]) + end + gauge :health_gauge do current(82) minimum(0) @@ -182,7 +192,7 @@ defmodule UnifiedUi.Examples.ThemedSignalWorkspace do end button :open_settings_button do - label("Open settings") + label("Open settings modal") interaction_refs([:open_settings]) style_refs([:command_action]) end @@ -286,19 +296,37 @@ defmodule UnifiedUi.Examples.ThemedSignalWorkspace do family(:navigation) intent(:navigate_dashboard) source_context(element_id: :dashboard_tabs) - target_intent(binding: :active_tab, route: :activity) + 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: :open_settings_screen_button, scope: :screen) + target_intent(action: :navigate_to, screen: :settings, params: %{tab: :profile}) + payload_mapping(tab: :profile) + end + interaction do id(:open_settings) - family(:open) - intent(:open_settings) - source_context(element_id: :open_settings_button) - target_intent(overlay: :settings_dialog) + family(:navigation) + intent(:open_settings_modal) + source_context(element_id: :open_settings_button, scope: :screen) + target_intent(action: :open_modal, modal: :settings_dialog, params: %{source: :button}) payload_mapping(source: :button) end + interaction do + id(:close_settings_modal) + family(:navigation) + intent(:close_settings_modal) + source_context(element_id: :close_settings_button, scope: :screen) + target_intent(action: :close_modal, modal: :settings_dialog, metadata: %{reason: :done}) + payload_mapping(reason: :done) + end + interaction do id(:open_commands) family(:command) diff --git a/packages/unified-ui/test/unified_ui/tooling_test.exs b/packages/unified-ui/test/unified_ui/tooling_test.exs index c1124005..42a1955b 100644 --- a/packages/unified-ui/test/unified_ui/tooling_test.exs +++ b/packages/unified-ui/test/unified_ui/tooling_test.exs @@ -27,19 +27,23 @@ defmodule UnifiedUi.ToolingTest do mode: :canonical, binding_names: [:active_tab, :filters], interaction_ids: [ + :close_settings_modal, :filters_change, :filters_submit, :navigate_activity, :open_commands, - :open_settings + :open_settings, + :open_settings_screen ], - families: [:change, :command, :navigation, :open, :submit], + families: [:change, :command, :navigation, :submit], interaction_target_kinds: %{ filters_change: :generic, filters_submit: :generic, navigate_activity: :local_destination, + open_settings_screen: :screen_transition, open_commands: :generic, - open_settings: :generic + open_settings: :modal_transition, + close_settings_modal: :modal_transition }, navigation_actions: [ :navigate_to, diff --git a/packages/unified-ui/test/unified_ui/validation_test.exs b/packages/unified-ui/test/unified_ui/validation_test.exs index 8cf83f92..7c42ff09 100644 --- a/packages/unified-ui/test/unified_ui/validation_test.exs +++ b/packages/unified-ui/test/unified_ui/validation_test.exs @@ -55,10 +55,10 @@ defmodule UnifiedUi.ValidationTest do assert report.signal_surface == %{ example_ids_with_signals: [:themed_signal_workspace], - families: [:change, :command, :navigation, :open, :submit], + families: [:change, :command, :navigation, :submit], canonical_only?: true, total_bindings: 2, - total_interactions: 5 + total_interactions: 7 } assert report.documentation_surface.complete? From 3d5ee84ec57fae437ccf128c4017f9aae23a094d Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Sat, 25 Apr 2026 13:24:37 -0400 Subject: [PATCH 4/4] Implement canonical navigation phase 1 section 1.4 --- ...igation-surface-and-descriptor-backbone.md | 18 +- .../canonical_navigation_integration_test.exs | 403 ++++++++++++++++++ 2 files changed, 412 insertions(+), 9 deletions(-) create mode 100644 packages/unified-ui/test/unified_ui/canonical_navigation_integration_test.exs diff --git a/.spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md b/.spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md index 45004d73..f2d8a841 100644 --- a/.spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md +++ b/.spec/planning/canonical_navigation/phase-01-unified-ui-authored-navigation-surface-and-descriptor-backbone.md @@ -84,22 +84,22 @@ Back to index: [README](./README.md) [x] 1.3.2.2 Subtask - Explain the difference between `screen` targets, local destinations, and host-runtime route resolution. [x] 1.3.2.3 Subtask - Explain how authored canonical navigation remains portable across web, desktop, and terminal runtimes. - [ ] 1.4 Section - Phase 1 Integration Tests + [x] 1.4 Section - Phase 1 Integration Tests Validate the authored navigation surface, diagnostics, and inspection output end to end inside `unified_ui`. - [ ] 1.4.1 Task - Authored descriptor and validation scenarios + [x] 1.4.1 Task - Authored descriptor and validation scenarios Verify `unified_ui` accepts valid canonical transitions and rejects host-specific navigation leakage deterministically. - [ ] 1.4.1.1 Subtask - Verify authored `navigate_to`, `replace_with`, `go_back`, `go_forward`, `open_modal`, and `close_modal` descriptors validate successfully with the expected field requirements. - [ ] 1.4.1.2 Subtask - Verify screen-transition descriptors reject URLs, route helpers, browser-history directives, and runtime-module identifiers. - [ ] 1.4.1.3 Subtask - Verify invalid action and target combinations fail with actionable diagnostics that distinguish malformed screen transitions from local destination changes. + [x] 1.4.1.1 Subtask - Verify authored `navigate_to`, `replace_with`, `go_back`, `go_forward`, `open_modal`, and `close_modal` descriptors validate successfully with the expected field requirements. + [x] 1.4.1.2 Subtask - Verify screen-transition descriptors reject URLs, route helpers, browser-history directives, and runtime-module identifiers. + [x] 1.4.1.3 Subtask - Verify invalid action and target combinations fail with actionable diagnostics that distinguish malformed screen transitions from local destination changes. - [ ] 1.4.2 Task - Inspection and example scenarios + [x] 1.4.2 Task - Inspection and example scenarios Verify maintained examples and tooling reflect canonical navigation intent clearly before runtime mapping begins. - [ ] 1.4.2.1 Subtask - Verify inspection and export helpers report navigation action, symbolic screen target, params, and modal target fields deterministically. - [ ] 1.4.2.2 Subtask - Verify the maintained examples cover in-screen destination changes, top-level screen transitions, and modal transitions. - [ ] 1.4.2.3 Subtask - Verify foundational guidance stays aligned with the actual authored navigation surface and does not describe host-router semantics as part of the DSL. + [x] 1.4.2.1 Subtask - Verify inspection and export helpers report navigation action, symbolic screen target, params, and modal target fields deterministically. + [x] 1.4.2.2 Subtask - Verify the maintained examples cover in-screen destination changes, top-level screen transitions, and modal transitions. + [x] 1.4.2.3 Subtask - Verify foundational guidance stays aligned with the actual authored navigation surface and does not describe host-router semantics as part of the DSL. 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 new file mode 100644 index 00000000..ef8d2bf4 --- /dev/null +++ b/packages/unified-ui/test/unified_ui/canonical_navigation_integration_test.exs @@ -0,0 +1,403 @@ +defmodule UnifiedUi.CanonicalNavigationIntegrationTest do + use ExUnit.Case, async: true + + alias UnifiedUi.{Export, Signals, Tooling} + + defmodule CanonicalNavigationScreen do + use UnifiedUi.Dsl + + identity do + id(:canonical_navigation_screen) + title("Canonical Navigation Screen") + authored_ref([:integration, :canonical_navigation_screen]) + tags([:integration, :canonical_navigation]) + end + + composition do + root(:canonical_navigation_root) + mode(:screen) + + box :settings_panel do + text :settings_title do + value("Settings") + end + + button :close_settings_button do + label("Close settings") + interaction_refs([:close_settings_modal]) + end + end + + row :navigation_shell do + tabs :dashboard_tabs do + items(overview: "Overview", activity: "Activity") + active_item(:overview) + interaction_refs([:navigate_activity]) + end + + button :open_settings_screen_button do + label("Go to settings screen") + interaction_refs([:open_settings_screen]) + end + + button :replace_home_button do + label("Replace with home") + interaction_refs([:replace_with_home]) + end + + button :back_button do + label("Back") + interaction_refs([:go_back_history]) + end + + button :forward_button do + label("Forward") + interaction_refs([:go_forward_history]) + end + + button :open_settings_button do + label("Open settings modal") + interaction_refs([:open_settings_modal]) + end + end + + dialog :settings_dialog do + title("Settings") + content_ref(:settings_panel) + trigger_ref(:open_settings_button) + visible?(true) + 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_dashboard) + 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: :open_settings_screen_button, scope: :screen) + target_intent(action: :navigate_to, screen: :settings, params: %{tab: :profile}) + payload_mapping(tab: :profile) + end + + interaction do + id(:replace_with_home) + family(:navigation) + intent(:replace_with_home) + source_context(element_id: :replace_home_button, 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(:go_forward_history) + family(:navigation) + intent(:go_forward_history) + source_context(element_id: :forward_button, scope: :screen) + target_intent(action: :go_forward, 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_button, scope: :screen) + target_intent(action: :open_modal, modal: :settings_dialog, params: %{source: :button}) + payload_mapping(source: :button) + end + + interaction do + id(:close_settings_modal) + family(:navigation) + intent(:close_settings_modal) + source_context(element_id: :close_settings_button, scope: :screen) + target_intent(action: :close_modal, modal: :settings_dialog, metadata: %{reason: :done}) + payload_mapping(reason: :done) + end + end + end + + test "accepts canonical navigation descriptors across transitions, history, and local destinations" do + summary = Signals.module_summary(CanonicalNavigationScreen) + interactions = Map.new(summary.interactions, &{&1.id, &1}) + + assert summary.namespace == :workspace + assert Enum.map(summary.bindings, & &1.id) == [:active_tab] + + assert interactions[:navigate_activity].target_intent == %{ + binding: :active_tab, + destination: :activity + } + + assert interactions[:open_settings_screen].target_intent == %{ + action: :navigate_to, + screen: :settings, + params: %{tab: :profile} + } + + assert interactions[:replace_with_home].target_intent == %{ + action: :replace_with, + screen: :home, + params: %{source: :launcher} + } + + assert interactions[:go_back_history].target_intent == %{ + action: :go_back, + metadata: %{source: :header} + } + + assert interactions[:go_forward_history].target_intent == %{ + action: :go_forward, + metadata: %{source: :header} + } + + assert interactions[:open_settings_modal].target_intent == %{ + action: :open_modal, + modal: :settings_dialog, + params: %{source: :button} + } + + assert interactions[:close_settings_modal].target_intent == %{ + action: :close_modal, + modal: :settings_dialog, + metadata: %{reason: :done} + } + + assert Enum.into(summary.interactions, %{}, fn interaction -> + {interaction.id, Signals.navigation_target_kind(interaction)} + end) == %{ + navigate_activity: :local_destination, + open_settings_screen: :screen_transition, + replace_with_home: :replace_transition, + go_back_history: :history_transition, + go_forward_history: :history_transition, + open_settings_modal: :modal_transition, + close_settings_modal: :modal_transition + } + end + + test "rejects host-specific navigation leakage and malformed authored target shapes" do + assert_compile_dsl_error( + """ + identity do + id(:history_leak_screen) + end + + composition do + root(:history_leak_root) + mode(:screen) + end + + signals do + interaction do + id(:bad_history) + family(:navigation) + intent(:bad_history) + target_intent(history: :back) + end + end + """, + "canonical navigation must not declare host-route key :history" + ) + + assert_compile_dsl_error( + """ + identity do + id(:missing_screen_target_screen) + end + + composition do + root(:missing_screen_target_root) + mode(:screen) + end + + signals do + interaction do + id(:open_settings) + family(:navigation) + intent(:open_settings) + target_intent(action: :navigate_to) + end + end + """, + "navigation action :navigate_to requires fields [:screen]" + ) + + assert_compile_dsl_error( + """ + identity do + id(:url_navigation_screen) + end + + composition do + root(:url_navigation_root) + mode(:screen) + end + + signals do + interaction do + id(:open_settings) + family(:navigation) + intent(:open_settings) + target_intent(action: :navigate_to, screen: "/settings") + end + end + """, + "navigation screen must be a symbolic identifier and must not use URL or path syntax" + ) + + assert_compile_dsl_error( + """ + identity do + id(:module_navigation_screen) + end + + composition do + root(:module_navigation_root) + mode(:screen) + end + + signals do + interaction do + id(:open_settings) + family(:navigation) + intent(:open_settings) + target_intent(action: :navigate_to, screen: UnifiedUi.Signal) + end + end + """, + "navigation screen must be a symbolic identifier and must not reference a runtime module" + ) + + assert_compile_dsl_error( + """ + identity do + id(:ambiguous_local_navigation_screen) + end + + composition do + root(:ambiguous_local_navigation_root) + mode(:screen) + end + + signals do + interaction do + id(:ambiguous_navigation) + family(:navigation) + intent(:ambiguous_navigation) + target_intent(binding: :active_tab) + end + end + """, + "navigation interaction must declare either a local destination pair (:binding and :destination) or a supported transition action" + ) + end + + test "reports canonical navigation intent through export, tooling, and maintained examples" do + assert {:ok, rendered_signals} = Export.module(CanonicalNavigationScreen, :signals) + assert {:ok, rendered_signals_again} = Export.module(CanonicalNavigationScreen, :signals) + + assert rendered_signals == rendered_signals_again + assert rendered_signals =~ "navigate_activity" + assert rendered_signals =~ "destination: :activity" + assert rendered_signals =~ "action: :navigate_to" + assert rendered_signals =~ "screen: :settings" + assert rendered_signals =~ "action: :replace_with" + assert rendered_signals =~ "action: :go_back" + assert rendered_signals =~ "action: :go_forward" + assert rendered_signals =~ "action: :open_modal" + assert rendered_signals =~ "modal: :settings_dialog" + assert rendered_signals =~ "action: :close_modal" + + assert {:ok, report} = Tooling.inspect_example(:themed_signal_workspace) + assert {:ok, example_signals} = Export.example(:themed_signal_workspace, :signals) + + assert report.signal_coverage.interaction_target_kinds[:navigate_activity] == + :local_destination + + assert report.signal_coverage.interaction_target_kinds[:open_settings_screen] == + :screen_transition + + assert report.signal_coverage.interaction_target_kinds[:open_settings] == :modal_transition + + assert report.signal_coverage.interaction_target_kinds[:close_settings_modal] == + :modal_transition + + assert example_signals =~ "open_settings_screen" + assert example_signals =~ "screen: :settings" + assert example_signals =~ "navigate_activity" + assert example_signals =~ "destination: :activity" + assert example_signals =~ "open_settings" + assert example_signals =~ "close_settings_modal" + assert example_signals =~ "modal: :settings_dialog" + end + + test "keeps the user guidance aligned with canonical screen-transition authoring" do + guide = + Path.expand("../../docs/user/bindings-and-interactions.md", __DIR__) + |> File.read!() + + assert guide =~ "`UnifiedUi` owns portable navigation intent, not host-router configuration." + assert guide =~ "Use `binding` plus `destination`" + assert guide =~ "Use `action` plus `screen`" + assert guide =~ "Use `action` plus `modal`" + assert guide =~ "target_intent(binding: :active_tab, destination: :activity)" + + assert guide =~ + "target_intent(action: :navigate_to, screen: :settings, params: %{tab: :profile})" + + assert guide =~ "target_intent(action: :open_modal, modal: :settings_dialog" + refute guide =~ "target_intent(binding: :active_tab, route: :activity)" + end + + defp compile_module(body) do + module_name = "Generated#{System.unique_integer([:positive])}" + + Code.compile_string(""" + defmodule UnifiedUi.CanonicalNavigationIntegrationTest.#{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