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 91b84ac1..4382ea76 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` | none |
| Workflow, progress, and status | `pipeline_stepper_horizontal`, `segmented_progress_bar`, `workflow_stage_list_vertical`, `meter_thin`, `unread_badge`, `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` | 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 |
| Redline and code | `redline_inline`, `code_block_syntax_highlighted` | none |
| Composition behavior | `list_repeat` | `repeat` -> `list_repeat`, `ui_relationship_repeat` -> `list_repeat` |
@@ -153,6 +153,14 @@ is not a rail by itself: place it inside `right_rail` when the picker belongs
in a secondary panel, and keep bundle-specific cards, drag hooks, and LiveView
event names in the renderer or host layer.
+`propose_new_doc_card` is the canonical callout card for an agent-authored
+document proposal. Author props such as `target_path`, `title`,
+`body_md_preview`, `status`, optional `body_md`, `conversation_seed_md`,
+`actor_handle`, `proposed_at`, `expanded?`, and `seed_expanded?`. Use
+canonical `accept`, `reject`, `preview`, `body_toggled`, and
+`conversation_seed_toggled` interactions for operator actions. Keep LiveView
+event fields, route helpers, and product-specific copy 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 ce2a93b8..543b1f5c 100644
--- a/lib/ash_ui/rendering/iur_adapter.ex
+++ b/lib/ash_ui/rendering/iur_adapter.ex
@@ -517,6 +517,13 @@ defmodule AshUI.Rendering.IURAdapter do
|> Map.fetch!(:attributes)
end
+ defp base_attributes(:propose_new_doc_card, props) do
+ props
+ |> propose_new_doc_card_opts()
+ |> IURComponents.propose_new_doc_card()
+ |> Map.fetch!(:attributes)
+ end
+
defp base_attributes(:redline_inline = kind, props) do
component_attributes(
kind,
@@ -1113,6 +1120,42 @@ defmodule AshUI.Rendering.IURAdapter do
|> compact_map()
end
+ defp propose_new_doc_card_opts(props) do
+ proposal = props |> fetch(:propose_new_doc, %{}) |> normalize_map()
+
+ %{
+ id: first_present(props, [:_element_id, :id]),
+ target_path:
+ first_present(proposal, [:target_path]) || first_present(props, [:target_path]),
+ title: first_present(proposal, [:title]) || first_present(props, [:title, :label, :name]),
+ body_md_preview:
+ first_present(proposal, [:body_md_preview]) ||
+ first_present(props, [:body_md_preview, :preview, :summary]),
+ body_md: first_present(proposal, [:body_md]) || first_present(props, [:body_md, :body]),
+ status:
+ normalize_existing_atom(
+ first_present(proposal, [:status]) || first_present(props, [:status]) || :pending
+ ),
+ conversation_seed_md:
+ first_present(proposal, [:conversation_seed_md]) ||
+ first_present(props, [:conversation_seed_md]),
+ actor_handle:
+ first_present(proposal, [:actor_handle]) || first_present(props, [:actor_handle]),
+ proposed_at:
+ first_present(proposal, [:proposed_at]) || first_present(props, [:proposed_at]),
+ actions:
+ first_present(proposal, [:actions]) ||
+ first_present(props, [:actions]) ||
+ [:accept, :reject, :preview],
+ accept_intent: first_present(props, [:accept_intent]),
+ reject_intent: first_present(props, [:reject_intent]),
+ preview_intent: first_present(props, [:preview_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 72b5d420..b4d87f61 100644
--- a/lib/ash_ui/rendering/live_ui_adapter.ex
+++ b/lib/ash_ui/rendering/live_ui_adapter.ex
@@ -704,6 +704,90 @@ defmodule AshUI.Rendering.LiveUIAdapter do
"""
end
+ defp generate_heex(%{"type" => "propose_new_doc_card"} = iur, _opts) do
+ props = iur["props"] || %{}
+ proposal = props |> prop("propose_new_doc", props) |> normalize_item()
+
+ target_path =
+ escaped_text_prop(proposal, "target_path", escaped_text_prop(props, "target_path", ""))
+
+ title =
+ escaped_text_prop(
+ proposal,
+ "title",
+ escaped_text_prop(props, ["title", "label"], "Document")
+ )
+
+ body_md_preview =
+ escaped_text_prop(
+ proposal,
+ "body_md_preview",
+ escaped_text_prop(proposal, "body_md", escaped_text_prop(props, "body_md_preview", ""))
+ )
+
+ body_md = escaped_text_prop(proposal, "body_md")
+ status = escaped_text_prop(proposal, "status", escaped_text_prop(props, "status", "pending"))
+ actor_handle = escaped_text_prop(proposal, "actor_handle")
+ proposed_at = escaped_text_prop(proposal, "proposed_at")
+ seed = escaped_text_prop(proposal, "conversation_seed_md")
+ iur_id = html_attr(iur["id"] || iur[:id] || "propose-new-doc-card")
+ body_id = "#{iur_id}-body"
+ seed_id = "#{iur_id}-conversation-seed"
+
+ full_body? = body_md not in [nil, ""] and body_md != body_md_preview
+
+ body_toggle =
+ if full_body? do
+ ~s()
+ else
+ ""
+ end
+
+ seed_html =
+ if seed do
+ """
+
+ """
+ else
+ ""
+ end
+
+ decision_html =
+ if status == "pending" do
+ """
+
+
+ """
+ else
+ ~s(
This proposal is #{status}.
)
+ end
+
+ """
+
+
+ #{target_path}
+
+ #{body_toggle}
+ #{seed_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 e704ddd9..63e9abf2 100644
--- a/packages/live_ui/lib/live_ui/renderer.ex
+++ b/packages/live_ui/lib/live_ui/renderer.ex
@@ -877,6 +877,46 @@ defmodule LiveUi.Renderer do
"""
end
+ # NOTE: `:propose_new_doc_card` is a canonical layer-shell callout. Keep this
+ # native clause before the generic component fallback so decision actions keep
+ # canonical interaction transport.
+ def render(%{element: %Element{kind: :propose_new_doc_card}} = assigns) do
+ proposal = propose_new_doc_attributes(assigns.element)
+
+ assigns =
+ assigns
+ |> assign(:proposal, proposal)
+ |> assign(
+ :action_attrs,
+ propose_new_doc_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))
@@ -2674,6 +2714,59 @@ defmodule LiveUi.Renderer do
defp query_preview_interaction_attrs(_element, _event_target, _interaction, _query), do: %{}
+ defp propose_new_doc_attributes(%Element{} = element) do
+ element.attributes
+ |> Map.get(:propose_new_doc, Map.get(element.attributes, "propose_new_doc", %{}))
+ |> case do
+ proposal when is_map(proposal) -> proposal
+ proposal when is_list(proposal) -> Map.new(proposal)
+ _other -> %{}
+ end
+ end
+
+ defp propose_new_doc_action_attrs(%Element{} = element, event_target) do
+ %{
+ accept: propose_new_doc_interaction_attrs(element, event_target, :accept),
+ reject: propose_new_doc_interaction_attrs(element, event_target, :reject),
+ preview: propose_new_doc_interaction_attrs(element, event_target, :preview)
+ }
+ end
+
+ defp propose_new_doc_interaction_attrs(%Element{} = element, event_target, action)
+ when not is_nil(event_target) do
+ case propose_new_doc_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, "propose-new-doc-card"),
+ :"phx-value-widget" => "propose_new_doc_card",
+ :"phx-value-action" => Atom.to_string(action),
+ :"phx-value-target_path" =>
+ element |> propose_new_doc_attributes() |> map_value(:target_path, "") |> to_string()
+ }
+
+ _ ->
+ %{}
+ end
+ end
+
+ defp propose_new_doc_interaction_attrs(_element, _event_target, _action), do: %{}
+
+ defp propose_new_doc_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 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/layer_shell_and_callout.ex b/packages/live_ui/lib/live_ui/widgets/layer_shell_and_callout.ex
index 158d5ed8..90455a1c 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
@@ -6,7 +6,8 @@ defmodule LiveUi.Widgets.LayerShellAndCallout do
@modules [
LiveUi.Widgets.RightRail,
LiveUi.Widgets.SidebarSection,
- LiveUi.Widgets.ComposerQueryPreview
+ LiveUi.Widgets.ComposerQueryPreview,
+ LiveUi.Widgets.ProposeNewDocCard
]
@spec modules() :: [module()]
diff --git a/packages/live_ui/lib/live_ui/widgets/propose_new_doc_card.ex b/packages/live_ui/lib/live_ui/widgets/propose_new_doc_card.ex
new file mode 100644
index 00000000..d4cbf935
--- /dev/null
+++ b/packages/live_ui/lib/live_ui/widgets/propose_new_doc_card.ex
@@ -0,0 +1,210 @@
+defmodule LiveUi.Widgets.ProposeNewDocCard do
+ @moduledoc """
+ Native proposal card for an agent-authored document draft.
+
+ The widget renders the canonical `:propose_new_doc_card` callout shape and
+ leaves accept, reject, and preview transport to renderer-supplied interaction
+ attributes.
+ """
+
+ use LiveUi.Component,
+ family: :layer_shell_and_callout,
+ name: :propose_new_doc_card,
+ slots: [],
+ events: [:accept, :reject, :preview, :body_toggled, :conversation_seed_toggled]
+
+ LiveUi.Component.common_attrs()
+ attr(:target_path, :string, required: true)
+ attr(:title, :string, required: true)
+ attr(:body_md_preview, :string, required: true)
+ attr(:body_md, :string, default: nil)
+ attr(:status, :atom, required: true)
+ attr(:conversation_seed_md, :string, default: nil)
+ attr(:actor_handle, :string, default: nil)
+ attr(:proposed_at, :string, default: nil)
+ attr(:expanded?, :boolean, default: false)
+ attr(:seed_expanded?, :boolean, default: false)
+ attr(:accept_attrs, :any, default: [])
+ attr(:reject_attrs, :any, default: [])
+ attr(:preview_attrs, :any, default: [])
+ attr(:body_toggle_attrs, :any, default: [])
+ attr(:seed_toggle_attrs, :any, default: [])
+
+ @impl true
+ def render(assigns) do
+ assigns =
+ assigns
+ |> assign(:body_id, "#{assigns.id}-body")
+ |> assign(:seed_id, "#{assigns.id}-conversation-seed")
+ |> assign(:full_body?, full_body_available?(assigns.body_md, assigns.body_md_preview))
+ |> assign(:status_text, status_label(assigns.status))
+
+ ~H"""
+
+
+
+
+ <%= @target_path %>
+
+
+
+ <%= body_text(@body_md_preview, @body_md, @expanded?) %>
+
+
+
+
+
+
+ <%= preview_text(@conversation_seed_md) %>
+
+
+
+
+ """
+ end
+
+ defp propose_new_doc_card_class(extra_class, status) do
+ [
+ "live-ui-propose-new-doc-card",
+ "live-ui-propose-new-doc-card--#{status}",
+ extra_class
+ ]
+ end
+
+ defp status_class(nil), do: nil
+ defp status_class(status), do: "live-ui-propose-new-doc-card__status-badge--#{status}"
+
+ defp status_label(status) do
+ status
+ |> to_string()
+ |> String.replace("_", " ")
+ |> String.capitalize()
+ end
+
+ defp locked_message(:accepted), do: "This proposal has been accepted."
+ defp locked_message(:rejected), do: "This proposal has been rejected."
+ defp locked_message(:archived), do: "This proposal has been archived."
+ defp locked_message(status), do: "This proposal is #{status_label(status)}."
+
+ defp full_body_available?(body_md, body_md_preview) do
+ is_binary(body_md) and body_md != "" and body_md != body_md_preview
+ end
+
+ defp body_text(_preview, body_md, true) when is_binary(body_md), do: body_md
+ defp body_text(preview, _body_md, _expanded?), do: preview_text(preview)
+
+ defp preview_text(text) when is_binary(text) do
+ if String.length(text) > 360 do
+ String.slice(text, 0, 360) <> "..."
+ else
+ text
+ end
+ end
+
+ defp preview_text(_text), do: ""
+
+ defp boolean_string(true), do: "true"
+ defp boolean_string(_), do: "false"
+
+ defp action_attrs(attrs, fallback_event), do: fallback_attrs(attrs, fallback_event)
+ defp toggle_attrs(attrs, fallback_event), do: fallback_attrs(attrs, fallback_event)
+
+ defp fallback_attrs(attrs, fallback_event) when attrs in [nil, [], %{}],
+ do: %{:"phx-click" => fallback_event}
+
+ defp fallback_attrs(attrs, _fallback_event), do: attrs
+end
diff --git a/packages/live_ui/test/live_ui/widgets/propose_new_doc_card_test.exs b/packages/live_ui/test/live_ui/widgets/propose_new_doc_card_test.exs
new file mode 100644
index 00000000..9c52b236
--- /dev/null
+++ b/packages/live_ui/test/live_ui/widgets/propose_new_doc_card_test.exs
@@ -0,0 +1,142 @@
+defmodule LiveUi.Widgets.ProposeNewDocCardTest do
+ use ExUnit.Case, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias LiveUi.Component
+ alias UnifiedIUR.Widgets.Components
+
+ describe "propose_new_doc_card widget metadata" do
+ test "registers as a layer_shell_and_callout widget with decision events" do
+ metadata = Component.metadata(LiveUi.Widgets.ProposeNewDocCard)
+
+ assert metadata.mountable?
+ assert metadata.component_module == LiveUi.Widgets.ProposeNewDocCard.Component
+ assert metadata.family == :layer_shell_and_callout
+ assert metadata.name == :propose_new_doc_card
+ assert :accept in metadata.events
+ assert :reject in metadata.events
+ assert :preview in metadata.events
+ end
+
+ test "is present in layer_shell_and_callout aggregation" do
+ assert LiveUi.Widgets.ProposeNewDocCard in LiveUi.Widgets.LayerShellAndCallout.modules()
+
+ assert LiveUi.Widgets.ProposeNewDocCard in LiveUi.Widgets.layer_shell_and_callout_modules()
+ end
+ end
+
+ describe "propose_new_doc_card component rendering" do
+ test "renders canonical root hooks, header, target path, and status badge" do
+ html = render_component(&LiveUi.Widgets.ProposeNewDocCard.component/1, base_assigns())
+
+ assert html =~ ~s(data-live-ui-widget="propose-new-doc-card")
+ assert html =~ ~s(data-status="pending")
+ assert html =~ ~s(aria-label="Proposed document Project brief, Pending")
+ assert html =~ "Project brief"
+ assert html =~ "@pascal"
+ assert html =~ "docs/project-brief.md"
+ assert html =~ "font-mono"
+ assert html =~ "live-ui-propose-new-doc-card__header"
+ assert html =~ ~s(role="status")
+ end
+
+ test "renders pending decision actions with descriptive aria labels" do
+ html = render_component(&LiveUi.Widgets.ProposeNewDocCard.component/1, base_assigns())
+
+ assert html =~ ~s(aria-label="Accept proposed document Project brief")
+ assert html =~ ~s(aria-label="Reject proposed document Project brief")
+ assert html =~ ~s(aria-label="Preview proposed document Project brief")
+ assert html =~ ~s(phx-click="accept")
+ assert html =~ ~s(phx-click="reject")
+ assert html =~ ~s(phx-click="preview")
+ end
+
+ test "renders status variants and locks resolved proposals" do
+ for status <- [:accepted, :rejected, :archived] do
+ html =
+ render_component(
+ &LiveUi.Widgets.ProposeNewDocCard.component/1,
+ base_assigns(%{id: "proposal-#{status}", status: status})
+ )
+
+ assert html =~ ~s(data-status="#{status}")
+ assert html =~ "live-ui-propose-new-doc-card--#{status}"
+ assert html =~ "live-ui-propose-new-doc-card__locked-message"
+ refute html =~ "live-ui-propose-new-doc-card__accept"
+ refute html =~ "live-ui-propose-new-doc-card__reject"
+ assert html =~ "live-ui-propose-new-doc-card__preview"
+ end
+ end
+
+ test "uses aria-controls for body expansion and conversation seed disclosure" do
+ html =
+ render_component(
+ &LiveUi.Widgets.ProposeNewDocCard.component/1,
+ base_assigns(%{
+ id: "proposal-expanded",
+ expanded?: true,
+ seed_expanded?: true
+ })
+ )
+
+ assert html =~ ~s(aria-expanded="true")
+ assert html =~ ~s(aria-controls="proposal-expanded-body")
+ assert html =~ ~s(aria-controls="proposal-expanded-conversation-seed")
+ assert html =~ "Full draft body"
+ assert html =~ "Conversation seed"
+ assert html =~ "Operator asked for a project brief."
+ end
+ end
+
+ describe "renderer dispatch" do
+ test "propose_new_doc_card kind is in supported_kinds" do
+ assert :propose_new_doc_card in LiveUi.Renderer.supported_kinds()
+ end
+
+ test "renders through native renderer with canonical action attrs" do
+ element =
+ Components.propose_new_doc_card(
+ id: "proposal-renderer",
+ target_path: "docs/project-brief.md",
+ title: "Project brief",
+ body_md_preview: "Draft preview.",
+ status: :pending
+ )
+
+ html =
+ render_component(&LiveUi.Renderer.render/1, %{
+ element: element,
+ event_target: "#runtime-host"
+ })
+
+ assert html =~ ~s(data-live-ui-widget="propose-new-doc-card")
+ assert html =~ ~s(phx-click="canonical_interaction")
+ assert html =~ ~s(phx-target="#runtime-host")
+ assert html =~ ~s(phx-value-widget="propose_new_doc_card")
+ assert html =~ ~s(phx-value-element_id="proposal-renderer")
+ assert html =~ ~s(phx-value-action="accept")
+ assert html =~ ~s(phx-value-action="reject")
+ assert html =~ ~s(phx-value-action="preview")
+ refute html =~ ~s(data-live-ui-component-kind="propose_new_doc_card")
+ refute html =~ ~s(data-live-ui-unsupported-native-component)
+ end
+ end
+
+ defp base_assigns(overrides \\ %{}) do
+ Map.merge(
+ %{
+ id: "proposal-card",
+ target_path: "docs/project-brief.md",
+ title: "Project brief",
+ body_md_preview: "Draft preview.",
+ body_md: "Draft preview.\n\nFull draft body.",
+ conversation_seed_md: "Operator asked for a project brief.",
+ actor_handle: "@pascal",
+ proposed_at: "2026-05-27T10:00:00Z",
+ status: :pending
+ },
+ overrides
+ )
+ end
+end
diff --git a/packages/unified_iur/lib/unified_iur/fixtures.ex b/packages/unified_iur/lib/unified_iur/fixtures.ex
index d093ee13..008e138d 100644
--- a/packages/unified_iur/lib/unified_iur/fixtures.ex
+++ b/packages/unified_iur/lib/unified_iur/fixtures.ex
@@ -1188,6 +1188,16 @@ defmodule UnifiedIUR.Fixtures do
depends_on: [],
depended_by: ["metagraph-analysis", "ariston-ui"],
selected?: false
+ )},
+ {:content,
+ Components.propose_new_doc_card(
+ id: "component-propose-new-doc",
+ target_path: "docs/proposed-spec.md",
+ title: "Proposed Spec",
+ body_md_preview: "An agent-authored spec for the accessibility surface.",
+ status: :pending,
+ actor_handle: "@codex",
+ proposed_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 6ae842a8..f720b821 100644
--- a/packages/unified_iur/lib/unified_iur/validate.ex
+++ b/packages/unified_iur/lib/unified_iur/validate.ex
@@ -153,6 +153,11 @@ defmodule UnifiedIUR.Validate do
guidance:
"Represent composer_query_preview findings as opaque result descriptors with id, ordinal, snippet, and confidence."
},
+ invalid_propose_new_doc_card: %{
+ construct_family: :widget_components,
+ 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_collection_picker: %{
construct_family: :widget_components,
guidance:
@@ -205,6 +210,8 @@ defmodule UnifiedIUR.Validate do
@artifact_badge_tones [:positive, :warning, :danger, :info, :neutral]
@rail_sides [:right]
@query_preview_states [:loading, :ready, :empty, :error]
+ @propose_new_doc_statuses [:pending, :accepted, :rejected, :archived]
+ @propose_new_doc_actions [:accept, :reject, :preview]
@collection_picker_forbidden_keys ~w[
bundle
bundle_id
@@ -693,6 +700,15 @@ defmodule UnifiedIUR.Validate do
|> validate_query_preview_shape()
end
+ defp validate_component_contracts(%Element{
+ kind: :propose_new_doc_card,
+ attributes: attributes
+ }) do
+ attributes
+ |> Map.get(:propose_new_doc, %{})
+ |> validate_propose_new_doc_shape()
+ end
+
defp validate_component_contracts(%Element{
kind: :collection_picker,
attributes: attributes
@@ -1509,6 +1525,150 @@ defmodule UnifiedIUR.Validate do
]
end
+ defp validate_propose_new_doc_shape(proposal) when is_map(proposal) do
+ []
+ |> maybe_add(
+ not non_blank_string?(fetch(proposal, :target_path)),
+ Error.new(
+ :invalid_propose_new_doc_card,
+ "propose_new_doc_card requires target_path as a non-empty string",
+ path: [:attributes, :propose_new_doc, :target_path],
+ details: %{target_path: inspect(fetch(proposal, :target_path))}
+ )
+ )
+ |> maybe_add(
+ not non_blank_string?(fetch(proposal, :title)),
+ Error.new(
+ :invalid_propose_new_doc_card,
+ "propose_new_doc_card requires title as a non-empty string",
+ path: [:attributes, :propose_new_doc, :title],
+ details: %{title: inspect(fetch(proposal, :title))}
+ )
+ )
+ |> maybe_add(
+ missing_propose_new_doc_body?(proposal),
+ Error.new(
+ :invalid_propose_new_doc_card,
+ "propose_new_doc_card requires body_md_preview or body_md as a non-empty string",
+ path: [:attributes, :propose_new_doc, :body_md_preview],
+ details: %{
+ body_md_preview: inspect(fetch(proposal, :body_md_preview)),
+ body_md: inspect(fetch(proposal, :body_md))
+ }
+ )
+ )
+ |> maybe_add(
+ invalid_optional_string?(proposal, :body_md_preview),
+ propose_new_doc_string_error(proposal, :body_md_preview)
+ )
+ |> maybe_add(
+ invalid_optional_string?(proposal, :body_md),
+ propose_new_doc_string_error(proposal, :body_md)
+ )
+ |> maybe_add(
+ invalid_optional_string?(proposal, :conversation_seed_md),
+ propose_new_doc_string_error(proposal, :conversation_seed_md)
+ )
+ |> maybe_add(
+ invalid_optional_string?(proposal, :actor_handle),
+ propose_new_doc_string_error(proposal, :actor_handle)
+ )
+ |> maybe_add(
+ invalid_optional_string?(proposal, :proposed_at),
+ propose_new_doc_string_error(proposal, :proposed_at)
+ )
+ |> maybe_add(
+ fetch(proposal, :status) not in @propose_new_doc_statuses,
+ Error.new(
+ :invalid_propose_new_doc_card,
+ "propose_new_doc_card status must be one of #{inspect(@propose_new_doc_statuses)}",
+ path: [:attributes, :propose_new_doc, :status],
+ details: %{status: inspect(fetch(proposal, :status))}
+ )
+ )
+ |> maybe_add(
+ invalid_propose_new_doc_marker?(proposal, :type),
+ propose_new_doc_marker_error(proposal, :type)
+ )
+ |> maybe_add(
+ invalid_propose_new_doc_marker?(proposal, :action_class),
+ propose_new_doc_marker_error(proposal, :action_class)
+ )
+ |> Kernel.++(
+ validate_propose_new_doc_actions(fetch(proposal, :actions, @propose_new_doc_actions))
+ )
+ end
+
+ defp validate_propose_new_doc_shape(_proposal) do
+ [
+ Error.new(
+ :invalid_propose_new_doc_card,
+ "propose_new_doc_card attributes.propose_new_doc must be a map",
+ path: [:attributes, :propose_new_doc]
+ )
+ ]
+ end
+
+ defp missing_propose_new_doc_body?(proposal) do
+ not non_blank_string?(fetch(proposal, :body_md_preview)) and
+ not non_blank_string?(fetch(proposal, :body_md))
+ end
+
+ defp invalid_optional_string?(proposal, key) do
+ has_key?(proposal, key) and not is_binary(fetch(proposal, key))
+ end
+
+ defp propose_new_doc_string_error(proposal, key) do
+ Error.new(
+ :invalid_propose_new_doc_card,
+ "propose_new_doc_card #{key} must be a string",
+ path: [:attributes, :propose_new_doc, key],
+ details: %{value: inspect(fetch(proposal, key))}
+ )
+ end
+
+ defp invalid_propose_new_doc_marker?(proposal, key) do
+ has_key?(proposal, key) and fetch(proposal, key) != :document_creation
+ end
+
+ defp propose_new_doc_marker_error(proposal, key) do
+ Error.new(
+ :invalid_propose_new_doc_card,
+ "propose_new_doc_card #{key} must be :document_creation",
+ path: [:attributes, :propose_new_doc, key],
+ details: %{value: inspect(fetch(proposal, key))}
+ )
+ end
+
+ defp validate_propose_new_doc_actions(actions) when is_list(actions) do
+ actions
+ |> Enum.with_index()
+ |> Enum.flat_map(fn
+ {action, _index} when action in @propose_new_doc_actions ->
+ []
+
+ {action, index} ->
+ [
+ Error.new(
+ :invalid_propose_new_doc_card,
+ "propose_new_doc_card actions must be accept, reject, or preview",
+ path: [:attributes, :propose_new_doc, :actions, index],
+ details: %{action: inspect(action)}
+ )
+ ]
+ end)
+ end
+
+ defp validate_propose_new_doc_actions(_actions) do
+ [
+ Error.new(
+ :invalid_propose_new_doc_card,
+ "propose_new_doc_card actions must be a list",
+ path: [:attributes, :propose_new_doc, :actions]
+ )
+ ]
+ end
+
defp validate_collection_picker_shape(picker) when is_map(picker) do
[]
|> maybe_add(
@@ -2189,6 +2349,7 @@ defmodule UnifiedIUR.Validate do
do: not (is_number(value) and value >= 0.0 and value <= 100.0)
defp non_empty_string?(value), do: is_binary(value) and value != ""
+ defp non_blank_string?(value), do: is_binary(value) and String.trim(value) != ""
defp non_negative_integer?(value), do: is_integer(value) and value >= 0
defp positive_integer?(value), do: is_integer(value) and value > 0
diff --git a/packages/unified_iur/lib/unified_iur/widgets/components.ex b/packages/unified_iur/lib/unified_iur/widgets/components.ex
index ddba87cc..a577db0d 100644
--- a/packages/unified_iur/lib/unified_iur/widgets/components.ex
+++ b/packages/unified_iur/lib/unified_iur/widgets/components.ex
@@ -75,10 +75,12 @@ defmodule UnifiedIUR.Widgets.Components do
:sidebar_item,
:right_rail,
:command_palette,
- :composer_query_preview
+ :composer_query_preview,
+ :propose_new_doc_card
]
@query_preview_states [:loading, :ready, :empty, :error]
+ @propose_new_doc_statuses [:pending, :accepted, :rejected, :archived]
@redline_code_kinds [
:redline_inline,
@@ -1158,6 +1160,69 @@ defmodule UnifiedIUR.Widgets.Components do
)
end
+ @spec propose_new_doc_card(opts()) :: Element.t()
+ def propose_new_doc_card(opts \\ []) do
+ opts = normalize_opts(opts)
+
+ target_path =
+ required_non_blank_string!(
+ opts,
+ :target_path,
+ "propose_new_doc_card requires a non-empty :target_path string"
+ )
+
+ title =
+ required_non_blank_string!(
+ opts,
+ :title,
+ "propose_new_doc_card requires a non-empty :title string"
+ )
+
+ body_md = optional_string!(option(opts, :body_md), :body_md)
+
+ body_md_preview =
+ option(opts, :body_md_preview, body_md)
+ |> required_string_value!(
+ :body_md_preview,
+ "propose_new_doc_card requires a non-empty :body_md_preview string or :body_md string"
+ )
+
+ status = normalize_propose_new_doc_status!(option(opts, :status))
+
+ actions =
+ normalize_propose_new_doc_actions!(option(opts, :actions, [:accept, :reject, :preview]))
+
+ opts = put_propose_new_doc_interactions(opts, target_path, actions)
+
+ build_component(
+ :propose_new_doc_card,
+ :layer_shell_and_callout,
+ %{
+ propose_new_doc:
+ %{
+ target_path: target_path,
+ title: title,
+ body_md_preview: body_md_preview,
+ status: status,
+ type: :document_creation,
+ action_class: :document_creation,
+ actions: actions
+ }
+ |> maybe_put(:body_md, body_md)
+ |> maybe_put(
+ :conversation_seed_md,
+ optional_string!(option(opts, :conversation_seed_md), :conversation_seed_md)
+ )
+ |> maybe_put(
+ :actor_handle,
+ optional_string!(option(opts, :actor_handle), :actor_handle)
+ )
+ |> maybe_put(:proposed_at, optional_string!(option(opts, :proposed_at), :proposed_at))
+ },
+ opts
+ )
+ end
+
defp build_component(kind, family, kind_attributes, opts) do
opts = normalize_opts(opts)
@@ -1345,6 +1410,38 @@ defmodule UnifiedIUR.Widgets.Components do
]
end
+ defp put_propose_new_doc_interactions(opts, target_path, actions) do
+ cond do
+ explicit_interactions?(opts) ->
+ opts
+
+ true ->
+ Map.put(opts, :interactions, propose_new_doc_interactions(opts, target_path, actions))
+ end
+ end
+
+ defp propose_new_doc_interactions(opts, target_path, actions) do
+ actions
+ |> Enum.map(fn action ->
+ Interaction.command(
+ intent: propose_new_doc_intent(opts, action),
+ element_id: option(opts, :id),
+ entity: target_path,
+ command: action,
+ value: target_path
+ )
+ end)
+ end
+
+ defp propose_new_doc_intent(opts, :accept),
+ do: option(opts, :accept_intent, :accept_proposed_doc)
+
+ defp propose_new_doc_intent(opts, :reject),
+ do: option(opts, :reject_intent, :reject_proposed_doc)
+
+ defp propose_new_doc_intent(opts, :preview),
+ do: option(opts, :preview_intent, :preview_proposed_doc)
+
defp put_collection_picker_interactions(opts, picker_id) do
cond do
Map.has_key?(opts, :interactions) or Map.has_key?(opts, "interactions") or
@@ -1550,6 +1647,66 @@ defmodule UnifiedIUR.Widgets.Components do
raise ArgumentError, "thread_card :progress_pct must be a number"
end
+ defp normalize_propose_new_doc_status!(status) when status in @propose_new_doc_statuses,
+ do: status
+
+ defp normalize_propose_new_doc_status!(status) when is_binary(status) do
+ status
+ |> String.to_existing_atom()
+ |> normalize_propose_new_doc_status!()
+ rescue
+ ArgumentError ->
+ raise ArgumentError,
+ "propose_new_doc_card :status must be one of #{inspect(@propose_new_doc_statuses)}"
+ end
+
+ defp normalize_propose_new_doc_status!(_status) do
+ raise ArgumentError,
+ "propose_new_doc_card :status must be one of #{inspect(@propose_new_doc_statuses)}"
+ end
+
+ defp normalize_propose_new_doc_actions!(actions) when is_list(actions) do
+ actions =
+ Enum.map(actions, fn
+ action when action in [:accept, :reject, :preview] ->
+ action
+
+ action when is_binary(action) ->
+ normalize_propose_new_doc_action!(action)
+
+ action ->
+ raise ArgumentError,
+ "propose_new_doc_card actions must be one of [:accept, :reject, :preview], got: #{inspect(action)}"
+ end)
+
+ if actions == [] do
+ raise ArgumentError, "propose_new_doc_card actions must include at least one action"
+ end
+
+ actions
+ end
+
+ defp normalize_propose_new_doc_actions!(_actions) do
+ raise ArgumentError, "propose_new_doc_card :actions must be a list"
+ end
+
+ defp normalize_propose_new_doc_action!(action) do
+ case action do
+ "accept" ->
+ :accept
+
+ "reject" ->
+ :reject
+
+ "preview" ->
+ :preview
+
+ _other ->
+ raise ArgumentError,
+ "propose_new_doc_card actions must be one of [:accept, :reject, :preview], got: #{inspect(action)}"
+ end
+ 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
@@ -1639,6 +1796,29 @@ defmodule UnifiedIUR.Widgets.Components do
end
end
+ defp required_non_blank_string!(opts, key, message) do
+ case option(opts, key) do
+ value when is_binary(value) ->
+ if String.trim(value) == "", do: raise(ArgumentError, message), else: value
+
+ _other ->
+ raise ArgumentError, message
+ end
+ end
+
+ defp required_string_value!(value, _key, message) when is_binary(value) do
+ if String.trim(value) == "", do: raise(ArgumentError, message), else: value
+ end
+
+ defp required_string_value!(_value, _key, message), do: raise(ArgumentError, message)
+
+ defp optional_string!(nil, _key), do: nil
+ defp optional_string!(value, _key) when is_binary(value), do: value
+
+ defp optional_string!(value, key) do
+ raise ArgumentError, "propose_new_doc_card :#{key} must be a string, got: #{inspect(value)}"
+ end
+
defp validate_positive_integer!(value, field) do
unless positive_integer?(value) do
raise ArgumentError, "composer_query_preview :#{field} must be a positive integer"
diff --git a/packages/unified_iur/test/unified_iur/validate_test.exs b/packages/unified_iur/test/unified_iur/validate_test.exs
index 7f7042ca..35845630 100644
--- a/packages/unified_iur/test/unified_iur/validate_test.exs
+++ b/packages/unified_iur/test/unified_iur/validate_test.exs
@@ -198,6 +198,79 @@ defmodule UnifiedIUR.ValidateTest do
assert Enum.all?(finding_errors, &(&1.code == :invalid_query_preview_finding))
end
+ test "validates canonical propose new doc card shape" do
+ valid_proposal =
+ Components.propose_new_doc_card(
+ id: :proposal,
+ target_path: "docs/proposed.md",
+ title: "Proposed brief",
+ body_md_preview: "Short draft preview.",
+ status: :pending
+ )
+
+ missing_target =
+ Element.new(:widget, :propose_new_doc_card,
+ attributes: %{
+ component: %{family: :layer_shell_and_callout, kind: :propose_new_doc_card},
+ propose_new_doc: %{
+ title: "Proposed brief",
+ body_md_preview: "Short draft preview.",
+ status: :pending
+ }
+ }
+ )
+
+ unknown_status =
+ Element.new(:widget, :propose_new_doc_card,
+ attributes: %{
+ component: %{family: :layer_shell_and_callout, kind: :propose_new_doc_card},
+ propose_new_doc: %{
+ target_path: "docs/proposed.md",
+ title: "Proposed brief",
+ body_md_preview: "Short draft preview.",
+ status: :open
+ }
+ }
+ )
+
+ non_string_field =
+ Element.new(:widget, :propose_new_doc_card,
+ attributes: %{
+ component: %{family: :layer_shell_and_callout, kind: :propose_new_doc_card},
+ propose_new_doc: %{
+ target_path: "docs/proposed.md",
+ title: "Proposed brief",
+ body_md_preview: %{bad: "shape"},
+ status: :pending
+ }
+ }
+ )
+
+ invalid_action =
+ Element.new(:widget, :propose_new_doc_card,
+ attributes: %{
+ component: %{family: :layer_shell_and_callout, kind: :propose_new_doc_card},
+ propose_new_doc: %{
+ target_path: "docs/proposed.md",
+ title: "Proposed brief",
+ body_md_preview: "Short draft preview.",
+ status: :pending,
+ actions: [:accept, :delete]
+ }
+ }
+ )
+
+ assert :ok = Validate.element(valid_proposal)
+ assert {:error, [target_error]} = Validate.element(missing_target)
+ assert target_error.code == :invalid_propose_new_doc_card
+ assert {:error, [status_error]} = Validate.element(unknown_status)
+ assert status_error.code == :invalid_propose_new_doc_card
+ assert {:error, string_errors} = Validate.element(non_string_field)
+ assert Enum.all?(string_errors, &(&1.code == :invalid_propose_new_doc_card))
+ assert {:error, [action_error]} = Validate.element(invalid_action)
+ assert action_error.code == :invalid_propose_new_doc_card
+ end
+
test "validates canonical collection picker shape" do
valid_picker =
Components.collection_picker(
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 bcbcd41e..0cc3c987 100644
--- a/packages/unified_iur/test/unified_iur/widgets/components_test.exs
+++ b/packages/unified_iur/test/unified_iur/widgets/components_test.exs
@@ -51,7 +51,8 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do
:sidebar_item,
:right_rail,
:command_palette,
- :composer_query_preview
+ :composer_query_preview,
+ :propose_new_doc_card
]
assert Components.redline_code_kinds() == [
@@ -355,6 +356,43 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do
end
end
+ test "validates canonical propose new doc card shape" do
+ assert_raise ArgumentError, ~r/non-empty :target_path/, fn ->
+ Components.propose_new_doc_card(
+ title: "Proposed brief",
+ body_md_preview: "Draft body",
+ status: :pending
+ )
+ end
+
+ assert_raise ArgumentError, ~r/body_md_preview string or :body_md string/, fn ->
+ Components.propose_new_doc_card(
+ target_path: "docs/proposed.md",
+ title: "Proposed brief",
+ status: :pending
+ )
+ end
+
+ assert_raise ArgumentError, ~r/status must be one of/, fn ->
+ Components.propose_new_doc_card(
+ target_path: "docs/proposed.md",
+ title: "Proposed brief",
+ body_md_preview: "Draft body",
+ status: :open
+ )
+ end
+
+ assert_raise ArgumentError, ~r/actions must be one of/, fn ->
+ Components.propose_new_doc_card(
+ target_path: "docs/proposed.md",
+ title: "Proposed brief",
+ body_md_preview: "Draft body",
+ status: :pending,
+ actions: [:accept, :delete]
+ )
+ end
+ end
+
test "represents workflow, layer, callout, redline, and code components" do
stepper =
Components.pipeline_stepper_horizontal(
@@ -410,6 +448,19 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do
]
)
+ proposal =
+ Components.propose_new_doc_card(
+ id: "proposal-1",
+ target_path: "docs/proposed.md",
+ title: "Proposed brief",
+ body_md_preview: "Short draft preview.",
+ body_md: "Short draft preview.\n\nFull draft body.",
+ conversation_seed_md: "Seed request",
+ actor_handle: "@pascal",
+ proposed_at: "2026-05-27T10:00:00Z",
+ status: :pending
+ )
+
redline = Components.redline_inline([%{state: :insert, text: "new"}])
code =
@@ -502,6 +553,31 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do
%Interaction{family: :command, intent: :save_query}
] = query_preview.attributes.interactions
+ assert proposal.attributes.component == %{
+ family: :layer_shell_and_callout,
+ kind: :propose_new_doc_card
+ }
+
+ assert proposal.attributes.propose_new_doc == %{
+ target_path: "docs/proposed.md",
+ title: "Proposed brief",
+ body_md_preview: "Short draft preview.",
+ body_md: "Short draft preview.\n\nFull draft body.",
+ conversation_seed_md: "Seed request",
+ actor_handle: "@pascal",
+ proposed_at: "2026-05-27T10:00:00Z",
+ status: :pending,
+ type: :document_creation,
+ action_class: :document_creation,
+ actions: [:accept, :reject, :preview]
+ }
+
+ assert [
+ %Interaction{family: :command, intent: :accept_proposed_doc},
+ %Interaction{family: :command, intent: :reject_proposed_doc},
+ %Interaction{family: :command, intent: :preview_proposed_doc}
+ ] = proposal.attributes.interactions
+
assert redline.attributes.redline == %{segments: [%{state: :insert, text: "new"}]}
assert redline.attributes.text_safety == %{content: :plain_text}
diff --git a/packages/unified_ui/lib/unified_ui/compiler/pipeline.ex b/packages/unified_ui/lib/unified_ui/compiler/pipeline.ex
index 26ac1e39..f61e6597 100644
--- a/packages/unified_ui/lib/unified_ui/compiler/pipeline.ex
+++ b/packages/unified_ui/lib/unified_ui/compiler/pipeline.ex
@@ -1048,6 +1048,24 @@ defmodule UnifiedUi.Compiler.Pipeline do
])
)
+ :propose_new_doc_card ->
+ Widgets.Components.propose_new_doc_card(
+ common_opts(node, attachments, [
+ :target_path,
+ :title,
+ :body_md_preview,
+ :body_md,
+ :status,
+ :conversation_seed_md,
+ :actor_handle,
+ :proposed_at,
+ :actions,
+ :accept_intent,
+ :reject_intent,
+ :preview_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 559537df..1944218b 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
@@ -14,6 +14,7 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do
@composition_behavior_family :composition_behavior
@presence_states [:active, :away, :offline, :focus, :do_not_disturb]
@artifact_kinds [:pr, :doc, :spec, :file, :grain, :generic]
+ @propose_new_doc_statuses [:pending, :accepted, :rejected, :archived]
@spec entities() :: [Spark.Dsl.Entity.t()]
def entities do
@@ -242,6 +243,23 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do
dismiss_intent: [type: :atom, required: false],
summary: [type: :string, required: false]
),
+ leaf(
+ :propose_new_doc_card,
+ @layer_family,
+ target_path: [type: :string, required: true],
+ title: [type: :string, required: true],
+ body_md_preview: [type: :string, required: false],
+ body_md: [type: :string, required: false],
+ status: [type: {:in, @propose_new_doc_statuses}, required: true],
+ conversation_seed_md: [type: :string, required: false],
+ actor_handle: [type: :string, required: false],
+ proposed_at: [type: :string, required: false],
+ actions: [type: :any, required: false, default: [:accept, :reject, :preview]],
+ accept_intent: [type: :atom, required: false],
+ reject_intent: [type: :atom, required: false],
+ preview_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 26491c82..0bb418f1 100644
--- a/packages/unified_ui/lib/unified_ui/dsl/node.ex
+++ b/packages/unified_ui/lib/unified_ui/dsl/node.ex
@@ -224,6 +224,16 @@ defmodule UnifiedUi.Dsl.Node do
empty_label: String.t() | nil,
open_intent: atom() | nil,
save_intent: atom() | nil,
+ target_path: String.t() | nil,
+ body_md_preview: String.t() | nil,
+ body_md: String.t() | nil,
+ conversation_seed_md: String.t() | nil,
+ actor_handle: String.t() | nil,
+ proposed_at: String.t() | nil,
+ actions: [atom()] | nil,
+ accept_intent: atom() | nil,
+ reject_intent: atom() | nil,
+ preview_intent: atom() | nil,
density: atom() | nil,
children: [t()]
}
@@ -428,6 +438,16 @@ defmodule UnifiedUi.Dsl.Node do
empty_label: nil,
open_intent: nil,
save_intent: nil,
+ target_path: nil,
+ body_md_preview: nil,
+ body_md: nil,
+ conversation_seed_md: nil,
+ actor_handle: nil,
+ proposed_at: nil,
+ actions: nil,
+ accept_intent: nil,
+ reject_intent: nil,
+ preview_intent: nil,
density: nil,
children: []
@@ -555,6 +575,16 @@ defmodule UnifiedUi.Dsl.Node do
empty_label: node.empty_label,
open_intent: node.open_intent,
save_intent: node.save_intent,
+ target_path: node.target_path,
+ body_md_preview: node.body_md_preview,
+ body_md: node.body_md,
+ conversation_seed_md: node.conversation_seed_md,
+ actor_handle: node.actor_handle,
+ proposed_at: node.proposed_at,
+ actions: node.actions,
+ accept_intent: node.accept_intent,
+ reject_intent: node.reject_intent,
+ preview_intent: node.preview_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 b1d71e2e..991b509b 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
@@ -70,6 +70,8 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do
url
]
@query_preview_states [:loading, :ready, :empty, :error]
+ @propose_new_doc_statuses [:pending, :accepted, :rejected, :archived]
+ @propose_new_doc_actions [:accept, :reject, :preview]
@spec verify(map()) :: :ok | {:error, Spark.Error.DslError.t()}
def verify(dsl) do
@@ -447,6 +449,51 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do
end
end
+ def validate_node(%Node{
+ kind: :propose_new_doc_card,
+ id: id,
+ target_path: target_path,
+ title: title,
+ body_md_preview: body_md_preview,
+ body_md: body_md,
+ status: status,
+ conversation_seed_md: conversation_seed_md,
+ actor_handle: actor_handle,
+ proposed_at: proposed_at,
+ actions: actions
+ }) do
+ cond do
+ not non_blank_string?(target_path) ->
+ {:error, [:composition, :propose_new_doc_card, id],
+ "propose_new_doc_card #{inspect(id)} target_path must be a non-empty string"}
+
+ not non_blank_string?(title) ->
+ {:error, [:composition, :propose_new_doc_card, id],
+ "propose_new_doc_card #{inspect(id)} title must be a non-empty string"}
+
+ not non_blank_string?(body_md_preview) and not non_blank_string?(body_md) ->
+ {:error, [:composition, :propose_new_doc_card, id],
+ "propose_new_doc_card #{inspect(id)} body_md_preview or body_md must be a non-empty string"}
+
+ not optional_string?(body_md_preview) or not optional_string?(body_md) or
+ not optional_string?(conversation_seed_md) or not optional_string?(actor_handle) or
+ not optional_string?(proposed_at) ->
+ {:error, [:composition, :propose_new_doc_card, id],
+ "propose_new_doc_card #{inspect(id)} markdown, actor, and timestamp fields must be strings when present"}
+
+ status not in @propose_new_doc_statuses ->
+ {:error, [:composition, :propose_new_doc_card, id],
+ "propose_new_doc_card #{inspect(id)} status must be one of #{inspect(@propose_new_doc_statuses)}"}
+
+ not valid_propose_new_doc_actions?(actions) ->
+ {:error, [:composition, :propose_new_doc_card, id],
+ "propose_new_doc_card #{inspect(id)} actions must be a list containing only accept, reject, or preview"}
+
+ true ->
+ :ok
+ end
+ end
+
def validate_node(%Node{kind: :redline_inline, id: id, segments: segments}) do
if valid_redline_segments?(segments) do
:ok
@@ -933,6 +980,16 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do
defp valid_scalar?(value) when is_atom(value) or is_binary(value) or is_number(value), do: true
defp valid_scalar?(_value), do: false
+ defp non_blank_string?(value), do: is_binary(value) and String.trim(value) != ""
+ defp optional_string?(nil), do: true
+ defp optional_string?(value), do: is_binary(value)
+
+ defp valid_propose_new_doc_actions?(actions) when is_list(actions) do
+ actions != [] and Enum.all?(actions, &(&1 in @propose_new_doc_actions))
+ end
+
+ defp valid_propose_new_doc_actions?(_actions), do: false
+
defp positive_number?(value) when is_number(value), do: value > 0
defp positive_number?(_value), do: false
diff --git a/packages/unified_ui/lib/unified_ui/widget_components.ex b/packages/unified_ui/lib/unified_ui/widget_components.ex
index b3e8cf67..ffda14a6 100644
--- a/packages/unified_ui/lib/unified_ui/widget_components.ex
+++ b/packages/unified_ui/lib/unified_ui/widget_components.ex
@@ -241,6 +241,13 @@ defmodule UnifiedUi.WidgetComponents do
"Inline composer-adjacent query preview with state, result summary, metrics, findings, and canonical actions.",
aliases: []
},
+ %{
+ kind: :propose_new_doc_card,
+ family: :layer_shell_and_callout,
+ summary:
+ "Inline document-creation proposal callout with target path, preview body, decision status, and canonical accept, reject, and preview 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 950248c6..3062bd24 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
@@ -183,6 +183,7 @@ defmodule UnifiedUi.AdvancedWidgetFamiliesTest do
:slide_over_panel,
:event_callout,
:composer_query_preview,
+ :propose_new_doc_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 c93b4b3a..4d601d4c 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
@@ -174,6 +174,7 @@ defmodule UnifiedUi.OperationalWidgetComponentsTest do
:slide_over_panel,
:event_callout,
:composer_query_preview,
+ :propose_new_doc_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 2aed93c3..dba347aa 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
@@ -318,6 +318,7 @@ defmodule UnifiedUi.Phase2IntegrationTest do
:slide_over_panel,
:event_callout,
:composer_query_preview,
+ :propose_new_doc_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 39ed134d..b9b42081 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
@@ -48,7 +48,8 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do
:sidebar_item,
:right_rail,
:command_palette,
- :composer_query_preview
+ :composer_query_preview,
+ :propose_new_doc_card
],
redline_and_code: [:redline_inline, :code_block_syntax_highlighted],
composition_behavior: [:list_repeat]
@@ -67,6 +68,7 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do
assert :unread_badge in kinds
assert :command_palette in kinds
assert :composer_query_preview in kinds
+ assert :propose_new_doc_card in kinds
assert :collection_picker in kinds
assert :workflow_progress_status_card in kinds
end
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 15776d30..cd117065 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
@@ -143,6 +143,20 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do
dismiss_intent(:dismiss_query_preview)
end
+ propose_new_doc_card :doc_proposal do
+ target_path("docs/proposed.md")
+ title("Proposed brief")
+ body_md_preview("Short draft preview.")
+ body_md("Short draft preview.\n\nFull draft body.")
+ status(:pending)
+ conversation_seed_md("Operator requested a brief.")
+ actor_handle("@pascal")
+ proposed_at("2026-05-27T10:00:00Z")
+ accept_intent(:accept_doc_proposal)
+ reject_intent(:reject_doc_proposal)
+ preview_intent(:preview_doc_proposal)
+ end
+
right_rail :workspace_rail do
side(:right)
@@ -188,6 +202,7 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do
progress = Tree.find_by_id(iur, :quality_bar)
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)
rail = Tree.find_by_id(iur, :workspace_rail)
code = Tree.find_by_id(iur, :code_sample)
@@ -293,6 +308,25 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do
save_label: "Save query"
}
+ assert proposal.attributes.component == %{
+ family: :layer_shell_and_callout,
+ kind: :propose_new_doc_card
+ }
+
+ assert proposal.attributes.propose_new_doc == %{
+ target_path: "docs/proposed.md",
+ title: "Proposed brief",
+ body_md_preview: "Short draft preview.",
+ body_md: "Short draft preview.\n\nFull draft body.",
+ conversation_seed_md: "Operator requested a brief.",
+ actor_handle: "@pascal",
+ proposed_at: "2026-05-27T10:00:00Z",
+ status: :pending,
+ type: :document_creation,
+ action_class: :document_creation,
+ actions: [:accept, :reject, :preview]
+ }
+
assert rail.attributes.component == %{
family: :layer_shell_and_callout,
kind: :right_rail
@@ -333,6 +367,7 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do
panel = Tree.find_by_id(iur, :details_panel)
callout = Tree.find_by_id(iur, :incident_callout)
query_preview = Tree.find_by_id(iur, :query_preview)
+ proposal = Tree.find_by_id(iur, :doc_proposal)
rail = Tree.find_by_id(iur, :workspace_rail)
assert [%Interaction{family: :selection, intent: :select_status} = selection] =
@@ -388,6 +423,12 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do
{:command, :save_query}
]
+ assert Enum.map(proposal.attributes.interactions, &{&1.family, &1.intent}) == [
+ {:command, :accept_doc_proposal},
+ {:command, :reject_doc_proposal},
+ {:command, :preview_doc_proposal}
+ ]
+
assert [
%Interaction{family: :selection, intent: :select_rail_panel} = panel_select,
%Interaction{family: :change, intent: :toggle_rail} = collapse_change
diff --git a/test/ash_ui/phase_31_canonical_conversion_test.exs b/test/ash_ui/phase_31_canonical_conversion_test.exs
index 87bd9ed0..72af0fec 100644
--- a/test/ash_ui/phase_31_canonical_conversion_test.exs
+++ b/test/ash_ui/phase_31_canonical_conversion_test.exs
@@ -62,6 +62,13 @@ defmodule AshUI.Phase31CanonicalConversionTest do
%{id: "finding-1", n: 1, snippet: "Conformance missing", confidence: 0.91}
]
}, :query_preview},
+ {:propose_new_doc_card, :layer_shell_and_callout,
+ %{
+ target_path: "docs/proposed.md",
+ title: "Proposed brief",
+ body_md_preview: "Short draft preview.",
+ status: :pending
+ }, :propose_new_doc},
{:redline_inline, :redline_and_code, %{segments: [%{state: :insert, text: "new"}]}, :redline},
{:code_block_syntax_highlighted, :redline_and_code,
%{language: :elixir, tokens: [%{type: :keyword, text: "def"}]}, :code},
diff --git a/test/ash_ui/phase_31_package_boundary_test.exs b/test/ash_ui/phase_31_package_boundary_test.exs
index a337bde6..9b9c622e 100644
--- a/test/ash_ui/phase_31_package_boundary_test.exs
+++ b/test/ash_ui/phase_31_package_boundary_test.exs
@@ -66,7 +66,8 @@ defmodule AshUI.Phase31PackageBoundaryTest do
:sidebar_item,
:right_rail,
:command_palette,
- :composer_query_preview
+ :composer_query_preview,
+ :propose_new_doc_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 a20f4a92..264602c2 100644
--- a/test/ash_ui/rendering/iur_adapter_test.exs
+++ b/test/ash_ui/rendering/iur_adapter_test.exs
@@ -318,6 +318,81 @@ defmodule AshUI.Rendering.IURAdapterTest do
assert error.message =~ "thread_card requires a non-empty :thread_id"
end
+ test "routes propose_new_doc_card kind through layer_shell_and_callout family with canonical actions" do
+ ash_iur =
+ IUR.new(:screen,
+ id: "propose-doc-screen",
+ name: "propose_doc_screen",
+ attributes: %{},
+ children: [
+ IUR.new(:propose_new_doc_card,
+ id: "proposal-1",
+ props: %{
+ "target_path" => "docs/proposed.md",
+ "title" => "Proposed brief",
+ "body_md_preview" => "Short draft preview.",
+ "status" => "pending",
+ "conversation_seed_md" => "Operator requested a brief.",
+ "actor_handle" => "@pascal",
+ "proposed_at" => "2026-05-27T10:00:00Z"
+ }
+ )
+ ]
+ )
+
+ assert {:ok, canonical} = IURAdapter.to_canonical(ash_iur)
+ [child] = canonical.children
+ assert child.element.kind == :propose_new_doc_card
+ assert child.element.type == :widget
+ assert child.element.attributes.component.family == :layer_shell_and_callout
+
+ assert child.element.attributes.propose_new_doc == %{
+ target_path: "docs/proposed.md",
+ title: "Proposed brief",
+ body_md_preview: "Short draft preview.",
+ conversation_seed_md: "Operator requested a brief.",
+ actor_handle: "@pascal",
+ proposed_at: "2026-05-27T10:00:00Z",
+ status: :pending,
+ type: :document_creation,
+ action_class: :document_creation,
+ actions: [:accept, :reject, :preview]
+ }
+
+ assert [
+ %UnifiedIUR.Interaction{family: :command, intent: :accept_proposed_doc},
+ %UnifiedIUR.Interaction{family: :command, intent: :reject_proposed_doc},
+ %UnifiedIUR.Interaction{family: :command, intent: :preview_proposed_doc}
+ ] = child.element.attributes.interactions
+
+ 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,
+ id: "propose-doc-screen-invalid",
+ name: "propose_doc_screen",
+ attributes: %{},
+ children: [
+ IUR.new(:propose_new_doc_card,
+ id: "proposal-invalid",
+ props: %{
+ "target_path" => "docs/proposed.md",
+ "title" => "Proposed brief",
+ "body_md_preview" => "Short draft preview.",
+ "status" => "open"
+ }
+ )
+ ]
+ )
+
+ assert {:error, {:conversion_failed, %ArgumentError{} = error}} =
+ IURAdapter.to_canonical(ash_iur)
+
+ assert error.message =~ "propose_new_doc_card :status 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 4b748c43..0cbd6cb1 100644
--- a/test/ash_ui/rendering/live_ui_adapter_test.exs
+++ b/test/ash_ui/rendering/live_ui_adapter_test.exs
@@ -1309,6 +1309,68 @@ defmodule AshUI.Rendering.LiveUIAdapterTest do
end
end
+ describe "propose_new_doc_card adapter dispatch" do
+ test "generates dedicated propose_new_doc_card fallback markup" do
+ iur = %{
+ "type" => "propose_new_doc_card",
+ "id" => "proposal-adapter-test",
+ "props" => %{
+ "propose_new_doc" => %{
+ "target_path" => "docs/proposed.md",
+ "title" => "Proposed brief",
+ "body_md_preview" => "Short draft preview.",
+ "body_md" => "Short draft preview.\n\nFull draft body.",
+ "conversation_seed_md" => "Operator requested a brief.",
+ "actor_handle" => "@pascal",
+ "proposed_at" => "2026-05-27T10:00:00Z",
+ "status" => "pending"
+ }
+ },
+ "children" => [],
+ "metadata" => %{}
+ }
+
+ {:ok, heex} = LiveUIAdapter.render(iur, force_fallback: true)
+
+ assert heex =~ "ash-propose-new-doc-card"
+ assert heex =~ ~s(data-live-ui-widget="propose-new-doc-card")
+ assert heex =~ ~s(data-status="pending")
+ assert heex =~ ~s(data-target-path="docs/proposed.md")
+ assert heex =~ "Proposed brief"
+ assert heex =~ "docs/proposed.md"
+ assert heex =~ "Short draft preview."
+ assert heex =~ "Show full draft"
+ assert heex =~ "Conversation seed"
+ assert heex =~ ~s(aria-label="Accept proposed document Proposed brief")
+ assert heex =~ ~s(aria-label="Reject proposed document Proposed brief")
+ assert heex =~ ~s(aria-label="Preview proposed document Proposed brief")
+ end
+
+ test "locks accept and reject actions for resolved proposals" do
+ iur = %{
+ "type" => "propose_new_doc_card",
+ "id" => "proposal-adapter-accepted",
+ "props" => %{
+ "propose_new_doc" => %{
+ "target_path" => "docs/proposed.md",
+ "title" => "Proposed brief",
+ "body_md_preview" => "Short draft preview.",
+ "status" => "accepted"
+ }
+ },
+ "children" => [],
+ "metadata" => %{}
+ }
+
+ {:ok, heex} = LiveUIAdapter.render(iur, force_fallback: true)
+
+ assert heex =~ "ash-propose-new-doc-card__locked-message"
+ refute heex =~ "ash-propose-new-doc-card__accept"
+ refute heex =~ "ash-propose-new-doc-card__reject"
+ assert heex =~ "ash-propose-new-doc-card__preview"
+ end
+ end
+
describe "collection_picker adapter dispatch" do
test "generates dedicated collection_picker fallback markup" do
iur = %{