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/.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/elm_ui/lib/elm_ui/examples.ex b/packages/elm_ui/lib/elm_ui/examples.ex
index edbf51b5..f70832e7 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,224 @@ 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_modal,
+ boundary: :boundary,
+ widget_id: "dialog-link",
+ target:
+ navigation_target(:open_modal,
+ modal: :settings_dialog,
+ params: %{mode: :advanced},
+ metadata: %{surface: :workspace}
+ )
+ )
+
+ {: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: %{source: :command_palette}
+ ),
+ route_state: navigation_route_state("home", %{source: :command_palette})
+ )
+
+ {: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", %{source: :command_palette})
+ )
+ }
+ 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 +1606,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 +1669,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 +1705,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 +1743,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/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/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/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/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/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/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(
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..e9c521d1
--- /dev/null
+++ b/packages/live_ui/lib/live_ui/examples/web_navigation_transition_comparison.ex
@@ -0,0 +1,280 @@
+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"""
+