From 0bcc9d4b9f42f9eef0bc771101c0c6178314a1c3 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Sat, 25 Apr 2026 15:56:40 -0400 Subject: [PATCH 1/4] Implement canonical navigation phase 3 section 3.1 --- .spec/specs/live_ui/runtime.spec.md | 13 + packages/live_ui/lib/live_ui/runtime.ex | 11 +- packages/live_ui/lib/live_ui/runtime/error.ex | 28 + .../live_ui/lib/live_ui/runtime/navigation.ex | 702 ++++++++++++++++++ .../lib/live_ui/runtime/screen_component.ex | 7 +- packages/live_ui/lib/live_ui/runtime/state.ex | 56 +- packages/live_ui/lib/live_ui/tooling.ex | 2 +- .../lib/live_ui/transport/diagnostics.ex | 38 +- .../live_ui/lib/live_ui/transport/error.ex | 9 + .../test/live_ui/runtime_navigation_test.exs | 171 +++++ .../live_ui/test/live_ui/runtime_test.exs | 4 +- .../live_ui/transport_diagnostics_test.exs | 14 + 12 files changed, 1036 insertions(+), 19 deletions(-) create mode 100644 packages/live_ui/lib/live_ui/runtime/navigation.ex create mode 100644 packages/live_ui/test/live_ui/runtime_navigation_test.exs diff --git a/.spec/specs/live_ui/runtime.spec.md b/.spec/specs/live_ui/runtime.spec.md index a4342117..1545ec89 100644 --- a/.spec/specs/live_ui/runtime.spec.md +++ b/.spec/specs/live_ui/runtime.spec.md @@ -21,6 +21,7 @@ surface: - .spec/specs/live_ui/runtime.spec.md decisions: - repo.ecosystem.contract_model + - repo.ecosystem.canonical_navigation_boundary - live_ui.runtime.widget_livecomponents ``` @@ -56,6 +57,16 @@ decisions: statement: Runtime state, assigns, and rendered widget structure shall preserve canonical UI meaning across updates whether the source is direct native usage or canonical IUR interpretation. priority: must stability: stable + +- id: live_ui.runtime.canonical_navigation_transition_mapping + statement: When canonical navigation interactions are emitted or consumed, the runtime shall map canonical screen-transition actions onto LiveView-appropriate screen or page transitions while preserving server-authoritative UI meaning. + priority: must + stability: stable + +- id: live_ui.runtime.host_route_resolution_boundary + statement: Phoenix router lookup, URL generation, and host-specific route matching may be used by applications, but they shall remain host or runtime concerns rather than part of the authored `UnifiedUi` contract. + priority: must + stability: stable ``` ## Scenarios @@ -89,6 +100,8 @@ decisions: - live_ui.runtime.hooks_only_where_necessary - live_ui.runtime.native_and_iur_entrypoints_share_runtime - live_ui.runtime.state_and_render_continuity + - live_ui.runtime.canonical_navigation_transition_mapping + - live_ui.runtime.host_route_resolution_boundary - live_ui.runtime.handle_canonical_event_server_side - live_ui.runtime_handle_direct_native_event - live_ui.runtime.screen_composes_widget_components diff --git a/packages/live_ui/lib/live_ui/runtime.ex b/packages/live_ui/lib/live_ui/runtime.ex index 89a98da7..80d6a84f 100644 --- a/packages/live_ui/lib/live_ui/runtime.ex +++ b/packages/live_ui/lib/live_ui/runtime.ex @@ -3,7 +3,7 @@ defmodule LiveUi.Runtime do Package-facing entrypoint for the server-authoritative LiveView runtime. """ - alias LiveUi.Runtime.{BrowserBridge, CanonicalScreen, ScreenComponent, State} + alias LiveUi.Runtime.{BrowserBridge, CanonicalScreen, Navigation, ScreenComponent, State} alias Jido.Signal alias UnifiedIUR.Element @@ -58,12 +58,12 @@ defmodule LiveUi.Runtime do |> Map.new() |> Map.put_new(:event, event) |> Map.put_new(:runtime_event, event) - |> Map.put_new(:screen, runtime_state.screen.id()) + |> Map.put_new(:screen, State.screen_id(runtime_state)) |> Map.put_new(:mode, runtime_state.mode) |> Map.put_new(:payload, payload) with {:ok, translation} <- LiveUi.Transport.translate_native(attrs), - {:ok, updated_state} <- State.handle_event(runtime_state, event, payload) do + {:ok, updated_state} <- State.handle_runtime_action(runtime_state, translation) do {:ok, updated_state, translation} end end @@ -72,8 +72,7 @@ defmodule LiveUi.Runtime do {:ok, State.t(), map()} | {:error, term()} def handle_boundary_signal(%State{} = runtime_state, signal) do with {:ok, runtime_action} <- LiveUi.Transport.decode_boundary_signal(signal), - {:ok, updated_state} <- - State.handle_event(runtime_state, runtime_action.runtime_event, runtime_action.payload) do + {:ok, updated_state} <- State.handle_runtime_action(runtime_state, runtime_action) do {:ok, updated_state, runtime_action} end end @@ -106,7 +105,7 @@ defmodule LiveUi.Runtime do @spec modules() :: [module()] def modules do - [State, ScreenComponent, BrowserBridge, CanonicalScreen, LiveUi.Transport.Channel] + [State, Navigation, ScreenComponent, BrowserBridge, CanonicalScreen, LiveUi.Transport.Channel] end @spec assumptions() :: map() diff --git a/packages/live_ui/lib/live_ui/runtime/error.ex b/packages/live_ui/lib/live_ui/runtime/error.ex index 1e520a17..20607d87 100644 --- a/packages/live_ui/lib/live_ui/runtime/error.ex +++ b/packages/live_ui/lib/live_ui/runtime/error.ex @@ -48,6 +48,34 @@ defmodule LiveUi.Runtime.Error do } end + @spec unresolved_navigation_target(atom() | String.t() | nil, term(), term()) :: t() + def unresolved_navigation_target(action, screen_id, reason) do + %__MODULE__{ + reason: :unresolved_navigation_target, + message: "canonical navigation transitions require a resolvable symbolic screen target", + details: %{action: action, screen_id: screen_id, reason: inspect(reason)} + } + end + + @spec unsupported_navigation_context(atom() | String.t() | nil, map()) :: t() + def unsupported_navigation_context(action, details \\ %{}) do + %__MODULE__{ + reason: :unsupported_navigation_context, + message: + "canonical navigation transition cannot be applied in the current live_ui runtime context", + details: Map.put(Map.new(details), :action, action) + } + end + + @spec host_route_navigation_syntax([atom() | String.t()]) :: t() + def host_route_navigation_syntax(keys) do + %__MODULE__{ + reason: :host_route_navigation_syntax, + message: "canonical navigation targets must not contain host-router syntax", + details: %{keys: keys} + } + end + @spec widget_event_failed(term()) :: t() def widget_event_failed(reason) do %__MODULE__{ diff --git a/packages/live_ui/lib/live_ui/runtime/navigation.ex b/packages/live_ui/lib/live_ui/runtime/navigation.ex new file mode 100644 index 00000000..3c7bf91b --- /dev/null +++ b/packages/live_ui/lib/live_ui/runtime/navigation.ex @@ -0,0 +1,702 @@ +defmodule LiveUi.Runtime.Navigation do + @moduledoc """ + Server-authoritative canonical navigation transition handling for `live_ui`. + """ + + alias LiveUi.Runtime.{CanonicalScreen, Error, State} + alias UnifiedIUR.Element + alias UnifiedIUR.Interaction + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport + + @managed_assign_keys [ + :current_screen_id, + :current_screen_title, + :navigation_action, + :navigation_params, + :navigation_history, + :navigation_forward, + :navigation_modal_stack, + :current_modal, + :navigation_host_route + ] + + @type screen_ref :: module() | Element.t() + @type route_resolver :: (map(), State.t() -> {:ok, term()} | {:error, term()} | term()) + @type host_route_resolver :: (map(), State.t() -> + map() | nil | {:ok, map() | nil} | {:error, term()}) + + @type entry :: %{ + screen_id: atom() | String.t(), + title: String.t(), + mode: State.mode(), + screen_ref: screen_ref(), + params: map(), + metadata: map(), + host_route: map() | nil + } + + @type t :: %{ + registry: map(), + resolver: route_resolver() | nil, + host_route_resolver: host_route_resolver() | nil, + active: entry(), + history: [entry()], + forward: [entry()], + modals: [map()], + last_transition: map() | nil, + last_host_route: map() | nil + } + + @spec initialize(module(), map(), State.mode(), keyword()) :: t() + def initialize(screen, assigns, mode, opts) when is_atom(screen) and is_map(assigns) do + active = current_entry(screen, assigns, mode, opts) + + %{ + registry: Keyword.get(opts, :screen_registry, %{}), + resolver: Keyword.get(opts, :navigation_resolver), + host_route_resolver: Keyword.get(opts, :host_route_resolver), + active: active, + history: [], + forward: [], + modals: [], + last_transition: nil, + last_host_route: nil + } + end + + @spec transition?(map()) :: boolean() + def transition?(%{family: :navigation, target: target}) when is_map(target) do + not is_nil(raw_navigation_descriptor(target)) + end + + def transition?(_other), do: false + + @spec apply_transition(State.t(), map()) :: {:ok, State.t()} | {:error, Error.t()} + def apply_transition(%State{} = state, runtime_action) when is_map(runtime_action) do + with {:ok, descriptor} <- navigation_descriptor(runtime_action), + :ok <- validate_runtime_target(runtime_action), + {:ok, next_state} <- apply_action(state, descriptor, runtime_action) do + {:ok, State.sync_navigation_assigns(next_state)} + end + end + + @spec screen_id(t() | map()) :: atom() | String.t() | nil + def screen_id(%{active: %{screen_id: screen_id}}), do: screen_id + def screen_id(_other), do: nil + + @spec screen_title(t() | map()) :: String.t() | nil + def screen_title(%{active: %{title: title}}), do: title + def screen_title(_other), do: nil + + @spec assign_overlays(t() | map()) :: map() + def assign_overlays(%{ + active: active, + history: history, + forward: forward, + modals: modals, + last_transition: last_transition, + last_host_route: last_host_route + }) do + %{ + current_screen_id: active.screen_id, + current_screen_title: active.title, + navigation_action: last_transition && last_transition.action, + navigation_params: normalize_map(active.params), + navigation_history: Enum.map(history, &entry_summary/1), + navigation_forward: Enum.map(forward, &entry_summary/1), + navigation_modal_stack: Enum.map(modals, &modal_summary/1), + current_modal: modals |> List.last() |> modal_summary(), + navigation_host_route: last_host_route + } + end + + def assign_overlays(_other), do: %{} + + @spec strip_managed_assigns(map()) :: map() + def strip_managed_assigns(assigns) when is_map(assigns) do + Map.drop(assigns, @managed_assign_keys) + end + + defp apply_action(%State{} = state, descriptor, runtime_action) do + action = Map.get(descriptor, :action) + + case action do + action when action in [:navigate_to, "navigate_to"] -> + transition_to_screen(state, descriptor, runtime_action, :push_history) + + action when action in [:replace_with, "replace_with"] -> + transition_to_screen(state, descriptor, runtime_action, :replace_current) + + action when action in [:go_back, "go_back"] -> + move_history(state, descriptor, :back) + + action when action in [:go_forward, "go_forward"] -> + move_history(state, descriptor, :forward) + + action when action in [:open_modal, "open_modal"] -> + open_modal(state, descriptor) + + action when action in [:close_modal, "close_modal"] -> + close_modal(state, descriptor) + + _other -> + {:error, + Error.unsupported_navigation_context(action, %{ + reason: :unsupported_navigation_action + })} + end + end + + defp transition_to_screen(%State{} = state, descriptor, runtime_action, strategy) do + with {:ok, resolution} <- resolve_screen(state, descriptor), + {:ok, host_route} <- resolve_host_route(state, descriptor, resolution) do + current = state.navigation.active + active = entry_from_resolution(resolution, host_route) + + navigation = + state.navigation + |> put_active(active) + |> put_last_transition(runtime_action, descriptor) + |> put_last_host_route(host_route) + |> reset_modal_stack() + |> update_history(current, strategy) + + {:ok, apply_resolution(state, resolution, navigation)} + end + end + + defp move_history(%State{} = state, descriptor, direction) do + navigation = state.navigation + + case direction do + :back -> + case back_target(navigation) do + {:ok, target, history, forward} -> + apply_history_transition(state, descriptor, target, history, [ + navigation.active | forward + ]) + + :empty -> + {:error, + Error.unsupported_navigation_context(descriptor.action, %{reason: :empty_history})} + end + + :forward -> + case forward_target(navigation) do + {:ok, target, history, forward} -> + apply_history_transition( + state, + descriptor, + target, + history ++ [navigation.active], + forward + ) + + :empty -> + {:error, + Error.unsupported_navigation_context(descriptor.action, %{reason: :empty_history})} + end + end + end + + defp apply_history_transition(state, descriptor, target, history, forward) do + with {:ok, resolution} <- resolution_from_entry(target), + {:ok, host_route} <- resolve_host_route(state, descriptor, resolution, target.host_route) do + updated_navigation = + state.navigation + |> put_active(%{target | host_route: host_route}) + |> put_last_transition(%{family: :navigation, intent: descriptor.action}, descriptor) + |> put_last_host_route(host_route) + |> Map.put(:history, history) + |> Map.put(:forward, forward) + + {:ok, apply_resolution(state, resolution, updated_navigation)} + end + end + + defp open_modal(%State{} = state, descriptor) do + modal_id = Map.get(descriptor, :modal) + + if modal_id in [nil, ""] do + {:error, Error.unsupported_navigation_context(descriptor.action, %{reason: :missing_modal})} + else + modal_entry = %{ + modal: modal_id, + params: normalize_map(Map.get(descriptor, :params, %{})), + metadata: normalize_map(Map.get(descriptor, :metadata, %{})) + } + + navigation = + state.navigation + |> Map.update!(:modals, &(&1 ++ [modal_entry])) + |> put_last_transition(%{family: :navigation, intent: descriptor.action}, descriptor) + + {:ok, %{state | navigation: navigation}} + end + end + + defp close_modal(%State{} = state, descriptor) do + modal_id = Map.get(descriptor, :modal) + + case pop_modal(state.navigation.modals, modal_id) do + {:ok, modals} -> + navigation = + state.navigation + |> Map.put(:modals, modals) + |> put_last_transition(%{family: :navigation, intent: descriptor.action}, descriptor) + + {:ok, %{state | navigation: navigation}} + + :error -> + {:error, + Error.unsupported_navigation_context(descriptor.action, %{ + reason: :missing_modal, + modal: modal_id + })} + end + end + + defp resolve_screen(%State{} = state, descriptor) do + screen_id = Map.get(descriptor, :screen) + + with {:ok, candidate} <- resolve_candidate(state.navigation, screen_id, descriptor, state), + {:ok, resolution} <- normalize_resolution(candidate, screen_id, descriptor) do + {:ok, resolution} + else + {:error, reason} -> + {:error, + Error.unresolved_navigation_target(Map.get(descriptor, :action), screen_id, reason)} + end + end + + defp resolve_candidate(%{resolver: resolver} = navigation, screen_id, descriptor, state) + when is_function(resolver, 2) do + case resolver.(descriptor, state) do + {:ok, candidate} -> {:ok, candidate} + {:error, _reason} = error -> error + nil -> lookup_registry(navigation.registry, screen_id) + candidate -> {:ok, candidate} + end + end + + defp resolve_candidate(%{registry: registry}, screen_id, _descriptor, _state) do + lookup_registry(registry, screen_id) + end + + defp resolve_host_route(%State{} = state, descriptor, resolution, fallback \\ nil) do + case state.navigation.host_route_resolver do + resolver when is_function(resolver, 2) -> + case resolver.(host_route_descriptor(descriptor, resolution), state) do + {:ok, nil} -> + {:ok, fallback} + + {:ok, route} when is_map(route) -> + {:ok, Map.new(route)} + + nil -> + {:ok, fallback} + + route when is_map(route) -> + {:ok, Map.new(route)} + + {:error, reason} -> + {:error, + Error.unsupported_navigation_context(Map.get(descriptor, :action), %{ + reason: :invalid_host_route_resolution, + details: inspect(reason) + })} + + other -> + {:error, + Error.unsupported_navigation_context(Map.get(descriptor, :action), %{ + reason: :invalid_host_route_resolution, + details: inspect(other) + })} + end + + _other -> + {:ok, fallback} + end + end + + defp resolution_from_entry( + %{mode: :native, screen_ref: screen_ref, screen_id: screen_id} = entry + ) + when is_atom(screen_ref) do + normalize_resolution( + %{screen: screen_ref, screen_id: screen_id, title: entry.title}, + screen_id, + %{ + params: entry.params, + metadata: entry.metadata + } + ) + end + + defp resolution_from_entry( + %{mode: :canonical, screen_ref: %Element{} = screen_ref, screen_id: screen_id} = entry + ) do + normalize_resolution( + %{element: screen_ref, screen_id: screen_id, title: entry.title}, + screen_id, + %{ + params: entry.params, + metadata: entry.metadata + } + ) + end + + defp resolution_from_entry(entry) do + {:error, {:invalid_navigation_entry, entry.screen_id}} + end + + defp normalize_resolution(candidate, fallback_screen_id, descriptor) + when is_atom(candidate) do + if valid_screen_module?(candidate) do + {:ok, + %{ + mode: :native, + screen: candidate, + screen_ref: candidate, + screen_id: fallback_screen_id || candidate.id(), + title: candidate.title(), + defaults: candidate.mount_defaults(), + assigns: %{}, + params: normalize_map(Map.get(descriptor, :params, %{})), + metadata: normalize_map(Map.get(descriptor, :metadata, %{})) + }} + else + {:error, {:invalid_screen_module, candidate}} + end + end + + defp normalize_resolution(%Element{} = element, fallback_screen_id, descriptor) do + {:ok, + %{ + mode: :canonical, + screen: CanonicalScreen, + screen_ref: element, + screen_id: fallback_screen_id || element.id || "canonical-screen", + title: fallback_screen_title(element, fallback_screen_id), + defaults: %{}, + assigns: %{iur: element}, + params: normalize_map(Map.get(descriptor, :params, %{})), + metadata: normalize_map(Map.get(descriptor, :metadata, %{})) + }} + end + + defp normalize_resolution(candidate, fallback_screen_id, descriptor) when is_map(candidate) do + assigns = normalize_map(Map.get(candidate, :assigns, %{})) + + screen_id = + Map.get(candidate, :screen_id) || Map.get(candidate, "screen_id") || fallback_screen_id + + title = Map.get(candidate, :title) || Map.get(candidate, "title") + metadata = normalize_map(Map.get(candidate, :metadata, %{})) + params = normalize_map(Map.get(descriptor, :params, %{})) + descriptor_metadata = normalize_map(Map.get(descriptor, :metadata, %{})) + + cond do + valid_screen_module?(Map.get(candidate, :screen) || Map.get(candidate, "screen")) -> + screen = Map.get(candidate, :screen) || Map.get(candidate, "screen") + + {:ok, + %{ + mode: :native, + screen: screen, + screen_ref: screen, + screen_id: screen_id || screen.id(), + title: title || screen.title(), + defaults: screen.mount_defaults(), + assigns: assigns, + params: params, + metadata: Map.merge(metadata, descriptor_metadata) + }} + + match?(%Element{}, Map.get(candidate, :element) || Map.get(candidate, "element")) -> + element = Map.get(candidate, :element) || Map.get(candidate, "element") + + {:ok, + %{ + mode: :canonical, + screen: CanonicalScreen, + screen_ref: element, + screen_id: screen_id || element.id || "canonical-screen", + title: title || fallback_screen_title(element, screen_id), + defaults: %{}, + assigns: Map.put(assigns, :iur, element), + params: params, + metadata: Map.merge(metadata, descriptor_metadata) + }} + + match?(%Element{}, Map.get(candidate, :iur) || Map.get(candidate, "iur")) -> + element = Map.get(candidate, :iur) || Map.get(candidate, "iur") + + {:ok, + %{ + mode: :canonical, + screen: CanonicalScreen, + screen_ref: element, + screen_id: screen_id || element.id || "canonical-screen", + title: title || fallback_screen_title(element, screen_id), + defaults: %{}, + assigns: Map.put(assigns, :iur, element), + params: params, + metadata: Map.merge(metadata, descriptor_metadata) + }} + + true -> + {:error, {:unsupported_navigation_target, inspect(candidate)}} + end + end + + defp normalize_resolution(other, _fallback_screen_id, _descriptor) do + {:error, {:unsupported_navigation_target, inspect(other)}} + end + + defp apply_resolution(%State{} = state, resolution, navigation) do + preserved_assigns = + state.assigns + |> strip_managed_assigns() + |> Map.drop([:iur]) + + next_assigns = + resolution.defaults + |> normalize_map() + |> Map.merge(preserved_assigns) + |> Map.merge(normalize_map(resolution.assigns)) + + %{ + state + | screen: resolution.screen, + assigns: next_assigns, + mode: resolution.mode, + event_routes: resolution.screen.event_routes(), + bridge_hooks: + LiveUi.Runtime.BrowserBridge.normalize_hooks(resolution.screen.bridge_hooks()), + navigation: navigation + } + end + + defp current_entry(screen, assigns, mode, opts) do + screen_id = + Keyword.get(opts, :screen_id) || current_screen_id(screen, assigns, mode) + + title = + Keyword.get(opts, :title) || current_screen_title(screen, assigns, mode) + + %{ + screen_id: screen_id, + title: title, + mode: mode, + screen_ref: current_screen_ref(screen, assigns, mode), + params: normalize_map(Keyword.get(opts, :navigation_params, %{})), + metadata: normalize_map(Keyword.get(opts, :navigation_metadata, %{})), + host_route: nil + } + end + + defp current_screen_id(_screen, %{iur: %Element{id: id}}, :canonical) when not is_nil(id), + do: id + + defp current_screen_id(screen, _assigns, _mode), do: screen.id() + + defp current_screen_title(_screen, %{iur: %Element{id: id}}, :canonical), + do: fallback_screen_title(%Element{id: id}, id) + + defp current_screen_title(screen, _assigns, _mode), do: screen.title() + + defp current_screen_ref(_screen, %{iur: %Element{} = element}, :canonical), do: element + defp current_screen_ref(screen, _assigns, _mode), do: screen + + defp back_target(%{history: []}), do: :empty + + defp back_target(%{history: history, forward: forward}) do + {remainder, [target]} = Enum.split(history, -1) + {:ok, target, remainder, forward} + end + + defp forward_target(%{forward: []}), do: :empty + + defp forward_target(%{history: history, forward: [target | remainder]}) do + {:ok, target, history, remainder} + end + + defp put_active(navigation, active), do: %{navigation | active: active} + + defp put_last_transition(navigation, runtime_action, descriptor) do + %{navigation | last_transition: transition_summary(runtime_action, descriptor)} + end + + defp put_last_host_route(navigation, host_route), + do: %{navigation | last_host_route: host_route} + + defp reset_modal_stack(navigation), do: %{navigation | modals: []} + + defp update_history(navigation, current, :push_history) do + %{navigation | history: navigation.history ++ [current], forward: []} + end + + defp update_history(navigation, _current, :replace_current) do + %{navigation | forward: []} + end + + defp entry_from_resolution(resolution, host_route) do + %{ + screen_id: resolution.screen_id, + title: resolution.title, + mode: resolution.mode, + screen_ref: resolution.screen_ref, + params: resolution.params, + metadata: resolution.metadata, + host_route: host_route + } + end + + defp transition_summary(runtime_action, descriptor) do + %{ + family: Map.get(runtime_action, :family, :navigation), + intent: Map.get(runtime_action, :intent), + action: Map.get(descriptor, :action), + screen: Map.get(descriptor, :screen), + modal: Map.get(descriptor, :modal), + params: normalize_map(Map.get(descriptor, :params, %{})) + } + end + + defp host_route_descriptor(descriptor, resolution) do + %{ + action: Map.get(descriptor, :action), + screen: resolution.screen_id, + modal: Map.get(descriptor, :modal), + params: resolution.params, + metadata: resolution.metadata, + mode: resolution.mode + } + end + + defp navigation_descriptor(runtime_action) do + case Interaction.navigation_descriptor(Map.get(runtime_action, :target, %{})) do + nil -> + {:error, + Error.unsupported_navigation_context(Map.get(runtime_action, :intent), %{ + reason: :missing_navigation_descriptor + })} + + descriptor -> + {:ok, normalize_map(descriptor)} + end + end + + defp validate_runtime_target(runtime_action) 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() + + if leaked_keys == [] do + :ok + else + {:error, Error.host_route_navigation_syntax(leaked_keys)} + end + end + + defp raw_navigation_descriptor(target) when is_map(target) do + Map.get(target, :navigation) || Map.get(target, "navigation") + end + + defp raw_navigation_descriptor(_other), do: nil + + 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} + {_key, value} -> {:ok, value} + end + end + + defp key_matches?(key, value), do: key == value or to_string(key) == to_string(value) + + defp valid_screen_module?(screen) when is_atom(screen) do + Code.ensure_loaded?(screen) and function_exported?(screen, :id, 0) and + function_exported?(screen, :title, 0) and function_exported?(screen, :mount_defaults, 0) and + function_exported?(screen, :render, 1) and function_exported?(screen, :event_routes, 0) and + function_exported?(screen, :bridge_hooks, 0) and + function_exported?(screen, :handle_event, 3) + end + + defp valid_screen_module?(_screen), do: false + + defp fallback_screen_title(%Element{id: id}, fallback_screen_id) do + (id || fallback_screen_id || "Canonical Screen") + |> to_string() + |> String.replace("_", " ") + |> String.replace("-", " ") + |> String.split() + |> Enum.map_join(" ", &String.capitalize/1) + end + + defp pop_modal([], _modal_id), do: :error + + defp pop_modal(modals, nil), do: {:ok, Enum.drop(modals, -1)} + + defp pop_modal(modals, modal_id) do + if Enum.any?(modals, &(Map.get(&1, :modal) == modal_id)) do + {:ok, remove_last_modal(modals, modal_id)} + else + :error + end + end + + defp remove_last_modal(modals, modal_id) do + {reversed_kept, dropped?} = + modals + |> Enum.reverse() + |> Enum.reduce({[], false}, fn modal, {acc, dropped?} -> + if not dropped? and Map.get(modal, :modal) == modal_id do + {acc, true} + else + {[modal | acc], dropped?} + end + end) + + if dropped?, do: reversed_kept, else: modals + end + + defp entry_summary(nil), do: nil + + defp entry_summary(entry) do + %{ + screen_id: entry.screen_id, + title: entry.title, + mode: entry.mode, + params: normalize_map(entry.params) + } + end + + defp modal_summary(nil), do: nil + + defp modal_summary(modal) do + %{ + modal: Map.get(modal, :modal), + params: normalize_map(Map.get(modal, :params, %{})), + metadata: normalize_map(Map.get(modal, :metadata, %{})) + } + 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 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(_other), do: %{} +end diff --git a/packages/live_ui/lib/live_ui/runtime/screen_component.ex b/packages/live_ui/lib/live_ui/runtime/screen_component.ex index 630dc87c..daf6054e 100644 --- a/packages/live_ui/lib/live_ui/runtime/screen_component.ex +++ b/packages/live_ui/lib/live_ui/runtime/screen_component.ex @@ -144,14 +144,15 @@ defmodule LiveUi.Runtime.ScreenComponent do {:ok, translation} <- LiveUi.Signals.from_interaction( interaction, - screen: runtime_state.screen.id(), + screen: State.screen_id(runtime_state), mode: runtime_state.mode, boundary: :boundary, element_id: Map.get(params, "element_id"), widget: Map.get(params, "widget"), payload: payload - ) do - updated_runtime_state = maybe_apply_interaction_hook(runtime_state, translation) + ), + {:ok, updated_runtime_state} <- State.handle_runtime_action(runtime_state, translation) do + updated_runtime_state = maybe_apply_interaction_hook(updated_runtime_state, translation) {:noreply, socket diff --git a/packages/live_ui/lib/live_ui/runtime/state.ex b/packages/live_ui/lib/live_ui/runtime/state.ex index 3ed37f24..6d1b3047 100644 --- a/packages/live_ui/lib/live_ui/runtime/state.ex +++ b/packages/live_ui/lib/live_ui/runtime/state.ex @@ -6,11 +6,19 @@ defmodule LiveUi.Runtime.State do remains subordinate to server-authoritative screen or application state. """ - alias LiveUi.Runtime.{BrowserBridge, Error} + alias LiveUi.Runtime.{BrowserBridge, Error, Navigation} alias LiveUi.Widget.Identity @enforce_keys [:screen, :assigns, :mode, :event_routes, :bridge_hooks] - defstruct [:screen, :assigns, :mode, :event_routes, :bridge_hooks, widget_local_state: %{}] + defstruct [ + :screen, + :assigns, + :mode, + :event_routes, + :bridge_hooks, + navigation: %{}, + widget_local_state: %{} + ] @type mode :: :native | :canonical @@ -22,6 +30,7 @@ defmodule LiveUi.Runtime.State do mode: mode(), event_routes: %{optional(String.t()) => atom()}, bridge_hooks: [atom()], + navigation: Navigation.t(), widget_local_state: widget_local_state() } @@ -37,8 +46,16 @@ defmodule LiveUi.Runtime.State do assigns: Map.merge(defaults, initial_assigns), mode: Keyword.get(opts, :mode, :native), event_routes: screen.event_routes(), - bridge_hooks: BrowserBridge.normalize_hooks(screen.bridge_hooks()) - }} + bridge_hooks: BrowserBridge.normalize_hooks(screen.bridge_hooks()), + navigation: + Navigation.initialize( + screen, + Map.merge(defaults, initial_assigns), + Keyword.get(opts, :mode, :native), + opts + ) + } + |> sync_navigation_assigns()} end end @@ -54,10 +71,39 @@ defmodule LiveUi.Runtime.State do end end + @spec handle_runtime_action(t(), map()) :: {:ok, t()} | {:error, Error.t()} + def handle_runtime_action(%__MODULE__{} = state, runtime_action) when is_map(runtime_action) do + if Navigation.transition?(runtime_action) do + Navigation.apply_transition(state, runtime_action) + else + handle_event(state, runtime_action.runtime_event, runtime_action.payload) + end + end + + @spec screen_id(t()) :: atom() | String.t() | nil + def screen_id(%__MODULE__{navigation: navigation, screen: screen}) do + Navigation.screen_id(navigation) || screen.id() + end + + @spec screen_title(t()) :: String.t() | nil + def screen_title(%__MODULE__{navigation: navigation, screen: screen}) do + Navigation.screen_title(navigation) || screen.title() + end + + @spec sync_navigation_assigns(t()) :: t() + def sync_navigation_assigns(%__MODULE__{} = state) do + navigation_assigns = Navigation.assign_overlays(state.navigation) + + %{ + state + | assigns: Map.merge(Navigation.strip_managed_assigns(state.assigns), navigation_assigns) + } + end + defp apply_event(%__MODULE__{} = state, route, payload) do case state.screen.handle_event(route, payload, state.assigns) do {:ok, updated_assigns} when is_map(updated_assigns) -> - {:ok, %{state | assigns: updated_assigns}} + {:ok, %{state | assigns: updated_assigns} |> sync_navigation_assigns()} {:error, reason} -> {:error, Error.invalid_event_result(state.screen, route, {:error, reason})} diff --git a/packages/live_ui/lib/live_ui/tooling.ex b/packages/live_ui/lib/live_ui/tooling.ex index ac863f7f..6a7c5b7c 100644 --- a/packages/live_ui/lib/live_ui/tooling.ex +++ b/packages/live_ui/lib/live_ui/tooling.ex @@ -276,7 +276,7 @@ defmodule LiveUi.Tooling do %{ path: path, mode: runtime_state.mode, - screen: runtime_state.screen.id(), + screen: State.screen_id(runtime_state), event_routes: Map.keys(runtime_state.event_routes) |> Enum.sort(), bridge_hooks: Enum.sort(runtime_state.bridge_hooks), widgets: Enum.map(entries, & &1.widget) |> Enum.uniq(), diff --git a/packages/live_ui/lib/live_ui/transport/diagnostics.ex b/packages/live_ui/lib/live_ui/transport/diagnostics.ex index 2e23c1e6..f4d0ac66 100644 --- a/packages/live_ui/lib/live_ui/transport/diagnostics.ex +++ b/packages/live_ui/lib/live_ui/transport/diagnostics.ex @@ -7,6 +7,7 @@ defmodule LiveUi.Transport.Diagnostics do alias Jido.Signal alias LiveUi.Transport.Error alias UnifiedIUR.Interaction + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport @families LiveUi.Signals.families() @renderer_local_event_prefixes ["phx_", "phx-", "lv:", "hook:"] @@ -18,7 +19,8 @@ defmodule LiveUi.Transport.Diagnostics do :ok <- validate_boundary_context(attrs, [:screen]), :ok <- validate_element_context(attrs), :ok <- validate_runtime_event(Map.get(attrs, :runtime_event) || Map.get(attrs, :event)), - :ok <- validate_payload(Map.get(attrs, :payload, %{})) do + :ok <- validate_payload(Map.get(attrs, :payload, %{})), + :ok <- validate_navigation_target(Map.get(attrs, :target, %{})) do :ok end end @@ -29,7 +31,9 @@ defmodule LiveUi.Transport.Diagnostics do :ok <- validate_boundary_context(attrs, [:screen]), :ok <- validate_runtime_event(Map.get(attrs, :runtime_event)), :ok <- validate_payload(interaction.payload), - :ok <- validate_payload(Map.get(attrs, :payload, %{})) do + :ok <- validate_payload(Map.get(attrs, :payload, %{})), + :ok <- validate_navigation_target(interaction.target), + :ok <- validate_navigation_target(Map.get(attrs, :target, %{})) do :ok end end @@ -45,6 +49,8 @@ defmodule LiveUi.Transport.Diagnostics do signal.extensions["live_ui_source_context"] || signal.extensions[:live_ui_source_context] || %{} + target = signal.extensions["live_ui_target"] || signal.extensions[:live_ui_target] || %{} + cond do is_nil(family) or is_nil(runtime_event) -> {:error, Error.invalid_boundary_signal(signal)} @@ -53,7 +59,8 @@ defmodule LiveUi.Transport.Diagnostics do with :ok <- validate_family(family), :ok <- validate_runtime_event(runtime_event), :ok <- validate_signal_context(signal, normalize_map(source_context)), - :ok <- validate_payload(signal.data || %{}) do + :ok <- validate_payload(signal.data || %{}), + :ok <- validate_navigation_target(target) do :ok end end @@ -126,12 +133,37 @@ defmodule LiveUi.Transport.Diagnostics do defp validate_payload(_payload), do: :ok + defp validate_navigation_target(target) 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) + + if leaked_keys == [] do + :ok + else + {:error, Error.host_route_syntax(leaked_keys)} + end + end + defp renderer_local_key?(key) when is_atom(key), do: renderer_local_key?(Atom.to_string(key)) defp renderer_local_key?(key) when is_binary(key) do Enum.any?(@renderer_local_payload_prefixes, &String.starts_with?(key, &1)) 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 normalize_map(map) when is_map(map), do: Map.new(map) defp normalize_map(_other), do: %{} end diff --git a/packages/live_ui/lib/live_ui/transport/error.ex b/packages/live_ui/lib/live_ui/transport/error.ex index 09540fcf..937fa17f 100644 --- a/packages/live_ui/lib/live_ui/transport/error.ex +++ b/packages/live_ui/lib/live_ui/transport/error.ex @@ -57,6 +57,15 @@ defmodule LiveUi.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-route syntax must not cross the canonical live_ui navigation contract", + details: %{keys: keys} + } + end + @spec invalid_channel_envelope(term()) :: t() def invalid_channel_envelope(value) do %__MODULE__{ diff --git a/packages/live_ui/test/live_ui/runtime_navigation_test.exs b/packages/live_ui/test/live_ui/runtime_navigation_test.exs new file mode 100644 index 00000000..60764e24 --- /dev/null +++ b/packages/live_ui/test/live_ui/runtime_navigation_test.exs @@ -0,0 +1,171 @@ +defmodule LiveUi.RuntimeNavigationTest do + use ExUnit.Case, async: true + + alias UnifiedIUR.Element + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport + + defmodule HomeScreen do + use LiveUi.Screen, id: :home, title: "Home" + + @impl true + def mount_defaults do + %{shared: "preserved", welcome: "Home"} + end + + @impl true + def render(assigns) do + ~H""" +
<%= @welcome %>
+ """ + end + end + + defmodule SettingsScreen do + use LiveUi.Screen, id: :settings, title: "Settings" + + @impl true + def mount_defaults do + %{welcome: "Settings"} + end + + @impl true + def render(assigns) do + ~H""" +
<%= @welcome %>
+ """ + end + end + + test "native runtime dispatch resolves canonical screen transitions on the server" do + assert {:ok, runtime_state} = + LiveUi.Runtime.mount(HomeScreen, + screen_registry: %{settings: SettingsScreen}, + host_route_resolver: fn descriptor, _state -> + if descriptor.screen == :settings, do: %{path: "/settings"}, else: nil + end + ) + + assert {:ok, updated_state, translation} = + LiveUi.Runtime.dispatch_native_event( + runtime_state, + "navigation:open_settings_screen", + %{}, + family: :navigation, + intent: :open_settings_screen, + target: %{ + navigation: %{ + action: :navigate_to, + screen: :settings, + params: %{tab: :profile} + } + } + ) + + assert translation.family == :navigation + assert updated_state.screen == SettingsScreen + assert updated_state.mode == :native + assert updated_state.assigns.shared == "preserved" + assert updated_state.assigns.current_screen_id == :settings + assert updated_state.assigns.navigation_params == %{tab: :profile} + assert updated_state.assigns.navigation_host_route == %{path: "/settings"} + assert Enum.map(updated_state.navigation.history, & &1.screen_id) == [:home] + end + + test "canonical boundary fixtures share the same transition resolver, history, and modal state" do + home_element = + Element.new(:widget, :text, id: :workspace_home, attributes: %{content: "Workspace Home"}) + + screen_transition = BoundaryTransport.boundary_fixture!("screen_transition--settings_profile") + history_transition = BoundaryTransport.boundary_fixture!("history_transition--back") + modal_transition = BoundaryTransport.boundary_fixture!("modal_transition--settings_dialog") + + assert {:ok, runtime_state} = + LiveUi.Runtime.mount_iur(home_element, + screen_registry: %{settings: SettingsScreen}, + screen_id: :workspace_home + ) + + assert {:ok, screen_translation} = + LiveUi.Signals.from_interaction( + screen_transition.interaction, + screen: :workspace_home, + mode: :screen, + boundary: :boundary, + payload: screen_transition.signal_data + ) + + assert {:ok, after_navigation, _runtime_action} = + LiveUi.Runtime.handle_boundary_signal(runtime_state, screen_translation.signal) + + assert after_navigation.screen == SettingsScreen + assert after_navigation.assigns.current_screen_id == :settings + + assert after_navigation.assigns.navigation_history == [ + %{screen_id: :workspace_home, title: "Workspace Home", mode: :canonical, params: %{}} + ] + + assert {:ok, modal_translation} = + LiveUi.Signals.from_interaction( + modal_transition.interaction, + screen: :settings, + mode: :screen, + boundary: :boundary, + payload: modal_transition.signal_data + ) + + assert {:ok, with_modal, _runtime_action} = + LiveUi.Runtime.handle_boundary_signal(after_navigation, modal_translation.signal) + + assert with_modal.assigns.current_modal == %{ + modal: modal_transition.summary.modal, + params: modal_transition.descriptor.target.navigation.params, + metadata: modal_transition.descriptor.target.navigation.metadata + } + + assert {:ok, history_translation} = + LiveUi.Signals.from_interaction( + history_transition.interaction, + screen: :settings, + mode: :screen, + boundary: :boundary, + payload: history_transition.signal_data + ) + + assert {:ok, back_home, _runtime_action} = + LiveUi.Runtime.handle_boundary_signal(with_modal, history_translation.signal) + + assert back_home.mode == :canonical + assert back_home.screen == LiveUi.Runtime.CanonicalScreen + assert back_home.assigns.current_screen_id == :workspace_home + + assert back_home.assigns.navigation_forward == [ + %{screen_id: :settings, title: "Settings", mode: :native, params: %{tab: :profile}} + ] + end + + test "navigation failures surface deterministic diagnostics" do + assert {:ok, runtime_state} = LiveUi.Runtime.mount(HomeScreen) + + assert {:error, %LiveUi.Runtime.Error{reason: :unresolved_navigation_target}} = + LiveUi.Runtime.dispatch_native_event( + runtime_state, + "navigation:open_missing", + %{}, + family: :navigation, + intent: :open_missing_screen, + target: %{navigation: %{action: :navigate_to, screen: :missing}} + ) + + assert {:error, %LiveUi.Runtime.Error{reason: :host_route_navigation_syntax}} = + LiveUi.Runtime.dispatch_native_event( + runtime_state, + "navigation:open_route", + %{}, + family: :navigation, + intent: :open_settings_screen, + target: %{ + navigation: %{action: :navigate_to, screen: :settings, route: "/settings"} + } + ) + end +end diff --git a/packages/live_ui/test/live_ui/runtime_test.exs b/packages/live_ui/test/live_ui/runtime_test.exs index 4f8b6b7f..93f84427 100644 --- a/packages/live_ui/test/live_ui/runtime_test.exs +++ b/packages/live_ui/test/live_ui/runtime_test.exs @@ -43,7 +43,9 @@ defmodule LiveUi.RuntimeTest do test "runtime mounts a screen with server-authoritative defaults" do assert {:ok, runtime_state} = LiveUi.Runtime.mount(CounterScreen, assigns: %{count: 2}) - assert runtime_state.assigns == %{count: 2} + assert runtime_state.assigns.count == 2 + assert runtime_state.assigns.current_screen_id == :counter_screen + assert runtime_state.assigns.navigation_history == [] assert runtime_state.event_routes == %{"increment" => :increment} assert runtime_state.bridge_hooks == [:resize_observer] end 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 e4d924a5..b3246800 100644 --- a/packages/live_ui/test/live_ui/transport_diagnostics_test.exs +++ b/packages/live_ui/test/live_ui/transport_diagnostics_test.exs @@ -30,6 +30,20 @@ defmodule LiveUi.TransportDiagnosticsTest do ) end + test "rejects leaked host-route syntax on canonical navigation targets" do + assert {:error, %Error{reason: :host_route_syntax}} = + LiveUi.Signals.from_native( + family: :navigation, + intent: :open_settings_screen, + screen: :profile, + element_id: :settings_link, + boundary: :boundary, + target: %{ + navigation: %{action: :navigate_to, screen: :settings, route: "/settings"} + } + ) + end + test "rejects missing canonical context and invalid families" do interaction = Interaction.submit(intent: :save_profile, element_id: :profile_form) From 22e256b36de98e0402fce2bbdec067350e610d73 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Sat, 25 Apr 2026 16:08:09 -0400 Subject: [PATCH 2/4] Implement canonical navigation phase 3 section 3.2 --- .spec/specs/elm_ui/server_runtime.spec.md | 13 + .../lib/elm_ui/frontend_runtime/boot.ex | 3 +- .../lib/elm_ui/frontend_runtime/bridge.ex | 65 +- .../lib/elm_ui/frontend_runtime/model.ex | 2 + packages/elm_ui/lib/elm_ui/runtime.ex | 1 + packages/elm_ui/lib/elm_ui/server_runtime.ex | 39 +- .../lib/elm_ui/server_runtime/event_router.ex | 7 +- .../lib/elm_ui/server_runtime/navigation.ex | 728 ++++++++++++++++++ .../elm_ui/lib/elm_ui/server_runtime/state.ex | 13 + .../elm_ui/server_runtime/sync_boundary.ex | 19 +- .../lib/elm_ui/server_runtime/view_state.ex | 9 +- .../elm_ui/lib/elm_ui/transport/bridge.ex | 1 + .../lib/elm_ui/transport/diagnostics.ex | 29 + packages/elm_ui/lib/elm_ui/transport/error.ex | 9 + .../elm_ui/lib/elm_ui/transport/signals.ex | 6 + .../test/elm_ui/runtime_navigation_test.exs | 122 +++ packages/elm_ui/test/elm_ui/runtime_test.exs | 4 + .../elm_ui/transport_diagnostics_test.exs | 15 + 18 files changed, 1070 insertions(+), 15 deletions(-) create mode 100644 packages/elm_ui/lib/elm_ui/server_runtime/navigation.ex create mode 100644 packages/elm_ui/test/elm_ui/runtime_navigation_test.exs diff --git a/.spec/specs/elm_ui/server_runtime.spec.md b/.spec/specs/elm_ui/server_runtime.spec.md index fbf03659..2bc574ac 100644 --- a/.spec/specs/elm_ui/server_runtime.spec.md +++ b/.spec/specs/elm_ui/server_runtime.spec.md @@ -21,6 +21,7 @@ surface: - .spec/specs/elm_ui/server_runtime.spec.md decisions: - repo.ecosystem.contract_model + - repo.ecosystem.canonical_navigation_boundary - repo.ecosystem.elm_ui_naming ``` @@ -51,6 +52,16 @@ decisions: statement: Browser-local state may exist on the frontend, but authoritative server-side UI meaning and package-boundary event translation shall remain anchored in the Phoenix runtime. priority: must stability: stable + +- id: elm_ui.server_runtime.canonical_navigation_transition_mapping + statement: When canonical navigation interactions are emitted or consumed, the server runtime shall map canonical screen-transition actions onto Phoenix-and-Elm-appropriate screen or page transitions while preserving authoritative server-side UI meaning. + priority: must + stability: stable + +- id: elm_ui.server_runtime.host_route_resolution_boundary + statement: Host router lookup, URL generation, and frontend route-matching details may be used by applications, but they shall remain host or runtime concerns rather than part of the authored `UnifiedUi` contract. + priority: must + stability: stable ``` ## Scenarios @@ -73,5 +84,7 @@ decisions: - elm_ui.server_runtime.handle_boundary_events - elm_ui.server_runtime.direct_and_iur_entrypoints_share_runtime - elm_ui.server_runtime.browser_state_is_bounded + - elm_ui.server_runtime.canonical_navigation_transition_mapping + - elm_ui.server_runtime.host_route_resolution_boundary - elm_ui.server_runtime_handle_canonical_event ``` diff --git a/packages/elm_ui/lib/elm_ui/frontend_runtime/boot.ex b/packages/elm_ui/lib/elm_ui/frontend_runtime/boot.ex index 86ab0642..d4a92c74 100644 --- a/packages/elm_ui/lib/elm_ui/frontend_runtime/boot.ex +++ b/packages/elm_ui/lib/elm_ui/frontend_runtime/boot.ex @@ -5,7 +5,7 @@ defmodule ElmUi.FrontendRuntime.Boot do alias ElmUi.FrontendRuntime.{Error, Message, Model, Realization} - @required_fields ~w[runtime_id title source_kind boundary_mode tree local_state diagnostics metadata]a + @required_fields ~w[runtime_id screen_id title source_kind boundary_mode tree local_state diagnostics metadata]a @spec hydrate(map()) :: {:ok, Model.t()} | {:error, Error.t()} def hydrate(payload) when is_map(payload) do @@ -22,6 +22,7 @@ defmodule ElmUi.FrontendRuntime.Boot do {:ok, %Model{ runtime_id: fetch(payload, :runtime_id), + screen_id: fetch(payload, :screen_id), title: fetch(payload, :title), source_kind: fetch(payload, :source_kind), boundary_mode: fetch(payload, :boundary_mode), diff --git a/packages/elm_ui/lib/elm_ui/frontend_runtime/bridge.ex b/packages/elm_ui/lib/elm_ui/frontend_runtime/bridge.ex index e0cf0b00..5ce91a53 100644 --- a/packages/elm_ui/lib/elm_ui/frontend_runtime/bridge.ex +++ b/packages/elm_ui/lib/elm_ui/frontend_runtime/bridge.ex @@ -54,10 +54,11 @@ defmodule ElmUi.FrontendRuntime.Bridge do attrs = attrs |> Enum.into(%{}) - |> Map.put_new(:screen, model.title) + |> Map.put_new(:screen, model.screen_id || model.title) |> Map.put_new(:runtime_id, model.runtime_id) |> Map.put_new(:source_kind, model.source_kind) |> Map.put_new(:boundary_mode, model.boundary_mode) + |> maybe_put_metadata(model) Transport.from_native_event(attrs) end @@ -139,11 +140,61 @@ defmodule ElmUi.FrontendRuntime.Bridge do _ -> model.diagnostics end - %{ - model - | local_state: local_state, - tree: Realization.realize(model.render_tree, local_state), - diagnostics: diagnostics - } + model + |> apply_authoritative_screen(payload) + |> Map.put(:local_state, local_state) + |> Map.put(:tree, Realization.realize(model.render_tree, local_state)) + |> Map.put(:diagnostics, diagnostics) end + + defp apply_authoritative_screen(%Model{} = model, payload) do + case Map.get(payload, :authoritative_screen) || Map.get(payload, "authoritative_screen") do + authoritative_screen when is_map(authoritative_screen) -> + authoritative_screen = Map.new(authoritative_screen) + + render_tree = + Map.get(authoritative_screen, :tree) || Map.get(authoritative_screen, "tree") + + %{ + model + | screen_id: + Map.get(authoritative_screen, :screen_id) || + Map.get(authoritative_screen, "screen_id") || model.screen_id, + title: + Map.get(authoritative_screen, :title) || Map.get(authoritative_screen, "title") || + model.title, + source_kind: + Map.get(authoritative_screen, :source_kind) || + Map.get(authoritative_screen, "source_kind") || model.source_kind, + boundary_mode: + Map.get(authoritative_screen, :boundary_mode) || + Map.get(authoritative_screen, "boundary_mode") || model.boundary_mode, + render_tree: render_tree || model.render_tree, + metadata: + Map.get(authoritative_screen, :metadata) || + Map.get(authoritative_screen, "metadata") || model.metadata + } + + _other -> + model + end + end + + defp maybe_put_metadata(attrs, model) do + metadata = + attrs + |> Map.get(:metadata, Map.get(attrs, "metadata", %{})) + |> normalize_map() + |> maybe_put(:route_state, Map.get(attrs, :route_state) || Map.get(attrs, "route_state")) + |> maybe_put(:screen_id, model.screen_id) + + Map.put(attrs, :metadata, metadata) + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + 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(_other), do: %{} end diff --git a/packages/elm_ui/lib/elm_ui/frontend_runtime/model.ex b/packages/elm_ui/lib/elm_ui/frontend_runtime/model.ex index f126fb32..23ae4fd1 100644 --- a/packages/elm_ui/lib/elm_ui/frontend_runtime/model.ex +++ b/packages/elm_ui/lib/elm_ui/frontend_runtime/model.ex @@ -6,6 +6,7 @@ defmodule ElmUi.FrontendRuntime.Model do @type tree :: map() @type t :: %__MODULE__{ runtime_id: String.t(), + screen_id: String.t() | atom() | nil, title: String.t(), source_kind: :native | :canonical, boundary_mode: :native_local | :canonical_boundary, @@ -17,6 +18,7 @@ defmodule ElmUi.FrontendRuntime.Model do } defstruct runtime_id: "", + screen_id: nil, title: "", source_kind: :native, boundary_mode: :native_local, diff --git a/packages/elm_ui/lib/elm_ui/runtime.ex b/packages/elm_ui/lib/elm_ui/runtime.ex index 46a7ab16..36ac1b81 100644 --- a/packages/elm_ui/lib/elm_ui/runtime.ex +++ b/packages/elm_ui/lib/elm_ui/runtime.ex @@ -13,6 +13,7 @@ defmodule ElmUi.Runtime do [ ElmUi.ServerRuntime, ElmUi.ServerRuntime.State, + ElmUi.ServerRuntime.Navigation, ElmUi.ServerRuntime.ViewState, ElmUi.ServerRuntime.StyleResolver, ElmUi.FrontendRuntime, diff --git a/packages/elm_ui/lib/elm_ui/server_runtime.ex b/packages/elm_ui/lib/elm_ui/server_runtime.ex index 4154713a..ea5009ed 100644 --- a/packages/elm_ui/lib/elm_ui/server_runtime.ex +++ b/packages/elm_ui/lib/elm_ui/server_runtime.ex @@ -8,6 +8,7 @@ defmodule ElmUi.ServerRuntime do alias ElmUi.ServerRuntime.{ Error, EventRouter, + Navigation, RenderModel, State, StyleResolver, @@ -19,7 +20,17 @@ defmodule ElmUi.ServerRuntime do @spec modules() :: [module()] def modules do - [__MODULE__, State, ViewState, RenderModel, StyleResolver, EventRouter, SyncBoundary, Error] + [ + __MODULE__, + State, + Navigation, + ViewState, + RenderModel, + StyleResolver, + EventRouter, + SyncBoundary, + Error + ] end @spec mount_native_screen(map(), keyword()) :: {:ok, State.t()} | {:error, Error.t()} @@ -140,6 +151,7 @@ defmodule ElmUi.ServerRuntime do boundary_mode: if(source_kind == :canonical, do: :canonical_boundary, else: :native_local), diagnostics: [], event_log: [], + navigation: Navigation.initialize(source_kind, screen, canonical_element, opts), metadata: metadata } end @@ -158,4 +170,29 @@ defmodule ElmUi.ServerRuntime do |> State.record_event(%{mode: :boundary, family: family}) |> State.record_boundary_signal(translation.signal)} end + + defp apply_route(%State{} = state, %{ + route: :navigation_transition, + family: family, + translation: translation + }) do + with {:ok, next_state} <- Navigation.apply_transition(state, translation) do + next_state = + next_state + |> State.record_event(%{ + mode: if(translation.boundary == :boundary, do: :boundary, else: :local), + family: family + }) + |> maybe_record_boundary_signal(translation) + + {:ok, next_state} + end + end + + defp maybe_record_boundary_signal(%State{} = state, %{signal: signal}) + when not is_nil(signal) do + State.record_boundary_signal(state, signal) + end + + defp maybe_record_boundary_signal(%State{} = state, _translation), do: state end diff --git a/packages/elm_ui/lib/elm_ui/server_runtime/event_router.ex b/packages/elm_ui/lib/elm_ui/server_runtime/event_router.ex index 112ff405..727fe66f 100644 --- a/packages/elm_ui/lib/elm_ui/server_runtime/event_router.ex +++ b/packages/elm_ui/lib/elm_ui/server_runtime/event_router.ex @@ -4,8 +4,9 @@ defmodule ElmUi.ServerRuntime.EventRouter do """ alias ElmUi.ServerRuntime.{Error, State} + alias UnifiedIUR.Interaction - @type route :: :local_runtime | :canonical_boundary + @type route :: :local_runtime | :canonical_boundary | :navigation_transition @spec route(State.t(), map()) :: {:ok, map()} | {:error, Error.t()} def route(%State{} = state, translation) when is_map(translation) do @@ -48,6 +49,10 @@ defmodule ElmUi.ServerRuntime.EventRouter do end end + defp route_for(%{family: :navigation, target: target}) when is_map(target) do + if Interaction.navigation_descriptor(target), do: :navigation_transition, else: :local_runtime + end + defp route_for(%{boundary: :boundary}), do: :canonical_boundary defp route_for(_translation), do: :local_runtime end diff --git a/packages/elm_ui/lib/elm_ui/server_runtime/navigation.ex b/packages/elm_ui/lib/elm_ui/server_runtime/navigation.ex new file mode 100644 index 00000000..8e566fa9 --- /dev/null +++ b/packages/elm_ui/lib/elm_ui/server_runtime/navigation.ex @@ -0,0 +1,728 @@ +defmodule ElmUi.ServerRuntime.Navigation do + @moduledoc """ + Authoritative screen-transition resolver for the Phoenix side of `elm_ui`. + """ + + alias UnifiedIUR.{Element, Interaction} + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport + alias ElmUi.ServerRuntime.{Error, State} + + @type screen_ref :: map() | Element.t() + @type resolver :: (map(), State.t() -> {:ok, term()} | {:error, term()} | term()) + @type host_route_resolver :: (map(), State.t() -> + map() | nil | {:ok, map() | nil} | {:error, term()}) + + @type entry :: %{ + screen_id: String.t() | atom(), + title: String.t(), + source_kind: :native | :canonical, + boundary_mode: State.boundary_mode(), + screen_ref: screen_ref(), + params: map(), + metadata: map(), + host_route: map() | nil + } + + @type t :: %{ + registry: map(), + resolver: resolver() | nil, + host_route_resolver: host_route_resolver() | nil, + active: entry(), + history: [entry()], + forward: [entry()], + modals: [map()], + last_transition: map() | nil, + last_host_route: map() | nil + } + + @spec initialize(:native | :canonical, map(), Element.t() | nil, keyword()) :: t() + def initialize(source_kind, screen, canonical_element, opts) when is_map(screen) do + active = current_entry(source_kind, screen, canonical_element, opts) + + %{ + registry: Keyword.get(opts, :screen_registry, %{}), + resolver: Keyword.get(opts, :navigation_resolver), + host_route_resolver: Keyword.get(opts, :host_route_resolver), + active: active, + history: [], + forward: [], + modals: [], + last_transition: nil, + last_host_route: nil + } + end + + @spec transition?(map()) :: boolean() + def transition?(%{family: :navigation, target: target}) when is_map(target) do + not is_nil(raw_navigation_descriptor(target)) + end + + def transition?(_other), do: false + + @spec apply_transition(State.t(), map()) :: {:ok, State.t()} | {:error, Error.t()} + def apply_transition(%State{} = state, translation) when is_map(translation) do + with {:ok, descriptor} <- navigation_descriptor(translation), + :ok <- validate_runtime_target(translation), + {:ok, next_state} <- apply_action(state, descriptor, translation) do + {:ok, next_state} + end + end + + @spec summary(t() | map()) :: map() + def summary(%{ + active: active, + history: history, + forward: forward, + modals: modals, + last_transition: last_transition, + last_host_route: last_host_route + }) do + %{ + current_screen_id: active.screen_id, + current_title: active.title, + params: normalize_map(active.params), + history: Enum.map(history, &entry_summary/1), + forward: Enum.map(forward, &entry_summary/1), + modals: Enum.map(modals, &modal_summary/1), + current_modal: modals |> List.last() |> modal_summary(), + last_transition: last_transition, + host_route: last_host_route + } + end + + def summary(_other), do: %{} + + @spec authoritative_screen_payload(State.t()) :: map() + def authoritative_screen_payload(%State{} = state) do + %{ + screen_id: state.screen_id, + title: state.title, + source_kind: state.source_kind, + boundary_mode: state.boundary_mode, + tree: + ElmUi.ServerRuntime.RenderModel.build( + state.rendered_tree, + theme: Map.get(state.metadata, :theme, :default) + ), + metadata: Map.put(state.metadata, :navigation, summary(state.navigation)) + } + end + + defp apply_action(%State{} = state, descriptor, translation) do + action = Map.get(descriptor, :action) + + case action do + action when action in [:navigate_to, "navigate_to"] -> + transition_to_screen(state, descriptor, translation, :push_history) + + action when action in [:replace_with, "replace_with"] -> + transition_to_screen(state, descriptor, translation, :replace_current) + + action when action in [:go_back, "go_back"] -> + move_history(state, descriptor, translation, :back) + + action when action in [:go_forward, "go_forward"] -> + move_history(state, descriptor, translation, :forward) + + action when action in [:open_modal, "open_modal"] -> + open_modal(state, descriptor, translation) + + action when action in [:close_modal, "close_modal"] -> + close_modal(state, descriptor, translation) + + _other -> + {:error, + Error.new(:unsupported_navigation_context, "Unsupported navigation transition action", %{ + action: action + })} + end + end + + defp transition_to_screen(%State{} = state, descriptor, translation, strategy) do + with {:ok, resolution} <- resolve_screen(state, descriptor), + {:ok, host_route} <- resolve_host_route(state, descriptor, resolution) do + current = state.navigation.active + + navigation = + state.navigation + |> Map.put(:active, entry_from_resolution(resolution, host_route)) + |> Map.put(:last_transition, transition_summary(translation, descriptor)) + |> Map.put(:last_host_route, host_route) + |> Map.put(:modals, []) + |> update_history(current, strategy) + + state = + state + |> apply_resolution(resolution, navigation) + |> maybe_record_route_state_diagnostic(translation, resolution, host_route) + + {:ok, state} + end + end + + defp move_history(%State{} = state, descriptor, translation, direction) do + navigation = state.navigation + + case direction do + :back -> + case back_target(navigation) do + {:ok, target, history, forward} -> + apply_history_transition( + state, + descriptor, + translation, + target, + history, + [navigation.active | forward] + ) + + :empty -> + {:error, + Error.new( + :unsupported_navigation_context, + "Cannot go back without navigation history", + %{ + action: descriptor.action + } + )} + end + + :forward -> + case forward_target(navigation) do + {:ok, target, history, forward} -> + apply_history_transition( + state, + descriptor, + translation, + target, + history ++ [navigation.active], + forward + ) + + :empty -> + {:error, + Error.new( + :unsupported_navigation_context, + "Cannot go forward without forward navigation state", + %{action: descriptor.action} + )} + end + end + end + + defp apply_history_transition(state, descriptor, translation, target, history, forward) do + with {:ok, resolution} <- resolution_from_entry(target), + {:ok, host_route} <- resolve_host_route(state, descriptor, resolution, target.host_route) do + navigation = + state.navigation + |> Map.put(:active, %{target | host_route: host_route}) + |> Map.put(:history, history) + |> Map.put(:forward, forward) + |> Map.put(:last_transition, transition_summary(translation, descriptor)) + |> Map.put(:last_host_route, host_route) + + state = + state + |> apply_resolution(resolution, navigation) + |> maybe_record_route_state_diagnostic(translation, resolution, host_route) + + {:ok, state} + end + end + + defp open_modal(%State{} = state, descriptor, translation) do + modal_id = Map.get(descriptor, :modal) + + if modal_id in [nil, ""] do + {:error, + Error.new(:unsupported_navigation_context, "open_modal requires a modal identifier", %{ + action: descriptor.action + })} + else + modal_entry = %{ + modal: modal_id, + params: normalize_map(Map.get(descriptor, :params, %{})), + metadata: normalize_map(Map.get(descriptor, :metadata, %{})) + } + + {:ok, + %{ + state + | navigation: %{ + state.navigation + | modals: state.navigation.modals ++ [modal_entry], + last_transition: transition_summary(translation, descriptor) + } + }} + end + end + + defp close_modal(%State{} = state, descriptor, translation) do + case pop_modal(state.navigation.modals, Map.get(descriptor, :modal)) do + {:ok, modals} -> + {:ok, + %{ + state + | navigation: %{ + state.navigation + | modals: modals, + last_transition: transition_summary(translation, descriptor) + } + }} + + :error -> + {:error, + Error.new(:unsupported_navigation_context, "Requested modal is not open", %{ + action: descriptor.action, + modal: Map.get(descriptor, :modal) + })} + end + end + + defp resolve_screen(%State{} = state, descriptor) do + screen_id = Map.get(descriptor, :screen) + + with {:ok, candidate} <- resolve_candidate(state.navigation, screen_id, descriptor, state), + {:ok, resolution} <- normalize_resolution(candidate, screen_id, descriptor) do + {:ok, resolution} + else + {:error, reason} -> + {:error, + Error.new( + :unresolved_navigation_target, + "Unable to resolve symbolic navigation target", + %{ + action: Map.get(descriptor, :action), + screen_id: screen_id, + reason: inspect(reason) + } + )} + end + end + + defp resolve_candidate(%{resolver: resolver} = navigation, screen_id, descriptor, state) + when is_function(resolver, 2) do + case resolver.(descriptor, state) do + {:ok, candidate} -> {:ok, candidate} + {:error, _reason} = error -> error + nil -> lookup_registry(navigation.registry, screen_id) + candidate -> {:ok, candidate} + end + end + + defp resolve_candidate(%{registry: registry}, screen_id, _descriptor, _state) do + lookup_registry(registry, screen_id) + end + + defp resolve_host_route(%State{} = state, descriptor, resolution, fallback \\ nil) do + case state.navigation.host_route_resolver do + resolver when is_function(resolver, 2) -> + case resolver.(host_route_descriptor(descriptor, resolution), state) do + {:ok, nil} -> + {:ok, fallback} + + {:ok, route} when is_map(route) -> + {:ok, Map.new(route)} + + nil -> + {:ok, fallback} + + route when is_map(route) -> + {:ok, Map.new(route)} + + {:error, reason} -> + {:error, + Error.new( + :unsupported_navigation_context, + "Host-route integration returned an invalid result", + %{reason: inspect(reason)} + )} + + other -> + {:error, + Error.new( + :unsupported_navigation_context, + "Host-route integration returned an invalid result", + %{reason: inspect(other)} + )} + end + + _other -> + {:ok, fallback} + end + end + + defp normalize_resolution(%Element{} = element, fallback_screen_id, descriptor) do + with {:ok, root} <- ElmUi.Renderer.render(element) do + {:ok, + %{ + source_kind: :canonical, + boundary_mode: :canonical_boundary, + screen_id: fallback_screen_id || element.id || "canonical-screen", + title: fallback_screen_title(element, fallback_screen_id), + rendered_tree: root, + canonical_element: element, + metadata: normalize_map(Map.get(descriptor, :metadata, %{})), + params: normalize_map(Map.get(descriptor, :params, %{})), + screen_ref: element + }} + else + {:error, error} -> + {:error, error} + end + end + + defp normalize_resolution(candidate, fallback_screen_id, descriptor) when is_map(candidate) do + metadata = + candidate + |> Map.get(:metadata, %{}) + |> normalize_map() + |> Map.merge(normalize_map(Map.get(descriptor, :metadata, %{}))) + + params = normalize_map(Map.get(descriptor, :params, %{})) + + cond do + valid_native_screen?(candidate) -> + {:ok, + %{ + source_kind: :native, + boundary_mode: :native_local, + screen_id: Map.get(candidate, :id) || fallback_screen_id, + title: Map.get(candidate, :title, "Screen"), + rendered_tree: Map.get(candidate, :root), + canonical_element: nil, + metadata: metadata, + params: params, + screen_ref: candidate + }} + + match?(%Element{}, Map.get(candidate, :element) || Map.get(candidate, "element")) -> + normalize_resolution( + Map.get(candidate, :element) || Map.get(candidate, "element"), + Map.get(candidate, :screen_id) || fallback_screen_id, + %{ + params: params, + metadata: metadata |> Map.merge(normalize_map(Map.get(candidate, :metadata, %{}))) + } + ) + + match?(%Element{}, Map.get(candidate, :iur) || Map.get(candidate, "iur")) -> + normalize_resolution( + Map.get(candidate, :iur) || Map.get(candidate, "iur"), + Map.get(candidate, :screen_id) || fallback_screen_id, + %{ + params: params, + metadata: metadata |> Map.merge(normalize_map(Map.get(candidate, :metadata, %{}))) + } + ) + + valid_native_screen?(Map.get(candidate, :screen) || Map.get(candidate, "screen")) -> + screen = Map.get(candidate, :screen) || Map.get(candidate, "screen") + + normalize_resolution( + screen, + Map.get(candidate, :screen_id) || fallback_screen_id, + %{ + params: params, + metadata: metadata |> Map.merge(normalize_map(Map.get(candidate, :metadata, %{}))) + } + ) + + true -> + {:error, {:unsupported_navigation_target, inspect(candidate)}} + end + end + + defp normalize_resolution(other, _fallback_screen_id, _descriptor) do + {:error, {:unsupported_navigation_target, inspect(other)}} + end + + defp resolution_from_entry(%{source_kind: :canonical, screen_ref: %Element{} = element} = entry) do + normalize_resolution(element, entry.screen_id, %{ + params: entry.params, + metadata: entry.metadata + }) + end + + defp resolution_from_entry(%{source_kind: :native, screen_ref: screen} = entry) + when is_map(screen) do + normalize_resolution(screen, entry.screen_id, %{ + params: entry.params, + metadata: entry.metadata + }) + end + + defp resolution_from_entry(entry) do + {:error, {:invalid_navigation_entry, entry.screen_id}} + end + + defp apply_resolution(%State{} = state, resolution, navigation) do + metadata = + state.metadata + |> normalize_map() + |> Map.drop([:navigation]) + |> Map.merge(normalize_map(resolution.metadata)) + + %{ + state + | source_kind: resolution.source_kind, + title: resolution.title, + screen_id: resolution.screen_id, + rendered_tree: resolution.rendered_tree, + canonical_element: resolution.canonical_element, + boundary_mode: resolution.boundary_mode, + navigation: navigation, + metadata: metadata + } + end + + defp current_entry(source_kind, screen, canonical_element, _opts) do + %{ + screen_id: Map.get(screen, :id), + title: Map.get(screen, :title, "Screen"), + source_kind: source_kind, + boundary_mode: if(source_kind == :canonical, do: :canonical_boundary, else: :native_local), + screen_ref: if(source_kind == :canonical, do: canonical_element, else: screen), + params: %{}, + metadata: normalize_map(Map.get(screen, :metadata, %{})), + host_route: nil + } + end + + defp maybe_record_route_state_diagnostic(%State{} = state, translation, resolution, host_route) do + case route_state_diagnostic(translation, resolution, host_route) do + nil -> state + diagnostic -> State.record_diagnostic(state, diagnostic) + end + end + + defp route_state_diagnostic(translation, resolution, host_route) do + metadata = + translation + |> Map.get(:metadata, %{}) + |> normalize_map() + + route_state = + metadata + |> Map.get(:route_state, Map.get(metadata, "route_state", %{})) + |> normalize_map() + + reported_screen = + Map.get(route_state, :screen_id) || Map.get(route_state, "screen_id") || + Map.get(route_state, :screen) || Map.get(route_state, "screen") + + reported_path = Map.get(route_state, :path) || Map.get(route_state, "path") + expected_path = host_route && (Map.get(host_route, :path) || Map.get(host_route, "path")) + + cond do + route_state == %{} -> + nil + + not is_nil(reported_screen) and + to_string(reported_screen) != to_string(resolution.screen_id) -> + %{ + level: :warning, + reason: :frontend_route_state_divergence, + reported_screen: reported_screen, + authoritative_screen: resolution.screen_id + } + + not is_nil(reported_path) and not is_nil(expected_path) and reported_path != expected_path -> + %{ + level: :warning, + reason: :frontend_route_state_divergence, + reported_path: reported_path, + authoritative_path: expected_path + } + + true -> + nil + end + end + + defp back_target(%{history: []}), do: :empty + + defp back_target(%{history: history, forward: forward}) do + {remainder, [target]} = Enum.split(history, -1) + {:ok, target, remainder, forward} + end + + defp forward_target(%{forward: []}), do: :empty + + defp forward_target(%{history: history, forward: [target | remainder]}) do + {:ok, target, history, remainder} + end + + defp update_history(navigation, current, :push_history) do + %{navigation | history: navigation.history ++ [current], forward: []} + end + + defp update_history(navigation, _current, :replace_current) do + %{navigation | forward: []} + end + + defp navigation_descriptor(translation) do + case Interaction.navigation_descriptor(Map.get(translation, :target, %{})) do + nil -> + {:error, + Error.new( + :unsupported_navigation_context, + "Navigation transitions require a canonical target descriptor", + %{ + intent: Map.get(translation, :intent) + } + )} + + descriptor -> + {:ok, normalize_map(descriptor)} + end + end + + defp validate_runtime_target(translation) 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() + + if leaked_keys == [] do + :ok + else + {:error, + Error.new( + :host_route_syntax, + "Canonical navigation targets must not contain host-router syntax", + %{ + keys: leaked_keys + } + )} + end + end + + defp raw_navigation_descriptor(target) when is_map(target) do + Map.get(target, :navigation) || Map.get(target, "navigation") + end + + defp raw_navigation_descriptor(_other), do: nil + + 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} + {_key, value} -> {:ok, value} + end + end + + defp lookup_registry(registry, screen_id) when is_map(registry), + do: resolve_candidate(%{registry: registry}, screen_id) + + defp key_matches?(key, value), do: key == value or to_string(key) == to_string(value) + + defp valid_native_screen?(%{id: _id, title: _title, root: %ElmUi.Widget{}}), do: true + defp valid_native_screen?(_other), do: false + + defp entry_from_resolution(resolution, host_route) do + %{ + screen_id: resolution.screen_id, + title: resolution.title, + source_kind: resolution.source_kind, + boundary_mode: resolution.boundary_mode, + screen_ref: resolution.screen_ref, + params: resolution.params, + metadata: resolution.metadata, + host_route: host_route + } + end + + defp transition_summary(translation, descriptor) 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, %{})) + } + end + + defp host_route_descriptor(descriptor, resolution) do + %{ + action: Map.get(descriptor, :action), + screen: resolution.screen_id, + modal: Map.get(descriptor, :modal), + params: resolution.params, + metadata: resolution.metadata, + source_kind: resolution.source_kind + } + end + + defp fallback_screen_title(%Element{id: id}, fallback_screen_id) do + (id || fallback_screen_id || "Canonical Screen") + |> to_string() + |> String.replace("_", " ") + |> String.replace("-", " ") + |> String.split() + |> Enum.map_join(" ", &String.capitalize/1) + end + + defp pop_modal([], _modal_id), do: :error + + defp pop_modal(modals, nil), do: {:ok, Enum.drop(modals, -1)} + + defp pop_modal(modals, modal_id) do + if Enum.any?(modals, &(Map.get(&1, :modal) == modal_id)) do + {:ok, remove_last_modal(modals, modal_id)} + else + :error + end + end + + defp remove_last_modal(modals, modal_id) do + {reversed_kept, dropped?} = + modals + |> Enum.reverse() + |> Enum.reduce({[], false}, fn modal, {acc, dropped?} -> + if not dropped? and Map.get(modal, :modal) == modal_id do + {acc, true} + else + {[modal | acc], dropped?} + end + end) + + if dropped?, do: reversed_kept, else: modals + end + + defp entry_summary(entry) do + %{ + screen_id: entry.screen_id, + title: entry.title, + source_kind: entry.source_kind, + params: normalize_map(entry.params) + } + end + + defp modal_summary(nil), do: nil + + defp modal_summary(modal) do + %{ + modal: Map.get(modal, :modal), + params: normalize_map(Map.get(modal, :params, %{})), + metadata: normalize_map(Map.get(modal, :metadata, %{})) + } + 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 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(_other), do: %{} +end diff --git a/packages/elm_ui/lib/elm_ui/server_runtime/state.ex b/packages/elm_ui/lib/elm_ui/server_runtime/state.ex index d6d72726..a32f5d4b 100644 --- a/packages/elm_ui/lib/elm_ui/server_runtime/state.ex +++ b/packages/elm_ui/lib/elm_ui/server_runtime/state.ex @@ -5,6 +5,7 @@ defmodule ElmUi.ServerRuntime.State do alias UnifiedIUR.Element alias ElmUi.Widget + alias ElmUi.ServerRuntime.Navigation @type boundary_mode :: :native_local | :canonical_boundary @type t :: %__MODULE__{ @@ -18,6 +19,7 @@ defmodule ElmUi.ServerRuntime.State do diagnostics: [map()], event_log: [map()], last_boundary_signal: Jido.Signal.t() | nil, + navigation: Navigation.t(), metadata: map() } @@ -31,6 +33,7 @@ defmodule ElmUi.ServerRuntime.State do diagnostics: [], event_log: [], last_boundary_signal: nil, + navigation: %{}, metadata: %{} @spec record_event(t(), map()) :: t() @@ -42,4 +45,14 @@ defmodule ElmUi.ServerRuntime.State do def record_boundary_signal(%__MODULE__{} = state, signal) do %{state | last_boundary_signal: signal} end + + @spec record_diagnostic(t(), map()) :: t() + def record_diagnostic(%__MODULE__{} = state, diagnostic) when is_map(diagnostic) do + %{state | diagnostics: state.diagnostics ++ [diagnostic]} + end + + @spec navigation_summary(t()) :: map() + def navigation_summary(%__MODULE__{navigation: navigation}) do + Navigation.summary(navigation) + end end diff --git a/packages/elm_ui/lib/elm_ui/server_runtime/sync_boundary.ex b/packages/elm_ui/lib/elm_ui/server_runtime/sync_boundary.ex index 6c2c5118..186b02f3 100644 --- a/packages/elm_ui/lib/elm_ui/server_runtime/sync_boundary.ex +++ b/packages/elm_ui/lib/elm_ui/server_runtime/sync_boundary.ex @@ -44,7 +44,11 @@ defmodule ElmUi.ServerRuntime.SyncBoundary do end @spec translation_for_message(State.t(), Message.t()) :: {:ok, map()} | {:error, Error.t()} - def translation_for_message(%State{} = state, %{kind: :event, payload: payload}) + def translation_for_message(%State{} = state, %{ + kind: :event, + payload: payload, + metadata: metadata + }) when is_map(payload) do payload = payload @@ -70,6 +74,11 @@ defmodule ElmUi.ServerRuntime.SyncBoundary do {:ok, translation} -> {:ok, translation + |> Map.update( + :metadata, + normalize_map(metadata), + &Map.merge(&1, normalize_map(metadata)) + ) |> Map.put_new(:runtime_id, state.runtime_id) |> Map.put_new(:screen, state.screen_id)} @@ -93,7 +102,7 @@ defmodule ElmUi.ServerRuntime.SyncBoundary do end _local -> - case Transport.from_native_event(local_event_attrs(payload)) do + case Transport.from_native_event(local_event_attrs(payload, metadata)) do {:ok, translation} -> {:ok, translation} @@ -141,7 +150,8 @@ defmodule ElmUi.ServerRuntime.SyncBoundary do runtime_event: translation.runtime_event, event_count: length(state.event_log), server_authority: true, - diagnostics: state.diagnostics + diagnostics: state.diagnostics, + authoritative_screen: ViewState.authoritative_screen_payload(state) }, %{ source_kind: state.source_kind, @@ -158,12 +168,13 @@ defmodule ElmUi.ServerRuntime.SyncBoundary do defp normalize_boundary("boundary"), do: :boundary defp normalize_boundary(_value), do: :local - defp local_event_attrs(payload) do + defp local_event_attrs(payload, metadata) do %{ family: fetch(payload, :family), intent: fetch(payload, :intent), boundary: :local, runtime_event: fetch(payload, :runtime_event), + metadata: normalize_map(metadata), payload: fetch(payload, :payload), target: fetch(payload, :target, %{}), widget_id: diff --git a/packages/elm_ui/lib/elm_ui/server_runtime/view_state.ex b/packages/elm_ui/lib/elm_ui/server_runtime/view_state.ex index bf04c82f..ce776961 100644 --- a/packages/elm_ui/lib/elm_ui/server_runtime/view_state.ex +++ b/packages/elm_ui/lib/elm_ui/server_runtime/view_state.ex @@ -21,7 +21,14 @@ defmodule ElmUi.ServerRuntime.ViewState do flash: nil }, diagnostics: state.diagnostics, - metadata: state.metadata + metadata: + state.metadata + |> Map.put(:navigation, State.navigation_summary(state)) } end + + @spec authoritative_screen_payload(State.t()) :: map() + def authoritative_screen_payload(%State{} = state) do + ElmUi.ServerRuntime.Navigation.authoritative_screen_payload(state) + end end diff --git a/packages/elm_ui/lib/elm_ui/transport/bridge.ex b/packages/elm_ui/lib/elm_ui/transport/bridge.ex index 31d926c4..279c7d1f 100644 --- a/packages/elm_ui/lib/elm_ui/transport/bridge.ex +++ b/packages/elm_ui/lib/elm_ui/transport/bridge.ex @@ -14,6 +14,7 @@ defmodule ElmUi.Transport.Bridge do payload_for(translation), %{ runtime_id: model.runtime_id, + screen_id: model.screen_id, source_kind: model.source_kind, boundary: translation.boundary } diff --git a/packages/elm_ui/lib/elm_ui/transport/diagnostics.ex b/packages/elm_ui/lib/elm_ui/transport/diagnostics.ex index c43fbc09..1fc63a32 100644 --- a/packages/elm_ui/lib/elm_ui/transport/diagnostics.ex +++ b/packages/elm_ui/lib/elm_ui/transport/diagnostics.ex @@ -6,6 +6,7 @@ defmodule ElmUi.Transport.Diagnostics do alias Jido.Signal alias ElmUi.Transport.Error + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport @renderer_local_event_prefixes ["phx_", "phx-", "elm:", "browser:", "dom:"] @renderer_local_payload_prefixes ["phx_", "elm_", "browser_", "dom_"] @@ -24,6 +25,7 @@ defmodule ElmUi.Transport.Diagnostics do :ok <- validate_payload_mapping(payload, :native_local), :ok <- validate_runtime_event(Map.get(attrs, :runtime_event), :native_local), :ok <- validate_payload_keys(payload, :native_local), + :ok <- validate_navigation_target(Map.get(attrs, :target, %{})), :ok <- maybe_validate_boundary_context(attrs, boundary) do :ok end @@ -37,11 +39,13 @@ defmodule ElmUi.Transport.Diagnostics do screen = extension(signal, :elm_ui_screen) runtime_id = extension(signal, :elm_ui_runtime_id) payload = signal.data || %{} + target = extension(signal, :elm_ui_target) || %{} with :ok <- validate_family(family, supported_families), :ok <- validate_payload_mapping(payload, :canonical_boundary), :ok <- validate_runtime_event(runtime_event, :canonical_boundary), :ok <- validate_payload_keys(payload, :canonical_boundary), + :ok <- validate_navigation_target(target), :ok <- validate_boundary_context(%{screen: screen, runtime_id: runtime_id}, signal.subject) do :ok @@ -152,6 +156,22 @@ defmodule ElmUi.Transport.Diagnostics do defp validate_payload_keys(_payload, _surface), do: :ok + defp validate_navigation_target(target) 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) + + if leaked_keys == [] do + :ok + else + {:error, Error.host_route_syntax(leaked_keys)} + end + end + defp maybe_validate_boundary_context(attrs, :boundary) do validate_boundary_context(attrs, Map.get(attrs, :widget_id)) end @@ -197,6 +217,15 @@ defmodule ElmUi.Transport.Diagnostics do defp renderer_local_key?(_key), do: false + 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 extension(signal, key) do Map.get(signal.extensions, key) || Map.get(signal.extensions, Atom.to_string(key)) end diff --git a/packages/elm_ui/lib/elm_ui/transport/error.ex b/packages/elm_ui/lib/elm_ui/transport/error.ex index aafb5e80..53ad233e 100644 --- a/packages/elm_ui/lib/elm_ui/transport/error.ex +++ b/packages/elm_ui/lib/elm_ui/transport/error.ex @@ -58,6 +58,15 @@ defmodule ElmUi.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-route syntax must not cross the canonical elm_ui navigation contract", + details: %{keys: keys} + } + end + @spec invalid_boundary_envelope(term()) :: t() def invalid_boundary_envelope(value) do %__MODULE__{ diff --git a/packages/elm_ui/lib/elm_ui/transport/signals.ex b/packages/elm_ui/lib/elm_ui/transport/signals.ex index 92b66ea0..d41d8763 100644 --- a/packages/elm_ui/lib/elm_ui/transport/signals.ex +++ b/packages/elm_ui/lib/elm_ui/transport/signals.ex @@ -35,6 +35,7 @@ defmodule ElmUi.Transport.Signals do intent = fetch(attrs, :intent, family) payload = fetch(attrs, :payload, %{}) |> normalize_map() target = fetch(attrs, :target, %{}) |> normalize_map() + metadata = fetch(attrs, :metadata, %{}) |> normalize_map() runtime_event = fetch(attrs, :runtime_event, default_runtime_event(family, intent)) translation = %{ @@ -44,6 +45,7 @@ defmodule ElmUi.Transport.Signals do runtime_event: runtime_event, payload: payload, target: target, + metadata: metadata, source_kind: normalize_source_kind(fetch(attrs, :source_kind, :native)), boundary_mode: normalize_boundary_mode(fetch(attrs, :boundary_mode)), widget_id: fetch(attrs, :widget_id), @@ -94,6 +96,7 @@ defmodule ElmUi.Transport.Signals do runtime_event: runtime_event, payload: normalize_map(signal.data || %{}), target: normalize_map(fetch_extension(signal, :elm_ui_target, %{})), + metadata: normalize_map(fetch_extension(signal, :elm_ui_metadata, %{})), source_kind: normalize_source_kind(fetch_extension(signal, :elm_ui_source_kind, :canonical)), boundary_mode: :canonical_boundary, @@ -150,6 +153,7 @@ defmodule ElmUi.Transport.Signals do :intent, :runtime_event, :target, + :metadata, :payload, :server_action, :frontend_update, @@ -170,6 +174,7 @@ defmodule ElmUi.Transport.Signals do :family, :intent, :runtime_event, + :metadata, :payload, :native_event, :server_action, @@ -190,6 +195,7 @@ defmodule ElmUi.Transport.Signals do elm_ui_intent: translation.intent, elm_ui_runtime_event: translation.runtime_event, elm_ui_target: translation.target, + elm_ui_metadata: translation.metadata, elm_ui_source_kind: translation.source_kind, elm_ui_runtime_id: translation.runtime_id, elm_ui_screen: translation.screen diff --git a/packages/elm_ui/test/elm_ui/runtime_navigation_test.exs b/packages/elm_ui/test/elm_ui/runtime_navigation_test.exs new file mode 100644 index 00000000..beea502b --- /dev/null +++ b/packages/elm_ui/test/elm_ui/runtime_navigation_test.exs @@ -0,0 +1,122 @@ +defmodule ElmUi.RuntimeNavigationTest do + use ExUnit.Case, async: true + + alias UnifiedIUR.Element + alias ElmUi.FrontendRuntime + alias ElmUi.Widgets + + defp home_screen do + Widgets.screen("home", "Home", [ + Widgets.button("settings-link", "Settings") + ]) + end + + defp settings_screen do + Widgets.screen("settings", "Settings", [ + Widgets.text("settings-title", "Settings") + ]) + end + + test "authoritative server navigation updates frontend screen selection through acknowledgements" do + assert {:ok, runtime_state} = + ElmUi.Runtime.mount_native_screen(home_screen(), + runtime_id: "nav-runtime", + screen_registry: %{settings: settings_screen()}, + host_route_resolver: fn descriptor, _state -> + if descriptor.screen == "settings", do: %{path: "/settings"}, else: nil + end + ) + + assert {:ok, frontend_model} = ElmUi.Runtime.hydrate_frontend(runtime_state) + assert frontend_model.screen_id == "home" + + assert {:ok, frontend_after_dispatch, event_message} = + FrontendRuntime.dispatch_interaction(frontend_model, + family: :navigation, + intent: :open_settings_screen, + boundary: :boundary, + widget_id: "settings-link", + target: %{ + navigation: %{ + action: :navigate_to, + screen: :settings, + params: %{tab: :profile} + } + } + ) + + assert frontend_after_dispatch.local_state.pending_boundary_event.runtime_event == + "navigation:open_settings_screen" + + assert {:ok, next_state, ack_message} = + ElmUi.Runtime.handle_frontend_event(runtime_state, event_message) + + assert next_state.screen_id == "settings" + assert next_state.boundary_mode == :native_local + assert ack_message.payload.authoritative_screen.screen_id == "settings" + + assert ack_message.payload.authoritative_screen.metadata.navigation.current_screen_id == + "settings" + + assert ack_message.payload.authoritative_screen.metadata.navigation.host_route == %{ + path: "/settings" + } + + assert {:ok, frontend_after_ack} = + ElmUi.FrontendRuntime.apply_server_message(frontend_after_dispatch, ack_message) + + assert frontend_after_ack.screen_id == "settings" + assert frontend_after_ack.title == "Settings" + + assert frontend_after_ack.local_state.last_server_ack.authoritative_screen.screen_id == + "settings" + end + + test "canonical boundary navigation reports divergence when frontend route state disagrees" do + element = + Element.new(:widget, :text, id: "workspace-home", attributes: %{content: "Workspace"}) + + assert {:ok, runtime_state} = + ElmUi.Runtime.mount_iur_screen(element, + runtime_id: "canonical-nav", + screen_registry: %{settings: settings_screen()} + ) + + assert {:ok, translation} = + ElmUi.Transport.from_native_event( + family: :navigation, + intent: :open_settings_screen, + 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: :navigate_to, + screen: :settings, + params: %{tab: :profile} + } + }, + metadata: %{route_state: %{screen_id: "wrong-screen"}} + ) + + assert {:ok, envelope} = + ElmUi.Transport.Bridge.boundary_envelope(translation, topic: "elm_ui:navigation") + + assert {:ok, next_state, ack_message} = + ElmUi.Runtime.handle_boundary_envelope(runtime_state, envelope) + + assert next_state.screen_id == "settings" + + assert Enum.any?(next_state.diagnostics, fn diagnostic -> + diagnostic.reason == :frontend_route_state_divergence and + diagnostic.authoritative_screen == "settings" + end) + + assert Enum.any?(ack_message.payload.diagnostics, fn diagnostic -> + diagnostic.reason == :frontend_route_state_divergence and + diagnostic.authoritative_screen == "settings" + end) + end +end diff --git a/packages/elm_ui/test/elm_ui/runtime_test.exs b/packages/elm_ui/test/elm_ui/runtime_test.exs index 2c1a9f1e..14197608 100644 --- a/packages/elm_ui/test/elm_ui/runtime_test.exs +++ b/packages/elm_ui/test/elm_ui/runtime_test.exs @@ -16,6 +16,7 @@ defmodule ElmUi.RuntimeTest do assert {:ok, frontend_model} = Runtime.hydrate_frontend(runtime_state) assert frontend_model.runtime_id == "native-runtime" + assert frontend_model.screen_id == "native-counter" assert frontend_model.title == "Native Counter" assert frontend_model.boundary_mode == :native_local end @@ -36,6 +37,7 @@ defmodule ElmUi.RuntimeTest do assert {:ok, frontend_model} = Runtime.hydrate_frontend(runtime_state) assert frontend_model.runtime_id == "canonical-runtime" + assert frontend_model.screen_id == :canonical_button assert frontend_model.source_kind == :canonical end @@ -113,6 +115,7 @@ defmodule ElmUi.RuntimeTest do assert ack_message.kind == :ack assert ack_message.payload.server_authority assert ack_message.payload.family == :click + assert ack_message.payload.authoritative_screen.screen_id == "native-counter" end test "runtime handles canonical boundary envelopes through the same server runtime" do @@ -147,6 +150,7 @@ defmodule ElmUi.RuntimeTest do assert next_state.last_boundary_signal.type == "elm_ui.command.run" assert ack_message.kind == :ack assert ack_message.payload.boundary == :boundary + assert ack_message.payload.authoritative_screen.screen_id == :ops_command_palette end test "frontend runtime returns deterministic hydration diagnostics" do 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 09c7aae1..66254f6c 100644 --- a/packages/elm_ui/test/elm_ui/transport_diagnostics_test.exs +++ b/packages/elm_ui/test/elm_ui/transport_diagnostics_test.exs @@ -32,6 +32,21 @@ defmodule ElmUi.TransportDiagnosticsTest do ) end + test "rejects leaked host-route syntax on canonical navigation targets" do + assert {:error, %Error{reason: :host_route_syntax}} = + 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: %{action: :navigate_to, screen: :settings, route: "/settings"} + } + ) + end + test "rejects missing canonical context and invalid families" do assert {:error, %Error{reason: :missing_boundary_context}} = ElmUi.Signals.from_native_event( From c319ff25ad544084efa542764ebfa278fa8ade02 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Sat, 25 Apr 2026 16:19:27 -0400 Subject: [PATCH 3/4] Implement canonical navigation phase 3 section 3.3 --- packages/elm_ui/lib/elm_ui/examples.ex | 359 ++++++++++++++++++ packages/elm_ui/lib/elm_ui/inspect.ex | 12 + packages/elm_ui/test/elm_ui/examples_test.exs | 32 ++ .../elm_ui/phase_six_integration_test.exs | 3 + packages/live_ui/lib/live_ui/examples.ex | 3 +- .../web_navigation_transition_comparison.ex | 276 ++++++++++++++ .../live_ui/test/live_ui/examples_test.exs | 26 ++ 7 files changed, 710 insertions(+), 1 deletion(-) create mode 100644 packages/live_ui/lib/live_ui/examples/web_navigation_transition_comparison.ex diff --git a/packages/elm_ui/lib/elm_ui/examples.ex b/packages/elm_ui/lib/elm_ui/examples.ex index edbf51b5..f3275896 100644 --- a/packages/elm_ui/lib/elm_ui/examples.ex +++ b/packages/elm_ui/lib/elm_ui/examples.ex @@ -326,6 +326,73 @@ defmodule ElmUi.Examples do ) end + @spec native_navigation_screen() :: map() + def native_navigation_screen do + Widgets.screen( + "home", + "Navigation Home", + [ + Widgets.content("navigation-header", [ + Widgets.text("navigation-title", "Navigation Home"), + Widgets.inline_feedback( + "navigation-feedback", + "Server-side transitions remain authoritative even when the browser keeps local route state." + ) + ]), + Widgets.row( + "navigation-actions", + [ + Widgets.button("settings-link", "Open Settings"), + Widgets.button("dialog-link", "Open Dialog") + ], + gap: :md + ) + ], + source: :native_navigation, + bridge: :phoenix_elm + ) + end + + @spec canonical_navigation_screen() :: Element.t() + def canonical_navigation_screen do + Element.new(:layout, :column, + id: "home", + attributes: %{gap: :md}, + children: [ + Element.new(:widget, :content, + id: "navigation-header", + children: [ + Element.new(:widget, :text, + id: "navigation-title", + attributes: %{content: "Navigation Home"} + ), + Element.new(:widget, :inline_feedback, + id: "navigation-feedback", + attributes: %{ + message: + "Canonical transitions stay screen-based while host routes remain external." + } + ) + ] + ), + Element.new(:layout, :row, + id: "navigation-actions", + attributes: %{gap: :md}, + children: [ + Element.new(:widget, :button, + id: "settings-link", + attributes: %{label: "Open Settings"} + ), + Element.new(:widget, :button, + id: "dialog-link", + attributes: %{label: "Open Dialog"} + ) + ] + ) + ] + ) + end + @spec native_advanced_screen() :: map() def native_advanced_screen do %{ @@ -939,6 +1006,9 @@ defmodule ElmUi.Examples do native_transport: native_transport_screen(), canonical_transport: canonical_transport_screen(), mixed_transport: mixed_transport_comparison(), + native_navigation: native_navigation_screen(), + canonical_navigation: canonical_navigation_screen(), + navigation_continuity: navigation_comparison(), native_advanced: native_advanced_screen(), canonical_advanced: canonical_advanced_screen(), advanced_continuity: advanced_comparison(), @@ -1109,6 +1179,51 @@ defmodule ElmUi.Examples do } end + @spec navigation_comparison() :: map() + def navigation_comparison do + route_fixture = navigation_host_route_fixture() + + native = navigation_flow(:native) + canonical = navigation_flow(:canonical) + + %{ + native: native, + canonical: canonical, + host_route_fixture: route_fixture, + continuity: %{ + same_navigation_target?: + native.after_navigate.screen_id == "settings" and + canonical.after_navigate.screen_id == "settings", + frontend_coordination?: + native.after_navigate.frontend_screen_id == native.after_navigate.screen_id and + canonical.after_navigate.frontend_screen_id == canonical.after_navigate.screen_id, + same_modal_identifier?: + get_in(native, [:after_modal, :navigation, :current_modal, :modal]) == + get_in(canonical, [:after_modal, :navigation, :current_modal, :modal]), + same_replacement_target?: + native.after_replace.screen_id == "home" and canonical.after_replace.screen_id == "home", + host_route_externalized?: + is_nil(get_in(route_fixture, [:canonical_target, :route])) and + get_in(native, [:after_navigate, :authoritative_host_route, :path]) == + get_in(canonical, [:after_navigate, :authoritative_host_route, :path]) and + get_in(native, [:after_navigate, :route_state, :path]) == + get_in(route_fixture, [:host_application, :frontend_route_state, :path]), + server_authority_preserved?: + Enum.all?( + [ + native.after_navigate.server_authoritative?, + native.after_modal.server_authoritative?, + native.after_replace.server_authoritative?, + canonical.after_navigate.server_authoritative?, + canonical.after_modal.server_authoritative?, + canonical.after_replace.server_authoritative? + ], + & &1 + ) + } + } + end + @spec styling_comparison() :: map() def styling_comparison do {:ok, native_state} = @@ -1146,6 +1261,220 @@ defmodule ElmUi.Examples do |> Enum.filter(&(&1.category == category)) end + defp navigation_flow(kind) do + {mount_result, screen_registry} = + case kind do + :native -> + home_screen = native_navigation_screen() + + { + ElmUi.Runtime.mount_native_screen(home_screen, + runtime_id: "native-navigation", + screen_registry: %{ + home: home_screen, + settings: native_navigation_settings_screen() + }, + host_route_resolver: &resolve_navigation_host_route/2 + ), + %{home: home_screen, settings: native_navigation_settings_screen()} + } + + :canonical -> + home_element = canonical_navigation_screen() + + { + ElmUi.Runtime.mount_iur_screen(home_element, + runtime_id: "canonical-navigation", + screen_registry: %{ + home: home_element, + settings: canonical_navigation_settings_screen() + }, + host_route_resolver: &resolve_navigation_host_route/2 + ), + %{home: home_element, settings: canonical_navigation_settings_screen()} + } + end + + {:ok, runtime_state} = mount_result + {:ok, frontend_model} = ElmUi.Runtime.hydrate_frontend(runtime_state) + + {:ok, frontend_after_navigate_dispatch, navigate_event_message} = + ElmUi.FrontendRuntime.dispatch_interaction(frontend_model, + family: :navigation, + intent: :open_settings_screen, + boundary: :boundary, + widget_id: "settings-link", + target: navigation_target(:navigate_to, screen: :settings, params: %{tab: :profile}), + route_state: navigation_route_state("settings", %{tab: :profile}) + ) + + {:ok, state_after_navigate, navigate_ack} = + ElmUi.Runtime.handle_frontend_event(runtime_state, navigate_event_message) + + {:ok, frontend_after_navigate} = + ElmUi.FrontendRuntime.apply_server_message( + frontend_after_navigate_dispatch, + navigate_ack + ) + + {:ok, frontend_after_modal_dispatch, modal_event_message} = + ElmUi.FrontendRuntime.dispatch_interaction(frontend_after_navigate, + family: :navigation, + intent: :open_settings_dialog, + boundary: :boundary, + widget_id: "dialog-link", + target: + navigation_target(:open_modal, + modal: :settings_dialog, + params: %{origin: :toolbar}, + metadata: %{host_overlay: :dialog} + ) + ) + + {:ok, state_after_modal, modal_ack} = + ElmUi.Runtime.handle_frontend_event(state_after_navigate, modal_event_message) + + {:ok, frontend_after_modal} = + ElmUi.FrontendRuntime.apply_server_message(frontend_after_modal_dispatch, modal_ack) + + {:ok, frontend_after_replace_dispatch, replace_event_message} = + ElmUi.FrontendRuntime.dispatch_interaction(frontend_after_modal, + family: :navigation, + intent: :replace_home_screen, + boundary: :boundary, + widget_id: "settings-link", + target: navigation_target(:replace_with, screen: :home, params: %{returning: true}), + route_state: navigation_route_state("home", %{returning: true}) + ) + + {:ok, state_after_replace, replace_ack} = + ElmUi.Runtime.handle_frontend_event(state_after_modal, replace_event_message) + + {:ok, frontend_after_replace} = + ElmUi.FrontendRuntime.apply_server_message(frontend_after_replace_dispatch, replace_ack) + + %{ + category: kind, + registry_size: map_size(screen_registry), + after_navigate: + navigation_snapshot( + state_after_navigate, + frontend_after_navigate, + navigate_ack, + navigation_route_state("settings", %{tab: :profile}) + ), + after_modal: navigation_snapshot(state_after_modal, frontend_after_modal, modal_ack, nil), + after_replace: + navigation_snapshot( + state_after_replace, + frontend_after_replace, + replace_ack, + navigation_route_state("home", %{returning: true}) + ) + } + end + + defp navigation_snapshot(state, frontend_model, ack_message, route_state) do + authoritative_screen = ack_message.payload.authoritative_screen + navigation = authoritative_screen.metadata.navigation + + %{ + screen_id: to_string(state.screen_id), + title: state.title, + source_kind: state.source_kind, + boundary_mode: state.boundary_mode, + frontend_screen_id: to_string(frontend_model.screen_id), + frontend_title: frontend_model.title, + server_authoritative?: ack_message.payload.server_authority, + signal_type: state.last_boundary_signal && state.last_boundary_signal.type, + authoritative_screen: authoritative_screen, + authoritative_host_route: navigation.host_route, + navigation: navigation, + route_state: route_state + } + end + + defp navigation_target(action, attrs) do + %{navigation: attrs |> Enum.into(%{}) |> Map.put(:action, action)} + end + + defp navigation_host_route_fixture do + %{ + canonical_target: %{action: :navigate_to, screen: :settings, params: %{tab: :profile}}, + host_application: %{ + phoenix_route: %{path: "/workspace/settings", params: %{tab: :profile}}, + frontend_route_state: navigation_route_state("settings", %{tab: :profile}), + note: + "Browser route matching and URL generation remain host concerns rather than canonical transition fields." + } + } + end + + defp navigation_route_state(screen_id, params) do + %{ + screen_id: screen_id, + path: "/workspace/#{screen_id}", + params: normalize_map(params) + } + end + + defp resolve_navigation_host_route(descriptor, _state) do + screen = + case Map.get(descriptor, :screen) do + screen when is_atom(screen) -> Atom.to_string(screen) + screen when is_binary(screen) -> screen + _other -> nil + end + + case screen do + screen when screen in ["home", "settings"] -> + {:ok, + %{ + path: "/workspace/#{screen}", + params: normalize_map(Map.get(descriptor, :params, %{})) + }} + + _other -> + {:ok, nil} + end + end + + defp native_navigation_settings_screen do + Widgets.screen( + "settings", + "Settings", + [ + Widgets.content("settings-header", [ + Widgets.text("settings-title", "Settings"), + Widgets.inline_feedback( + "settings-feedback", + "The server selected this screen and the Elm frontend acknowledged it." + ) + ]) + ], + source: :native_navigation, + bridge: :phoenix_elm + ) + end + + defp canonical_navigation_settings_screen do + Element.new(:layout, :column, + id: "settings", + children: [ + Element.new(:widget, :text, + id: "settings-title", + attributes: %{content: "Settings"} + ), + Element.new(:widget, :inline_feedback, + id: "settings-feedback", + attributes: %{ + message: "The authoritative Phoenix runtime coordinated this frontend screen change." + } + ) + ] + ) + end + defp snapshot(widget_tree, frontend_tree) do %{ widget_ids: collect_widget_ids(widget_tree), @@ -1273,6 +1602,15 @@ defmodule ElmUi.Examples do parity_group: :advanced_operations, parity_with: [:native_advanced, :advanced_continuity] }, + %{ + id: :canonical_navigation, + category: :canonical, + workflow: :navigation, + summary: "Canonical web navigation workspace", + coverage: [:canonical_renderer, :navigation, :server_authority, :route_boundary], + parity_group: :navigation_workspace, + parity_with: [:native_navigation, :navigation_continuity] + }, %{ id: :canonical_styling, category: :canonical, @@ -1327,6 +1665,15 @@ defmodule ElmUi.Examples do parity_group: :transport_workspace, parity_with: [:native_transport, :canonical_transport] }, + %{ + id: :navigation_continuity, + category: :mixed, + workflow: :navigation, + summary: "Native and canonical web navigation transition comparison", + coverage: [:comparison_artifact, :navigation, :server_authority, :route_boundary], + parity_group: :navigation_workspace, + parity_with: [:native_navigation, :canonical_navigation] + }, %{ id: :native_advanced, category: :native, @@ -1354,6 +1701,15 @@ defmodule ElmUi.Examples do parity_group: :foundational_workspace, parity_with: [:canonical_foundational, :foundational_continuity] }, + %{ + id: :native_navigation, + category: :native, + workflow: :navigation, + summary: "Direct-native web navigation workspace", + coverage: [:navigation, :native_runtime, :server_authority, :route_boundary], + parity_group: :navigation_workspace, + parity_with: [:canonical_navigation, :navigation_continuity] + }, %{ id: :native_styling, category: :native, @@ -1383,4 +1739,7 @@ defmodule ElmUi.Examples do } ] end + + defp normalize_map(map) when is_map(map), do: Map.new(map) + defp normalize_map(_other), do: %{} end diff --git a/packages/elm_ui/lib/elm_ui/inspect.ex b/packages/elm_ui/lib/elm_ui/inspect.ex index 2135c5bb..9d23ed20 100644 --- a/packages/elm_ui/lib/elm_ui/inspect.ex +++ b/packages/elm_ui/lib/elm_ui/inspect.ex @@ -50,6 +50,10 @@ defmodule ElmUi.Inspect do preview_native(ElmUi.Examples.native_transport_screen(), %{}) end + defp preview_surface(%{category: :native, id: :native_navigation}) do + preview_native(ElmUi.Examples.native_navigation_screen(), %{}) + end + defp preview_surface(%{category: :native, id: :native_advanced}) do preview_native(ElmUi.Examples.native_advanced_screen(), %{}) end @@ -73,6 +77,10 @@ defmodule ElmUi.Inspect do preview_canonical(ElmUi.Examples.canonical_transport_screen(), %{}) end + defp preview_surface(%{category: :canonical, id: :canonical_navigation}) do + preview_canonical(ElmUi.Examples.canonical_navigation_screen(), %{}) + end + defp preview_surface(%{category: :canonical, id: :canonical_advanced}) do preview_canonical(ElmUi.Examples.canonical_advanced_screen(), %{}) end @@ -96,6 +104,10 @@ defmodule ElmUi.Inspect do ElmUi.Examples.mixed_transport_comparison() end + defp preview_surface(%{category: :mixed, id: :navigation_continuity}) do + ElmUi.Examples.navigation_comparison() + end + defp preview_surface(%{category: :mixed, id: :styling_continuity}) do ElmUi.Examples.styling_comparison() end diff --git a/packages/elm_ui/test/elm_ui/examples_test.exs b/packages/elm_ui/test/elm_ui/examples_test.exs index 836ccbfa..563b99c1 100644 --- a/packages/elm_ui/test/elm_ui/examples_test.exs +++ b/packages/elm_ui/test/elm_ui/examples_test.exs @@ -6,6 +6,7 @@ defmodule ElmUi.ExamplesTest do :advanced_continuity, :canonical_advanced, :canonical_foundational, + :canonical_navigation, :canonical_styling, :canonical_transport, :canonical_welcome, @@ -14,8 +15,10 @@ defmodule ElmUi.ExamplesTest do :native_advanced, :native_counter, :native_foundational, + :native_navigation, :native_styling, :native_transport, + :navigation_continuity, :styling_continuity ] = ElmUi.Examples.catalog() @@ -28,6 +31,7 @@ defmodule ElmUi.ExamplesTest do :native_advanced, :native_counter, :native_foundational, + :native_navigation, :native_styling, :native_transport ] @@ -35,6 +39,7 @@ defmodule ElmUi.ExamplesTest do assert Enum.sort(Enum.map(ElmUi.Examples.canonical_examples(), & &1.id)) == [ :canonical_advanced, :canonical_foundational, + :canonical_navigation, :canonical_styling, :canonical_transport, :canonical_welcome @@ -44,6 +49,7 @@ defmodule ElmUi.ExamplesTest do :advanced_continuity, :foundational_continuity, :mixed_transport, + :navigation_continuity, :styling_continuity ] @@ -76,6 +82,7 @@ defmodule ElmUi.ExamplesTest do :advanced_continuity, :foundational_continuity, :mixed_transport, + :navigation_continuity, :styling_continuity ] @@ -83,6 +90,7 @@ defmodule ElmUi.ExamplesTest do :native_advanced, :native_counter, :native_foundational, + :native_navigation, :native_styling, :native_transport ] @@ -90,6 +98,7 @@ defmodule ElmUi.ExamplesTest do assert Enum.sort(canonical_ids) == [ :canonical_advanced, :canonical_foundational, + :canonical_navigation, :canonical_styling, :canonical_transport, :canonical_welcome @@ -174,6 +183,29 @@ defmodule ElmUi.ExamplesTest do assert comparison.continuity.server_authority_preserved? end + test "navigation comparison keeps authoritative server transitions and frontend coordination aligned" do + comparison = ElmUi.Examples.navigation_comparison() + + 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_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_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_replacement_target? + assert comparison.continuity.host_route_externalized? + assert comparison.continuity.server_authority_preserved? + + assert comparison.host_route_fixture.host_application.phoenix_route.path == + "/workspace/settings" + end + test "styling comparison exposes side-by-side resolved style and browser realization artifacts" do comparison = ElmUi.Examples.styling_comparison() diff --git a/packages/elm_ui/test/elm_ui/phase_six_integration_test.exs b/packages/elm_ui/test/elm_ui/phase_six_integration_test.exs index 55060d00..53507d06 100644 --- a/packages/elm_ui/test/elm_ui/phase_six_integration_test.exs +++ b/packages/elm_ui/test/elm_ui/phase_six_integration_test.exs @@ -6,6 +6,7 @@ defmodule ElmUi.PhaseSixIntegrationTest do :native_advanced, :native_counter, :native_foundational, + :native_navigation, :native_styling, :native_transport ] @@ -13,6 +14,7 @@ defmodule ElmUi.PhaseSixIntegrationTest do assert Enum.sort(Enum.map(ElmUi.Examples.canonical_examples(), & &1.id)) == [ :canonical_advanced, :canonical_foundational, + :canonical_navigation, :canonical_styling, :canonical_transport, :canonical_welcome @@ -22,6 +24,7 @@ defmodule ElmUi.PhaseSixIntegrationTest do :advanced_continuity, :foundational_continuity, :mixed_transport, + :navigation_continuity, :styling_continuity ] diff --git a/packages/live_ui/lib/live_ui/examples.ex b/packages/live_ui/lib/live_ui/examples.ex index a2ec845f..f753f024 100644 --- a/packages/live_ui/lib/live_ui/examples.ex +++ b/packages/live_ui/lib/live_ui/examples.ex @@ -25,7 +25,8 @@ defmodule LiveUi.Examples do @mixed_examples [ LiveUi.Examples.MixedBoundaryTransport, - LiveUi.Examples.StyledContinuityComparison + LiveUi.Examples.StyledContinuityComparison, + LiveUi.Examples.WebNavigationTransitionComparison ] @spec native_examples() :: [module()] 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 new file mode 100644 index 00000000..7502d6c6 --- /dev/null +++ b/packages/live_ui/lib/live_ui/examples/web_navigation_transition_comparison.ex @@ -0,0 +1,276 @@ +defmodule LiveUi.Examples.WebNavigationTransitionComparison do + @moduledoc """ + Maintained mixed-flow example comparing server-authoritative web navigation + transitions across direct-native and canonical `live_ui` runtime paths. + """ + + alias UnifiedIUR.Element + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport + + defmodule HomeScreen do + use LiveUi.Screen, id: :home, title: "Home" + + @impl true + def mount_defaults do + %{shared: "preserved", section: "Home"} + end + + @impl true + def render(assigns) do + ~H""" + +
<%= @section %>
+
+ """ + end + end + + defmodule SettingsScreen do + use LiveUi.Screen, id: :settings, title: "Settings" + + @impl true + def mount_defaults do + %{section: "Settings"} + end + + @impl true + def render(assigns) do + ~H""" + +
<%= @section %>
+
+ """ + end + end + + def compare do + with {:ok, native} <- native_flow(), + {:ok, canonical} <- canonical_flow() do + {:ok, + %{ + native: native, + canonical: canonical, + host_route_fixture: host_route_fixture(), + continuity: continuity(native, canonical) + }} + end + end + + def metadata do + %{ + id: :web_navigation_transition_compare, + title: "Web Navigation Transition Comparison", + families: [:navigation, :comparison, :transport], + comparable_to: [:native_navigation, :canonical_navigation], + summary: + "Mixed example comparing server-authoritative screen, replacement, modal, and host-route seams." + } + end + + defp native_flow do + with {:ok, runtime_state} <- + LiveUi.Runtime.mount(HomeScreen, + screen_registry: screen_registry(:native), + host_route_resolver: &resolve_host_route/2 + ), + {:ok, after_navigate, navigate_translation} <- + LiveUi.Runtime.dispatch_native_event( + runtime_state, + "navigation:open_settings_screen", + %{}, + family: :navigation, + intent: :open_settings_screen, + target: navigation_target(:navigate_to, screen: :settings, params: %{tab: :profile}) + ), + {:ok, with_modal, modal_translation} <- + LiveUi.Runtime.dispatch_native_event( + after_navigate, + "navigation:open_settings_dialog", + %{}, + family: :navigation, + intent: :open_settings_dialog, + target: + navigation_target(:open_modal, + modal: :settings_dialog, + params: %{origin: :toolbar}, + metadata: %{host_overlay: :dialog} + ) + ), + {:ok, after_replace, replace_translation} <- + LiveUi.Runtime.dispatch_native_event( + with_modal, + "navigation:replace_home_screen", + %{}, + family: :navigation, + intent: :replace_home_screen, + target: navigation_target(:replace_with, screen: :home, params: %{returning: true}) + ) do + {:ok, + %{ + after_navigate: runtime_snapshot(after_navigate), + after_modal: runtime_snapshot(with_modal), + after_replace: runtime_snapshot(after_replace), + runtime_events: [ + navigate_translation.runtime_event, + modal_translation.runtime_event, + replace_translation.runtime_event + ], + transition_targets: %{ + navigate: navigate_translation.target.navigation, + modal: modal_translation.target.navigation, + replace: replace_translation.target.navigation + } + }} + end + end + + defp canonical_flow do + home_element = home_element() + + screen_transition = BoundaryTransport.boundary_fixture!("screen_transition--settings_profile") + modal_transition = BoundaryTransport.boundary_fixture!("modal_transition--settings_dialog") + replace_transition = BoundaryTransport.boundary_fixture!("replace_transition--home") + + with {:ok, runtime_state} <- + LiveUi.Runtime.mount_iur(home_element, + screen_id: :home, + screen_registry: screen_registry(:canonical), + host_route_resolver: &resolve_host_route/2 + ), + {:ok, navigate_signal} <- boundary_signal(screen_transition, :home), + {:ok, after_navigate, navigate_runtime_action} <- + LiveUi.Runtime.handle_boundary_signal(runtime_state, navigate_signal), + {:ok, modal_signal} <- boundary_signal(modal_transition, :settings), + {:ok, with_modal, modal_runtime_action} <- + LiveUi.Runtime.handle_boundary_signal(after_navigate, modal_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 + {:ok, + %{ + after_navigate: runtime_snapshot(after_navigate), + after_modal: runtime_snapshot(with_modal), + after_replace: runtime_snapshot(after_replace), + runtime_events: [ + navigate_runtime_action.runtime_event, + modal_runtime_action.runtime_event, + replace_runtime_action.runtime_event + ], + transition_targets: %{ + navigate: navigate_runtime_action.target.navigation, + modal: modal_runtime_action.target.navigation, + replace: replace_runtime_action.target.navigation + } + }} + end + end + + defp host_route_fixture do + %{ + canonical_target: %{action: :navigate_to, screen: :settings, params: %{tab: :profile}}, + host_application: %{ + live_view_route: %{path: "/workspace/settings", params: %{tab: :profile}}, + note: + "Host route lookup stays in the application resolver; the canonical target remains screen-based." + } + } + end + + defp continuity(native, canonical) do + %{ + same_navigation_target?: + native.after_navigate.screen_id == :settings and + canonical.after_navigate.screen_id == :settings, + same_modal_identifier?: + get_in(native, [:after_modal, :current_modal, :modal]) == + get_in(canonical, [:after_modal, :current_modal, :modal]), + same_replacement_target?: + native.after_replace.screen_id == :home and canonical.after_replace.screen_id == :home, + replacement_clears_modal?: + is_nil(native.after_replace.current_modal) and + is_nil(canonical.after_replace.current_modal), + history_meaning_preserved?: + Enum.map(native.after_replace.history, & &1.screen_id) == + Enum.map(canonical.after_replace.history, & &1.screen_id), + host_route_externalized?: + is_nil(get_in(host_route_fixture(), [:canonical_target, :route])) and + native.after_navigate.host_route == canonical.after_navigate.host_route, + server_authoritative?: + Enum.all?( + [ + native.after_navigate.server_authoritative?, + native.after_modal.server_authoritative?, + native.after_replace.server_authoritative?, + canonical.after_navigate.server_authoritative?, + canonical.after_modal.server_authoritative?, + canonical.after_replace.server_authoritative? + ], + & &1 + ) + } + end + + defp boundary_signal(fixture, screen) do + with {:ok, translation} <- + LiveUi.Signals.from_interaction( + fixture.interaction, + screen: screen, + mode: :screen, + boundary: :boundary, + payload: fixture.signal_data + ) do + {:ok, translation.signal} + end + end + + defp home_element do + Element.new(:widget, :text, id: :home, attributes: %{content: "Home"}) + end + + defp screen_registry(:native) do + %{home: HomeScreen, settings: SettingsScreen} + end + + defp screen_registry(:canonical) do + %{home: home_element(), settings: SettingsScreen} + end + + defp resolve_host_route(descriptor, _state) do + screen = Map.get(descriptor, :screen) + + case screen do + screen when screen in [:home, :settings] -> + {:ok, + %{ + path: "/workspace/#{screen}", + params: normalize_map(Map.get(descriptor, :params, %{})) + }} + + _other -> + {:ok, nil} + end + end + + defp navigation_target(action, attrs) do + %{navigation: attrs |> Enum.into(%{}) |> Map.put(:action, action)} + end + + defp runtime_snapshot(state) do + %{ + screen_id: state.assigns.current_screen_id, + title: state.assigns.current_screen_title, + mode: state.mode, + shared_assign: Map.get(state.assigns, :shared), + navigation_action: state.assigns.navigation_action, + params: state.assigns.navigation_params, + history: state.assigns.navigation_history, + forward: state.assigns.navigation_forward, + current_modal: state.assigns.current_modal, + host_route: state.assigns.navigation_host_route, + server_authoritative?: LiveUi.Runtime.assumptions().server_authoritative? + } + end + + defp normalize_map(map) when is_map(map), do: Map.new(map) + defp normalize_map(_other), do: %{} +end diff --git a/packages/live_ui/test/live_ui/examples_test.exs b/packages/live_ui/test/live_ui/examples_test.exs index de27957f..e45fc021 100644 --- a/packages/live_ui/test/live_ui/examples_test.exs +++ b/packages/live_ui/test/live_ui/examples_test.exs @@ -53,6 +53,11 @@ defmodule LiveUi.ExamplesTest do assert Enum.any?(catalog, &(&1.id == :boundary_transport_compare and &1.path == :mixed)) assert Enum.any?(catalog, &(&1.id == :styled_continuity_compare and &1.path == :mixed)) + + assert Enum.any?( + catalog, + &(&1.id == :web_navigation_transition_compare and &1.path == :mixed) + ) end test "native and canonical examples render through their intended package paths" do @@ -103,6 +108,7 @@ defmodule LiveUi.ExamplesTest do assert :native_styled_profile in example_ids assert :canonical_styled_operations in example_ids assert :styled_continuity_compare in example_ids + assert :web_navigation_transition_compare in example_ids end test "example catalog provides stable preview and review metadata" do @@ -127,6 +133,7 @@ defmodule LiveUi.ExamplesTest do assert Enum.any?(grouped.native, &(&1.id == :native_display)) assert Enum.any?(grouped.canonical, &(&1.id == :canonical_display)) assert Enum.any?(grouped.mixed, &(&1.id == :styled_continuity_compare)) + assert Enum.any?(grouped.mixed, &(&1.id == :web_navigation_transition_compare)) end test "styled continuity examples keep native and canonical paths aligned" do @@ -153,4 +160,23 @@ defmodule LiveUi.ExamplesTest do assert continuity.boundary.runtime_action.runtime_event == "rename" end + + test "web navigation transition example keeps screen, modal, replacement, and host-route meaning aligned" do + assert {:ok, comparison} = LiveUi.Examples.WebNavigationTransitionComparison.compare() + + assert comparison.native.after_navigate.screen_id == :settings + assert comparison.native.after_modal.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_replace.mode == :canonical + assert comparison.continuity.same_navigation_target? + assert comparison.continuity.same_modal_identifier? + assert comparison.continuity.same_replacement_target? + assert comparison.continuity.replacement_clears_modal? + assert comparison.continuity.host_route_externalized? + assert comparison.continuity.server_authoritative? + + assert comparison.host_route_fixture.host_application.live_view_route.path == + "/workspace/settings" + end end From 7bcaeab5cd37a0cdb49c7af05ab01b3d5a4506f4 Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Sat, 25 Apr 2026 16:24:29 -0400 Subject: [PATCH 4/4] Implement canonical navigation phase 3 section 3.4 --- packages/elm_ui/lib/elm_ui/examples.ex | 16 ++- ...avigation_web_runtime_integration_test.exs | 135 ++++++++++++++++++ .../web_navigation_transition_comparison.ex | 12 +- ...avigation_web_runtime_integration_test.exs | 104 ++++++++++++++ 4 files changed, 257 insertions(+), 10 deletions(-) create mode 100644 packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs create mode 100644 packages/live_ui/test/live_ui/canonical_navigation_web_runtime_integration_test.exs diff --git a/packages/elm_ui/lib/elm_ui/examples.ex b/packages/elm_ui/lib/elm_ui/examples.ex index f3275896..f70832e7 100644 --- a/packages/elm_ui/lib/elm_ui/examples.ex +++ b/packages/elm_ui/lib/elm_ui/examples.ex @@ -1320,14 +1320,14 @@ defmodule ElmUi.Examples do {:ok, frontend_after_modal_dispatch, modal_event_message} = ElmUi.FrontendRuntime.dispatch_interaction(frontend_after_navigate, family: :navigation, - intent: :open_settings_dialog, + intent: :open_settings_modal, boundary: :boundary, widget_id: "dialog-link", target: navigation_target(:open_modal, modal: :settings_dialog, - params: %{origin: :toolbar}, - metadata: %{host_overlay: :dialog} + params: %{mode: :advanced}, + metadata: %{surface: :workspace} ) ) @@ -1343,8 +1343,12 @@ defmodule ElmUi.Examples do intent: :replace_home_screen, boundary: :boundary, widget_id: "settings-link", - target: navigation_target(:replace_with, screen: :home, params: %{returning: true}), - route_state: navigation_route_state("home", %{returning: true}) + target: + navigation_target(:replace_with, + screen: :home, + params: %{source: :command_palette} + ), + route_state: navigation_route_state("home", %{source: :command_palette}) ) {:ok, state_after_replace, replace_ack} = @@ -1369,7 +1373,7 @@ defmodule ElmUi.Examples do state_after_replace, frontend_after_replace, replace_ack, - navigation_route_state("home", %{returning: true}) + navigation_route_state("home", %{source: :command_palette}) ) } end 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 new file mode 100644 index 00000000..d54e1f94 --- /dev/null +++ b/packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs @@ -0,0 +1,135 @@ +defmodule ElmUi.CanonicalNavigationWebRuntimeIntegrationTest do + use ExUnit.Case, async: true + + alias UnifiedIUR.Element + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport + + test "maintained navigation example keeps authoritative server and frontend screens aligned across native and canonical flows" 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") + + comparison = ElmUi.Examples.navigation_comparison() + + assert transition_summary(comparison.native.after_navigate.navigation.last_transition) == + transition_summary(navigate_fixture.descriptor.target.navigation) + + assert transition_summary(comparison.canonical.after_navigate.navigation.last_transition) == + transition_summary(navigate_fixture.descriptor.target.navigation) + + assert transition_summary(comparison.native.after_replace.navigation.last_transition) == + transition_summary(replace_fixture.descriptor.target.navigation) + + assert transition_summary(comparison.canonical.after_replace.navigation.last_transition) == + transition_summary(replace_fixture.descriptor.target.navigation) + + assert modal_summary(comparison.native.after_modal.navigation.current_modal) == + modal_summary(modal_fixture.descriptor.target.navigation) + + assert modal_summary(comparison.canonical.after_modal.navigation.current_modal) == + modal_summary(modal_fixture.descriptor.target.navigation) + + assert comparison.continuity.same_navigation_target? + assert comparison.continuity.frontend_coordination? + assert comparison.continuity.same_modal_identifier? + assert comparison.continuity.same_replacement_target? + assert comparison.continuity.server_authority_preserved? + end + + test "canonical elm_ui navigation keeps host-route state outside the transition contract" do + comparison = ElmUi.Examples.navigation_comparison() + + refute Map.has_key?(comparison.host_route_fixture.canonical_target, :route) + refute Map.has_key?(comparison.host_route_fixture.canonical_target, :path) + + assert comparison.native.after_navigate.authoritative_host_route.path == "/workspace/settings" + + assert comparison.canonical.after_navigate.authoritative_host_route.path == + "/workspace/settings" + + assert comparison.continuity.host_route_externalized? + + assert {:ok, translation} = + ElmUi.Transport.from_native_event( + family: :navigation, + intent: :open_settings_screen, + widget_id: "settings-link", + screen: "home", + runtime_id: "contract-check", + source_kind: :canonical, + boundary_mode: :canonical_boundary, + target: %{ + navigation: %{action: :navigate_to, screen: :settings, params: %{tab: :profile}} + } + ) + + assert transition_summary(translation.target.navigation) == + transition_summary(comparison.host_route_fixture.canonical_target) + end + + test "frontend route divergence is reported deterministically when browser-local state disagrees" do + home_element = Element.new(:widget, :text, id: "home", attributes: %{content: "Home"}) + + settings_element = + Element.new(:widget, :text, id: "settings", attributes: %{content: "Settings"}) + + assert {:ok, runtime_state} = + ElmUi.Runtime.mount_iur_screen(home_element, + runtime_id: "canonical-navigation-divergence", + screen_registry: %{settings: settings_element} + ) + + assert {:ok, frontend_model} = ElmUi.Runtime.hydrate_frontend(runtime_state) + + assert {:ok, frontend_after_dispatch, event_message} = + ElmUi.FrontendRuntime.dispatch_interaction(frontend_model, + family: :navigation, + intent: :open_settings_screen, + boundary: :boundary, + widget_id: "settings-link", + target: %{ + navigation: %{action: :navigate_to, screen: :settings, params: %{tab: :profile}} + }, + route_state: %{screen_id: "wrong-screen", path: "/workspace/wrong-screen"} + ) + + assert frontend_after_dispatch.local_state.pending_boundary_event.runtime_event == + "navigation:open_settings_screen" + + assert {:ok, next_state, ack_message} = + ElmUi.Runtime.handle_frontend_event(runtime_state, event_message) + + assert Enum.any?(next_state.diagnostics, fn diagnostic -> + diagnostic.reason == :frontend_route_state_divergence and + to_string(diagnostic.authoritative_screen) == "settings" + end) + + assert Enum.any?(ack_message.payload.diagnostics, fn diagnostic -> + diagnostic.reason == :frontend_route_state_divergence and + to_string(diagnostic.authoritative_screen) == "settings" + end) + end + + defp transition_summary(target) do + %{ + action: get_value(target, :action), + screen: get_value(target, :screen), + params: normalize_map(get_value(target, :params, %{})) + } + end + + defp modal_summary(target) do + %{ + modal: get_value(target, :modal), + params: normalize_map(get_value(target, :params, %{})), + metadata: normalize_map(get_value(target, :metadata, %{})) + } + 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 + + defp normalize_map(map) when is_map(map), do: Map.new(map) + defp normalize_map(_other), do: %{} +end 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 7502d6c6..e9c521d1 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 @@ -88,12 +88,12 @@ defmodule LiveUi.Examples.WebNavigationTransitionComparison do "navigation:open_settings_dialog", %{}, family: :navigation, - intent: :open_settings_dialog, + intent: :open_settings_modal, target: navigation_target(:open_modal, modal: :settings_dialog, - params: %{origin: :toolbar}, - metadata: %{host_overlay: :dialog} + params: %{mode: :advanced}, + metadata: %{surface: :workspace} ) ), {:ok, after_replace, replace_translation} <- @@ -103,7 +103,11 @@ defmodule LiveUi.Examples.WebNavigationTransitionComparison do %{}, family: :navigation, intent: :replace_home_screen, - target: navigation_target(:replace_with, screen: :home, params: %{returning: true}) + target: + navigation_target(:replace_with, + screen: :home, + params: %{source: :command_palette} + ) ) do {:ok, %{ 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 new file mode 100644 index 00000000..51f79e7c --- /dev/null +++ b/packages/live_ui/test/live_ui/canonical_navigation_web_runtime_integration_test.exs @@ -0,0 +1,104 @@ +defmodule LiveUi.CanonicalNavigationWebRuntimeIntegrationTest do + use ExUnit.Case, async: true + + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport + alias LiveUi.Examples.WebNavigationTransitionComparison + alias LiveUi.Runtime + alias LiveUi.Runtime.Error + + test "maintained web navigation example preserves canonical transition meaning across native and canonical runtime paths" 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") + + assert {:ok, comparison} = WebNavigationTransitionComparison.compare() + + assert transition_summary(comparison.native.transition_targets.navigate) == + transition_summary(navigate_fixture.descriptor.target.navigation) + + assert transition_summary(comparison.canonical.transition_targets.navigate) == + transition_summary(navigate_fixture.descriptor.target.navigation) + + assert transition_summary(comparison.native.transition_targets.replace) == + transition_summary(replace_fixture.descriptor.target.navigation) + + assert transition_summary(comparison.canonical.transition_targets.replace) == + transition_summary(replace_fixture.descriptor.target.navigation) + + assert modal_summary(comparison.native.transition_targets.modal) == + modal_summary(modal_fixture.descriptor.target.navigation) + + assert modal_summary(comparison.canonical.transition_targets.modal) == + modal_summary(modal_fixture.descriptor.target.navigation) + + assert comparison.continuity.same_navigation_target? + assert comparison.continuity.same_modal_identifier? + assert comparison.continuity.same_replacement_target? + assert comparison.continuity.history_meaning_preserved? + assert comparison.continuity.server_authoritative? + end + + test "host route integration remains optional and external to the canonical live_ui navigation contract" do + assert {:ok, comparison} = WebNavigationTransitionComparison.compare() + + refute Map.has_key?(comparison.host_route_fixture.canonical_target, :route) + refute Map.has_key?(comparison.host_route_fixture.canonical_target, :path) + + assert comparison.host_route_fixture.host_application.live_view_route.path == + "/workspace/settings" + + assert comparison.native.after_navigate.host_route.path == "/workspace/settings" + assert comparison.canonical.after_navigate.host_route.path == "/workspace/settings" + assert comparison.continuity.host_route_externalized? + end + + test "invalid symbolic targets and leaked host-route syntax fail with actionable diagnostics" do + assert {:ok, runtime_state} = Runtime.mount(WebNavigationTransitionComparison.HomeScreen) + + assert {:error, %Error{reason: :unresolved_navigation_target}} = + Runtime.dispatch_native_event( + runtime_state, + "navigation:missing_screen", + %{}, + family: :navigation, + intent: :open_missing_screen, + target: %{navigation: %{action: :navigate_to, screen: :missing}} + ) + + assert {:error, %Error{reason: :host_route_navigation_syntax}} = + Runtime.dispatch_native_event( + runtime_state, + "navigation:route_syntax", + %{}, + family: :navigation, + intent: :open_settings_screen, + target: %{ + navigation: %{action: :navigate_to, screen: :settings, route: "/settings"} + } + ) + end + + defp transition_summary(target) do + %{ + action: get_value(target, :action), + screen: get_value(target, :screen), + params: normalize_map(get_value(target, :params, %{})) + } + end + + defp modal_summary(target) do + %{ + action: get_value(target, :action), + modal: get_value(target, :modal), + params: normalize_map(get_value(target, :params, %{})), + metadata: normalize_map(get_value(target, :metadata, %{})) + } + 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 + + defp normalize_map(map) when is_map(map), do: Map.new(map) + defp normalize_map(_other), do: %{} +end