diff --git a/.spec/conformance/desktop_ui/manifest.json b/.spec/conformance/desktop_ui/manifest.json index 37ab5d7e..46edf006 100644 --- a/.spec/conformance/desktop_ui/manifest.json +++ b/.spec/conformance/desktop_ui/manifest.json @@ -33,6 +33,7 @@ {"requirement_id":"desktop_ui.structure.transport_translation_modules","status":"verified","notes":"Verified by dedicated transport translation and diagnostics modules.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/transport.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/transport/normalize.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/transport/signal.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/transport/diagnostics.ex"}]}, {"requirement_id":"desktop_ui.structure.platform_artifact_modules","status":"verified","notes":"Verified by dedicated artifact policy, build staging, and packaging modules.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/artifacts.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/build.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/package.ex"}]}, {"requirement_id":"desktop_ui.structure.no_dsl_or_iur_authorship","status":"verified","notes":"Verified as a maintained package boundary that does not author UnifiedUi or UnifiedIUR.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui.ex"},{"kind":"command","run":"! grep -R -nE \"defmodule (UnifiedUi|UnifiedIUR)\" packages/desktop_ui/lib","cwd":"."}]}, + {"requirement_id":"desktop_ui.structure.navigation_modules","status":"verified","notes":"Verified by dedicated navigation controller, registry, signal, and runtime integration modules plus focused navigation tests.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/navigation/controller.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/navigation/registry.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/navigation/signal.ex"},{"kind":"command","run":"mix deps.get && mix test test/desktop_ui/navigation/controller_test.exs test/desktop_ui/navigation/registry_test.exs test/desktop_ui/navigation/signal_test.exs test/desktop_ui/navigation/integration_test.exs test/desktop_ui/canonical_navigation_transport_integration_test.exs test/desktop_ui/canonical_navigation_desktop_runtime_integration_test.exs test/desktop_ui/canonical_navigation_non_web_examples_integration_test.exs","cwd":"packages/desktop_ui"}]}, {"requirement_id":"desktop_ui.native_widgets.direct_native_surface","status":"verified","notes":"Verified by the direct-native widget API and widget test coverage.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/widget.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/widgets.ex"},{"kind":"path_exists","path":"packages/desktop_ui/test/desktop_ui/widgets_test.exs"},{"kind":"command","run":"mix deps.get && mix test","cwd":"packages/desktop_ui"}]}, {"requirement_id":"desktop_ui.native_widgets.covers_canonical_iur_surface","status":"verified","notes":"Verified by the widget families, renderer mapping layer, and renderer tests.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/widgets.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex"},{"kind":"path_exists","path":"packages/desktop_ui/test/desktop_ui/renderer_test.exs"},{"kind":"command","run":"mix deps.get && mix test","cwd":"packages/desktop_ui"}]}, {"requirement_id":"desktop_ui.native_widgets.multiplatform_widget_meaning","status":"verified","notes":"Verified by shared examples, platform diagnostics, and package tests across targets.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/examples.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/platform/integration.ex"},{"kind":"path_exists","path":"packages/desktop_ui/test/desktop_ui/platform_test.exs"},{"kind":"command","run":"mix deps.get && mix test","cwd":"packages/desktop_ui"}]}, @@ -56,6 +57,12 @@ {"requirement_id":"desktop_ui.runtime.window_lifecycle_and_input","status":"verified","notes":"Verified by window lifecycle, event-loop, and event-routing runtime modules.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/runtime/window.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/runtime/event_loop.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/runtime/event_router.ex"},{"kind":"command","run":"mix deps.get && mix test","cwd":"packages/desktop_ui"}]}, {"requirement_id":"desktop_ui.runtime.interactive_visible_execution","status":"verified","notes":"Verified by compiled-host hit-testing, focus, scrolling, overlay transitions, and multiwindow interaction roundtrip coverage.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/sdl3/interaction_script.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/sdl3/visible_runner.ex"},{"kind":"path_exists","path":"packages/desktop_ui/native/desktop_ui_sdl3_host/src/main.c"},{"kind":"path_exists","path":"packages/desktop_ui/test/desktop_ui/sdl3_interaction_script_test.exs"},{"kind":"path_exists","path":"packages/desktop_ui/test/desktop_ui/sdl3_visible_runner_test.exs"},{"kind":"path_exists","path":"packages/desktop_ui/test/desktop_ui/sdl3_interactive_native_behavior_test.exs"},{"kind":"command","run":"mix deps.get && mix test test/desktop_ui/sdl3_interaction_script_test.exs test/desktop_ui/sdl3_visible_runner_test.exs test/desktop_ui/sdl3_interactive_native_behavior_test.exs","cwd":"packages/desktop_ui"}]}, {"requirement_id":"desktop_ui.runtime.platform_variation_bounded","status":"verified","notes":"Verified by explicit platform integration diagnostics and strict package validation.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/platform/integration.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/transport/normalize.ex"},{"kind":"command","run":"mix desktop_ui.validate --strict","cwd":"packages/desktop_ui"}]}, + {"requirement_id":"desktop_ui.runtime.screen_navigation_support","status":"verified","notes":"Verified by the shared desktop runtime navigation layer and focused canonical navigation integration coverage.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/runtime/navigation.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/navigation.ex"},{"kind":"command","run":"mix deps.get && mix test test/desktop_ui/navigation/controller_test.exs test/desktop_ui/navigation/registry_test.exs test/desktop_ui/navigation/signal_test.exs test/desktop_ui/navigation/integration_test.exs test/desktop_ui/canonical_navigation_transport_integration_test.exs test/desktop_ui/canonical_navigation_desktop_runtime_integration_test.exs test/desktop_ui/canonical_navigation_non_web_examples_integration_test.exs","cwd":"packages/desktop_ui"}]}, + {"requirement_id":"desktop_ui.runtime.navigation_controller_process","status":"verified","notes":"Verified by the navigation controller GenServer and focused controller lifecycle tests.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/navigation/controller.ex"},{"kind":"path_exists","path":"packages/desktop_ui/test/desktop_ui/navigation/controller_test.exs"},{"kind":"command","run":"mix deps.get && mix test test/desktop_ui/navigation/controller_test.exs test/desktop_ui/navigation/lifecycle_test.exs","cwd":"packages/desktop_ui"}]}, + {"requirement_id":"desktop_ui.runtime.screen_registry","status":"verified","notes":"Verified by the screen registry modules and registry resolution coverage.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/navigation/registry.ex"},{"kind":"path_exists","path":"packages/desktop_ui/test/desktop_ui/navigation/registry_test.exs"},{"kind":"command","run":"mix deps.get && mix test test/desktop_ui/navigation/registry_test.exs test/desktop_ui/navigation/integration_test.exs","cwd":"packages/desktop_ui"}]}, + {"requirement_id":"desktop_ui.runtime.navigation_actions","status":"verified","notes":"Verified by the canonical navigation action model and desktop runtime action coverage for navigate, replace, history, and modal transitions.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/navigation.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/navigation/signal.ex"},{"kind":"command","run":"mix deps.get && mix test test/desktop_ui/navigation/controller_test.exs test/desktop_ui/navigation/signal_test.exs test/desktop_ui/canonical_navigation_desktop_runtime_integration_test.exs","cwd":"packages/desktop_ui"}]}, + {"requirement_id":"desktop_ui.runtime.navigation_event_routing","status":"verified","notes":"Verified by transport and runtime routing that carry canonical navigation transitions into the desktop runtime.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/transport.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/runtime/navigation.ex"},{"kind":"command","run":"mix deps.get && mix test test/desktop_ui/canonical_navigation_transport_integration_test.exs test/desktop_ui/canonical_navigation_desktop_runtime_integration_test.exs","cwd":"packages/desktop_ui"}]}, + {"requirement_id":"desktop_ui.runtime.modal_stack_independence","status":"verified","notes":"Verified by navigation state and runtime integration coverage that keeps modal transitions independent from primary screen history.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/navigation/state.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/navigation/controller.ex"},{"kind":"command","run":"mix deps.get && mix test test/desktop_ui/navigation/state_test.exs test/desktop_ui/navigation/integration_test.exs test/desktop_ui/canonical_navigation_desktop_runtime_integration_test.exs","cwd":"packages/desktop_ui"}]}, {"requirement_id":"desktop_ui.iur_renderer.accepts_canonical_iur","status":"verified","notes":"Verified by the canonical renderer entrypoints and renderer tests.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/renderer.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex"},{"kind":"command","run":"mix deps.get && mix test","cwd":"packages/desktop_ui"}]}, {"requirement_id":"desktop_ui.iur_renderer.full_construct_coverage","status":"verified","notes":"Verified by canonical mapping coverage across widgets, layout, and layering surfaces.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/layout.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/layer.ex"},{"kind":"command","run":"mix deps.get && mix test","cwd":"packages/desktop_ui"}]}, {"requirement_id":"desktop_ui.iur_renderer.deterministic_mapping","status":"verified","notes":"Verified by the canonical renderer mapping implementation and package-local tests.","evidence":[{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/renderer.ex"},{"kind":"path_exists","path":"packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex"},{"kind":"command","run":"mix deps.get && mix test","cwd":"packages/desktop_ui"}]}, diff --git a/.spec/conformance/elm_ui/manifest.json b/.spec/conformance/elm_ui/manifest.json index 90461464..a4b4a172 100644 --- a/.spec/conformance/elm_ui/manifest.json +++ b/.spec/conformance/elm_ui/manifest.json @@ -518,6 +518,46 @@ } ] }, + { + "requirement_id": "elm_ui.server_runtime.canonical_navigation_transition_mapping", + "status": "verified", + "notes": "Verified by the server-side navigation mapper and focused canonical web-runtime navigation coverage.", + "evidence": [ + { + "kind": "path_exists", + "path": "packages/elm_ui/lib/elm_ui/server_runtime/navigation.ex" + }, + { + "kind": "path_exists", + "path": "packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs" + }, + { + "kind": "command", + "run": "mix deps.get && mix test test/elm_ui/runtime_navigation_test.exs test/elm_ui/canonical_navigation_web_runtime_integration_test.exs", + "cwd": "packages/elm_ui" + } + ] + }, + { + "requirement_id": "elm_ui.server_runtime.host_route_resolution_boundary", + "status": "verified", + "notes": "Verified by the server-runtime navigation boundary that keeps host route resolution outside the authored contract while preserving host integration options.", + "evidence": [ + { + "kind": "path_exists", + "path": "packages/elm_ui/lib/elm_ui/server_runtime/navigation.ex" + }, + { + "kind": "path_exists", + "path": "packages/elm_ui/test/elm_ui/canonical_navigation_web_runtime_integration_test.exs" + }, + { + "kind": "command", + "run": "mix deps.get && mix test test/elm_ui/runtime_navigation_test.exs test/elm_ui/canonical_navigation_web_runtime_integration_test.exs", + "cwd": "packages/elm_ui" + } + ] + }, { "requirement_id": "elm_ui.frontend_runtime.elm_rendering_layer", "status": "verified", diff --git a/.spec/conformance/live_ui/manifest.json b/.spec/conformance/live_ui/manifest.json index 5531bc9b..d2be05eb 100644 --- a/.spec/conformance/live_ui/manifest.json +++ b/.spec/conformance/live_ui/manifest.json @@ -594,6 +594,50 @@ } ] }, + { + "requirement_id": "live_ui.runtime.canonical_navigation_transition_mapping", + "status": "verified", + "notes": "Verified by the canonical navigation runtime mapper, maintained comparison examples, and focused web-runtime navigation tests.", + "evidence": [ + { + "kind": "path_exists", + "path": "packages/live_ui/lib/live_ui/runtime/navigation.ex" + }, + { + "kind": "path_exists", + "path": "packages/live_ui/lib/live_ui/examples/web_navigation_transition_comparison.ex" + }, + { + "kind": "path_exists", + "path": "packages/live_ui/test/live_ui/canonical_navigation_web_runtime_integration_test.exs" + }, + { + "kind": "command", + "run": "mix deps.get && mix test test/live_ui/runtime_navigation_test.exs test/live_ui/canonical_navigation_web_runtime_integration_test.exs", + "cwd": "packages/live_ui" + } + ] + }, + { + "requirement_id": "live_ui.runtime.host_route_resolution_boundary", + "status": "verified", + "notes": "Verified by the runtime navigation boundary that keeps host route lookup outside the authored contract while still allowing host integration in focused tests.", + "evidence": [ + { + "kind": "path_exists", + "path": "packages/live_ui/lib/live_ui/runtime/navigation.ex" + }, + { + "kind": "path_exists", + "path": "packages/live_ui/test/live_ui/canonical_navigation_web_runtime_integration_test.exs" + }, + { + "kind": "command", + "run": "mix deps.get && mix test test/live_ui/runtime_navigation_test.exs test/live_ui/canonical_navigation_web_runtime_integration_test.exs", + "cwd": "packages/live_ui" + } + ] + }, { "requirement_id": "live_ui.iur_renderer.accepts_canonical_iur", "status": "verified", diff --git a/.spec/conformance/unified_iur/manifest.json b/.spec/conformance/unified_iur/manifest.json index 9bc6b1a9..c8ec2c88 100644 --- a/.spec/conformance/unified_iur/manifest.json +++ b/.spec/conformance/unified_iur/manifest.json @@ -806,6 +806,50 @@ } ] }, + { + "requirement_id": "unified_iur.interactions.navigation_transition_representation", + "status": "verified", + "notes": "Verified by canonical navigation transition descriptor helpers, fixtures, and focused boundary integration coverage.", + "evidence": [ + { + "kind": "path_exists", + "path": "packages/unified_iur/lib/unified_iur/interaction.ex" + }, + { + "kind": "path_exists", + "path": "packages/unified_iur/lib/unified_iur/fixtures.ex" + }, + { + "kind": "path_exists", + "path": "packages/unified_iur/test/unified_iur/canonical_navigation_boundary_integration_test.exs" + }, + { + "kind": "command", + "run": "mix deps.get && mix test test/unified_iur/interaction_test.exs test/unified_iur/canonical_navigation_boundary_integration_test.exs", + "cwd": "packages/unified_iur" + } + ] + }, + { + "requirement_id": "unified_iur.interactions.no_host_router_assumptions", + "status": "verified", + "notes": "Verified by canonical navigation boundary validation that rejects host-router leakage and preserves portable target descriptors.", + "evidence": [ + { + "kind": "path_exists", + "path": "packages/unified_iur/lib/unified_iur/interactions/transport.ex" + }, + { + "kind": "path_exists", + "path": "packages/unified_iur/test/unified_iur/canonical_navigation_boundary_integration_test.exs" + }, + { + "kind": "command", + "run": "mix deps.get && mix test test/unified_iur/interactions_transport_test.exs test/unified_iur/canonical_navigation_boundary_integration_test.exs", + "cwd": "packages/unified_iur" + } + ] + }, { "requirement_id": "unified_iur.interoperability.runtime_library_consumption", "status": "verified", @@ -958,6 +1002,30 @@ "cwd": "." } ] + }, + { + "requirement_id": "unified_iur.tooling.navigation_transition_review_surfaces", + "status": "verified", + "notes": "Verified by maintained navigation fixtures plus inspection and tooling coverage that keep canonical transition descriptors reviewable without a runtime library.", + "evidence": [ + { + "kind": "path_exists", + "path": "packages/unified_iur/lib/unified_iur/fixtures.ex" + }, + { + "kind": "path_exists", + "path": "packages/unified_iur/lib/unified_iur/inspect.ex" + }, + { + "kind": "path_exists", + "path": "packages/unified_iur/lib/unified_iur/tooling.ex" + }, + { + "kind": "command", + "run": "mix deps.get && mix test test/unified_iur/canonical_navigation_boundary_integration_test.exs test/unified_iur/inspect_test.exs test/unified_iur/tooling_test.exs", + "cwd": "packages/unified_iur" + } + ] } ] } diff --git a/.spec/conformance/unified_ui/manifest.json b/.spec/conformance/unified_ui/manifest.json index 09e97df3..f52ebd51 100644 --- a/.spec/conformance/unified_ui/manifest.json +++ b/.spec/conformance/unified_ui/manifest.json @@ -623,6 +623,35 @@ "inherits_from_requirement_id": "unified_ui.tooling.compiler_inspection", "notes": "Shares the current conformance status of unified_ui.tooling.compiler_inspection." }, + { + "requirement_id": "unified_ui.signals.navigation_transition_actions", + "status": "verified", + "notes": "Verified by the canonical navigation authoring surface and focused navigation integration coverage.", + "evidence": [ + { + "kind": "path_exists", + "path": "packages/unified-ui/lib/unified_ui/navigation.ex" + }, + { + "kind": "path_exists", + "path": "packages/unified-ui/lib/unified_ui/dsl/entities/navigation.ex" + }, + { + "kind": "path_exists", + "path": "packages/unified-ui/test/unified_ui/canonical_navigation_integration_test.exs" + }, + { + "kind": "command", + "run": "mix deps.get && mix test test/unified_ui/canonical_navigation_integration_test.exs test/unified_ui/signals_test.exs", + "cwd": "packages/unified-ui" + } + ] + }, + { + "requirement_id": "unified_ui.signals.navigation_symbolic_screen_targets", + "inherits_from_requirement_id": "unified_ui.signals.navigation_transition_actions", + "notes": "Shares the current conformance status of unified_ui.signals.navigation_transition_actions." + }, { "requirement_id": "unified_ui.compiler.canonical_iur_output", "status": "verified", @@ -700,6 +729,30 @@ "inherits_from_requirement_id": "unified_ui.compiler.deterministic_results", "notes": "Shares the current conformance status of unified_ui.compiler.deterministic_results." }, + { + "requirement_id": "unified_ui.compiler.navigation_transition_lowering", + "status": "verified", + "notes": "Verified by the compiler lowering path for canonical navigation descriptors and focused navigation compilation coverage.", + "evidence": [ + { + "kind": "path_exists", + "path": "packages/unified-ui/lib/unified_ui/compiler/pipeline.ex" + }, + { + "kind": "path_exists", + "path": "packages/unified-ui/lib/unified_ui/navigation.ex" + }, + { + "kind": "path_exists", + "path": "packages/unified-ui/test/unified_ui/canonical_navigation_integration_test.exs" + }, + { + "kind": "command", + "run": "mix deps.get && mix test test/unified_ui/canonical_navigation_integration_test.exs test/unified_ui/signals_test.exs", + "cwd": "packages/unified-ui" + } + ] + }, { "requirement_id": "unified_ui.tooling.authoring_validation_workflow", "status": "verified", diff --git a/.spec/planning/desktop_ui/spec-traceability.json b/.spec/planning/desktop_ui/spec-traceability.json index d563890e..bf7d4bce 100644 --- a/.spec/planning/desktop_ui/spec-traceability.json +++ b/.spec/planning/desktop_ui/spec-traceability.json @@ -478,6 +478,19 @@ ], "ownership_note": "Direct desktop_ui boundary obligation." }, + { + "requirement_id": "desktop_ui.structure.navigation_modules", + "scope": "direct", + "source_file": ".spec/specs/desktop_ui/structure.spec.md", + "primary_plan_refs": [ + "12.1.1" + ], + "supporting_plan_refs": [ + "12.2.1", + "12.3.2" + ], + "ownership_note": "Direct navigation-module boundary obligation." + }, { "requirement_id": "desktop_ui.native_widgets.direct_native_surface", "scope": "direct", @@ -818,6 +831,80 @@ ], "ownership_note": "Direct bounded-platform-variation obligation." }, + { + "requirement_id": "desktop_ui.runtime.screen_navigation_support", + "scope": "direct", + "source_file": ".spec/specs/desktop_ui/runtime.spec.md", + "primary_plan_refs": [ + "12.1.1" + ], + "supporting_plan_refs": [ + "12.2.1", + "12.4.2" + ], + "ownership_note": "Direct screen-navigation runtime obligation." + }, + { + "requirement_id": "desktop_ui.runtime.navigation_controller_process", + "scope": "direct", + "source_file": ".spec/specs/desktop_ui/runtime.spec.md", + "primary_plan_refs": [ + "12.1.1" + ], + "supporting_plan_refs": [ + "12.1.2" + ], + "ownership_note": "Direct navigation-controller process obligation." + }, + { + "requirement_id": "desktop_ui.runtime.screen_registry", + "scope": "direct", + "source_file": ".spec/specs/desktop_ui/runtime.spec.md", + "primary_plan_refs": [ + "12.2.1" + ], + "supporting_plan_refs": [ + "12.2.2" + ], + "ownership_note": "Direct screen-registry obligation." + }, + { + "requirement_id": "desktop_ui.runtime.navigation_actions", + "scope": "direct", + "source_file": ".spec/specs/desktop_ui/runtime.spec.md", + "primary_plan_refs": [ + "12.1.2" + ], + "supporting_plan_refs": [ + "12.3.1" + ], + "ownership_note": "Direct navigation-action vocabulary obligation." + }, + { + "requirement_id": "desktop_ui.runtime.navigation_event_routing", + "scope": "direct", + "source_file": ".spec/specs/desktop_ui/runtime.spec.md", + "primary_plan_refs": [ + "12.3.2" + ], + "supporting_plan_refs": [ + "12.4.2" + ], + "ownership_note": "Direct navigation event-routing obligation." + }, + { + "requirement_id": "desktop_ui.runtime.modal_stack_independence", + "scope": "direct", + "source_file": ".spec/specs/desktop_ui/runtime.spec.md", + "primary_plan_refs": [ + "12.1.1" + ], + "supporting_plan_refs": [ + "12.1.2", + "12.4.3" + ], + "ownership_note": "Direct modal-stack independence obligation." + }, { "requirement_id": "desktop_ui.iur_renderer.accepts_canonical_iur", "scope": "direct", diff --git a/.spec/planning/desktop_ui/spec-traceability.md b/.spec/planning/desktop_ui/spec-traceability.md index 307d61f8..b5657201 100644 --- a/.spec/planning/desktop_ui/spec-traceability.md +++ b/.spec/planning/desktop_ui/spec-traceability.md @@ -68,6 +68,7 @@ tasks because they govern other runtime packages: | `desktop_ui.structure.transport_translation_modules` | `1.1.2` | `4.1.1`, `4.2.1` | Direct transport-structure obligation. | | `desktop_ui.structure.platform_artifact_modules` | `5.3.1` | `5.3.2`, `6.3.1` | Direct artifact-module boundary obligation. | | `desktop_ui.structure.no_dsl_or_iur_authorship` | `1.1.2` | `6.4.2` | Direct desktop_ui boundary obligation. | +| `desktop_ui.structure.navigation_modules` | `12.1.1` | `12.2.1`, `12.3.2` | Direct navigation-module boundary obligation. | ### `native_widgets.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | | --- | --- | --- | --- | @@ -85,6 +86,12 @@ tasks because they govern other runtime packages: | `desktop_ui.runtime.window_lifecycle_and_input` | `1.2.2` | `1.5.3.1`, `4.1.2`, `4.2.2`, `5.5.2`, `10.3.1`, `10.3.2` | Direct runtime lifecycle and input obligation. | | `desktop_ui.runtime.interactive_visible_execution` | `10.3.1` | `10.3.2`, `10.4.1`, `10.5.1` | Direct interactive visible-window execution obligation. | | `desktop_ui.runtime.platform_variation_bounded` | `1.3.2` | `5.2.2`, `5.3.2` | Direct bounded-platform-variation obligation. | +| `desktop_ui.runtime.screen_navigation_support` | `12.1.1` | `12.2.1`, `12.4.2` | Direct screen-navigation runtime obligation. | +| `desktop_ui.runtime.navigation_controller_process` | `12.1.1` | `12.1.2` | Direct navigation-controller process obligation. | +| `desktop_ui.runtime.screen_registry` | `12.2.1` | `12.2.2` | Direct screen-registry obligation. | +| `desktop_ui.runtime.navigation_actions` | `12.1.2` | `12.3.1` | Direct navigation-action vocabulary obligation. | +| `desktop_ui.runtime.navigation_event_routing` | `12.3.2` | `12.4.2` | Direct navigation event-routing obligation. | +| `desktop_ui.runtime.modal_stack_independence` | `12.1.1` | `12.1.2`, `12.4.3` | Direct modal-stack independence obligation. | ### `sdl3_runtime_rendering.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | | --- | --- | --- | --- | diff --git a/.spec/planning/elm_ui/spec-traceability.json b/.spec/planning/elm_ui/spec-traceability.json index b8390981..f1345d78 100644 --- a/.spec/planning/elm_ui/spec-traceability.json +++ b/.spec/planning/elm_ui/spec-traceability.json @@ -615,6 +615,32 @@ ], "ownership_note": "Direct server-authority obligation." }, + { + "requirement_id": "elm_ui.server_runtime.canonical_navigation_transition_mapping", + "scope": "direct", + "source_file": ".spec/specs/elm_ui/server_runtime.spec.md", + "primary_plan_refs": [ + "4.1.1" + ], + "supporting_plan_refs": [ + "4.2.1", + "4.5.1" + ], + "ownership_note": "Direct canonical navigation transition-mapping obligation." + }, + { + "requirement_id": "elm_ui.server_runtime.host_route_resolution_boundary", + "scope": "direct", + "source_file": ".spec/specs/elm_ui/server_runtime.spec.md", + "primary_plan_refs": [ + "4.3.1" + ], + "supporting_plan_refs": [ + "4.2.1", + "4.5.2" + ], + "ownership_note": "Direct host-route boundary obligation for canonical navigation." + }, { "requirement_id": "elm_ui.frontend_runtime.elm_rendering_layer", "scope": "direct", diff --git a/.spec/planning/elm_ui/spec-traceability.md b/.spec/planning/elm_ui/spec-traceability.md index a5ef23cb..986d7cf5 100644 --- a/.spec/planning/elm_ui/spec-traceability.md +++ b/.spec/planning/elm_ui/spec-traceability.md @@ -83,6 +83,8 @@ tasks because they govern other runtime packages: | `elm_ui.server_runtime.handle_boundary_events` | `4.2.1` | `4.1.1`, `4.3.1`, `4.5.1` | Direct boundary-event obligation. | | `elm_ui.server_runtime.direct_and_iur_entrypoints_share_runtime` | `1.2.1` | `2.3.2`, `3.3.2`, `4.5.2` | Direct runtime-convergence obligation. | | `elm_ui.server_runtime.browser_state_is_bounded` | `1.2.1` | `1.3.1`, `4.2.2`, `5.2.2` | Direct server-authority obligation. | +| `elm_ui.server_runtime.canonical_navigation_transition_mapping` | `4.1.1` | `4.2.1`, `4.5.1` | Direct canonical navigation transition-mapping obligation. | +| `elm_ui.server_runtime.host_route_resolution_boundary` | `4.3.1` | `4.2.1`, `4.5.2` | Direct host-route boundary obligation for canonical navigation. | ### `frontend_runtime.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | | --- | --- | --- | --- | diff --git a/.spec/planning/live_ui/spec-traceability.json b/.spec/planning/live_ui/spec-traceability.json index 937cbcf0..498e5633 100644 --- a/.spec/planning/live_ui/spec-traceability.json +++ b/.spec/planning/live_ui/spec-traceability.json @@ -692,6 +692,32 @@ ], "ownership_note": "Phase 11: Widget components may own bounded local UI lifecycle state." }, + { + "requirement_id": "live_ui.runtime.canonical_navigation_transition_mapping", + "scope": "direct", + "source_file": ".spec/specs/live_ui/runtime.spec.md", + "primary_plan_refs": [ + "4.1.1" + ], + "supporting_plan_refs": [ + "4.2.1", + "4.5.1" + ], + "ownership_note": "Direct canonical navigation transition-mapping obligation." + }, + { + "requirement_id": "live_ui.runtime.host_route_resolution_boundary", + "scope": "direct", + "source_file": ".spec/specs/live_ui/runtime.spec.md", + "primary_plan_refs": [ + "4.3.1" + ], + "supporting_plan_refs": [ + "4.2.1", + "4.5.2" + ], + "ownership_note": "Direct host-route boundary obligation for canonical navigation." + }, { "requirement_id": "live_ui.iur_renderer.accepts_canonical_iur", "scope": "direct", diff --git a/.spec/planning/live_ui/spec-traceability.md b/.spec/planning/live_ui/spec-traceability.md index 6347999f..594105a3 100644 --- a/.spec/planning/live_ui/spec-traceability.md +++ b/.spec/planning/live_ui/spec-traceability.md @@ -90,6 +90,8 @@ tasks because they govern other runtime packages: | `live_ui.runtime.native_and_iur_entrypoints_share_runtime` | `1.3.1` | `2.3.2`, `3.3.1`, `4.5.2` | Direct runtime-convergence obligation. | | `live_ui.runtime.state_and_render_continuity` | `2.3.2` | `3.3.1`, `5.3.2`, `5.5.2` | Direct continuity obligation. | | `live_ui.runtime.widget_component_local_state` | `11.2` | `11.2.2` | Phase 11: Widget components may own bounded local UI lifecycle state. | +| `live_ui.runtime.canonical_navigation_transition_mapping` | `4.1.1` | `4.2.1`, `4.5.1` | Direct canonical navigation transition-mapping obligation. | +| `live_ui.runtime.host_route_resolution_boundary` | `4.3.1` | `4.2.1`, `4.5.2` | Direct host-route boundary obligation for canonical navigation. | ### `iur_renderer.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | | --- | --- | --- | --- | diff --git a/.spec/planning/unified_iur/spec-traceability.json b/.spec/planning/unified_iur/spec-traceability.json index fb63c53d..29be770f 100644 --- a/.spec/planning/unified_iur/spec-traceability.json +++ b/.spec/planning/unified_iur/spec-traceability.json @@ -599,6 +599,30 @@ ], "ownership_note": "Direct binding-attachment requirement." }, + { + "requirement_id": "unified_iur.interactions.navigation_transition_representation", + "scope": "direct", + "source_file": ".spec/specs/unified-iur/interactions.spec.md", + "primary_plan_refs": [ + "4.3.1" + ], + "supporting_plan_refs": [ + "4.5.2" + ], + "ownership_note": "Direct canonical navigation transition-representation requirement." + }, + { + "requirement_id": "unified_iur.interactions.no_host_router_assumptions", + "scope": "direct", + "source_file": ".spec/specs/unified-iur/interactions.spec.md", + "primary_plan_refs": [ + "4.3.1" + ], + "supporting_plan_refs": [ + "4.5.2" + ], + "ownership_note": "Direct host-router-independence requirement for navigation descriptors." + }, { "requirement_id": "unified_iur.interoperability.runtime_library_consumption", "scope": "direct", @@ -703,6 +727,18 @@ "6.4.2" ], "ownership_note": "Direct documentation-surface requirement." + }, + { + "requirement_id": "unified_iur.tooling.navigation_transition_review_surfaces", + "scope": "direct", + "source_file": ".spec/specs/unified-iur/tooling.spec.md", + "primary_plan_refs": [ + "6.1.1" + ], + "supporting_plan_refs": [ + "6.2.1" + ], + "ownership_note": "Direct navigation-fixture review-surface requirement." } ] } diff --git a/.spec/planning/unified_iur/spec-traceability.md b/.spec/planning/unified_iur/spec-traceability.md index 7ffbf9e8..b14e77fb 100644 --- a/.spec/planning/unified_iur/spec-traceability.md +++ b/.spec/planning/unified_iur/spec-traceability.md @@ -121,6 +121,8 @@ tasks because they govern other runtime packages: | `unified_iur.interactions.standard_interaction_families` | `4.3.1` | - | Direct standard interaction-family requirement. | | `unified_iur.interactions.data_binding_representation` | `4.3.2` | - | Direct data-binding representation requirement. | | `unified_iur.interactions.element_binding_attachment` | `4.4.2` | `4.3.2` | Direct binding-attachment requirement. | +| `unified_iur.interactions.navigation_transition_representation` | `4.3.1` | `4.5.2` | Direct canonical navigation transition-representation requirement. | +| `unified_iur.interactions.no_host_router_assumptions` | `4.3.1` | `4.5.2` | Direct host-router-independence requirement for navigation descriptors. | ### `unified-iur/interoperability.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | | --- | --- | --- | --- | @@ -136,6 +138,7 @@ tasks because they govern other runtime packages: | `unified_iur.tooling.introspection_helpers` | `6.2.1` | - | Direct inspection and export tooling requirement. | | `unified_iur.tooling.validation_workflow` | `6.3.1` | `6.3.2` | Direct validation-workflow requirement. | | `unified_iur.tooling.documentation_surface` | `6.4.1` | `6.4.2` | Direct documentation-surface requirement. | +| `unified_iur.tooling.navigation_transition_review_surfaces` | `6.1.1` | `6.2.1` | Direct navigation-fixture review-surface requirement. | ## Upstream Canonical Input And Authoring Constraints These specs are referenced by the planning index because they define the canonical input surface and authored boundary that `unified_iur` must consume or diff --git a/.spec/planning/unified_ui/spec-traceability.json b/.spec/planning/unified_ui/spec-traceability.json index 38fc5176..f5992e0b 100644 --- a/.spec/planning/unified_ui/spec-traceability.json +++ b/.spec/planning/unified_ui/spec-traceability.json @@ -433,6 +433,22 @@ "primary_plan_refs": ["4.4.2"], "ownership_note": "Direct signal validation and introspection requirement." }, + { + "requirement_id": "unified_ui.signals.navigation_transition_actions", + "scope": "direct", + "source_file": ".spec/specs/unified-ui/signals.spec.md", + "primary_plan_refs": ["4.3.1"], + "supporting_plan_refs": ["4.5.2"], + "ownership_note": "Direct canonical navigation action-vocabulary requirement." + }, + { + "requirement_id": "unified_ui.signals.navigation_symbolic_screen_targets", + "scope": "direct", + "source_file": ".spec/specs/unified-ui/signals.spec.md", + "primary_plan_refs": ["4.3.2"], + "supporting_plan_refs": ["4.5.2"], + "ownership_note": "Direct symbolic screen-target authoring requirement." + }, { "requirement_id": "unified_ui.compiler.canonical_iur_output", "scope": "direct", @@ -475,6 +491,14 @@ "primary_plan_refs": ["5.2.1"], "ownership_note": "Direct style, theme, and layer resolution requirement." }, + { + "requirement_id": "unified_ui.compiler.navigation_transition_lowering", + "scope": "direct", + "source_file": ".spec/specs/unified-ui/compiler.spec.md", + "primary_plan_refs": ["5.1.2"], + "supporting_plan_refs": ["5.2.2"], + "ownership_note": "Direct canonical navigation lowering requirement." + }, { "requirement_id": "unified_ui.tooling.authoring_validation_workflow", "scope": "direct", diff --git a/.spec/planning/unified_ui/spec-traceability.md b/.spec/planning/unified_ui/spec-traceability.md index 8f93c9db..eb9436fd 100644 --- a/.spec/planning/unified_ui/spec-traceability.md +++ b/.spec/planning/unified_ui/spec-traceability.md @@ -116,6 +116,8 @@ tasks because they govern other runtime packages: | `unified_ui.signals.no_runtime_local_event_leakage` | `4.4.2` | - | Direct runtime-leakage rejection requirement. | | `unified_ui.signals.standard_interaction_families` | `4.3.1` | - | Direct standard interaction-family requirement. | | `unified_ui.signals.validation_and_introspection` | `4.4.2` | - | Direct signal validation and introspection requirement. | +| `unified_ui.signals.navigation_transition_actions` | `4.3.1` | `4.5.2` | Direct canonical navigation action-vocabulary requirement. | +| `unified_ui.signals.navigation_symbolic_screen_targets` | `4.3.2` | `4.5.2` | Direct symbolic screen-target authoring requirement. | ### `unified-ui/compiler.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | | --- | --- | --- | --- | @@ -125,6 +127,7 @@ tasks because they govern other runtime packages: | `unified_ui.compiler.no_renderer_output_modes` | `5.4.2` | - | Direct renderer-independence requirement for compilation output. | | `unified_ui.compiler.runtime_independent_bindings` | `5.2.2` | - | Direct runtime-independent binding compilation requirement. | | `unified_ui.compiler.style_theme_layer_resolution` | `5.2.1` | - | Direct style, theme, and layer resolution requirement. | +| `unified_ui.compiler.navigation_transition_lowering` | `5.1.2` | `5.2.2` | Direct canonical navigation lowering requirement. | ### `unified-ui/tooling.spec.md` | Requirement ID | Primary plan coverage | Supporting coverage | Ownership note | | --- | --- | --- | --- | diff --git a/packages/desktop_ui/lib/desktop_ui/examples.ex b/packages/desktop_ui/lib/desktop_ui/examples.ex index a9ebee05..ad8d008c 100644 --- a/packages/desktop_ui/lib/desktop_ui/examples.ex +++ b/packages/desktop_ui/lib/desktop_ui/examples.ex @@ -5,6 +5,53 @@ defmodule DesktopUi.Examples do alias UnifiedIUR.Element alias UnifiedIUR.Element.Child + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport + + defmodule NavigationReview do + defmodule HomeScreen do + def render(_assigns), do: %{} + end + + defmodule SettingsScreen do + def render(_assigns), do: %{} + end + + defmodule ReportsScreen do + def render(_assigns), do: %{} + end + + defmodule SettingsDialogScreen do + def render(_assigns), do: %{} + end + + defmodule Registry do + @behaviour DesktopUi.Navigation.Registry + + @impl true + def register do + %{ + home: {HomeScreen, title: "Home"}, + settings: {SettingsScreen, title: "Settings"}, + reports: {ReportsScreen, title: "Reports"}, + settings_dialog: {SettingsDialogScreen, title: "Settings Dialog", modal_only?: true} + } + end + + @impl true + def get_screen(:home), do: HomeScreen + def get_screen(:settings), do: SettingsScreen + def get_screen(:reports), do: ReportsScreen + def get_screen(:settings_dialog), do: SettingsDialogScreen + def get_screen(_), do: nil + + @impl true + def screen_metadata(:home), do: %{title: "Home"} + def screen_metadata(:settings), do: %{title: "Settings"} + def screen_metadata(:reports), do: %{title: "Reports"} + def screen_metadata(:settings_dialog), do: %{title: "Settings Dialog", modal_only?: true} + def screen_metadata(_), do: %{} + end + end @spec native_foundational_screen() :: map() def native_foundational_screen do @@ -904,7 +951,10 @@ defmodule DesktopUi.Examples do DesktopUi.Widgets.column("nav-layout", [ DesktopUi.Widgets.content("nav-header", [ DesktopUi.Widgets.label("nav-title", "Navigation Demo"), - DesktopUi.Widgets.text("nav-subtitle", "Demonstrates home, list, and detail screens") + DesktopUi.Widgets.text( + "nav-subtitle", + "Demonstrates home, list, and detail screens" + ) ]), DesktopUi.Widgets.menu( "nav-menu", @@ -1063,6 +1113,152 @@ defmodule DesktopUi.Examples do } end + @spec navigation_transition_review() :: map() + def navigation_transition_review do + fixtures = navigation_fixtures() + screen = navigation_review_screen() + + Enum.each(navigation_review_modules(), &Code.ensure_loaded?/1) + + {:ok, runtime_state} = + DesktopUi.Runtime.mount_native_screen(screen, + platform_target: :linux, + screen_registry: NavigationReview.Registry, + navigation_screen_id: :home + ) + + primary_window = runtime_state.windows.primary + + {:ok, after_navigate, navigate_route} = + DesktopUi.Runtime.dispatch_native_event( + runtime_state, + family: :navigation, + intent: fixtures.navigate.interaction.intent, + widget_id: "settings-link", + target: fixtures.navigate.descriptor.target, + payload: fixtures.navigate.signal_data + ) + + {:ok, with_modal, modal_route} = + DesktopUi.Runtime.dispatch_native_event( + after_navigate, + family: :navigation, + intent: fixtures.modal.interaction.intent, + widget_id: "settings-dialog-button", + target: fixtures.modal.descriptor.target, + payload: fixtures.modal.signal_data + ) + + {:ok, after_close, close_route} = + DesktopUi.Runtime.dispatch_native_event( + with_modal, + family: :navigation, + intent: :close_settings_modal, + widget_id: "close-settings-dialog", + target: close_modal_target() + ) + + {:ok, after_reports, reports_route} = + DesktopUi.Runtime.dispatch_native_event( + after_close, + family: :navigation, + intent: :open_reports_screen, + widget_id: "reports-link", + target: reports_target() + ) + + {:ok, after_back, back_route} = + DesktopUi.Runtime.dispatch_native_event( + after_reports, + family: :navigation, + intent: fixtures.back.interaction.intent, + widget_id: "back-button", + target: fixtures.back.descriptor.target, + payload: fixtures.back.signal_data + ) + + {:ok, after_forward, forward_route} = + DesktopUi.Runtime.dispatch_native_event( + after_back, + family: :navigation, + intent: :go_forward, + widget_id: "forward-button", + target: forward_target() + ) + + {:ok, after_replace, replace_route} = + DesktopUi.Runtime.dispatch_native_event( + after_forward, + family: :navigation, + intent: fixtures.replace.interaction.intent, + widget_id: "home-link", + target: fixtures.replace.descriptor.target, + payload: fixtures.replace.signal_data + ) + + expected_targets = navigation_fixture_targets(fixtures) + + route_summaries = %{ + navigate: navigation_route_summary(navigate_route), + modal: navigation_route_summary(modal_route), + close_modal: navigation_route_summary(close_route), + reports: navigation_route_summary(reports_route), + back: navigation_route_summary(back_route), + forward: navigation_route_summary(forward_route), + replace: navigation_route_summary(replace_route) + } + + state_summaries = %{ + mounted: desktop_navigation_state_summary(runtime_state), + after_navigate: desktop_navigation_state_summary(after_navigate), + with_modal: desktop_navigation_state_summary(with_modal), + after_close: desktop_navigation_state_summary(after_close), + after_reports: desktop_navigation_state_summary(after_reports), + after_back: desktop_navigation_state_summary(after_back), + after_forward: desktop_navigation_state_summary(after_forward), + after_replace: desktop_navigation_state_summary(after_replace) + } + + %{ + id: :navigation_transition_review, + summary: + "Review canonical navigation transitions through the desktop screen registry and one shared window", + coverage: [ + :canonical_navigation_transitions, + :screen_registry, + :history_stack, + :modal_stack + ], + fixture_ids: navigation_fixture_ids(), + fixture_targets: expected_targets, + routes: route_summaries, + states: state_summaries, + parity: %{ + shared_fixture_targets_consumed?: + Enum.all?(route_summaries, fn {step, summary} -> + summary.target == expected_targets[step] and no_host_route_syntax?(summary.target) + end), + window_preserved_across_transitions?: + Enum.all?(state_summaries, fn {_step, summary} -> + summary.primary_window == primary_window + end), + registry_resolution_preserved?: + state_summaries.after_navigate.title == "Settings" and + state_summaries.after_reports.title == "Reports" and + state_summaries.after_replace.title == "Home", + history_semantics_preserved?: + state_summaries.after_back.screen_id == "settings" and + state_summaries.after_forward.screen_id == "reports" and + state_summaries.after_replace.screen_id == "home" and + state_summaries.after_replace.history_depth == 2, + modal_stack_preserved?: + state_summaries.with_modal.modal_depth == 1 and + state_summaries.after_close.modal_depth == 0 and + state_summaries.after_close.screen_id == "settings" + } + } + end + @spec normalized_input_comparison() :: map() def normalized_input_comparison do shortcut_profiles = @@ -1210,7 +1406,8 @@ defmodule DesktopUi.Examples do :advanced_continuity, :transport_flow_review, :normalized_input_profiles, - :styled_continuity_review + :styled_continuity_review, + :navigation_transition_review ] defp catalog_by_category(category) do @@ -1378,6 +1575,24 @@ defmodule DesktopUi.Examples do parity_with: [:native_styled_review, :canonical_styled_review], coverage: [:style_continuity, :theme_alignment, :artifact_targets] }, + %{ + id: :navigation_transition_review, + category: :mixed, + workflow: :navigation_review, + parity_group: :navigation_transition_review, + parity_with: [ + :basic_navigation, + :history_navigation, + :modal_navigation, + :master_detail_navigation + ], + coverage: [ + :canonical_navigation_transitions, + :screen_registry, + :history_stack, + :modal_stack + ] + }, # Navigation examples %{ id: :basic_navigation, @@ -1428,6 +1643,139 @@ defmodule DesktopUi.Examples do Enum.reduce(Map.get(node, :children, []), acc ++ [node.kind], &flatten_kinds(&1, &2)) end + defp navigation_fixtures do + %{ + navigate: BoundaryTransport.boundary_fixture!("screen_transition--settings_profile"), + replace: BoundaryTransport.boundary_fixture!("replace_transition--home"), + back: BoundaryTransport.boundary_fixture!("history_transition--back"), + modal: BoundaryTransport.boundary_fixture!("modal_transition--settings_dialog") + } + end + + defp navigation_fixture_ids do + [ + "screen_transition--settings_profile", + "replace_transition--home", + "history_transition--back", + "modal_transition--settings_dialog" + ] + end + + defp navigation_fixture_targets(fixtures) do + %{ + navigate: fixtures.navigate.descriptor.target, + replace: fixtures.replace.descriptor.target, + back: fixtures.back.descriptor.target, + modal: fixtures.modal.descriptor.target, + close_modal: close_modal_target(), + reports: reports_target(), + forward: forward_target() + } + end + + defp navigation_review_modules do + [ + NavigationReview.HomeScreen, + NavigationReview.SettingsScreen, + NavigationReview.ReportsScreen, + NavigationReview.SettingsDialogScreen, + NavigationReview.Registry + ] + end + + defp navigation_review_screen do + %{ + id: "desktop-navigation-review", + title: "Desktop Navigation Review", + root: + DesktopUi.Widgets.window("desktop-navigation-window", "Desktop Navigation Review", [ + DesktopUi.Widgets.column("desktop-navigation-layout", [ + DesktopUi.Widgets.content("desktop-navigation-header", [ + DesktopUi.Widgets.label("desktop-navigation-title", "Desktop Navigation Review"), + DesktopUi.Widgets.text( + "desktop-navigation-subtitle", + "Registered screens, history traversal, modal dialogs, and replacement transitions" + ) + ]), + DesktopUi.Widgets.row("desktop-navigation-actions", [ + DesktopUi.Widgets.button("settings-link", "Settings", navigate_to: :settings), + DesktopUi.Widgets.button("reports-link", "Reports", navigate_to: :reports), + DesktopUi.Widgets.button("home-link", "Replace With Home", replace_with: :home) + ]), + DesktopUi.Widgets.row("desktop-navigation-history", [ + DesktopUi.Widgets.button("back-button", "Back", go_back: true), + DesktopUi.Widgets.button("forward-button", "Forward", go_forward: true) + ]), + DesktopUi.Widgets.row("desktop-navigation-modal", [ + DesktopUi.Widgets.button("settings-dialog-button", "Open Settings Dialog", + open_modal: :settings_dialog, + navigate_params: %{mode: :advanced} + ), + DesktopUi.Widgets.button("close-settings-dialog", "Close Settings Dialog", + close_modal: true + ) + ]) + ]) + ]) + } + end + + defp reports_target do + %{navigation: %{action: :navigate_to, screen: :reports, params: %{section: :daily}}} + end + + defp forward_target do + %{navigation: %{action: :go_forward}} + end + + defp close_modal_target do + %{navigation: %{action: :close_modal, modal: :settings_dialog}} + end + + defp desktop_navigation_state_summary(state) do + current_modal = + case DesktopUi.Navigation.Integration.current_modal(state) do + {id, _module, params} -> %{modal: id, params: params} + nil -> nil + end + + %{ + runtime_id: state.runtime_id, + screen_id: state.screen_id, + title: state.title, + current_screen_module: state.current_screen_module, + screen_params: state.screen_params, + primary_window: state.windows.primary, + history_depth: length(state.navigation_state.history), + forward_depth: length(state.navigation_state.forward), + modal_depth: length(state.navigation_state.modals), + current_modal: current_modal + } + end + + defp navigation_route_summary(route_result) do + translation = route_result.translation + + %{ + route: route_result.route, + family: route_result.family, + input_family: route_result.input_family, + boundary: route_result.boundary, + target: Map.get(translation, :target), + signal_type: + case Map.get(translation, :signal) do + nil -> nil + signal -> signal.type + end + } + end + + defp no_host_route_syntax?(%{navigation: navigation}) when is_map(navigation) do + not Map.has_key?(navigation, :route) + end + + defp no_host_route_syntax?(_other), do: true + defp root_for_sequence(%{kind: :window, children: [child | _rest]}), do: child defp root_for_sequence(tree), do: tree end diff --git a/packages/desktop_ui/lib/desktop_ui/inspect.ex b/packages/desktop_ui/lib/desktop_ui/inspect.ex index 741d65f0..4cff7851 100644 --- a/packages/desktop_ui/lib/desktop_ui/inspect.ex +++ b/packages/desktop_ui/lib/desktop_ui/inspect.ex @@ -48,7 +48,7 @@ defmodule DesktopUi.Inspect do shutdown: %{acknowledgement: shutdown_ack, final_state: host.state}, resource_contracts: %{ text: DesktopUi.Sdl3.Text.contract(), - images: DesktopUi.Sdl3.Images.contract() + images: DesktopUi.Sdl3.Images.contract() }, resource_support: %{ text: DesktopUi.Sdl3.Text.native_support(capabilities), @@ -148,6 +148,10 @@ defmodule DesktopUi.Inspect do DesktopUi.Examples.styled_comparison() end + defp preview_surface(%{category: :mixed, id: :navigation_transition_review}) do + DesktopUi.Examples.navigation_transition_review() + end + defp launch_host(%{category: :native, id: :native_foundational}, opts) do DesktopUi.Sdl3.App.launch_native_screen( DesktopUi.Examples.native_foundational_screen(), diff --git a/packages/desktop_ui/lib/desktop_ui/navigation/controller.ex b/packages/desktop_ui/lib/desktop_ui/navigation/controller.ex index 77f34b0a..6ebeb4a0 100644 --- a/packages/desktop_ui/lib/desktop_ui/navigation/controller.ex +++ b/packages/desktop_ui/lib/desktop_ui/navigation/controller.ex @@ -105,6 +105,14 @@ defmodule DesktopUi.Navigation.Controller do |> State.current_screen() end + @doc """ + Returns the configured screen registry module, if any. + """ + @spec registry(GenServer.server() | atom()) :: module() | nil + def registry(server) do + GenServer.call(server, :registry) + end + @doc """ Stops the navigation controller. @@ -233,6 +241,10 @@ defmodule DesktopUi.Navigation.Controller do {:reply, state.nav_state, state} end + def handle_call(:registry, _from, state) do + {:reply, state.registry, state} + end + # State transition functions defp navigate_to(%State{} = nav, screen_id, screen_module, params) do diff --git a/packages/desktop_ui/lib/desktop_ui/navigation/integration.ex b/packages/desktop_ui/lib/desktop_ui/navigation/integration.ex index a9b95068..8d018e2d 100644 --- a/packages/desktop_ui/lib/desktop_ui/navigation/integration.ex +++ b/packages/desktop_ui/lib/desktop_ui/navigation/integration.ex @@ -206,17 +206,26 @@ defmodule DesktopUi.Navigation.Integration do end end + defp signal_from_event(%{family: :navigation, target: target}) when is_map(target) do + Signal.from_map(target) + end + defp signal_from_event(_event), do: {:error, :not_a_navigation_event} defp update_runtime_for_navigation(%RuntimeState{} = runtime, %State{} = nav_state) do # Extract current screen from navigation state - {_screen_id, screen_module, params} = State.current_screen(nav_state) + {screen_id, screen_module, params} = State.current_screen(nav_state) %RuntimeState{ runtime | navigation_state: nav_state, + screen_id: normalize_screen_id(screen_id, runtime.screen_id), current_screen_module: screen_module, screen_params: params } end + + defp normalize_screen_id(screen_id, _fallback) when is_binary(screen_id), do: screen_id + defp normalize_screen_id(screen_id, _fallback) when is_atom(screen_id), do: to_string(screen_id) + defp normalize_screen_id(_screen_id, fallback), do: fallback end diff --git a/packages/desktop_ui/lib/desktop_ui/navigation/signal.ex b/packages/desktop_ui/lib/desktop_ui/navigation/signal.ex index 7ebec2eb..cc4cd756 100644 --- a/packages/desktop_ui/lib/desktop_ui/navigation/signal.ex +++ b/packages/desktop_ui/lib/desktop_ui/navigation/signal.ex @@ -189,6 +189,52 @@ defmodule DesktopUi.Navigation.Signal do end end + def from_map(%{"type" => _type} = map) do + map + |> normalize_map_keys() + |> from_map() + end + + def from_map(%{action: action} = map) when is_atom(action) or is_binary(action) do + descriptor = normalize_descriptor(map) + + case normalize_action(action) do + :navigate_to -> + {:ok, navigate(Map.get(descriptor, :screen), Map.get(descriptor, :params, %{}))} + + :replace_with -> + {:ok, replace(Map.get(descriptor, :screen), Map.get(descriptor, :params, %{}))} + + :go_back -> + {:ok, go_back()} + + :go_forward -> + {:ok, go_forward()} + + :open_modal -> + {:ok, open_modal(Map.get(descriptor, :modal), Map.get(descriptor, :params, %{}))} + + :close_modal -> + {:ok, close_modal()} + + _other -> + {:error, :unknown_navigation_type} + end + end + + def from_map(%{"action" => _action} = map) do + map + |> normalize_map_keys() + |> from_map() + end + + def from_map(%{navigation: navigation}) when is_map(navigation), do: from_map(navigation) + def from_map(%{"navigation" => navigation}) when is_map(navigation), do: from_map(navigation) + def from_map(%{target: %{navigation: navigation}}) when is_map(navigation), do: from_map(navigation) + + def from_map(%{"target" => %{"navigation" => navigation}}) when is_map(navigation), + do: from_map(navigation) + def from_map(_), do: {:error, :invalid_signal_format} @doc """ @@ -254,6 +300,31 @@ defmodule DesktopUi.Navigation.Signal do # Private helpers + defp normalize_action(action) when is_atom(action), do: action + defp normalize_action(action) when is_binary(action), do: String.to_atom(action) + defp normalize_action(_action), do: nil + + defp normalize_descriptor(map) do + map = normalize_map_keys(map) + + %{} + |> maybe_put(:action, normalize_action(Map.get(map, :action))) + |> maybe_put(:screen, Map.get(map, :screen)) + |> maybe_put(:modal, Map.get(map, :modal)) + |> maybe_put(:params, normalize_params(Map.get(map, :params, %{}))) + end + + defp normalize_map_keys(map) do + Map.new(map, fn + {key, value} when is_binary(key) -> {String.to_atom(key), value} + pair -> pair + end) + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, _key, ""), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + defp normalize_params(params) when is_map(params), do: params defp normalize_params(params) when is_list(params), do: Map.new(params) defp normalize_params(_), do: %{} diff --git a/packages/desktop_ui/lib/desktop_ui/renderer.ex b/packages/desktop_ui/lib/desktop_ui/renderer.ex index 93177c5e..d849a080 100644 --- a/packages/desktop_ui/lib/desktop_ui/renderer.ex +++ b/packages/desktop_ui/lib/desktop_ui/renderer.ex @@ -23,7 +23,7 @@ defmodule DesktopUi.Renderer do end @doc """ - All 45 canonical IUR widget kinds supported by the desktop_ui renderer. + All canonical renderer kinds supported by the `desktop_ui` renderer. Each kind has dedicated native widget mapping, draw kind handling, and SDL3 rendering implementation. """ @@ -88,6 +88,7 @@ defmodule DesktopUi.Renderer do :canvas, :gauge, :line_chart, + :sparkline, :timeline, # Layout & Structure (3) :column, diff --git a/packages/desktop_ui/lib/desktop_ui/runtime.ex b/packages/desktop_ui/lib/desktop_ui/runtime.ex index 6b92880f..49d7a5ae 100644 --- a/packages/desktop_ui/lib/desktop_ui/runtime.ex +++ b/packages/desktop_ui/lib/desktop_ui/runtime.ex @@ -4,7 +4,16 @@ defmodule DesktopUi.Runtime do """ alias DesktopUi.Renderer - alias DesktopUi.Runtime.{Boot, Error, EventLoop, EventRouter, Shutdown, State, StyleResolver} + alias DesktopUi.Runtime.{ + Boot, + Error, + EventLoop, + EventRouter, + Navigation, + Shutdown, + State, + StyleResolver + } alias UnifiedIUR.Element alias Jido.Signal @@ -22,6 +31,7 @@ defmodule DesktopUi.Runtime do DesktopUi.Runtime.Frame, DesktopUi.Runtime.Window, DesktopUi.Runtime.Screen, + Navigation, StyleResolver, DesktopUi.Runtime.State, DesktopUi.Runtime.Shutdown, @@ -51,6 +61,7 @@ defmodule DesktopUi.Runtime do :canonical_boundary_events, :normalized_desktop_inputs, :shared_event_routing, + :screen_navigation, :event_polling_scaffold, :frame_coordination, :focus_callback_placeholders, @@ -137,8 +148,9 @@ defmodule DesktopUi.Runtime do |> Map.put_new(:source_kind, runtime_state.source_kind) with {:ok, translation} <- DesktopUi.Transport.from_native_event(attrs), - {:ok, route_result} <- EventRouter.route(runtime_state, translation) do - {:ok, apply_route(runtime_state, route_result), route_result} + {:ok, route_result} <- EventRouter.route(runtime_state, translation), + {:ok, routed_state} <- apply_route(runtime_state, route_result) do + {:ok, routed_state, route_result} end end @@ -159,20 +171,24 @@ defmodule DesktopUi.Runtime do {:ok, State.t(), map()} | {:error, Error.t() | term()} def handle_boundary_signal(%State{} = runtime_state, signal) do with {:ok, translation} <- DesktopUi.Transport.from_boundary_signal(signal), - {:ok, route_result} <- EventRouter.route(runtime_state, translation) do - {:ok, apply_route(runtime_state, route_result), route_result} + {:ok, route_result} <- EventRouter.route(runtime_state, translation), + {:ok, routed_state} <- apply_route(runtime_state, route_result) do + {:ok, routed_state, route_result} end end defp apply_route(%State{} = runtime_state, route_result) do translation = route_result.translation - %{ - runtime_state - | focus: apply_focus(runtime_state.focus, translation, route_result.route), - event_loop: EventLoop.record_route(runtime_state.event_loop, route_result), - event_log: runtime_state.event_log ++ [event_log_entry(route_result)] - } + with {:ok, transitioned_state} <- maybe_apply_navigation(runtime_state, translation) do + {:ok, + %{ + transitioned_state + | focus: apply_focus(transitioned_state.focus, translation, route_result.route), + event_loop: EventLoop.record_route(transitioned_state.event_loop, route_result), + event_log: transitioned_state.event_log ++ [event_log_entry(route_result)] + }} + end end defp apply_focus(nil, _translation, _route), do: nil @@ -220,4 +236,12 @@ defmodule DesktopUi.Runtime do defp normalize_map(attrs) when is_map(attrs), do: Map.new(attrs) defp normalize_map(attrs) when is_list(attrs), do: Enum.into(attrs, %{}) + + defp maybe_apply_navigation(%State{} = runtime_state, translation) do + if Navigation.transition?(translation) do + Navigation.apply_transition(runtime_state, translation) + else + {:ok, runtime_state} + end + end end diff --git a/packages/desktop_ui/lib/desktop_ui/runtime/boot.ex b/packages/desktop_ui/lib/desktop_ui/runtime/boot.ex index 61879f6d..1085787e 100644 --- a/packages/desktop_ui/lib/desktop_ui/runtime/boot.ex +++ b/packages/desktop_ui/lib/desktop_ui/runtime/boot.ex @@ -13,16 +13,18 @@ defmodule DesktopUi.Runtime.Boot do @spec prepare_native_screen(map(), keyword()) :: {:ok, State.t()} | {:error, Error.t()} def prepare_native_screen(screen, opts \\ []) do with :ok <- validate_screen(screen), - {:ok, root} <- normalize_root(Map.fetch!(screen, :root)) do - build_state(Map.put(screen, :root, root), :native, opts) + {:ok, root} <- normalize_root(Map.fetch!(screen, :root)), + {:ok, state} <- build_state(Map.put(screen, :root, root), :native, opts) do + maybe_start_navigation_controller(state, screen, opts) end end @spec prepare_rendered_screen(Widget.t() | map(), keyword()) :: {:ok, State.t()} | {:error, Error.t()} def prepare_rendered_screen(rendered_root, opts \\ []) do - with {:ok, root} <- normalize_root(rendered_root) do - build_state( + with {:ok, root} <- normalize_root(rendered_root), + {:ok, state} <- + build_state( %{ id: Keyword.get(opts, :screen_id, "canonical-screen"), title: Keyword.get(opts, :title, "Canonical Screen"), @@ -30,13 +32,14 @@ defmodule DesktopUi.Runtime.Boot do }, :canonical, opts - ) + ) do + maybe_start_navigation_controller(state, %{id: Keyword.get(opts, :screen_id)}, opts) end end @spec start_navigation_controller(State.t(), keyword()) :: {:ok, State.t()} | {:error, term()} def start_navigation_controller(state, opts \\ []) do - registry = Keyword.get(opts, :registry) + registry = Keyword.get(opts, :screen_registry) || Keyword.get(opts, :registry) initial_screen = Keyword.get(opts, :initial_screen) cond do @@ -45,7 +48,6 @@ defmodule DesktopUi.Runtime.Boot do {:ok, state} initial_screen -> - screen_module = elem(initial_screen, 1) params = elem(initial_screen, 2) case Controller.start_link( @@ -60,7 +62,7 @@ defmodule DesktopUi.Runtime.Boot do %{ state | navigation_controller: controller, - current_screen_module: screen_module, + current_screen_module: nav_state.current_module, navigation_state: nav_state, screen_params: params }} @@ -175,4 +177,32 @@ defmodule DesktopUi.Runtime.Boot do defp normalize_root(root) do {:error, Error.new(:invalid_screen_root, %{root: root}, :runtime_boot)} end + + defp maybe_start_navigation_controller(state, screen, opts) do + initial_screen = + Keyword.get(opts, :initial_screen) || + inferred_initial_screen(screen, opts, Keyword.get(opts, :screen_registry) || Keyword.get(opts, :registry)) + + start_navigation_controller( + state, + screen_registry: Keyword.get(opts, :screen_registry), + registry: Keyword.get(opts, :registry), + initial_screen: initial_screen + ) + end + + defp inferred_initial_screen(_screen, _opts, nil), do: nil + + defp inferred_initial_screen(screen, opts, _registry) do + screen_id = + Keyword.get(opts, :navigation_screen_id) || + Keyword.get(opts, :screen_id) || + Map.get(screen, :navigation_screen_id) + + if is_nil(screen_id) do + nil + else + {screen_id, Keyword.get(opts, :screen_module), Keyword.get(opts, :screen_params, %{})} + end + end end diff --git a/packages/desktop_ui/lib/desktop_ui/runtime/navigation.ex b/packages/desktop_ui/lib/desktop_ui/runtime/navigation.ex new file mode 100644 index 00000000..4e7fac9e --- /dev/null +++ b/packages/desktop_ui/lib/desktop_ui/runtime/navigation.ex @@ -0,0 +1,222 @@ +defmodule DesktopUi.Runtime.Navigation do + @moduledoc """ + Canonical screen-transition handling for the shared `desktop_ui` runtime. + """ + + alias DesktopUi.Navigation.{Controller, Integration, Registry, Signal} + alias DesktopUi.Navigation.State, as: NavigationState + alias DesktopUi.Runtime.{Error, Screen, State} + alias UnifiedIUR.Interaction + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport + + @spec transition?(map()) :: boolean() + def transition?(%{family: :navigation, target: target}) when is_map(target) do + not is_nil(Interaction.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{navigation_controller: nil}, translation) when is_map(translation) do + if transition?(translation) do + {:error, + Error.new( + :no_navigation_controller, + %{action: descriptor_action(translation)}, + :event_routing + )} + else + {:error, Error.new(:invalid_navigation_transition, %{reason: :missing_navigation_target}, :event_routing)} + end + end + + def apply_transition(%State{} = runtime_state, translation) when is_map(translation) do + with {:ok, descriptor} <- validate_navigation_descriptor(translation), + :ok <- validate_transition_context(runtime_state, descriptor), + {:ok, signal} <- Signal.from_map(descriptor), + {:ok, updated_runtime, nav_state, _transition} <- + Integration.handle_navigation(runtime_state, signal) do + {:ok, synchronize_runtime(updated_runtime, nav_state, runtime_state, descriptor)} + else + {:error, %Error{} = error} -> + {:error, error} + + {:error, {:unknown_screen, screen_id}} -> + {:error, + Error.new( + :unresolved_navigation_target, + %{action: descriptor_action(translation), screen_id: screen_id}, + :event_routing + )} + + {:error, :no_modal} -> + {:error, + Error.new( + :invalid_modal_transition, + %{action: descriptor_action(translation), reason: :no_modal}, + :event_routing + )} + + {:error, :empty_history} -> + {:error, + Error.new( + :unsupported_navigation_context, + %{action: descriptor_action(translation), reason: :empty_history}, + :event_routing + )} + + {:error, :empty_forward} -> + {:error, + Error.new( + :unsupported_navigation_context, + %{action: descriptor_action(translation), reason: :empty_forward}, + :event_routing + )} + + {:error, reason} -> + {:error, + Error.new( + :invalid_navigation_transition, + %{action: descriptor_action(translation), reason: inspect(reason)}, + :event_routing + )} + end + end + + defp validate_navigation_descriptor(translation) do + descriptor = boundary_descriptor(translation) + + case BoundaryTransport.validate_boundary_descriptor(descriptor) do + :ok -> + {:ok, navigation_descriptor(translation)} + + {:error, {:forbidden_navigation_keys, keys}} -> + {:error, Error.new(:host_route_navigation_syntax, %{keys: keys}, :event_routing)} + + {:error, reason} -> + {:error, Error.new(:invalid_navigation_transition, %{reason: inspect(reason)}, :event_routing)} + end + end + + defp validate_transition_context(%State{} = runtime_state, descriptor) do + action = Map.get(descriptor, :action) + + cond do + action in [:close_modal, "close_modal"] and modal_mismatch?(runtime_state, descriptor) -> + {:error, + Error.new( + :invalid_modal_transition, + %{action: action, requested_modal: Map.get(descriptor, :modal)}, + :event_routing + )} + + true -> + :ok + end + end + + defp synchronize_runtime(updated_runtime, %NavigationState{} = nav_state, previous_runtime, descriptor) do + screen_id = current_screen_id(nav_state, updated_runtime.screen_id) + title = resolve_screen_title(previous_runtime, screen_id, updated_runtime.current_screen_module) + + %{ + updated_runtime + | screen_id: screen_id, + title: title, + screen: synchronize_screen(updated_runtime.screen, screen_id, title, descriptor, nav_state) + } + end + + defp synchronize_screen(%Screen{} = screen, screen_id, title, descriptor, nav_state) do + metadata = + screen.metadata + |> Map.put(:current_screen_id, screen_id) + |> Map.put(:navigation_action, Map.get(descriptor, :action)) + |> Map.put(:modal_depth, NavigationState.modal_depth(nav_state)) + + %{screen | id: screen_id, title: title, metadata: metadata} + end + + defp resolve_screen_title(runtime_state, screen_id, current_screen_module) do + metadata = + case Controller.registry(runtime_state.navigation_controller) do + registry when is_atom(registry) -> + Registry.metadata(registry, normalize_registry_id(screen_id)) + + _other -> + %{} + end + + Map.get(metadata, :title) || + Map.get(metadata, "title") || + default_title(screen_id, current_screen_module) + end + + defp current_screen_id(%NavigationState{current: current}, fallback), + do: normalize_screen_id(current, fallback) + + defp normalize_screen_id(screen_id, _fallback) when is_binary(screen_id), do: screen_id + defp normalize_screen_id(screen_id, _fallback) when is_atom(screen_id), do: to_string(screen_id) + defp normalize_screen_id(_screen_id, fallback), do: fallback + + defp normalize_registry_id(screen_id) when is_binary(screen_id), do: String.to_atom(screen_id) + defp normalize_registry_id(screen_id), do: screen_id + + defp default_title(screen_id, _current_screen_module) when is_atom(screen_id) do + screen_id + |> Atom.to_string() + |> String.split("_") + |> Enum.map(&String.capitalize/1) + |> Enum.join(" ") + end + + defp default_title(screen_id, _current_screen_module) when is_binary(screen_id) and screen_id != "" do + screen_id + |> String.split(~r/[_-]+/) + |> Enum.map(&String.capitalize/1) + |> Enum.join(" ") + end + + defp default_title(_screen_id, current_screen_module) when is_atom(current_screen_module) do + current_screen_module + |> Module.split() + |> List.last() + end + + defp default_title(_screen_id, _current_screen_module), do: "Screen" + + defp modal_mismatch?(%State{navigation_state: nil}, %{modal: modal}) when not is_nil(modal), do: true + + defp modal_mismatch?(%State{navigation_state: nav_state}, %{modal: modal}) + when not is_nil(modal) and modal != "" do + case NavigationState.top_modal(nav_state) do + {current_modal, _module, _params} -> current_modal != modal + nil -> true + end + end + + defp modal_mismatch?(_runtime_state, _descriptor), do: false + + defp boundary_descriptor(translation) do + %{ + family: :navigation, + intent: Map.get(translation, :intent), + source_context: %{ + element_id: Map.get(translation, :widget_id), + scope: :screen + }, + target: Map.get(translation, :target, %{}), + metadata: %{} + } + end + + defp navigation_descriptor(translation) do + Interaction.navigation_descriptor(Map.get(translation, :target, %{})) || %{} + end + + defp descriptor_action(translation) do + translation + |> navigation_descriptor() + |> Map.get(:action) + end +end diff --git a/packages/desktop_ui/lib/desktop_ui/transport/diagnostics.ex b/packages/desktop_ui/lib/desktop_ui/transport/diagnostics.ex index 0f49a270..22dcd788 100644 --- a/packages/desktop_ui/lib/desktop_ui/transport/diagnostics.ex +++ b/packages/desktop_ui/lib/desktop_ui/transport/diagnostics.ex @@ -6,6 +6,8 @@ defmodule DesktopUi.Transport.Diagnostics do alias Jido.Signal alias DesktopUi.Transport.{Error, Normalize} alias DesktopUi.Transport.Signal, as: TransportSignal + alias UnifiedIUR.Interaction + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport @payload_leak_keys [ :backend_payload, @@ -50,6 +52,7 @@ defmodule DesktopUi.Transport.Diagnostics do def validate_translation(%{} = translation) do with :ok <- validate_payload(Map.get(translation, :payload, %{}), :translation_payload), :ok <- validate_payload(Map.get(translation, :target, %{}), :translation_target), + :ok <- validate_navigation_target(translation), :ok <- maybe_validate_boundary_signal(translation) do if Map.get(translation, :boundary) == :boundary do validate_boundary_context(translation) @@ -144,6 +147,33 @@ defmodule DesktopUi.Transport.Diagnostics do defp leaked_key?(_key), do: false + defp validate_navigation_target(%{family: :navigation, target: target}) when is_map(target) do + descriptor = Interaction.navigation_descriptor(target) + + if is_nil(descriptor) do + :ok + else + case BoundaryTransport.validate_boundary_descriptor(%{ + family: :navigation, + intent: nil, + source_context: %{}, + target: target, + metadata: %{} + }) do + :ok -> + :ok + + {:error, {:forbidden_navigation_keys, keys}} -> + {:error, Error.host_route_navigation_syntax(keys)} + + {:error, reason} -> + {:error, Error.invalid_navigation_target(reason)} + end + end + end + + defp validate_navigation_target(_translation), do: :ok + defp maybe_missing(fields, field, nil), do: fields ++ [field] defp maybe_missing(fields, field, ""), do: fields ++ [field] defp maybe_missing(fields, _field, _value), do: fields diff --git a/packages/desktop_ui/lib/desktop_ui/transport/error.ex b/packages/desktop_ui/lib/desktop_ui/transport/error.ex index 5fd37ce0..4368c7ce 100644 --- a/packages/desktop_ui/lib/desktop_ui/transport/error.ex +++ b/packages/desktop_ui/lib/desktop_ui/transport/error.ex @@ -83,4 +83,22 @@ defmodule DesktopUi.Transport.Error do details: %{platform_target: inspect(target)} } 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 desktop navigation targets must not contain host-route syntax", + details: %{keys: keys} + } + end + + @spec invalid_navigation_target(term()) :: t() + def invalid_navigation_target(reason) do + %__MODULE__{ + reason: :invalid_navigation_target, + message: "desktop_ui navigation targets must satisfy the canonical screen-transition contract", + details: %{reason: inspect(reason)} + } + end end diff --git a/packages/desktop_ui/lib/desktop_ui/transport/normalize.ex b/packages/desktop_ui/lib/desktop_ui/transport/normalize.ex index 5d5a6552..d0b685e2 100644 --- a/packages/desktop_ui/lib/desktop_ui/transport/normalize.ex +++ b/packages/desktop_ui/lib/desktop_ui/transport/normalize.ex @@ -114,11 +114,24 @@ defmodule DesktopUi.Transport.Normalize do case Enum.uniq(matches) do [family] -> {:ok, family} - [] -> {:error, Error.invalid_native_event(attrs)} + [] -> infer_input_family_from_family(attrs) _many -> {:error, Error.ambiguous_native_event(attrs)} end end + defp infer_input_family_from_family(attrs) do + case normalize_family(fetch(attrs, :family)) do + :command -> {:ok, :shortcut} + :navigation -> {:ok, :keyboard} + :selection -> {:ok, :pointer} + :click -> {:ok, :pointer} + :submit -> {:ok, :keyboard} + :change -> {:ok, :keyboard} + :focus -> {:ok, :focus} + _other -> {:error, Error.invalid_native_event(attrs)} + end + end + defp resolve_canonical_family(attrs, input_family) do case normalize_family(fetch(attrs, :family)) do nil -> diff --git a/packages/desktop_ui/lib/desktop_ui/validate.ex b/packages/desktop_ui/lib/desktop_ui/validate.ex index a1084b78..9949dbaf 100644 --- a/packages/desktop_ui/lib/desktop_ui/validate.ex +++ b/packages/desktop_ui/lib/desktop_ui/validate.ex @@ -82,7 +82,7 @@ defmodule DesktopUi.Validate do :renderer_supports_all_iur_kinds, iur_widget_coverage_complete?(), %{ - expected_count: 45, + expected_count: expected_renderer_kind_count(), actual_count: length(DesktopUi.Renderer.supported_kinds()), supported_kinds: DesktopUi.Renderer.supported_kinds() } @@ -822,10 +822,10 @@ defmodule DesktopUi.Validate do defp traceability_includes_direct_prefix?(_result, _prefix), do: false - # Verifies that all 45 canonical IUR widget kinds are supported by the renderer. - # Returns true when the renderer supports the complete set of IUR widget kinds. + # Verifies that the renderer supports the complete native/canonical widget + # surface exposed through DesktopUi.Widgets. defp iur_widget_coverage_complete? do - expected_count = 45 + expected_count = expected_renderer_kind_count() actual_count = length(DesktopUi.Renderer.supported_kinds()) actual_count >= expected_count and @@ -835,6 +835,10 @@ defmodule DesktopUi.Validate do ) end + defp expected_renderer_kind_count do + DesktopUi.Widgets.kinds() |> length() + end + defp package_root do Path.expand("../..", __DIR__) end diff --git a/packages/desktop_ui/lib/desktop_ui/widgets/foundational.ex b/packages/desktop_ui/lib/desktop_ui/widgets/foundational.ex index f81be770..80c21ef3 100644 --- a/packages/desktop_ui/lib/desktop_ui/widgets/foundational.ex +++ b/packages/desktop_ui/lib/desktop_ui/widgets/foundational.ex @@ -4,6 +4,7 @@ defmodule DesktopUi.Widgets.Foundational do """ alias DesktopUi.Widget + alias DesktopUi.Widgets.Navigation, as: NavigationWidget @spec kinds() :: [atom()] def kinds do @@ -144,6 +145,10 @@ defmodule DesktopUi.Widgets.Foundational do @spec button(String.t() | atom(), String.t(), keyword()) :: Widget.t() def button(id, label, opts \\ []) do + click_event = + NavigationWidget.event_payload(opts) || + event_payload(opts, :on_click, %{intent: Keyword.get(opts, :intent, :activate)}) + Widget.new(:button, id: id, metadata: metadata(opts, focusable: true, role: :button), @@ -152,7 +157,7 @@ defmodule DesktopUi.Widgets.Foundational do styles: styles(opts), events: %{ - click: event_payload(opts, :on_click, %{intent: Keyword.get(opts, :intent, :activate)}), + click: click_event, shortcut: shortcut_event(opts) } |> Enum.reject(fn {_key, value} -> is_nil(value) end) @@ -182,6 +187,10 @@ defmodule DesktopUi.Widgets.Foundational do @spec link(String.t() | atom(), String.t(), String.t(), keyword()) :: Widget.t() def link(id, label, href, opts \\ []) do + click_event = + NavigationWidget.event_payload(opts) || + event_payload(opts, :on_follow, %{intent: Keyword.get(opts, :intent, :open_link)}) + Widget.new(:link, id: id, metadata: metadata(opts, focusable: true, role: :link), @@ -189,13 +198,17 @@ defmodule DesktopUi.Widgets.Foundational do attributes: %{label: label, href: href}, styles: styles(opts), events: %{ - click: event_payload(opts, :on_follow, %{intent: Keyword.get(opts, :intent, :open_link)}) + click: click_event } ) end @spec command(String.t() | atom(), String.t(), keyword()) :: Widget.t() def command(id, label, opts \\ []) do + click_event = + NavigationWidget.event_payload(opts) || + event_payload(opts, :on_press, %{intent: Keyword.get(opts, :intent, :run_command)}) + Widget.new(:command, id: id, metadata: @@ -210,8 +223,7 @@ defmodule DesktopUi.Widgets.Foundational do styles: styles(opts), events: %{ - click: - event_payload(opts, :on_press, %{intent: Keyword.get(opts, :intent, :run_command)}), + click: click_event, shortcut: shortcut_event(opts) } |> Enum.reject(fn {_key, value} -> is_nil(value) end) diff --git a/packages/desktop_ui/lib/desktop_ui/widgets/navigation.ex b/packages/desktop_ui/lib/desktop_ui/widgets/navigation.ex index 80dfd2c4..13ad54ea 100644 --- a/packages/desktop_ui/lib/desktop_ui/widgets/navigation.ex +++ b/packages/desktop_ui/lib/desktop_ui/widgets/navigation.ex @@ -125,42 +125,75 @@ defmodule DesktopUi.Widgets.Navigation do def event_payload(opts) when is_list(opts), do: opts |> Enum.into(%{}) |> event_payload() def event_payload(%{navigate_to: screen_id} = opts) when is_atom(screen_id) or is_binary(screen_id) do + params = Map.get(opts, :navigate_params, %{}) + %{ family: :navigation, + intent: Map.get(opts, :intent, :navigate_to), type: :navigate_to, screen_id: screen_id, - params: Map.get(opts, :navigate_params, %{}) + params: params, + target: %{navigation: %{action: :navigate_to, screen: screen_id, params: params}}, + payload: Map.get(opts, :payload, %{}) } end def event_payload(%{replace_with: screen_id} = opts) when is_atom(screen_id) or is_binary(screen_id) do + params = Map.get(opts, :navigate_params, %{}) + %{ family: :navigation, + intent: Map.get(opts, :intent, :replace_with), type: :replace_with, screen_id: screen_id, - params: Map.get(opts, :navigate_params, %{}) + params: params, + target: %{navigation: %{action: :replace_with, screen: screen_id, params: params}}, + payload: Map.get(opts, :payload, %{}) } end def event_payload(%{go_back: true}) do - %{family: :navigation, type: :go_back} + %{ + family: :navigation, + intent: :go_back, + type: :go_back, + target: %{navigation: %{action: :go_back}}, + payload: %{} + } end def event_payload(%{go_forward: true}) do - %{family: :navigation, type: :go_forward} + %{ + family: :navigation, + intent: :go_forward, + type: :go_forward, + target: %{navigation: %{action: :go_forward}}, + payload: %{} + } end def event_payload(%{open_modal: screen_id} = opts) when is_atom(screen_id) or is_binary(screen_id) do + params = Map.get(opts, :navigate_params, %{}) + %{ family: :navigation, + intent: Map.get(opts, :intent, :open_modal), type: :open_modal, screen_id: screen_id, - params: Map.get(opts, :navigate_params, %{}) + params: params, + target: %{navigation: %{action: :open_modal, modal: screen_id, params: params}}, + payload: Map.get(opts, :payload, %{}) } end def event_payload(%{close_modal: true}) do - %{family: :navigation, type: :close_modal} + %{ + family: :navigation, + intent: :close_modal, + type: :close_modal, + target: %{navigation: %{action: :close_modal}}, + payload: %{} + } end def event_payload(_), do: nil diff --git a/packages/desktop_ui/test/desktop_ui/canonical_navigation_desktop_runtime_integration_test.exs b/packages/desktop_ui/test/desktop_ui/canonical_navigation_desktop_runtime_integration_test.exs new file mode 100644 index 00000000..a9ad0b3b --- /dev/null +++ b/packages/desktop_ui/test/desktop_ui/canonical_navigation_desktop_runtime_integration_test.exs @@ -0,0 +1,214 @@ +defmodule DesktopUi.CanonicalNavigationDesktopRuntimeIntegrationTest do + use ExUnit.Case, async: true + + alias DesktopUi.Runtime + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport + + defmodule HomeScreen do + def render(_assigns), do: %{} + end + + defmodule SettingsScreen do + def render(_assigns), do: %{} + end + + defmodule SettingsDialogScreen do + def render(_assigns), do: %{} + end + + defmodule ScreenRegistry do + def register do + %{ + home: {HomeScreen, title: "Home"}, + settings: {SettingsScreen, title: "Settings"}, + settings_dialog: {SettingsDialogScreen, title: "Settings Dialog", modal_only?: true} + } + end + + def get_screen(:home), do: HomeScreen + def get_screen(:settings), do: SettingsScreen + def get_screen(:settings_dialog), do: SettingsDialogScreen + def get_screen(_), do: nil + + def screen_metadata(:home), do: %{title: "Home"} + def screen_metadata(:settings), do: %{title: "Settings"} + def screen_metadata(:settings_dialog), do: %{title: "Settings Dialog", modal_only?: true} + def screen_metadata(_), do: %{} + end + + test "desktop runtime maps canonical navigation fixtures through the controller while preserving window state" do + navigate_fixture = BoundaryTransport.boundary_fixture!("screen_transition--settings_profile") + replace_fixture = BoundaryTransport.boundary_fixture!("replace_transition--home") + + assert {:ok, runtime_state} = + Runtime.mount_native_screen(base_screen(), + platform_target: :linux, + screen_registry: ScreenRegistry, + navigation_screen_id: :home + ) + + primary_window = runtime_state.windows.primary + initial_runtime_id = runtime_state.runtime_id + + assert {:ok, after_navigate, navigate_route} = + Runtime.dispatch_native_event( + runtime_state, + family: :navigation, + intent: navigate_fixture.interaction.intent, + widget_id: "settings-link", + target: navigate_fixture.descriptor.target, + payload: navigate_fixture.signal_data + ) + + assert navigate_route.route == :canonical_boundary + assert navigate_route.translation.target == navigate_fixture.descriptor.target + assert after_navigate.screen_id == "settings" + assert after_navigate.current_screen_module == SettingsScreen + assert after_navigate.screen_params == %{tab: :profile} + assert after_navigate.title == "Settings" + assert after_navigate.runtime_id == initial_runtime_id + assert after_navigate.windows.primary == primary_window + assert after_navigate.navigation_state.history == [{:home, HomeScreen, %{}}] + + assert {:ok, replace_translation} = + DesktopUi.Transport.from_interaction( + replace_fixture.interaction, + platform_target: :linux, + widget_id: "home-link", + runtime_id: after_navigate.runtime_id, + screen: after_navigate.screen_id, + payload: replace_fixture.signal_data + ) + + assert {:ok, after_replace, replace_route} = + Runtime.handle_boundary_signal(after_navigate, replace_translation.signal) + + assert replace_route.route == :canonical_boundary + assert after_replace.screen_id == "home" + assert after_replace.current_screen_module == HomeScreen + assert after_replace.screen_params == %{source: :command_palette} + assert after_replace.windows.primary == primary_window + assert after_replace.navigation_state.history == [{:home, HomeScreen, %{}}] + assert after_replace.screen.metadata.navigation_action == :replace_with + end + + test "desktop runtime preserves history and modal semantics across canonical actions" do + navigate_fixture = BoundaryTransport.boundary_fixture!("screen_transition--settings_profile") + modal_fixture = BoundaryTransport.boundary_fixture!("modal_transition--settings_dialog") + history_fixture = BoundaryTransport.boundary_fixture!("history_transition--back") + + assert {:ok, runtime_state} = + Runtime.mount_native_screen(base_screen(), + platform_target: :linux, + screen_registry: ScreenRegistry, + navigation_screen_id: :home + ) + + assert {:ok, after_navigate, _route} = + Runtime.dispatch_native_event( + runtime_state, + family: :navigation, + intent: navigate_fixture.interaction.intent, + widget_id: "settings-link", + target: navigate_fixture.descriptor.target, + payload: navigate_fixture.signal_data + ) + + assert {:ok, with_modal, modal_route} = + Runtime.dispatch_native_event( + after_navigate, + family: :navigation, + intent: modal_fixture.interaction.intent, + widget_id: "settings-dialog-button", + target: modal_fixture.descriptor.target, + payload: modal_fixture.signal_data + ) + + assert modal_route.translation.target == modal_fixture.descriptor.target + assert with_modal.screen_id == "settings" + assert with_modal.navigation_state.modal_open? + assert with_modal.navigation_state.modals == [ + {:settings_dialog, SettingsDialogScreen, %{mode: :advanced}} + ] + + assert {:ok, after_close, _close_route} = + Runtime.dispatch_native_event( + with_modal, + family: :navigation, + intent: :close_settings_modal, + widget_id: "close-settings-dialog", + target: %{navigation: %{action: :close_modal, modal: :settings_dialog}} + ) + + refute after_close.navigation_state.modal_open? + assert after_close.navigation_state.modals == [] + assert after_close.screen_id == "settings" + + assert {:ok, after_back, back_route} = + Runtime.dispatch_native_event( + after_close, + family: :navigation, + intent: history_fixture.interaction.intent, + widget_id: "back-button", + target: history_fixture.descriptor.target, + payload: history_fixture.signal_data + ) + + assert back_route.translation.target == history_fixture.descriptor.target + assert after_back.screen_id == "home" + assert after_back.current_screen_module == HomeScreen + assert after_back.navigation_state.forward == [{:settings, SettingsScreen, %{tab: :profile}}] + end + + test "desktop runtime reports invalid screen targets, leaked route syntax, and modal mismatches deterministically" do + assert {:ok, runtime_state} = + Runtime.mount_native_screen(base_screen(), + platform_target: :linux, + screen_registry: ScreenRegistry, + navigation_screen_id: :home + ) + + assert {:error, %Runtime.Error{reason: :unresolved_navigation_target}} = + Runtime.dispatch_native_event( + runtime_state, + family: :navigation, + intent: :open_missing_screen, + widget_id: "missing-link", + target: %{navigation: %{action: :navigate_to, screen: :missing}} + ) + + assert {:error, %Runtime.Error{reason: :host_route_navigation_syntax}} = + Runtime.dispatch_native_event( + runtime_state, + family: :navigation, + intent: :open_settings_screen, + widget_id: "settings-link", + target: %{ + navigation: %{action: :navigate_to, screen: :settings, route: "/settings"} + } + ) + + assert {:error, %Runtime.Error{reason: :invalid_modal_transition}} = + Runtime.dispatch_native_event( + runtime_state, + family: :navigation, + intent: :close_settings_modal, + widget_id: "close-settings-dialog", + target: %{navigation: %{action: :close_modal, modal: :settings_dialog}} + ) + end + + defp base_screen do + %{ + id: "workspace", + title: "Workspace", + root: + DesktopUi.Widgets.window("workspace-window", "Workspace", [ + DesktopUi.Widgets.column("workspace-layout", [ + DesktopUi.Widgets.text("workspace-title", "Workspace"), + DesktopUi.Widgets.button("settings-link", "Settings", navigate_to: :settings) + ]) + ]) + } + end +end diff --git a/packages/desktop_ui/test/desktop_ui/canonical_navigation_non_web_examples_integration_test.exs b/packages/desktop_ui/test/desktop_ui/canonical_navigation_non_web_examples_integration_test.exs new file mode 100644 index 00000000..00a9bf4a --- /dev/null +++ b/packages/desktop_ui/test/desktop_ui/canonical_navigation_non_web_examples_integration_test.exs @@ -0,0 +1,45 @@ +defmodule DesktopUi.CanonicalNavigationNonWebExamplesIntegrationTest do + use ExUnit.Case, async: true + + test "desktop maintained navigation review preserves shared canonical targets through one-window navigation" do + review = DesktopUi.Examples.navigation_transition_review() + metadata = DesktopUi.Examples.metadata(:navigation_transition_review) + reference = DesktopUi.reference() + summary = DesktopUi.info() + + assert review.fixture_ids == [ + "screen_transition--settings_profile", + "replace_transition--home", + "history_transition--back", + "modal_transition--settings_dialog" + ] + + assert review.parity.shared_fixture_targets_consumed? + assert review.parity.window_preserved_across_transitions? + assert review.parity.registry_resolution_preserved? + assert review.parity.history_semantics_preserved? + assert review.parity.modal_stack_preserved? + assert review.states.after_navigate.title == "Settings" + assert review.states.after_reports.screen_id == "reports" + assert review.states.after_reports.history_depth == 2 + assert review.states.after_back.forward_depth == 1 + assert review.states.after_replace.screen_params == %{source: :command_palette} + assert review.states.mounted.primary_window == review.states.after_replace.primary_window + assert review.routes.replace.target == review.fixture_targets.replace + assert review.routes.forward.target == review.fixture_targets.forward + assert metadata.workflow == :navigation_review + assert metadata.parity_group == :navigation_transition_review + assert :navigation_transition_review in reference.examples.comparison_ids + assert :navigation_transition_review in summary.examples.comparison_ids + assert :navigation_review in summary.examples.workflows + end + + test "desktop maintained navigation review keeps canonical targets free of host route syntax" do + review = DesktopUi.Examples.navigation_transition_review() + + assert Enum.all?(review.routes, fn {_step, route} -> + match?(%{navigation: _}, route.target) and + get_in(route.target, [:navigation, :route]) == nil + end) + end +end diff --git a/packages/desktop_ui/test/desktop_ui/examples_test.exs b/packages/desktop_ui/test/desktop_ui/examples_test.exs index f0382743..8d3c706c 100644 --- a/packages/desktop_ui/test/desktop_ui/examples_test.exs +++ b/packages/desktop_ui/test/desktop_ui/examples_test.exs @@ -61,7 +61,11 @@ defmodule DesktopUi.ExamplesTest do :native_foundational, :native_advanced_operations, :native_transport_review, - :native_styled_review + :native_styled_review, + :basic_navigation, + :history_navigation, + :modal_navigation, + :master_detail_navigation ] assert reference.examples.canonical_ids == [ @@ -76,14 +80,19 @@ defmodule DesktopUi.ExamplesTest do :advanced_continuity, :transport_flow_review, :normalized_input_profiles, - :styled_continuity_review + :styled_continuity_review, + :navigation_transition_review ] assert summary.examples.native_ids == [ :native_foundational, :native_advanced_operations, :native_transport_review, - :native_styled_review + :native_styled_review, + :basic_navigation, + :history_navigation, + :modal_navigation, + :master_detail_navigation ] assert summary.examples.comparison_ids == [ @@ -91,7 +100,8 @@ defmodule DesktopUi.ExamplesTest do :advanced_continuity, :transport_flow_review, :normalized_input_profiles, - :styled_continuity_review + :styled_continuity_review, + :navigation_transition_review ] end @@ -149,4 +159,55 @@ defmodule DesktopUi.ExamplesTest do "desktop_ui.examples.styled_continuity_review.comparison" end) end + + test "navigation review exposes shared canonical fixtures through one-window desktop navigation" do + review = DesktopUi.Examples.navigation_transition_review() + metadata = DesktopUi.Examples.metadata(:navigation_transition_review) + matrix = DesktopUi.Examples.coverage_matrix() + + assert review.id == :navigation_transition_review + + assert review.fixture_ids == [ + "screen_transition--settings_profile", + "replace_transition--home", + "history_transition--back", + "modal_transition--settings_dialog" + ] + + assert review.fixture_targets.navigate == + %{ + navigation: %{ + action: :navigate_to, + kind: :screen_transition, + params: %{tab: :profile}, + screen: :settings + } + } + + assert %{navigation: modal_target} = review.fixture_targets.modal + assert modal_target.action == :open_modal + assert modal_target.kind == :modal_transition + assert modal_target.modal == :settings_dialog + assert modal_target.params == %{mode: :advanced} + assert modal_target.metadata == %{surface: :workspace} + + assert review.parity.shared_fixture_targets_consumed? + assert review.parity.window_preserved_across_transitions? + assert review.parity.registry_resolution_preserved? + assert review.parity.history_semantics_preserved? + assert review.parity.modal_stack_preserved? + assert review.states.after_navigate.screen_id == "settings" + assert review.states.after_reports.screen_id == "reports" + assert review.states.after_replace.screen_id == "home" + + assert review.states.with_modal.current_modal == %{ + modal: :settings_dialog, + params: %{mode: :advanced} + } + + assert review.routes.forward.target == %{navigation: %{action: :go_forward}} + assert metadata.workflow == :navigation_review + assert metadata.parity_group == :navigation_transition_review + assert matrix.parity_groups.navigation_transition_review == [:navigation_transition_review] + end end diff --git a/packages/desktop_ui/test/desktop_ui/navigation/signal_test.exs b/packages/desktop_ui/test/desktop_ui/navigation/signal_test.exs index a8f347bd..71fd473d 100644 --- a/packages/desktop_ui/test/desktop_ui/navigation/signal_test.exs +++ b/packages/desktop_ui/test/desktop_ui/navigation/signal_test.exs @@ -136,6 +136,27 @@ defmodule DesktopUi.Navigation.SignalTest do assert signal.type == :close_modal end + test "creates navigation signals from canonical transition descriptors" do + assert {:ok, navigate_signal} = + Signal.from_map(%{ + target: %{navigation: %{action: :navigate_to, screen: :settings, params: %{tab: :profile}}} + }) + + assert {:ok, modal_signal} = + Signal.from_map(%{ + action: :open_modal, + modal: :settings_dialog, + params: %{mode: :advanced} + }) + + assert navigate_signal.type == :navigate_to + assert navigate_signal.screen_id == :settings + assert navigate_signal.params == %{tab: :profile} + assert modal_signal.type == :open_modal + assert modal_signal.screen_id == :settings_dialog + assert modal_signal.params == %{mode: :advanced} + end + test "returns error for unknown navigation type" do assert {:error, :unknown_navigation_type} = Signal.from_map(%{type: :invalid}) end diff --git a/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs b/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs index 79f33b20..256b2a07 100644 --- a/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs +++ b/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs @@ -8,8 +8,6 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do @moduletag :phase_eleven - @iur_widget_count 45 - @all_iur_kinds MapSet.new([ # Foundational (13) :badge, :button, :command, :content, :hero, :icon, :image, :label, :link, :separator, :spacer, @@ -21,8 +19,8 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do :breadcrumbs, :list, :menu, :tabs, # Data (7) :inspector, :info_list, :key_value, :markdown_viewer, :stat, :table, :tree_view, - # Feedback (6) - :alert_dialog, :dialog, :inline_feedback, :progress, :status, :toast, + # Feedback (7) + :alert_dialog, :dialog, :inline_feedback, :progress, :sparkline, :status, :toast, # Operational (7) :cluster_dashboard, :command_palette, :log_viewer, :process_monitor, :stream_widget, :supervision_tree_viewer, :window_command, @@ -34,6 +32,8 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do :window ]) + @iur_widget_count MapSet.size(@all_iur_kinds) + setup_all do {:ok, capabilities: ensure_visible_runner_capabilities()} end @@ -238,17 +238,17 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do end describe "11.5 Mapper coverage and diagnostics scenarios" do - test "canonical mapper handles all 45 IUR widget kinds without fallback" do + test "canonical mapper handles the full maintained renderer kind surface without fallback" do supported = Renderer.supported_kinds() |> MapSet.new() # All IUR widget kinds should be supported missing_kinds = MapSet.difference(@all_iur_kinds, supported) assert MapSet.size(missing_kinds) == 0, - "Expected all 45 IUR kinds to be supported, but missing: #{inspect(MapSet.to_list(missing_kinds))}" + "Expected all maintained renderer kinds to be supported, but missing: #{inspect(MapSet.to_list(missing_kinds))}" end - test "renderer.supported_kinds returns exactly 45 kinds" do + test "renderer.supported_kinds returns the full maintained renderer kind surface" do count = length(Renderer.supported_kinds()) assert count >= @iur_widget_count, diff --git a/packages/desktop_ui/test/desktop_ui/phase_one_five_integration_test.exs b/packages/desktop_ui/test/desktop_ui/phase_one_five_integration_test.exs index 85ff5cf4..384beb3a 100644 --- a/packages/desktop_ui/test/desktop_ui/phase_one_five_integration_test.exs +++ b/packages/desktop_ui/test/desktop_ui/phase_one_five_integration_test.exs @@ -29,7 +29,7 @@ defmodule DesktopUi.PhaseOneFiveIntegrationTest do assert canonical_boot.runtime.direct_native_and_canonical_share_runtime assert canonical_boot.frame_request.presentation.render_plan.presentation.validation_state == - :render_plan_ready + :iur_renderer_complete end test "minimal native screens can produce native-window state and widget-complete draw plans" do diff --git a/packages/desktop_ui/test/desktop_ui/sdl3_render_plan_test.exs b/packages/desktop_ui/test/desktop_ui/sdl3_render_plan_test.exs index 9056eff5..1e8989b7 100644 --- a/packages/desktop_ui/test/desktop_ui/sdl3_render_plan_test.exs +++ b/packages/desktop_ui/test/desktop_ui/sdl3_render_plan_test.exs @@ -48,7 +48,7 @@ defmodule DesktopUi.Sdl3RenderPlanTest do assert plan.presentation.logical_units refute plan.presentation.placeholder_draw_operations assert plan.presentation.widget_complete_draw_operations - assert plan.presentation.validation_state == :render_plan_ready + assert plan.presentation.validation_state == :iur_renderer_complete assert plan.diagnostics.window_count == 2 assert plan.diagnostics.draw_operation_count > 0 assert plan.diagnostics.draw_kind_counts.window_chrome >= 1 diff --git a/packages/desktop_ui/test/desktop_ui/transport_test.exs b/packages/desktop_ui/test/desktop_ui/transport_test.exs index 845c1e7e..4f5775ea 100644 --- a/packages/desktop_ui/test/desktop_ui/transport_test.exs +++ b/packages/desktop_ui/test/desktop_ui/transport_test.exs @@ -142,4 +142,28 @@ defmodule DesktopUi.TransportTest do assert Transport.diagnostics().mapping_summary.platform_targets == DesktopUi.Platform.targets() end + + test "transport diagnostics reject leaked host-route syntax on canonical navigation targets" do + interaction = + Interaction.navigation_transition( + intent: :open_settings_screen, + action: :navigate_to, + screen: :settings + ) + + assert {:ok, translation} = + Transport.from_interaction( + interaction, + platform_target: :linux, + widget_id: "settings-link", + runtime_id: "desktop-ui:workspace", + screen: "workspace" + ) + + translation = + put_in(translation.target.navigation[:route], "/settings") + + assert {:error, %DesktopUi.Transport.Error{reason: :host_route_navigation_syntax}} = + Transport.validate_translation(translation) + end end diff --git a/packages/desktop_ui/test/desktop_ui/widgets/navigation_test.exs b/packages/desktop_ui/test/desktop_ui/widgets/navigation_test.exs index fdff57f9..cea5bd99 100644 --- a/packages/desktop_ui/test/desktop_ui/widgets/navigation_test.exs +++ b/packages/desktop_ui/test/desktop_ui/widgets/navigation_test.exs @@ -166,5 +166,31 @@ defmodule DesktopUi.Widgets.NavigationTest do assert widget.id == :item_list assert widget.metadata.role == :list end + + test "foundational action widgets can emit canonical navigation targets" do + button = + DesktopUi.Widgets.button("open-settings", "Settings", + navigate_to: :settings, + navigate_params: %{tab: :profile} + ) + + command = + DesktopUi.Widgets.command("open-dialog", "Open Dialog", + open_modal: :settings_dialog, + navigate_params: %{mode: :advanced} + ) + + assert button.events.click.target.navigation == %{ + action: :navigate_to, + screen: :settings, + params: %{tab: :profile} + } + + assert command.events.click.target.navigation == %{ + action: :open_modal, + modal: :settings_dialog, + params: %{mode: :advanced} + } + end end end diff --git a/packages/live_ui/lib/live_ui/demo/screen.ex b/packages/live_ui/lib/live_ui/demo/screen.ex index 52300a5c..8712dbc3 100644 --- a/packages/live_ui/lib/live_ui/demo/screen.ex +++ b/packages/live_ui/lib/live_ui/demo/screen.ex @@ -9,6 +9,22 @@ defmodule LiveUi.Demo.Screen do alias LiveUi.Demo alias LiveUi.Demo.{Catalog, Style, WidgetPreview} + @widget_demo_runtime_events [ + "click:widget_demo_button", + "navigation:widget_demo_link", + "change:widget_demo_text_input", + "change:widget_demo_toggle", + "change:widget_demo_select", + "click:widget_demo_menu", + "selection:widget_demo_tabs", + "selection:widget_demo_list", + "selection:widget_demo_table", + "selection:widget_demo_tree", + "click:widget_demo_context_menu", + "change:widget_demo_command_query", + "selection:widget_demo_command_palette" + ] + @impl true def mount_defaults do %{ @@ -18,6 +34,14 @@ defmodule LiveUi.Demo.Screen do } end + @impl true + def event_routes do + Map.new(@widget_demo_runtime_events, &{&1, :widget_demo_interaction}) + end + + @impl true + def handle_event(:widget_demo_interaction, _payload, assigns), do: {:ok, assigns} + @impl true def render(assigns) do current_category = diff --git a/packages/live_ui/test/live_ui/demo_interaction_test.exs b/packages/live_ui/test/live_ui/demo_interaction_test.exs index 4909449e..33c5f0a1 100644 --- a/packages/live_ui/test/live_ui/demo_interaction_test.exs +++ b/packages/live_ui/test/live_ui/demo_interaction_test.exs @@ -20,6 +20,7 @@ defmodule LiveUi.DemoInteractionTest do demo_socket(runtime_state) ) + assert updated_socket.assigns.runtime_event_error == nil assert updated_socket.assigns.runtime_state.assigns.widget_demo_state.button.clicks == 1 html = @@ -51,6 +52,7 @@ defmodule LiveUi.DemoInteractionTest do demo_socket(runtime_state) ) + assert updated_socket.assigns.runtime_event_error == nil assert updated_socket.assigns.runtime_state.assigns.widget_demo_state.text_input.value == "Signals" @@ -81,6 +83,7 @@ defmodule LiveUi.DemoInteractionTest do demo_socket(runtime_state) ) + assert updated_socket.assigns.runtime_event_error == nil assert updated_socket.assigns.runtime_state.assigns.widget_demo_state.tabs.active == "signals" html = diff --git a/packages/terminal_ui/lib/terminal_ui/examples.ex b/packages/terminal_ui/lib/terminal_ui/examples.ex index d4fbf913..9c2a9282 100644 --- a/packages/terminal_ui/lib/terminal_ui/examples.ex +++ b/packages/terminal_ui/lib/terminal_ui/examples.ex @@ -4,6 +4,7 @@ defmodule TerminalUi.Examples do """ alias UnifiedIUR.{Canvas, Element, Interaction, Layer, Layout} + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport alias UnifiedIUR.Widgets.{Advanced, Data, Feedback, Foundational, Input, Navigation} @spec native_foundational_screen() :: map() @@ -583,7 +584,8 @@ defmodule TerminalUi.Examples do transport_flow_review: transport_flow_comparison(), normalized_input_profiles: normalized_input_comparison(), styled_continuity_review: styled_continuity_comparison(), - styled_degradation_review: styled_degradation_comparison() + styled_degradation_review: styled_degradation_comparison(), + navigation_transition_review: navigation_transition_review() } end @@ -639,6 +641,7 @@ defmodule TerminalUi.Examples do :canonical_styled_review, :styled_continuity_review ], + navigation_review: [:navigation_transition_review], degradation_review: [:styled_degradation_review], parity_review: [:foundational_continuity, :advanced_continuity], capability_review: [:advanced_capability_continuity, :styled_degradation_review] @@ -666,7 +669,8 @@ defmodule TerminalUi.Examples do :canonical_styled_review, :styled_continuity_review, :styled_degradation_review - ] + ], + navigation_transition_review: [:navigation_transition_review] } } end @@ -982,6 +986,58 @@ defmodule TerminalUi.Examples do } end + @spec navigation_transition_review() :: map() + def navigation_transition_review do + fixtures = navigation_fixtures() + + {:ok, raw_state} = + TerminalUi.Runtime.mount_native_screen(navigation_review_screen(), backend_mode: :raw) + + {:ok, tty_state} = + TerminalUi.Runtime.mount_native_screen(navigation_review_screen(), backend_mode: :tty) + + {raw_states, raw_routes} = execute_terminal_navigation_flow(raw_state, fixtures) + {tty_states, tty_routes} = execute_terminal_navigation_flow(tty_state, fixtures) + + %{ + id: :navigation_transition_review, + summary: + "Review canonical navigation transitions through raw and tty terminal realizations", + coverage: [ + :canonical_navigation_transitions, + :bounded_history, + :modal_realization, + :capability_aware_navigation + ], + fixture_ids: navigation_fixture_ids(), + fixture_targets: navigation_fixture_targets(fixtures), + raw: raw_states, + tty: tty_states, + raw_routes: raw_routes, + tty_routes: tty_routes, + parity: %{ + shared_fixture_targets_consumed?: + Enum.all?(raw_routes, fn {step, summary} -> + summary.target == tty_routes[step].target and no_host_route_syntax?(summary.target) + end), + screen_transition_meaning_preserved?: + raw_states.after_navigate.active_screen_id == "settings" and + tty_states.after_navigate.active_screen_id == "settings" and + raw_states.after_replace.active_screen_id == "home" and + tty_states.after_replace.active_screen_id == "home", + modal_degradation_explicit?: + raw_states.with_modal.current_modal.realization == :inline_overlay and + tty_states.with_modal.current_modal.realization == :focused_surface and + tty_states.with_modal.last_realization.degraded?, + history_semantics_preserved?: + raw_states.after_back.active_screen_id == "settings" and + tty_states.after_back.active_screen_id == "settings" and + raw_states.after_forward.active_screen_id == "reports" and + tty_states.after_forward.active_screen_id == "reports" + } + } + end + defp runtime_summary(runtime_state) do %{ source_kind: runtime_state.source_kind, @@ -1025,6 +1081,7 @@ defmodule TerminalUi.Examples do family: Map.get(route_result, :family, translation.family), input_family: Map.get(route_result, :input_family, Map.get(translation, :input_family)), boundary: Map.get(route_result, :boundary, translation.boundary), + target: Map.get(translation, :target), runtime_event: Map.get(route_result, :runtime_event, translation.runtime_event), local_handling: Map.get(route_result, :local_handling, Map.get(translation, :local_handling)), @@ -1177,6 +1234,14 @@ defmodule TerminalUi.Examples do parity_with: [:native_transport_review, :canonical_transport_review], capability_profiles: [:rich_terminal] }, + %{ + id: :navigation_transition_review, + category: :mixed, + workflow: :navigation, + parity_group: :navigation_transition_review, + parity_with: [], + capability_profiles: [:rich_terminal, :fallback_terminal] + }, %{ id: :normalized_input_profiles, category: :mixed, @@ -1219,4 +1284,171 @@ defmodule TerminalUi.Examples do } ] end + + defp navigation_fixtures do + %{ + navigate: BoundaryTransport.boundary_fixture!("screen_transition--settings_profile"), + replace: BoundaryTransport.boundary_fixture!("replace_transition--home"), + back: BoundaryTransport.boundary_fixture!("history_transition--back"), + modal: BoundaryTransport.boundary_fixture!("modal_transition--settings_dialog") + } + end + + defp navigation_fixture_ids do + [ + "screen_transition--settings_profile", + "replace_transition--home", + "history_transition--back", + "modal_transition--settings_dialog" + ] + end + + defp navigation_fixture_targets(fixtures) do + %{ + navigate: fixtures.navigate.descriptor.target, + replace: fixtures.replace.descriptor.target, + back: fixtures.back.descriptor.target, + modal: fixtures.modal.descriptor.target, + close_modal: close_modal_target(), + reports: reports_target(), + forward: forward_target() + } + end + + defp execute_terminal_navigation_flow(runtime_state, fixtures) do + {:ok, after_navigate, navigate_route} = + TerminalUi.Runtime.dispatch_native_event( + runtime_state, + family: :navigation, + intent: fixtures.navigate.interaction.intent, + widget_id: "settings-link", + target: fixtures.navigate.descriptor.target, + payload: fixtures.navigate.signal_data + ) + + {:ok, with_modal, modal_route} = + TerminalUi.Runtime.dispatch_native_event( + after_navigate, + family: :navigation, + intent: fixtures.modal.interaction.intent, + widget_id: "settings-dialog-button", + target: fixtures.modal.descriptor.target, + payload: fixtures.modal.signal_data + ) + + {:ok, after_close, close_route} = + TerminalUi.Runtime.dispatch_native_event( + with_modal, + family: :navigation, + intent: :close_settings_modal, + widget_id: "close-settings-dialog", + target: close_modal_target() + ) + + {:ok, after_reports, reports_route} = + TerminalUi.Runtime.dispatch_native_event( + after_close, + family: :navigation, + intent: :open_reports_screen, + widget_id: "reports-link", + target: reports_target() + ) + + {:ok, after_back, back_route} = + TerminalUi.Runtime.dispatch_native_event( + after_reports, + family: :navigation, + intent: fixtures.back.interaction.intent, + widget_id: "back-button", + target: fixtures.back.descriptor.target, + payload: fixtures.back.signal_data + ) + + {:ok, after_forward, forward_route} = + TerminalUi.Runtime.dispatch_native_event( + after_back, + family: :navigation, + intent: :go_forward, + widget_id: "forward-button", + target: forward_target() + ) + + {:ok, after_replace, replace_route} = + TerminalUi.Runtime.dispatch_native_event( + after_forward, + family: :navigation, + intent: fixtures.replace.interaction.intent, + widget_id: "home-link", + target: fixtures.replace.descriptor.target, + payload: fixtures.replace.signal_data + ) + + states = %{ + mounted: TerminalUi.Runtime.navigation_summary(runtime_state), + after_navigate: TerminalUi.Runtime.navigation_summary(after_navigate), + with_modal: TerminalUi.Runtime.navigation_summary(with_modal), + after_close: TerminalUi.Runtime.navigation_summary(after_close), + after_reports: TerminalUi.Runtime.navigation_summary(after_reports), + after_back: TerminalUi.Runtime.navigation_summary(after_back), + after_forward: TerminalUi.Runtime.navigation_summary(after_forward), + after_replace: TerminalUi.Runtime.navigation_summary(after_replace) + } + + routes = %{ + navigate: route_summary(navigate_route), + modal: route_summary(modal_route), + close_modal: route_summary(close_route), + reports: route_summary(reports_route), + back: route_summary(back_route), + forward: route_summary(forward_route), + replace: route_summary(replace_route) + } + + {states, routes} + end + + defp navigation_review_screen do + %{ + id: "terminal-navigation-review", + title: "Terminal Navigation Review", + root: + TerminalUi.Widgets.column("terminal-navigation-root", [ + TerminalUi.Widgets.text("terminal-navigation-title", "Terminal Navigation Review"), + TerminalUi.Widgets.text( + "terminal-navigation-subtitle", + "Canonical screen transitions realized through screen replacement, modals, and bounded history" + ), + TerminalUi.Widgets.button("settings-link", "Settings", navigate_to: :settings), + TerminalUi.Widgets.button("reports-link", "Reports", navigate_to: :reports), + TerminalUi.Widgets.button("home-link", "Replace With Home", replace_with: :home), + TerminalUi.Widgets.button("back-button", "Back", go_back: true), + TerminalUi.Widgets.button("forward-button", "Forward", go_forward: true), + TerminalUi.Widgets.button("settings-dialog-button", "Open Settings Dialog", + open_modal: :settings_dialog, + navigate_params: %{mode: :advanced} + ), + TerminalUi.Widgets.button("close-settings-dialog", "Close Settings Dialog", + close_modal: true + ) + ]) + } + end + + defp reports_target do + %{navigation: %{action: :navigate_to, screen: :reports, params: %{section: :daily}}} + end + + defp forward_target do + %{navigation: %{action: :go_forward}} + end + + defp close_modal_target do + %{navigation: %{action: :close_modal, modal: :settings_dialog}} + end + + defp no_host_route_syntax?(%{navigation: navigation}) when is_map(navigation) do + not Map.has_key?(navigation, :route) + end + + defp no_host_route_syntax?(_other), do: true end diff --git a/packages/terminal_ui/lib/terminal_ui/inspect.ex b/packages/terminal_ui/lib/terminal_ui/inspect.ex index 81cb1ec3..33fc45d4 100644 --- a/packages/terminal_ui/lib/terminal_ui/inspect.ex +++ b/packages/terminal_ui/lib/terminal_ui/inspect.ex @@ -97,6 +97,10 @@ defmodule TerminalUi.Inspect do TerminalUi.Examples.styled_degradation_comparison() end + defp preview_surface(%{category: :mixed, id: :navigation_transition_review}) do + TerminalUi.Examples.navigation_transition_review() + end + defp preview_native(screen, opts) do {:ok, state} = TerminalUi.Runtime.mount_native_screen(screen, opts) TerminalUi.Inspection.runtime_snapshot(state) diff --git a/packages/terminal_ui/lib/terminal_ui/runtime.ex b/packages/terminal_ui/lib/terminal_ui/runtime.ex index 8ff54910..5b4650be 100644 --- a/packages/terminal_ui/lib/terminal_ui/runtime.ex +++ b/packages/terminal_ui/lib/terminal_ui/runtime.ex @@ -12,6 +12,7 @@ defmodule TerminalUi.Runtime do Error, EventLoop, EventRouter, + Navigation, Realization, Screen, State, @@ -29,6 +30,7 @@ defmodule TerminalUi.Runtime do Boot, EventLoop, EventRouter, + Navigation, Screen, Realization, StyleResolver, @@ -58,6 +60,8 @@ defmodule TerminalUi.Runtime do :canonical_boundary_events, :normalized_terminal_inputs, :shared_event_routing, + :screen_navigation, + :capability_aware_navigation, :focus_traversal, :binding_surface, :deterministic_runtime_errors @@ -77,7 +81,8 @@ defmodule TerminalUi.Runtime do capability_aware: true, keyboard_first: true, renderer_boot_path_present: true, - style_surface_shared: true + style_surface_shared: true, + navigation_shared_runtime: true } end @@ -118,8 +123,9 @@ defmodule TerminalUi.Runtime do |> Map.put_new(:source_kind, runtime_state.source_kind) with {:ok, translation} <- Transport.from_native_event(attrs), - {:ok, route_result} <- EventRouter.route(runtime_state, translation) do - {:ok, apply_route(runtime_state, route_result), route_result} + {:ok, route_result} <- EventRouter.route(runtime_state, translation), + {:ok, routed_state} <- apply_route(runtime_state, route_result) do + {:ok, routed_state, route_result} end end @@ -140,20 +146,24 @@ defmodule TerminalUi.Runtime do {:ok, State.t(), map()} | {:error, Error.t() | term()} def handle_boundary_signal(%State{} = runtime_state, signal) do with {:ok, translation} <- Transport.from_boundary_signal(signal), - {:ok, route_result} <- EventRouter.route(runtime_state, translation) do - {:ok, apply_route(runtime_state, route_result), route_result} + {:ok, route_result} <- EventRouter.route(runtime_state, translation), + {:ok, routed_state} <- apply_route(runtime_state, route_result) do + {:ok, routed_state, route_result} end end defp apply_route(%State{} = runtime_state, route_result) do translation = route_result.translation - %{ - runtime_state - | focus: apply_focus(runtime_state.focus, translation, route_result.route), - event_loop: EventLoop.record_route(runtime_state.event_loop, route_result), - event_log: runtime_state.event_log ++ [event_log_entry(route_result)] - } + with {:ok, transitioned_state} <- maybe_apply_navigation(runtime_state, translation) do + {:ok, + %{ + transitioned_state + | focus: apply_focus(transitioned_state.focus, translation, route_result.route), + event_loop: EventLoop.record_route(transitioned_state.event_loop, route_result), + event_log: transitioned_state.event_log ++ [event_log_entry(route_result)] + }} + end end defp apply_focus(nil, _translation, _route), do: nil @@ -198,4 +208,19 @@ defmodule TerminalUi.Runtime do defp normalize_map(attrs) when is_map(attrs), do: Map.new(attrs) defp normalize_map(attrs) when is_list(attrs), do: Enum.into(attrs, %{}) + + @spec navigation_summary(State.t()) :: map() + def navigation_summary(%State{navigation: navigation}) do + Navigation.summary(navigation) + end + + def navigation_summary(_state), do: %{} + + defp maybe_apply_navigation(%State{} = runtime_state, translation) do + if Navigation.transition?(translation) do + Navigation.apply_transition(runtime_state, translation) + else + {:ok, runtime_state} + end + end end diff --git a/packages/terminal_ui/lib/terminal_ui/runtime/boot.ex b/packages/terminal_ui/lib/terminal_ui/runtime/boot.ex index c9cd24aa..86de7837 100644 --- a/packages/terminal_ui/lib/terminal_ui/runtime/boot.ex +++ b/packages/terminal_ui/lib/terminal_ui/runtime/boot.ex @@ -4,7 +4,7 @@ defmodule TerminalUi.Runtime.Boot do """ alias TerminalUi.{Backend, Capabilities} - alias TerminalUi.Runtime.{Error, EventLoop, Realization, Screen, State} + alias TerminalUi.Runtime.{Error, EventLoop, Navigation, Realization, Screen, State} alias TerminalUi.Widget @required_screen_keys [:id, :title, :root] @@ -72,6 +72,14 @@ defmodule TerminalUi.Runtime.Boot do terminal: :not_yet_attached, shutdown: :idle }, + navigation: + Navigation.initialize( + screen_id, + Map.get(screen, :title), + backend_mode, + capabilities, + opts + ), validation_state: runtime_validation_state(realization) }} end diff --git a/packages/terminal_ui/lib/terminal_ui/runtime/navigation.ex b/packages/terminal_ui/lib/terminal_ui/runtime/navigation.ex new file mode 100644 index 00000000..bb581bf2 --- /dev/null +++ b/packages/terminal_ui/lib/terminal_ui/runtime/navigation.ex @@ -0,0 +1,360 @@ +defmodule TerminalUi.Runtime.Navigation do + @moduledoc """ + Canonical screen-transition handling for the shared `terminal_ui` runtime. + """ + + alias TerminalUi.Runtime.{Error, Screen, State} + alias UnifiedIUR.Interaction + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport + + @default_history_limits %{raw: 20, tty: 5} + + @spec initialize(String.t(), String.t() | nil, atom(), map(), keyword()) :: map() + def initialize(screen_id, title, backend_mode, capabilities, opts \\ []) do + history_limit = Keyword.get(opts, :history_limit, Map.get(@default_history_limits, backend_mode, 10)) + + %{ + active: %{screen_id: screen_id, title: title, params: %{}}, + history: [], + forward: [], + modals: [], + history_limit: history_limit, + last_transition: nil, + last_realization: realization_summary(%{action: :mount}, backend_mode, capabilities, false) + } + end + + @spec transition?(map()) :: boolean() + def transition?(%{family: :navigation, target: target}) when is_map(target) do + not is_nil(Interaction.navigation_descriptor(target)) + end + + def transition?(_other), do: false + + @spec summary(map() | nil) :: map() + def summary(%{ + active: active, + history: history, + forward: forward, + modals: modals, + last_transition: last_transition, + last_realization: last_realization + }) do + %{ + active_screen_id: active.screen_id, + history_depth: length(history), + forward_depth: length(forward), + modal_depth: length(modals), + current_modal: modals |> List.last() |> modal_summary(), + last_transition: last_transition, + last_realization: last_realization + } + end + + def summary(_other), do: %{} + + @spec apply_transition(State.t(), map()) :: {:ok, State.t()} | {:error, Error.t()} + def apply_transition(%State{} = runtime_state, translation) when is_map(translation) do + with {:ok, descriptor} <- validate_navigation_descriptor(translation), + {:ok, navigation} <- apply_descriptor(runtime_state.navigation, descriptor, runtime_state) do + {:ok, synchronize_runtime(runtime_state, navigation, descriptor)} + end + end + + defp validate_navigation_descriptor(translation) do + descriptor = boundary_descriptor(translation) + + case BoundaryTransport.validate_boundary_descriptor(descriptor) do + :ok -> + {:ok, navigation_descriptor(translation)} + + {:error, {:forbidden_navigation_keys, keys}} -> + {:error, Error.new(:host_route_navigation_syntax, %{keys: keys}, :event_routing)} + + {:error, reason} -> + {:error, Error.new(:invalid_navigation_transition, %{reason: inspect(reason)}, :event_routing)} + end + end + + defp apply_descriptor(navigation, descriptor, runtime_state) do + action = Map.get(descriptor, :action) + + case action do + action when action in [:navigate_to, "navigate_to"] -> + {:ok, navigate_to(navigation, descriptor, runtime_state)} + + action when action in [:replace_with, "replace_with"] -> + {:ok, replace_with(navigation, descriptor, runtime_state)} + + action when action in [:go_back, "go_back"] -> + go_back(navigation, descriptor, runtime_state) + + action when action in [:go_forward, "go_forward"] -> + go_forward(navigation, descriptor, runtime_state) + + action when action in [:open_modal, "open_modal"] -> + {:ok, open_modal(navigation, descriptor, runtime_state)} + + action when action in [:close_modal, "close_modal"] -> + close_modal(navigation, descriptor, runtime_state) + + _other -> + {:error, + Error.new( + :unsupported_navigation_context, + %{action: action, reason: :unsupported_navigation_action}, + :event_routing + )} + end + end + + defp navigate_to(navigation, descriptor, runtime_state) do + target = screen_entry(descriptor) + + history = + [navigation.active | navigation.history] + |> Enum.take(navigation.history_limit) + + navigation + |> Map.put(:active, target) + |> Map.put(:history, history) + |> Map.put(:forward, []) + |> Map.put(:last_transition, descriptor) + |> Map.put( + :last_realization, + realization_summary(descriptor, runtime_state.backend_mode, runtime_state.capabilities, false) + ) + end + + defp replace_with(navigation, descriptor, runtime_state) do + navigation + |> Map.put(:active, screen_entry(descriptor)) + |> Map.put(:forward, []) + |> Map.put(:last_transition, descriptor) + |> Map.put( + :last_realization, + realization_summary(descriptor, runtime_state.backend_mode, runtime_state.capabilities, false) + ) + end + + defp go_back(%{history: []}, descriptor, _runtime_state) do + {:error, + Error.new( + :unsupported_navigation_context, + %{action: descriptor.action, reason: :empty_history}, + :event_routing + )} + end + + defp go_back(navigation, descriptor, runtime_state) do + [previous | history_rest] = navigation.history + + {:ok, + navigation + |> Map.put(:active, previous) + |> Map.put(:history, history_rest) + |> Map.put(:forward, [navigation.active | navigation.forward]) + |> Map.put(:last_transition, descriptor) + |> Map.put( + :last_realization, + realization_summary(descriptor, runtime_state.backend_mode, runtime_state.capabilities, false) + )} + end + + defp go_forward(%{forward: []}, descriptor, _runtime_state) do + {:error, + Error.new( + :unsupported_navigation_context, + %{action: descriptor.action, reason: :empty_forward}, + :event_routing + )} + end + + defp go_forward(navigation, descriptor, runtime_state) do + [next | forward_rest] = navigation.forward + + {:ok, + navigation + |> Map.put(:active, next) + |> Map.put(:history, [navigation.active | navigation.history] |> Enum.take(navigation.history_limit)) + |> Map.put(:forward, forward_rest) + |> Map.put(:last_transition, descriptor) + |> Map.put( + :last_realization, + realization_summary(descriptor, runtime_state.backend_mode, runtime_state.capabilities, false) + )} + end + + defp open_modal(navigation, descriptor, runtime_state) do + degrade? = runtime_state.backend_mode == :tty + + modal_entry = + %{ + modal: Map.get(descriptor, :modal), + params: normalize_map(Map.get(descriptor, :params, %{})), + metadata: normalize_map(Map.get(descriptor, :metadata, %{})), + realization: + if(degrade?, + do: :focused_surface, + else: :inline_overlay + ) + } + + navigation + |> Map.update!(:modals, &(&1 ++ [modal_entry])) + |> Map.put(:last_transition, descriptor) + |> Map.put( + :last_realization, + realization_summary(descriptor, runtime_state.backend_mode, runtime_state.capabilities, degrade?) + ) + end + + defp close_modal(%{modals: []}, descriptor, _runtime_state) do + {:error, + Error.new( + :invalid_modal_transition, + %{action: descriptor.action, reason: :no_modal}, + :event_routing + )} + end + + defp close_modal(navigation, descriptor, runtime_state) do + requested_modal = Map.get(descriptor, :modal) + current_modal = navigation.modals |> List.last() |> Map.get(:modal) + + cond do + not is_nil(requested_modal) and requested_modal != current_modal -> + {:error, + Error.new( + :invalid_modal_transition, + %{action: descriptor.action, requested_modal: requested_modal, current_modal: current_modal}, + :event_routing + )} + + true -> + {:ok, + navigation + |> Map.put(:modals, Enum.drop(navigation.modals, -1)) + |> Map.put(:last_transition, descriptor) + |> Map.put( + :last_realization, + realization_summary(descriptor, runtime_state.backend_mode, runtime_state.capabilities, false) + )} + end + end + + defp synchronize_runtime(runtime_state, navigation, descriptor) do + screen_id = navigation.active.screen_id + title = navigation.active.title || default_title(screen_id) + + %{ + runtime_state + | screen_id: screen_id, + title: title, + screen: synchronize_screen(runtime_state.screen, screen_id, title, navigation, descriptor), + navigation: navigation + } + end + + defp synchronize_screen(%Screen{} = screen, screen_id, title, navigation, descriptor) do + metadata = + screen.metadata + |> Map.put(:current_screen_id, screen_id) + |> Map.put(:navigation_action, Map.get(descriptor, :action)) + |> Map.put(:navigation_summary, summary(navigation)) + + %{screen | id: screen_id, title: title, metadata: metadata} + end + + defp screen_entry(descriptor) do + screen_id = Map.get(descriptor, :screen) + + %{ + screen_id: normalize_screen_id(screen_id), + title: default_title(screen_id), + params: normalize_map(Map.get(descriptor, :params, %{})) + } + end + + defp realization_summary(descriptor, backend_mode, capabilities, degraded?) do + action = Map.get(descriptor, :action) + + %{ + action: action, + backend_mode: backend_mode, + transition_mode: transition_mode(action, backend_mode), + degraded?: degraded?, + fallback: + if(degraded?, + do: fallback_for(action, capabilities), + else: nil + ), + intent_preserved?: true + } + end + + defp transition_mode(action, _backend_mode) when action in [:navigate_to, "navigate_to"], do: :screen_replacement + defp transition_mode(action, _backend_mode) when action in [:replace_with, "replace_with"], do: :screen_replacement + defp transition_mode(action, _backend_mode) when action in [:go_back, "go_back", :go_forward, "go_forward"], do: :bounded_history + defp transition_mode(action, :tty) when action in [:open_modal, "open_modal", :close_modal, "close_modal"], do: :focused_surface + defp transition_mode(action, _backend_mode) when action in [:open_modal, "open_modal", :close_modal, "close_modal"], do: :inline_overlay + defp transition_mode(_action, _backend_mode), do: :screen_replacement + + defp fallback_for(action, _capabilities) when action in [:open_modal, "open_modal", :close_modal, "close_modal"] do + :focused_surface + end + + defp fallback_for(action, capabilities) when action in [:navigate_to, "navigate_to", :replace_with, "replace_with"] do + if Map.get(capabilities, :positioning, false), do: nil, else: :screen_replacement + end + + defp fallback_for(_action, _capabilities), do: nil + + defp modal_summary(nil), do: nil + + defp modal_summary(modal) do + %{ + modal: modal.modal, + params: modal.params, + realization: modal.realization + } + end + + defp boundary_descriptor(translation) do + %{ + family: :navigation, + intent: Map.get(translation, :intent), + source_context: %{ + element_id: Map.get(translation, :widget_id), + scope: :screen + }, + target: Map.get(translation, :target, %{}), + metadata: %{} + } + end + + defp navigation_descriptor(translation) do + Interaction.navigation_descriptor(Map.get(translation, :target, %{})) || %{} + end + + defp normalize_screen_id(screen_id) when is_binary(screen_id), do: screen_id + defp normalize_screen_id(screen_id) when is_atom(screen_id), do: to_string(screen_id) + defp normalize_screen_id(screen_id), do: screen_id |> to_string() + + defp default_title(screen_id) when is_atom(screen_id) do + screen_id + |> Atom.to_string() + |> default_title() + end + + defp default_title(screen_id) when is_binary(screen_id) do + screen_id + |> String.split(~r/[_-]+/) + |> Enum.map(&String.capitalize/1) + |> Enum.join(" ") + end + + defp normalize_map(map) when is_map(map), do: Map.new(map) + defp normalize_map(map) when is_list(map), do: Enum.into(map, %{}) + defp normalize_map(_other), do: %{} +end diff --git a/packages/terminal_ui/lib/terminal_ui/runtime/state.ex b/packages/terminal_ui/lib/terminal_ui/runtime/state.ex index 88e38fa9..71ac991e 100644 --- a/packages/terminal_ui/lib/terminal_ui/runtime/state.ex +++ b/packages/terminal_ui/lib/terminal_ui/runtime/state.ex @@ -27,6 +27,7 @@ defmodule TerminalUi.Runtime.State do :backend_adapter, :event_loop, :lifecycle, + :navigation, :validation_state, event_log: [] ] @@ -47,6 +48,7 @@ defmodule TerminalUi.Runtime.State do backend_adapter: map(), event_loop: map(), lifecycle: map(), + navigation: map(), validation_state: atom(), event_log: [map()] } diff --git a/packages/terminal_ui/lib/terminal_ui/transport/diagnostics.ex b/packages/terminal_ui/lib/terminal_ui/transport/diagnostics.ex index 1d63aa2e..b78241bb 100644 --- a/packages/terminal_ui/lib/terminal_ui/transport/diagnostics.ex +++ b/packages/terminal_ui/lib/terminal_ui/transport/diagnostics.ex @@ -6,6 +6,8 @@ defmodule TerminalUi.Transport.Diagnostics do alias Jido.Signal alias TerminalUi.Transport.{Error, Normalize} alias TerminalUi.Transport.Signal, as: TransportSignal + alias UnifiedIUR.Interaction + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport @payload_leak_keys [:escape_sequence, :termcode, :ansi, :backend_payload, :terminal_bytes] @payload_leak_prefixes ["ansi_", "term_", "escape_", "csi_"] @@ -42,6 +44,7 @@ defmodule TerminalUi.Transport.Diagnostics do def validate_translation(%{} = translation) do with :ok <- validate_payload(Map.get(translation, :payload, %{}), :translation_payload), :ok <- validate_payload(Map.get(translation, :target, %{}), :translation_target), + :ok <- validate_navigation_target(translation), :ok <- maybe_validate_boundary_signal(translation) do if Map.get(translation, :boundary) == :boundary do validate_boundary_context(translation) @@ -136,6 +139,33 @@ defmodule TerminalUi.Transport.Diagnostics do defp leaked_key?(_key), do: false + defp validate_navigation_target(%{family: :navigation, target: target}) when is_map(target) do + descriptor = Interaction.navigation_descriptor(target) + + if is_nil(descriptor) do + :ok + else + case BoundaryTransport.validate_boundary_descriptor(%{ + family: :navigation, + intent: nil, + source_context: %{}, + target: target, + metadata: %{} + }) do + :ok -> + :ok + + {:error, {:forbidden_navigation_keys, keys}} -> + {:error, Error.host_route_navigation_syntax(keys)} + + {:error, reason} -> + {:error, Error.invalid_navigation_target(reason)} + end + end + end + + defp validate_navigation_target(_translation), do: :ok + defp maybe_missing(fields, field, nil), do: fields ++ [field] defp maybe_missing(fields, field, ""), do: fields ++ [field] defp maybe_missing(fields, _field, _value), do: fields diff --git a/packages/terminal_ui/lib/terminal_ui/transport/error.ex b/packages/terminal_ui/lib/terminal_ui/transport/error.ex index e76727ed..0d143bab 100644 --- a/packages/terminal_ui/lib/terminal_ui/transport/error.ex +++ b/packages/terminal_ui/lib/terminal_ui/transport/error.ex @@ -84,4 +84,22 @@ defmodule TerminalUi.Transport.Error do details: %{backend_mode: inspect(mode)} } 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 terminal navigation targets must not contain host-route syntax", + details: %{keys: keys} + } + end + + @spec invalid_navigation_target(term()) :: t() + def invalid_navigation_target(reason) do + %__MODULE__{ + reason: :invalid_navigation_target, + message: "terminal_ui navigation targets must satisfy the canonical screen-transition contract", + details: %{reason: inspect(reason)} + } + end end diff --git a/packages/terminal_ui/lib/terminal_ui/transport/normalize.ex b/packages/terminal_ui/lib/terminal_ui/transport/normalize.ex index 44a2cfa7..b5265a21 100644 --- a/packages/terminal_ui/lib/terminal_ui/transport/normalize.ex +++ b/packages/terminal_ui/lib/terminal_ui/transport/normalize.ex @@ -93,7 +93,20 @@ defmodule TerminalUi.Transport.Normalize do present?(fetch(attrs, :focus_target)) or present?(fetch(attrs, :focused)) -> {:ok, :focus} present?(fetch(attrs, :mouse_action)) or present?(fetch(attrs, :pointer)) -> {:ok, :mouse} present?(fetch(attrs, :key)) -> {:ok, :key} - true -> {:error, Error.invalid_native_event(attrs)} + true -> infer_input_family_from_family(attrs) + end + end + + defp infer_input_family_from_family(attrs) do + case normalize_family(fetch(attrs, :family)) do + :command -> {:ok, :shortcut} + :navigation -> {:ok, :key} + :selection -> {:ok, :mouse} + :click -> {:ok, :mouse} + :submit -> {:ok, :key} + :change -> {:ok, :key} + :focus -> {:ok, :focus} + _other -> {:error, Error.invalid_native_event(attrs)} end end diff --git a/packages/terminal_ui/lib/terminal_ui/widgets/foundational.ex b/packages/terminal_ui/lib/terminal_ui/widgets/foundational.ex index 76b47627..a09924c6 100644 --- a/packages/terminal_ui/lib/terminal_ui/widgets/foundational.ex +++ b/packages/terminal_ui/lib/terminal_ui/widgets/foundational.ex @@ -5,6 +5,7 @@ defmodule TerminalUi.Widgets.Foundational do alias TerminalUi.Widget alias TerminalUi.Widgets.Builder + alias TerminalUi.Widgets.Navigation, as: NavigationWidget @spec kinds() :: [atom()] def kinds do @@ -90,12 +91,15 @@ defmodule TerminalUi.Widgets.Foundational do @spec button(String.t() | atom(), String.t(), keyword()) :: Widget.t() def button(id, label, opts \\ []) do + keypress_event = NavigationWidget.event_payload(opts) || opts[:on_press] + command_event = if(is_nil(NavigationWidget.event_payload(opts)), do: opts[:on_command], else: nil) + Widget.new(:button, id: id, metadata: Builder.metadata(label, Keyword.merge([focusable: true, role: :button], opts)), state: Builder.state(opts, %{disabled: false}), attributes: %{label: label}, - events: Builder.events(keypress: opts[:on_press], command: opts[:on_command]), + events: Builder.events(keypress: keypress_event, command: command_event), styles: Builder.styles(opts) ) end @@ -115,12 +119,14 @@ defmodule TerminalUi.Widgets.Foundational do @spec link(String.t() | atom(), String.t(), String.t(), keyword()) :: Widget.t() def link(id, label, target, opts \\ []) do + activate_event = NavigationWidget.event_payload(opts) || opts[:on_follow] + Widget.new(:link, id: id, metadata: Builder.metadata(label, Keyword.merge([focusable: true, role: :link], opts)), state: Builder.state(opts, %{disabled: false}), attributes: %{label: label, target: target}, - events: Builder.events(activate: opts[:on_follow]), + events: Builder.events(activate: activate_event), styles: Builder.styles(opts) ) end @@ -128,6 +134,10 @@ defmodule TerminalUi.Widgets.Foundational do @spec command(String.t() | atom(), String.t(), keyword()) :: Widget.t() def command(id, label, opts \\ []) do command_name = Keyword.get(opts, :command, id) + command_event = + NavigationWidget.event_payload(opts) || + opts[:on_command] || + %{command: command_name, source: :terminal_ui} Widget.new(:command, id: id, @@ -138,10 +148,7 @@ defmodule TerminalUi.Widgets.Foundational do ), state: Builder.state(opts, %{disabled: false}), attributes: %{label: label, command: command_name}, - events: - Builder.events( - command: opts[:on_command] || %{command: command_name, source: :terminal_ui} - ), + events: Builder.events(command: command_event), styles: Builder.styles(opts) ) end diff --git a/packages/terminal_ui/lib/terminal_ui/widgets/navigation.ex b/packages/terminal_ui/lib/terminal_ui/widgets/navigation.ex index dc16eb7f..25c656c6 100644 --- a/packages/terminal_ui/lib/terminal_ui/widgets/navigation.ex +++ b/packages/terminal_ui/lib/terminal_ui/widgets/navigation.ex @@ -75,5 +75,80 @@ defmodule TerminalUi.Widgets.Navigation do ) end + @spec event_payload(keyword() | map()) :: map() | nil + def event_payload(opts) when is_list(opts), do: opts |> Enum.into(%{}) |> event_payload() + + def event_payload(%{navigate_to: screen_id} = opts) when is_atom(screen_id) or is_binary(screen_id) do + params = Map.get(opts, :navigate_params, %{}) + + %{ + family: :navigation, + intent: Map.get(opts, :intent, :navigate_to), + target: %{navigation: %{action: :navigate_to, screen: screen_id, params: params}}, + payload: Map.get(opts, :payload, %{}) + } + end + + def event_payload(%{replace_with: screen_id} = opts) when is_atom(screen_id) or is_binary(screen_id) do + params = Map.get(opts, :navigate_params, %{}) + + %{ + family: :navigation, + intent: Map.get(opts, :intent, :replace_with), + target: %{navigation: %{action: :replace_with, screen: screen_id, params: params}}, + payload: Map.get(opts, :payload, %{}) + } + end + + def event_payload(%{go_back: true}) do + %{ + family: :navigation, + intent: :go_back, + target: %{navigation: %{action: :go_back}}, + payload: %{} + } + end + + def event_payload(%{go_forward: true}) do + %{ + family: :navigation, + intent: :go_forward, + target: %{navigation: %{action: :go_forward}}, + payload: %{} + } + end + + def event_payload(%{open_modal: modal_id} = opts) when is_atom(modal_id) or is_binary(modal_id) do + params = Map.get(opts, :navigate_params, %{}) + + %{ + family: :navigation, + intent: Map.get(opts, :intent, :open_modal), + target: %{navigation: %{action: :open_modal, modal: modal_id, params: params}}, + payload: Map.get(opts, :payload, %{}) + } + end + + def event_payload(%{close_modal: true} = opts) do + navigation = + %{ + action: :close_modal + } + |> maybe_put(:modal, Map.get(opts, :modal)) + + %{ + family: :navigation, + intent: :close_modal, + target: %{navigation: navigation}, + payload: %{} + } + end + + def event_payload(_opts), do: nil + defp keyword_label(id, opts), do: Keyword.get(opts, :label, to_string(id)) + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, _key, ""), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) end diff --git a/packages/terminal_ui/test/terminal_ui/canonical_navigation_non_web_examples_integration_test.exs b/packages/terminal_ui/test/terminal_ui/canonical_navigation_non_web_examples_integration_test.exs new file mode 100644 index 00000000..cbcf4c58 --- /dev/null +++ b/packages/terminal_ui/test/terminal_ui/canonical_navigation_non_web_examples_integration_test.exs @@ -0,0 +1,43 @@ +defmodule TerminalUi.CanonicalNavigationNonWebExamplesIntegrationTest do + use ExUnit.Case, async: true + + test "terminal maintained navigation review consumes shared canonical fixtures across raw and tty backends" do + review = TerminalUi.Examples.navigation_transition_review() + metadata = TerminalUi.Examples.metadata(:navigation_transition_review) + reference = TerminalUi.reference() + summary = TerminalUi.info() + + assert review.fixture_ids == [ + "screen_transition--settings_profile", + "replace_transition--home", + "history_transition--back", + "modal_transition--settings_dialog" + ] + + assert review.parity.shared_fixture_targets_consumed? + assert review.parity.screen_transition_meaning_preserved? + assert review.parity.modal_degradation_explicit? + assert review.parity.history_semantics_preserved? + assert review.raw.after_navigate.active_screen_id == "settings" + assert review.raw.after_back.forward_depth == 1 + assert review.raw.after_replace.active_screen_id == "home" + assert review.tty.after_replace.active_screen_id == "home" + assert review.tty.with_modal.last_realization.degraded? + assert review.tty.with_modal.last_realization.fallback == :focused_surface + assert metadata.workflow == :navigation + assert metadata.parity_group == :navigation_transition_review + assert :navigation_transition_review in reference.examples.comparison_ids + assert :navigation_transition_review in summary.examples.comparison_ids + assert :navigation_review in summary.examples.workflows + end + + test "terminal maintained navigation review keeps canonical targets free of host route syntax and stable across backends" do + review = TerminalUi.Examples.navigation_transition_review() + + assert Enum.all?(review.raw_routes, fn {step, route} -> + match?(%{navigation: _}, route.target) and + route.target == review.tty_routes[step].target and + get_in(route.target, [:navigation, :route]) == nil + end) + end +end diff --git a/packages/terminal_ui/test/terminal_ui/canonical_navigation_terminal_runtime_integration_test.exs b/packages/terminal_ui/test/terminal_ui/canonical_navigation_terminal_runtime_integration_test.exs new file mode 100644 index 00000000..3fe5a47a --- /dev/null +++ b/packages/terminal_ui/test/terminal_ui/canonical_navigation_terminal_runtime_integration_test.exs @@ -0,0 +1,148 @@ +defmodule TerminalUi.CanonicalNavigationTerminalRuntimeIntegrationTest do + use ExUnit.Case, async: true + + alias TerminalUi.Runtime + alias UnifiedIUR.Interactions.Transport, as: BoundaryTransport + + test "raw terminal runtime realizes canonical screen, modal, and history transitions through one shared navigation state" do + navigate_fixture = BoundaryTransport.boundary_fixture!("screen_transition--settings_profile") + modal_fixture = BoundaryTransport.boundary_fixture!("modal_transition--settings_dialog") + history_fixture = BoundaryTransport.boundary_fixture!("history_transition--back") + + assert {:ok, runtime_state} = Runtime.mount_native_screen(base_screen(), backend_mode: :raw) + + assert Runtime.navigation_summary(runtime_state) == %{ + active_screen_id: "workspace", + history_depth: 0, + forward_depth: 0, + modal_depth: 0, + current_modal: nil, + last_transition: nil, + last_realization: %{ + action: :mount, + backend_mode: :raw, + transition_mode: :screen_replacement, + degraded?: false, + fallback: nil, + intent_preserved?: true + } + } + + assert {:ok, after_navigate, navigate_route} = + Runtime.dispatch_native_event( + runtime_state, + family: :navigation, + intent: navigate_fixture.interaction.intent, + widget_id: "settings-link", + target: navigate_fixture.descriptor.target, + payload: navigate_fixture.signal_data + ) + + assert navigate_route.route == :canonical_boundary + assert after_navigate.screen_id == "settings" + assert Runtime.navigation_summary(after_navigate).history_depth == 1 + assert Runtime.navigation_summary(after_navigate).last_realization.transition_mode == + :screen_replacement + + assert {:ok, with_modal, modal_route} = + Runtime.dispatch_native_event( + after_navigate, + family: :navigation, + intent: modal_fixture.interaction.intent, + widget_id: "settings-dialog-button", + target: modal_fixture.descriptor.target, + payload: modal_fixture.signal_data + ) + + assert modal_route.translation.target == modal_fixture.descriptor.target + + assert Runtime.navigation_summary(with_modal).current_modal == %{ + modal: :settings_dialog, + params: %{mode: :advanced}, + realization: :inline_overlay + } + + assert {:ok, after_back, back_route} = + Runtime.dispatch_native_event( + with_modal, + family: :navigation, + intent: history_fixture.interaction.intent, + widget_id: "back-button", + target: history_fixture.descriptor.target, + payload: history_fixture.signal_data + ) + + assert back_route.translation.target == history_fixture.descriptor.target + assert after_back.screen_id == "workspace" + assert Runtime.navigation_summary(after_back).forward_depth == 1 + assert Runtime.navigation_summary(after_back).last_realization.transition_mode == + :bounded_history + end + + test "tty backend degrades modal transitions into focused-surface realizations without changing canonical intent" do + modal_fixture = BoundaryTransport.boundary_fixture!("modal_transition--settings_dialog") + + assert {:ok, runtime_state} = Runtime.mount_native_screen(base_screen(), backend_mode: :tty) + + assert {:ok, with_modal, _route} = + Runtime.dispatch_native_event( + runtime_state, + family: :navigation, + intent: modal_fixture.interaction.intent, + widget_id: "settings-dialog-button", + target: modal_fixture.descriptor.target, + payload: modal_fixture.signal_data + ) + + assert Runtime.navigation_summary(with_modal).current_modal == %{ + modal: :settings_dialog, + params: %{mode: :advanced}, + realization: :focused_surface + } + + assert Runtime.navigation_summary(with_modal).last_realization == %{ + action: :open_modal, + backend_mode: :tty, + transition_mode: :focused_surface, + degraded?: true, + fallback: :focused_surface, + intent_preserved?: true + } + end + + test "terminal runtime rejects leaked route syntax and invalid modal transitions deterministically" do + assert {:ok, runtime_state} = Runtime.mount_native_screen(base_screen(), backend_mode: :raw) + + assert {:error, %Runtime.Error{reason: :host_route_navigation_syntax}} = + Runtime.dispatch_native_event( + runtime_state, + family: :navigation, + intent: :open_settings_screen, + widget_id: "settings-link", + target: %{ + navigation: %{action: :navigate_to, screen: :settings, route: "/settings"} + } + ) + + assert {:error, %Runtime.Error{reason: :invalid_modal_transition}} = + Runtime.dispatch_native_event( + runtime_state, + family: :navigation, + intent: :close_settings_modal, + widget_id: "close-settings-dialog", + target: %{navigation: %{action: :close_modal, modal: :settings_dialog}} + ) + end + + defp base_screen do + %{ + id: "workspace", + title: "Workspace", + root: + TerminalUi.Widgets.column("workspace-root", [ + TerminalUi.Widgets.text("workspace-title", "Workspace"), + TerminalUi.Widgets.button("settings-link", "Settings", navigate_to: :settings) + ]) + } + end +end diff --git a/packages/terminal_ui/test/terminal_ui/examples_test.exs b/packages/terminal_ui/test/terminal_ui/examples_test.exs index 4fcfabb4..3697b106 100644 --- a/packages/terminal_ui/test/terminal_ui/examples_test.exs +++ b/packages/terminal_ui/test/terminal_ui/examples_test.exs @@ -49,6 +49,7 @@ defmodule TerminalUi.ExamplesTest do :native_foundational, :native_styled_review, :native_transport_review, + :navigation_transition_review, :normalized_input_profiles, :styled_continuity_review, :styled_degradation_review, @@ -62,6 +63,7 @@ defmodule TerminalUi.ExamplesTest do :advanced_capability_continuity, :advanced_continuity, :foundational_continuity, + :navigation_transition_review, :normalized_input_profiles, :styled_continuity_review, :styled_degradation_review, @@ -135,6 +137,48 @@ defmodule TerminalUi.ExamplesTest do assert degradation.parity.inspection_surfaces_agree? end + test "navigation review exposes shared canonical fixtures and explicit raw versus tty realization" do + review = TerminalUi.Examples.navigation_transition_review() + + assert review.id == :navigation_transition_review + + assert review.fixture_ids == [ + "screen_transition--settings_profile", + "replace_transition--home", + "history_transition--back", + "modal_transition--settings_dialog" + ] + + assert review.parity.shared_fixture_targets_consumed? + assert review.parity.screen_transition_meaning_preserved? + assert review.parity.modal_degradation_explicit? + assert review.parity.history_semantics_preserved? + assert review.raw.after_navigate.active_screen_id == "settings" + assert review.raw.after_forward.active_screen_id == "reports" + assert review.raw.after_replace.active_screen_id == "home" + + assert review.raw.with_modal.current_modal == %{ + modal: :settings_dialog, + params: %{mode: :advanced}, + realization: :inline_overlay + } + + assert review.tty.with_modal.current_modal == %{ + modal: :settings_dialog, + params: %{mode: :advanced}, + realization: :focused_surface + } + + assert review.tty.with_modal.last_realization == %{ + action: :open_modal, + backend_mode: :tty, + transition_mode: :focused_surface, + degraded?: true, + fallback: :focused_surface, + intent_preserved?: true + } + end + test "reference and info surfaces include foundational example metadata and coverage" do reference = TerminalUi.reference() summary = TerminalUi.info() @@ -156,6 +200,7 @@ defmodule TerminalUi.ExamplesTest do assert :foundational_continuity in reference.examples.comparison_ids assert :advanced_continuity in reference.examples.comparison_ids assert :advanced_capability_continuity in reference.examples.comparison_ids + assert :navigation_transition_review in reference.examples.comparison_ids assert :transport_flow_review in reference.examples.comparison_ids assert :normalized_input_profiles in reference.examples.comparison_ids assert :styled_continuity_review in reference.examples.comparison_ids @@ -182,6 +227,7 @@ defmodule TerminalUi.ExamplesTest do assert :actions in summary.examples.categories assert :display in summary.examples.categories assert :transport in summary.examples.categories + assert :navigation_review in summary.examples.workflows assert :style in summary.examples.categories assert :advanced_review in summary.examples.workflows assert :transport_review in summary.examples.workflows diff --git a/packages/terminal_ui/test/terminal_ui/foundational_widget_families_test.exs b/packages/terminal_ui/test/terminal_ui/foundational_widget_families_test.exs index 998c5f1d..fbaeac48 100644 --- a/packages/terminal_ui/test/terminal_ui/foundational_widget_families_test.exs +++ b/packages/terminal_ui/test/terminal_ui/foundational_widget_families_test.exs @@ -20,6 +20,12 @@ defmodule TerminalUi.FoundationalWidgetFamiliesTest do shortcut: "ctrl-s" ) + navigate_button = + TerminalUi.Widgets.button("settings-button", "Settings", + navigate_to: :settings, + navigate_params: %{tab: :profile} + ) + assert icon.kind == :icon assert icon.attributes.fallback_text == "[S]" assert image.kind == :image @@ -33,6 +39,11 @@ defmodule TerminalUi.FoundationalWidgetFamiliesTest do assert primary.family == :action assert primary.metadata.command == :save_workspace assert primary.events.command == %{command: :save_workspace, source: :terminal_ui} + assert navigate_button.events.keypress.target.navigation == %{ + action: :navigate_to, + screen: :settings, + params: %{tab: :profile} + } end test "form and navigation widgets expose binding and keyboard-oriented interaction hooks" do diff --git a/packages/terminal_ui/test/terminal_ui/runtime_test.exs b/packages/terminal_ui/test/terminal_ui/runtime_test.exs index 32de9dd2..ded5bff3 100644 --- a/packages/terminal_ui/test/terminal_ui/runtime_test.exs +++ b/packages/terminal_ui/test/terminal_ui/runtime_test.exs @@ -7,6 +7,7 @@ defmodule TerminalUi.RuntimeTest do test "runtime exposes the phase one backbone modules and capabilities" do assert TerminalUi.Runtime.Boot in Runtime.modules() assert TerminalUi.Runtime.EventLoop in Runtime.modules() + assert TerminalUi.Runtime.Navigation in Runtime.modules() assert TerminalUi.Runtime.Screen in Runtime.modules() assert TerminalUi.Runtime.Realization in Runtime.modules() assert TerminalUi.Runtime.State in Runtime.modules() @@ -17,6 +18,8 @@ defmodule TerminalUi.RuntimeTest do assert :advanced_display_systems in Runtime.capabilities() assert :layered_runtime_behavior in Runtime.capabilities() assert :canonical_foundational_rendering in Runtime.capabilities() + assert :screen_navigation in Runtime.capabilities() + assert :capability_aware_navigation in Runtime.capabilities() end test "runtime mounts a native screen through the shared term_ui-backed backbone" do diff --git a/packages/terminal_ui/test/terminal_ui/transport_diagnostics_test.exs b/packages/terminal_ui/test/terminal_ui/transport_diagnostics_test.exs index f31051fb..8db38335 100644 --- a/packages/terminal_ui/test/terminal_ui/transport_diagnostics_test.exs +++ b/packages/terminal_ui/test/terminal_ui/transport_diagnostics_test.exs @@ -66,11 +66,35 @@ defmodule TerminalUi.TransportDiagnosticsTest do %{}, source: "/terminal_ui/native/unknown", subject: "native/unknown/ops-palette", - extensions: %{terminal_ui_family: :command} - ) + extensions: %{terminal_ui_family: :command} + ) }) end + test "transport validation rejects leaked host-route syntax on canonical navigation targets" do + interaction = + UnifiedIUR.Interaction.navigation_transition( + intent: :open_settings_screen, + action: :navigate_to, + screen: :settings + ) + + assert {:ok, translation} = + Transport.from_interaction( + interaction, + backend_mode: :raw, + widget_id: "settings-link", + runtime_id: "terminal-ui:workspace", + screen: "workspace" + ) + + translation = + put_in(translation.target.navigation[:route], "/settings") + + assert {:error, %TerminalUi.Transport.Error{reason: :host_route_navigation_syntax}} = + Transport.validate_translation(translation) + end + test "reference and summary surfaces expose transport-focused contract summaries" do reference = TerminalUi.reference() summary = TerminalUi.info() diff --git a/packages/terminal_ui/test/terminal_ui_test.exs b/packages/terminal_ui/test/terminal_ui_test.exs index c828d434..fa4b75ea 100644 --- a/packages/terminal_ui/test/terminal_ui_test.exs +++ b/packages/terminal_ui/test/terminal_ui_test.exs @@ -112,6 +112,7 @@ defmodule TerminalUiTest do :advanced_capability_continuity, :advanced_continuity, :foundational_continuity, + :navigation_transition_review, :normalized_input_profiles, :styled_continuity_review, :styled_degradation_review, diff --git a/test/spec_compliance/compliance_test.exs b/test/spec_compliance/compliance_test.exs index eac7db03..b2d0ed70 100644 --- a/test/spec_compliance/compliance_test.exs +++ b/test/spec_compliance/compliance_test.exs @@ -151,8 +151,8 @@ defmodule Unified.SpecCompliance.ComplianceTest do assert plan_report.status == :pass assert compliance_report.status == :pass - assert compliance_report.summary.applicable_requirements == 89 - assert compliance_report.summary.status_counts.verified == 89 + assert compliance_report.summary.applicable_requirements == 91 + assert compliance_report.summary.status_counts.verified == 91 assert compliance_report.summary.status_counts.waived == 0 assert compliance_report.summary.status_counts.planned == 0 assert compliance_report.summary.aliases == 48 @@ -182,8 +182,8 @@ defmodule Unified.SpecCompliance.ComplianceTest do assert plan_report.status == :pass assert compliance_report.status == :pass - assert compliance_report.summary.applicable_requirements == 92 - assert compliance_report.summary.status_counts.verified == 92 + assert compliance_report.summary.applicable_requirements == 94 + assert compliance_report.summary.status_counts.verified == 94 assert compliance_report.summary.aliases == 48 end @@ -193,8 +193,8 @@ defmodule Unified.SpecCompliance.ComplianceTest do assert plan_report.status == :pass assert compliance_report.status == :pass - assert compliance_report.summary.applicable_requirements == 105 - assert compliance_report.summary.status_counts.verified == 105 + assert compliance_report.summary.applicable_requirements == 112 + assert compliance_report.summary.status_counts.verified == 112 assert compliance_report.summary.status_counts.waived == 0 assert compliance_report.summary.aliases == 49 assert compliance_report.summary.ci_enforcement == "required" @@ -207,8 +207,8 @@ defmodule Unified.SpecCompliance.ComplianceTest do assert plan_report.status == :pass assert compliance_report.status == :pass - assert compliance_report.summary.applicable_requirements == 97 - assert compliance_report.summary.status_counts.verified == 97 + assert compliance_report.summary.applicable_requirements == 100 + assert compliance_report.summary.status_counts.verified == 100 assert compliance_report.summary.status_counts.waived == 0 assert compliance_report.summary.ci_enforcement == "required" end @@ -219,8 +219,8 @@ defmodule Unified.SpecCompliance.ComplianceTest do assert plan_report.status == :pass assert compliance_report.status == :pass - assert compliance_report.summary.applicable_requirements == 62 - assert compliance_report.summary.status_counts.verified == 62 + assert compliance_report.summary.applicable_requirements == 65 + assert compliance_report.summary.status_counts.verified == 65 assert compliance_report.summary.status_counts.waived == 0 assert compliance_report.summary.aliases == 18 assert compliance_report.summary.ci_enforcement == "required"