From eea7129e0f4cd0968eed16b9e6b750af6a1ded62 Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Wed, 27 May 2026 10:49:28 -0500 Subject: [PATCH 1/3] 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) --- lib/ash_ui/rendering/iur_adapter.ex | 43 ++++ lib/ash_ui/rendering/live_ui_adapter.ex | 84 +++++++ packages/live_ui/lib/live_ui/renderer.ex | 93 ++++++++ .../widgets/layer_shell_and_callout.ex | 3 +- .../live_ui/widgets/propose_new_doc_card.ex | 210 ++++++++++++++++++ .../widgets/propose_new_doc_card_test.exs | 142 ++++++++++++ .../unified_iur/lib/unified_iur/fixtures.ex | 10 + .../unified_iur/lib/unified_iur/validate.ex | 161 ++++++++++++++ .../lib/unified_iur/widgets/components.ex | 182 ++++++++++++++- .../test/unified_iur/validate_test.exs | 73 ++++++ .../unified_iur/widgets/components_test.exs | 78 ++++++- .../lib/unified_ui/compiler/pipeline.ex | 18 ++ .../dsl/entities/widget_components.ex | 18 ++ .../unified_ui/lib/unified_ui/dsl/node.ex | 30 +++ .../verifiers/validate_widget_components.ex | 57 +++++ .../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 | 41 ++++ .../phase_31_canonical_conversion_test.exs | 7 + .../ash_ui/phase_31_package_boundary_test.exs | 3 +- test/ash_ui/rendering/iur_adapter_test.exs | 75 +++++++ .../ash_ui/rendering/live_ui_adapter_test.exs | 62 ++++++ 25 files changed, 1399 insertions(+), 5 deletions(-) create mode 100644 packages/live_ui/lib/live_ui/widgets/propose_new_doc_card.ex create mode 100644 packages/live_ui/test/live_ui/widgets/propose_new_doc_card_test.exs 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 + + """ +
+
+
+

#{title}

+ #{if actor_handle, do: "#{actor_handle}", else: ""} + #{if proposed_at, do: "", else: ""} +
+ #{status} +
+

#{target_path}

+
+
#{body_md_preview}
+
+ #{body_toggle} + #{seed_html} +
+ #{decision_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..f850cc0b --- /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""" +
+
+
+

<%= @title %>

+ + <%= @actor_handle %> + + +
+ + <%= @status_text %> + +
+ +

+ <%= @target_path %> +

+ +
+
<%= body_text(@body_md_preview, @body_md, @expanded?) %>
+
+ + + +
+ +
<%= preview_text(@conversation_seed_md) %>
+
+ +
+ <%= if @status == :pending do %> + + + <% else %> +

+ <%= locked_message(@status) %> +

+ <% end %> + + +
+
+ """ + 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 = %{ From 389c8967e43779503715a55d1c5702923126b554 Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Wed, 27 May 2026 10:58:56 -0500 Subject: [PATCH 2/3] fix(propose_new_doc_card): keep seed section in DOM with hidden attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus Max review P2: aria-controls references @seed_id; when collapsed via :if={@seed_expanded?}, the target node is removed from DOM, leaving a dangling aria-controls reference. ARIA disclosure pattern recommends the target exist with the hidden attribute. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/live_ui/lib/live_ui/widgets/propose_new_doc_card.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index f850cc0b..d4cbf935 100644 --- 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 @@ -114,8 +114,8 @@ defmodule LiveUi.Widgets.ProposeNewDocCard do Conversation seed From 141515387f8bc1a823a399d2e6aa72c6d3ec9484 Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Thu, 28 May 2026 21:33:47 -0500 Subject: [PATCH 3/3] docs(conformance): add propose_new_doc_card to UG-0003 (:layer_shell_and_callout) Phase31DocsConformanceTest asserts each AshUI.WidgetComponents.kinds() name appears in the user guide. propose_new_doc_card was added as a canonical widget in Phase 41.6 but not documented in UG-0003, causing a net-new conformance regression on PR #148. Added propose_new_doc_card to the Layer shell and callout family table row and added a descriptive paragraph covering its key props (target_path, title, body_md_preview, status, actor_handle, expanded?) and canonical interactions (accept, reject, preview, body_toggled, conversation_seed_toggled). Co-Authored-By: Claude Opus 4.7 --- .../UG-0003-widget-types-properties-and-signals.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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