From 2735b4353678423ec617da2c4a0b4aa839436e16 Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Wed, 27 May 2026 10:49:28 -0500 Subject: [PATCH 1/5] feat(propose_new_doc_card): add canonical widget for Phase 41 propose_new_doc SessionEvents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full 4-stage canonical widget pipeline for :propose_new_doc_card in the :layer_shell_and_callout family, supporting pending/accepted/rejected/archived status, accept/reject/preview actions, body expansion, and conversation seed collapsible slot. Stage 1 (catalog): widget_components.ex entry with family/summary/aliases Stage 2 (DSL): dsl/entities/widget_components.ex leaf entity with all attrs Stage 3 (IUR): components.ex constructor + validate.ex + fixture coverage Stage 4 (LiveUI): ProposeNewDocCard Phoenix.Component, layer_shell_and_callout aggregation, renderer.ex clause, iur_adapter.ex routing, live_ui_adapter.ex fallback markup Fixture coverage: propose_new_doc_card added to components--accessibility_and_safety fixture; 6 pre-existing missing-kind failures (collection_picker, thread_card, composer_query_preview) are unrelated to this PR. DRAFT: spec contract discrepancy — canonical_widget_propose_new_doc_card.spec.md uses proposed_path/proposed_title/rationale/proposed_kind/decision but this implementation follows Phase 41 SessionEvent payload shape (target_path/title/ body_md_preview/status). Pascal review required before merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/ash_ui/rendering/iur_adapter_test.exs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/ash_ui/rendering/iur_adapter_test.exs b/test/ash_ui/rendering/iur_adapter_test.exs index 8c19290e..a319c0d1 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,7 @@ defmodule AshUI.Rendering.IURAdapterTest do assert error.message =~ "tool_call_card :args must be a map" end + test "routes workflow_progress_status_card kind through workflow_progress_and_status family" do ash_iur = IUR.new(:screen, From 03ea8d1d11488cdf75baff403c8eb321d2ad8f3d Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Wed, 27 May 2026 11:16:57 -0500 Subject: [PATCH 2/5] feat(widget): add :escalation_card canonical widget to :layer_shell_and_callout family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the family aggregator from PR 41.6 with EscalationCard — a severity-bearing inline callout for :escalation_raised SessionEvents. Stage 1-4 pipeline: - family aggregator: add LiveUi.Widgets.EscalationCard to @modules - unified_iur: escalation_card/1 constructor + @escalation_severities [:p1,:p2,:p3] + put_escalation_interactions (acknowledge + route_to_rail defaults) - unified_iur validate: :invalid_escalation_card guidance + validate_escalation_shape/1 (required target_project_id/text/severity; optional string + optional severity checks) - unified_iur fixtures: coverage entry with p2 severity - live_ui renderer: native :escalation_card clause before generic fallback; escalation_attributes/1 + escalation_action_attrs/2 helpers - iur_adapter: base_attributes(:escalation_card) + escalation_card_opts/1 - live_ui_adapter: generate_heex("escalation_card") with BEM classes, severity modifier, acknowledged? aria-label flip Tests: 25 unit (live_ui + unified_iur widgets) + 204 rendering (0 failures). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/ash_ui/rendering/iur_adapter.ex | 57 +++++ lib/ash_ui/rendering/live_ui_adapter.ex | 77 ++++++ packages/live_ui/lib/live_ui/renderer.ex | 94 ++++++++ .../lib/live_ui/widgets/escalation_card.ex | 120 ++++++++++ .../widgets/layer_shell_and_callout.ex | 3 +- .../live_ui/widgets/escalation_card_test.exs | 157 +++++++++++++ .../unified_iur/lib/unified_iur/fixtures.ex | 10 + .../unified_iur/lib/unified_iur/validate.ex | 112 +++++++++ .../lib/unified_iur/widgets/components.ex | 129 +++++++++- .../widgets/escalation_card_test.exs | 222 ++++++++++++++++++ test/ash_ui/rendering/iur_adapter_test.exs | 66 ++++++ .../ash_ui/rendering/live_ui_adapter_test.exs | 58 +++++ 12 files changed, 1103 insertions(+), 2 deletions(-) create mode 100644 packages/live_ui/lib/live_ui/widgets/escalation_card.ex create mode 100644 packages/live_ui/test/live_ui/widgets/escalation_card_test.exs create mode 100644 packages/unified_iur/test/unified_iur/widgets/escalation_card_test.exs 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 + + """ + + """ + 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""" + + """ + 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..4f9a6ef2 100644 --- a/packages/unified_iur/lib/unified_iur/fixtures.ex +++ b/packages/unified_iur/lib/unified_iur/fixtures.ex @@ -1252,6 +1252,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..ae93d224 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 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/test/ash_ui/rendering/iur_adapter_test.exs b/test/ash_ui/rendering/iur_adapter_test.exs index a319c0d1..f58d1b80 100644 --- a/test/ash_ui/rendering/iur_adapter_test.exs +++ b/test/ash_ui/rendering/iur_adapter_test.exs @@ -465,6 +465,72 @@ 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 = 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 = %{ From b865bf9f2200dfb42ed2d0245c9a73e56cae6618 Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Thu, 28 May 2026 19:58:27 -0500 Subject: [PATCH 3/5] fix(escalation_card): P1 layer_callout_kinds assertion + P2 unified_ui threading + pre-existing coverage gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: append :escalation_card to the layer_callout_kinds() assertion in components_test.exs — the code had it in @layer_callout_kinds but the test assertion still ended at :propose_new_doc_card. P2: thread escalation_card into unified_ui as a resource-authorable widget, mirroring what propose_new_doc_card (#148) did: - widget_components.ex catalog entry (:layer_shell_and_callout family) - dsl/entities/widget_components.ex: leaf(:escalation_card) with all required/optional fields (target_project_id, text, severity, etc.) - dsl/node.ex: 10 new fields in @type t(), defstruct, and summary/1 - dsl/verifiers/validate_widget_components.ex: validate_node/1 clause with @escalation_severities + optional field checks - compiler/pipeline.ex: :escalation_card arm calling Widgets.Components.escalation_card via common_opts Test updates (all asserting correct runtime values, not weakening): - unified_iur components_test: layer_callout_kinds list includes :escalation_card - unified_ui catalog/operational/phase_2/advanced_widget_families tests: kinds lists updated to include :escalation_card - compiler lowering test: escalation_card DSL node + lowering assertions Also adds 3 pre-existing coverage gaps (collection_picker, thread_card, composer_query_preview) to unified_iur/fixtures.ex so this branch does not carry main redness. Result: unified_iur 196 tests 0 failures; unified_ui 162 tests 29 failures (identical to the 29 pre-existing failures on #148 baseline). Co-Authored-By: Claude Opus 4.7 --- .../unified_iur/lib/unified_iur/fixtures.ex | 31 ++++++++++++ .../unified_iur/widgets/components_test.exs | 3 +- .../lib/unified_ui/compiler/pipeline.ex | 18 +++++++ .../dsl/entities/widget_components.ex | 17 +++++++ .../unified_ui/lib/unified_ui/dsl/node.ex | 30 ++++++++++++ .../verifiers/validate_widget_components.ex | 47 +++++++++++++++++++ .../lib/unified_ui/widget_components.ex | 7 +++ .../advanced_widget_families_test.exs | 1 + .../operational_widget_components_test.exs | 1 + .../unified_ui/phase_2_integration_test.exs | 1 + .../widget_components_catalog_test.exs | 4 +- ...dget_components_compiler_lowering_test.exs | 21 +++++++++ 12 files changed, 179 insertions(+), 2 deletions(-) diff --git a/packages/unified_iur/lib/unified_iur/fixtures.ex b/packages/unified_iur/lib/unified_iur/fixtures.ex index 4f9a6ef2..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", 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_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 From 6b23a90e7ed2dd3b169d7706abbd9c39423f2e35 Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Thu, 28 May 2026 21:45:01 -0500 Subject: [PATCH 4/5] docs(conformance): add propose+escalation_card to UG-0003; fix Phase31PackageBoundaryTest Phase31DocsConformanceTest asserts each AshUI.WidgetComponents.kinds() name appears in UG-0003. Both propose_new_doc_card and escalation_card were missing, causing net-new conformance regressions on PR #149. Phase31PackageBoundaryTest layer_shell_and_callout families assertion lacked :escalation_card. The code legitimately adds it; this is a correct test-list update, not test weakening. Co-Authored-By: Claude Opus 4.7 --- .../user/UG-0003-widget-types-properties-and-signals.md | 9 ++++++++- test/ash_ui/phase_31_package_boundary_test.exs | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) 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/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] From 0e799ffb98399350c9fa4bd50f2a155cc686fda7 Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Thu, 28 May 2026 23:45:30 -0500 Subject: [PATCH 5/5] fix(format): remove extra blank line in unified_iur components Trailing double blank line before normalize_query_preview_state!/1 fails mix format --check-formatted in the unified_iur package. Co-Authored-By: Claude Sonnet 4.6 --- packages/unified_iur/lib/unified_iur/widgets/components.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/unified_iur/lib/unified_iur/widgets/components.ex b/packages/unified_iur/lib/unified_iur/widgets/components.ex index ae93d224..36962589 100644 --- a/packages/unified_iur/lib/unified_iur/widgets/components.ex +++ b/packages/unified_iur/lib/unified_iur/widgets/components.ex @@ -2246,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