From a033a48b09f58b917a16aaa8eb9b2682227c68ef Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 06:59:06 -0400 Subject: [PATCH 01/13] Implement canonical navigation action vocabulary --- ...igation-surface-and-descriptor-backbone.md | 20 ++++++------ .../unified-ui/lib/unified_ui/reference.ex | 2 ++ packages/unified-ui/lib/unified_ui/signal.ex | 32 +++++++++++++++++++ packages/unified-ui/lib/unified_ui/signals.ex | 7 ++++ .../test/unified_ui/reference_test.exs | 18 +++++++++++ .../test/unified_ui/signals_test.exs | 19 +++++++++++ .../test/unified_ui/tooling_test.exs | 18 +++++++++++ 7 files changed, 106 insertions(+), 10 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 bd765953..f715a9be 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 @@ -27,26 +27,26 @@ Back to index: [README](./README.md) developers can declare cross-runtime navigation intent without leaking host routing syntax into the DSL. - [ ] 1.1 Section - Authored Navigation Action Vocabulary + [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. - [ ] 1.1.1 Task - Define the canonical authored transition fields + [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. - [ ] 1.1.1.1 Subtask - Define how `action`, `screen`, `params`, `metadata`, and modal-oriented target fields appear in authored `target_intent` values. - [ ] 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. - [ ] 1.1.1.3 Subtask - Define how modal transitions and replacement transitions are distinguished from ordinary history-push transitions. - [ ] 1.1.1.4 Subtask - Define modal stack authoring rules for `open_modal` push behavior, targetless top-modal `close_modal`, optional named modal close, and the absence of required structural modal containment. + [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.1.4 Subtask - Define modal stack authoring rules for `open_modal` push behavior, targetless top-modal `close_modal`, optional named modal close, and the absence of required structural modal containment. - [ ] 1.1.2 Task - Distinguish screen transitions from in-screen navigation + [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. - [ ] 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. - [ ] 1.1.2.2 Subtask - Ensure canonical navigation examples demonstrate both local destination changes and top-level screen transitions without blurring their semantics. - [ ] 1.1.2.3 Subtask - Define how existing generic interaction descriptors continue to work when no top-level screen transition is intended. + [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 diff --git a/packages/unified-ui/lib/unified_ui/reference.ex b/packages/unified-ui/lib/unified_ui/reference.ex index c615d139..385d7232 100644 --- a/packages/unified-ui/lib/unified_ui/reference.ex +++ b/packages/unified-ui/lib/unified_ui/reference.ex @@ -136,12 +136,14 @@ defmodule UnifiedUi.Reference do @spec navigation_contract() :: %{ transition_fields: [atom()], local_navigation_fields: [atom()], + modal_stack: %{Signal.navigation_transition_action() => Signal.modal_stack_semantics()}, 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(), + modal_stack: Signal.navigation_modal_stack_semantics(), actions: Signal.navigation_action_contracts() } end diff --git a/packages/unified-ui/lib/unified_ui/signal.ex b/packages/unified-ui/lib/unified_ui/signal.ex index 90c2f2ad..82796fc3 100644 --- a/packages/unified-ui/lib/unified_ui/signal.ex +++ b/packages/unified-ui/lib/unified_ui/signal.ex @@ -27,6 +27,15 @@ defmodule UnifiedUi.Signal do optional_fields: [atom()] } + @type modal_stack_semantics :: %{ + operation: :push | :close, + target: :symbolic_modal | :topmost_modal, + target_required?: boolean(), + named_target_allowed?: boolean(), + containment_required?: false, + stack_effect: :push_modal | :close_topmost_or_named_modal + } + @type family :: :click | :change @@ -87,6 +96,24 @@ defmodule UnifiedUi.Signal do @navigation_actions Keyword.keys(@navigation_action_contracts) @navigation_transition_fields [:action, :screen, :modal, :params, :metadata] @local_navigation_fields [:binding, :destination] + @navigation_modal_stack_semantics [ + open_modal: %{ + operation: :push, + target: :symbolic_modal, + target_required?: true, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :push_modal + }, + close_modal: %{ + operation: :close, + target: :topmost_modal, + target_required?: false, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :close_topmost_or_named_modal + } + ] defstruct __identifier__: nil, id: nil, @@ -116,6 +143,11 @@ defmodule UnifiedUi.Signal do @spec local_navigation_fields() :: [atom()] def local_navigation_fields, do: @local_navigation_fields + @spec navigation_modal_stack_semantics() :: %{ + navigation_transition_action() => modal_stack_semantics() + } + def navigation_modal_stack_semantics, do: Map.new(@navigation_modal_stack_semantics) + @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) diff --git a/packages/unified-ui/lib/unified_ui/signals.ex b/packages/unified-ui/lib/unified_ui/signals.ex index 5aeaa127..e215e6a4 100644 --- a/packages/unified-ui/lib/unified_ui/signals.ex +++ b/packages/unified-ui/lib/unified_ui/signals.ex @@ -33,6 +33,13 @@ defmodule UnifiedUi.Signals do Signal.local_navigation_fields() end + @spec navigation_modal_stack_semantics() :: %{ + Signal.navigation_transition_action() => Signal.modal_stack_semantics() + } + def navigation_modal_stack_semantics do + Signal.navigation_modal_stack_semantics() + end + @spec navigation_target_kind(Signal.t() | map() | keyword()) :: Signal.navigation_target_kind() def navigation_target_kind(signal) do Signal.navigation_target_kind(signal) diff --git a/packages/unified-ui/test/unified_ui/reference_test.exs b/packages/unified-ui/test/unified_ui/reference_test.exs index 10d2ece1..1d75f690 100644 --- a/packages/unified-ui/test/unified_ui/reference_test.exs +++ b/packages/unified-ui/test/unified_ui/reference_test.exs @@ -97,6 +97,24 @@ defmodule UnifiedUi.ReferenceTest do assert UnifiedUi.Reference.navigation_contract() == %{ transition_fields: [:action, :screen, :modal, :params, :metadata], local_navigation_fields: [:binding, :destination], + modal_stack: %{ + open_modal: %{ + operation: :push, + target: :symbolic_modal, + target_required?: true, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :push_modal + }, + close_modal: %{ + operation: :close, + target: :topmost_modal, + target_required?: false, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :close_topmost_or_named_modal + } + }, actions: %{ navigate_to: %{ kind: :screen_transition, diff --git a/packages/unified-ui/test/unified_ui/signals_test.exs b/packages/unified-ui/test/unified_ui/signals_test.exs index dbfca3a3..463768e7 100644 --- a/packages/unified-ui/test/unified_ui/signals_test.exs +++ b/packages/unified-ui/test/unified_ui/signals_test.exs @@ -178,6 +178,25 @@ defmodule UnifiedUi.SignalsTest do assert Signals.local_navigation_fields() == [:binding, :destination] + assert Signals.navigation_modal_stack_semantics() == %{ + open_modal: %{ + operation: :push, + target: :symbolic_modal, + target_required?: true, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :push_modal + }, + close_modal: %{ + operation: :close, + target: :topmost_modal, + target_required?: false, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :close_topmost_or_named_modal + } + } + assert Signals.navigation_action_contracts() == %{ navigate_to: %{ kind: :screen_transition, diff --git a/packages/unified-ui/test/unified_ui/tooling_test.exs b/packages/unified-ui/test/unified_ui/tooling_test.exs index 42a1955b..9ad22bde 100644 --- a/packages/unified-ui/test/unified_ui/tooling_test.exs +++ b/packages/unified-ui/test/unified_ui/tooling_test.exs @@ -56,6 +56,24 @@ defmodule UnifiedUi.ToolingTest do navigation_contract: %{ transition_fields: [:action, :screen, :modal, :params, :metadata], local_navigation_fields: [:binding, :destination], + modal_stack: %{ + open_modal: %{ + operation: :push, + target: :symbolic_modal, + target_required?: true, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :push_modal + }, + close_modal: %{ + operation: :close, + target: :topmost_modal, + target_required?: false, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :close_topmost_or_named_modal + } + }, actions: %{ navigate_to: %{ kind: :screen_transition, From 7d8747c1fba073f0eed35505816e80b3de7aa981 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 07:06:37 -0400 Subject: [PATCH 02/13] Expose canonical navigation diagnostics --- ...igation-surface-and-descriptor-backbone.md | 20 +- packages/unified-ui/lib/unified_ui/signal.ex | 23 +++ packages/unified-ui/lib/unified_ui/signals.ex | 17 +- packages/unified-ui/lib/unified_ui/tooling.ex | 4 +- .../canonical_navigation_integration_test.exs | 178 ++++++++++++++++++ .../unified-ui/test/unified_ui/info_test.exs | 3 +- .../unified_ui/phase_4_integration_test.exs | 8 + .../test/unified_ui/signals_test.exs | 46 +++++ .../test/unified_ui/tooling_test.exs | 46 +++++ 9 files changed, 332 insertions(+), 13 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 f715a9be..8f7fa161 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 @@ -48,26 +48,26 @@ 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. - [ ] 1.2.1.4 Subtask - Validate modal stack descriptors so targetless `close_modal` is accepted as top-modal close, targeted `close_modal` remains symbolic, and stack behavior never requires runtime-local stack identifiers. + [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. + [x] 1.2.1.4 Subtask - Validate modal stack descriptors so targetless `close_modal` is accepted as top-modal close, targeted `close_modal` remains symbolic, and stack behavior never requires runtime-local stack identifiers. - [ ] 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/signal.ex b/packages/unified-ui/lib/unified_ui/signal.ex index 82796fc3..eb18f258 100644 --- a/packages/unified-ui/lib/unified_ui/signal.ex +++ b/packages/unified-ui/lib/unified_ui/signal.ex @@ -36,6 +36,8 @@ defmodule UnifiedUi.Signal do stack_effect: :push_modal | :close_topmost_or_named_modal } + @type navigation_descriptor :: map() + @type family :: :click | :change @@ -161,6 +163,23 @@ defmodule UnifiedUi.Signal do def navigation_target_kind(target_intent) when is_map(target_intent), do: do_navigation_target_kind(target_intent) + @spec navigation_descriptor(t() | keyword() | map()) :: navigation_descriptor() + def navigation_descriptor(signal) do + signal = new(signal) + target_intent = signal.target_intent + action = fetch(target_intent, :action) + + %{id: signal.id, kind: navigation_target_kind(signal)} + |> maybe_put(:action, action) + |> maybe_put(:screen, fetch(target_intent, :screen)) + |> maybe_put(:modal, fetch(target_intent, :modal)) + |> maybe_put(:params, fetch(target_intent, :params)) + |> maybe_put(:metadata, fetch(target_intent, :metadata)) + |> maybe_put(:binding, fetch(target_intent, :binding)) + |> maybe_put(:destination, fetch(target_intent, :destination)) + |> maybe_put(:modal_stack, navigation_modal_stack_semantics()[action]) + end + @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() @@ -266,4 +285,8 @@ defmodule UnifiedUi.Signal do defp fetch(source, key, default \\ nil) do Map.get(source, key, Map.get(source, Atom.to_string(key), default)) end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, _key, value) when value in [%{}, []], do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) end diff --git a/packages/unified-ui/lib/unified_ui/signals.ex b/packages/unified-ui/lib/unified_ui/signals.ex index e215e6a4..b14e6d4d 100644 --- a/packages/unified-ui/lib/unified_ui/signals.ex +++ b/packages/unified-ui/lib/unified_ui/signals.ex @@ -45,6 +45,11 @@ defmodule UnifiedUi.Signals do Signal.navigation_target_kind(signal) end + @spec navigation_descriptor(Signal.t() | map() | keyword()) :: Signal.navigation_descriptor() + def navigation_descriptor(signal) do + Signal.navigation_descriptor(signal) + end + @spec bindings(module()) :: [Binding.t()] def bindings(module) when is_atom(module) do module @@ -59,6 +64,15 @@ defmodule UnifiedUi.Signals do |> Enum.filter(&match?(%Signal{}, &1)) end + @spec navigation_descriptors(module()) :: [Signal.navigation_descriptor()] + def navigation_descriptors(module) when is_atom(module) do + module + |> interactions() + |> Enum.filter(&(&1.family == :navigation)) + |> Enum.map(&Signal.navigation_descriptor/1) + |> Enum.sort_by(&Map.get(&1, :id)) + end + @spec module_summary(module()) :: map() def module_summary(module) when is_atom(module) do %{ @@ -67,7 +81,8 @@ defmodule UnifiedUi.Signals do mode: Extension.get_opt(module, [:signals], :mode, :canonical), families: families(), bindings: Enum.map(bindings(module), &Binding.summary/1), - interactions: Enum.map(interactions(module), &Signal.summary/1) + interactions: Enum.map(interactions(module), &Signal.summary/1), + navigation_descriptors: navigation_descriptors(module) } end end diff --git a/packages/unified-ui/lib/unified_ui/tooling.ex b/packages/unified-ui/lib/unified_ui/tooling.ex index 9a0469a8..cfe61454 100644 --- a/packages/unified-ui/lib/unified_ui/tooling.ex +++ b/packages/unified-ui/lib/unified_ui/tooling.ex @@ -203,7 +203,8 @@ defmodule UnifiedUi.Tooling do "related specs: #{inspect(diagnostics.related_specs)}", "signal families: #{inspect(diagnostics.signal_coverage.families)}", "binding names: #{inspect(diagnostics.signal_coverage.binding_names)}", - "navigation target kinds: #{inspect(diagnostics.signal_coverage.interaction_target_kinds)}" + "navigation target kinds: #{inspect(diagnostics.signal_coverage.interaction_target_kinds)}", + "navigation descriptors: #{inspect(diagnostics.signal_coverage.navigation_descriptors, sort_maps: true)}" ] |> Enum.join("\n") end @@ -328,6 +329,7 @@ defmodule UnifiedUi.Tooling do {interaction.id, Signals.navigation_target_kind(interaction)} end) |> Enum.into(%{}), + navigation_descriptors: signal_catalog.navigation_descriptors, navigation_actions: Signals.navigation_actions(), navigation_contract: UnifiedUi.Reference.navigation_contract(), target_bindings: 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 8e02bf34..eca71287 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 @@ -201,6 +201,110 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do open_settings_modal: :modal_transition, close_settings_modal: :modal_transition } + + assert summary.navigation_descriptors == [ + %{ + id: :close_settings_modal, + kind: :modal_transition, + action: :close_modal, + modal: :settings_dialog, + metadata: %{reason: :done}, + modal_stack: %{ + operation: :close, + target: :topmost_modal, + target_required?: false, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :close_topmost_or_named_modal + } + }, + %{ + id: :go_back_history, + kind: :history_transition, + action: :go_back, + metadata: %{source: :header} + }, + %{ + id: :go_forward_history, + kind: :history_transition, + action: :go_forward, + metadata: %{source: :header} + }, + %{ + id: :navigate_activity, + kind: :local_destination, + binding: :active_tab, + destination: :activity + }, + %{ + id: :open_settings_modal, + kind: :modal_transition, + action: :open_modal, + modal: :settings_dialog, + params: %{source: :button}, + modal_stack: %{ + operation: :push, + target: :symbolic_modal, + target_required?: true, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :push_modal + } + }, + %{ + id: :open_settings_screen, + kind: :screen_transition, + action: :navigate_to, + screen: :settings, + params: %{tab: :profile} + }, + %{ + id: :replace_with_home, + kind: :replace_transition, + action: :replace_with, + screen: :home, + params: %{source: :launcher} + } + ] + end + + test "accepts targetless top-modal close descriptors" do + assert [{module, _bytecode}] = + compile_module(""" + identity do + id(:targetless_close_screen) + end + + composition do + root(:targetless_close_root) + mode(:screen) + end + + signals do + interaction do + id(:close_top_modal) + family(:navigation) + intent(:close_top_modal) + target_intent(action: :close_modal) + end + end + """) + + assert [interaction] = Signals.interactions(module) + + assert Signals.navigation_descriptor(interaction) == %{ + id: :close_top_modal, + kind: :modal_transition, + action: :close_modal, + modal_stack: %{ + operation: :close, + target: :topmost_modal, + target_required?: false, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :close_topmost_or_named_modal + } + } end test "rejects host-specific navigation leakage and malformed authored target shapes" do @@ -250,6 +354,52 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do "navigation action :navigate_to requires fields [:screen]" ) + assert_compile_dsl_error( + """ + identity do + id(:unsupported_navigation_action_screen) + end + + composition do + root(:unsupported_navigation_action_root) + mode(:screen) + end + + signals do + interaction do + id(:push_settings) + family(:navigation) + intent(:push_settings) + target_intent(action: :push_screen, screen: :settings) + end + end + """, + "unsupported navigation action :push_screen" + ) + + assert_compile_dsl_error( + """ + identity do + id(:missing_modal_target_screen) + end + + composition do + root(:missing_modal_target_root) + mode(:screen) + end + + signals do + interaction do + id(:open_settings) + family(:navigation) + intent(:open_settings) + target_intent(action: :open_modal) + end + end + """, + "navigation action :open_modal requires fields [:modal]" + ) + assert_compile_dsl_error( """ identity do @@ -296,6 +446,29 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do "navigation screen must be a symbolic identifier and must not reference a runtime module" ) + assert_compile_dsl_error( + """ + identity do + id(:modal_url_navigation_screen) + end + + composition do + root(:modal_url_navigation_root) + mode(:screen) + end + + signals do + interaction do + id(:close_modal) + family(:navigation) + intent(:close_modal) + target_intent(action: :close_modal, modal: "/settings") + end + end + """, + "navigation modal must be a symbolic identifier and must not use URL or path syntax" + ) + assert_compile_dsl_error( """ identity do @@ -325,6 +498,7 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do assert {:ok, rendered_signals_again} = Export.module(CanonicalNavigationScreen, :signals) assert rendered_signals == rendered_signals_again + assert rendered_signals =~ "navigation_descriptors" assert rendered_signals =~ "navigate_activity" assert rendered_signals =~ "destination: :activity" assert rendered_signals =~ "action: :navigate_to" @@ -350,6 +524,10 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do assert report.signal_coverage.interaction_target_kinds[:close_settings_modal] == :modal_transition + assert Enum.find(report.signal_coverage.navigation_descriptors, &(&1.id == :open_settings)) + |> Map.fetch!(:modal_stack) + |> Map.fetch!(:stack_effect) == :push_modal + assert example_signals =~ "open_settings_screen" assert example_signals =~ "screen: :settings" assert example_signals =~ "navigate_activity" diff --git a/packages/unified-ui/test/unified_ui/info_test.exs b/packages/unified-ui/test/unified_ui/info_test.exs index 3ab426d8..e91cc4d2 100644 --- a/packages/unified-ui/test/unified_ui/info_test.exs +++ b/packages/unified-ui/test/unified_ui/info_test.exs @@ -103,7 +103,8 @@ defmodule UnifiedUi.InfoTest do :command ], bindings: [], - interactions: [] + interactions: [], + navigation_descriptors: [] }, validation_state: :phase_1_valid } 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 93c7f2d5..198dd236 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 @@ -501,6 +501,14 @@ defmodule UnifiedUi.Phase4IntegrationTest do target_intent: %{command: :workspace_commands}, payload_mapping: %{source: :keyboard_shortcut} } + ], + navigation_descriptors: [ + %{ + id: :navigate_activity, + kind: :local_destination, + binding: :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 463768e7..c0b5c2d7 100644 --- a/packages/unified-ui/test/unified_ui/signals_test.exs +++ b/packages/unified-ui/test/unified_ui/signals_test.exs @@ -331,6 +331,38 @@ defmodule UnifiedUi.SignalsTest do }, payload_mapping: %{tab: :profile} } + ], + navigation_descriptors: [ + %{ + id: :navigate_activity, + kind: :local_destination, + binding: :active_tab, + destination: :activity + }, + %{ + id: :open_settings_modal, + kind: :modal_transition, + action: :open_modal, + modal: :settings_dialog, + params: %{tab: :profile}, + metadata: %{source: :save_button}, + modal_stack: %{ + operation: :push, + target: :symbolic_modal, + target_required?: true, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :push_modal + } + }, + %{ + id: :open_settings_screen, + kind: :screen_transition, + action: :navigate_to, + screen: :settings, + params: %{tab: :profile}, + metadata: %{source: :save_button} + } ] } end @@ -360,6 +392,20 @@ defmodule UnifiedUi.SignalsTest do assert Signals.navigation_target_kind(%{target_intent: %{action: :go_back}}) == :history_transition + + assert Signals.navigation_descriptor(target_intent: [action: :close_modal]) == %{ + id: nil, + kind: :modal_transition, + action: :close_modal, + modal_stack: %{ + operation: :close, + target: :topmost_modal, + target_required?: false, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :close_topmost_or_named_modal + } + } end test "attaches authored signal and binding references to composition nodes" do diff --git a/packages/unified-ui/test/unified_ui/tooling_test.exs b/packages/unified-ui/test/unified_ui/tooling_test.exs index 9ad22bde..f329e251 100644 --- a/packages/unified-ui/test/unified_ui/tooling_test.exs +++ b/packages/unified-ui/test/unified_ui/tooling_test.exs @@ -45,6 +45,51 @@ defmodule UnifiedUi.ToolingTest do open_settings: :modal_transition, close_settings_modal: :modal_transition }, + navigation_descriptors: [ + %{ + id: :close_settings_modal, + kind: :modal_transition, + action: :close_modal, + modal: :settings_dialog, + metadata: %{reason: :done}, + modal_stack: %{ + operation: :close, + target: :topmost_modal, + target_required?: false, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :close_topmost_or_named_modal + } + }, + %{ + id: :navigate_activity, + kind: :local_destination, + binding: :active_tab, + destination: :activity + }, + %{ + id: :open_settings, + kind: :modal_transition, + action: :open_modal, + modal: :settings_dialog, + params: %{source: :button}, + modal_stack: %{ + operation: :push, + target: :symbolic_modal, + target_required?: true, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :push_modal + } + }, + %{ + id: :open_settings_screen, + kind: :screen_transition, + action: :navigate_to, + screen: :settings, + params: %{tab: :profile} + } + ], navigation_actions: [ :navigate_to, :replace_with, @@ -175,5 +220,6 @@ defmodule UnifiedUi.ToolingTest do assert rendered =~ "related specs:" assert rendered =~ "signal families:" assert rendered =~ "navigation target kinds:" + assert rendered =~ "navigation descriptors:" end end From 0dc8e6bd962220714069a200824862ead06402b4 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 07:11:12 -0400 Subject: [PATCH 03/13] Add canonical navigation authoring examples --- ...igation-surface-and-descriptor-backbone.md | 22 ++++---- .../docs/user/bindings-and-interactions.md | 38 +++++++++++++ .../examples/themed_signal_workspace.ex | 55 ++++++++++++++++++- .../canonical_navigation_integration_test.exs | 16 ++++++ .../test/unified_ui/examples_test.exs | 4 ++ .../test/unified_ui/tooling_test.exs | 33 +++++++++++ .../test/unified_ui/validation_test.exs | 2 +- 7 files changed, 157 insertions(+), 13 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 8f7fa161..749662fb 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 @@ -69,27 +69,27 @@ 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. - [ ] 1.3.1.4 Subtask - Add an authored stacked-modal example that opens a second modal from an existing modal and closes the topmost modal without declaring nested modal containment. + [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. + [x] 1.3.1.4 Subtask - Add an authored stacked-modal example that opens a second modal from an existing modal and closes the topmost modal without declaring nested modal containment. - [ ] 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. - [ ] 1.3.2.4 Subtask - Explain that stacked modal flows are ordered modal transitions and that focus trapping, backdrop treatment, and terminal degradation remain runtime concerns. + [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. + [x] 1.3.2.4 Subtask - Explain that stacked modal flows are ordered modal transitions and that focus trapping, backdrop treatment, and terminal degradation remain runtime concerns. [ ] 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 0435b391..f751ea18 100644 --- a/packages/unified-ui/docs/user/bindings-and-interactions.md +++ b/packages/unified-ui/docs/user/bindings-and-interactions.md @@ -115,6 +115,9 @@ The canonical interaction families currently supported are: - 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. +- Treat stacked modal flows as ordered modal transitions: each `open_modal` + pushes a symbolic modal target, and targetless `close_modal` closes the + topmost modal. - Keep URL paths, Phoenix route helpers, browser-history directives, and runtime module names out of `target_intent`. @@ -185,6 +188,41 @@ interaction do end ``` +### Stacked Modal Transitions + +Stacked modals do not require nested modal definitions. Author the second modal +as another `open_modal` transition, then use targetless `close_modal` when the +current top modal should close. + +```elixir +interaction do + id(:open_settings_confirmation) + family(:navigation) + intent(:open_settings_confirmation_modal) + source_context(element_id: :open_confirm_settings_button, scope: :modal) + + target_intent( + action: :open_modal, + modal: :settings_confirm_dialog, + params: %{from: :settings_dialog} + ) + + payload_mapping(source: :settings_dialog) +end + +interaction do + id(:close_top_modal) + family(:navigation) + intent(:close_top_modal) + source_context(element_id: :close_top_modal_button, scope: :modal) + target_intent(action: :close_modal, metadata: %{reason: :cancel}) + payload_mapping(reason: :cancel) +end +``` + +Focus trapping, backdrop behavior, and terminal degradation are runtime +responsibilities. The authored DSL only preserves the portable stack intent. + ## Canonical, Not Renderer-Local Authored interactions should describe meaning, not runtime mechanics. 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 9d353ee6..6ee21f5a 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 @@ -126,6 +126,26 @@ defmodule UnifiedUi.Examples.ThemedSignalWorkspace do label("Close settings modal") interaction_refs([:close_settings_modal]) end + + button :open_confirm_settings_button do + label("Open confirmation modal") + interaction_refs([:open_settings_confirmation]) + end + end + + box :settings_confirm_panel do + theme_ref(:workspace_dark) + style_refs([:panel_shell]) + + text :settings_confirm_title do + value("Confirm settings") + variant(:headline) + end + + button :close_top_modal_button do + label("Close top modal") + interaction_refs([:close_top_modal]) + end end row :workspace_shell do @@ -245,9 +265,18 @@ defmodule UnifiedUi.Examples.ThemedSignalWorkspace do style_refs([:modal_shell]) end + dialog :settings_confirm_dialog do + title("Confirm settings") + content_ref(:settings_confirm_panel) + trigger_ref(:open_confirm_settings_button) + visible?(false) + theme_ref(:workspace_dark) + style_refs([:modal_shell]) + end + overlay :workspace_overlay do base_ref(:workspace_shell) - layer_refs([:settings_dialog]) + layer_refs([:settings_dialog, :settings_confirm_dialog]) background_fill(:scrim) style(style_value(emphasis: %{elevation: 3})) @@ -318,6 +347,30 @@ defmodule UnifiedUi.Examples.ThemedSignalWorkspace do payload_mapping(source: :button) end + interaction do + id(:open_settings_confirmation) + family(:navigation) + intent(:open_settings_confirmation_modal) + source_context(element_id: :open_confirm_settings_button, scope: :modal) + + target_intent( + action: :open_modal, + modal: :settings_confirm_dialog, + params: %{from: :settings_dialog} + ) + + payload_mapping(source: :settings_dialog) + end + + interaction do + id(:close_top_modal) + family(:navigation) + intent(:close_top_modal) + source_context(element_id: :close_top_modal_button, scope: :modal) + target_intent(action: :close_modal, metadata: %{reason: :cancel}) + payload_mapping(reason: :cancel) + end + interaction do id(:close_settings_modal) family(:navigation) 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 eca71287..52638303 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 @@ -521,6 +521,12 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do assert report.signal_coverage.interaction_target_kinds[:open_settings] == :modal_transition + assert report.signal_coverage.interaction_target_kinds[:open_settings_confirmation] == + :modal_transition + + assert report.signal_coverage.interaction_target_kinds[:close_top_modal] == + :modal_transition + assert report.signal_coverage.interaction_target_kinds[:close_settings_modal] == :modal_transition @@ -528,13 +534,20 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do |> Map.fetch!(:modal_stack) |> Map.fetch!(:stack_effect) == :push_modal + assert Enum.find(report.signal_coverage.navigation_descriptors, &(&1.id == :close_top_modal)) + |> Map.fetch!(:modal_stack) + |> Map.fetch!(:target) == :topmost_modal + 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 =~ "open_settings_confirmation" + assert example_signals =~ "close_top_modal" assert example_signals =~ "close_settings_modal" assert example_signals =~ "modal: :settings_dialog" + assert example_signals =~ "modal: :settings_confirm_dialog" end test "keeps the user guidance aligned with canonical screen-transition authoring" do @@ -546,12 +559,15 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do assert guide =~ "Use `binding` plus `destination`" assert guide =~ "Use `action` plus `screen`" assert guide =~ "Use `action` plus `modal`" + assert guide =~ "targetless `close_modal` closes the" 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" + assert guide =~ "target_intent(action: :close_modal, metadata: %{reason: :cancel})" + assert guide =~ "Focus trapping, backdrop behavior, and terminal degradation are runtime" refute guide =~ "target_intent(binding: :active_tab, route: :activity)" end diff --git a/packages/unified-ui/test/unified_ui/examples_test.exs b/packages/unified-ui/test/unified_ui/examples_test.exs index 7dd19bd3..1b13ea3b 100644 --- a/packages/unified-ui/test/unified_ui/examples_test.exs +++ b/packages/unified-ui/test/unified_ui/examples_test.exs @@ -174,5 +174,9 @@ defmodule UnifiedUi.ExamplesTest do Enum.any?(node.children, &(&1.id == :activity_viewport and &1.kind == :viewport)) and Enum.any?(node.children, &(&1.id == :status_canvas and &1.kind == :canvas)) end) + + assert Enum.any?(themed_workspace.composition, fn node -> + node.id == :settings_confirm_dialog and node.kind == :dialog + end) end end diff --git a/packages/unified-ui/test/unified_ui/tooling_test.exs b/packages/unified-ui/test/unified_ui/tooling_test.exs index f329e251..7af6b8b6 100644 --- a/packages/unified-ui/test/unified_ui/tooling_test.exs +++ b/packages/unified-ui/test/unified_ui/tooling_test.exs @@ -28,21 +28,25 @@ defmodule UnifiedUi.ToolingTest do binding_names: [:active_tab, :filters], interaction_ids: [ :close_settings_modal, + :close_top_modal, :filters_change, :filters_submit, :navigate_activity, :open_commands, :open_settings, + :open_settings_confirmation, :open_settings_screen ], families: [:change, :command, :navigation, :submit], interaction_target_kinds: %{ + close_top_modal: :modal_transition, filters_change: :generic, filters_submit: :generic, navigate_activity: :local_destination, open_settings_screen: :screen_transition, open_commands: :generic, open_settings: :modal_transition, + open_settings_confirmation: :modal_transition, close_settings_modal: :modal_transition }, navigation_descriptors: [ @@ -61,6 +65,20 @@ defmodule UnifiedUi.ToolingTest do stack_effect: :close_topmost_or_named_modal } }, + %{ + id: :close_top_modal, + kind: :modal_transition, + action: :close_modal, + metadata: %{reason: :cancel}, + modal_stack: %{ + operation: :close, + target: :topmost_modal, + target_required?: false, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :close_topmost_or_named_modal + } + }, %{ id: :navigate_activity, kind: :local_destination, @@ -82,6 +100,21 @@ defmodule UnifiedUi.ToolingTest do stack_effect: :push_modal } }, + %{ + id: :open_settings_confirmation, + kind: :modal_transition, + action: :open_modal, + modal: :settings_confirm_dialog, + params: %{from: :settings_dialog}, + modal_stack: %{ + operation: :push, + target: :symbolic_modal, + target_required?: true, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :push_modal + } + }, %{ id: :open_settings_screen, kind: :screen_transition, diff --git a/packages/unified-ui/test/unified_ui/validation_test.exs b/packages/unified-ui/test/unified_ui/validation_test.exs index 7c42ff09..0c83b6ab 100644 --- a/packages/unified-ui/test/unified_ui/validation_test.exs +++ b/packages/unified-ui/test/unified_ui/validation_test.exs @@ -58,7 +58,7 @@ defmodule UnifiedUi.ValidationTest do families: [:change, :command, :navigation, :submit], canonical_only?: true, total_bindings: 2, - total_interactions: 7 + total_interactions: 9 } assert report.documentation_surface.complete? From d458aa3db89ad948e57588b1947d19fefa629fcf Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 07:13:33 -0400 Subject: [PATCH 04/13] Cover canonical navigation integration paths --- ...igation-surface-and-descriptor-backbone.md | 24 ++++----- .../canonical_navigation_integration_test.exs | 53 +++++++++++++++++++ 2 files changed, 65 insertions(+), 12 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 749662fb..b682b325 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 @@ -22,7 +22,7 @@ Back to index: [README](./README.md) - 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 +[x] 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. @@ -91,24 +91,24 @@ Back to index: [README](./README.md) [x] 1.3.2.3 Subtask - Explain how authored canonical navigation remains portable across web, desktop, and terminal runtimes. [x] 1.3.2.4 Subtask - Explain that stacked modal flows are ordered modal transitions and that focus trapping, backdrop treatment, and terminal degradation remain runtime concerns. - [ ] 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. - [ ] 1.4.1.4 Subtask - Verify stacked modal descriptors accept repeated `open_modal` transitions and targetless top-modal `close_modal` without requiring modal nesting metadata. + [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. + [x] 1.4.1.4 Subtask - Verify stacked modal descriptors accept repeated `open_modal` transitions and targetless top-modal `close_modal` without requiring modal nesting metadata. - [ ] 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. - [ ] 1.4.2.4 Subtask - Verify the stacked-modal example and inspection output make topmost close behavior visible without renderer-local stack identifiers. + [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. + [x] 1.4.2.4 Subtask - Verify the stacked-modal example and inspection output make topmost close behavior visible without renderer-local stack identifiers. 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 52638303..52abbaf8 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 @@ -354,6 +354,29 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do "navigation action :navigate_to requires fields [:screen]" ) + assert_compile_dsl_error( + """ + identity do + id(:route_helper_navigation_screen) + end + + composition do + root(:route_helper_navigation_root) + mode(:screen) + end + + signals do + interaction do + id(:open_settings) + family(:navigation) + intent(:open_settings) + target_intent(action: :navigate_to, screen: :settings, route_helper: :settings_path) + end + end + """, + "canonical navigation must not declare host-route key :route_helper" + ) + assert_compile_dsl_error( """ identity do @@ -571,6 +594,36 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do refute guide =~ "target_intent(binding: :active_tab, route: :activity)" end + test "maintained stacked-modal example compiles into portable navigation descriptors" do + assert {:ok, result} = Compiler.compile(UnifiedUi.Examples.ThemedSignalWorkspace) + assert {:ok, snapshot} = Export.example(:themed_signal_workspace, :snapshot) + + targets = + Map.new(result.interactions, fn interaction -> + {interaction.intent, interaction.target} + end) + + assert targets[:open_settings_confirmation_modal] == %{ + navigation: %{ + action: :open_modal, + kind: :modal_transition, + modal: :settings_confirm_dialog, + params: %{from: :settings_dialog} + } + } + + assert targets[:close_top_modal] == %{ + navigation: %{ + action: :close_modal, + kind: :modal_transition, + metadata: %{reason: :cancel} + } + } + + refute snapshot =~ "route_helper" + refute snapshot =~ "stack_id" + 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) From 9a7a3d5a05d73273e1595c8f949160f86c7d9354 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 07:17:04 -0400 Subject: [PATCH 05/13] Cover compiler navigation lowering --- ...fied-iur-and-signal-transport-alignment.md | 20 +-- .../test/unified_ui/compiler_test.exs | 158 ++++++++++++++++++ 2 files changed, 168 insertions(+), 10 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 2514d2d9..3159e527 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 @@ -26,26 +26,26 @@ Back to index: [README](./README.md) semantics that carry screen-transition intent from authored `UnifiedUi` modules to runtime libraries. - [ ] 2.1 Section - UnifiedUi Compiler Navigation Lowering + [x] 2.1 Section - UnifiedUi Compiler Navigation Lowering Implement compiler support that lowers authored navigation transitions into stable canonical descriptors. - [ ] 2.1.1 Task - Lower authored navigation into 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. - [ ] 2.1.1.1 Subtask - Lower navigation actions, symbolic screen targets, modal targets, params, and source-context fields into canonical interaction descriptors. - [ ] 2.1.1.2 Subtask - Preserve payload mapping and binding references for navigation transitions without introducing renderer-local callback logic. - [ ] 2.1.1.3 Subtask - Ensure canonical lowering stays deterministic across equivalent authored modules so navigation diffs remain review-friendly. - [ ] 2.1.1.4 Subtask - Preserve modal stack transition ordering, targetless top-modal `close_modal`, targeted symbolic modal close, params, and metadata without adding renderer-local modal hierarchy. + [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.1.4 Subtask - Preserve modal stack transition ordering, targetless top-modal `close_modal`, targeted symbolic modal close, params, and metadata without adding renderer-local modal hierarchy. - [ ] 2.1.2 Task - Preserve compatibility for non-transition interactions + [x] 2.1.2 Task - Preserve compatibility for non-transition interactions Keep existing canonical interaction lowering intact while introducing the new screen-transition model. - [ ] 2.1.2.1 Subtask - Ensure non-navigation interaction families continue to compile without depending on the new transition fields. - [ ] 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. - [ ] 2.1.2.3 Subtask - Add normalization rules that keep older generic target-intent usage reviewable while preserving the newer canonical transition contract. + [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 diff --git a/packages/unified-ui/test/unified_ui/compiler_test.exs b/packages/unified-ui/test/unified_ui/compiler_test.exs index e782d2a7..e24f7153 100644 --- a/packages/unified-ui/test/unified_ui/compiler_test.exs +++ b/packages/unified-ui/test/unified_ui/compiler_test.exs @@ -134,10 +134,30 @@ defmodule UnifiedUi.CompilerTest do interaction_refs([:go_back_history]) end + button :forward_button do + label("Forward") + interaction_refs([:go_forward_history]) + end + button :open_settings_modal_button do label("Open settings modal") interaction_refs([:open_settings_modal]) end + + button :open_confirm_modal_button do + label("Open confirm modal") + interaction_refs([:open_confirm_modal]) + end + + button :close_top_modal_button do + label("Close top modal") + interaction_refs([:close_top_modal]) + end + + button :close_settings_modal_button do + label("Close settings modal") + interaction_refs([:close_settings_modal]) + end end signals do @@ -186,6 +206,15 @@ defmodule UnifiedUi.CompilerTest do 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) @@ -194,6 +223,39 @@ defmodule UnifiedUi.CompilerTest do target_intent(action: :open_modal, modal: :settings_dialog, params: %{source: :button}) payload_mapping(source: :button) end + + interaction do + id(:open_confirm_modal) + family(:navigation) + intent(:open_confirm_modal) + source_context(element_id: :open_confirm_modal_button, scope: :modal) + + target_intent( + action: :open_modal, + modal: :confirm_dialog, + params: %{from: :settings_dialog} + ) + + payload_mapping(source: :settings_dialog) + end + + interaction do + id(:close_top_modal) + family(:navigation) + intent(:close_top_modal) + source_context(element_id: :close_top_modal_button, scope: :modal) + target_intent(action: :close_modal, metadata: %{reason: :dismiss}) + payload_mapping(reason: :dismiss) + end + + interaction do + id(:close_settings_modal) + family(:navigation) + intent(:close_settings_modal) + source_context(element_id: :close_settings_modal_button, scope: :modal) + target_intent(action: :close_modal, modal: :settings_dialog, metadata: %{reason: :done}) + payload_mapping(reason: :done) + end end end @@ -460,6 +522,21 @@ defmodule UnifiedUi.CompilerTest do metadata: %{authored_id: :go_back_history, binding_refs: [], summary: nil} } + assert result.trace.interaction_by_id[:go_forward_history] == %UnifiedIUR.Interaction{ + family: :navigation, + intent: :go_forward_history, + source: %{element_id: :forward_button, scope: :screen}, + target: %{ + navigation: %{ + action: :go_forward, + kind: :history_transition, + metadata: %{source: :header} + } + }, + payload: %{source: :header}, + metadata: %{authored_id: :go_forward_history, binding_refs: [], summary: nil} + } + assert result.trace.interaction_by_id[:open_settings_modal] == %UnifiedIUR.Interaction{ family: :navigation, intent: :open_settings_modal, @@ -476,6 +553,53 @@ defmodule UnifiedUi.CompilerTest do metadata: %{authored_id: :open_settings_modal, binding_refs: [], summary: nil} } + assert result.trace.interaction_by_id[:open_confirm_modal] == %UnifiedIUR.Interaction{ + family: :navigation, + intent: :open_confirm_modal, + source: %{element_id: :open_confirm_modal_button, scope: :modal}, + target: %{ + navigation: %{ + action: :open_modal, + kind: :modal_transition, + modal: :confirm_dialog, + params: %{from: :settings_dialog} + } + }, + payload: %{source: :settings_dialog}, + metadata: %{authored_id: :open_confirm_modal, binding_refs: [], summary: nil} + } + + assert result.trace.interaction_by_id[:close_top_modal] == %UnifiedIUR.Interaction{ + family: :navigation, + intent: :close_top_modal, + source: %{element_id: :close_top_modal_button, scope: :modal}, + target: %{ + navigation: %{ + action: :close_modal, + kind: :modal_transition, + metadata: %{reason: :dismiss} + } + }, + payload: %{reason: :dismiss}, + metadata: %{authored_id: :close_top_modal, binding_refs: [], summary: nil} + } + + assert result.trace.interaction_by_id[:close_settings_modal] == %UnifiedIUR.Interaction{ + family: :navigation, + intent: :close_settings_modal, + source: %{element_id: :close_settings_modal_button, scope: :modal}, + target: %{ + navigation: %{ + action: :close_modal, + kind: :modal_transition, + metadata: %{reason: :done}, + modal: :settings_dialog + } + }, + payload: %{reason: :done}, + metadata: %{authored_id: :close_settings_modal, binding_refs: [], summary: nil} + } + assert Enum.map(result.iur.attributes.interactions, &{&1.intent, &1.target}) == [ {:navigate_activity, %{ @@ -514,6 +638,14 @@ defmodule UnifiedUi.CompilerTest do metadata: %{source: :header} } }}, + {:go_forward_history, + %{ + navigation: %{ + action: :go_forward, + kind: :history_transition, + metadata: %{source: :header} + } + }}, {:open_settings_modal, %{ navigation: %{ @@ -522,6 +654,32 @@ defmodule UnifiedUi.CompilerTest do modal: :settings_dialog, params: %{source: :button} } + }}, + {:open_confirm_modal, + %{ + navigation: %{ + action: :open_modal, + kind: :modal_transition, + modal: :confirm_dialog, + params: %{from: :settings_dialog} + } + }}, + {:close_top_modal, + %{ + navigation: %{ + action: :close_modal, + kind: :modal_transition, + metadata: %{reason: :dismiss} + } + }}, + {:close_settings_modal, + %{ + navigation: %{ + action: :close_modal, + kind: :modal_transition, + metadata: %{reason: :done}, + modal: :settings_dialog + } }} ] end From 5d6e8b2c7ccbbf03b33e7dc31142582bb5953960 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 07:21:09 -0400 Subject: [PATCH 06/13] Extend IUR navigation modal stack fixtures --- ...fied-iur-and-signal-transport-alignment.md | 22 ++--- .../unified_iur/lib/unified_iur/fixtures.ex | 95 +++++++++++++++++++ .../lib/unified_iur/interaction.ex | 14 ++- .../test/unified_iur/export_test.exs | 6 ++ .../test/unified_iur/fixtures_test.exs | 21 +++- .../test/unified_iur/inspect_test.exs | 11 +++ .../test/unified_iur/interaction_test.exs | 29 ++++++ .../interactions_transport_test.exs | 5 +- 8 files changed, 189 insertions(+), 14 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 3159e527..b1ccc51c 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 @@ -47,27 +47,27 @@ 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. - [ ] 2.2.1.4 Subtask - Define ordered modal stack transition representation for repeated `open_modal`, targetless top-modal close, and targeted symbolic modal close without structural modal nesting. + [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. + [x] 2.2.1.4 Subtask - Define ordered modal stack transition representation for repeated `open_modal`, targetless top-modal close, and targeted symbolic modal close without structural modal nesting. - [ ] 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. - [ ] 2.2.2.4 Subtask - Add canonical fixtures and review summaries for stacked modal open/open/close sequences and named modal close behavior. + [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. + [x] 2.2.2.4 Subtask - Add canonical fixtures and review summaries for stacked modal open/open/close sequences and named modal close behavior. [ ] 2.3 Section - Shared Signal Transport Alignment Implement the shared transport semantics that move canonical screen diff --git a/packages/unified_iur/lib/unified_iur/fixtures.ex b/packages/unified_iur/lib/unified_iur/fixtures.ex index cc61fb6d..9e506175 100644 --- a/packages/unified_iur/lib/unified_iur/fixtures.ex +++ b/packages/unified_iur/lib/unified_iur/fixtures.ex @@ -132,6 +132,39 @@ defmodule UnifiedIUR.Fixtures do "portable params and metadata" ], snapshot: "fixtures/navigation/modal_transition--settings_dialog.snapshot" + }, + %{ + id: "modal_stack--open_confirm_dialog", + description: + "Canonical stacked modal push opening a second symbolic modal from an existing modal flow.", + semantics: [ + "modal stack push transition", + "second symbolic modal target", + "no renderer-local modal containment" + ], + snapshot: "fixtures/navigation/modal_stack--open_confirm_dialog.snapshot" + }, + %{ + id: "modal_stack--close_top", + description: + "Canonical targetless modal close that removes the topmost modal without a stack id.", + semantics: [ + "targetless top-modal close", + "modal stack pop transition", + "no renderer-local stack identifiers" + ], + snapshot: "fixtures/navigation/modal_stack--close_top.snapshot" + }, + %{ + id: "modal_stack--close_named_settings", + description: + "Canonical named modal close using a symbolic modal target without structural nesting.", + semantics: [ + "symbolic named modal close", + "modal stack close transition", + "no renderer-local modal hierarchy" + ], + snapshot: "fixtures/navigation/modal_stack--close_named_settings.snapshot" } ] @@ -337,10 +370,72 @@ defmodule UnifiedIUR.Fixtures do modal: :settings_dialog, params: %{mode: :advanced}, metadata: %{surface: :workspace}, + modal_stack: modal_stack_push(), mapping: %{surface: :workspace} ) end + defp build_navigation_fixture("modal_stack--open_confirm_dialog") do + Interaction.navigation_transition( + intent: :open_confirm_modal, + element_id: "confirm-settings-button", + scope: :modal, + action: :open_modal, + modal: :settings_confirm_dialog, + params: %{from: :settings_dialog}, + metadata: %{previous_modal: :settings_dialog}, + modal_stack: modal_stack_push(), + mapping: %{source: :settings_dialog} + ) + end + + defp build_navigation_fixture("modal_stack--close_top") do + Interaction.navigation_transition( + intent: :close_top_modal, + element_id: "close-top-modal-button", + scope: :modal, + action: :close_modal, + metadata: %{reason: :cancel}, + modal_stack: modal_stack_close(), + mapping: %{reason: :cancel} + ) + end + + defp build_navigation_fixture("modal_stack--close_named_settings") do + Interaction.navigation_transition( + intent: :close_settings_modal, + element_id: "close-settings-modal-button", + scope: :modal, + action: :close_modal, + modal: :settings_dialog, + metadata: %{reason: :done}, + modal_stack: modal_stack_close(), + mapping: %{reason: :done} + ) + end + + defp modal_stack_push do + %{ + operation: :push, + target: :symbolic_modal, + target_required?: true, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :push_modal + } + end + + defp modal_stack_close do + %{ + operation: :close, + target: :topmost_modal, + target_required?: false, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :close_topmost_or_named_modal + } + end + defp build_fixture("foundational--workspace_chrome") do semantic_hero = Foundational.hero( diff --git a/packages/unified_iur/lib/unified_iur/interaction.ex b/packages/unified_iur/lib/unified_iur/interaction.ex index 107af6ad..e2c250fe 100644 --- a/packages/unified_iur/lib/unified_iur/interaction.ex +++ b/packages/unified_iur/lib/unified_iur/interaction.ex @@ -28,13 +28,24 @@ defmodule UnifiedIUR.Interaction do | :history_transition | :modal_transition + @type modal_stack_semantics :: %{ + optional(:operation) => :push | :close | atom() | String.t(), + optional(:target) => :symbolic_modal | :topmost_modal | atom() | String.t(), + optional(:target_required?) => boolean(), + optional(:named_target_allowed?) => boolean(), + optional(:containment_required?) => boolean(), + optional(:stack_effect) => + :push_modal | :close_topmost_or_named_modal | atom() | String.t() + } + @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() + optional(:metadata) => map(), + optional(:modal_stack) => modal_stack_semantics() } @type t :: %__MODULE__{ @@ -244,6 +255,7 @@ defmodule UnifiedIUR.Interaction do |> 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))) + |> maybe_put(:modal_stack, normalize_optional_non_empty_map(fetch(descriptor, :modal_stack))) end defp infer_navigation_kind(:navigate_to), do: :screen_transition diff --git a/packages/unified_iur/test/unified_iur/export_test.exs b/packages/unified_iur/test/unified_iur/export_test.exs index 6da9e4bd..b53193bb 100644 --- a/packages/unified_iur/test/unified_iur/export_test.exs +++ b/packages/unified_iur/test/unified_iur/export_test.exs @@ -29,6 +29,12 @@ defmodule UnifiedIUR.ExportTest do assert navigation_snapshot =~ "action: :go_back" assert navigation_snapshot =~ "kind: :history_transition" refute navigation_snapshot =~ "screen:" + + assert {:ok, stacked_modal_export} = + Export.navigation_fixture("modal_stack--open_confirm_dialog", :inspection) + + assert stacked_modal_export =~ "modal_stack" + assert stacked_modal_export =~ "stack_effect: :push_modal" 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 ad8a4532..231af4ef 100644 --- a/packages/unified_iur/test/unified_iur/fixtures_test.exs +++ b/packages/unified_iur/test/unified_iur/fixtures_test.exs @@ -53,7 +53,10 @@ defmodule UnifiedIUR.FixturesTest do "screen_transition--settings_profile", "replace_transition--home", "history_transition--back", - "modal_transition--settings_dialog" + "modal_transition--settings_dialog", + "modal_stack--open_confirm_dialog", + "modal_stack--close_top", + "modal_stack--close_named_settings" ] assert {:ok, history_fixture} = Fixtures.navigation_fixture("history_transition--back") @@ -71,6 +74,22 @@ defmodule UnifiedIUR.FixturesTest do refute Map.has_key?(Interaction.navigation_descriptor(history_fixture.interaction), :screen) + assert {:ok, stacked_close} = Fixtures.navigation_fixture("modal_stack--close_top") + + assert Interaction.navigation_descriptor(stacked_close.interaction) == %{ + action: :close_modal, + kind: :modal_transition, + metadata: %{reason: :cancel}, + modal_stack: %{ + operation: :close, + target: :topmost_modal, + target_required?: false, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :close_topmost_or_named_modal + } + } + assert %{ id: "modal_transition--settings_dialog", snapshot_path: "fixtures/navigation/modal_transition--settings_dialog.snapshot" diff --git a/packages/unified_iur/test/unified_iur/inspect_test.exs b/packages/unified_iur/test/unified_iur/inspect_test.exs index 913ba616..ff212246 100644 --- a/packages/unified_iur/test/unified_iur/inspect_test.exs +++ b/packages/unified_iur/test/unified_iur/inspect_test.exs @@ -64,5 +64,16 @@ defmodule UnifiedIUR.InspectTest do assert report.target == %{navigation: report.navigation} assert "symbolic screen identifiers" in report.semantics + + assert {:ok, stack_report} = Inspect.navigation_fixture("modal_stack--open_confirm_dialog") + + assert stack_report.navigation.modal_stack == %{ + operation: :push, + target: :symbolic_modal, + target_required?: true, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :push_modal + } 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 ff84b511..8a029b44 100644 --- a/packages/unified_iur/test/unified_iur/interaction_test.exs +++ b/packages/unified_iur/test/unified_iur/interaction_test.exs @@ -143,6 +143,35 @@ defmodule UnifiedIUR.InteractionTest do params: %{"source" => :launcher}, screen: :home } + + modal_push = + Interaction.navigation_transition( + intent: :open_confirm_modal, + action: :open_modal, + modal: :settings_confirm_dialog, + modal_stack: [ + operation: :push, + target: :symbolic_modal, + target_required?: true, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :push_modal + ] + ) + + assert Interaction.navigation_descriptor(modal_push) == %{ + action: :open_modal, + kind: :modal_transition, + modal: :settings_confirm_dialog, + modal_stack: %{ + operation: :push, + target: :symbolic_modal, + target_required?: true, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :push_modal + } + } end test "builds bindings with source paths, dependencies, and derived-value metadata" do diff --git a/packages/unified_iur/test/unified_iur/interactions_transport_test.exs b/packages/unified_iur/test/unified_iur/interactions_transport_test.exs index feb00dac..1dfa8eb9 100644 --- a/packages/unified_iur/test/unified_iur/interactions_transport_test.exs +++ b/packages/unified_iur/test/unified_iur/interactions_transport_test.exs @@ -8,7 +8,10 @@ defmodule UnifiedIUR.InteractionsTransportTest do "screen_transition--settings_profile", "replace_transition--home", "history_transition--back", - "modal_transition--settings_dialog" + "modal_transition--settings_dialog", + "modal_stack--open_confirm_dialog", + "modal_stack--close_top", + "modal_stack--close_named_settings" ] assert {:ok, fixture} = Transport.boundary_fixture("screen_transition--settings_profile") From d7b1579a1eb5de662aa41d5cbb18fe016bb2cc5b Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 07:25:34 -0400 Subject: [PATCH 07/13] Align navigation transport modal stack semantics --- ...fied-iur-and-signal-transport-alignment.md | 22 +-- .../lib/unified_iur/interactions/transport.ex | 142 +++++++++++++++++- ...l_navigation_boundary_integration_test.exs | 12 +- .../interactions_transport_test.exs | 79 +++++++++- 4 files changed, 234 insertions(+), 21 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 b1ccc51c..490efebe 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 @@ -69,27 +69,27 @@ Back to index: [README](./README.md) [x] 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.4 Subtask - Add canonical fixtures and review summaries for stacked modal open/open/close sequences and named modal close behavior. - [ ] 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. - [ ] 2.3.1.4 Subtask - Define boundary preservation rules for modal stack push behavior, targetless top-modal close, symbolic targeted close, params, and metadata without runtime-local stack identifiers. + [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. + [x] 2.3.1.4 Subtask - Define boundary preservation rules for modal stack push behavior, targetless top-modal close, symbolic targeted close, params, and metadata without runtime-local stack identifiers. - [ ] 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. - [ ] 2.3.2.4 Subtask - Add shared transport fixtures and validation cases for stacked modal flows so runtimes can prove topmost close semantics consistently. + [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. + [x] 2.3.2.4 Subtask - Add shared transport fixtures and validation cases for stacked modal flows so runtimes can prove topmost close semantics consistently. [ ] 2.4 Section - Phase 2 Integration Tests Validate canonical lowering, `unified_iur` representation, and boundary diff --git a/packages/unified_iur/lib/unified_iur/interactions/transport.ex b/packages/unified_iur/lib/unified_iur/interactions/transport.ex index 397f8ab8..92f7e141 100644 --- a/packages/unified_iur/lib/unified_iur/interactions/transport.ex +++ b/packages/unified_iur/lib/unified_iur/interactions/transport.ex @@ -17,7 +17,12 @@ defmodule UnifiedIUR.Interactions.Transport do :helper, :module, :runtime_module, - :live_action + :live_action, + :stack_id, + :modal_stack_id, + :runtime_stack, + :runtime_stack_id, + :stack_ref ] @navigation_actions Interaction.navigation_actions() @@ -36,7 +41,11 @@ defmodule UnifiedIUR.Interactions.Transport do screen: atom() | String.t() | nil, modal: atom() | String.t() | nil, params?: boolean(), - targetless?: boolean() + targetless?: boolean(), + modal_stack?: boolean(), + modal_stack_operation: atom() | String.t() | nil, + modal_stack_target: atom() | String.t() | nil, + modal_stack_effect: atom() | String.t() | nil } @spec extension_key() :: atom() @@ -119,6 +128,7 @@ defmodule UnifiedIUR.Interactions.Transport do def summarize_boundary_descriptor(descriptor) do descriptor = normalize_descriptor(descriptor) navigation = Interaction.navigation_descriptor(descriptor.target) || %{} + modal_stack = navigation |> fetch_value(:modal_stack, %{}) |> normalize_map() %{ family: :navigation, @@ -127,7 +137,11 @@ defmodule UnifiedIUR.Interactions.Transport do screen: Map.get(navigation, :screen), modal: Map.get(navigation, :modal), params?: navigation_params?(navigation), - targetless?: targetless_navigation?(navigation) + targetless?: targetless_navigation?(navigation), + modal_stack?: modal_stack != %{}, + modal_stack_operation: fetch_value(modal_stack, :operation), + modal_stack_target: fetch_value(modal_stack, :target), + modal_stack_effect: fetch_value(modal_stack, :stack_effect) } end @@ -223,7 +237,9 @@ defmodule UnifiedIUR.Interactions.Transport do 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 <- validate_optional_map(Map.get(descriptor, :metadata), :metadata), + :ok <- validate_optional_map(Map.get(descriptor, :modal_stack), :modal_stack), + :ok <- validate_modal_stack_descriptor(action, Map.get(descriptor, :modal_stack)) do :ok end end @@ -291,6 +307,116 @@ defmodule UnifiedIUR.Interactions.Transport do 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_modal_stack_descriptor(_action, nil), do: :ok + + defp validate_modal_stack_descriptor(action, modal_stack) when is_map(modal_stack) do + modal_stack = normalize_map(modal_stack) + + with :ok <- validate_forbidden_navigation_keys(modal_stack), + :ok <- validate_modal_stack_operation(action, modal_stack), + :ok <- validate_modal_stack_target(action, modal_stack), + :ok <- validate_modal_stack_effect(action, modal_stack), + :ok <- validate_modal_stack_boolean_fields(modal_stack), + :ok <- validate_modal_stack_containment(modal_stack) do + :ok + end + end + + defp validate_modal_stack_operation(action, modal_stack) + when action in [:open_modal, "open_modal"] do + validate_modal_stack_value( + :operation, + fetch_value(modal_stack, :operation), + [:push, "push"], + action + ) + end + + defp validate_modal_stack_operation(action, modal_stack) + when action in [:close_modal, "close_modal"] do + validate_modal_stack_value( + :operation, + fetch_value(modal_stack, :operation), + [:close, "close"], + action + ) + end + + defp validate_modal_stack_operation(action, _modal_stack), + do: {:error, {:unexpected_modal_stack, action}} + + defp validate_modal_stack_target(action, modal_stack) + when action in [:open_modal, "open_modal"] do + validate_modal_stack_value( + :target, + fetch_value(modal_stack, :target), + [:symbolic_modal, "symbolic_modal"], + action + ) + end + + defp validate_modal_stack_target(action, modal_stack) + when action in [:close_modal, "close_modal"] do + validate_modal_stack_value( + :target, + fetch_value(modal_stack, :target), + [:topmost_modal, "topmost_modal", :symbolic_modal, "symbolic_modal"], + action + ) + end + + defp validate_modal_stack_target(_action, _modal_stack), do: :ok + + defp validate_modal_stack_effect(action, modal_stack) + when action in [:open_modal, "open_modal"] do + validate_modal_stack_value( + :stack_effect, + fetch_value(modal_stack, :stack_effect), + [:push_modal, "push_modal"], + action + ) + end + + defp validate_modal_stack_effect(action, modal_stack) + when action in [:close_modal, "close_modal"] do + validate_modal_stack_value( + :stack_effect, + fetch_value(modal_stack, :stack_effect), + [:close_topmost_or_named_modal, "close_topmost_or_named_modal"], + action + ) + end + + defp validate_modal_stack_effect(_action, _modal_stack), do: :ok + + defp validate_modal_stack_value(_field, nil, _allowed, _action), do: :ok + + defp validate_modal_stack_value(field, value, allowed, action) do + if value in allowed do + :ok + else + {:error, {:invalid_modal_stack_value, action, field, value}} + end + end + + defp validate_modal_stack_boolean_fields(modal_stack) do + [:target_required?, :named_target_allowed?, :containment_required?] + |> Enum.reduce_while(:ok, fn field, :ok -> + case fetch_value(modal_stack, field, :not_present) do + :not_present -> {:cont, :ok} + value when is_boolean(value) -> {:cont, :ok} + value -> {:halt, {:error, {:invalid_field, field, value}}} + end + end) + end + + defp validate_modal_stack_containment(modal_stack) do + case fetch_value(modal_stack, :containment_required?) do + true -> {:error, {:invalid_modal_stack_containment, true}} + _other -> :ok + end + end + defp validate_summary(nil, descriptor) do {:error, {:missing_boundary_summary, summarize_boundary_descriptor(descriptor)}} end @@ -332,13 +458,17 @@ defmodule UnifiedIUR.Interactions.Transport do defp navigation_params?(navigation) do navigation - |> Map.get(:params, %{}) + |> fetch_value(:params, %{}) |> normalize_map() |> Kernel.!=(%{}) end defp targetless_navigation?(navigation) do - is_nil(Map.get(navigation, :screen)) and is_nil(Map.get(navigation, :modal)) + is_nil(fetch_value(navigation, :screen)) and is_nil(fetch_value(navigation, :modal)) + end + + defp fetch_value(map, key, default \\ nil) when is_map(map) do + Map.get(map, key, Map.get(map, Atom.to_string(key), default)) end defp forbidden_navigation_key?(key) when is_atom(key), 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 index 3eb1915b..7cbeca68 100644 --- 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 @@ -44,7 +44,11 @@ defmodule UnifiedIUR.CanonicalNavigationBoundaryIntegrationTest do screen: :settings, modal: nil, params?: true, - targetless?: false + targetless?: false, + modal_stack?: false, + modal_stack_operation: nil, + modal_stack_target: nil, + modal_stack_effect: nil } }, summary: %{ @@ -54,7 +58,11 @@ defmodule UnifiedIUR.CanonicalNavigationBoundaryIntegrationTest do screen: :settings, modal: nil, params?: true, - targetless?: false + targetless?: false, + modal_stack?: false, + modal_stack_operation: nil, + modal_stack_target: nil, + modal_stack_effect: nil } } end diff --git a/packages/unified_iur/test/unified_iur/interactions_transport_test.exs b/packages/unified_iur/test/unified_iur/interactions_transport_test.exs index 1dfa8eb9..1384dbe3 100644 --- a/packages/unified_iur/test/unified_iur/interactions_transport_test.exs +++ b/packages/unified_iur/test/unified_iur/interactions_transport_test.exs @@ -25,7 +25,11 @@ defmodule UnifiedIUR.InteractionsTransportTest do screen: :settings, modal: nil, params?: true, - targetless?: false + targetless?: false, + modal_stack?: false, + modal_stack_operation: nil, + modal_stack_target: nil, + modal_stack_effect: nil } assert fixture.extensions == %{ @@ -52,6 +56,48 @@ defmodule UnifiedIUR.InteractionsTransportTest do assert :ok = Transport.validate_boundary_extensions(fixture.extensions) end + test "summarizes and validates modal stack boundary semantics" do + open_fixture = Transport.boundary_fixture!("modal_stack--open_confirm_dialog") + close_top_fixture = Transport.boundary_fixture!("modal_stack--close_top") + close_named_fixture = Transport.boundary_fixture!("modal_stack--close_named_settings") + + assert open_fixture.summary == %{ + family: :navigation, + intent: :open_confirm_modal, + action: :open_modal, + screen: nil, + modal: :settings_confirm_dialog, + params?: true, + targetless?: false, + modal_stack?: true, + modal_stack_operation: :push, + modal_stack_target: :symbolic_modal, + modal_stack_effect: :push_modal + } + + assert close_top_fixture.summary == %{ + family: :navigation, + intent: :close_top_modal, + action: :close_modal, + screen: nil, + modal: nil, + params?: false, + targetless?: true, + modal_stack?: true, + modal_stack_operation: :close, + modal_stack_target: :topmost_modal, + modal_stack_effect: :close_topmost_or_named_modal + } + + assert close_named_fixture.summary.modal == :settings_dialog + refute close_named_fixture.summary.targetless? + assert close_named_fixture.summary.modal_stack_effect == :close_topmost_or_named_modal + + assert :ok = Transport.validate_boundary_fixture(open_fixture) + assert :ok = Transport.validate_boundary_fixture(close_top_fixture) + assert :ok = Transport.validate_boundary_fixture(close_named_fixture) + end + test "rejects leaked router syntax and missing required canonical fields" do assert {:error, {:forbidden_navigation_keys, [:route]}} = Transport.validate_boundary_extensions(%{ @@ -81,8 +127,37 @@ defmodule UnifiedIUR.InteractionsTransportTest do screen: nil, modal: nil, params?: false, - targetless?: true + targetless?: true, + modal_stack?: false, + modal_stack_operation: nil, + modal_stack_target: nil, + modal_stack_effect: nil } }) end + + test "rejects runtime-local modal stack identifiers crossing the boundary" do + assert {:error, {:forbidden_navigation_keys, [:stack_id]}} = + Transport.validate_boundary_extensions(%{ + unified_iur_boundary: %{ + family: :navigation, + intent: :open_confirm_modal, + source_context: %{element_id: "confirm-settings-button"}, + target: %{ + navigation: %{ + action: :open_modal, + modal: :settings_confirm_dialog, + modal_stack: %{ + operation: :push, + target: :symbolic_modal, + stack_effect: :push_modal, + stack_id: "runtime-stack-1" + } + } + }, + metadata: %{} + }, + unified_iur_boundary_summary: %{} + }) + end end From 7ec53c19672c61cd02245cd7ef05b89e73c3277a Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 07:29:20 -0400 Subject: [PATCH 08/13] Cover canonical navigation compiler transport integration --- ...fied-iur-and-signal-transport-alignment.md | 24 +++++------ .../lib/unified_ui/compiler/pipeline.ex | 6 +++ .../canonical_navigation_integration_test.exs | 43 +++++++++++++++++++ .../test/unified_ui/compiler_test.exs | 30 +++++++++++++ 4 files changed, 91 insertions(+), 12 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 490efebe..e64a526a 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 @@ -21,7 +21,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. @@ -91,24 +91,24 @@ Back to index: [README](./README.md) [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. [x] 2.3.2.4 Subtask - Add shared transport fixtures and validation cases for stacked modal flows so runtimes can prove topmost close semantics consistently. - [ ] 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. - [ ] 2.4.1.4 Subtask - Verify stacked modal transitions lower into ordered canonical descriptors without structural containment fields or renderer-local modal stack ids. + [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. + [x] 2.4.1.4 Subtask - Verify stacked modal transitions lower into ordered canonical descriptors without structural containment fields or renderer-local modal stack ids. - [ ] 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. - [ ] 2.4.2.4 Subtask - Verify modal stack fixtures preserve `open_modal` push, targetless top-modal close, symbolic targeted close, params, and metadata across the shared boundary. + [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. + [x] 2.4.2.4 Subtask - Verify modal stack fixtures preserve `open_modal` push, targetless top-modal close, symbolic targeted close, params, and metadata across the shared boundary. diff --git a/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex b/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex index fff6aaa4..27385f3e 100644 --- a/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex +++ b/packages/unified-ui/lib/unified_ui/compiler/pipeline.ex @@ -388,6 +388,12 @@ defmodule UnifiedUi.Compiler.Pipeline do |> 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))) + |> maybe_put( + :modal_stack, + compact_optional_map( + Signal.navigation_modal_stack_semantics()[Map.get(target_intent, :action)] + ) + ) } _other -> 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 52abbaf8..91701c77 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,6 +1,7 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do use ExUnit.Case, async: true + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport alias UnifiedUi.{Compiler, Export, Signals, Tooling} defmodule CanonicalNavigationScreen do @@ -607,6 +608,7 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do navigation: %{ action: :open_modal, kind: :modal_transition, + modal_stack: modal_stack_push(), modal: :settings_confirm_dialog, params: %{from: :settings_dialog} } @@ -616,10 +618,28 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do navigation: %{ action: :close_modal, kind: :modal_transition, + modal_stack: modal_stack_close(), metadata: %{reason: :cancel} } } + open_interaction = + Enum.find(result.interactions, &(&1.intent == :open_settings_confirmation_modal)) + + close_interaction = Enum.find(result.interactions, &(&1.intent == :close_top_modal)) + + open_extensions = BoundaryTransport.boundary_extensions(open_interaction) + close_extensions = BoundaryTransport.boundary_extensions(close_interaction) + + assert :ok = BoundaryTransport.validate_boundary_extensions(open_extensions) + assert :ok = BoundaryTransport.validate_boundary_extensions(close_extensions) + assert open_extensions.unified_iur_boundary_summary.modal_stack_effect == :push_modal + + assert close_extensions.unified_iur_boundary_summary.modal_stack_effect == + :close_topmost_or_named_modal + + assert close_extensions.unified_iur_boundary_summary.targetless? + refute snapshot =~ "route_helper" refute snapshot =~ "stack_id" end @@ -659,6 +679,7 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do navigation: %{ action: :close_modal, kind: :modal_transition, + modal_stack: modal_stack_close(), metadata: %{reason: :done}, modal: :settings_dialog } @@ -668,6 +689,28 @@ defmodule UnifiedUi.CanonicalNavigationIntegrationTest do refute first_snapshot =~ "\"/settings\"" end + defp modal_stack_push do + %{ + operation: :push, + target: :symbolic_modal, + target_required?: true, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :push_modal + } + end + + defp modal_stack_close do + %{ + operation: :close, + target: :topmost_modal, + target_required?: false, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :close_topmost_or_named_modal + } + end + defp compile_module(body) do module_name = "Generated#{System.unique_integer([:positive])}" diff --git a/packages/unified-ui/test/unified_ui/compiler_test.exs b/packages/unified-ui/test/unified_ui/compiler_test.exs index e24f7153..c50643e6 100644 --- a/packages/unified-ui/test/unified_ui/compiler_test.exs +++ b/packages/unified-ui/test/unified_ui/compiler_test.exs @@ -545,6 +545,7 @@ defmodule UnifiedUi.CompilerTest do navigation: %{ action: :open_modal, kind: :modal_transition, + modal_stack: modal_stack_push(), modal: :settings_dialog, params: %{source: :button} } @@ -561,6 +562,7 @@ defmodule UnifiedUi.CompilerTest do navigation: %{ action: :open_modal, kind: :modal_transition, + modal_stack: modal_stack_push(), modal: :confirm_dialog, params: %{from: :settings_dialog} } @@ -577,6 +579,7 @@ defmodule UnifiedUi.CompilerTest do navigation: %{ action: :close_modal, kind: :modal_transition, + modal_stack: modal_stack_close(), metadata: %{reason: :dismiss} } }, @@ -592,6 +595,7 @@ defmodule UnifiedUi.CompilerTest do navigation: %{ action: :close_modal, kind: :modal_transition, + modal_stack: modal_stack_close(), metadata: %{reason: :done}, modal: :settings_dialog } @@ -651,6 +655,7 @@ defmodule UnifiedUi.CompilerTest do navigation: %{ action: :open_modal, kind: :modal_transition, + modal_stack: modal_stack_push(), modal: :settings_dialog, params: %{source: :button} } @@ -660,6 +665,7 @@ defmodule UnifiedUi.CompilerTest do navigation: %{ action: :open_modal, kind: :modal_transition, + modal_stack: modal_stack_push(), modal: :confirm_dialog, params: %{from: :settings_dialog} } @@ -669,6 +675,7 @@ defmodule UnifiedUi.CompilerTest do navigation: %{ action: :close_modal, kind: :modal_transition, + modal_stack: modal_stack_close(), metadata: %{reason: :dismiss} } }}, @@ -677,10 +684,33 @@ defmodule UnifiedUi.CompilerTest do navigation: %{ action: :close_modal, kind: :modal_transition, + modal_stack: modal_stack_close(), metadata: %{reason: :done}, modal: :settings_dialog } }} ] end + + defp modal_stack_push do + %{ + operation: :push, + target: :symbolic_modal, + target_required?: true, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :push_modal + } + end + + defp modal_stack_close do + %{ + operation: :close, + target: :topmost_modal, + target_required?: false, + named_target_allowed?: true, + containment_required?: false, + stack_effect: :close_topmost_or_named_modal + } + end end From 587296340b911950399bba672b65fdc8dfdb53b8 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 07:32:08 -0400 Subject: [PATCH 09/13] Refine shared modal stack boundary summaries --- ...untime-modal-stack-navigation-alignment.md | 18 ++++---- .../lib/unified_iur/interactions/transport.ex | 31 +++++++++---- ...l_navigation_boundary_integration_test.exs | 10 +++- .../interactions_transport_test.exs | 46 +++++++++++++++++-- 4 files changed, 80 insertions(+), 25 deletions(-) diff --git a/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md b/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md index 423263f9..0b243d92 100644 --- a/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md +++ b/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md @@ -29,25 +29,25 @@ Back to index: [README](./README.md) portable while allowing each runtime to realize the experience through its native host model. - [ ] 3.1 Section - Shared Modal Stack Fixtures and Boundary Expectations + [x] 3.1 Section - Shared Modal Stack Fixtures and Boundary Expectations Extend shared canonical fixtures, validation, and review output so every runtime can consume the same stacked modal transition scenarios. - [ ] 3.1.1 Task - Add shared modal stack transition fixtures + [x] 3.1.1 Task - Add shared modal stack transition fixtures Define the canonical fixture set that represents the new stack requirements without making any runtime the source of truth. - [ ] 3.1.1.1 Subtask - Add an `open_modal` fixture for a first symbolic modal target with params and metadata. - [ ] 3.1.1.2 Subtask - Add a second `open_modal` fixture that represents opening another modal while one is already active. - [ ] 3.1.1.3 Subtask - Add targetless and targeted `close_modal` fixtures that prove topmost close and named-modal close semantics stay portable. + [x] 3.1.1.1 Subtask - Add an `open_modal` fixture for a first symbolic modal target with params and metadata. + [x] 3.1.1.2 Subtask - Add a second `open_modal` fixture that represents opening another modal while one is already active. + [x] 3.1.1.3 Subtask - Add targetless and targeted `close_modal` fixtures that prove topmost close and named-modal close semantics stay portable. - [ ] 3.1.2 Task - Add shared validation and inspection for modal stacks + [x] 3.1.2 Task - Add shared validation and inspection for modal stacks Make modal stack behavior visible and deterministic before runtime package-specific handling begins. - [ ] 3.1.2.1 Subtask - Extend shared fixture validation so modal stack descriptors reject host-router fields, runtime-local stack ids, and structural modal containment assumptions. - [ ] 3.1.2.2 Subtask - Extend fixture summaries to show modal stack action, symbolic modal target, params, metadata, and whether a close is topmost or targeted. - [ ] 3.1.2.3 Subtask - Keep fixture serialization deterministic so stacked modal review output remains diff-friendly. + [x] 3.1.2.1 Subtask - Extend shared fixture validation so modal stack descriptors reject host-router fields, runtime-local stack ids, and structural modal containment assumptions. + [x] 3.1.2.2 Subtask - Extend fixture summaries to show modal stack action, symbolic modal target, params, metadata, and whether a close is topmost or targeted. + [x] 3.1.2.3 Subtask - Keep fixture serialization deterministic so stacked modal review output remains diff-friendly. [ ] 3.2 Section - Web Runtime Modal Stack Handling Align the web runtimes with the shared canonical modal stack contract while diff --git a/packages/unified_iur/lib/unified_iur/interactions/transport.ex b/packages/unified_iur/lib/unified_iur/interactions/transport.ex index 92f7e141..aaff5132 100644 --- a/packages/unified_iur/lib/unified_iur/interactions/transport.ex +++ b/packages/unified_iur/lib/unified_iur/interactions/transport.ex @@ -40,12 +40,15 @@ defmodule UnifiedIUR.Interactions.Transport do action: atom() | String.t() | nil, screen: atom() | String.t() | nil, modal: atom() | String.t() | nil, + params: map(), + metadata: map(), params?: boolean(), targetless?: boolean(), modal_stack?: boolean(), modal_stack_operation: atom() | String.t() | nil, modal_stack_target: atom() | String.t() | nil, - modal_stack_effect: atom() | String.t() | nil + modal_stack_effect: atom() | String.t() | nil, + modal_stack_close: :topmost | :targeted | nil } @spec extension_key() :: atom() @@ -129,6 +132,8 @@ defmodule UnifiedIUR.Interactions.Transport do descriptor = normalize_descriptor(descriptor) navigation = Interaction.navigation_descriptor(descriptor.target) || %{} modal_stack = navigation |> fetch_value(:modal_stack, %{}) |> normalize_map() + params = navigation |> fetch_value(:params, %{}) |> normalize_map() + metadata = navigation |> fetch_value(:metadata, %{}) |> normalize_map() %{ family: :navigation, @@ -136,12 +141,15 @@ defmodule UnifiedIUR.Interactions.Transport do action: Map.get(navigation, :action), screen: Map.get(navigation, :screen), modal: Map.get(navigation, :modal), - params?: navigation_params?(navigation), + params: params, + metadata: metadata, + params?: params != %{}, targetless?: targetless_navigation?(navigation), modal_stack?: modal_stack != %{}, modal_stack_operation: fetch_value(modal_stack, :operation), modal_stack_target: fetch_value(modal_stack, :target), - modal_stack_effect: fetch_value(modal_stack, :stack_effect) + modal_stack_effect: fetch_value(modal_stack, :stack_effect), + modal_stack_close: modal_stack_close(navigation) } end @@ -456,17 +464,20 @@ defmodule UnifiedIUR.Interactions.Transport do Map.get(target, :navigation) || Map.get(target, "navigation") || %{} end - defp navigation_params?(navigation) do - navigation - |> fetch_value(:params, %{}) - |> normalize_map() - |> Kernel.!=(%{}) - end - defp targetless_navigation?(navigation) do is_nil(fetch_value(navigation, :screen)) and is_nil(fetch_value(navigation, :modal)) end + defp modal_stack_close(navigation) do + case fetch_value(navigation, :action) do + action when action in [:close_modal, "close_modal"] -> + if targetless_navigation?(navigation), do: :topmost, else: :targeted + + _other -> + nil + end + end + defp fetch_value(map, key, default \\ nil) when is_map(map) do Map.get(map, key, Map.get(map, Atom.to_string(key), default)) end 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 index 7cbeca68..96377466 100644 --- 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 @@ -43,12 +43,15 @@ defmodule UnifiedIUR.CanonicalNavigationBoundaryIntegrationTest do action: :navigate_to, screen: :settings, modal: nil, + params: %{tab: :profile}, + metadata: %{}, params?: true, targetless?: false, modal_stack?: false, modal_stack_operation: nil, modal_stack_target: nil, - modal_stack_effect: nil + modal_stack_effect: nil, + modal_stack_close: nil } }, summary: %{ @@ -57,12 +60,15 @@ defmodule UnifiedIUR.CanonicalNavigationBoundaryIntegrationTest do action: :navigate_to, screen: :settings, modal: nil, + params: %{tab: :profile}, + metadata: %{}, params?: true, targetless?: false, modal_stack?: false, modal_stack_operation: nil, modal_stack_target: nil, - modal_stack_effect: nil + modal_stack_effect: nil, + modal_stack_close: nil } } end diff --git a/packages/unified_iur/test/unified_iur/interactions_transport_test.exs b/packages/unified_iur/test/unified_iur/interactions_transport_test.exs index 1384dbe3..3a47c929 100644 --- a/packages/unified_iur/test/unified_iur/interactions_transport_test.exs +++ b/packages/unified_iur/test/unified_iur/interactions_transport_test.exs @@ -24,12 +24,15 @@ defmodule UnifiedIUR.InteractionsTransportTest do action: :navigate_to, screen: :settings, modal: nil, + params: %{tab: :profile}, + metadata: %{}, params?: true, targetless?: false, modal_stack?: false, modal_stack_operation: nil, modal_stack_target: nil, - modal_stack_effect: nil + modal_stack_effect: nil, + modal_stack_close: nil } assert fixture.extensions == %{ @@ -67,12 +70,15 @@ defmodule UnifiedIUR.InteractionsTransportTest do action: :open_modal, screen: nil, modal: :settings_confirm_dialog, + params: %{from: :settings_dialog}, + metadata: %{previous_modal: :settings_dialog}, params?: true, targetless?: false, modal_stack?: true, modal_stack_operation: :push, modal_stack_target: :symbolic_modal, - modal_stack_effect: :push_modal + modal_stack_effect: :push_modal, + modal_stack_close: nil } assert close_top_fixture.summary == %{ @@ -81,16 +87,20 @@ defmodule UnifiedIUR.InteractionsTransportTest do action: :close_modal, screen: nil, modal: nil, + params: %{}, + metadata: %{reason: :cancel}, params?: false, targetless?: true, modal_stack?: true, modal_stack_operation: :close, modal_stack_target: :topmost_modal, - modal_stack_effect: :close_topmost_or_named_modal + modal_stack_effect: :close_topmost_or_named_modal, + modal_stack_close: :topmost } assert close_named_fixture.summary.modal == :settings_dialog refute close_named_fixture.summary.targetless? + assert close_named_fixture.summary.modal_stack_close == :targeted assert close_named_fixture.summary.modal_stack_effect == :close_topmost_or_named_modal assert :ok = Transport.validate_boundary_fixture(open_fixture) @@ -126,12 +136,15 @@ defmodule UnifiedIUR.InteractionsTransportTest do action: :navigate_to, screen: nil, modal: nil, + params: %{}, + metadata: %{}, params?: false, targetless?: true, modal_stack?: false, modal_stack_operation: nil, modal_stack_target: nil, - modal_stack_effect: nil + modal_stack_effect: nil, + modal_stack_close: nil } }) end @@ -160,4 +173,29 @@ defmodule UnifiedIUR.InteractionsTransportTest do unified_iur_boundary_summary: %{} }) end + + test "rejects structural modal containment in shared stack descriptors" do + assert {:error, {:invalid_modal_stack_containment, true}} = + Transport.validate_boundary_extensions(%{ + unified_iur_boundary: %{ + family: :navigation, + intent: :open_confirm_modal, + source_context: %{element_id: "confirm-settings-button"}, + target: %{ + navigation: %{ + action: :open_modal, + modal: :settings_confirm_dialog, + modal_stack: %{ + operation: :push, + target: :symbolic_modal, + stack_effect: :push_modal, + containment_required?: true + } + } + }, + metadata: %{} + }, + unified_iur_boundary_summary: %{} + }) + end end From df63b20159f6d0cac9c72c977e444c6a9df78e8d Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 07:37:20 -0400 Subject: [PATCH 10/13] Align web runtime modal stack navigation --- ...untime-modal-stack-navigation-alignment.md | 18 +-- .../lib/elm_ui/server_runtime/navigation.ex | 21 ++- .../lib/elm_ui/transport/diagnostics.ex | 16 ++- ...avigation_web_runtime_integration_test.exs | 120 ++++++++++++++++++ .../live_ui/lib/live_ui/runtime/navigation.ex | 21 ++- .../lib/live_ui/transport/diagnostics.ex | 16 ++- .../test/live_ui/runtime_navigation_test.exs | 109 ++++++++++++++++ 7 files changed, 292 insertions(+), 29 deletions(-) diff --git a/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md b/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md index 0b243d92..07f7a4dc 100644 --- a/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md +++ b/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md @@ -49,26 +49,26 @@ Back to index: [README](./README.md) [x] 3.1.2.2 Subtask - Extend fixture summaries to show modal stack action, symbolic modal target, params, metadata, and whether a close is topmost or targeted. [x] 3.1.2.3 Subtask - Keep fixture serialization deterministic so stacked modal review output remains diff-friendly. - [ ] 3.2 Section - Web Runtime Modal Stack Handling + [x] 3.2 Section - Web Runtime Modal Stack Handling Align the web runtimes with the shared canonical modal stack contract while preserving each runtime's existing server-authoritative model. - [ ] 3.2.1 Task - Implement LiveUi server-authoritative modal stack handling + [x] 3.2.1 Task - Implement LiveUi server-authoritative modal stack handling Ensure `live_ui` resolves canonical modal transitions through server state and exposes the current modal stack predictably to rendering and inspection surfaces. - [ ] 3.2.1.1 Subtask - Verify or implement `open_modal` appends symbolic modal entries with params and metadata to the server-authoritative modal stack. - [ ] 3.2.1.2 Subtask - Verify or implement targetless `close_modal` closes the topmost modal and targeted `close_modal` closes the matching open modal without mutating screen history. - [ ] 3.2.1.3 Subtask - Expose current modal and modal stack summaries through assigns, inspection, and runtime navigation summaries without leaking Phoenix route details. + [x] 3.2.1.1 Subtask - Verify or implement `open_modal` appends symbolic modal entries with params and metadata to the server-authoritative modal stack. + [x] 3.2.1.2 Subtask - Verify or implement targetless `close_modal` closes the topmost modal and targeted `close_modal` closes the matching open modal without mutating screen history. + [x] 3.2.1.3 Subtask - Expose current modal and modal stack summaries through assigns, inspection, and runtime navigation summaries without leaking Phoenix route details. - [ ] 3.2.2 Task - Implement ElmUi server and frontend modal stack reflection + [x] 3.2.2 Task - Implement ElmUi server and frontend modal stack reflection Ensure `elm_ui` keeps the Phoenix server runtime authoritative while the frontend runtime reflects the resulting modal stack state. - [ ] 3.2.2.1 Subtask - Verify or implement server-side modal stack push and close semantics for canonical `open_modal` and `close_modal` transitions. - [ ] 3.2.2.2 Subtask - Update authoritative screen payloads and frontend acknowledgements so current modal and modal stack state are reflected after each transition. - [ ] 3.2.2.3 Subtask - Add diagnostics for missing modal targets, invalid targeted close, and frontend/server modal stack divergence. + [x] 3.2.2.1 Subtask - Verify or implement server-side modal stack push and close semantics for canonical `open_modal` and `close_modal` transitions. + [x] 3.2.2.2 Subtask - Update authoritative screen payloads and frontend acknowledgements so current modal and modal stack state are reflected after each transition. + [x] 3.2.2.3 Subtask - Add diagnostics for missing modal targets, invalid targeted close, and frontend/server modal stack divergence. [ ] 3.3 Section - Desktop and Terminal Runtime Modal Stack Handling Align desktop and terminal runtimes with the shared stack contract while diff --git a/packages/elm_ui/lib/elm_ui/server_runtime/navigation.ex b/packages/elm_ui/lib/elm_ui/server_runtime/navigation.ex index 8e566fa9..8f9a271b 100644 --- a/packages/elm_ui/lib/elm_ui/server_runtime/navigation.ex +++ b/packages/elm_ui/lib/elm_ui/server_runtime/navigation.ex @@ -582,11 +582,7 @@ defmodule ElmUi.ServerRuntime.Navigation do target = normalize_map(Map.get(translation, :target, %{})) navigation = normalize_map(raw_navigation_descriptor(target) || %{}) - leaked_keys = - navigation - |> Map.keys() - |> Enum.filter(&forbidden_navigation_key?/1) - |> Enum.uniq() + leaked_keys = forbidden_navigation_keys(navigation) if leaked_keys == [] do :ok @@ -608,6 +604,17 @@ defmodule ElmUi.ServerRuntime.Navigation do defp raw_navigation_descriptor(_other), do: nil + defp forbidden_navigation_keys(navigation) do + modal_stack = + navigation + |> Map.get(:modal_stack, Map.get(navigation, "modal_stack", %{})) + |> normalize_map() + + (Map.keys(navigation) ++ Map.keys(modal_stack)) + |> Enum.filter(&forbidden_navigation_key?/1) + |> Enum.uniq() + end + defp resolve_candidate(%{registry: registry}, screen_id) do case Enum.find(registry, fn {key, _value} -> key_matches?(key, screen_id) end) do nil -> {:error, :unknown_screen} @@ -643,7 +650,9 @@ defmodule ElmUi.ServerRuntime.Navigation do action: Map.get(descriptor, :action), screen: Map.get(descriptor, :screen), modal: Map.get(descriptor, :modal), - params: normalize_map(Map.get(descriptor, :params, %{})) + params: normalize_map(Map.get(descriptor, :params, %{})), + metadata: normalize_map(Map.get(descriptor, :metadata, %{})), + modal_stack: normalize_map(Map.get(descriptor, :modal_stack, %{})) } end diff --git a/packages/elm_ui/lib/elm_ui/transport/diagnostics.ex b/packages/elm_ui/lib/elm_ui/transport/diagnostics.ex index 1fc63a32..24fc5407 100644 --- a/packages/elm_ui/lib/elm_ui/transport/diagnostics.ex +++ b/packages/elm_ui/lib/elm_ui/transport/diagnostics.ex @@ -160,10 +160,7 @@ defmodule ElmUi.Transport.Diagnostics do target = normalize_map(target) navigation = Map.get(target, :navigation) || Map.get(target, "navigation") || %{} - leaked_keys = - navigation - |> Map.keys() - |> Enum.filter(&forbidden_navigation_key?/1) + leaked_keys = forbidden_navigation_keys(normalize_map(navigation)) if leaked_keys == [] do :ok @@ -217,6 +214,17 @@ defmodule ElmUi.Transport.Diagnostics do defp renderer_local_key?(_key), do: false + defp forbidden_navigation_keys(navigation) do + modal_stack = + navigation + |> Map.get(:modal_stack, Map.get(navigation, "modal_stack", %{})) + |> normalize_map() + + (Map.keys(navigation) ++ Map.keys(modal_stack)) + |> Enum.filter(&forbidden_navigation_key?/1) + |> Enum.uniq() + end + defp forbidden_navigation_key?(key) when is_atom(key), do: key in BoundaryTransport.forbidden_navigation_keys() diff --git a/packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs b/packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs index d54e1f94..9b9c9ab2 100644 --- a/packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs +++ b/packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs @@ -36,6 +36,62 @@ defmodule ElmUi.CanonicalNavigationWebRuntimeIntegrationTest do assert comparison.continuity.server_authority_preserved? end + test "server-authoritative modal stack fixtures are reflected in frontend acknowledgements" do + home_element = + Element.new(:widget, :text, id: "home", attributes: %{content: "Home"}) + + first_modal = BoundaryTransport.boundary_fixture!("modal_transition--settings_dialog") + second_modal = BoundaryTransport.boundary_fixture!("modal_stack--open_confirm_dialog") + close_top = BoundaryTransport.boundary_fixture!("modal_stack--close_top") + close_named = BoundaryTransport.boundary_fixture!("modal_stack--close_named_settings") + + assert {:ok, runtime_state} = + ElmUi.Runtime.mount_iur_screen(home_element, runtime_id: "elm-ui-modal-stack") + + assert {:ok, with_first_modal, _ack} = apply_fixture(runtime_state, first_modal) + assert {:ok, with_second_modal, ack} = apply_fixture(with_first_modal, second_modal) + + navigation = ElmUi.ServerRuntime.State.navigation_summary(with_second_modal) + + assert navigation.history == [] + + assert navigation.modals == [ + %{ + modal: :settings_dialog, + params: %{mode: :advanced}, + metadata: %{surface: :workspace} + }, + %{ + modal: :settings_confirm_dialog, + params: %{from: :settings_dialog}, + metadata: %{previous_modal: :settings_dialog} + } + ] + + assert navigation.current_modal.modal == :settings_confirm_dialog + assert navigation.last_transition.modal_stack.stack_effect == :push_modal + + assert ack.payload.authoritative_screen.metadata.navigation.current_modal.modal == + :settings_confirm_dialog + + assert ack.payload.authoritative_screen.metadata.navigation.modals == navigation.modals + + assert {:ok, after_named_close, _ack} = apply_fixture(with_second_modal, close_named) + + assert ElmUi.ServerRuntime.State.navigation_summary(after_named_close).modals == [ + %{ + modal: :settings_confirm_dialog, + params: %{from: :settings_dialog}, + metadata: %{previous_modal: :settings_dialog} + } + ] + + assert {:ok, after_top_close, _ack} = apply_fixture(after_named_close, close_top) + + assert ElmUi.ServerRuntime.State.navigation_summary(after_top_close).modals == [] + assert ElmUi.ServerRuntime.State.navigation_summary(after_top_close).current_modal == nil + end + test "canonical elm_ui navigation keeps host-route state outside the transition contract" do comparison = ElmUi.Examples.navigation_comparison() @@ -110,6 +166,48 @@ defmodule ElmUi.CanonicalNavigationWebRuntimeIntegrationTest do end) end + test "modal stack diagnostics reject invalid targeted close and runtime-local stack ids" do + home_element = + Element.new(:widget, :text, id: "home", attributes: %{content: "Home"}) + + close_named = BoundaryTransport.boundary_fixture!("modal_stack--close_named_settings") + + assert {:ok, runtime_state} = + ElmUi.Runtime.mount_iur_screen(home_element, runtime_id: "elm-ui-modal-stack-errors") + + assert {:ok, close_translation} = translation_for_fixture(runtime_state, close_named) + + assert {:error, %ElmUi.ServerRuntime.Error{reason: :unsupported_navigation_context} = error} = + ElmUi.ServerRuntime.handle_event(runtime_state, close_translation) + + assert error.details.modal == :settings_dialog + + assert {:error, %ElmUi.Transport.Error{reason: :host_route_syntax} = transport_error} = + ElmUi.Transport.from_native_event( + family: :navigation, + intent: :open_settings_modal, + widget_id: "settings-link", + screen: runtime_state.screen_id, + runtime_id: runtime_state.runtime_id, + source_kind: :canonical, + boundary_mode: :canonical_boundary, + target: %{ + navigation: %{ + action: :open_modal, + modal: :settings_dialog, + modal_stack: %{ + operation: :push, + target: :symbolic_modal, + stack_effect: :push_modal, + stack_id: "runtime-stack" + } + } + } + ) + + assert transport_error.details.keys == [:stack_id] + end + defp transition_summary(target) do %{ action: get_value(target, :action), @@ -132,4 +230,26 @@ defmodule ElmUi.CanonicalNavigationWebRuntimeIntegrationTest do defp normalize_map(map) when is_map(map), do: Map.new(map) defp normalize_map(_other), do: %{} + + defp apply_fixture(runtime_state, fixture) do + with {:ok, translation} <- translation_for_fixture(runtime_state, fixture), + {:ok, next_state} <- ElmUi.ServerRuntime.handle_event(runtime_state, translation) do + {:ok, next_state, + ElmUi.ServerRuntime.SyncBoundary.acknowledgement_envelope(next_state, translation)} + end + end + + defp translation_for_fixture(runtime_state, fixture) do + ElmUi.Transport.from_native_event( + family: :navigation, + intent: fixture.interaction.intent, + widget_id: fixture.interaction.source.element_id, + screen: runtime_state.screen_id, + runtime_id: runtime_state.runtime_id, + source_kind: :canonical, + boundary_mode: :canonical_boundary, + target: fixture.descriptor.target, + payload: fixture.signal_data + ) + end end diff --git a/packages/live_ui/lib/live_ui/runtime/navigation.ex b/packages/live_ui/lib/live_ui/runtime/navigation.ex index 3c7bf91b..49076be1 100644 --- a/packages/live_ui/lib/live_ui/runtime/navigation.ex +++ b/packages/live_ui/lib/live_ui/runtime/navigation.ex @@ -560,7 +560,9 @@ defmodule LiveUi.Runtime.Navigation do action: Map.get(descriptor, :action), screen: Map.get(descriptor, :screen), modal: Map.get(descriptor, :modal), - params: normalize_map(Map.get(descriptor, :params, %{})) + params: normalize_map(Map.get(descriptor, :params, %{})), + metadata: normalize_map(Map.get(descriptor, :metadata, %{})), + modal_stack: normalize_map(Map.get(descriptor, :modal_stack, %{})) } end @@ -592,11 +594,7 @@ defmodule LiveUi.Runtime.Navigation do target = normalize_map(Map.get(runtime_action, :target, %{})) navigation = normalize_map(raw_navigation_descriptor(target) || %{}) - leaked_keys = - navigation - |> Map.keys() - |> Enum.filter(&forbidden_navigation_key?/1) - |> Enum.uniq() + leaked_keys = forbidden_navigation_keys(navigation) if leaked_keys == [] do :ok @@ -611,6 +609,17 @@ defmodule LiveUi.Runtime.Navigation do defp raw_navigation_descriptor(_other), do: nil + defp forbidden_navigation_keys(navigation) do + modal_stack = + navigation + |> Map.get(:modal_stack, Map.get(navigation, "modal_stack", %{})) + |> normalize_map() + + (Map.keys(navigation) ++ Map.keys(modal_stack)) + |> Enum.filter(&forbidden_navigation_key?/1) + |> Enum.uniq() + end + defp lookup_registry(registry, screen_id) when is_map(registry) do case Enum.find(registry, fn {key, _value} -> key_matches?(key, screen_id) end) do nil -> {:error, :unknown_screen} diff --git a/packages/live_ui/lib/live_ui/transport/diagnostics.ex b/packages/live_ui/lib/live_ui/transport/diagnostics.ex index f4d0ac66..0b18b51f 100644 --- a/packages/live_ui/lib/live_ui/transport/diagnostics.ex +++ b/packages/live_ui/lib/live_ui/transport/diagnostics.ex @@ -137,10 +137,7 @@ defmodule LiveUi.Transport.Diagnostics do target = normalize_map(target) navigation = Map.get(target, :navigation) || Map.get(target, "navigation") || %{} - leaked_keys = - navigation - |> Map.keys() - |> Enum.filter(&forbidden_navigation_key?/1) + leaked_keys = forbidden_navigation_keys(navigation) if leaked_keys == [] do :ok @@ -155,6 +152,17 @@ defmodule LiveUi.Transport.Diagnostics do Enum.any?(@renderer_local_payload_prefixes, &String.starts_with?(key, &1)) end + defp forbidden_navigation_keys(navigation) do + modal_stack = + navigation + |> Map.get(:modal_stack, Map.get(navigation, "modal_stack", %{})) + |> normalize_map() + + (Map.keys(navigation) ++ Map.keys(modal_stack)) + |> Enum.filter(&forbidden_navigation_key?/1) + |> Enum.uniq() + end + defp forbidden_navigation_key?(key) when is_atom(key), do: key in BoundaryTransport.forbidden_navigation_keys() diff --git a/packages/live_ui/test/live_ui/runtime_navigation_test.exs b/packages/live_ui/test/live_ui/runtime_navigation_test.exs index 60764e24..5ec25c2b 100644 --- a/packages/live_ui/test/live_ui/runtime_navigation_test.exs +++ b/packages/live_ui/test/live_ui/runtime_navigation_test.exs @@ -143,6 +143,75 @@ defmodule LiveUi.RuntimeNavigationTest do ] end + test "canonical modal stack fixtures preserve stack behavior without mutating history" do + home_element = + Element.new(:widget, :text, id: :workspace_home, attributes: %{content: "Workspace Home"}) + + first_modal = BoundaryTransport.boundary_fixture!("modal_transition--settings_dialog") + second_modal = BoundaryTransport.boundary_fixture!("modal_stack--open_confirm_dialog") + close_top = BoundaryTransport.boundary_fixture!("modal_stack--close_top") + close_named = BoundaryTransport.boundary_fixture!("modal_stack--close_named_settings") + + assert {:ok, runtime_state} = + LiveUi.Runtime.mount_iur(home_element, screen_id: :workspace_home) + + assert {:ok, with_first_modal, _runtime_action} = + LiveUi.Runtime.handle_boundary_signal( + runtime_state, + boundary_signal(first_modal, :workspace_home) + ) + + assert {:ok, with_second_modal, _runtime_action} = + LiveUi.Runtime.handle_boundary_signal( + with_first_modal, + boundary_signal(second_modal, :workspace_home) + ) + + assert with_second_modal.assigns.navigation_history == [] + + assert with_second_modal.assigns.navigation_modal_stack == [ + %{ + modal: :settings_dialog, + params: %{mode: :advanced}, + metadata: %{surface: :workspace} + }, + %{ + modal: :settings_confirm_dialog, + params: %{from: :settings_dialog}, + metadata: %{previous_modal: :settings_dialog} + } + ] + + assert with_second_modal.assigns.current_modal.modal == :settings_confirm_dialog + assert with_second_modal.navigation.last_transition.modal_stack.stack_effect == :push_modal + + assert {:ok, after_named_close, _runtime_action} = + LiveUi.Runtime.handle_boundary_signal( + with_second_modal, + boundary_signal(close_named, :workspace_home) + ) + + assert after_named_close.assigns.navigation_modal_stack == [ + %{ + modal: :settings_confirm_dialog, + params: %{from: :settings_dialog}, + metadata: %{previous_modal: :settings_dialog} + } + ] + + assert after_named_close.assigns.current_modal.modal == :settings_confirm_dialog + + assert {:ok, after_top_close, _runtime_action} = + LiveUi.Runtime.handle_boundary_signal( + after_named_close, + boundary_signal(close_top, :workspace_home) + ) + + assert after_top_close.assigns.navigation_modal_stack == [] + assert after_top_close.assigns.current_modal == nil + assert after_top_close.assigns.navigation_history == [] + end + test "navigation failures surface deterministic diagnostics" do assert {:ok, runtime_state} = LiveUi.Runtime.mount(HomeScreen) @@ -168,4 +237,44 @@ defmodule LiveUi.RuntimeNavigationTest do } ) end + + test "modal stack runtime-local identifiers are rejected" do + assert {:ok, runtime_state} = LiveUi.Runtime.mount(HomeScreen) + + assert {:error, %LiveUi.Runtime.Error{reason: :host_route_navigation_syntax} = error} = + LiveUi.Runtime.dispatch_native_event( + runtime_state, + "navigation:open_runtime_stack", + %{}, + family: :navigation, + intent: :open_settings_modal, + target: %{ + navigation: %{ + action: :open_modal, + modal: :settings_dialog, + modal_stack: %{ + operation: :push, + target: :symbolic_modal, + stack_effect: :push_modal, + stack_id: "runtime-stack" + } + } + } + ) + + assert error.details.keys == [:stack_id] + end + + defp boundary_signal(fixture, screen) do + assert {:ok, translation} = + LiveUi.Signals.from_interaction( + fixture.interaction, + screen: screen, + mode: :screen, + boundary: :boundary, + payload: fixture.signal_data + ) + + translation.signal + end end From ab31e9b3d744fff363561b4fa41376348143185f Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 07:44:16 -0400 Subject: [PATCH 11/13] Align desktop and terminal modal stack navigation --- ...untime-modal-stack-navigation-alignment.md | 18 +-- .../desktop_ui/lib/desktop_ui/navigation.ex | 16 ++- .../lib/desktop_ui/navigation/controller.ex | 68 +++++++-- .../lib/desktop_ui/navigation/signal.ex | 31 ++-- .../desktop_ui/navigation/controller_test.exs | 41 ++++-- .../desktop_ui/navigation/signal_test.exs | 28 +++- .../terminal_ui/lib/terminal_ui/inspection.ex | 1 + .../terminal_ui/lib/terminal_ui/runtime.ex | 132 +++++++++++++++++- .../lib/terminal_ui/runtime/state.ex | 7 + ..._navigation_transport_integration_test.exs | 91 ++++++++++++ 10 files changed, 386 insertions(+), 47 deletions(-) diff --git a/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md b/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md index 07f7a4dc..a3e7aac2 100644 --- a/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md +++ b/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md @@ -70,26 +70,26 @@ Back to index: [README](./README.md) [x] 3.2.2.2 Subtask - Update authoritative screen payloads and frontend acknowledgements so current modal and modal stack state are reflected after each transition. [x] 3.2.2.3 Subtask - Add diagnostics for missing modal targets, invalid targeted close, and frontend/server modal stack divergence. - [ ] 3.3 Section - Desktop and Terminal Runtime Modal Stack Handling + [x] 3.3 Section - Desktop and Terminal Runtime Modal Stack Handling Align desktop and terminal runtimes with the shared stack contract while respecting their native presentation and capability constraints. - [ ] 3.3.1 Task - Implement DesktopUi modal stack navigation behavior + [x] 3.3.1 Task - Implement DesktopUi modal stack navigation behavior Keep desktop modal navigation as an independent stack managed by the navigation controller and rendered through desktop-native overlay behavior. - [ ] 3.3.1.1 Subtask - Verify or implement controller state transitions where each `open_modal` pushes and targetless `close_modal` pops the top modal while preserving history and forward stacks. - [ ] 3.3.1.2 Subtask - Verify or implement current/top modal helpers, modal depth reporting, and targeted close diagnostics for named symbolic modal targets. - [ ] 3.3.1.3 Subtask - Align modal overlay rendering, focus behavior, and navigation events with the controller stack without requiring canonical structural containment. + [x] 3.3.1.1 Subtask - Verify or implement controller state transitions where each `open_modal` pushes and targetless `close_modal` pops the top modal while preserving history and forward stacks. + [x] 3.3.1.2 Subtask - Verify or implement current/top modal helpers, modal depth reporting, and targeted close diagnostics for named symbolic modal targets. + [x] 3.3.1.3 Subtask - Align modal overlay rendering, focus behavior, and navigation events with the controller stack without requiring canonical structural containment. - [ ] 3.3.2 Task - Implement TerminalUi modal stack degradation behavior + [x] 3.3.2 Task - Implement TerminalUi modal stack degradation behavior Preserve canonical modal stack meaning in terminal runtime state even when visual presentation must degrade for terminal capabilities. - [ ] 3.3.2.1 Subtask - Add terminal runtime navigation state for modal stack entries, current modal, transition summaries, and bounded fallback metadata. - [ ] 3.3.2.2 Subtask - Implement `open_modal`, targetless `close_modal`, and targeted `close_modal` using terminal-appropriate panels, inline overlays, or bounded section transitions. - [ ] 3.3.2.3 Subtask - Add diagnostics and inspection output that show degradation decisions while preserving canonical stack meaning. + [x] 3.3.2.1 Subtask - Add terminal runtime navigation state for modal stack entries, current modal, transition summaries, and bounded fallback metadata. + [x] 3.3.2.2 Subtask - Implement `open_modal`, targetless `close_modal`, and targeted `close_modal` using terminal-appropriate panels, inline overlays, or bounded section transitions. + [x] 3.3.2.3 Subtask - Add diagnostics and inspection output that show degradation decisions while preserving canonical stack meaning. [ ] 3.4 Section - Examples, Documentation, and Traceability Update maintainer-facing examples and planning evidence so the new modal diff --git a/packages/desktop_ui/lib/desktop_ui/navigation.ex b/packages/desktop_ui/lib/desktop_ui/navigation.ex index d74a80ec..9689a875 100644 --- a/packages/desktop_ui/lib/desktop_ui/navigation.ex +++ b/packages/desktop_ui/lib/desktop_ui/navigation.ex @@ -169,9 +169,9 @@ defmodule DesktopUi.Navigation do @doc """ Closes the top modal dialog. """ - @spec close_modal(GenServer.server() | atom()) :: + @spec close_modal(GenServer.server() | atom(), screen_id() | nil) :: {:ok, State.t(), transition()} | {:error, term()} - defdelegate close_modal(server), to: Controller + def close_modal(server, screen_id \\ nil), do: Controller.close_modal(server, screen_id) @doc """ Gets the current navigation state. @@ -203,4 +203,16 @@ defmodule DesktopUi.Navigation do """ @spec modal_open?(State.t()) :: boolean() defdelegate modal_open?(state), to: State, as: :modal_open? + + @doc """ + Returns the top modal entry for the given state. + """ + @spec top_modal(State.t()) :: {screen_id(), screen_module(), params()} | nil + defdelegate top_modal(state), to: State + + @doc """ + Returns the number of open modals in the given state. + """ + @spec modal_depth(State.t()) :: non_neg_integer() + defdelegate modal_depth(state), to: State end diff --git a/packages/desktop_ui/lib/desktop_ui/navigation/controller.ex b/packages/desktop_ui/lib/desktop_ui/navigation/controller.ex index 77f34b0a..2c514783 100644 --- a/packages/desktop_ui/lib/desktop_ui/navigation/controller.ex +++ b/packages/desktop_ui/lib/desktop_ui/navigation/controller.ex @@ -21,7 +21,9 @@ defmodule DesktopUi.Navigation.Controller do ] @type transition_result :: - {:ok, State.t(), {:transition, :navigated | :replaced | :back | :forward | :modal_opened | :modal_closed}} + {:ok, State.t(), + {:transition, + :navigated | :replaced | :back | :forward | :modal_opened | :modal_closed}} | {:error, term()} # Client API @@ -82,9 +84,9 @@ defmodule DesktopUi.Navigation.Controller do @doc """ Closes the top modal screen. """ - @spec close_modal(GenServer.server() | atom()) :: transition_result() - def close_modal(server) do - GenServer.call(server, :close_modal) + @spec close_modal(GenServer.server() | atom(), atom() | nil) :: transition_result() + def close_modal(server, screen_id \\ nil) do + GenServer.call(server, {:close_modal, screen_id}) end @doc """ @@ -219,13 +221,16 @@ defmodule DesktopUi.Navigation.Controller do end end - def handle_call(:close_modal, _from, state) do - case pop_modal(state.nav_state) do + def handle_call({:close_modal, screen_id}, _from, state) do + case pop_modal(state.nav_state, screen_id) do {:ok, new_nav} -> {:reply, {:ok, new_nav, {:transition, :modal_closed}}, %{state | nav_state: new_nav}} {:error, :no_modal} = error -> {:reply, error, state} + + {:error, {:unknown_modal, _screen_id}} = error -> + {:reply, error, state} end end @@ -266,7 +271,13 @@ defmodule DesktopUi.Navigation.Controller do {:error, :empty_history} end - defp go_back_in(%State{history: [prev | history_rest], current: curr, current_module: curr_mod, current_params: curr_params, forward: fwd}) do + defp go_back_in(%State{ + history: [prev | history_rest], + current: curr, + current_module: curr_mod, + current_params: curr_params, + forward: fwd + }) do new_nav = %State{ current: elem(prev, 0), current_module: elem(prev, 1), @@ -283,7 +294,13 @@ defmodule DesktopUi.Navigation.Controller do {:error, :empty_forward} end - defp go_forward_in(%State{forward: [next | fwd_rest], current: curr, current_module: curr_mod, current_params: curr_params, history: hist}) do + defp go_forward_in(%State{ + forward: [next | fwd_rest], + current: curr, + current_module: curr_mod, + current_params: curr_params, + history: hist + }) do new_nav = %State{ current: elem(next, 0), current_module: elem(next, 1), @@ -301,20 +318,49 @@ defmodule DesktopUi.Navigation.Controller do %State{nav | modals: [modal_entry | nav.modals], modal_open?: true} end - defp pop_modal(%State{modals: []}) do + defp pop_modal(%State{modals: []}, _screen_id) do {:error, :no_modal} end - defp pop_modal(%State{modals: [_]} = nav) do + defp pop_modal(%State{} = nav, nil) do + pop_top_modal(nav) + end + + defp pop_modal(%State{} = nav, screen_id) do + case remove_modal(nav.modals, screen_id) do + {:ok, modals} -> + {:ok, %{nav | modals: modals, modal_open?: modals != []}} + + :error -> + {:error, {:unknown_modal, screen_id}} + end + end + + defp pop_top_modal(%State{modals: [_]} = nav) do # Last modal closed - preserve current screen fields {:ok, %{nav | modals: [], modal_open?: false}} end - defp pop_modal(%State{modals: [_ | rest]} = nav) do + defp pop_top_modal(%State{modals: [_ | rest]} = nav) do # More modals remain {:ok, %{nav | modals: rest, modal_open?: true}} end + defp remove_modal(modals, screen_id) do + {kept, removed?} = + Enum.reduce(modals, {[], false}, fn modal = {id, _module, _params}, {acc, removed?} -> + if not removed? and key_matches?(id, screen_id) do + {acc, true} + else + {[modal | acc], removed?} + end + end) + + if removed?, do: {:ok, Enum.reverse(kept)}, else: :error + end + + defp key_matches?(key, value), do: key == value or to_string(key) == to_string(value) + # Screen resolution defp resolve_screen(nil, screen_id) when is_atom(screen_id) do diff --git a/packages/desktop_ui/lib/desktop_ui/navigation/signal.ex b/packages/desktop_ui/lib/desktop_ui/navigation/signal.ex index 7ebec2eb..a4b0fc50 100644 --- a/packages/desktop_ui/lib/desktop_ui/navigation/signal.ex +++ b/packages/desktop_ui/lib/desktop_ui/navigation/signal.ex @@ -29,17 +29,17 @@ defmodule DesktopUi.Navigation.Signal do @type screen_id :: atom() | String.t() @type params :: map() @type navigation_signal :: %{ - __struct__: __MODULE__, - type: atom(), - screen_id: screen_id() | nil, - params: params() - } + __struct__: __MODULE__, + type: atom(), + screen_id: screen_id() | nil, + params: params() + } @type t :: %__MODULE__{ - type: atom(), - screen_id: screen_id() | nil, - params: params() - } + type: atom(), + screen_id: screen_id() | nil, + params: params() + } defstruct [:type, :screen_id, :params] @@ -134,7 +134,8 @@ defmodule DesktopUi.Navigation.Signal do end @doc """ - Creates a close_modal signal for closing the top modal dialog. + Creates a close_modal signal for closing the top modal dialog or a named + modal. ## Examples @@ -142,11 +143,11 @@ defmodule DesktopUi.Navigation.Signal do %Navigation.Signal{type: :close_modal, screen_id: nil, params: %{}} """ - @spec close_modal() :: t() - def close_modal do + @spec close_modal(screen_id() | nil) :: t() + def close_modal(screen_id \\ nil) do %__MODULE__{ type: :close_modal, - screen_id: nil, + screen_id: screen_id, params: %{} } end @@ -248,8 +249,8 @@ defmodule DesktopUi.Navigation.Signal do DesktopUi.Navigation.Controller.open_modal(controller, screen_id, params) end - def execute(%__MODULE__{type: :close_modal}, controller) do - DesktopUi.Navigation.Controller.close_modal(controller) + def execute(%__MODULE__{type: :close_modal, screen_id: screen_id}, controller) do + DesktopUi.Navigation.Controller.close_modal(controller, screen_id) end # Private helpers diff --git a/packages/desktop_ui/test/desktop_ui/navigation/controller_test.exs b/packages/desktop_ui/test/desktop_ui/navigation/controller_test.exs index a5b6a162..fd70e461 100644 --- a/packages/desktop_ui/test/desktop_ui/navigation/controller_test.exs +++ b/packages/desktop_ui/test/desktop_ui/navigation/controller_test.exs @@ -21,9 +21,7 @@ defmodule DesktopUi.Navigation.ControllerTest do test "starts with initial screen" do {:ok, pid} = - Controller.start_link( - initial_screen: {:home, nil, %{foo: "bar"}} - ) + Controller.start_link(initial_screen: {:home, nil, %{foo: "bar"}}) state = Controller.get_state(pid) @@ -80,9 +78,7 @@ defmodule DesktopUi.Navigation.ControllerTest do describe "replace/3" do setup do {:ok, pid} = - Controller.start_link( - initial_screen: {:home, nil, %{foo: "bar"}} - ) + Controller.start_link(initial_screen: {:home, nil, %{foo: "bar"}}) %{pid: pid} end @@ -115,7 +111,9 @@ defmodule DesktopUi.Navigation.ControllerTest do assert state.current == :home assert state.current_module == DesktopUi.Navigation.Controller.MockScreen.Home assert state.history == [] - assert [{:detail, DesktopUi.Navigation.Controller.MockScreen.Detail, %{id: 1}}] = state.forward + + assert [{:detail, DesktopUi.Navigation.Controller.MockScreen.Detail, %{id: 1}}] = + state.forward end test "returns error when history is empty", %{pid: pid} do @@ -170,7 +168,11 @@ defmodule DesktopUi.Navigation.ControllerTest do assert state.current == :home assert state.current_module == DesktopUi.Navigation.Controller.MockScreen.Home assert state.modal_open? - assert [{:confirm_dialog, DesktopUi.Navigation.Controller.MockScreen.ConfirmDialog, %{action: :delete}}] = state.modals + + assert [ + {:confirm_dialog, DesktopUi.Navigation.Controller.MockScreen.ConfirmDialog, + %{action: :delete}} + ] = state.modals end test "stacks multiple modals", %{pid: pid} do @@ -183,6 +185,10 @@ defmodule DesktopUi.Navigation.ControllerTest do assert state.modal_open? assert length(state.modals) == 2 assert {:modal2, DesktopUi.Navigation.Controller.MockScreen.Modal2, %{}} = hd(state.modals) + assert State.modal_depth(state) == 2 + + assert State.top_modal(state) == + {:modal2, DesktopUi.Navigation.Controller.MockScreen.Modal2, %{}} end end @@ -211,6 +217,25 @@ defmodule DesktopUi.Navigation.ControllerTest do # Try to close again assert {:error, :no_modal} = Controller.close_modal(pid) end + + test "closes a named modal without changing screen history", %{pid: pid} do + {:ok, _, {:transition, :modal_opened}} = + Controller.open_modal(pid, :modal2, %{step: 2}) + + {:ok, state, {:transition, :modal_closed}} = + Controller.close_modal(pid, :confirm_dialog) + + assert state.current == :home + assert state.history == [] + assert state.modal_open? + + assert state.modals == [ + {:modal2, DesktopUi.Navigation.Controller.MockScreen.Modal2, %{step: 2}} + ] + + assert {:error, {:unknown_modal, :confirm_dialog}} = + Controller.close_modal(pid, :confirm_dialog) + end end describe "with registry" do diff --git a/packages/desktop_ui/test/desktop_ui/navigation/signal_test.exs b/packages/desktop_ui/test/desktop_ui/navigation/signal_test.exs index a8f347bd..3fa74977 100644 --- a/packages/desktop_ui/test/desktop_ui/navigation/signal_test.exs +++ b/packages/desktop_ui/test/desktop_ui/navigation/signal_test.exs @@ -1,6 +1,7 @@ defmodule DesktopUi.Navigation.SignalTest do use ExUnit.Case alias DesktopUi.Navigation.Signal + alias DesktopUi.Navigation.State @moduletag :navigation @@ -93,6 +94,14 @@ defmodule DesktopUi.Navigation.SignalTest do assert signal.screen_id == nil assert signal.params == %{} end + + test "creates a targeted close_modal signal" do + signal = Signal.close_modal(:settings_dialog) + + assert signal.type == :close_modal + assert signal.screen_id == :settings_dialog + assert signal.params == %{} + end end describe "from_map/1" do @@ -124,7 +133,11 @@ defmodule DesktopUi.Navigation.SignalTest do test "creates open_modal signal from map" do assert {:ok, signal} = - Signal.from_map(%{type: :open_modal, screen_id: :confirm, params: %{message: "OK"}}) + Signal.from_map(%{ + type: :open_modal, + screen_id: :confirm, + params: %{message: "OK"} + }) assert signal.type == :open_modal assert signal.screen_id == :confirm @@ -255,5 +268,18 @@ defmodule DesktopUi.Navigation.SignalTest do assert {:ok, _state, {:transition, :modal_closed}} = result end + + test "executes targeted close_modal signal", %{controller: controller} do + Signal.open_modal(:confirm_dialog) |> Signal.execute(controller) + Signal.open_modal(:modal2) |> Signal.execute(controller) + + signal = Signal.close_modal(:confirm_dialog) + result = Signal.execute(signal, controller) + + assert {:ok, state, {:transition, :modal_closed}} = result + + assert State.top_modal(state) == + {:modal2, DesktopUi.Navigation.Controller.MockScreen.Modal2, %{}} + end end end diff --git a/packages/terminal_ui/lib/terminal_ui/inspection.ex b/packages/terminal_ui/lib/terminal_ui/inspection.ex index 580b6d6d..868559e0 100644 --- a/packages/terminal_ui/lib/terminal_ui/inspection.ex +++ b/packages/terminal_ui/lib/terminal_ui/inspection.ex @@ -66,6 +66,7 @@ defmodule TerminalUi.Inspection do plan: TerminalUi.Degradation.plan(state.capabilities), active: degradation_nodes(nodes) }, + navigation: state.navigation, event_loop: TerminalUi.Runtime.EventLoop.diagnostics(state.event_loop) } end diff --git a/packages/terminal_ui/lib/terminal_ui/runtime.ex b/packages/terminal_ui/lib/terminal_ui/runtime.ex index 8ff54910..e547e2a5 100644 --- a/packages/terminal_ui/lib/terminal_ui/runtime.ex +++ b/packages/terminal_ui/lib/terminal_ui/runtime.ex @@ -18,7 +18,7 @@ defmodule TerminalUi.Runtime do StyleResolver } - alias UnifiedIUR.Element + alias UnifiedIUR.{Element, Interaction} @type validation_state :: :foundational_realization_ready | :advanced_runtime_ready @@ -56,6 +56,7 @@ defmodule TerminalUi.Runtime do :native_style_surface, :shared_theme_model, :canonical_boundary_events, + :modal_stack_navigation, :normalized_terminal_inputs, :shared_event_routing, :focus_traversal, @@ -152,6 +153,8 @@ defmodule TerminalUi.Runtime do runtime_state | focus: apply_focus(runtime_state.focus, translation, route_result.route), event_loop: EventLoop.record_route(runtime_state.event_loop, route_result), + navigation: + apply_navigation(runtime_state.navigation, translation, runtime_state.backend_mode), event_log: runtime_state.event_log ++ [event_log_entry(route_result)] } end @@ -170,6 +173,133 @@ defmodule TerminalUi.Runtime do defp apply_focus(focus, _translation, _route), do: focus + defp apply_navigation( + navigation, + %{family: :navigation, target: target} = translation, + backend_mode + ) do + case Interaction.navigation_descriptor(target) do + nil -> + normalize_navigation(navigation) + + descriptor -> + apply_navigation_descriptor( + normalize_navigation(navigation), + descriptor, + translation, + backend_mode + ) + end + end + + defp apply_navigation(navigation, _translation, _backend_mode), + do: normalize_navigation(navigation) + + defp apply_navigation_descriptor(navigation, descriptor, translation, backend_mode) do + action = Map.get(descriptor, :action) + transition = transition_summary(translation, descriptor, backend_mode) + + case action do + action when action in [:open_modal, "open_modal"] -> + modal_entry = %{ + modal: Map.get(descriptor, :modal), + params: normalize_map(Map.get(descriptor, :params, %{})), + metadata: normalize_map(Map.get(descriptor, :metadata, %{})), + degradation: modal_degradation(backend_mode) + } + + modals = navigation.modals ++ [modal_entry] + + %{ + navigation + | modals: modals, + current_modal: List.last(modals), + last_transition: transition + } + + action when action in [:close_modal, "close_modal"] -> + case close_modal_stack(navigation.modals, Map.get(descriptor, :modal)) do + {:ok, modals} -> + %{ + navigation + | modals: modals, + current_modal: List.last(modals), + last_transition: transition + } + + {:error, diagnostic} -> + %{ + navigation + | last_transition: transition, + diagnostics: navigation.diagnostics ++ [diagnostic] + } + end + + _other -> + %{navigation | last_transition: transition} + end + end + + defp normalize_navigation(nil) do + %{ + modals: [], + current_modal: nil, + last_transition: nil, + diagnostics: [] + } + end + + defp normalize_navigation(navigation) when is_map(navigation) do + %{ + modals: Map.get(navigation, :modals, []), + current_modal: Map.get(navigation, :current_modal), + last_transition: Map.get(navigation, :last_transition), + diagnostics: Map.get(navigation, :diagnostics, []) + } + end + + defp close_modal_stack([], modal_id) do + {:error, %{reason: :missing_modal, modal: modal_id, action: :close_modal}} + end + + defp close_modal_stack(modals, nil), do: {:ok, Enum.drop(modals, -1)} + + defp close_modal_stack(modals, modal_id) do + {kept, removed?} = + modals + |> Enum.reverse() + |> Enum.reduce({[], false}, fn modal, {acc, removed?} -> + if not removed? and Map.get(modal, :modal) == modal_id do + {acc, true} + else + {[modal | acc], removed?} + end + end) + + if removed? do + {:ok, kept} + else + {:error, %{reason: :missing_modal, modal: modal_id, action: :close_modal}} + end + end + + defp transition_summary(translation, descriptor, backend_mode) do + %{ + family: Map.get(translation, :family, :navigation), + intent: Map.get(translation, :intent), + action: Map.get(descriptor, :action), + screen: Map.get(descriptor, :screen), + modal: Map.get(descriptor, :modal), + params: normalize_map(Map.get(descriptor, :params, %{})), + metadata: normalize_map(Map.get(descriptor, :metadata, %{})), + modal_stack: normalize_map(Map.get(descriptor, :modal_stack, %{})), + degradation: modal_degradation(backend_mode) + } + end + + defp modal_degradation(:tty), do: %{presentation: :inline_overlay, bounded?: true} + defp modal_degradation(_backend_mode), do: %{presentation: :overlay, bounded?: true} + defp event_log_entry(route_result) do translation = route_result.translation diff --git a/packages/terminal_ui/lib/terminal_ui/runtime/state.ex b/packages/terminal_ui/lib/terminal_ui/runtime/state.ex index 88e38fa9..932bda87 100644 --- a/packages/terminal_ui/lib/terminal_ui/runtime/state.ex +++ b/packages/terminal_ui/lib/terminal_ui/runtime/state.ex @@ -28,6 +28,12 @@ defmodule TerminalUi.Runtime.State do :event_loop, :lifecycle, :validation_state, + navigation: %{ + modals: [], + current_modal: nil, + last_transition: nil, + diagnostics: [] + }, event_log: [] ] @@ -48,6 +54,7 @@ defmodule TerminalUi.Runtime.State do event_loop: map(), lifecycle: map(), validation_state: atom(), + navigation: map(), event_log: [map()] } 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 index d84329bd..1f621f70 100644 --- 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 @@ -2,6 +2,7 @@ defmodule TerminalUi.CanonicalNavigationTransportIntegrationTest do use ExUnit.Case, async: true alias Jido.Signal + alias TerminalUi.Runtime alias TerminalUi.Transport alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport @@ -27,4 +28,94 @@ defmodule TerminalUi.CanonicalNavigationTransportIntegrationTest do refute Map.has_key?(translation.target.navigation, :screen) refute Map.has_key?(translation.target.navigation, :modal) end + + test "preserves modal stack meaning with terminal fallback metadata" do + first_modal = BoundaryTransport.boundary_fixture!("modal_transition--settings_dialog") + second_modal = BoundaryTransport.boundary_fixture!("modal_stack--open_confirm_dialog") + close_top = BoundaryTransport.boundary_fixture!("modal_stack--close_top") + close_named = BoundaryTransport.boundary_fixture!("modal_stack--close_named_settings") + + assert {:ok, state} = Runtime.mount_native_screen(screen(), backend_mode: :tty) + + assert {:ok, with_first_modal, _route_result} = + Runtime.handle_boundary_signal(state, boundary_signal(first_modal)) + + assert {:ok, with_second_modal, _route_result} = + Runtime.handle_boundary_signal(with_first_modal, boundary_signal(second_modal)) + + assert with_second_modal.navigation.modals == [ + %{ + modal: :settings_dialog, + params: %{mode: :advanced}, + metadata: %{surface: :workspace}, + degradation: %{presentation: :inline_overlay, bounded?: true} + }, + %{ + modal: :settings_confirm_dialog, + params: %{from: :settings_dialog}, + metadata: %{previous_modal: :settings_dialog}, + degradation: %{presentation: :inline_overlay, bounded?: true} + } + ] + + assert with_second_modal.navigation.current_modal.modal == :settings_confirm_dialog + assert with_second_modal.navigation.last_transition.modal_stack.stack_effect == :push_modal + + snapshot = TerminalUi.Inspection.runtime_snapshot(with_second_modal) + assert snapshot.navigation.current_modal.modal == :settings_confirm_dialog + + assert {:ok, after_named_close, _route_result} = + Runtime.handle_boundary_signal(with_second_modal, boundary_signal(close_named)) + + assert Enum.map(after_named_close.navigation.modals, & &1.modal) == [ + :settings_confirm_dialog + ] + + assert {:ok, after_top_close, _route_result} = + Runtime.handle_boundary_signal(after_named_close, boundary_signal(close_top)) + + assert after_top_close.navigation.modals == [] + assert after_top_close.navigation.current_modal == nil + end + + test "reports missing modal diagnostics without losing canonical transition meaning" do + close_named = BoundaryTransport.boundary_fixture!("modal_stack--close_named_settings") + + assert {:ok, state} = Runtime.mount_native_screen(screen(), backend_mode: :raw) + + assert {:ok, updated_state, _route_result} = + Runtime.handle_boundary_signal(state, boundary_signal(close_named)) + + assert updated_state.navigation.diagnostics == [ + %{reason: :missing_modal, modal: :settings_dialog, action: :close_modal} + ] + + assert updated_state.navigation.last_transition.modal == :settings_dialog + end + + defp boundary_signal(fixture) do + assert {:ok, translation} = + Transport.from_interaction( + fixture.interaction, + backend_mode: :tty, + input_family: :key, + widget_id: fixture.interaction.source.element_id, + runtime_id: "terminal-ui:workspace", + screen: "workspace", + payload: fixture.signal_data + ) + + translation.signal + end + + defp screen do + %{ + id: "workspace", + title: "Workspace", + root: + TerminalUi.Widgets.column("workspace-root", [ + TerminalUi.Widgets.text("title", "Workspace") + ]) + } + end end From e77aef1b739c449483de936dbe18606b00ff15e9 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 08:02:46 -0400 Subject: [PATCH 12/13] Add modal stack navigation review examples --- ...untime-modal-stack-navigation-alignment.md | 18 +- .../desktop_ui/spec-traceability.json | 156 +++++++++++++++++- .../planning/desktop_ui/spec-traceability.md | 11 ++ .spec/planning/elm_ui/spec-traceability.json | 91 +++++++++- .spec/planning/elm_ui/spec-traceability.md | 6 + .spec/planning/live_ui/spec-traceability.json | 91 +++++++++- .spec/planning/live_ui/spec-traceability.md | 6 + .../terminal_ui/spec-traceability.json | 39 ++++- .../planning/terminal_ui/spec-traceability.md | 4 + packages/desktop_ui/README.md | 6 + .../desktop_ui/lib/desktop_ui/examples.ex | 85 +++++++++- .../test/desktop_ui/examples_test.exs | 49 +++++- packages/elm_ui/guides/runtime_backbone.md | 13 ++ packages/elm_ui/lib/elm_ui/examples.ex | 70 +++++++- ...avigation_web_runtime_integration_test.exs | 32 ++++ packages/elm_ui/test/elm_ui/examples_test.exs | 18 ++ packages/live_ui/guides/runtime_backbone.md | 13 ++ .../web_navigation_transition_comparison.ex | 66 +++++++- ...avigation_web_runtime_integration_test.exs | 32 ++++ .../terminal_ui/guides/runtime_backbone.md | 13 ++ .../terminal_ui/lib/terminal_ui/examples.ex | 129 ++++++++++++++- .../test/terminal_ui/examples_test.exs | 24 +++ 22 files changed, 941 insertions(+), 31 deletions(-) diff --git a/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md b/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md index a3e7aac2..31c57dff 100644 --- a/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md +++ b/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md @@ -91,25 +91,25 @@ Back to index: [README](./README.md) [x] 3.3.2.2 Subtask - Implement `open_modal`, targetless `close_modal`, and targeted `close_modal` using terminal-appropriate panels, inline overlays, or bounded section transitions. [x] 3.3.2.3 Subtask - Add diagnostics and inspection output that show degradation decisions while preserving canonical stack meaning. - [ ] 3.4 Section - Examples, Documentation, and Traceability + [x] 3.4 Section - Examples, Documentation, and Traceability Update maintainer-facing examples and planning evidence so the new modal stack behavior can be reviewed package by package. - [ ] 3.4.1 Task - Add focused runtime examples for stacked modal navigation + [x] 3.4.1 Task - Add focused runtime examples for stacked modal navigation Provide small examples that prove each runtime maps the same canonical stack semantics into its own host model. - [ ] 3.4.1.1 Subtask - Add or update focused examples for `live_ui` and `elm_ui` that open a second modal, close the topmost modal, and expose server-authoritative modal stack state. - [ ] 3.4.1.2 Subtask - Add or update focused examples for `desktop_ui` that demonstrate independent modal stack behavior over a stable screen history. - [ ] 3.4.1.3 Subtask - Add or update focused examples for `terminal_ui` that demonstrate modal stack semantics with explicit terminal degradation output. + [x] 3.4.1.1 Subtask - Add or update focused examples for `live_ui` and `elm_ui` that open a second modal, close the topmost modal, and expose server-authoritative modal stack state. + [x] 3.4.1.2 Subtask - Add or update focused examples for `desktop_ui` that demonstrate independent modal stack behavior over a stable screen history. + [x] 3.4.1.3 Subtask - Add or update focused examples for `terminal_ui` that demonstrate modal stack semantics with explicit terminal degradation output. - [ ] 3.4.2 Task - Update documentation and traceability surfaces + [x] 3.4.2 Task - Update documentation and traceability surfaces Keep package plans, generated mirrors, and documentation aligned with the new modal stack requirements. - [ ] 3.4.2.1 Subtask - Update runtime package documentation to describe stack-based modal navigation without adding authored DSL router semantics. - [ ] 3.4.2.2 Subtask - Update package planning traceability for the new modal stack requirements where package-local manifests are maintained. - [ ] 3.4.2.3 Subtask - Regenerate generated traceability mirrors through the owning Mix tasks rather than hand-editing generated review output. + [x] 3.4.2.1 Subtask - Update runtime package documentation to describe stack-based modal navigation without adding authored DSL router semantics. + [x] 3.4.2.2 Subtask - Update package planning traceability for the new modal stack requirements where package-local manifests are maintained. + [x] 3.4.2.3 Subtask - Regenerate generated traceability mirrors through the owning Mix tasks rather than hand-editing generated review output. [ ] 3.5 Section - Phase 3 Integration Tests Validate that each runtime consumes canonical modal stack transitions diff --git a/.spec/planning/desktop_ui/spec-traceability.json b/.spec/planning/desktop_ui/spec-traceability.json index d563890e..47761a9f 100644 --- a/.spec/planning/desktop_ui/spec-traceability.json +++ b/.spec/planning/desktop_ui/spec-traceability.json @@ -22,7 +22,8 @@ "ecosystem.signal_transport.dsl_event_bindings", "ecosystem.signal_transport.desktop_translation", "ecosystem.signal_transport.native_signal_models_allowed", - "ecosystem.signal_transport.local_state_not_contract" + "ecosystem.signal_transport.local_state_not_contract", + "ecosystem.signal_transport.modal_stack_transition_meaning" ], "upstream_requirement_ids": [ "unified_iur.widgets.input_and_navigation", @@ -47,6 +48,7 @@ "unified_iur.interactions.renderer_independent_payload_mapping", "unified_iur.interactions.standard_interaction_families", "unified_iur.interactions.data_binding_representation", + "unified_iur.interactions.modal_stack_transition_semantics", "unified_ui.package.library_identity", "unified_ui.package.dsl_boundary_only", "unified_ui.package.canonical_iur_dependency", @@ -56,7 +58,8 @@ "unified_ui.signals.authoring_event_semantics", "unified_ui.signals.standard_interaction_families", "unified_ui.signals.validation_and_introspection", - "unified_ui.signals.no_runtime_local_event_leakage" + "unified_ui.signals.no_runtime_local_event_leakage", + "unified_ui.signals.navigation_modal_stack_semantics" ], "non_applicable_requirement_ids": [ "ecosystem.platform_runtimes.elm_ui_runtime_split", @@ -302,6 +305,20 @@ ], "ownership_note": "Inherited boundary-contract guardrail for local desktop runtime state." }, + { + "requirement_id": "ecosystem.signal_transport.modal_stack_transition_meaning", + "scope": "inherited", + "source_file": ".spec/specs/signal_transport.spec.md", + "primary_plan_refs": [ + "4.1.1" + ], + "supporting_plan_refs": [ + "4.5.1", + "12.1.2", + "12.6.3" + ], + "ownership_note": "Inherited canonical modal stack transition meaning consumed by desktop_ui." + }, { "requirement_id": "desktop_ui.package.library_identity", "scope": "direct", @@ -478,6 +495,20 @@ ], "ownership_note": "Direct desktop_ui boundary obligation." }, + { + "requirement_id": "desktop_ui.structure.navigation_modules", + "scope": "direct", + "source_file": ".spec/specs/desktop_ui/structure.spec.md", + "primary_plan_refs": [ + "12.1.1" + ], + "supporting_plan_refs": [ + "12.2.1", + "12.3.1", + "12.3.2" + ], + "ownership_note": "Direct navigation module boundary obligation." + }, { "requirement_id": "desktop_ui.native_widgets.direct_native_surface", "scope": "direct", @@ -818,6 +849,99 @@ ], "ownership_note": "Direct bounded-platform-variation obligation." }, + { + "requirement_id": "desktop_ui.runtime.screen_navigation_support", + "scope": "direct", + "source_file": ".spec/specs/desktop_ui/runtime.spec.md", + "primary_plan_refs": [ + "12.1.1" + ], + "supporting_plan_refs": [ + "12.5.1", + "12.6.1" + ], + "ownership_note": "Direct screen navigation runtime obligation." + }, + { + "requirement_id": "desktop_ui.runtime.navigation_controller_process", + "scope": "direct", + "source_file": ".spec/specs/desktop_ui/runtime.spec.md", + "primary_plan_refs": [ + "12.1.1" + ], + "supporting_plan_refs": [ + "12.1.2", + "12.6.1" + ], + "ownership_note": "Direct navigation controller process obligation." + }, + { + "requirement_id": "desktop_ui.runtime.screen_registry", + "scope": "direct", + "source_file": ".spec/specs/desktop_ui/runtime.spec.md", + "primary_plan_refs": [ + "12.2.1" + ], + "supporting_plan_refs": [ + "12.2.2", + "12.6.2" + ], + "ownership_note": "Direct screen registry obligation." + }, + { + "requirement_id": "desktop_ui.runtime.navigation_actions", + "scope": "direct", + "source_file": ".spec/specs/desktop_ui/runtime.spec.md", + "primary_plan_refs": [ + "12.3.1" + ], + "supporting_plan_refs": [ + "12.1.2", + "12.6.3" + ], + "ownership_note": "Direct navigation action surface obligation." + }, + { + "requirement_id": "desktop_ui.runtime.navigation_event_routing", + "scope": "direct", + "source_file": ".spec/specs/desktop_ui/runtime.spec.md", + "primary_plan_refs": [ + "12.3.2" + ], + "supporting_plan_refs": [ + "12.3.3", + "12.6.3" + ], + "ownership_note": "Direct navigation event routing obligation." + }, + { + "requirement_id": "desktop_ui.runtime.modal_stack_independence", + "scope": "direct", + "source_file": ".spec/specs/desktop_ui/runtime.spec.md", + "primary_plan_refs": [ + "12.1.2" + ], + "supporting_plan_refs": [ + "12.4.3", + "12.6.1", + "12.6.5" + ], + "ownership_note": "Direct independent modal stack obligation." + }, + { + "requirement_id": "desktop_ui.runtime.stacked_modal_navigation", + "scope": "direct", + "source_file": ".spec/specs/desktop_ui/runtime.spec.md", + "primary_plan_refs": [ + "12.1.2" + ], + "supporting_plan_refs": [ + "12.5.1", + "12.6.1", + "12.6.5" + ], + "ownership_note": "Direct stacked modal navigation obligation." + }, { "requirement_id": "desktop_ui.iur_renderer.accepts_canonical_iur", "scope": "direct", @@ -1383,6 +1507,20 @@ ], "ownership_note": "Upstream canonical data-binding input consumed by desktop_ui." }, + { + "requirement_id": "unified_iur.interactions.modal_stack_transition_semantics", + "scope": "upstream_constraint", + "source_file": ".spec/specs/unified-iur/interactions.spec.md", + "primary_plan_refs": [ + "4.1.1" + ], + "supporting_plan_refs": [ + "4.5.1", + "12.1.2", + "12.6.3" + ], + "ownership_note": "Upstream canonical modal stack descriptor semantics consumed by desktop_ui." + }, { "requirement_id": "unified_ui.package.library_identity", "scope": "upstream_constraint", @@ -1511,6 +1649,20 @@ "6.5.2" ], "ownership_note": "Upstream authored-signal hygiene guardrail." + }, + { + "requirement_id": "unified_ui.signals.navigation_modal_stack_semantics", + "scope": "upstream_constraint", + "source_file": ".spec/specs/unified-ui/signals.spec.md", + "primary_plan_refs": [ + "4.1.1" + ], + "supporting_plan_refs": [ + "4.5.1", + "12.1.2", + "12.6.3" + ], + "ownership_note": "Upstream authored modal stack signal semantics consumed by desktop_ui." } ] } diff --git a/.spec/planning/desktop_ui/spec-traceability.md b/.spec/planning/desktop_ui/spec-traceability.md index 307d61f8..c118c76f 100644 --- a/.spec/planning/desktop_ui/spec-traceability.md +++ b/.spec/planning/desktop_ui/spec-traceability.md @@ -41,6 +41,7 @@ and which upstream package specs are treated as input constraints rather than | `ecosystem.signal_transport.desktop_translation` | `4.2.1` | `4.2.2`, `4.5.1`, `6.2.2` | Direct inherited desktop_ui transport obligation. | | `ecosystem.signal_transport.native_signal_models_allowed` | `4.1.2` | `4.2.2`, `4.5.2` | Inherited local-runtime flexibility guardrail. | | `ecosystem.signal_transport.local_state_not_contract` | `1.2.1` | `4.2.2`, `5.4.2` | Inherited boundary-contract guardrail for local desktop runtime state. | +| `ecosystem.signal_transport.modal_stack_transition_meaning` | `4.1.1` | `4.5.1`, `12.1.2`, `12.6.3` | Inherited canonical modal stack transition meaning consumed by desktop_ui. | ### Referenced Root Requirements Outside `desktop_ui` Ownership These referenced root requirements are intentionally not mapped to `desktop_ui` tasks because they govern other runtime packages: @@ -68,6 +69,7 @@ tasks because they govern other runtime packages: | `desktop_ui.structure.transport_translation_modules` | `1.1.2` | `4.1.1`, `4.2.1` | Direct transport-structure obligation. | | `desktop_ui.structure.platform_artifact_modules` | `5.3.1` | `5.3.2`, `6.3.1` | Direct artifact-module boundary obligation. | | `desktop_ui.structure.no_dsl_or_iur_authorship` | `1.1.2` | `6.4.2` | Direct desktop_ui boundary obligation. | +| `desktop_ui.structure.navigation_modules` | `12.1.1` | `12.2.1`, `12.3.1`, `12.3.2` | Direct navigation module boundary obligation. | ### `native_widgets.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | | --- | --- | --- | --- | @@ -85,6 +87,13 @@ tasks because they govern other runtime packages: | `desktop_ui.runtime.window_lifecycle_and_input` | `1.2.2` | `1.5.3.1`, `4.1.2`, `4.2.2`, `5.5.2`, `10.3.1`, `10.3.2` | Direct runtime lifecycle and input obligation. | | `desktop_ui.runtime.interactive_visible_execution` | `10.3.1` | `10.3.2`, `10.4.1`, `10.5.1` | Direct interactive visible-window execution obligation. | | `desktop_ui.runtime.platform_variation_bounded` | `1.3.2` | `5.2.2`, `5.3.2` | Direct bounded-platform-variation obligation. | +| `desktop_ui.runtime.screen_navigation_support` | `12.1.1` | `12.5.1`, `12.6.1` | Direct screen navigation runtime obligation. | +| `desktop_ui.runtime.navigation_controller_process` | `12.1.1` | `12.1.2`, `12.6.1` | Direct navigation controller process obligation. | +| `desktop_ui.runtime.screen_registry` | `12.2.1` | `12.2.2`, `12.6.2` | Direct screen registry obligation. | +| `desktop_ui.runtime.navigation_actions` | `12.3.1` | `12.1.2`, `12.6.3` | Direct navigation action surface obligation. | +| `desktop_ui.runtime.navigation_event_routing` | `12.3.2` | `12.3.3`, `12.6.3` | Direct navigation event routing obligation. | +| `desktop_ui.runtime.modal_stack_independence` | `12.1.2` | `12.4.3`, `12.6.1`, `12.6.5` | Direct independent modal stack obligation. | +| `desktop_ui.runtime.stacked_modal_navigation` | `12.1.2` | `12.5.1`, `12.6.1`, `12.6.5` | Direct stacked modal navigation obligation. | ### `sdl3_runtime_rendering.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | | --- | --- | --- | --- | @@ -171,6 +180,7 @@ deliberate task coverage to preserve compatibility. | `unified_iur.interactions.renderer_independent_payload_mapping` | `4.1.1` | `4.3.1`, `4.5.1` | Upstream canonical interaction hygiene constraint. | | `unified_iur.interactions.standard_interaction_families` | `4.1.1` | `2.1.2`, `4.4.1`, `4.5.1` | Upstream canonical interaction scope consumed by desktop_ui. | | `unified_iur.interactions.data_binding_representation` | `2.1.2` | `2.2.1`, `4.2.1`, `4.5.2` | Upstream canonical data-binding input consumed by desktop_ui. | +| `unified_iur.interactions.modal_stack_transition_semantics` | `4.1.1` | `4.5.1`, `12.1.2`, `12.6.3` | Upstream canonical modal stack descriptor semantics consumed by desktop_ui. | ### `unified-ui/package.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | | --- | --- | --- | --- | @@ -187,6 +197,7 @@ deliberate task coverage to preserve compatibility. | `unified_ui.signals.standard_interaction_families` | `4.1.1` | `2.1.2`, `4.4.1`, `4.5.1` | Upstream authored-signal scope consumed by desktop_ui. | | `unified_ui.signals.validation_and_introspection` | `6.2.1` | `4.3.1`, `6.2.2`, `6.3.2` | Upstream inspection expectations reflected in desktop_ui tooling. | | `unified_ui.signals.no_runtime_local_event_leakage` | `4.3.1` | `1.2.1`, `6.4.2`, `6.5.2` | Upstream authored-signal hygiene guardrail. | +| `unified_ui.signals.navigation_modal_stack_semantics` | `4.1.1` | `4.5.1`, `12.1.2`, `12.6.3` | Upstream authored modal stack signal semantics consumed by desktop_ui. | ## Scenario Alignment Pattern The per-spec scenario clauses are covered through the integration-test sections at the end of every phase: diff --git a/.spec/planning/elm_ui/spec-traceability.json b/.spec/planning/elm_ui/spec-traceability.json index b8390981..ff90e2d6 100644 --- a/.spec/planning/elm_ui/spec-traceability.json +++ b/.spec/planning/elm_ui/spec-traceability.json @@ -21,7 +21,8 @@ "ecosystem.signal_transport.dsl_event_bindings", "ecosystem.signal_transport.elm_ui_bridge", "ecosystem.signal_transport.native_signal_models_allowed", - "ecosystem.signal_transport.local_state_not_contract" + "ecosystem.signal_transport.local_state_not_contract", + "ecosystem.signal_transport.modal_stack_transition_meaning" ], "upstream_requirement_ids": [ "unified_iur.widgets.input_and_navigation", @@ -46,6 +47,7 @@ "unified_iur.interactions.renderer_independent_payload_mapping", "unified_iur.interactions.standard_interaction_families", "unified_iur.interactions.data_binding_representation", + "unified_iur.interactions.modal_stack_transition_semantics", "unified_ui.package.library_identity", "unified_ui.package.dsl_boundary_only", "unified_ui.package.canonical_iur_dependency", @@ -55,7 +57,8 @@ "unified_ui.signals.authoring_event_semantics", "unified_ui.signals.standard_interaction_families", "unified_ui.signals.validation_and_introspection", - "unified_ui.signals.no_runtime_local_event_leakage" + "unified_ui.signals.no_runtime_local_event_leakage", + "unified_ui.signals.navigation_modal_stack_semantics" ], "non_applicable_requirement_ids": [ "ecosystem.platform_runtimes.live_ui_runtime", @@ -297,6 +300,20 @@ ], "ownership_note": "Inherited boundary-contract guardrail." }, + { + "requirement_id": "ecosystem.signal_transport.modal_stack_transition_meaning", + "scope": "inherited", + "source_file": ".spec/specs/signal_transport.spec.md", + "primary_plan_refs": [ + "4.1.1" + ], + "supporting_plan_refs": [ + "4.4.1", + "4.5.1", + "3.2.2" + ], + "ownership_note": "Inherited canonical modal stack transition meaning consumed by elm_ui." + }, { "requirement_id": "elm_ui.package.library_identity", "scope": "direct", @@ -615,6 +632,48 @@ ], "ownership_note": "Direct server-authority obligation." }, + { + "requirement_id": "elm_ui.server_runtime.canonical_navigation_transition_mapping", + "scope": "direct", + "source_file": ".spec/specs/elm_ui/server_runtime.spec.md", + "primary_plan_refs": [ + "4.1.1" + ], + "supporting_plan_refs": [ + "4.2.1", + "4.5.1", + "4.5.2" + ], + "ownership_note": "Direct Phoenix-side canonical navigation transition obligation." + }, + { + "requirement_id": "elm_ui.server_runtime.modal_stack_navigation_state", + "scope": "direct", + "source_file": ".spec/specs/elm_ui/server_runtime.spec.md", + "primary_plan_refs": [ + "3.2.2" + ], + "supporting_plan_refs": [ + "4.1.1", + "4.4.1", + "4.5.2" + ], + "ownership_note": "Direct server-authoritative modal stack state obligation." + }, + { + "requirement_id": "elm_ui.server_runtime.host_route_resolution_boundary", + "scope": "direct", + "source_file": ".spec/specs/elm_ui/server_runtime.spec.md", + "primary_plan_refs": [ + "4.3.1" + ], + "supporting_plan_refs": [ + "4.2.1", + "4.2.2", + "4.5.1" + ], + "ownership_note": "Direct host-route boundary obligation for canonical navigation." + }, { "requirement_id": "elm_ui.frontend_runtime.elm_rendering_layer", "scope": "direct", @@ -1184,6 +1243,20 @@ ], "ownership_note": "Upstream canonical data-binding input consumed by `elm_ui`." }, + { + "requirement_id": "unified_iur.interactions.modal_stack_transition_semantics", + "scope": "upstream_constraint", + "source_file": ".spec/specs/unified-iur/interactions.spec.md", + "primary_plan_refs": [ + "4.1.1" + ], + "supporting_plan_refs": [ + "3.2.2", + "4.4.1", + "4.5.1" + ], + "ownership_note": "Upstream canonical modal stack descriptor semantics consumed by `elm_ui`." + }, { "requirement_id": "unified_ui.package.library_identity", "scope": "upstream_constraint", @@ -1312,6 +1385,20 @@ "6.5.2" ], "ownership_note": "Upstream authored-signal hygiene guardrail." + }, + { + "requirement_id": "unified_ui.signals.navigation_modal_stack_semantics", + "scope": "upstream_constraint", + "source_file": ".spec/specs/unified-ui/signals.spec.md", + "primary_plan_refs": [ + "4.1.1" + ], + "supporting_plan_refs": [ + "3.2.2", + "4.4.1", + "4.5.1" + ], + "ownership_note": "Upstream authored modal stack signal semantics consumed by `elm_ui`." } ] } diff --git a/.spec/planning/elm_ui/spec-traceability.md b/.spec/planning/elm_ui/spec-traceability.md index a5ef23cb..eb343efa 100644 --- a/.spec/planning/elm_ui/spec-traceability.md +++ b/.spec/planning/elm_ui/spec-traceability.md @@ -40,6 +40,7 @@ and which upstream package specs are treated as input constraints rather than | `ecosystem.signal_transport.elm_ui_bridge` | `4.1.1` | `4.2.1`, `4.2.2`, `4.3.1`, `4.5.1`, `6.2.2` | Direct inherited `elm_ui` transport obligation. | | `ecosystem.signal_transport.native_signal_models_allowed` | `4.1.2` | `4.2.2`, `4.5.2` | Inherited local-runtime flexibility guardrail. | | `ecosystem.signal_transport.local_state_not_contract` | `1.3.1` | `4.2.2`, `5.2.2`, `4.5.2` | Inherited boundary-contract guardrail. | +| `ecosystem.signal_transport.modal_stack_transition_meaning` | `4.1.1` | `4.4.1`, `4.5.1`, `3.2.2` | Inherited canonical modal stack transition meaning consumed by elm_ui. | ### Referenced Root Requirements Outside `elm_ui` Ownership These referenced root requirements are intentionally not mapped to `elm_ui` tasks because they govern other runtime packages: @@ -83,6 +84,9 @@ tasks because they govern other runtime packages: | `elm_ui.server_runtime.handle_boundary_events` | `4.2.1` | `4.1.1`, `4.3.1`, `4.5.1` | Direct boundary-event obligation. | | `elm_ui.server_runtime.direct_and_iur_entrypoints_share_runtime` | `1.2.1` | `2.3.2`, `3.3.2`, `4.5.2` | Direct runtime-convergence obligation. | | `elm_ui.server_runtime.browser_state_is_bounded` | `1.2.1` | `1.3.1`, `4.2.2`, `5.2.2` | Direct server-authority obligation. | +| `elm_ui.server_runtime.canonical_navigation_transition_mapping` | `4.1.1` | `4.2.1`, `4.5.1`, `4.5.2` | Direct Phoenix-side canonical navigation transition obligation. | +| `elm_ui.server_runtime.modal_stack_navigation_state` | `3.2.2` | `4.1.1`, `4.4.1`, `4.5.2` | Direct server-authoritative modal stack state obligation. | +| `elm_ui.server_runtime.host_route_resolution_boundary` | `4.3.1` | `4.2.1`, `4.2.2`, `4.5.1` | Direct host-route boundary obligation for canonical navigation. | ### `frontend_runtime.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | | --- | --- | --- | --- | @@ -153,6 +157,7 @@ deliberate task coverage to preserve compatibility. | `unified_iur.interactions.renderer_independent_payload_mapping` | `4.1.1` | `4.3.1`, `4.5.1` | Upstream canonical interaction hygiene constraint. | | `unified_iur.interactions.standard_interaction_families` | `4.1.1` | `2.1.2`, `4.4.1`, `4.5.1` | Upstream canonical interaction scope consumed by `elm_ui`. | | `unified_iur.interactions.data_binding_representation` | `2.1.2` | `2.2.1`, `4.2.1`, `4.5.2` | Upstream canonical data-binding input consumed by `elm_ui`. | +| `unified_iur.interactions.modal_stack_transition_semantics` | `4.1.1` | `3.2.2`, `4.4.1`, `4.5.1` | Upstream canonical modal stack descriptor semantics consumed by `elm_ui`. | ### `unified-ui/package.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | | --- | --- | --- | --- | @@ -169,6 +174,7 @@ deliberate task coverage to preserve compatibility. | `unified_ui.signals.standard_interaction_families` | `4.1.1` | `2.1.2`, `4.4.1`, `4.5.1` | Upstream authored-signal scope consumed by `elm_ui`. | | `unified_ui.signals.validation_and_introspection` | `6.2.1` | `4.3.1`, `6.2.2`, `6.3.2` | Upstream inspection expectations reflected in `elm_ui` maintainer tooling. | | `unified_ui.signals.no_runtime_local_event_leakage` | `4.3.1` | `1.1.2`, `6.3.1`, `6.5.2` | Upstream authored-signal hygiene guardrail. | +| `unified_ui.signals.navigation_modal_stack_semantics` | `4.1.1` | `3.2.2`, `4.4.1`, `4.5.1` | Upstream authored modal stack signal semantics consumed by `elm_ui`. | ## Scenario Alignment Pattern The per-spec scenario clauses are covered through the integration-test sections at the end of every phase: diff --git a/.spec/planning/live_ui/spec-traceability.json b/.spec/planning/live_ui/spec-traceability.json index 2f1f3ba9..a1b77c40 100644 --- a/.spec/planning/live_ui/spec-traceability.json +++ b/.spec/planning/live_ui/spec-traceability.json @@ -21,7 +21,8 @@ "ecosystem.signal_transport.dsl_event_bindings", "ecosystem.signal_transport.live_bridge", "ecosystem.signal_transport.native_signal_models_allowed", - "ecosystem.signal_transport.local_state_not_contract" + "ecosystem.signal_transport.local_state_not_contract", + "ecosystem.signal_transport.modal_stack_transition_meaning" ], "upstream_requirement_ids": [ "unified_iur.widgets.input_and_navigation", @@ -46,6 +47,7 @@ "unified_iur.interactions.renderer_independent_payload_mapping", "unified_iur.interactions.standard_interaction_families", "unified_iur.interactions.data_binding_representation", + "unified_iur.interactions.modal_stack_transition_semantics", "unified_ui.package.library_identity", "unified_ui.package.dsl_boundary_only", "unified_ui.package.canonical_iur_dependency", @@ -55,7 +57,8 @@ "unified_ui.signals.authoring_event_semantics", "unified_ui.signals.standard_interaction_families", "unified_ui.signals.validation_and_introspection", - "unified_ui.signals.no_runtime_local_event_leakage" + "unified_ui.signals.no_runtime_local_event_leakage", + "unified_ui.signals.navigation_modal_stack_semantics" ], "non_applicable_requirement_ids": [ "ecosystem.platform_runtimes.elm_ui_runtime_split", @@ -295,6 +298,20 @@ ], "ownership_note": "Inherited boundary-contract guardrail." }, + { + "requirement_id": "ecosystem.signal_transport.modal_stack_transition_meaning", + "scope": "inherited", + "source_file": ".spec/specs/signal_transport.spec.md", + "primary_plan_refs": [ + "4.1.1" + ], + "supporting_plan_refs": [ + "4.4.1", + "4.5.1", + "14.1.1" + ], + "ownership_note": "Inherited canonical modal stack transition meaning consumed by live_ui." + }, { "requirement_id": "live_ui.package.library_identity", "scope": "direct", @@ -706,6 +723,48 @@ ], "ownership_note": "Phase 11: Widget components may own bounded local UI lifecycle state." }, + { + "requirement_id": "live_ui.runtime.canonical_navigation_transition_mapping", + "scope": "direct", + "source_file": ".spec/specs/live_ui/runtime.spec.md", + "primary_plan_refs": [ + "4.1.1" + ], + "supporting_plan_refs": [ + "4.5.1", + "4.5.2", + "12.4.2" + ], + "ownership_note": "Direct server-side canonical navigation transition obligation." + }, + { + "requirement_id": "live_ui.runtime.modal_stack_navigation_state", + "scope": "direct", + "source_file": ".spec/specs/live_ui/runtime.spec.md", + "primary_plan_refs": [ + "14.1.1" + ], + "supporting_plan_refs": [ + "4.1.1", + "4.4.1", + "4.5.1" + ], + "ownership_note": "Direct server-authoritative modal stack navigation obligation." + }, + { + "requirement_id": "live_ui.runtime.host_route_resolution_boundary", + "scope": "direct", + "source_file": ".spec/specs/live_ui/runtime.spec.md", + "primary_plan_refs": [ + "4.3.1" + ], + "supporting_plan_refs": [ + "4.2.1", + "4.5.1", + "4.5.2" + ], + "ownership_note": "Direct host-route boundary obligation for canonical navigation." + }, { "requirement_id": "live_ui.iur_renderer.accepts_canonical_iur", "scope": "direct", @@ -1232,6 +1291,20 @@ ], "ownership_note": "Upstream canonical data-binding input consumed by live_ui." }, + { + "requirement_id": "unified_iur.interactions.modal_stack_transition_semantics", + "scope": "upstream_constraint", + "source_file": ".spec/specs/unified-iur/interactions.spec.md", + "primary_plan_refs": [ + "4.1.1" + ], + "supporting_plan_refs": [ + "4.4.1", + "4.5.1", + "14.1.1" + ], + "ownership_note": "Upstream canonical modal stack descriptor semantics consumed by live_ui." + }, { "requirement_id": "unified_ui.package.library_identity", "scope": "upstream_constraint", @@ -1360,6 +1433,20 @@ "6.5.2" ], "ownership_note": "Upstream authored-signal hygiene guardrail." + }, + { + "requirement_id": "unified_ui.signals.navigation_modal_stack_semantics", + "scope": "upstream_constraint", + "source_file": ".spec/specs/unified-ui/signals.spec.md", + "primary_plan_refs": [ + "4.1.1" + ], + "supporting_plan_refs": [ + "4.4.1", + "4.5.1", + "14.1.1" + ], + "ownership_note": "Upstream authored modal stack signal semantics consumed by live_ui." } ] } diff --git a/.spec/planning/live_ui/spec-traceability.md b/.spec/planning/live_ui/spec-traceability.md index a733c95d..39f3a9be 100644 --- a/.spec/planning/live_ui/spec-traceability.md +++ b/.spec/planning/live_ui/spec-traceability.md @@ -40,6 +40,7 @@ and which upstream package specs are treated as input constraints rather than | `ecosystem.signal_transport.live_bridge` | `4.2.1` | `4.2.2`, `4.5.1`, `6.2.2` | Direct inherited live_ui transport obligation. | | `ecosystem.signal_transport.native_signal_models_allowed` | `4.1.2` | `4.2.2`, `4.5.1` | Inherited local-runtime flexibility guardrail. | | `ecosystem.signal_transport.local_state_not_contract` | `1.3.2` | `4.2.2`, `4.5.2`, `5.3.2` | Inherited boundary-contract guardrail. | +| `ecosystem.signal_transport.modal_stack_transition_meaning` | `4.1.1` | `4.4.1`, `4.5.1`, `14.1.1` | Inherited canonical modal stack transition meaning consumed by live_ui. | ### Referenced Root Requirements Outside `live_ui` Ownership These referenced root requirements are intentionally not mapped to `live_ui` tasks because they govern other runtime packages: @@ -91,6 +92,9 @@ tasks because they govern other runtime packages: | `live_ui.runtime.native_and_iur_entrypoints_share_runtime` | `1.3.1` | `2.3.2`, `3.3.1`, `4.5.2` | Direct runtime-convergence obligation. | | `live_ui.runtime.state_and_render_continuity` | `2.3.2` | `3.3.1`, `5.3.2`, `5.5.2` | Direct continuity obligation. | | `live_ui.runtime.widget_component_local_state` | `11.2` | `11.2.2` | Phase 11: Widget components may own bounded local UI lifecycle state. | +| `live_ui.runtime.canonical_navigation_transition_mapping` | `4.1.1` | `4.5.1`, `4.5.2`, `12.4.2` | Direct server-side canonical navigation transition obligation. | +| `live_ui.runtime.modal_stack_navigation_state` | `14.1.1` | `4.1.1`, `4.4.1`, `4.5.1` | Direct server-authoritative modal stack navigation obligation. | +| `live_ui.runtime.host_route_resolution_boundary` | `4.3.1` | `4.2.1`, `4.5.1`, `4.5.2` | Direct host-route boundary obligation for canonical navigation. | ### `iur_renderer.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | | --- | --- | --- | --- | @@ -155,6 +159,7 @@ deliberate task coverage to preserve compatibility. | `unified_iur.interactions.renderer_independent_payload_mapping` | `4.1.1` | `4.3.1`, `4.5.1` | Upstream canonical interaction hygiene constraint. | | `unified_iur.interactions.standard_interaction_families` | `4.1.1` | `2.2.1`, `4.4.1`, `4.5.1` | Upstream canonical interaction scope consumed by live_ui. | | `unified_iur.interactions.data_binding_representation` | `2.2.1` | `1.2.2`, `4.2.1`, `4.5.2` | Upstream canonical data-binding input consumed by live_ui. | +| `unified_iur.interactions.modal_stack_transition_semantics` | `4.1.1` | `4.4.1`, `4.5.1`, `14.1.1` | Upstream canonical modal stack descriptor semantics consumed by live_ui. | ### `unified-ui/package.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | | --- | --- | --- | --- | @@ -171,6 +176,7 @@ deliberate task coverage to preserve compatibility. | `unified_ui.signals.standard_interaction_families` | `4.1.1` | `2.2.1`, `4.4.1`, `4.5.1` | Upstream authored-signal scope consumed by live_ui. | | `unified_ui.signals.validation_and_introspection` | `6.2.1` | `4.3.1`, `6.3.1`, `6.3.2` | Upstream inspection expectations reflected in live_ui maintainer tooling. | | `unified_ui.signals.no_runtime_local_event_leakage` | `4.3.1` | `1.3.2`, `6.4.2`, `6.5.2` | Upstream authored-signal hygiene guardrail. | +| `unified_ui.signals.navigation_modal_stack_semantics` | `4.1.1` | `4.4.1`, `4.5.1`, `14.1.1` | Upstream authored modal stack signal semantics consumed by live_ui. | ## Scenario Alignment Pattern The per-spec scenario clauses are covered through the integration-test sections at the end of every phase: diff --git a/.spec/planning/terminal_ui/spec-traceability.json b/.spec/planning/terminal_ui/spec-traceability.json index 935ec67a..beacd41a 100644 --- a/.spec/planning/terminal_ui/spec-traceability.json +++ b/.spec/planning/terminal_ui/spec-traceability.json @@ -22,7 +22,8 @@ "ecosystem.signal_transport.dsl_event_bindings", "ecosystem.signal_transport.terminal_bridge", "ecosystem.signal_transport.native_signal_models_allowed", - "ecosystem.signal_transport.local_state_not_contract" + "ecosystem.signal_transport.local_state_not_contract", + "ecosystem.signal_transport.modal_stack_transition_meaning" ], "upstream_requirement_ids": [ "unified_iur.widgets.input_and_navigation", @@ -48,6 +49,7 @@ "unified_iur.interactions.standard_interaction_families", "unified_iur.interactions.data_binding_representation", "unified_iur.interactions.navigation_transition_representation", + "unified_iur.interactions.modal_stack_transition_semantics", "unified_iur.interactions.no_host_router_assumptions", "unified_ui.package.library_identity", "unified_ui.package.dsl_boundary_only", @@ -60,7 +62,8 @@ "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.navigation_symbolic_screen_targets", + "unified_ui.signals.navigation_modal_stack_semantics" ], "non_applicable_requirement_ids": [ "ecosystem.platform_runtimes.elm_ui_runtime_split", @@ -209,6 +212,14 @@ "supporting_plan_refs": ["4.2.2", "5.3.2"], "ownership_note": "Inherited boundary-contract guardrail for local terminal runtime state." }, + { + "requirement_id": "ecosystem.signal_transport.modal_stack_transition_meaning", + "scope": "inherited", + "source_file": ".spec/specs/signal_transport.spec.md", + "primary_plan_refs": ["7.3.2"], + "supporting_plan_refs": ["7.4.1", "7.4.2", "7.5.1"], + "ownership_note": "Inherited canonical modal stack transition meaning consumed by terminal_ui." + }, { "requirement_id": "terminal_ui.package.library_identity", "scope": "direct", @@ -393,6 +404,14 @@ "supporting_plan_refs": ["7.1.1", "7.3.1", "7.3.2", "7.4.1", "7.5.1"], "ownership_note": "Direct canonical navigation runtime obligation." }, + { + "requirement_id": "terminal_ui.runtime.modal_stack_navigation_degradation", + "scope": "direct", + "source_file": ".spec/specs/terminal_ui/runtime.spec.md", + "primary_plan_refs": ["7.3.2"], + "supporting_plan_refs": ["7.1.1", "7.4.2", "7.5.1"], + "ownership_note": "Direct terminal modal stack degradation obligation." + }, { "requirement_id": "terminal_ui.runtime.no_url_routing_assumption", "scope": "direct", @@ -745,6 +764,14 @@ "supporting_plan_refs": ["7.3.1", "7.3.2", "7.4.1", "7.5.1"], "ownership_note": "Upstream canonical navigation descriptor input consumed by terminal_ui." }, + { + "requirement_id": "unified_iur.interactions.modal_stack_transition_semantics", + "scope": "upstream_constraint", + "source_file": ".spec/specs/unified-iur/interactions.spec.md", + "primary_plan_refs": ["7.3.2"], + "supporting_plan_refs": ["7.4.1", "7.4.2", "7.5.1"], + "ownership_note": "Upstream canonical modal stack descriptor semantics consumed by terminal_ui." + }, { "requirement_id": "unified_iur.interactions.no_host_router_assumptions", "scope": "upstream_constraint", @@ -848,6 +875,14 @@ "primary_plan_refs": ["7.2.1"], "supporting_plan_refs": ["7.4.1", "7.5.2"], "ownership_note": "Upstream symbolic-target guardrail consumed by terminal_ui." + }, + { + "requirement_id": "unified_ui.signals.navigation_modal_stack_semantics", + "scope": "upstream_constraint", + "source_file": ".spec/specs/unified-ui/signals.spec.md", + "primary_plan_refs": ["7.3.2"], + "supporting_plan_refs": ["7.4.1", "7.4.2", "7.5.1"], + "ownership_note": "Upstream authored modal stack signal semantics consumed by terminal_ui." } ] } diff --git a/.spec/planning/terminal_ui/spec-traceability.md b/.spec/planning/terminal_ui/spec-traceability.md index d8c9fa2b..23d58db7 100644 --- a/.spec/planning/terminal_ui/spec-traceability.md +++ b/.spec/planning/terminal_ui/spec-traceability.md @@ -41,6 +41,7 @@ and which upstream package specs are treated as input constraints rather than | `ecosystem.signal_transport.terminal_bridge` | `4.2.1` | `4.2.2`, `4.5.1`, `6.2.2` | Direct inherited terminal_ui transport obligation. | | `ecosystem.signal_transport.native_signal_models_allowed` | `4.1.2` | `4.2.2`, `4.5.2` | Inherited local-runtime flexibility guardrail. | | `ecosystem.signal_transport.local_state_not_contract` | `1.2.1` | `4.2.2`, `5.3.2` | Inherited boundary-contract guardrail for local terminal runtime state. | +| `ecosystem.signal_transport.modal_stack_transition_meaning` | `7.3.2` | `7.4.1`, `7.4.2`, `7.5.1` | Inherited canonical modal stack transition meaning consumed by terminal_ui. | ### Referenced Root Requirements Outside `terminal_ui` Ownership These referenced root requirements are intentionally not mapped to `terminal_ui` tasks because they govern other runtime packages: @@ -87,6 +88,7 @@ tasks because they govern other runtime packages: | `terminal_ui.runtime.terminal_lifecycle_and_input` | `1.2.2` | `4.1.2`, `4.2.2`, `5.5.2` | Direct runtime lifecycle and input obligation. | | `terminal_ui.runtime.capability_variation_bounded` | `1.3.2` | `5.2.2`, `5.3.2` | Direct bounded-capability-variation obligation. | | `terminal_ui.runtime.canonical_navigation_transition_support` | `7.2.2` | `7.1.1`, `7.3.1`, `7.3.2`, `7.4.1`, `7.5.1` | Direct canonical navigation runtime obligation. | +| `terminal_ui.runtime.modal_stack_navigation_degradation` | `7.3.2` | `7.1.1`, `7.4.2`, `7.5.1` | Direct terminal modal stack degradation obligation. | | `terminal_ui.runtime.no_url_routing_assumption` | `7.2.1` | `7.4.1`, `7.4.2`, `7.5.2` | Direct no-host-route canonical navigation guardrail. | ### `capabilities.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | @@ -160,6 +162,7 @@ deliberate task coverage to preserve compatibility. | `unified_iur.interactions.standard_interaction_families` | `4.1.1` | `2.1.2`, `4.4.1`, `4.5.1` | Upstream canonical interaction scope consumed by terminal_ui. | | `unified_iur.interactions.data_binding_representation` | `2.1.2` | `2.2.1`, `4.2.1`, `4.5.2` | Upstream canonical data-binding input consumed by terminal_ui. | | `unified_iur.interactions.navigation_transition_representation` | `7.2.2` | `7.3.1`, `7.3.2`, `7.4.1`, `7.5.1` | Upstream canonical navigation descriptor input consumed by terminal_ui. | +| `unified_iur.interactions.modal_stack_transition_semantics` | `7.3.2` | `7.4.1`, `7.4.2`, `7.5.1` | Upstream canonical modal stack descriptor semantics consumed by terminal_ui. | | `unified_iur.interactions.no_host_router_assumptions` | `7.2.1` | `7.4.2`, `7.5.2` | Upstream canonical navigation hygiene guardrail. | ### `unified-ui/package.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | @@ -179,6 +182,7 @@ deliberate task coverage to preserve compatibility. | `unified_ui.signals.no_runtime_local_event_leakage` | `4.3.1` | `1.2.1`, `6.4.2`, `6.5.2` | Upstream authored-signal hygiene guardrail. | | `unified_ui.signals.navigation_transition_actions` | `7.2.2` | `7.3.1`, `7.3.2`, `7.4.1`, `7.5.1` | Upstream authored navigation actions consumed by terminal_ui. | | `unified_ui.signals.navigation_symbolic_screen_targets` | `7.2.1` | `7.4.1`, `7.5.2` | Upstream symbolic-target guardrail consumed by terminal_ui. | +| `unified_ui.signals.navigation_modal_stack_semantics` | `7.3.2` | `7.4.1`, `7.4.2`, `7.5.1` | Upstream authored modal stack signal semantics consumed by terminal_ui. | ## Scenario Alignment Pattern The per-spec scenario clauses are covered through the integration-test sections at the end of every phase: diff --git a/packages/desktop_ui/README.md b/packages/desktop_ui/README.md index c5b4b37f..bbd9b5fe 100644 --- a/packages/desktop_ui/README.md +++ b/packages/desktop_ui/README.md @@ -55,6 +55,12 @@ The package ships paired maintained examples for: - advanced layered and multiwindow flows - transport and normalized-input review - styled review and style continuity +- screen navigation, including independent modal stack review + +Desktop modal navigation is managed by `DesktopUi.Navigation.Controller`, not by +host routes. Opening a modal pushes onto the modal stack, targetless close pops +the top modal, and targeted close removes a matching symbolic modal while +preserving the main screen history and forward stacks. Use the example helpers directly: diff --git a/packages/desktop_ui/lib/desktop_ui/examples.ex b/packages/desktop_ui/lib/desktop_ui/examples.ex index a9ebee05..af0d2961 100644 --- a/packages/desktop_ui/lib/desktop_ui/examples.ex +++ b/packages/desktop_ui/lib/desktop_ui/examples.ex @@ -3,6 +3,7 @@ defmodule DesktopUi.Examples do Maintained foundational and advanced native and canonical examples for `desktop_ui`. """ + alias DesktopUi.Navigation.{Controller, State} alias UnifiedIUR.Element alias UnifiedIUR.Element.Child @@ -904,7 +905,10 @@ defmodule DesktopUi.Examples do DesktopUi.Widgets.column("nav-layout", [ DesktopUi.Widgets.content("nav-header", [ DesktopUi.Widgets.label("nav-title", "Navigation Demo"), - DesktopUi.Widgets.text("nav-subtitle", "Demonstrates home, list, and detail screens") + DesktopUi.Widgets.text( + "nav-subtitle", + "Demonstrates home, list, and detail screens" + ) ]), DesktopUi.Widgets.menu( "nav-menu", @@ -1012,6 +1016,51 @@ defmodule DesktopUi.Examples do } end + @spec modal_stack_navigation_review() :: map() + def modal_stack_navigation_review do + {:ok, controller} = + Controller.start_link(initial_screen: {:main, nil, %{section: :workspace}}) + + try do + {:ok, after_navigate, _transition} = + Controller.navigate(controller, :detail, %{item_id: :alpha}) + + {:ok, after_first_modal, _transition} = + Controller.open_modal(controller, :confirm_dialog, %{source: :detail}) + + {:ok, after_second_modal, _transition} = + Controller.open_modal(controller, :settings, %{section: :permissions}) + + {:ok, after_top_close, _transition} = Controller.close_modal(controller) + + {:ok, after_named_close, _transition} = + Controller.close_modal(controller, :confirm_dialog) + + %{ + id: :modal_stack_navigation_review, + summary: "Review independent desktop modal stack behavior over stable screen history", + coverage: [:navigation_widgets, :screen_navigation, :modal_stack, :history_stack], + after_navigate: navigation_snapshot(after_navigate), + after_first_modal: navigation_snapshot(after_first_modal), + after_second_modal: navigation_snapshot(after_second_modal), + after_top_close: navigation_snapshot(after_top_close), + after_named_close: navigation_snapshot(after_named_close), + parity: %{ + top_close_restores_previous_modal?: + State.top_modal(after_top_close) == State.top_modal(after_first_modal), + targetless_close_pops_only_top_modal?: + State.modal_depth(after_second_modal) == 2 and State.modal_depth(after_top_close) == 1, + named_close_clears_remaining_modal?: State.modal_depth(after_named_close) == 0, + screen_history_preserved?: + after_first_modal.history == after_second_modal.history and + after_second_modal.history == after_top_close.history + } + } + after + Controller.stop(controller) + end + end + @spec master_detail_navigation_screen() :: map() def master_detail_navigation_screen do %{ @@ -1210,7 +1259,8 @@ defmodule DesktopUi.Examples do :advanced_continuity, :transport_flow_review, :normalized_input_profiles, - :styled_continuity_review + :styled_continuity_review, + :modal_stack_navigation_review ] defp catalog_by_category(category) do @@ -1399,10 +1449,18 @@ defmodule DesktopUi.Examples do id: :modal_navigation, category: :native, workflow: :navigation_review, - parity_group: nil, - parity_with: [], + parity_group: :navigation_review, + parity_with: [:modal_stack_navigation_review], coverage: [:navigation_widgets, :screen_navigation, :modal_stack] }, + %{ + id: :modal_stack_navigation_review, + category: :mixed, + workflow: :navigation_review, + parity_group: :navigation_review, + parity_with: [:modal_navigation], + coverage: [:navigation_widgets, :screen_navigation, :modal_stack, :history_stack] + }, %{ id: :master_detail_navigation, category: :native, @@ -1418,6 +1476,25 @@ defmodule DesktopUi.Examples do Enum.reject(ids, &(&1 == "workspace-window")) end + defp navigation_snapshot(%State{} = state) do + %{ + current: state.current, + current_params: state.current_params, + history: Enum.map(state.history, &screen_entry_summary/1), + forward: Enum.map(state.forward, &screen_entry_summary/1), + modals: Enum.map(state.modals, &screen_entry_summary/1), + top_modal: state |> State.top_modal() |> screen_entry_summary(), + modal_depth: State.modal_depth(state), + modal_open?: State.modal_open?(state) + } + end + + defp screen_entry_summary(nil), do: nil + + defp screen_entry_summary({screen_id, _module, params}) do + %{screen_id: screen_id, params: params} + end + defp body_kind_sequence(tree) do tree |> root_for_sequence() diff --git a/packages/desktop_ui/test/desktop_ui/examples_test.exs b/packages/desktop_ui/test/desktop_ui/examples_test.exs index f0382743..762a5de1 100644 --- a/packages/desktop_ui/test/desktop_ui/examples_test.exs +++ b/packages/desktop_ui/test/desktop_ui/examples_test.exs @@ -61,7 +61,11 @@ defmodule DesktopUi.ExamplesTest do :native_foundational, :native_advanced_operations, :native_transport_review, - :native_styled_review + :native_styled_review, + :basic_navigation, + :history_navigation, + :modal_navigation, + :master_detail_navigation ] assert reference.examples.canonical_ids == [ @@ -76,14 +80,19 @@ defmodule DesktopUi.ExamplesTest do :advanced_continuity, :transport_flow_review, :normalized_input_profiles, - :styled_continuity_review + :styled_continuity_review, + :modal_stack_navigation_review ] assert summary.examples.native_ids == [ :native_foundational, :native_advanced_operations, :native_transport_review, - :native_styled_review + :native_styled_review, + :basic_navigation, + :history_navigation, + :modal_navigation, + :master_detail_navigation ] assert summary.examples.comparison_ids == [ @@ -91,10 +100,42 @@ defmodule DesktopUi.ExamplesTest do :advanced_continuity, :transport_flow_review, :normalized_input_profiles, - :styled_continuity_review + :styled_continuity_review, + :modal_stack_navigation_review ] end + test "navigation examples expose focused modal stack behavior over stable history" do + modal_example = DesktopUi.Examples.modal_navigation_screen() + review = DesktopUi.Examples.modal_stack_navigation_review() + + assert modal_example.metadata.example_id == :modal_navigation + assert :modal_stack in modal_example.metadata.coverage + + assert review.id == :modal_stack_navigation_review + assert review.after_second_modal.modal_depth == 2 + + assert Enum.map(review.after_second_modal.modals, & &1.screen_id) == [ + :settings, + :confirm_dialog + ] + + assert review.after_top_close.top_modal.screen_id == :confirm_dialog + assert review.after_named_close.modal_depth == 0 + assert review.parity.top_close_restores_previous_modal? + assert review.parity.targetless_close_pops_only_top_modal? + assert review.parity.named_close_clears_remaining_modal? + assert review.parity.screen_history_preserved? + + assert DesktopUi.Examples.metadata(:modal_stack_navigation_review).workflow == + :navigation_review + + assert :modal_stack_navigation_review in Enum.map( + DesktopUi.Examples.mixed_examples(), + & &1.id + ) + end + test "transport examples expose local routing, boundary translation, and normalized profiles" do native = DesktopUi.Examples.native_transport_review() canonical = DesktopUi.Examples.canonical_transport_review() diff --git a/packages/elm_ui/guides/runtime_backbone.md b/packages/elm_ui/guides/runtime_backbone.md index 1a8dee43..a319401f 100644 --- a/packages/elm_ui/guides/runtime_backbone.md +++ b/packages/elm_ui/guides/runtime_backbone.md @@ -23,6 +23,19 @@ 3. `ElmUi.FrontendRuntime` hydrates the payload into browser-facing realization. 4. Local events stay local when allowed, or cross the boundary through `ElmUi.Transport`. +## Modal Stack Navigation + +Canonical modal navigation remains server-authoritative. `open_modal` appends a +symbolic modal entry with params and metadata to the Phoenix runtime stack. +Targetless `close_modal` removes the topmost modal, targeted `close_modal` +removes the matching symbolic modal, and the frontend receives the resulting +authoritative modal stack in acknowledgement payloads. + +Host router state, browser route state, and URL generation are review inputs +for applications, not fields in the canonical transition contract. Divergence +between browser-local route state and the server decision is reported as a +runtime diagnostic. + ## Review Surfaces - `mix elm_ui.inspect native_styling` diff --git a/packages/elm_ui/lib/elm_ui/examples.ex b/packages/elm_ui/lib/elm_ui/examples.ex index f70832e7..e759b74e 100644 --- a/packages/elm_ui/lib/elm_ui/examples.ex +++ b/packages/elm_ui/lib/elm_ui/examples.ex @@ -5,6 +5,7 @@ defmodule ElmUi.Examples do alias UnifiedIUR.Element alias UnifiedIUR.Element.Child + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport alias ElmUi.Widgets @display_kinds [:viewport, :scroll_bar, :split_pane] @@ -1200,6 +1201,19 @@ defmodule ElmUi.Examples do same_modal_identifier?: get_in(native, [:after_modal, :navigation, :current_modal, :modal]) == get_in(canonical, [:after_modal, :navigation, :current_modal, :modal]), + same_second_modal_identifier?: + get_in(native, [:after_second_modal, :navigation, :current_modal, :modal]) == + get_in(canonical, [:after_second_modal, :navigation, :current_modal, :modal]), + top_close_restores_previous_modal?: + get_in(native, [:after_top_close, :navigation, :current_modal, :modal]) == + get_in(native, [:after_modal, :navigation, :current_modal, :modal]) and + get_in(canonical, [:after_top_close, :navigation, :current_modal, :modal]) == + get_in(canonical, [:after_modal, :navigation, :current_modal, :modal]), + modal_stack_reflected?: + length(native.after_second_modal.navigation.modals) == 2 and + length(canonical.after_second_modal.navigation.modals) == 2 and + length(native.after_top_close.navigation.modals) == 1 and + length(canonical.after_top_close.navigation.modals) == 1, same_replacement_target?: native.after_replace.screen_id == "home" and canonical.after_replace.screen_id == "home", host_route_externalized?: @@ -1213,9 +1227,13 @@ defmodule ElmUi.Examples do [ native.after_navigate.server_authoritative?, native.after_modal.server_authoritative?, + native.after_second_modal.server_authoritative?, + native.after_top_close.server_authoritative?, native.after_replace.server_authoritative?, canonical.after_navigate.server_authoritative?, canonical.after_modal.server_authoritative?, + canonical.after_second_modal.server_authoritative?, + canonical.after_top_close.server_authoritative?, canonical.after_replace.server_authoritative? ], & &1 @@ -1262,6 +1280,9 @@ defmodule ElmUi.Examples do end defp navigation_flow(kind) do + second_modal_fixture = BoundaryTransport.boundary_fixture!("modal_stack--open_confirm_dialog") + close_top_fixture = BoundaryTransport.boundary_fixture!("modal_stack--close_top") + {mount_result, screen_registry} = case kind do :native -> @@ -1337,8 +1358,44 @@ defmodule ElmUi.Examples do {:ok, frontend_after_modal} = ElmUi.FrontendRuntime.apply_server_message(frontend_after_modal_dispatch, modal_ack) - {:ok, frontend_after_replace_dispatch, replace_event_message} = + {:ok, frontend_after_second_modal_dispatch, second_modal_event_message} = ElmUi.FrontendRuntime.dispatch_interaction(frontend_after_modal, + family: :navigation, + intent: :open_settings_confirm_dialog, + boundary: :boundary, + widget_id: "dialog-link", + target: second_modal_fixture.descriptor.target + ) + + {:ok, state_after_second_modal, second_modal_ack} = + ElmUi.Runtime.handle_frontend_event(state_after_modal, second_modal_event_message) + + {:ok, frontend_after_second_modal} = + ElmUi.FrontendRuntime.apply_server_message( + frontend_after_second_modal_dispatch, + second_modal_ack + ) + + {:ok, frontend_after_top_close_dispatch, close_top_event_message} = + ElmUi.FrontendRuntime.dispatch_interaction(frontend_after_second_modal, + family: :navigation, + intent: :close_top_modal, + boundary: :boundary, + widget_id: "dialog-link", + target: close_top_fixture.descriptor.target + ) + + {:ok, state_after_top_close, close_top_ack} = + ElmUi.Runtime.handle_frontend_event(state_after_second_modal, close_top_event_message) + + {:ok, frontend_after_top_close} = + ElmUi.FrontendRuntime.apply_server_message( + frontend_after_top_close_dispatch, + close_top_ack + ) + + {:ok, frontend_after_replace_dispatch, replace_event_message} = + ElmUi.FrontendRuntime.dispatch_interaction(frontend_after_top_close, family: :navigation, intent: :replace_home_screen, boundary: :boundary, @@ -1352,7 +1409,7 @@ defmodule ElmUi.Examples do ) {:ok, state_after_replace, replace_ack} = - ElmUi.Runtime.handle_frontend_event(state_after_modal, replace_event_message) + ElmUi.Runtime.handle_frontend_event(state_after_top_close, replace_event_message) {:ok, frontend_after_replace} = ElmUi.FrontendRuntime.apply_server_message(frontend_after_replace_dispatch, replace_ack) @@ -1368,6 +1425,15 @@ defmodule ElmUi.Examples do navigation_route_state("settings", %{tab: :profile}) ), after_modal: navigation_snapshot(state_after_modal, frontend_after_modal, modal_ack, nil), + after_second_modal: + navigation_snapshot( + state_after_second_modal, + frontend_after_second_modal, + second_modal_ack, + nil + ), + after_top_close: + navigation_snapshot(state_after_top_close, frontend_after_top_close, close_top_ack, nil), after_replace: navigation_snapshot( state_after_replace, diff --git a/packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs b/packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs index 9b9c9ab2..d7c36cfd 100644 --- a/packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs +++ b/packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs @@ -8,6 +8,8 @@ defmodule ElmUi.CanonicalNavigationWebRuntimeIntegrationTest do navigate_fixture = BoundaryTransport.boundary_fixture!("screen_transition--settings_profile") replace_fixture = BoundaryTransport.boundary_fixture!("replace_transition--home") modal_fixture = BoundaryTransport.boundary_fixture!("modal_transition--settings_dialog") + second_modal_fixture = BoundaryTransport.boundary_fixture!("modal_stack--open_confirm_dialog") + close_top_fixture = BoundaryTransport.boundary_fixture!("modal_stack--close_top") comparison = ElmUi.Examples.navigation_comparison() @@ -29,9 +31,32 @@ defmodule ElmUi.CanonicalNavigationWebRuntimeIntegrationTest do assert modal_summary(comparison.canonical.after_modal.navigation.current_modal) == modal_summary(modal_fixture.descriptor.target.navigation) + assert modal_summary(comparison.native.after_second_modal.navigation.current_modal) == + modal_summary(second_modal_fixture.descriptor.target.navigation) + + assert modal_summary(comparison.canonical.after_second_modal.navigation.current_modal) == + modal_summary(second_modal_fixture.descriptor.target.navigation) + + assert modal_stack_summary(comparison.native.after_top_close.navigation.last_transition) == + modal_stack_summary(close_top_fixture.descriptor.target.navigation) + + assert modal_stack_summary(comparison.canonical.after_top_close.navigation.last_transition) == + modal_stack_summary(close_top_fixture.descriptor.target.navigation) + + assert Enum.map(comparison.native.after_second_modal.navigation.modals, & &1.modal) == [ + :settings_dialog, + :settings_confirm_dialog + ] + + assert comparison.native.after_top_close.navigation.current_modal.modal == :settings_dialog + assert comparison.canonical.after_top_close.navigation.current_modal.modal == :settings_dialog + assert comparison.continuity.same_navigation_target? assert comparison.continuity.frontend_coordination? assert comparison.continuity.same_modal_identifier? + assert comparison.continuity.same_second_modal_identifier? + assert comparison.continuity.top_close_restores_previous_modal? + assert comparison.continuity.modal_stack_reflected? assert comparison.continuity.same_replacement_target? assert comparison.continuity.server_authority_preserved? end @@ -224,6 +249,13 @@ defmodule ElmUi.CanonicalNavigationWebRuntimeIntegrationTest do } end + defp modal_stack_summary(target) do + %{ + action: get_value(target, :action), + modal_stack: normalize_map(get_value(target, :modal_stack, %{})) + } + end + defp get_value(map, key, default \\ nil) when is_map(map) do Map.get(map, key, Map.get(map, Atom.to_string(key), default)) end diff --git a/packages/elm_ui/test/elm_ui/examples_test.exs b/packages/elm_ui/test/elm_ui/examples_test.exs index 563b99c1..f9cb0921 100644 --- a/packages/elm_ui/test/elm_ui/examples_test.exs +++ b/packages/elm_ui/test/elm_ui/examples_test.exs @@ -189,15 +189,33 @@ defmodule ElmUi.ExamplesTest do assert comparison.native.after_navigate.screen_id == "settings" assert comparison.native.after_navigate.frontend_screen_id == "settings" assert comparison.native.after_modal.navigation.current_modal.modal == :settings_dialog + + assert comparison.native.after_second_modal.navigation.current_modal.modal == + :settings_confirm_dialog + + assert Enum.map(comparison.native.after_second_modal.navigation.modals, & &1.modal) == [ + :settings_dialog, + :settings_confirm_dialog + ] + + assert comparison.native.after_top_close.navigation.current_modal.modal == :settings_dialog assert comparison.native.after_replace.screen_id == "home" assert comparison.canonical.after_navigate.screen_id == "settings" assert comparison.canonical.after_navigate.frontend_screen_id == "settings" + + assert comparison.canonical.after_second_modal.navigation.current_modal.modal == + :settings_confirm_dialog + + assert comparison.canonical.after_top_close.navigation.current_modal.modal == :settings_dialog assert comparison.canonical.after_replace.screen_id == "home" assert comparison.continuity.same_navigation_target? assert comparison.continuity.frontend_coordination? assert comparison.continuity.same_modal_identifier? + assert comparison.continuity.same_second_modal_identifier? + assert comparison.continuity.top_close_restores_previous_modal? + assert comparison.continuity.modal_stack_reflected? assert comparison.continuity.same_replacement_target? assert comparison.continuity.host_route_externalized? assert comparison.continuity.server_authority_preserved? diff --git a/packages/live_ui/guides/runtime_backbone.md b/packages/live_ui/guides/runtime_backbone.md index a08fafd4..2ee2a74f 100644 --- a/packages/live_ui/guides/runtime_backbone.md +++ b/packages/live_ui/guides/runtime_backbone.md @@ -20,6 +20,19 @@ That shared runtime is intentionally server-authoritative: - browser hooks are normalized before entering runtime event handling - canonical rendering reuses the same runtime host instead of introducing a second renderer stack +## Modal Stack Navigation + +Canonical modal navigation is resolved on the LiveView server. `open_modal` +pushes a symbolic modal entry with params and metadata onto the server-owned +modal stack. Targetless `close_modal` closes the topmost modal, and targeted +`close_modal` closes a matching symbolic modal without changing screen history +or forward state. + +Phoenix router lookup and URL generation may still be used by host +applications, but those values stay outside the canonical navigation descriptor. +The maintained web navigation comparison example exposes the modal stack in +native and canonical runtime snapshots for review. + ## Boundary Expectations `LiveUi` is allowed to keep renderer-local behavior inside the direct native diff --git a/packages/live_ui/lib/live_ui/examples/web_navigation_transition_comparison.ex b/packages/live_ui/lib/live_ui/examples/web_navigation_transition_comparison.ex index e9c521d1..5e4e029e 100644 --- a/packages/live_ui/lib/live_ui/examples/web_navigation_transition_comparison.ex +++ b/packages/live_ui/lib/live_ui/examples/web_navigation_transition_comparison.ex @@ -68,6 +68,9 @@ defmodule LiveUi.Examples.WebNavigationTransitionComparison do end defp native_flow do + second_modal_fixture = BoundaryTransport.boundary_fixture!("modal_stack--open_confirm_dialog") + close_top_fixture = BoundaryTransport.boundary_fixture!("modal_stack--close_top") + with {:ok, runtime_state} <- LiveUi.Runtime.mount(HomeScreen, screen_registry: screen_registry(:native), @@ -96,9 +99,27 @@ defmodule LiveUi.Examples.WebNavigationTransitionComparison do metadata: %{surface: :workspace} ) ), - {:ok, after_replace, replace_translation} <- + {:ok, with_second_modal, second_modal_translation} <- LiveUi.Runtime.dispatch_native_event( with_modal, + "navigation:open_settings_confirm_dialog", + %{}, + family: :navigation, + intent: :open_settings_confirm_dialog, + target: second_modal_fixture.descriptor.target + ), + {:ok, after_top_close, close_top_translation} <- + LiveUi.Runtime.dispatch_native_event( + with_second_modal, + "navigation:close_top_modal", + %{}, + family: :navigation, + intent: :close_top_modal, + target: close_top_fixture.descriptor.target + ), + {:ok, after_replace, replace_translation} <- + LiveUi.Runtime.dispatch_native_event( + after_top_close, "navigation:replace_home_screen", %{}, family: :navigation, @@ -113,15 +134,21 @@ defmodule LiveUi.Examples.WebNavigationTransitionComparison do %{ after_navigate: runtime_snapshot(after_navigate), after_modal: runtime_snapshot(with_modal), + after_second_modal: runtime_snapshot(with_second_modal), + after_top_close: runtime_snapshot(after_top_close), after_replace: runtime_snapshot(after_replace), runtime_events: [ navigate_translation.runtime_event, modal_translation.runtime_event, + second_modal_translation.runtime_event, + close_top_translation.runtime_event, replace_translation.runtime_event ], transition_targets: %{ navigate: navigate_translation.target.navigation, modal: modal_translation.target.navigation, + second_modal: second_modal_translation.target.navigation, + close_top: close_top_translation.target.navigation, replace: replace_translation.target.navigation } }} @@ -133,6 +160,11 @@ defmodule LiveUi.Examples.WebNavigationTransitionComparison do screen_transition = BoundaryTransport.boundary_fixture!("screen_transition--settings_profile") modal_transition = BoundaryTransport.boundary_fixture!("modal_transition--settings_dialog") + + second_modal_transition = + BoundaryTransport.boundary_fixture!("modal_stack--open_confirm_dialog") + + close_top_transition = BoundaryTransport.boundary_fixture!("modal_stack--close_top") replace_transition = BoundaryTransport.boundary_fixture!("replace_transition--home") with {:ok, runtime_state} <- @@ -147,22 +179,34 @@ defmodule LiveUi.Examples.WebNavigationTransitionComparison do {:ok, modal_signal} <- boundary_signal(modal_transition, :settings), {:ok, with_modal, modal_runtime_action} <- LiveUi.Runtime.handle_boundary_signal(after_navigate, modal_signal), + {:ok, second_modal_signal} <- boundary_signal(second_modal_transition, :settings), + {:ok, with_second_modal, second_modal_runtime_action} <- + LiveUi.Runtime.handle_boundary_signal(with_modal, second_modal_signal), + {:ok, close_top_signal} <- boundary_signal(close_top_transition, :settings), + {:ok, after_top_close, close_top_runtime_action} <- + LiveUi.Runtime.handle_boundary_signal(with_second_modal, close_top_signal), {:ok, replace_signal} <- boundary_signal(replace_transition, :settings), {:ok, after_replace, replace_runtime_action} <- - LiveUi.Runtime.handle_boundary_signal(with_modal, replace_signal) do + LiveUi.Runtime.handle_boundary_signal(after_top_close, replace_signal) do {:ok, %{ after_navigate: runtime_snapshot(after_navigate), after_modal: runtime_snapshot(with_modal), + after_second_modal: runtime_snapshot(with_second_modal), + after_top_close: runtime_snapshot(after_top_close), after_replace: runtime_snapshot(after_replace), runtime_events: [ navigate_runtime_action.runtime_event, modal_runtime_action.runtime_event, + second_modal_runtime_action.runtime_event, + close_top_runtime_action.runtime_event, replace_runtime_action.runtime_event ], transition_targets: %{ navigate: navigate_runtime_action.target.navigation, modal: modal_runtime_action.target.navigation, + second_modal: second_modal_runtime_action.target.navigation, + close_top: close_top_runtime_action.target.navigation, replace: replace_runtime_action.target.navigation } }} @@ -188,6 +232,19 @@ defmodule LiveUi.Examples.WebNavigationTransitionComparison do same_modal_identifier?: get_in(native, [:after_modal, :current_modal, :modal]) == get_in(canonical, [:after_modal, :current_modal, :modal]), + same_second_modal_identifier?: + get_in(native, [:after_second_modal, :current_modal, :modal]) == + get_in(canonical, [:after_second_modal, :current_modal, :modal]), + top_close_restores_previous_modal?: + get_in(native, [:after_top_close, :current_modal, :modal]) == + get_in(native, [:after_modal, :current_modal, :modal]) and + get_in(canonical, [:after_top_close, :current_modal, :modal]) == + get_in(canonical, [:after_modal, :current_modal, :modal]), + modal_stack_reflected?: + length(native.after_second_modal.modal_stack) == 2 and + length(canonical.after_second_modal.modal_stack) == 2 and + length(native.after_top_close.modal_stack) == 1 and + length(canonical.after_top_close.modal_stack) == 1, same_replacement_target?: native.after_replace.screen_id == :home and canonical.after_replace.screen_id == :home, replacement_clears_modal?: @@ -204,9 +261,13 @@ defmodule LiveUi.Examples.WebNavigationTransitionComparison do [ native.after_navigate.server_authoritative?, native.after_modal.server_authoritative?, + native.after_second_modal.server_authoritative?, + native.after_top_close.server_authoritative?, native.after_replace.server_authoritative?, canonical.after_navigate.server_authoritative?, canonical.after_modal.server_authoritative?, + canonical.after_second_modal.server_authoritative?, + canonical.after_top_close.server_authoritative?, canonical.after_replace.server_authoritative? ], & &1 @@ -269,6 +330,7 @@ defmodule LiveUi.Examples.WebNavigationTransitionComparison do params: state.assigns.navigation_params, history: state.assigns.navigation_history, forward: state.assigns.navigation_forward, + modal_stack: state.assigns.navigation_modal_stack, current_modal: state.assigns.current_modal, host_route: state.assigns.navigation_host_route, server_authoritative?: LiveUi.Runtime.assumptions().server_authoritative? diff --git a/packages/live_ui/test/live_ui/canonical_navigation_web_runtime_integration_test.exs b/packages/live_ui/test/live_ui/canonical_navigation_web_runtime_integration_test.exs index 51f79e7c..78ae91b8 100644 --- a/packages/live_ui/test/live_ui/canonical_navigation_web_runtime_integration_test.exs +++ b/packages/live_ui/test/live_ui/canonical_navigation_web_runtime_integration_test.exs @@ -10,6 +10,8 @@ defmodule LiveUi.CanonicalNavigationWebRuntimeIntegrationTest do navigate_fixture = BoundaryTransport.boundary_fixture!("screen_transition--settings_profile") replace_fixture = BoundaryTransport.boundary_fixture!("replace_transition--home") modal_fixture = BoundaryTransport.boundary_fixture!("modal_transition--settings_dialog") + second_modal_fixture = BoundaryTransport.boundary_fixture!("modal_stack--open_confirm_dialog") + close_top_fixture = BoundaryTransport.boundary_fixture!("modal_stack--close_top") assert {:ok, comparison} = WebNavigationTransitionComparison.compare() @@ -31,8 +33,31 @@ defmodule LiveUi.CanonicalNavigationWebRuntimeIntegrationTest do assert modal_summary(comparison.canonical.transition_targets.modal) == modal_summary(modal_fixture.descriptor.target.navigation) + assert modal_summary(comparison.native.transition_targets.second_modal) == + modal_summary(second_modal_fixture.descriptor.target.navigation) + + assert modal_summary(comparison.canonical.transition_targets.second_modal) == + modal_summary(second_modal_fixture.descriptor.target.navigation) + + assert modal_stack_summary(comparison.native.transition_targets.close_top) == + modal_stack_summary(close_top_fixture.descriptor.target.navigation) + + assert modal_stack_summary(comparison.canonical.transition_targets.close_top) == + modal_stack_summary(close_top_fixture.descriptor.target.navigation) + + assert Enum.map(comparison.native.after_second_modal.modal_stack, & &1.modal) == [ + :settings_dialog, + :settings_confirm_dialog + ] + + assert comparison.native.after_top_close.current_modal.modal == :settings_dialog + assert comparison.canonical.after_top_close.current_modal.modal == :settings_dialog + assert comparison.continuity.same_navigation_target? assert comparison.continuity.same_modal_identifier? + assert comparison.continuity.same_second_modal_identifier? + assert comparison.continuity.top_close_restores_previous_modal? + assert comparison.continuity.modal_stack_reflected? assert comparison.continuity.same_replacement_target? assert comparison.continuity.history_meaning_preserved? assert comparison.continuity.server_authoritative? @@ -95,6 +120,13 @@ defmodule LiveUi.CanonicalNavigationWebRuntimeIntegrationTest do } end + defp modal_stack_summary(target) do + %{ + action: get_value(target, :action), + modal_stack: normalize_map(get_value(target, :modal_stack, %{})) + } + end + defp get_value(map, key, default \\ nil) when is_map(map) do Map.get(map, key, Map.get(map, Atom.to_string(key), default)) end diff --git a/packages/terminal_ui/guides/runtime_backbone.md b/packages/terminal_ui/guides/runtime_backbone.md index 9e4e47ea..cefda6aa 100644 --- a/packages/terminal_ui/guides/runtime_backbone.md +++ b/packages/terminal_ui/guides/runtime_backbone.md @@ -34,6 +34,19 @@ degradation arrive in later phases. - `TerminalUi.Transport` keeps local terminal inputs and canonical boundary signal translation explicit under one package boundary. +## Modal Stack Navigation + +Canonical modal transitions preserve stack meaning even when terminal +presentation degrades. `open_modal` records symbolic modal entries with params, +metadata, and backend-specific degradation metadata. Targetless `close_modal` +removes the topmost modal and restores the previous modal or underlying screen +state without requiring browser routes, runtime-local stack ids, or structural +modal containment. + +Rich backends report overlay presentation, while TTY-compatible backends report +bounded inline overlay presentation. Both paths expose the same canonical stack +state through runtime inspection. + Use the additional guides for task-oriented detail: - `guides/native_runtime_and_examples.md` diff --git a/packages/terminal_ui/lib/terminal_ui/examples.ex b/packages/terminal_ui/lib/terminal_ui/examples.ex index d4fbf913..a05d6330 100644 --- a/packages/terminal_ui/lib/terminal_ui/examples.ex +++ b/packages/terminal_ui/lib/terminal_ui/examples.ex @@ -4,6 +4,7 @@ defmodule TerminalUi.Examples do """ alias UnifiedIUR.{Canvas, Element, Interaction, Layer, Layout} + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport alias UnifiedIUR.Widgets.{Advanced, Data, Feedback, Foundational, Input, Navigation} @spec native_foundational_screen() :: map() @@ -581,6 +582,7 @@ defmodule TerminalUi.Examples do advanced_continuity: advanced_comparison(), advanced_capability_continuity: advanced_capability_comparison(), transport_flow_review: transport_flow_comparison(), + modal_stack_navigation_review: modal_stack_navigation_review(), normalized_input_profiles: normalized_input_comparison(), styled_continuity_review: styled_continuity_comparison(), styled_degradation_review: styled_degradation_comparison() @@ -632,8 +634,10 @@ defmodule TerminalUi.Examples do :native_transport_review, :canonical_transport_review, :transport_flow_review, - :normalized_input_profiles + :normalized_input_profiles, + :modal_stack_navigation_review ], + navigation_review: [:modal_stack_navigation_review], style_review: [ :native_styled_review, :canonical_styled_review, @@ -659,8 +663,10 @@ defmodule TerminalUi.Examples do :native_transport_review, :canonical_transport_review, :transport_flow_review, - :normalized_input_profiles + :normalized_input_profiles, + :modal_stack_navigation_review ], + navigation_runtime_review: [:modal_stack_navigation_review], styled_workspace_review: [ :native_styled_review, :canonical_styled_review, @@ -834,6 +840,49 @@ defmodule TerminalUi.Examples do } end + @spec modal_stack_navigation_review() :: map() + def modal_stack_navigation_review do + raw = modal_stack_navigation_profile(:raw) + tty = modal_stack_navigation_profile(:tty) + + %{ + id: :modal_stack_navigation_review, + summary: + "Review canonical modal stack navigation across rich and degraded terminal backends", + coverage: [:canonical_navigation, :modal_stack, :capability_degradation, :inspection], + raw: raw, + tty: tty, + parity: %{ + same_stack_meaning?: + modal_ids(raw.after_second_modal) == modal_ids(tty.after_second_modal) and + modal_ids(raw.after_top_close) == modal_ids(tty.after_top_close), + targetless_close_restores_previous_modal?: + get_in(raw, [:after_top_close, :current_modal, :modal]) == :settings_dialog and + get_in(tty, [:after_top_close, :current_modal, :modal]) == :settings_dialog, + tty_degradation_explicit?: + Enum.all?(tty.after_second_modal.modals, fn modal -> + modal.degradation.presentation == :inline_overlay and modal.degradation.bounded? + end), + raw_overlay_preserved?: + Enum.all?(raw.after_second_modal.modals, fn modal -> + modal.degradation.presentation == :overlay and modal.degradation.bounded? + end), + no_host_route_fields?: + Enum.all?( + [ + raw.first_route, + raw.second_route, + raw.close_top_route, + tty.first_route, + tty.second_route, + tty.close_top_route + ], + fn route -> not Map.has_key?(route.translation.target.navigation, :route) end + ) + } + } + end + @spec normalized_input_comparison() :: map() def normalized_input_comparison do {:ok, raw_shortcut} = @@ -1037,6 +1086,74 @@ defmodule TerminalUi.Examples do } end + defp modal_stack_navigation_profile(backend_mode) do + first_modal = BoundaryTransport.boundary_fixture!("modal_transition--settings_dialog") + second_modal = BoundaryTransport.boundary_fixture!("modal_stack--open_confirm_dialog") + close_top = BoundaryTransport.boundary_fixture!("modal_stack--close_top") + + {:ok, state} = + TerminalUi.Runtime.mount_native_screen(native_transport_screen(), + backend_mode: backend_mode + ) + + {:ok, after_first_modal, first_route} = + TerminalUi.Runtime.handle_boundary_signal( + state, + boundary_signal(first_modal, backend_mode) + ) + + {:ok, after_second_modal, second_route} = + TerminalUi.Runtime.handle_boundary_signal( + after_first_modal, + boundary_signal(second_modal, backend_mode) + ) + + {:ok, after_top_close, close_top_route} = + TerminalUi.Runtime.handle_boundary_signal( + after_second_modal, + boundary_signal(close_top, backend_mode) + ) + + %{ + backend_mode: backend_mode, + after_first_modal: navigation_snapshot(after_first_modal), + after_second_modal: navigation_snapshot(after_second_modal), + after_top_close: navigation_snapshot(after_top_close), + first_route: route_summary(first_route), + second_route: route_summary(second_route), + close_top_route: route_summary(close_top_route) + } + end + + defp boundary_signal(fixture, backend_mode) do + {:ok, translation} = + TerminalUi.Transport.from_interaction( + fixture.interaction, + backend_mode: backend_mode, + input_family: :key, + widget_id: fixture.interaction.source.element_id, + runtime_id: "terminal-ui:modal-stack-review", + screen: "transport-review", + payload: fixture.signal_data + ) + + translation.signal + end + + defp navigation_snapshot(runtime_state) do + %{ + modals: runtime_state.navigation.modals, + current_modal: runtime_state.navigation.current_modal, + last_transition: runtime_state.navigation.last_transition, + diagnostics: runtime_state.navigation.diagnostics, + inspection: TerminalUi.Inspection.runtime_snapshot(runtime_state).navigation + } + end + + defp modal_ids(snapshot) do + Enum.map(snapshot.modals, & &1.modal) + end + defp route_for_translation(%{boundary: :boundary}), do: :canonical_boundary defp route_for_translation(_translation), do: :local_runtime @@ -1177,6 +1294,14 @@ defmodule TerminalUi.Examples do parity_with: [:native_transport_review, :canonical_transport_review], capability_profiles: [:rich_terminal] }, + %{ + id: :modal_stack_navigation_review, + category: :mixed, + workflow: :navigation, + parity_group: :navigation_review, + parity_with: [:native_transport_review, :canonical_transport_review], + capability_profiles: [:rich_terminal, :fallback_terminal] + }, %{ id: :normalized_input_profiles, category: :mixed, diff --git a/packages/terminal_ui/test/terminal_ui/examples_test.exs b/packages/terminal_ui/test/terminal_ui/examples_test.exs index 4fcfabb4..5a95f86a 100644 --- a/packages/terminal_ui/test/terminal_ui/examples_test.exs +++ b/packages/terminal_ui/test/terminal_ui/examples_test.exs @@ -45,6 +45,7 @@ defmodule TerminalUi.ExamplesTest do :canonical_styled_review, :canonical_transport_review, :foundational_continuity, + :modal_stack_navigation_review, :native_advanced_operations, :native_foundational, :native_styled_review, @@ -62,6 +63,7 @@ defmodule TerminalUi.ExamplesTest do :advanced_capability_continuity, :advanced_continuity, :foundational_continuity, + :modal_stack_navigation_review, :normalized_input_profiles, :styled_continuity_review, :styled_degradation_review, @@ -90,6 +92,7 @@ defmodule TerminalUi.ExamplesTest do advanced = TerminalUi.Examples.advanced_comparison() capability = TerminalUi.Examples.advanced_capability_comparison() transport = TerminalUi.Examples.transport_flow_comparison() + modal_stack = TerminalUi.Examples.modal_stack_navigation_review() normalized = TerminalUi.Examples.normalized_input_comparison() styled = TerminalUi.Examples.styled_continuity_comparison() degradation = TerminalUi.Examples.styled_degradation_comparison() @@ -118,6 +121,25 @@ defmodule TerminalUi.ExamplesTest do assert transport.parity.boundary_routes_emit_signals? assert transport.parity.runtime_event_meaning_preserved? + assert modal_stack.id == :modal_stack_navigation_review + + assert Enum.map(modal_stack.raw.after_second_modal.modals, & &1.modal) == [ + :settings_dialog, + :settings_confirm_dialog + ] + + assert modal_stack.tty.after_second_modal.current_modal.modal == :settings_confirm_dialog + assert modal_stack.raw.after_top_close.current_modal.modal == :settings_dialog + + assert modal_stack.tty.after_second_modal.inspection.current_modal.modal == + :settings_confirm_dialog + + assert modal_stack.parity.same_stack_meaning? + assert modal_stack.parity.targetless_close_restores_previous_modal? + assert modal_stack.parity.tty_degradation_explicit? + assert modal_stack.parity.raw_overlay_preserved? + assert modal_stack.parity.no_host_route_fields? + assert normalized.id == :normalized_input_profiles assert normalized.parity.shortcut_family_match? assert normalized.parity.resize_family_match? @@ -157,12 +179,14 @@ defmodule TerminalUi.ExamplesTest do assert :advanced_continuity in reference.examples.comparison_ids assert :advanced_capability_continuity in reference.examples.comparison_ids assert :transport_flow_review in reference.examples.comparison_ids + assert :modal_stack_navigation_review in reference.examples.comparison_ids assert :normalized_input_profiles in reference.examples.comparison_ids assert :styled_continuity_review in reference.examples.comparison_ids assert :styled_degradation_review in reference.examples.comparison_ids assert Map.has_key?(reference.examples.coverage_matrix.categories, :forms) assert Map.has_key?(reference.examples.coverage_matrix.categories, :layering) assert Map.has_key?(reference.examples.coverage_matrix.categories, :transport) + assert Map.has_key?(reference.examples.coverage_matrix.workflows, :navigation_review) assert Map.has_key?(reference.examples.coverage_matrix.categories, :style) assert summary.examples.native_ids == [ From cc4e7e6f964e9ad8ef8651be4abe59db094b5d3a Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Thu, 14 May 2026 08:11:34 -0400 Subject: [PATCH 13/13] Cover runtime modal stack integration gates --- ...untime-modal-stack-navigation-alignment.md | 20 ++-- .../lib/desktop_ui/transport/diagnostics.ex | 42 +++++++- .../lib/desktop_ui/transport/error.ex | 10 ++ .../lib/desktop_ui/transport/signal.ex | 41 +++++++- ..._navigation_transport_integration_test.exs | 95 +++++++++++++++++++ .../test/desktop_ui/transport_test.exs | 25 +++++ ...avigation_web_runtime_integration_test.exs | 43 ++++++--- .../elm_ui/transport_diagnostics_test.exs | 22 +++++ ..._navigation_transport_integration_test.exs | 35 +++++++ ...avigation_web_runtime_integration_test.exs | 10 ++ .../live_ui/transport_diagnostics_test.exs | 21 ++++ .../lib/terminal_ui/transport/diagnostics.ex | 42 +++++++- .../lib/terminal_ui/transport/error.ex | 10 ++ .../lib/terminal_ui/transport/signal.ex | 41 +++++++- ..._navigation_transport_integration_test.exs | 44 +++++++++ .../transport_diagnostics_test.exs | 25 +++++ ...l_navigation_boundary_integration_test.exs | 58 +++++++++++ .../interactions_transport_test.exs | 23 +++++ 18 files changed, 580 insertions(+), 27 deletions(-) diff --git a/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md b/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md index 31c57dff..b677c2b5 100644 --- a/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md +++ b/.spec/planning/canonical_navigation/phase-03-runtime-modal-stack-navigation-alignment.md @@ -23,7 +23,7 @@ Back to index: [README](./README.md) - Terminal implementations may degrade stacked modal presentation into panels, inline overlays, or bounded screen sections while preserving stack meaning. -[ ] 3 Phase 3 - Runtime Modal Stack Navigation Alignment +[x] 3 Phase 3 - Runtime Modal Stack Navigation Alignment Implement and verify modal stack navigation behavior in each runtime package that consumes canonical navigation transitions, keeping stack meaning portable while allowing each runtime to realize the experience through its @@ -111,23 +111,23 @@ Back to index: [README](./README.md) [x] 3.4.2.2 Subtask - Update package planning traceability for the new modal stack requirements where package-local manifests are maintained. [x] 3.4.2.3 Subtask - Regenerate generated traceability mirrors through the owning Mix tasks rather than hand-editing generated review output. - [ ] 3.5 Section - Phase 3 Integration Tests + [x] 3.5 Section - Phase 3 Integration Tests Validate that each runtime consumes canonical modal stack transitions consistently and keeps host-specific details outside the canonical boundary. - [ ] 3.5.1 Task - Cross-package modal stack transition scenarios + [x] 3.5.1 Task - Cross-package modal stack transition scenarios Verify shared fixtures produce the same stack meaning in each runtime package even when host realization differs. - [ ] 3.5.1.1 Subtask - Verify shared modal stack fixtures validate through `UnifiedIUR` and signal transport before runtime package tests consume them. - [ ] 3.5.1.2 Subtask - Verify `live_ui`, `elm_ui`, and `desktop_ui` preserve open/open/top-close state transitions without changing screen history. - [ ] 3.5.1.3 Subtask - Verify `terminal_ui` preserves stack meaning and reports bounded degradation without requiring browser routes or runtime-local stack ids. + [x] 3.5.1.1 Subtask - Verify shared modal stack fixtures validate through `UnifiedIUR` and signal transport before runtime package tests consume them. + [x] 3.5.1.2 Subtask - Verify `live_ui`, `elm_ui`, and `desktop_ui` preserve open/open/top-close state transitions without changing screen history. + [x] 3.5.1.3 Subtask - Verify `terminal_ui` preserves stack meaning and reports bounded degradation without requiring browser routes or runtime-local stack ids. - [ ] 3.5.2 Task - Contract hygiene and regression scenarios + [x] 3.5.2 Task - Contract hygiene and regression scenarios Verify modal stack support does not reopen router leakage or accidental structural containment assumptions. - [ ] 3.5.2.1 Subtask - Verify URL-like targets, host-router names, runtime module references, and modal containment metadata are rejected or ignored according to the canonical contract. - [ ] 3.5.2.2 Subtask - Verify targetless `close_modal` remains topmost close in every runtime and targeted `close_modal` remains symbolic where supported. - [ ] 3.5.2.3 Subtask - Verify examples, inspection output, and validation workflows expose modal stack state clearly enough for review. + [x] 3.5.2.1 Subtask - Verify URL-like targets, host-router names, runtime module references, and modal containment metadata are rejected or ignored according to the canonical contract. + [x] 3.5.2.2 Subtask - Verify targetless `close_modal` remains topmost close in every runtime and targeted `close_modal` remains symbolic where supported. + [x] 3.5.2.3 Subtask - Verify examples, inspection output, and validation workflows expose modal stack state clearly enough for review. diff --git a/packages/desktop_ui/lib/desktop_ui/transport/diagnostics.ex b/packages/desktop_ui/lib/desktop_ui/transport/diagnostics.ex index 0f49a270..c4a56b88 100644 --- a/packages/desktop_ui/lib/desktop_ui/transport/diagnostics.ex +++ b/packages/desktop_ui/lib/desktop_ui/transport/diagnostics.ex @@ -6,6 +6,7 @@ defmodule DesktopUi.Transport.Diagnostics do alias Jido.Signal alias DesktopUi.Transport.{Error, Normalize} alias DesktopUi.Transport.Signal, as: TransportSignal + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport @payload_leak_keys [ :backend_payload, @@ -35,7 +36,8 @@ defmodule DesktopUi.Transport.Diagnostics do @spec validate_native_event(keyword() | map()) :: :ok | {:error, Error.t()} def validate_native_event(attrs) when is_map(attrs) or is_list(attrs) do with {:ok, normalized} <- Normalize.normalize(attrs), - :ok <- validate_payload(Map.get(normalized, :payload, %{}), :native_event) do + :ok <- validate_payload(Map.get(normalized, :payload, %{}), :native_event), + :ok <- validate_navigation_target(Map.get(normalized, :target, %{})) do if Map.get(normalized, :boundary) == :boundary do validate_boundary_context(normalized) else @@ -50,6 +52,7 @@ defmodule DesktopUi.Transport.Diagnostics do def validate_translation(%{} = translation) do with :ok <- validate_payload(Map.get(translation, :payload, %{}), :translation_payload), :ok <- validate_payload(Map.get(translation, :target, %{}), :translation_target), + :ok <- validate_navigation_target(Map.get(translation, :target, %{})), :ok <- maybe_validate_boundary_signal(translation) do if Map.get(translation, :boundary) == :boundary do validate_boundary_context(translation) @@ -110,6 +113,19 @@ defmodule DesktopUi.Transport.Diagnostics do defp validate_payload(payload, surface), do: {:error, Error.invalid_payload_mapping(payload, surface)} + defp validate_navigation_target(target) do + target = normalize_map(target) + navigation = Map.get(target, :navigation) || Map.get(target, "navigation") || %{} + + leaked_keys = forbidden_navigation_keys(normalize_map(navigation)) + + if leaked_keys == [] do + :ok + else + {:error, Error.host_route_syntax(leaked_keys)} + end + end + defp validate_boundary_context(translation) do missing = [] @@ -144,7 +160,31 @@ defmodule DesktopUi.Transport.Diagnostics do defp leaked_key?(_key), do: false + defp forbidden_navigation_keys(navigation) do + modal_stack = + navigation + |> Map.get(:modal_stack, Map.get(navigation, "modal_stack", %{})) + |> normalize_map() + + (Map.keys(navigation) ++ Map.keys(modal_stack)) + |> Enum.filter(&forbidden_navigation_key?/1) + |> Enum.uniq() + end + + defp forbidden_navigation_key?(key) when is_atom(key), + do: key in BoundaryTransport.forbidden_navigation_keys() + + defp forbidden_navigation_key?(key) when is_binary(key) do + key in Enum.map(BoundaryTransport.forbidden_navigation_keys(), &Atom.to_string/1) + end + + defp forbidden_navigation_key?(_key), do: false + defp maybe_missing(fields, field, nil), do: fields ++ [field] defp maybe_missing(fields, field, ""), do: fields ++ [field] defp maybe_missing(fields, _field, _value), do: fields + + 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/desktop_ui/lib/desktop_ui/transport/error.ex b/packages/desktop_ui/lib/desktop_ui/transport/error.ex index 5fd37ce0..16a6fef5 100644 --- a/packages/desktop_ui/lib/desktop_ui/transport/error.ex +++ b/packages/desktop_ui/lib/desktop_ui/transport/error.ex @@ -75,6 +75,16 @@ defmodule DesktopUi.Transport.Error do } end + @spec host_route_syntax([atom() | String.t()]) :: t() + def host_route_syntax(keys) do + %__MODULE__{ + reason: :host_route_syntax, + message: + "host-router and runtime-module syntax must not cross the desktop_ui navigation boundary", + details: %{keys: keys} + } + end + @spec unsupported_platform_target(term()) :: t() def unsupported_platform_target(target) do %__MODULE__{ diff --git a/packages/desktop_ui/lib/desktop_ui/transport/signal.ex b/packages/desktop_ui/lib/desktop_ui/transport/signal.ex index 3ddbceb0..15f9ea1e 100644 --- a/packages/desktop_ui/lib/desktop_ui/transport/signal.ex +++ b/packages/desktop_ui/lib/desktop_ui/transport/signal.ex @@ -6,6 +6,7 @@ defmodule DesktopUi.Transport.Signal do alias Jido.Signal alias DesktopUi.Transport.Error alias UnifiedIUR.Interaction + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport @families Interaction.families() @local_default_families [:change, :open, :close, :focus] @@ -26,6 +27,7 @@ defmodule DesktopUi.Transport.Signal do with :ok <- validate_family(family), :ok <- validate_payload(Map.get(normalized, :payload, %{}), :boundary_translation), + :ok <- validate_navigation_target(Map.get(normalized, :target, %{})), :ok <- maybe_validate_boundary_context(normalized) do translation = %{ boundary: Map.get(normalized, :boundary, :local), @@ -94,9 +96,11 @@ defmodule DesktopUi.Transport.Signal do @spec from_boundary_signal(Signal.t() | map()) :: {:ok, map()} | {:error, Error.t() | term()} def from_boundary_signal(%Signal{} = signal) do family = extension(signal, :desktop_ui_family) + target = normalize_map(extension(signal, :desktop_ui_target, %{})) with :ok <- validate_family(family), - :ok <- validate_payload(signal.data || %{}, :boundary_signal) do + :ok <- validate_payload(signal.data || %{}, :boundary_signal), + :ok <- validate_navigation_target(target) do {:ok, %{ boundary: :boundary, @@ -109,7 +113,7 @@ defmodule DesktopUi.Transport.Signal do widget_id: signal.subject, runtime_id: extension(signal, :desktop_ui_runtime_id), screen: extension(signal, :desktop_ui_screen, "unknown"), - target: normalize_map(extension(signal, :desktop_ui_target, %{})), + target: target, payload: normalize_map(signal.data || %{}), normalized_input: %{}, local_handling: extension(signal, :desktop_ui_local_handling), @@ -155,6 +159,39 @@ defmodule DesktopUi.Transport.Signal do defp validate_payload(payload, surface), do: {:error, Error.invalid_payload_mapping(payload, surface)} + defp validate_navigation_target(target) do + target = normalize_map(target) + navigation = Map.get(target, :navigation) || Map.get(target, "navigation") || %{} + + leaked_keys = forbidden_navigation_keys(normalize_map(navigation)) + + if leaked_keys == [] do + :ok + else + {:error, Error.host_route_syntax(leaked_keys)} + end + end + + defp forbidden_navigation_keys(navigation) do + modal_stack = + navigation + |> Map.get(:modal_stack, Map.get(navigation, "modal_stack", %{})) + |> normalize_map() + + (Map.keys(navigation) ++ Map.keys(modal_stack)) + |> Enum.filter(&forbidden_navigation_key?/1) + |> Enum.uniq() + end + + defp forbidden_navigation_key?(key) when is_atom(key), + do: key in BoundaryTransport.forbidden_navigation_keys() + + defp forbidden_navigation_key?(key) when is_binary(key) do + key in Enum.map(BoundaryTransport.forbidden_navigation_keys(), &Atom.to_string/1) + end + + defp forbidden_navigation_key?(_key), do: false + defp maybe_validate_boundary_context(%{boundary: :boundary} = normalized) do missing = [] 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 index 0543ba5c..a4d6977b 100644 --- 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 @@ -2,6 +2,8 @@ defmodule DesktopUi.CanonicalNavigationTransportIntegrationTest do use ExUnit.Case, async: true alias Jido.Signal + alias DesktopUi.Navigation.{Controller, State} + alias DesktopUi.Navigation.Signal, as: NavigationSignal alias DesktopUi.Transport alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport @@ -25,4 +27,97 @@ defmodule DesktopUi.CanonicalNavigationTransportIntegrationTest do assert translation.signal.extensions.desktop_ui_target == fixture.descriptor.target refute translation.signal.extensions.desktop_ui_target.navigation[:module] end + + test "validated shared modal stack fixtures drive desktop modal state without mutating history" do + first_modal = translate_fixture("modal_transition--settings_dialog") + second_modal = translate_fixture("modal_stack--open_confirm_dialog") + close_top = translate_fixture("modal_stack--close_top") + + assert {:ok, controller} = + Controller.start_link(initial_screen: {:workspace, nil, %{section: :home}}) + + try do + assert {:ok, after_first_modal, {:transition, :modal_opened}} = + first_modal |> navigation_signal() |> NavigationSignal.execute(controller) + + assert {:ok, after_second_modal, {:transition, :modal_opened}} = + second_modal |> navigation_signal() |> NavigationSignal.execute(controller) + + assert Enum.map(after_second_modal.modals, &elem(&1, 0)) == [ + :settings_confirm_dialog, + :settings_dialog + ] + + assert State.top_modal(after_second_modal) |> elem(0) == :settings_confirm_dialog + assert after_second_modal.history == after_first_modal.history + assert after_second_modal.current == :workspace + + assert {:ok, after_top_close, {:transition, :modal_closed}} = + close_top |> navigation_signal() |> NavigationSignal.execute(controller) + + assert Enum.map(after_top_close.modals, &elem(&1, 0)) == [:settings_dialog] + assert State.top_modal(after_top_close) |> elem(0) == :settings_dialog + assert after_top_close.history == after_second_modal.history + assert after_top_close.current == :workspace + after + Controller.stop(controller) + end + end + + test "desktop transport rejects host-router and runtime-module navigation leakage" do + fixture = BoundaryTransport.boundary_fixture!("screen_transition--settings_profile") + + for {key, value} <- [ + url: "https://example.invalid/settings", + router: :desktop_router, + module: DesktopUi.Navigation.Controller.MockScreen.Settings + ] do + assert {:error, + %DesktopUi.Transport.Error{reason: :host_route_syntax, details: %{keys: [^key]}}} = + Transport.from_interaction( + fixture.interaction, + platform_target: :linux, + widget_id: "settings-trigger", + runtime_id: "desktop-ui:workspace", + screen: "workspace", + target: %{ + navigation: + Map.merge(%{action: :navigate_to, screen: :settings}, %{key => value}) + } + ) + end + end + + defp translate_fixture(fixture_id) do + fixture = BoundaryTransport.boundary_fixture!(fixture_id) + assert :ok = BoundaryTransport.validate_boundary_fixture(fixture) + + assert {:ok, translation} = + Transport.from_interaction( + fixture.interaction, + platform_target: :linux, + widget_id: fixture.interaction.source.element_id, + runtime_id: "desktop-ui:workspace", + screen: "workspace", + payload: fixture.signal_data + ) + + assert :ok = Transport.validate_translation(translation) + assert :ok = Transport.validate_boundary_signal(translation.signal) + assert translation.target == fixture.descriptor.target + + translation + end + + defp navigation_signal(translation) do + navigation = translation.target.navigation + + case navigation.action do + :open_modal -> + NavigationSignal.open_modal(navigation.modal, Map.get(navigation, :params, %{})) + + :close_modal -> + NavigationSignal.close_modal(Map.get(navigation, :modal)) + end + end end diff --git a/packages/desktop_ui/test/desktop_ui/transport_test.exs b/packages/desktop_ui/test/desktop_ui/transport_test.exs index 845c1e7e..076f8011 100644 --- a/packages/desktop_ui/test/desktop_ui/transport_test.exs +++ b/packages/desktop_ui/test/desktop_ui/transport_test.exs @@ -115,6 +115,31 @@ defmodule DesktopUi.TransportTest do ) end + test "invalid canonical navigation fields fail at the desktop boundary" do + assert {:error, + %DesktopUi.Transport.Error{reason: :host_route_syntax, details: %{keys: [:stack_id]}}} = + Transport.from_native_event( + platform_target: :linux, + input_family: :pointer, + pointer_action: :click, + widget_id: "confirm-settings-button", + runtime_id: "desktop-ui:ops", + screen: "operations", + target: %{ + navigation: %{ + action: :open_modal, + modal: :settings_confirm_dialog, + modal_stack: %{ + operation: :push, + target: :symbolic_modal, + stack_effect: :push_modal, + stack_id: "desktop-runtime-stack" + } + } + } + ) + end + test "transport diagnostics validate no-leakage guarantees and boundary signals" do assert :ok == Transport.validate_native_event( diff --git a/packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs b/packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs index d7c36cfd..21b582d1 100644 --- a/packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs +++ b/packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs @@ -51,6 +51,18 @@ defmodule ElmUi.CanonicalNavigationWebRuntimeIntegrationTest do assert comparison.native.after_top_close.navigation.current_modal.modal == :settings_dialog assert comparison.canonical.after_top_close.navigation.current_modal.modal == :settings_dialog + assert comparison.native.after_second_modal.navigation.history == + comparison.native.after_modal.navigation.history + + assert comparison.native.after_top_close.navigation.history == + comparison.native.after_second_modal.navigation.history + + assert comparison.canonical.after_second_modal.navigation.history == + comparison.canonical.after_modal.navigation.history + + assert comparison.canonical.after_top_close.navigation.history == + comparison.canonical.after_second_modal.navigation.history + assert comparison.continuity.same_navigation_target? assert comparison.continuity.frontend_coordination? assert comparison.continuity.same_modal_identifier? @@ -73,6 +85,10 @@ defmodule ElmUi.CanonicalNavigationWebRuntimeIntegrationTest do assert {:ok, runtime_state} = ElmUi.Runtime.mount_iur_screen(home_element, runtime_id: "elm-ui-modal-stack") + for fixture <- [first_modal, second_modal, close_top, close_named] do + assert :ok = BoundaryTransport.validate_boundary_fixture(fixture) + end + assert {:ok, with_first_modal, _ack} = apply_fixture(runtime_state, first_modal) assert {:ok, with_second_modal, ack} = apply_fixture(with_first_modal, second_modal) @@ -272,16 +288,21 @@ defmodule ElmUi.CanonicalNavigationWebRuntimeIntegrationTest do end defp translation_for_fixture(runtime_state, fixture) do - ElmUi.Transport.from_native_event( - family: :navigation, - intent: fixture.interaction.intent, - widget_id: fixture.interaction.source.element_id, - screen: runtime_state.screen_id, - runtime_id: runtime_state.runtime_id, - source_kind: :canonical, - boundary_mode: :canonical_boundary, - target: fixture.descriptor.target, - payload: fixture.signal_data - ) + with {:ok, translation} <- + ElmUi.Transport.from_native_event( + family: :navigation, + intent: fixture.interaction.intent, + widget_id: fixture.interaction.source.element_id, + screen: runtime_state.screen_id, + runtime_id: runtime_state.runtime_id, + source_kind: :canonical, + boundary_mode: :canonical_boundary, + target: fixture.descriptor.target, + payload: fixture.signal_data + ), + {:ok, decoded} <- ElmUi.Transport.from_boundary_signal(translation.signal) do + assert decoded.target == fixture.descriptor.target + {:ok, translation} + end end end diff --git a/packages/elm_ui/test/elm_ui/transport_diagnostics_test.exs b/packages/elm_ui/test/elm_ui/transport_diagnostics_test.exs index 66254f6c..9d4ddd7a 100644 --- a/packages/elm_ui/test/elm_ui/transport_diagnostics_test.exs +++ b/packages/elm_ui/test/elm_ui/transport_diagnostics_test.exs @@ -47,6 +47,28 @@ defmodule ElmUi.TransportDiagnosticsTest do ) end + test "rejects URL-like targets, host-router names, and runtime module references" do + for {key, value} <- [ + url: "https://example.invalid/settings", + router: :workspace_router, + runtime_module: ElmUi.Runtime + ] do + assert {:error, %Error{reason: :host_route_syntax, details: %{keys: [^key]}}} = + ElmUi.Signals.from_native_event( + family: :navigation, + intent: :open_settings_screen, + boundary: :boundary, + screen: "settings", + runtime_id: "settings-runtime", + widget_id: :settings_link, + target: %{ + navigation: + Map.merge(%{action: :navigate_to, screen: :settings}, %{key => value}) + } + ) + end + end + test "rejects missing canonical context and invalid families" do assert {:error, %Error{reason: :missing_boundary_context}} = ElmUi.Signals.from_native_event( 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 index 368380e1..b60e8521 100644 --- 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 @@ -23,4 +23,39 @@ defmodule LiveUi.CanonicalNavigationTransportIntegrationTest do assert translation.signal.extensions.live_ui_target == fixture.descriptor.target refute translation.signal.extensions.live_ui_target.navigation[:route] end + + test "validates shared modal stack fixtures before live_ui boundary transport consumes them" do + fixtures = + [ + "modal_transition--settings_dialog", + "modal_stack--open_confirm_dialog", + "modal_stack--close_top", + "modal_stack--close_named_settings" + ] + |> Enum.map(&BoundaryTransport.boundary_fixture!/1) + + for fixture <- fixtures do + assert :ok = BoundaryTransport.validate_boundary_fixture(fixture) + + assert {:ok, translation} = + LiveUi.Transport.translate_canonical( + fixture.interaction, + screen: :workspace, + mode: :screen, + boundary: :boundary, + payload: fixture.signal_data + ) + + assert :ok = LiveUi.Transport.Diagnostics.validate_boundary_signal(translation.signal) + assert {:ok, runtime_action} = LiveUi.Transport.decode_boundary_signal(translation.signal) + + assert translation.target == fixture.descriptor.target + assert runtime_action.target == fixture.descriptor.target + assert translation.signal.data == fixture.signal_data + assert translation.signal.extensions.live_ui_target == fixture.descriptor.target + + assert translation.signal.extensions.live_ui_target.navigation.modal_stack == + fixture.descriptor.target.navigation.modal_stack + end + end end diff --git a/packages/live_ui/test/live_ui/canonical_navigation_web_runtime_integration_test.exs b/packages/live_ui/test/live_ui/canonical_navigation_web_runtime_integration_test.exs index 78ae91b8..60fe8b71 100644 --- a/packages/live_ui/test/live_ui/canonical_navigation_web_runtime_integration_test.exs +++ b/packages/live_ui/test/live_ui/canonical_navigation_web_runtime_integration_test.exs @@ -52,6 +52,16 @@ defmodule LiveUi.CanonicalNavigationWebRuntimeIntegrationTest do assert comparison.native.after_top_close.current_modal.modal == :settings_dialog assert comparison.canonical.after_top_close.current_modal.modal == :settings_dialog + assert comparison.native.after_second_modal.history == comparison.native.after_modal.history + + assert comparison.native.after_top_close.history == + comparison.native.after_second_modal.history + + assert comparison.canonical.after_second_modal.history == + comparison.canonical.after_modal.history + + assert comparison.canonical.after_top_close.history == + comparison.canonical.after_second_modal.history assert comparison.continuity.same_navigation_target? assert comparison.continuity.same_modal_identifier? diff --git a/packages/live_ui/test/live_ui/transport_diagnostics_test.exs b/packages/live_ui/test/live_ui/transport_diagnostics_test.exs index b3246800..8e409634 100644 --- a/packages/live_ui/test/live_ui/transport_diagnostics_test.exs +++ b/packages/live_ui/test/live_ui/transport_diagnostics_test.exs @@ -44,6 +44,27 @@ defmodule LiveUi.TransportDiagnosticsTest do ) end + test "rejects URL-like targets, host-router names, and runtime module references" do + for {key, value} <- [ + url: "https://example.invalid/settings", + router: :workspace_router, + runtime_module: LiveUi.Runtime + ] do + assert {:error, %Error{reason: :host_route_syntax, details: %{keys: [^key]}}} = + LiveUi.Signals.from_native( + family: :navigation, + intent: :open_settings_screen, + screen: :profile, + element_id: :settings_link, + boundary: :boundary, + target: %{ + navigation: + Map.merge(%{action: :navigate_to, screen: :settings}, %{key => value}) + } + ) + end + end + test "rejects missing canonical context and invalid families" do interaction = Interaction.submit(intent: :save_profile, element_id: :profile_form) diff --git a/packages/terminal_ui/lib/terminal_ui/transport/diagnostics.ex b/packages/terminal_ui/lib/terminal_ui/transport/diagnostics.ex index 1d63aa2e..35a911e4 100644 --- a/packages/terminal_ui/lib/terminal_ui/transport/diagnostics.ex +++ b/packages/terminal_ui/lib/terminal_ui/transport/diagnostics.ex @@ -6,6 +6,7 @@ defmodule TerminalUi.Transport.Diagnostics do alias Jido.Signal alias TerminalUi.Transport.{Error, Normalize} alias TerminalUi.Transport.Signal, as: TransportSignal + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport @payload_leak_keys [:escape_sequence, :termcode, :ansi, :backend_payload, :terminal_bytes] @payload_leak_prefixes ["ansi_", "term_", "escape_", "csi_"] @@ -27,7 +28,8 @@ defmodule TerminalUi.Transport.Diagnostics do @spec validate_native_event(keyword() | map()) :: :ok | {:error, Error.t()} def validate_native_event(attrs) when is_map(attrs) or is_list(attrs) do with {:ok, normalized} <- Normalize.normalize(attrs), - :ok <- validate_payload(Map.get(normalized, :payload, %{}), :native_event) do + :ok <- validate_payload(Map.get(normalized, :payload, %{}), :native_event), + :ok <- validate_navigation_target(Map.get(normalized, :target, %{})) do if Map.get(normalized, :boundary) == :boundary do validate_boundary_context(normalized) else @@ -42,6 +44,7 @@ defmodule TerminalUi.Transport.Diagnostics do def validate_translation(%{} = translation) do with :ok <- validate_payload(Map.get(translation, :payload, %{}), :translation_payload), :ok <- validate_payload(Map.get(translation, :target, %{}), :translation_target), + :ok <- validate_navigation_target(Map.get(translation, :target, %{})), :ok <- maybe_validate_boundary_signal(translation) do if Map.get(translation, :boundary) == :boundary do validate_boundary_context(translation) @@ -102,6 +105,19 @@ defmodule TerminalUi.Transport.Diagnostics do defp validate_payload(payload, surface), do: {:error, Error.invalid_payload_mapping(payload, surface)} + defp validate_navigation_target(target) do + target = normalize_map(target) + navigation = Map.get(target, :navigation) || Map.get(target, "navigation") || %{} + + leaked_keys = forbidden_navigation_keys(normalize_map(navigation)) + + if leaked_keys == [] do + :ok + else + {:error, Error.host_route_syntax(leaked_keys)} + end + end + defp validate_boundary_context(translation) do missing = [] @@ -136,7 +152,31 @@ defmodule TerminalUi.Transport.Diagnostics do defp leaked_key?(_key), do: false + defp forbidden_navigation_keys(navigation) do + modal_stack = + navigation + |> Map.get(:modal_stack, Map.get(navigation, "modal_stack", %{})) + |> normalize_map() + + (Map.keys(navigation) ++ Map.keys(modal_stack)) + |> Enum.filter(&forbidden_navigation_key?/1) + |> Enum.uniq() + end + + defp forbidden_navigation_key?(key) when is_atom(key), + do: key in BoundaryTransport.forbidden_navigation_keys() + + defp forbidden_navigation_key?(key) when is_binary(key) do + key in Enum.map(BoundaryTransport.forbidden_navigation_keys(), &Atom.to_string/1) + end + + defp forbidden_navigation_key?(_key), do: false + defp maybe_missing(fields, field, nil), do: fields ++ [field] defp maybe_missing(fields, field, ""), do: fields ++ [field] defp maybe_missing(fields, _field, _value), do: fields + + 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/terminal_ui/lib/terminal_ui/transport/error.ex b/packages/terminal_ui/lib/terminal_ui/transport/error.ex index e76727ed..51f47f51 100644 --- a/packages/terminal_ui/lib/terminal_ui/transport/error.ex +++ b/packages/terminal_ui/lib/terminal_ui/transport/error.ex @@ -76,6 +76,16 @@ defmodule TerminalUi.Transport.Error do } end + @spec host_route_syntax([atom() | String.t()]) :: t() + def host_route_syntax(keys) do + %__MODULE__{ + reason: :host_route_syntax, + message: + "host-router and runtime-stack syntax must not cross the terminal_ui navigation boundary", + details: %{keys: keys} + } + end + @spec unsupported_backend_mode(term()) :: t() def unsupported_backend_mode(mode) do %__MODULE__{ diff --git a/packages/terminal_ui/lib/terminal_ui/transport/signal.ex b/packages/terminal_ui/lib/terminal_ui/transport/signal.ex index d6a715c2..03d13bc3 100644 --- a/packages/terminal_ui/lib/terminal_ui/transport/signal.ex +++ b/packages/terminal_ui/lib/terminal_ui/transport/signal.ex @@ -6,6 +6,7 @@ defmodule TerminalUi.Transport.Signal do alias Jido.Signal alias TerminalUi.Transport.Error alias UnifiedIUR.Interaction + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport @families Interaction.families() @local_default_families [:change, :open, :close, :focus] @@ -26,6 +27,7 @@ defmodule TerminalUi.Transport.Signal do with :ok <- validate_family(family), :ok <- validate_payload(Map.get(normalized, :payload, %{}), :boundary_translation), + :ok <- validate_navigation_target(Map.get(normalized, :target, %{})), :ok <- maybe_validate_boundary_context(normalized) do translation = %{ @@ -94,9 +96,11 @@ defmodule TerminalUi.Transport.Signal do @spec from_boundary_signal(Signal.t() | map()) :: {:ok, map()} | {:error, Error.t() | term()} def from_boundary_signal(%Signal{} = signal) do family = extension(signal, :terminal_ui_family) + target = normalize_map(extension(signal, :terminal_ui_target, %{})) with :ok <- validate_family(family), - :ok <- validate_payload(signal.data || %{}, :boundary_signal) do + :ok <- validate_payload(signal.data || %{}, :boundary_signal), + :ok <- validate_navigation_target(target) do {:ok, %{ boundary: :boundary, @@ -109,7 +113,7 @@ defmodule TerminalUi.Transport.Signal do widget_id: signal.subject, runtime_id: extension(signal, :terminal_ui_runtime_id), screen: extension(signal, :terminal_ui_screen, "unknown"), - target: normalize_map(extension(signal, :terminal_ui_target, %{})), + target: target, payload: normalize_map(signal.data || %{}), normalized_input: %{}, local_handling: extension(signal, :terminal_ui_local_handling), @@ -155,6 +159,39 @@ defmodule TerminalUi.Transport.Signal do defp validate_payload(payload, surface), do: {:error, Error.invalid_payload_mapping(payload, surface)} + defp validate_navigation_target(target) do + target = normalize_map(target) + navigation = Map.get(target, :navigation) || Map.get(target, "navigation") || %{} + + leaked_keys = forbidden_navigation_keys(normalize_map(navigation)) + + if leaked_keys == [] do + :ok + else + {:error, Error.host_route_syntax(leaked_keys)} + end + end + + defp forbidden_navigation_keys(navigation) do + modal_stack = + navigation + |> Map.get(:modal_stack, Map.get(navigation, "modal_stack", %{})) + |> normalize_map() + + (Map.keys(navigation) ++ Map.keys(modal_stack)) + |> Enum.filter(&forbidden_navigation_key?/1) + |> Enum.uniq() + end + + defp forbidden_navigation_key?(key) when is_atom(key), + do: key in BoundaryTransport.forbidden_navigation_keys() + + defp forbidden_navigation_key?(key) when is_binary(key) do + key in Enum.map(BoundaryTransport.forbidden_navigation_keys(), &Atom.to_string/1) + end + + defp forbidden_navigation_key?(_key), do: false + defp maybe_validate_boundary_context(%{boundary: :boundary} = normalized) do missing = [] 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 index 1f621f70..0540d85e 100644 --- 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 @@ -35,6 +35,10 @@ defmodule TerminalUi.CanonicalNavigationTransportIntegrationTest do close_top = BoundaryTransport.boundary_fixture!("modal_stack--close_top") close_named = BoundaryTransport.boundary_fixture!("modal_stack--close_named_settings") + for fixture <- [first_modal, second_modal, close_top, close_named] do + assert :ok = BoundaryTransport.validate_boundary_fixture(fixture) + end + assert {:ok, state} = Runtime.mount_native_screen(screen(), backend_mode: :tty) assert {:ok, with_first_modal, _route_result} = @@ -60,9 +64,12 @@ defmodule TerminalUi.CanonicalNavigationTransportIntegrationTest do assert with_second_modal.navigation.current_modal.modal == :settings_confirm_dialog assert with_second_modal.navigation.last_transition.modal_stack.stack_effect == :push_modal + refute Map.has_key?(with_second_modal.navigation.last_transition, :route) + refute Map.has_key?(with_second_modal.navigation.last_transition.modal_stack, :stack_id) snapshot = TerminalUi.Inspection.runtime_snapshot(with_second_modal) assert snapshot.navigation.current_modal.modal == :settings_confirm_dialog + assert snapshot.navigation.modals == with_second_modal.navigation.modals assert {:ok, after_named_close, _route_result} = Runtime.handle_boundary_signal(with_second_modal, boundary_signal(close_named)) @@ -78,6 +85,42 @@ defmodule TerminalUi.CanonicalNavigationTransportIntegrationTest do assert after_top_close.navigation.current_modal == nil end + test "terminal transport rejects host routing and runtime stack leakage in navigation targets" do + fixture = BoundaryTransport.boundary_fixture!("modal_stack--open_confirm_dialog") + + for {key, value} <- [ + url: "ssh://workspace/settings", + router: :terminal_router, + runtime_stack_id: "terminal-runtime-stack" + ] do + assert {:error, + %TerminalUi.Transport.Error{reason: :host_route_syntax, details: %{keys: [^key]}}} = + Transport.from_interaction( + fixture.interaction, + backend_mode: :tty, + input_family: :key, + widget_id: "confirm-settings-button", + runtime_id: "terminal-ui:workspace", + screen: "workspace", + target: %{ + navigation: + Map.merge( + %{ + action: :open_modal, + modal: :settings_confirm_dialog, + modal_stack: %{ + operation: :push, + target: :symbolic_modal, + stack_effect: :push_modal + } + }, + %{key => value} + ) + } + ) + end + end + test "reports missing modal diagnostics without losing canonical transition meaning" do close_named = BoundaryTransport.boundary_fixture!("modal_stack--close_named_settings") @@ -105,6 +148,7 @@ defmodule TerminalUi.CanonicalNavigationTransportIntegrationTest do payload: fixture.signal_data ) + assert :ok = Transport.validate_boundary_signal(translation.signal) translation.signal end diff --git a/packages/terminal_ui/test/terminal_ui/transport_diagnostics_test.exs b/packages/terminal_ui/test/terminal_ui/transport_diagnostics_test.exs index f31051fb..ab6fa568 100644 --- a/packages/terminal_ui/test/terminal_ui/transport_diagnostics_test.exs +++ b/packages/terminal_ui/test/terminal_ui/transport_diagnostics_test.exs @@ -71,6 +71,31 @@ defmodule TerminalUi.TransportDiagnosticsTest do }) end + test "transport validation rejects canonical navigation host details" do + assert {:error, + %TerminalUi.Transport.Error{ + reason: :host_route_syntax, + details: %{keys: [:runtime_module]} + }} = + Transport.from_native_event( + backend_mode: :raw, + input_family: :key, + key: "enter", + family: :navigation, + intent: :open_settings_screen, + widget_id: "settings-link", + runtime_id: "terminal-ui:ops", + screen: "operations", + target: %{ + navigation: %{ + action: :navigate_to, + screen: :settings, + runtime_module: TerminalUi.Runtime + } + } + ) + end + test "reference and summary surfaces expose transport-focused contract summaries" do reference = TerminalUi.reference() summary = TerminalUi.info() 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 index 96377466..398d7800 100644 --- 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 @@ -73,6 +73,41 @@ defmodule UnifiedIUR.CanonicalNavigationBoundaryIntegrationTest do } end + test "shared modal stack fixtures validate before runtime package consumption" do + fixtures = + [ + "modal_transition--settings_dialog", + "modal_stack--open_confirm_dialog", + "modal_stack--close_top", + "modal_stack--close_named_settings" + ] + |> Enum.map(&Transport.boundary_fixture!/1) + + assert Enum.all?(fixtures, &(Transport.validate_boundary_fixture(&1) == :ok)) + + assert Enum.map(fixtures, & &1.summary.modal_stack_effect) == [ + :push_modal, + :push_modal, + :close_topmost_or_named_modal, + :close_topmost_or_named_modal + ] + + assert Enum.map(fixtures, & &1.summary.modal_stack_close) == [ + nil, + nil, + :topmost, + :targeted + ] + + for fixture <- fixtures do + assert fixture.extensions.unified_iur_boundary == fixture.descriptor + assert fixture.extensions.unified_iur_boundary_summary == fixture.summary + refute Map.has_key?(fixture.descriptor.target.navigation, :route) + refute Map.has_key?(fixture.descriptor.target.navigation, :module) + refute Map.has_key?(fixture.descriptor.target.navigation.modal_stack, :stack_id) + end + end + test "shared boundary validation rejects malformed payloads and leaked route syntax" do assert {:error, {:invalid_boundary_payload, "oops"}} = Transport.validate_boundary_payload("oops") @@ -99,4 +134,27 @@ defmodule UnifiedIUR.CanonicalNavigationBoundaryIntegrationTest do } }) end + + test "shared boundary validation rejects URL-like targets, router names, and runtime modules" do + for {key, value} <- [ + url: "https://example.invalid/settings", + router: :workspace_router, + runtime_module: LiveUi.Runtime + ] do + assert {:error, {:forbidden_navigation_keys, [^key]}} = + Transport.validate_boundary_extensions(%{ + unified_iur_boundary: %{ + family: :navigation, + intent: :open_settings_screen, + source_context: %{element_id: "settings-link"}, + target: %{ + navigation: + Map.merge(%{action: :navigate_to, screen: :settings}, %{key => value}) + }, + metadata: %{} + }, + unified_iur_boundary_summary: %{} + }) + end + end end diff --git a/packages/unified_iur/test/unified_iur/interactions_transport_test.exs b/packages/unified_iur/test/unified_iur/interactions_transport_test.exs index 3a47c929..5aa702fa 100644 --- a/packages/unified_iur/test/unified_iur/interactions_transport_test.exs +++ b/packages/unified_iur/test/unified_iur/interactions_transport_test.exs @@ -149,6 +149,29 @@ defmodule UnifiedIUR.InteractionsTransportTest do }) end + test "rejects URL-like targets, host-router names, and runtime module references" do + for {key, value} <- [ + url: "/settings?tab=profile", + router: :workspace_router, + runtime_module: DesktopUi.Navigation.Controller.MockScreen.Settings + ] do + assert {:error, {:forbidden_navigation_keys, [^key]}} = + Transport.validate_boundary_extensions(%{ + unified_iur_boundary: %{ + family: :navigation, + intent: :open_settings_screen, + source_context: %{element_id: "settings-link"}, + target: %{ + navigation: + Map.merge(%{action: :navigate_to, screen: :settings}, %{key => value}) + }, + metadata: %{} + }, + unified_iur_boundary_summary: %{} + }) + end + end + test "rejects runtime-local modal stack identifiers crossing the boundary" do assert {:error, {:forbidden_navigation_keys, [:stack_id]}} = Transport.validate_boundary_extensions(%{