diff --git a/guides/user/UG-0003-widget-types-properties-and-signals.md b/guides/user/UG-0003-widget-types-properties-and-signals.md
index eaa1dda9..b5e3e676 100644
--- a/guides/user/UG-0003-widget-types-properties-and-signals.md
+++ b/guides/user/UG-0003-widget-types-properties-and-signals.md
@@ -100,7 +100,7 @@ authoring boundaries and normalize before renderer-facing output.
| Form control and composer | `runtime_form_shell`, `segmented_button_group`, `chat_composer`, `collection_picker`, `mode_nav` | `phoenix_form` -> `runtime_form_shell` |
| Row and artifact | `list_item_multi_column`, `artifact_row`, `thread_card`, `tool_call_card` | none |
| Workflow, progress, and status | `pipeline_stepper_horizontal`, `segmented_progress_bar`, `workflow_stage_list_vertical`, `meter_thin`, `unread_badge`, `live_session_card`, `workflow_progress_status_card` | none |
-| Layer shell and callout | `sticky_frosted_header`, `slide_over_panel`, `event_callout`, `top_strip`, `sidebar_shell`, `sidebar_section`, `sidebar_item`, `command_palette`, `right_rail`, `composer_query_preview`, `propose_new_doc_card` | none |
+| Layer shell and callout | `sticky_frosted_header`, `slide_over_panel`, `event_callout`, `top_strip`, `sidebar_shell`, `sidebar_section`, `sidebar_item`, `command_palette`, `right_rail`, `composer_query_preview`, `propose_new_doc_card`, `escalation_card` | none |
| Redline and code | `redline_inline`, `code_block_syntax_highlighted` | none |
| Composition behavior | `list_repeat` | `repeat` -> `list_repeat`, `ui_relationship_repeat` -> `list_repeat` |
@@ -176,6 +176,13 @@ conversation timeline. Author props such as `tool_name`, `tool_kind`, `target`,
paired result child. Use canonical `expand_toggled` interaction for expansion.
Keep LiveView event fields and route helpers in the host layer.
+`escalation_card` is the canonical callout card for a cross-team escalation
+raised by an MCP tool. Author props such as `target_project_id`, `severity`,
+`text`, optional `proposed_action`, `related_finding_id`, `actor_handle`,
+`escalated_at`, and `acknowledged?`. Use canonical `acknowledge` and
+`route_to_rail` interactions for operator routing. Keep LiveView event fields
+and route helpers in the host layer.
+
You can also author `custom:*` types. They are accepted as widget types, but the
shipped validation/runtime does not automatically give them built-in signal
semantics. Some explicitly supported custom surfaces do have dedicated fallback
diff --git a/lib/ash_ui/rendering/iur_adapter.ex b/lib/ash_ui/rendering/iur_adapter.ex
index 3ba00f16..2150be67 100644
--- a/lib/ash_ui/rendering/iur_adapter.ex
+++ b/lib/ash_ui/rendering/iur_adapter.ex
@@ -561,6 +561,13 @@ defmodule AshUI.Rendering.IURAdapter do
|> Map.fetch!(:attributes)
end
+ defp base_attributes(:escalation_card, props) do
+ props
+ |> escalation_card_opts()
+ |> IURComponents.escalation_card()
+ |> Map.fetch!(:attributes)
+ end
+
defp base_attributes(:redline_inline = kind, props) do
component_attributes(
kind,
@@ -1234,6 +1241,56 @@ defmodule AshUI.Rendering.IURAdapter do
|> compact_map()
end
+ defp escalation_card_opts(props) do
+ escalation = props |> fetch(:escalation, %{}) |> normalize_map()
+
+ %{
+ id: first_present(props, [:_element_id, :id]),
+ target_project_id:
+ first_present(escalation, [:target_project_id]) ||
+ first_present(props, [:target_project_id, :project_id, :project]),
+ text:
+ first_present(escalation, [:text]) ||
+ first_present(props, [:text, :description, :message]),
+ severity:
+ normalize_existing_atom(
+ first_present(escalation, [:severity]) ||
+ first_present(props, [:severity]) ||
+ :p2
+ ),
+ related_finding_id:
+ first_present(escalation, [:related_finding_id]) ||
+ first_present(props, [:related_finding_id]),
+ proposed_action:
+ first_present(escalation, [:proposed_action]) ||
+ first_present(props, [:proposed_action]),
+ target_finding_id:
+ first_present(escalation, [:target_finding_id]) ||
+ first_present(props, [:target_finding_id]),
+ target_severity:
+ normalize_existing_atom(
+ first_present(escalation, [:target_severity]) ||
+ first_present(props, [:target_severity])
+ ),
+ originating_severity:
+ normalize_existing_atom(
+ first_present(escalation, [:originating_severity]) ||
+ first_present(props, [:originating_severity])
+ ),
+ actor_handle:
+ first_present(escalation, [:actor_handle]) ||
+ first_present(props, [:actor_handle]),
+ escalated_at:
+ first_present(escalation, [:escalated_at]) ||
+ first_present(props, [:escalated_at]),
+ acknowledge_intent: first_present(props, [:acknowledge_intent]),
+ route_intent: first_present(props, [:route_intent]),
+ interactions: fetch(props, :interactions),
+ interaction: fetch(props, :interaction)
+ }
+ |> compact_map()
+ end
+
defp collection_picker_opts(props) do
picker = props |> fetch(:collection_picker, %{}) |> normalize_map()
diff --git a/lib/ash_ui/rendering/live_ui_adapter.ex b/lib/ash_ui/rendering/live_ui_adapter.ex
index e478dc6c..3aadf35d 100644
--- a/lib/ash_ui/rendering/live_ui_adapter.ex
+++ b/lib/ash_ui/rendering/live_ui_adapter.ex
@@ -788,6 +788,83 @@ defmodule AshUI.Rendering.LiveUIAdapter do
"""
end
+ defp generate_heex(%{"type" => "escalation_card"} = iur, _opts) do
+ props = iur["props"] || %{}
+ escalation = props |> prop("escalation", props) |> normalize_item()
+
+ target_project_id =
+ escaped_text_prop(
+ escalation,
+ "target_project_id",
+ escaped_text_prop(props, "target_project_id", "")
+ )
+
+ text =
+ escaped_text_prop(
+ escalation,
+ "text",
+ escaped_text_prop(props, ["text", "description"], "")
+ )
+
+ severity =
+ escaped_text_prop(escalation, "severity", escaped_text_prop(props, "severity", "p2"))
+
+ actor_handle = escaped_text_prop(escalation, "actor_handle")
+ proposed_action = escaped_text_prop(escalation, "proposed_action")
+
+ acknowledged? =
+ truthy_prop(escalation, "acknowledged?", truthy_prop(props, "acknowledged?", false))
+
+ iur_id = html_attr(iur["id"] || iur[:id] || "escalation-card")
+ severity_label = String.upcase(severity)
+
+ meta_html =
+ if target_project_id != "" || proposed_action do
+ project_row =
+ if target_project_id != "" do
+ "
Target project#{target_project_id}"
+ else
+ ""
+ end
+
+ action_row =
+ if proposed_action do
+ "Proposed action#{proposed_action}"
+ else
+ ""
+ end
+
+ ~s(#{project_row}#{action_row}
)
+ else
+ ""
+ end
+
+ footer_html =
+ if acknowledged? do
+ ~s(Acknowledged
)
+ else
+ """
+
+ """
+ end
+
+ """
+
+
+ #{text}
+ #{meta_html}
+ #{footer_html}
+
+ """
+ end
+
defp generate_heex(%{"type" => "list_item_multi_column"} = iur, opts) do
props = iur["props"] || %{}
active? = truthy_prop(props, "active?", truthy_prop(props, "active", false))
diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex
index bac155b6..6fb435db 100644
--- a/packages/live_ui/lib/live_ui/renderer.ex
+++ b/packages/live_ui/lib/live_ui/renderer.ex
@@ -1013,6 +1013,46 @@ defmodule LiveUi.Renderer do
"""
end
+ # NOTE: `:escalation_card` is a canonical layer-shell callout. Keep this
+ # native clause before the generic component fallback so acknowledge and
+ # route_to_rail actions keep canonical interaction transport.
+ def render(%{element: %Element{kind: :escalation_card}} = assigns) do
+ escalation = escalation_attributes(assigns.element)
+
+ assigns =
+ assigns
+ |> assign(:escalation, escalation)
+ |> assign(
+ :action_attrs,
+ escalation_action_attrs(assigns.element, Map.get(assigns, :event_target))
+ )
+ |> assign(:style_attrs, style_rest(assigns.element))
+
+ ~H"""
+
+ """
+ end
+
def render(%{element: %Element{kind: kind}} = assigns) when kind in @component_kinds do
assigns = assign(assigns, :style_attrs, style_rest(assigns.element))
@@ -2910,6 +2950,60 @@ defmodule LiveUi.Renderer do
end)
end
+ defp escalation_attributes(%Element{} = element) do
+ element.attributes
+ |> Map.get(:escalation, Map.get(element.attributes, "escalation", %{}))
+ |> case do
+ escalation when is_map(escalation) -> escalation
+ escalation when is_list(escalation) -> Map.new(escalation)
+ _other -> %{}
+ end
+ end
+
+ defp escalation_action_attrs(%Element{} = element, event_target) do
+ %{
+ acknowledge: escalation_interaction_attrs(element, event_target, :acknowledge),
+ route_to_rail: escalation_interaction_attrs(element, event_target, :route_to_rail)
+ }
+ end
+
+ defp escalation_interaction_attrs(%Element{} = element, event_target, action)
+ when not is_nil(event_target) do
+ case escalation_interaction(element, action) do
+ %Interaction{} = interaction ->
+ %{
+ :"phx-click" => "canonical_interaction",
+ :"phx-target" => event_target,
+ :"phx-value-interaction" => encode_interaction(interaction),
+ :"phx-value-element_id" => element_id(element, "escalation-card"),
+ :"phx-value-widget" => "escalation_card",
+ :"phx-value-action" => Atom.to_string(action),
+ :"phx-value-target_project_id" =>
+ element |> escalation_attributes() |> map_value(:target_project_id, "") |> to_string()
+ }
+
+ _ ->
+ %{}
+ end
+ end
+
+ defp escalation_interaction_attrs(_element, _event_target, _action), do: %{}
+
+ defp escalation_interaction(%Element{} = element, action) do
+ element.attributes
+ |> Map.get(:interactions, [])
+ |> List.wrap()
+ |> Enum.find(fn
+ %Interaction{family: :command, payload: payload, intent: intent} ->
+ map_value(payload, :command) == action or intent == action or intent == to_string(action)
+
+ _other ->
+ false
+ end)
+ end
+
+ defp escalation_interaction(_element, _action), do: nil
+
defp sidebar_section_attributes(%Element{} = element) do
element.attributes
|> Map.get(:section, Map.get(element.attributes, "section", %{}))
diff --git a/packages/live_ui/lib/live_ui/widgets/escalation_card.ex b/packages/live_ui/lib/live_ui/widgets/escalation_card.ex
new file mode 100644
index 00000000..d7c04844
--- /dev/null
+++ b/packages/live_ui/lib/live_ui/widgets/escalation_card.ex
@@ -0,0 +1,120 @@
+defmodule LiveUi.Widgets.EscalationCard do
+ @moduledoc """
+ Native escalation card for a cross-team escalation raised by an MCP tool.
+
+ The widget renders the canonical `:escalation_card` callout shape for
+ `:escalation_raised` SessionEvents. Severity, evidence references, and
+ operator routing actions are exposed; acknowledged state is stateless
+ (state lives in the consumer LiveView).
+ """
+
+ use LiveUi.Component,
+ family: :layer_shell_and_callout,
+ name: :escalation_card,
+ slots: [],
+ events: [:acknowledge, :route_to_rail]
+
+ LiveUi.Component.common_attrs()
+ attr(:target_project_id, :string, required: true)
+ attr(:severity, :atom, required: true)
+ attr(:text, :string, required: true)
+ attr(:related_finding_id, :string, default: nil)
+ attr(:proposed_action, :string, default: nil)
+ attr(:target_finding_id, :string, default: nil)
+ attr(:target_severity, :atom, default: nil)
+ attr(:originating_severity, :atom, default: nil)
+ attr(:actor_handle, :string, default: nil)
+ attr(:escalated_at, :string, default: nil)
+ attr(:acknowledged?, :boolean, default: false)
+ attr(:ack_attrs, :any, default: [])
+ attr(:route_attrs, :any, default: [])
+
+ @impl true
+ def render(assigns) do
+ assigns =
+ assigns
+ |> assign(:card_id, assigns.id)
+ |> assign(:severity_label, severity_label(assigns.severity))
+
+ ~H"""
+
+
+
+ <%= @text %>
+
+
+ - Target project
+ - <%= @target_project_id %>
+ - Proposed action
+ - <%= @proposed_action %>
+
+
+
+
+
+ Acknowledged
+
+
+ """
+ end
+
+ defp escalation_card_class(extra_class, severity) do
+ [
+ "live-ui-escalation-card",
+ "live-ui-escalation-card--#{severity}",
+ extra_class
+ ]
+ end
+
+ defp severity_label(severity) when is_atom(severity),
+ do: severity |> Atom.to_string() |> String.upcase()
+
+ defp severity_label(severity) when is_binary(severity), do: String.upcase(severity)
+ defp severity_label(_severity), do: ""
+
+ defp action_attrs(attrs, fallback_event) when attrs in [nil, [], %{}],
+ do: %{:"phx-click" => fallback_event}
+
+ defp action_attrs(attrs, _fallback_event), do: attrs
+end
diff --git a/packages/live_ui/lib/live_ui/widgets/layer_shell_and_callout.ex b/packages/live_ui/lib/live_ui/widgets/layer_shell_and_callout.ex
index 90455a1c..aaccf3fd 100644
--- a/packages/live_ui/lib/live_ui/widgets/layer_shell_and_callout.ex
+++ b/packages/live_ui/lib/live_ui/widgets/layer_shell_and_callout.ex
@@ -7,7 +7,8 @@ defmodule LiveUi.Widgets.LayerShellAndCallout do
LiveUi.Widgets.RightRail,
LiveUi.Widgets.SidebarSection,
LiveUi.Widgets.ComposerQueryPreview,
- LiveUi.Widgets.ProposeNewDocCard
+ LiveUi.Widgets.ProposeNewDocCard,
+ LiveUi.Widgets.EscalationCard
]
@spec modules() :: [module()]
diff --git a/packages/live_ui/test/live_ui/widgets/escalation_card_test.exs b/packages/live_ui/test/live_ui/widgets/escalation_card_test.exs
new file mode 100644
index 00000000..8b52cb81
--- /dev/null
+++ b/packages/live_ui/test/live_ui/widgets/escalation_card_test.exs
@@ -0,0 +1,157 @@
+defmodule LiveUi.Widgets.EscalationCardTest do
+ use ExUnit.Case, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias LiveUi.Component
+ alias UnifiedIUR.Widgets.Components
+
+ describe "escalation_card widget metadata" do
+ test "registers as a layer_shell_and_callout widget with action events" do
+ metadata = Component.metadata(LiveUi.Widgets.EscalationCard)
+
+ assert metadata.mountable?
+ assert metadata.component_module == LiveUi.Widgets.EscalationCard.Component
+ assert metadata.family == :layer_shell_and_callout
+ assert metadata.name == :escalation_card
+ assert :acknowledge in metadata.events
+ assert :route_to_rail in metadata.events
+ end
+
+ test "is present in layer_shell_and_callout aggregation" do
+ assert LiveUi.Widgets.EscalationCard in LiveUi.Widgets.LayerShellAndCallout.modules()
+
+ assert LiveUi.Widgets.EscalationCard in LiveUi.Widgets.layer_shell_and_callout_modules()
+ end
+ end
+
+ describe "escalation_card component rendering" do
+ test "renders canonical root hooks, header, severity badge, and text" do
+ html = render_component(&LiveUi.Widgets.EscalationCard.component/1, base_assigns())
+
+ assert html =~ ~s(data-live-ui-widget="escalation-card")
+ assert html =~ ~s(data-severity="p2")
+ assert html =~ ~s(role="alert")
+ assert html =~ ~s(aria-labelledby="escalation-card-title")
+ assert html =~ "live-ui-escalation-card__header"
+ assert html =~ "P2"
+ assert html =~ ~s(role="status")
+ assert html =~ "Escalation"
+ assert html =~ "Coverage gap detected on chat surface."
+ assert html =~ "@codex"
+ end
+
+ test "renders p1, p2, p3 severity variants with correct BEM modifier" do
+ for severity <- [:p1, :p2, :p3] do
+ html =
+ render_component(
+ &LiveUi.Widgets.EscalationCard.component/1,
+ base_assigns(%{id: "esc-#{severity}", severity: severity})
+ )
+
+ assert html =~ ~s(data-severity="#{severity}")
+ assert html =~ "live-ui-escalation-card--#{severity}"
+ assert html =~ String.upcase(to_string(severity))
+ end
+ end
+
+ test "renders action buttons with descriptive aria labels when unacknowledged" do
+ html = render_component(&LiveUi.Widgets.EscalationCard.component/1, base_assigns())
+
+ assert html =~ ~s(aria-label="Acknowledge p2 escalation")
+ assert html =~ ~s(aria-label="Route p2 escalation to rail")
+ assert html =~ ~s(phx-click="acknowledge")
+ assert html =~ ~s(phx-click="route_to_rail")
+ assert html =~ "live-ui-escalation-card__actions"
+ end
+
+ test "hides action buttons and shows acknowledged status when acknowledged" do
+ html =
+ render_component(
+ &LiveUi.Widgets.EscalationCard.component/1,
+ base_assigns(%{acknowledged?: true})
+ )
+
+ assert html =~ ~s(data-acknowledged="true")
+ assert html =~ "live-ui-escalation-card__acknowledged"
+ assert html =~ "Acknowledged"
+ assert html =~ ~s(role="status")
+ refute html =~ "live-ui-escalation-card__actions"
+ refute html =~ ~s(aria-label="Acknowledge p2 escalation")
+ end
+
+ test "renders optional meta section with target_project_id and proposed_action" do
+ html =
+ render_component(
+ &LiveUi.Widgets.EscalationCard.component/1,
+ base_assigns(%{
+ target_project_id: "ariston-ui",
+ proposed_action: "Add aria-live region"
+ })
+ )
+
+ assert html =~ "live-ui-escalation-card__meta"
+ assert html =~ "Target project"
+ assert html =~ "ariston-ui"
+ assert html =~ "Proposed action"
+ assert html =~ "Add aria-live region"
+ end
+
+ test "omits meta section when no optional meta fields are present" do
+ html =
+ render_component(
+ &LiveUi.Widgets.EscalationCard.component/1,
+ base_assigns(%{proposed_action: nil})
+ )
+
+ # target_project_id is still required and present — meta still renders
+ assert html =~ "live-ui-escalation-card__meta"
+ end
+ end
+
+ describe "renderer dispatch" do
+ test "escalation_card kind is in supported_kinds" do
+ assert :escalation_card in LiveUi.Renderer.supported_kinds()
+ end
+
+ test "renders through native renderer with canonical action attrs" do
+ element =
+ Components.escalation_card(
+ id: "escalation-renderer",
+ target_project_id: "ariston-ui",
+ severity: :p1,
+ text: "Critical coverage gap detected."
+ )
+
+ html =
+ render_component(&LiveUi.Renderer.render/1, %{
+ element: element,
+ event_target: "#runtime-host"
+ })
+
+ assert html =~ ~s(data-live-ui-widget="escalation-card")
+ assert html =~ ~s(phx-click="canonical_interaction")
+ assert html =~ ~s(phx-target="#runtime-host")
+ assert html =~ ~s(phx-value-widget="escalation_card")
+ assert html =~ ~s(phx-value-element_id="escalation-renderer")
+ assert html =~ ~s(phx-value-action="acknowledge")
+ assert html =~ ~s(phx-value-action="route_to_rail")
+ refute html =~ ~s(data-live-ui-component-kind="escalation_card")
+ refute html =~ ~s(data-live-ui-unsupported-native-component)
+ end
+ end
+
+ defp base_assigns(overrides \\ %{}) do
+ Map.merge(
+ %{
+ id: "escalation-card",
+ target_project_id: "ariston-ui",
+ severity: :p2,
+ text: "Coverage gap detected on chat surface.",
+ actor_handle: "@codex",
+ acknowledged?: false
+ },
+ overrides
+ )
+ end
+end
diff --git a/packages/unified_iur/lib/unified_iur/fixtures.ex b/packages/unified_iur/lib/unified_iur/fixtures.ex
index 883c5f23..02a59aea 100644
--- a/packages/unified_iur/lib/unified_iur/fixtures.ex
+++ b/packages/unified_iur/lib/unified_iur/fixtures.ex
@@ -1189,6 +1189,37 @@ defmodule UnifiedIUR.Fixtures do
depended_by: ["metagraph-analysis", "ariston-ui"],
selected?: false
)},
+ {:content,
+ Components.collection_picker(
+ id: "component-collection-picker",
+ picker_id: "adr-picker",
+ title: "ADRs",
+ query: "boundary",
+ filters: [%{id: :all, label: "All", selected?: true, count: 3}],
+ items: [%{id: :adr_0001, label: "ADR 0001", description: "Stream workspace doc body"}],
+ suggestions: []
+ )},
+ {:content,
+ Components.thread_card(
+ id: "component-thread-card",
+ thread_id: "thread:spec-review-0001",
+ title: "Spec review discussion",
+ seed_quote: "Should the runtime own this transition?",
+ reply_count: 3,
+ participants: [%{actor_name: "Pascal", avatar: %{initials: "PC"}}],
+ progress_pct: 0.6
+ )},
+ {:content,
+ Components.composer_query_preview(
+ id: "component-query-preview",
+ composer_id: "composer-main",
+ query: "release blockers",
+ preview_state: :ready,
+ explanation: "Two likely blockers found.",
+ findings: [
+ %{id: "finding-1", n: 1, snippet: "Conformance missing", confidence: 0.88}
+ ]
+ )},
{:content,
Components.propose_new_doc_card(
id: "component-propose-new-doc",
@@ -1252,6 +1283,16 @@ defmodule UnifiedIUR.Fixtures do
tokens_consumed: 8192,
started_at: "2026-05-28T10:00:00Z",
current_task_title: "Fix coverage fixtures"
+ )},
+ {:content,
+ Components.escalation_card(
+ id: "component-escalation-card",
+ target_project_id: "ariston-ui",
+ severity: :p2,
+ text: "Accessibility coverage gap detected on chat surface.",
+ actor_handle: "@codex",
+ proposed_action: "Add aria-live region to chat timeline",
+ escalated_at: "2026-05-27T10:00:00Z"
)}
],
id: "component-safety-fixture"
diff --git a/packages/unified_iur/lib/unified_iur/validate.ex b/packages/unified_iur/lib/unified_iur/validate.ex
index 19d68c64..51538c88 100644
--- a/packages/unified_iur/lib/unified_iur/validate.ex
+++ b/packages/unified_iur/lib/unified_iur/validate.ex
@@ -168,6 +168,11 @@ defmodule UnifiedIUR.Validate do
guidance:
"Represent propose_new_doc_card with target_path, title, preview or full markdown body, status, and renderer-independent accept, reject, and preview actions."
},
+ invalid_escalation_card: %{
+ construct_family: :widget_components,
+ guidance:
+ "Represent escalation_card with a non-empty target_project_id, non-empty text, and severity in [:p1, :p2, :p3]. Optional fields include related_finding_id, proposed_action, actor_handle, escalated_at, target_finding_id, target_severity, and originating_severity."
+ },
invalid_collection_picker: %{
construct_family: :widget_components,
guidance:
@@ -233,6 +238,7 @@ defmodule UnifiedIUR.Validate do
@live_session_action_names ~w[pin interrupt expanded_recent]
@live_session_action_keys [:pin, :interrupt, :expanded_recent]
@live_session_event_names ~w[pin_toggled interrupted expanded_recent]
+ @escalation_severities [:p1, :p2, :p3]
@collection_picker_forbidden_keys ~w[
bundle
bundle_id
@@ -750,6 +756,15 @@ defmodule UnifiedIUR.Validate do
|> validate_propose_new_doc_shape()
end
+ defp validate_component_contracts(%Element{
+ kind: :escalation_card,
+ attributes: attributes
+ }) do
+ attributes
+ |> Map.get(:escalation, %{})
+ |> validate_escalation_shape()
+ end
+
defp validate_component_contracts(%Element{
kind: :collection_picker,
attributes: attributes
@@ -2298,6 +2313,103 @@ defmodule UnifiedIUR.Validate do
]
end
+ defp validate_escalation_shape(escalation) when is_map(escalation) do
+ []
+ |> maybe_add(
+ not non_blank_string?(fetch(escalation, :target_project_id)),
+ Error.new(
+ :invalid_escalation_card,
+ "escalation_card requires target_project_id as a non-empty string",
+ path: [:attributes, :escalation, :target_project_id],
+ details: %{target_project_id: inspect(fetch(escalation, :target_project_id))}
+ )
+ )
+ |> maybe_add(
+ not non_blank_string?(fetch(escalation, :text)),
+ Error.new(
+ :invalid_escalation_card,
+ "escalation_card requires text as a non-empty string",
+ path: [:attributes, :escalation, :text],
+ details: %{text: inspect(fetch(escalation, :text))}
+ )
+ )
+ |> maybe_add(
+ fetch(escalation, :severity) not in @escalation_severities,
+ Error.new(
+ :invalid_escalation_card,
+ "escalation_card severity must be one of #{inspect(@escalation_severities)}",
+ path: [:attributes, :escalation, :severity],
+ details: %{severity: inspect(fetch(escalation, :severity))}
+ )
+ )
+ |> maybe_add(
+ escalation_optional_string_invalid?(escalation, :related_finding_id),
+ escalation_string_error(escalation, :related_finding_id)
+ )
+ |> maybe_add(
+ escalation_optional_string_invalid?(escalation, :proposed_action),
+ escalation_string_error(escalation, :proposed_action)
+ )
+ |> maybe_add(
+ escalation_optional_string_invalid?(escalation, :actor_handle),
+ escalation_string_error(escalation, :actor_handle)
+ )
+ |> maybe_add(
+ escalation_optional_string_invalid?(escalation, :escalated_at),
+ escalation_string_error(escalation, :escalated_at)
+ )
+ |> maybe_add(
+ escalation_optional_string_invalid?(escalation, :target_finding_id),
+ escalation_string_error(escalation, :target_finding_id)
+ )
+ |> maybe_add(
+ escalation_optional_severity_invalid?(escalation, :target_severity),
+ escalation_optional_severity_error(escalation, :target_severity)
+ )
+ |> maybe_add(
+ escalation_optional_severity_invalid?(escalation, :originating_severity),
+ escalation_optional_severity_error(escalation, :originating_severity)
+ )
+ end
+
+ defp validate_escalation_shape(_escalation) do
+ [
+ Error.new(
+ :invalid_escalation_card,
+ "escalation_card attributes.escalation must be a map",
+ path: [:attributes, :escalation]
+ )
+ ]
+ end
+
+ defp escalation_optional_string_invalid?(escalation, key) do
+ has_key?(escalation, key) and not is_nil(fetch(escalation, key)) and
+ not is_binary(fetch(escalation, key))
+ end
+
+ defp escalation_string_error(escalation, key) do
+ Error.new(
+ :invalid_escalation_card,
+ "escalation_card #{key} must be a string",
+ path: [:attributes, :escalation, key],
+ details: %{value: inspect(fetch(escalation, key))}
+ )
+ end
+
+ defp escalation_optional_severity_invalid?(escalation, key) do
+ val = fetch(escalation, key)
+ not is_nil(val) and val not in @escalation_severities
+ end
+
+ defp escalation_optional_severity_error(escalation, key) do
+ Error.new(
+ :invalid_escalation_card,
+ "escalation_card #{key} must be one of #{inspect(@escalation_severities)} when present",
+ path: [:attributes, :escalation, key],
+ details: %{value: inspect(fetch(escalation, key))}
+ )
+ end
+
defp validate_collection_picker_shape(picker) when is_map(picker) do
[]
|> maybe_add(
diff --git a/packages/unified_iur/lib/unified_iur/widgets/components.ex b/packages/unified_iur/lib/unified_iur/widgets/components.ex
index 0188f25d..36962589 100644
--- a/packages/unified_iur/lib/unified_iur/widgets/components.ex
+++ b/packages/unified_iur/lib/unified_iur/widgets/components.ex
@@ -78,7 +78,8 @@ defmodule UnifiedIUR.Widgets.Components do
:right_rail,
:command_palette,
:composer_query_preview,
- :propose_new_doc_card
+ :propose_new_doc_card,
+ :escalation_card
]
@query_preview_states [:loading, :ready, :empty, :error]
@@ -86,6 +87,7 @@ defmodule UnifiedIUR.Widgets.Components do
@tool_call_kinds [:read, :edit, :write, :bash, :multiedit, :other]
@tool_call_statuses [:pending, :approved, :denied, :complete, :failed]
@live_session_statuses [:running]
+ @escalation_severities [:p1, :p2, :p3]
@redline_code_kinds [
:redline_inline,
@@ -1497,6 +1499,71 @@ defmodule UnifiedIUR.Widgets.Components do
)
end
+ @spec escalation_card(opts()) :: Element.t()
+ def escalation_card(opts \\ []) do
+ opts = normalize_opts(opts)
+
+ target_project_id =
+ required_non_blank_string!(
+ opts,
+ :target_project_id,
+ "escalation_card requires a non-empty :target_project_id string"
+ )
+
+ text =
+ required_non_blank_string!(
+ opts,
+ :text,
+ "escalation_card requires a non-empty :text string"
+ )
+
+ severity = normalize_escalation_severity!(option(opts, :severity))
+
+ opts = put_escalation_interactions(opts, target_project_id)
+
+ build_component(
+ :escalation_card,
+ :layer_shell_and_callout,
+ %{
+ escalation:
+ %{
+ target_project_id: target_project_id,
+ text: text,
+ severity: severity
+ }
+ |> maybe_put(
+ :related_finding_id,
+ optional_string!(option(opts, :related_finding_id), :related_finding_id)
+ )
+ |> maybe_put(
+ :proposed_action,
+ optional_string!(option(opts, :proposed_action), :proposed_action)
+ )
+ |> maybe_put(
+ :target_finding_id,
+ optional_string!(option(opts, :target_finding_id), :target_finding_id)
+ )
+ |> maybe_put(
+ :target_severity,
+ normalize_optional_escalation_severity(option(opts, :target_severity))
+ )
+ |> maybe_put(
+ :originating_severity,
+ normalize_optional_escalation_severity(option(opts, :originating_severity))
+ )
+ |> maybe_put(
+ :actor_handle,
+ optional_string!(option(opts, :actor_handle), :actor_handle)
+ )
+ |> maybe_put(
+ :escalated_at,
+ optional_string!(option(opts, :escalated_at), :escalated_at)
+ )
+ },
+ opts
+ )
+ end
+
defp build_component(kind, family, kind_attributes, opts) do
opts = normalize_opts(opts)
@@ -1775,6 +1842,66 @@ defmodule UnifiedIUR.Widgets.Components do
defp propose_new_doc_intent(opts, :preview),
do: option(opts, :preview_intent, :preview_proposed_doc)
+ defp put_escalation_interactions(opts, target_project_id) do
+ cond do
+ explicit_interactions?(opts) ->
+ opts
+
+ true ->
+ Map.put(opts, :interactions, escalation_interactions(opts, target_project_id))
+ end
+ end
+
+ defp escalation_interactions(opts, target_project_id) do
+ [
+ Interaction.command(
+ intent: option(opts, :acknowledge_intent, :acknowledge_escalation),
+ element_id: option(opts, :id),
+ entity: target_project_id,
+ command: :acknowledge,
+ value: target_project_id
+ ),
+ Interaction.command(
+ intent: option(opts, :route_intent, :route_escalation_to_rail),
+ element_id: option(opts, :id),
+ entity: target_project_id,
+ command: :route_to_rail,
+ value: target_project_id
+ )
+ ]
+ end
+
+ defp normalize_escalation_severity!(severity) when severity in @escalation_severities,
+ do: severity
+
+ defp normalize_escalation_severity!(severity) when is_binary(severity) do
+ severity
+ |> String.to_existing_atom()
+ |> normalize_escalation_severity!()
+ rescue
+ ArgumentError ->
+ raise ArgumentError,
+ "escalation_card :severity must be one of #{inspect(@escalation_severities)}"
+ end
+
+ defp normalize_escalation_severity!(_severity) do
+ raise ArgumentError,
+ "escalation_card :severity must be one of #{inspect(@escalation_severities)}"
+ end
+
+ defp normalize_optional_escalation_severity(nil), do: nil
+ defp normalize_optional_escalation_severity(sev) when sev in @escalation_severities, do: sev
+
+ defp normalize_optional_escalation_severity(sev) when is_binary(sev) do
+ sev
+ |> String.to_existing_atom()
+ |> normalize_optional_escalation_severity()
+ rescue
+ ArgumentError -> nil
+ end
+
+ defp normalize_optional_escalation_severity(_), do: nil
+
defp put_collection_picker_interactions(opts, picker_id) do
cond do
Map.has_key?(opts, :interactions) or Map.has_key?(opts, "interactions") or
@@ -2119,7 +2246,6 @@ defmodule UnifiedIUR.Widgets.Components do
raise ArgumentError, "tool_result_summary must be a map"
end
-
defp normalize_query_preview_state!(state) when state in @query_preview_states, do: state
defp normalize_query_preview_state!(state) when is_binary(state) do
diff --git a/packages/unified_iur/test/unified_iur/widgets/components_test.exs b/packages/unified_iur/test/unified_iur/widgets/components_test.exs
index 98d8db27..ee4e03be 100644
--- a/packages/unified_iur/test/unified_iur/widgets/components_test.exs
+++ b/packages/unified_iur/test/unified_iur/widgets/components_test.exs
@@ -54,7 +54,8 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do
:right_rail,
:command_palette,
:composer_query_preview,
- :propose_new_doc_card
+ :propose_new_doc_card,
+ :escalation_card
]
assert Components.redline_code_kinds() == [
diff --git a/packages/unified_iur/test/unified_iur/widgets/escalation_card_test.exs b/packages/unified_iur/test/unified_iur/widgets/escalation_card_test.exs
new file mode 100644
index 00000000..88a68637
--- /dev/null
+++ b/packages/unified_iur/test/unified_iur/widgets/escalation_card_test.exs
@@ -0,0 +1,222 @@
+defmodule UnifiedIUR.Widgets.EscalationCardTest do
+ use ExUnit.Case, async: true
+
+ alias UnifiedIUR.{Element, Validate}
+ alias UnifiedIUR.Widgets.Components
+
+ describe "escalation_card constructor" do
+ test "emits canonical layer_shell_and_callout component metadata" do
+ card =
+ Components.escalation_card(
+ id: "esc-1",
+ target_project_id: "ariston-ui",
+ severity: :p2,
+ text: "Coverage gap detected."
+ )
+
+ assert %Element{type: :widget, kind: :escalation_card} = card
+
+ assert card.attributes.component == %{
+ family: :layer_shell_and_callout,
+ kind: :escalation_card
+ }
+
+ assert %{target_project_id: "ariston-ui", severity: :p2, text: "Coverage gap detected."} =
+ card.attributes.escalation
+
+ assert :ok = Validate.element(card)
+ end
+
+ test "accepts all three severity values" do
+ for severity <- [:p1, :p2, :p3] do
+ card =
+ Components.escalation_card(
+ id: "esc-#{severity}",
+ target_project_id: "proj",
+ severity: severity,
+ text: "Test."
+ )
+
+ assert card.attributes.escalation.severity == severity
+ assert :ok = Validate.element(card)
+ end
+ end
+
+ test "accepts severity as binary and normalizes to atom" do
+ card =
+ Components.escalation_card(
+ id: "esc-bin",
+ target_project_id: "proj",
+ severity: "p1",
+ text: "Test."
+ )
+
+ assert card.attributes.escalation.severity == :p1
+ end
+
+ test "stores optional fields when present" do
+ card =
+ Components.escalation_card(
+ id: "esc-full",
+ target_project_id: "ariston-ui",
+ severity: :p1,
+ text: "P1 escalation.",
+ related_finding_id: "finding-42",
+ proposed_action: "Patch the gap immediately",
+ actor_handle: "@codex",
+ escalated_at: "2026-05-27T10:00:00Z",
+ target_finding_id: "finding-43",
+ target_severity: :p2,
+ originating_severity: :p3
+ )
+
+ esc = card.attributes.escalation
+
+ assert esc.related_finding_id == "finding-42"
+ assert esc.proposed_action == "Patch the gap immediately"
+ assert esc.actor_handle == "@codex"
+ assert esc.escalated_at == "2026-05-27T10:00:00Z"
+ assert esc.target_finding_id == "finding-43"
+ assert esc.target_severity == :p2
+ assert esc.originating_severity == :p3
+
+ assert :ok = Validate.element(card)
+ end
+
+ test "raises when target_project_id is missing" do
+ assert_raise ArgumentError, ~r/target_project_id/, fn ->
+ Components.escalation_card(severity: :p2, text: "Test.")
+ end
+ end
+
+ test "raises when text is missing" do
+ assert_raise ArgumentError, ~r/text/, fn ->
+ Components.escalation_card(target_project_id: "proj", severity: :p2)
+ end
+ end
+
+ test "raises on unknown severity" do
+ assert_raise ArgumentError, ~r/severity/, fn ->
+ Components.escalation_card(
+ target_project_id: "proj",
+ severity: :critical,
+ text: "Test."
+ )
+ end
+ end
+
+ test "emits default interactions for acknowledge and route_to_rail" do
+ card =
+ Components.escalation_card(
+ id: "esc-default",
+ target_project_id: "ariston-ui",
+ severity: :p2,
+ text: "Test."
+ )
+
+ interactions = Map.get(card.attributes, :interactions, [])
+ commands = Enum.map(interactions, & &1.payload.command)
+
+ assert :acknowledge in commands
+ assert :route_to_rail in commands
+ end
+ end
+
+ describe "escalation_card validation" do
+ test "returns :ok for a well-formed element" do
+ card =
+ Components.escalation_card(
+ id: "esc-ok",
+ target_project_id: "proj",
+ severity: :p3,
+ text: "Low-priority gap."
+ )
+
+ assert :ok = Validate.element(card)
+ end
+
+ test "returns :invalid_escalation_card when target_project_id is blank" do
+ card =
+ Element.new(:widget, :escalation_card,
+ attributes: %{
+ component: %{family: :layer_shell_and_callout, kind: :escalation_card},
+ escalation: %{target_project_id: "", severity: :p2, text: "Test."}
+ }
+ )
+
+ assert {:error, errors} = Validate.element(card)
+ assert Enum.any?(errors, &(&1.code == :invalid_escalation_card))
+ end
+
+ test "returns :invalid_escalation_card when text is blank" do
+ card =
+ Element.new(:widget, :escalation_card,
+ attributes: %{
+ component: %{family: :layer_shell_and_callout, kind: :escalation_card},
+ escalation: %{target_project_id: "proj", severity: :p2, text: ""}
+ }
+ )
+
+ assert {:error, errors} = Validate.element(card)
+ assert Enum.any?(errors, &(&1.code == :invalid_escalation_card))
+ end
+
+ test "returns :invalid_escalation_card when severity is unknown" do
+ card =
+ Element.new(:widget, :escalation_card,
+ attributes: %{
+ component: %{family: :layer_shell_and_callout, kind: :escalation_card},
+ escalation: %{target_project_id: "proj", severity: :critical, text: "Test."}
+ }
+ )
+
+ assert {:error, errors} = Validate.element(card)
+ assert Enum.any?(errors, &(&1.code == :invalid_escalation_card))
+ end
+
+ test "returns :invalid_escalation_card when escalation is not a map" do
+ card =
+ Element.new(:widget, :escalation_card,
+ attributes: %{
+ component: %{family: :layer_shell_and_callout, kind: :escalation_card},
+ escalation: "not-a-map"
+ }
+ )
+
+ assert {:error, errors} = Validate.element(card)
+ assert Enum.any?(errors, &(&1.code == :invalid_escalation_card))
+ end
+
+ test "returns :invalid_escalation_card when optional string field is non-string" do
+ card =
+ Element.new(:widget, :escalation_card,
+ attributes: %{
+ component: %{family: :layer_shell_and_callout, kind: :escalation_card},
+ escalation: %{
+ target_project_id: "proj",
+ severity: :p2,
+ text: "Test.",
+ actor_handle: 123
+ }
+ }
+ )
+
+ assert {:error, errors} = Validate.element(card)
+ assert Enum.any?(errors, &(&1.code == :invalid_escalation_card))
+ end
+ end
+
+ describe "escalation_card is in @layer_callout_kinds" do
+ test "escalation_card is classified as a layer callout kind" do
+ card =
+ Components.escalation_card(
+ id: "esc-family",
+ target_project_id: "ariston-ui",
+ severity: :p2,
+ text: "Test."
+ )
+
+ assert card.attributes.component.family == :layer_shell_and_callout
+ end
+ end
+end
diff --git a/packages/unified_ui/lib/unified_ui/compiler/pipeline.ex b/packages/unified_ui/lib/unified_ui/compiler/pipeline.ex
index f61e6597..b4b0b011 100644
--- a/packages/unified_ui/lib/unified_ui/compiler/pipeline.ex
+++ b/packages/unified_ui/lib/unified_ui/compiler/pipeline.ex
@@ -1066,6 +1066,24 @@ defmodule UnifiedUi.Compiler.Pipeline do
])
)
+ :escalation_card ->
+ Widgets.Components.escalation_card(
+ common_opts(node, attachments, [
+ :target_project_id,
+ :text,
+ :severity,
+ :related_finding_id,
+ :proposed_action,
+ :target_finding_id,
+ :target_severity,
+ :originating_severity,
+ :actor_handle,
+ :escalated_at,
+ :acknowledge_intent,
+ :route_intent
+ ])
+ )
+
:right_rail ->
lower_right_rail(node, context, visited, attachments)
diff --git a/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex b/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex
index 6142e131..70f7bbb2 100644
--- a/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex
+++ b/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex
@@ -302,6 +302,23 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do
preview_intent: [type: :atom, required: false],
summary: [type: :string, required: false]
),
+ leaf(
+ :escalation_card,
+ @layer_family,
+ target_project_id: [type: :string, required: true],
+ text: [type: :string, required: true],
+ severity: [type: {:in, [:p1, :p2, :p3]}, required: true],
+ related_finding_id: [type: :string, required: false],
+ proposed_action: [type: :string, required: false],
+ target_finding_id: [type: :string, required: false],
+ target_severity: [type: {:in, [:p1, :p2, :p3]}, required: false],
+ originating_severity: [type: {:in, [:p1, :p2, :p3]}, required: false],
+ actor_handle: [type: :string, required: false],
+ escalated_at: [type: :string, required: false],
+ acknowledge_intent: [type: :atom, required: false],
+ route_intent: [type: :atom, required: false],
+ summary: [type: :string, required: false]
+ ),
right_rail_entity()
]
end
diff --git a/packages/unified_ui/lib/unified_ui/dsl/node.ex b/packages/unified_ui/lib/unified_ui/dsl/node.ex
index 0bb418f1..3703429a 100644
--- a/packages/unified_ui/lib/unified_ui/dsl/node.ex
+++ b/packages/unified_ui/lib/unified_ui/dsl/node.ex
@@ -234,6 +234,16 @@ defmodule UnifiedUi.Dsl.Node do
accept_intent: atom() | nil,
reject_intent: atom() | nil,
preview_intent: atom() | nil,
+ target_project_id: String.t() | nil,
+ text: String.t() | nil,
+ related_finding_id: String.t() | nil,
+ proposed_action: String.t() | nil,
+ target_finding_id: String.t() | nil,
+ target_severity: atom() | nil,
+ originating_severity: atom() | nil,
+ escalated_at: String.t() | nil,
+ acknowledge_intent: atom() | nil,
+ route_intent: atom() | nil,
density: atom() | nil,
children: [t()]
}
@@ -448,6 +458,16 @@ defmodule UnifiedUi.Dsl.Node do
accept_intent: nil,
reject_intent: nil,
preview_intent: nil,
+ target_project_id: nil,
+ text: nil,
+ related_finding_id: nil,
+ proposed_action: nil,
+ target_finding_id: nil,
+ target_severity: nil,
+ originating_severity: nil,
+ escalated_at: nil,
+ acknowledge_intent: nil,
+ route_intent: nil,
density: nil,
children: []
@@ -585,6 +605,16 @@ defmodule UnifiedUi.Dsl.Node do
accept_intent: node.accept_intent,
reject_intent: node.reject_intent,
preview_intent: node.preview_intent,
+ target_project_id: node.target_project_id,
+ text: node.text,
+ related_finding_id: node.related_finding_id,
+ proposed_action: node.proposed_action,
+ target_finding_id: node.target_finding_id,
+ target_severity: node.target_severity,
+ originating_severity: node.originating_severity,
+ escalated_at: node.escalated_at,
+ acknowledge_intent: node.acknowledge_intent,
+ route_intent: node.route_intent,
density: node.density,
current: node.current,
minimum: node.minimum,
diff --git a/packages/unified_ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex b/packages/unified_ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex
index 991b509b..13fec015 100644
--- a/packages/unified_ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex
+++ b/packages/unified_ui/lib/unified_ui/dsl/verifiers/validate_widget_components.ex
@@ -72,6 +72,7 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do
@query_preview_states [:loading, :ready, :empty, :error]
@propose_new_doc_statuses [:pending, :accepted, :rejected, :archived]
@propose_new_doc_actions [:accept, :reject, :preview]
+ @escalation_severities [:p1, :p2, :p3]
@spec verify(map()) :: :ok | {:error, Spark.Error.DslError.t()}
def verify(dsl) do
@@ -494,6 +495,52 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do
end
end
+ def validate_node(%Node{
+ kind: :escalation_card,
+ id: id,
+ target_project_id: target_project_id,
+ text: text,
+ severity: severity,
+ related_finding_id: related_finding_id,
+ proposed_action: proposed_action,
+ target_finding_id: target_finding_id,
+ target_severity: target_severity,
+ originating_severity: originating_severity,
+ actor_handle: actor_handle,
+ escalated_at: escalated_at
+ }) do
+ cond do
+ not non_blank_string?(target_project_id) ->
+ {:error, [:composition, :escalation_card, id],
+ "escalation_card #{inspect(id)} target_project_id must be a non-empty string"}
+
+ not non_blank_string?(text) ->
+ {:error, [:composition, :escalation_card, id],
+ "escalation_card #{inspect(id)} text must be a non-empty string"}
+
+ severity not in @escalation_severities ->
+ {:error, [:composition, :escalation_card, id],
+ "escalation_card #{inspect(id)} severity must be one of #{inspect(@escalation_severities)}"}
+
+ not optional_string?(related_finding_id) or not optional_string?(proposed_action) or
+ not optional_string?(target_finding_id) or not optional_string?(actor_handle) or
+ not optional_string?(escalated_at) ->
+ {:error, [:composition, :escalation_card, id],
+ "escalation_card #{inspect(id)} optional string fields must be strings when present"}
+
+ not (is_nil(target_severity) or target_severity in @escalation_severities) ->
+ {:error, [:composition, :escalation_card, id],
+ "escalation_card #{inspect(id)} target_severity must be one of #{inspect(@escalation_severities)}"}
+
+ not (is_nil(originating_severity) or originating_severity in @escalation_severities) ->
+ {:error, [:composition, :escalation_card, id],
+ "escalation_card #{inspect(id)} originating_severity must be one of #{inspect(@escalation_severities)}"}
+
+ true ->
+ :ok
+ end
+ end
+
def validate_node(%Node{kind: :redline_inline, id: id, segments: segments}) do
if valid_redline_segments?(segments) do
:ok
diff --git a/packages/unified_ui/lib/unified_ui/widget_components.ex b/packages/unified_ui/lib/unified_ui/widget_components.ex
index 18e2a4b6..7fd8becb 100644
--- a/packages/unified_ui/lib/unified_ui/widget_components.ex
+++ b/packages/unified_ui/lib/unified_ui/widget_components.ex
@@ -262,6 +262,13 @@ defmodule UnifiedUi.WidgetComponents do
"Inline document-creation proposal callout with target path, preview body, decision status, and canonical accept, reject, and preview actions.",
aliases: []
},
+ %{
+ kind: :escalation_card,
+ family: :layer_shell_and_callout,
+ summary:
+ "Inline severity-tagged escalation callout targeting a project with canonical acknowledge and route actions.",
+ aliases: []
+ },
%{
kind: :workflow_progress_status_card,
family: :workflow_progress_and_status,
diff --git a/packages/unified_ui/test/unified_ui/advanced_widget_families_test.exs b/packages/unified_ui/test/unified_ui/advanced_widget_families_test.exs
index 96dc1eb9..ced9d98a 100644
--- a/packages/unified_ui/test/unified_ui/advanced_widget_families_test.exs
+++ b/packages/unified_ui/test/unified_ui/advanced_widget_families_test.exs
@@ -185,6 +185,7 @@ defmodule UnifiedUi.AdvancedWidgetFamiliesTest do
:event_callout,
:composer_query_preview,
:propose_new_doc_card,
+ :escalation_card,
:right_rail,
:redline_inline,
:code_block_syntax_highlighted,
diff --git a/packages/unified_ui/test/unified_ui/operational_widget_components_test.exs b/packages/unified_ui/test/unified_ui/operational_widget_components_test.exs
index 6a6e69dd..c0f9893f 100644
--- a/packages/unified_ui/test/unified_ui/operational_widget_components_test.exs
+++ b/packages/unified_ui/test/unified_ui/operational_widget_components_test.exs
@@ -176,6 +176,7 @@ defmodule UnifiedUi.OperationalWidgetComponentsTest do
:event_callout,
:composer_query_preview,
:propose_new_doc_card,
+ :escalation_card,
:right_rail
]
diff --git a/packages/unified_ui/test/unified_ui/phase_2_integration_test.exs b/packages/unified_ui/test/unified_ui/phase_2_integration_test.exs
index 1027c39c..9971c8ac 100644
--- a/packages/unified_ui/test/unified_ui/phase_2_integration_test.exs
+++ b/packages/unified_ui/test/unified_ui/phase_2_integration_test.exs
@@ -320,6 +320,7 @@ defmodule UnifiedUi.Phase2IntegrationTest do
:event_callout,
:composer_query_preview,
:propose_new_doc_card,
+ :escalation_card,
:right_rail,
:redline_inline,
:code_block_syntax_highlighted,
diff --git a/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs b/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs
index 6b40f135..c7760f9a 100644
--- a/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs
+++ b/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs
@@ -55,7 +55,8 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do
:right_rail,
:command_palette,
:composer_query_preview,
- :propose_new_doc_card
+ :propose_new_doc_card,
+ :escalation_card
],
redline_and_code: [:redline_inline, :code_block_syntax_highlighted],
composition_behavior: [:list_repeat]
@@ -75,6 +76,7 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do
assert :command_palette in kinds
assert :composer_query_preview in kinds
assert :propose_new_doc_card in kinds
+ assert :escalation_card in kinds
assert :collection_picker in kinds
assert :live_session_card in kinds
assert :workflow_progress_status_card in kinds
diff --git a/packages/unified_ui/test/unified_ui/widget_components_compiler_lowering_test.exs b/packages/unified_ui/test/unified_ui/widget_components_compiler_lowering_test.exs
index cd117065..59be6236 100644
--- a/packages/unified_ui/test/unified_ui/widget_components_compiler_lowering_test.exs
+++ b/packages/unified_ui/test/unified_ui/widget_components_compiler_lowering_test.exs
@@ -157,6 +157,17 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do
preview_intent(:preview_doc_proposal)
end
+ escalation_card :blocker_escalation do
+ target_project_id("ariston-ui")
+ text("Accessibility coverage gap on chat surface.")
+ severity(:p2)
+ proposed_action("Add aria-live region to chat timeline")
+ actor_handle("@codex")
+ escalated_at("2026-05-28T10:00:00Z")
+ acknowledge_intent(:acknowledge_escalation)
+ route_intent(:route_escalation)
+ end
+
right_rail :workspace_rail do
side(:right)
@@ -203,6 +214,7 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do
panel = Tree.find_by_id(iur, :details_panel)
query_preview = Tree.find_by_id(iur, :query_preview)
proposal = Tree.find_by_id(iur, :doc_proposal)
+ escalation = Tree.find_by_id(iur, :blocker_escalation)
rail = Tree.find_by_id(iur, :workspace_rail)
code = Tree.find_by_id(iur, :code_sample)
@@ -327,6 +339,15 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do
actions: [:accept, :reject, :preview]
}
+ assert escalation.attributes.component == %{
+ family: :layer_shell_and_callout,
+ kind: :escalation_card
+ }
+
+ assert escalation.attributes.escalation.target_project_id == "ariston-ui"
+ assert escalation.attributes.escalation.text == "Accessibility coverage gap on chat surface."
+ assert escalation.attributes.escalation.severity == :p2
+
assert rail.attributes.component == %{
family: :layer_shell_and_callout,
kind: :right_rail
diff --git a/test/ash_ui/phase_31_package_boundary_test.exs b/test/ash_ui/phase_31_package_boundary_test.exs
index 34282b9e..d72eea61 100644
--- a/test/ash_ui/phase_31_package_boundary_test.exs
+++ b/test/ash_ui/phase_31_package_boundary_test.exs
@@ -73,7 +73,8 @@ defmodule AshUI.Phase31PackageBoundaryTest do
:right_rail,
:command_palette,
:composer_query_preview,
- :propose_new_doc_card
+ :propose_new_doc_card,
+ :escalation_card
]
assert families.redline_and_code == [:redline_inline, :code_block_syntax_highlighted]
diff --git a/test/ash_ui/rendering/iur_adapter_test.exs b/test/ash_ui/rendering/iur_adapter_test.exs
index 8c19290e..f58d1b80 100644
--- a/test/ash_ui/rendering/iur_adapter_test.exs
+++ b/test/ash_ui/rendering/iur_adapter_test.exs
@@ -412,6 +412,7 @@ defmodule AshUI.Rendering.IURAdapterTest do
assert :ok = UnifiedIUR.Validate.element(child.element)
end
+
test "returns structured conversion errors for invalid propose_new_doc_card payloads" do
ash_iur =
IUR.new(:screen,
@@ -464,6 +465,73 @@ defmodule AshUI.Rendering.IURAdapterTest do
assert error.message =~ "tool_call_card :args must be a map"
end
+ test "routes escalation_card kind through layer_shell_and_callout family with canonical actions" do
+ ash_iur =
+ IUR.new(:screen,
+ id: "escalation-screen",
+ name: "escalation_screen",
+ attributes: %{},
+ children: [
+ IUR.new(:escalation_card,
+ id: "esc-1",
+ props: %{
+ "target_project_id" => "ariston-ui",
+ "severity" => "p2",
+ "text" => "Coverage gap detected.",
+ "actor_handle" => "@codex",
+ "proposed_action" => "Add aria-live region"
+ }
+ )
+ ]
+ )
+
+ assert {:ok, canonical} = IURAdapter.to_canonical(ash_iur)
+ [child] = canonical.children
+ assert child.element.kind == :escalation_card
+ assert child.element.type == :widget
+ assert child.element.attributes.component.family == :layer_shell_and_callout
+
+ esc = child.element.attributes.escalation
+
+ assert esc.target_project_id == "ariston-ui"
+ assert esc.severity == :p2
+ assert esc.text == "Coverage gap detected."
+ assert esc.actor_handle == "@codex"
+ assert esc.proposed_action == "Add aria-live region"
+
+ assert [
+ %UnifiedIUR.Interaction{family: :command, intent: :acknowledge_escalation},
+ %UnifiedIUR.Interaction{family: :command, intent: :route_escalation_to_rail}
+ ] = child.element.attributes.interactions
+
+ assert :ok = UnifiedIUR.Validate.element(child.element)
+ end
+
+ test "returns structured conversion errors for invalid escalation_card payloads" do
+ ash_iur =
+ IUR.new(:screen,
+ id: "escalation-screen-invalid",
+ name: "escalation_screen",
+ attributes: %{},
+ children: [
+ IUR.new(:escalation_card,
+ id: "esc-invalid",
+ props: %{
+ "target_project_id" => "ariston-ui",
+ "severity" => "critical",
+ "text" => "Test."
+ }
+ )
+ ]
+ )
+
+ assert {:error, {:conversion_failed, %ArgumentError{} = error}} =
+ IURAdapter.to_canonical(ash_iur)
+
+ assert error.message =~ "severity must be one of"
+ end
+
+
test "routes workflow_progress_status_card kind through workflow_progress_and_status family" do
ash_iur =
IUR.new(:screen,
diff --git a/test/ash_ui/rendering/live_ui_adapter_test.exs b/test/ash_ui/rendering/live_ui_adapter_test.exs
index d76a347c..4cfbc38b 100644
--- a/test/ash_ui/rendering/live_ui_adapter_test.exs
+++ b/test/ash_ui/rendering/live_ui_adapter_test.exs
@@ -1413,6 +1413,64 @@ defmodule AshUI.Rendering.LiveUIAdapterTest do
end
end
+ describe "escalation_card adapter dispatch" do
+ test "generates dedicated escalation_card fallback markup with severity class" do
+ iur = %{
+ "type" => "escalation_card",
+ "id" => "escalation-adapter-test",
+ "props" => %{
+ "escalation" => %{
+ "target_project_id" => "ariston-ui",
+ "severity" => "p1",
+ "text" => "Critical coverage gap detected.",
+ "actor_handle" => "@codex",
+ "proposed_action" => "Add aria-live region"
+ }
+ },
+ "children" => [],
+ "metadata" => %{}
+ }
+
+ {:ok, heex} = LiveUIAdapter.render(iur, force_fallback: true)
+
+ assert heex =~ "ash-escalation-card"
+ assert heex =~ "ash-escalation-card--p1"
+ assert heex =~ ~s(data-live-ui-widget="escalation-card")
+ assert heex =~ ~s(data-severity="p1")
+ assert heex =~ "P1"
+ assert heex =~ "Critical coverage gap detected."
+ assert heex =~ "@codex"
+ assert heex =~ ~s(aria-label="Acknowledge p1 escalation")
+ assert heex =~ ~s(aria-label="Route p1 escalation to rail")
+ assert heex =~ ~s(role="alert")
+ assert heex =~ ~s(role="status")
+ end
+
+ test "shows acknowledged status and hides action buttons when acknowledged" do
+ iur = %{
+ "type" => "escalation_card",
+ "id" => "escalation-adapter-ack",
+ "props" => %{
+ "escalation" => %{
+ "target_project_id" => "ariston-ui",
+ "severity" => "p2",
+ "text" => "Gap.",
+ "acknowledged?" => true
+ }
+ },
+ "children" => [],
+ "metadata" => %{}
+ }
+
+ {:ok, heex} = LiveUIAdapter.render(iur, force_fallback: true)
+
+ assert heex =~ "ash-escalation-card__acknowledged"
+ assert heex =~ "Acknowledged"
+ refute heex =~ ~s(aria-label="Acknowledge p2 escalation")
+ refute heex =~ ~s(aria-label="Route p2 escalation to rail")
+ end
+ end
+
describe "collection_picker adapter dispatch" do
test "generates dedicated collection_picker fallback markup" do
iur = %{