diff --git a/.spec/specs/canonical_widget_components.spec.md b/.spec/specs/canonical_widget_components.spec.md
index 23bee38e..a829de26 100644
--- a/.spec/specs/canonical_widget_components.spec.md
+++ b/.spec/specs/canonical_widget_components.spec.md
@@ -52,6 +52,10 @@ Required behavior:
- Supported aliases match `UnifiedUi.WidgetComponents.aliases/0`.
- `thread_card` is a first-class `row_and_artifact` component, not a
content-identity component or baseline navigation primitive.
+- `composer_query_preview` is a first-class `layer_shell_and_callout`
+ component for composer-adjacent query result previews; it does not expose
+ product-specific Ask/MGQL names or renderer event fields in its canonical
+ contract.
- Drift is caught by package-boundary tests before release.
### ash_ui.canonical_widget_components.authoring_admission
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 49626b37..1d62e383 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`, `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` | 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 |
| Redline and code | `redline_inline`, `code_block_syntax_highlighted` | none |
| Composition behavior | `list_repeat` | `repeat` -> `list_repeat`, `ui_relationship_repeat` -> `list_repeat` |
@@ -134,6 +134,13 @@ such as `subject_id`, `name`, `path`, `progress_pct`, `active_count`,
`attributes.subject`; it does not expose map placement names, route helpers,
LiveView event fields, or application-specific card names.
+`composer_query_preview` is the reusable inline preview band for query results
+adjacent to a composer. Author generic props such as `composer_id`, `query`,
+`preview_state`, `explanation`, `metrics`, `findings`, and
+`max_findings_shown`; use canonical interactions for dismiss, open, and save
+actions. Keep product mode names, keyboard shortcut hints, route helpers, and
+LiveView event fields 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
@@ -236,6 +243,7 @@ Signal support is type-specific.
| `form_builder` | `:submit` |
| `diff_banner` | `:change`, `:input` |
| `right_rail` | `:change`, `:toggle`, `:click` |
+| `composer_query_preview` | `:click` |
If you declare a signal outside this matrix, authoring validation raises.
@@ -269,6 +277,7 @@ The current action-binding-capable widgets are:
- `context_selector`
- `file_tree_browser`
- `right_rail`
+- `composer_query_preview`
## Compatibility and Normalization Notes
diff --git a/lib/ash_ui/rendering/iur_adapter.ex b/lib/ash_ui/rendering/iur_adapter.ex
index 54fddb80..d862cf42 100644
--- a/lib/ash_ui/rendering/iur_adapter.ex
+++ b/lib/ash_ui/rendering/iur_adapter.ex
@@ -503,6 +503,13 @@ defmodule AshUI.Rendering.IURAdapter do
)
end
+ defp base_attributes(:composer_query_preview, props) do
+ props
+ |> composer_query_preview_opts()
+ |> IURComponents.composer_query_preview()
+ |> Map.fetch!(:attributes)
+ end
+
defp base_attributes(:redline_inline = kind, props) do
component_attributes(
kind,
@@ -1054,6 +1061,51 @@ defmodule AshUI.Rendering.IURAdapter do
|> compact_map()
end
+ defp composer_query_preview_opts(props) do
+ preview = props |> fetch(:query_preview, %{}) |> normalize_map()
+
+ %{
+ id: first_present(props, [:_element_id, :id]),
+ composer_id: first_present(preview, [:composer_id]) || first_present(props, [:composer_id]),
+ query:
+ first_present(preview, [:query]) || first_present(props, [:query, :ask_query, :value]),
+ preview_state:
+ normalize_existing_atom(
+ first_present(preview, [:preview_state]) ||
+ first_present(props, [:preview_state]) ||
+ :empty
+ ),
+ explanation:
+ first_present(preview, [:explanation]) ||
+ first_present(props, [:explanation, :explain, :summary]),
+ metrics:
+ normalize_optional_map(
+ first_present(preview, [:metrics]) || first_present(props, [:metrics, :meta])
+ ),
+ findings:
+ first_present(preview, [:findings]) ||
+ first_present(props, [:findings, :preview_findings]) ||
+ [],
+ max_findings_shown:
+ first_present(preview, [:max_findings_shown]) ||
+ first_present(props, [:max_findings_shown]) ||
+ 2,
+ error_message:
+ first_present(preview, [:error_message]) || first_present(props, [:error_message]),
+ loading_label:
+ first_present(preview, [:loading_label]) || first_present(props, [:loading_label]),
+ empty_label: first_present(preview, [:empty_label]) || first_present(props, [:empty_label]),
+ open_label: first_present(preview, [:open_label]) || first_present(props, [:open_label]),
+ save_label: first_present(preview, [:save_label]) || first_present(props, [:save_label]),
+ dismiss_intent: first_present(props, [:dismiss_intent]),
+ open_intent: first_present(props, [:open_intent]),
+ save_intent: first_present(props, [:save_intent]),
+ interactions: fetch(props, :interactions),
+ interaction: fetch(props, :interaction)
+ }
+ |> compact_map()
+ end
+
defp workflow_progress_status_card_opts(props) do
%{
subject_id: first_present(props, [:subject_id]),
diff --git a/lib/ash_ui/rendering/live_ui_adapter.ex b/lib/ash_ui/rendering/live_ui_adapter.ex
index 4db62202..5e9325c2 100644
--- a/lib/ash_ui/rendering/live_ui_adapter.ex
+++ b/lib/ash_ui/rendering/live_ui_adapter.ex
@@ -558,6 +558,97 @@ defmodule AshUI.Rendering.LiveUIAdapter do
"""
end
+ defp generate_heex(%{"type" => "composer_query_preview"} = iur, _opts) do
+ props = iur["props"] || %{}
+ preview = props |> prop("query_preview", props) |> normalize_item()
+ composer_id = escaped_text_prop(preview, "composer_id", iur["id"] || "")
+ query = escaped_text_prop(preview, "query", "")
+ preview_state = escaped_text_prop(preview, "preview_state", "empty")
+ explanation = escaped_text_prop(preview, "explanation")
+ error_message = escaped_text_prop(preview, "error_message", "Query failed. Try again.")
+ loading_label = escaped_text_prop(preview, "loading_label", "Searching")
+ empty_label = escaped_text_prop(preview, "empty_label", "No results for this query.")
+ metrics = preview |> prop("metrics", %{}) |> normalize_item()
+ findings = List.wrap(prop(preview, "findings", []))
+ max_findings_shown = max(numeric_count(prop(preview, "max_findings_shown", 2)), 1)
+
+ metrics_html =
+ if metrics == %{} do
+ ""
+ else
+ results_count = escaped_text_prop(metrics, "results_count")
+ duration_ms = prop(metrics, "duration_ms")
+ sources_visited = escaped_text_prop(metrics, "sources_visited")
+
+ duration =
+ case duration_ms do
+ value when is_integer(value) or is_float(value) ->
+ :erlang.float_to_binary(value / 1000, decimals: 2) <> "s"
+
+ value when not is_nil(value) ->
+ html_escape(value)
+
+ _other ->
+ nil
+ end
+
+ """
+
+ #{if results_count != "", do: "#{results_count} results", else: ""}
+ #{if duration, do: "#{duration}", else: ""}
+ #{if sources_visited != "", do: "#{sources_visited} sources", else: ""}
+
+ """
+ end
+
+ findings_html =
+ findings
+ |> Enum.take(max_findings_shown)
+ |> Enum.map_join(fn finding ->
+ finding = normalize_item(finding)
+ id = escaped_text_prop(finding, ["id", "finding_id"], "")
+ n = escaped_text_prop(finding, "n", "")
+ snippet = escaped_text_prop(finding, "snippet", "")
+ confidence = escaped_text_prop(finding, "confidence", "")
+
+ """
+
+ ##{n}
+ #{snippet}
+ #{confidence}
+
+ """
+ end)
+
+ body_html =
+ case preview_state do
+ "loading" ->
+ ~s(#{loading_label}
)
+
+ "ready" ->
+ """
+ #{if explanation, do: "#{explanation}
", else: ""}
+ #{metrics_html}
+ #{if findings_html != "", do: "", else: ""}
+ """
+
+ "error" ->
+ ~s(#{error_message}
)
+
+ _other ->
+ ~s(#{empty_label}
)
+ end
+
+ """
+
+ """
+ end
+
defp generate_heex(%{"type" => "list_item_multi_column"} = iur, opts) do
props = iur["props"] || %{}
active? = truthy_prop(props, "active?", truthy_prop(props, "active", false))
diff --git a/lib/ash_ui/resources/validations/authoring.ex b/lib/ash_ui/resources/validations/authoring.ex
index c2c16063..11be5d09 100644
--- a/lib/ash_ui/resources/validations/authoring.ex
+++ b/lib/ash_ui/resources/validations/authoring.ex
@@ -40,6 +40,7 @@ defmodule AshUI.Resources.Validations.Authoring do
"context_selector",
"file_tree_browser",
"right_rail",
+ "composer_query_preview",
"workflow_progress_status_card"
])
@signal_capabilities %{
@@ -60,6 +61,7 @@ defmodule AshUI.Resources.Validations.Authoring do
"context_selector" => [:change, :input],
"file_tree_browser" => [:change, :input, :toggle],
"right_rail" => [:change, :toggle, :click],
+ "composer_query_preview" => [:click],
"workflow_progress_status_card" => [:click, :change]
}
diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex
index 5cf4f0da..956fe651 100644
--- a/packages/live_ui/lib/live_ui/renderer.ex
+++ b/packages/live_ui/lib/live_ui/renderer.ex
@@ -31,6 +31,7 @@ defmodule LiveUi.Renderer do
:cluster_dashboard,
:column,
:command_palette,
+ :composer_query_preview,
:confidence_indicator,
:content,
:context_selector,
@@ -740,6 +741,48 @@ defmodule LiveUi.Renderer do
"""
end
+ # NOTE: `:composer_query_preview` is a canonical layer-shell component. Keep
+ # this native renderer clause before the generic component fallback so action
+ # buttons receive canonical interaction transport.
+ def render(%{element: %Element{kind: :composer_query_preview}} = assigns) do
+ preview = query_preview_attributes(assigns.element)
+
+ assigns =
+ assigns
+ |> assign(:preview, preview)
+ |> assign(
+ :action_attrs,
+ query_preview_action_attrs(assigns.element, Map.get(assigns, :event_target), preview)
+ )
+ |> 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))
@@ -2332,6 +2375,63 @@ defmodule LiveUi.Renderer do
end
end
+ defp query_preview_attributes(%Element{} = element) do
+ element.attributes
+ |> Map.get(:query_preview, Map.get(element.attributes, "query_preview", %{}))
+ |> case do
+ preview when is_map(preview) -> preview
+ preview when is_list(preview) -> Map.new(preview)
+ _other -> %{}
+ end
+ end
+
+ defp query_preview_action_attrs(%Element{} = element, event_target, preview) do
+ query = map_value(preview, :query)
+
+ %{
+ dismiss:
+ query_preview_interaction_attrs(
+ element,
+ event_target,
+ primary_interaction(element, :close),
+ query
+ ),
+ open:
+ query_preview_interaction_attrs(
+ element,
+ event_target,
+ primary_interaction(element, :open),
+ query
+ ),
+ save:
+ query_preview_interaction_attrs(
+ element,
+ event_target,
+ primary_interaction(element, :command),
+ query
+ )
+ }
+ end
+
+ defp query_preview_interaction_attrs(
+ %Element{} = element,
+ event_target,
+ %Interaction{} = interaction,
+ query
+ )
+ when not is_nil(event_target) do
+ %{
+ :"phx-click" => "canonical_interaction",
+ :"phx-target" => event_target,
+ :"phx-value-interaction" => encode_interaction(interaction),
+ :"phx-value-element_id" => element_id(element, "composer-query-preview"),
+ :"phx-value-widget" => "composer_query_preview",
+ :"phx-value-query" => to_string(query || "")
+ }
+ end
+
+ defp query_preview_interaction_attrs(_element, _event_target, _interaction, _query), do: %{}
+
defp right_rail_panels(rail) do
rail
|> map_value(:panels, [])
diff --git a/packages/live_ui/lib/live_ui/widgets/composer_query_preview.ex b/packages/live_ui/lib/live_ui/widgets/composer_query_preview.ex
new file mode 100644
index 00000000..56aee037
--- /dev/null
+++ b/packages/live_ui/lib/live_ui/widgets/composer_query_preview.ex
@@ -0,0 +1,184 @@
+defmodule LiveUi.Widgets.ComposerQueryPreview do
+ @moduledoc """
+ Native renderer for the canonical `:composer_query_preview` layer callout.
+
+ The widget is a result-preview band mounted next to a composer. It receives
+ canonical query preview attributes and renderer-supplied interaction attrs for
+ dismiss, open, and save actions. It does not own query lifecycle, product mode,
+ route, path, or Phoenix event names.
+ """
+
+ use LiveUi.Component,
+ family: :layer_shell_and_callout,
+ name: :composer_query_preview,
+ events: [:close, :open, :command]
+
+ LiveUi.Component.common_attrs()
+ attr(:composer_id, :string, required: true)
+ attr(:query, :string, required: true)
+ attr(:preview_state, :atom, default: :empty)
+ attr(:explanation, :string, default: nil)
+ attr(:metrics, :map, default: nil)
+ attr(:findings, :list, default: [])
+ attr(:max_findings_shown, :integer, default: 2)
+ attr(:error_message, :string, default: nil)
+ attr(:loading_label, :string, default: "Searching")
+ attr(:empty_label, :string, default: "No results for this query.")
+ attr(:open_label, :string, default: "Open query")
+ attr(:save_label, :string, default: "Save query")
+ attr(:dismiss_attrs, :map, default: %{})
+ attr(:open_attrs, :map, default: %{})
+ attr(:save_attrs, :map, default: %{})
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+ <%= case @preview_state do %>
+ <% :loading -> %>
+
+ <%= @loading_label %>
+
+
+ <% :ready -> %>
+
+ <%= @explanation %>
+
+
+
+
+ <%= metric_value(@metrics, :results_count) %> results
+
+
+ <%= duration_label(metric_value(@metrics, :duration_ms)) %>
+
+
+ <%= metric_value(@metrics, :sources_visited) %> sources
+
+
+
+
+ -
+
+ #<%= finding_value(finding, :n) %>
+
+
+ <%= finding_value(finding, :snippet) %>
+
+
+ <%= confidence_label(finding_value(finding, :confidence)) %>
+
+
+
+
+ @max_findings_shown}
+ class="live-ui-composer-query-preview__overflow"
+ aria-label={"#{length(@findings) - @max_findings_shown} more results"}
+ >
+ +<%= length(@findings) - @max_findings_shown %> more
+
+
+ <% :empty -> %>
+ <%= @empty_label %>
+
+ <% :error -> %>
+
+ <%= @error_message || "Query failed. Try again." %>
+
+
+ <% _ -> %>
+ <% end %>
+
+
+
+
+
+
+ """
+ end
+
+ defp metric_value(nil, _key), do: nil
+
+ defp metric_value(metrics, key) when is_map(metrics) do
+ Map.get(metrics, key, Map.get(metrics, to_string(key)))
+ end
+
+ defp metric_value(metrics, key) when is_list(metrics), do: Keyword.get(metrics, key)
+ defp metric_value(_metrics, _key), do: nil
+
+ defp finding_value(finding, key) when is_map(finding) do
+ Map.get(finding, key, Map.get(finding, to_string(key)))
+ end
+
+ defp finding_value(finding, key) when is_list(finding), do: Keyword.get(finding, key)
+ defp finding_value(_finding, _key), do: nil
+
+ defp duration_label(value) when is_integer(value) or is_float(value) do
+ seconds = value / 1000
+ :erlang.float_to_binary(seconds, decimals: 2) <> "s"
+ end
+
+ defp duration_label(value), do: to_string(value)
+
+ defp confidence_percent(value) when is_integer(value) or is_float(value) do
+ value
+ |> max(0)
+ |> min(1)
+ |> Kernel.*(100)
+ |> round()
+ end
+
+ defp confidence_percent(_value), do: 0
+
+ defp confidence_label(value) when is_integer(value) or is_float(value) do
+ :erlang.float_to_binary(value / 1, decimals: 2)
+ end
+
+ defp confidence_label(_value), do: "0.00"
+end
diff --git a/packages/live_ui/lib/live_ui/widgets/layer_shell_and_callout.ex b/packages/live_ui/lib/live_ui/widgets/layer_shell_and_callout.ex
index 8932f0b7..b129bcea 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
@@ -4,7 +4,8 @@ defmodule LiveUi.Widgets.LayerShellAndCallout do
"""
@modules [
- LiveUi.Widgets.RightRail
+ LiveUi.Widgets.RightRail,
+ LiveUi.Widgets.ComposerQueryPreview
]
@spec modules() :: [module()]
diff --git a/packages/live_ui/test/live_ui/composer_query_preview_widget_test.exs b/packages/live_ui/test/live_ui/composer_query_preview_widget_test.exs
new file mode 100644
index 00000000..8efe199e
--- /dev/null
+++ b/packages/live_ui/test/live_ui/composer_query_preview_widget_test.exs
@@ -0,0 +1,128 @@
+defmodule LiveUi.ComposerQueryPreviewWidgetTest do
+ use ExUnit.Case, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias UnifiedIUR.Widgets.Components
+
+ describe "ComposerQueryPreview Phoenix.Component" do
+ test "is registered in the layer shell component family" do
+ metadata = LiveUi.Component.metadata(LiveUi.Widgets.ComposerQueryPreview)
+
+ assert LiveUi.Widgets.ComposerQueryPreview in LiveUi.Widgets.LayerShellAndCallout.modules()
+
+ assert LiveUi.Widgets.ComposerQueryPreview in LiveUi.Widgets.layer_shell_and_callout_modules()
+
+ assert LiveUi.Widgets.ComposerQueryPreview in LiveUi.Widgets.modules()
+ assert metadata.family == :layer_shell_and_callout
+ assert metadata.name == :composer_query_preview
+ end
+
+ test "renders ready results without the generic component fallback" do
+ html =
+ render_component(&LiveUi.Widgets.ComposerQueryPreview.component/1, %{
+ id: "query-preview",
+ composer_id: "composer-main",
+ query: "release blockers",
+ preview_state: :ready,
+ explanation: "Three likely blockers found.",
+ metrics: %{results_count: 3, duration_ms: 42, sources_visited: 8},
+ findings: [
+ %{id: "finding-1", n: 1, snippet: "Conformance missing", confidence: 0.91}
+ ],
+ max_findings_shown: 2
+ })
+
+ assert html =~ ~s(data-live-ui-widget="composer-query-preview")
+ assert html =~ ~s(data-composer-id="composer-main")
+ assert html =~ ~s(data-preview-state="ready")
+ assert html =~ "Three likely blockers found."
+ assert html =~ "Conformance missing"
+ assert html =~ "3"
+ assert html =~ "0.04s"
+ refute html =~ ~s(data-live-ui-component-kind)
+ refute html =~ "data-live-ui-intent"
+ end
+
+ test "renders loading and error states with accessibility state" do
+ loading_html =
+ render_component(&LiveUi.Widgets.ComposerQueryPreview.component/1, %{
+ id: "loading-preview",
+ composer_id: "composer-main",
+ query: "status",
+ preview_state: :loading
+ })
+
+ error_html =
+ render_component(&LiveUi.Widgets.ComposerQueryPreview.component/1, %{
+ id: "error-preview",
+ composer_id: "composer-main",
+ query: "status",
+ preview_state: :error,
+ error_message: "No index available"
+ })
+
+ assert loading_html =~ ~s(aria-busy="true")
+ refute loading_html =~ "live-ui-composer-query-preview__actions"
+ assert error_html =~ ~s(role="alert")
+ assert error_html =~ "No index available"
+ end
+
+ test "accepts renderer-supplied canonical action attrs" do
+ html =
+ render_component(&LiveUi.Widgets.ComposerQueryPreview.component/1, %{
+ id: "query-preview-actions",
+ composer_id: "composer-main",
+ query: "release blockers",
+ preview_state: :empty,
+ dismiss_attrs: %{
+ "phx-click": "canonical_interaction",
+ "phx-value-widget": "composer_query_preview"
+ },
+ open_attrs: %{
+ "phx-click": "canonical_interaction",
+ "phx-value-widget": "composer_query_preview"
+ },
+ save_attrs: %{
+ "phx-click": "canonical_interaction",
+ "phx-value-widget": "composer_query_preview"
+ }
+ })
+
+ assert html =~ ~s(phx-click="canonical_interaction")
+ assert html =~ ~s(phx-value-widget="composer_query_preview")
+ refute html =~ "open in Ask"
+ end
+ end
+
+ describe "LiveUi.Renderer integration" do
+ test "dispatches canonical composer_query_preview through native component boundary" do
+ element =
+ Components.composer_query_preview(
+ id: "query-preview-renderer",
+ composer_id: "composer-main",
+ query: "release blockers",
+ preview_state: :ready,
+ explanation: "Three likely blockers found.",
+ metrics: %{results_count: 3, duration_ms: 42, sources_visited: 8},
+ findings: [
+ %{id: "finding-1", n: 1, snippet: "Conformance missing", confidence: 0.91}
+ ]
+ )
+
+ html =
+ render_component(&LiveUi.Renderer.render/1, %{
+ element: element,
+ event_target: "#runtime-host"
+ })
+
+ assert html =~ ~s(data-live-ui-widget="composer-query-preview")
+ assert html =~ ~s(phx-click="canonical_interaction")
+ assert html =~ ~s(phx-target="#runtime-host")
+ assert html =~ ~s(phx-value-widget="composer_query_preview")
+ assert html =~ ~s(phx-value-query="release blockers")
+ refute html =~ ~s(data-live-ui-component-kind)
+ refute html =~ "open in Ask"
+ end
+ end
+end
diff --git a/packages/unified_iur/lib/unified_iur/validate.ex b/packages/unified_iur/lib/unified_iur/validate.ex
index 5e67eae7..31cf4a54 100644
--- a/packages/unified_iur/lib/unified_iur/validate.ex
+++ b/packages/unified_iur/lib/unified_iur/validate.ex
@@ -143,6 +143,16 @@ defmodule UnifiedIUR.Validate do
guidance:
"Represent rail panel selection and collapse as UnifiedIUR.Interaction structs, not renderer event names."
},
+ invalid_query_preview: %{
+ construct_family: :widget_components,
+ guidance:
+ "Represent composer_query_preview with generic composer, query, preview state, metrics, and renderer-independent action intent."
+ },
+ invalid_query_preview_finding: %{
+ construct_family: :widget_components,
+ guidance:
+ "Represent composer_query_preview findings as opaque result descriptors with id, ordinal, snippet, and confidence."
+ },
invalid_workflow_progress_status_card: %{
construct_family: :widget_components,
guidance:
@@ -174,6 +184,27 @@ defmodule UnifiedIUR.Validate do
@artifact_kinds [:pr, :doc, :spec, :file, :grain, :generic]
@artifact_badge_tones [:positive, :warning, :danger, :info, :neutral]
@rail_sides [:right]
+ @query_preview_states [:loading, :ready, :empty, :error]
+ @query_preview_forbidden_keys ~w[
+ ask_query
+ event
+ event_target
+ helper
+ live_action
+ mgql
+ module
+ on_click
+ on_dismiss
+ on_open_in_ask
+ on_save_query
+ path
+ phx-click
+ phx_click
+ phx_event
+ route
+ runtime_module
+ url
+ ]
@rail_forbidden_panel_keys ~w[
event
helper
@@ -604,6 +635,15 @@ defmodule UnifiedIUR.Validate do
|> Kernel.++(validate_rail_interactions(Map.get(attributes, :interactions, [])))
end
+ defp validate_component_contracts(%Element{
+ kind: :composer_query_preview,
+ attributes: attributes
+ }) do
+ attributes
+ |> Map.get(:query_preview, %{})
+ |> validate_query_preview_shape()
+ end
+
defp validate_component_contracts(%Element{
kind: :workflow_progress_status_card,
attributes: attributes
@@ -1244,6 +1284,173 @@ defmodule UnifiedIUR.Validate do
]
end
+ defp validate_query_preview_shape(preview) when is_map(preview) do
+ preview_state = fetch(preview, :preview_state, :empty)
+ max_findings_shown = fetch(preview, :max_findings_shown, 2)
+
+ []
+ |> maybe_add(
+ not non_empty_string?(fetch(preview, :composer_id)),
+ Error.new(
+ :invalid_query_preview,
+ "composer_query_preview requires composer_id as a non-empty string",
+ path: [:attributes, :query_preview, :composer_id],
+ details: %{composer_id: inspect(fetch(preview, :composer_id))}
+ )
+ )
+ |> maybe_add(
+ not non_empty_string?(fetch(preview, :query)),
+ Error.new(
+ :invalid_query_preview,
+ "composer_query_preview requires query as a non-empty string",
+ path: [:attributes, :query_preview, :query],
+ details: %{query: inspect(fetch(preview, :query))}
+ )
+ )
+ |> maybe_add(
+ not valid_query_preview_state?(preview_state),
+ Error.new(
+ :invalid_query_preview,
+ "composer_query_preview preview_state must be one of #{inspect(@query_preview_states)}",
+ path: [:attributes, :query_preview, :preview_state],
+ details: %{preview_state: inspect(preview_state)}
+ )
+ )
+ |> maybe_add(
+ ready_query_preview?(preview_state) and not non_empty_string?(fetch(preview, :explanation)),
+ Error.new(
+ :invalid_query_preview,
+ "composer_query_preview ready previews require explanation as a non-empty string",
+ path: [:attributes, :query_preview, :explanation]
+ )
+ )
+ |> maybe_add(
+ not positive_integer?(max_findings_shown),
+ Error.new(
+ :invalid_query_preview,
+ "composer_query_preview max_findings_shown must be a positive integer",
+ path: [:attributes, :query_preview, :max_findings_shown],
+ details: %{max_findings_shown: inspect(max_findings_shown)}
+ )
+ )
+ |> maybe_add(
+ has_forbidden_query_preview_key_deep?(preview),
+ Error.new(
+ :invalid_query_preview,
+ "composer_query_preview must not include product-specific, renderer event, or host route fields",
+ path: [:attributes, :query_preview]
+ )
+ )
+ |> Kernel.++(validate_query_preview_metrics(fetch(preview, :metrics)))
+ |> Kernel.++(
+ validate_query_preview_findings(
+ fetch(preview, :findings, []),
+ [:attributes, :query_preview, :findings]
+ )
+ )
+ end
+
+ defp validate_query_preview_shape(_preview) do
+ [
+ Error.new(
+ :invalid_query_preview,
+ "composer_query_preview attributes.query_preview must be a map",
+ path: [:attributes, :query_preview]
+ )
+ ]
+ end
+
+ defp validate_query_preview_metrics(nil), do: []
+
+ defp validate_query_preview_metrics(metrics) when is_map(metrics) or is_list(metrics) do
+ metrics = normalize_map(metrics)
+
+ [:results_count, :findings_count, :duration_ms, :sources_visited, :grains_visited]
+ |> Enum.flat_map(fn key ->
+ value = fetch(metrics, key)
+
+ maybe_add(
+ [],
+ not is_nil(value) and not non_negative_integer?(value),
+ Error.new(
+ :invalid_query_preview,
+ "composer_query_preview metrics must be non-negative integer counts",
+ path: [:attributes, :query_preview, :metrics, key],
+ details: %{value: inspect(value)}
+ )
+ )
+ end)
+ end
+
+ defp validate_query_preview_metrics(_metrics) do
+ [
+ Error.new(
+ :invalid_query_preview,
+ "composer_query_preview metrics must be a map when present",
+ path: [:attributes, :query_preview, :metrics]
+ )
+ ]
+ end
+
+ defp validate_query_preview_findings(findings, path) when is_list(findings) do
+ findings
+ |> Enum.with_index()
+ |> Enum.flat_map(fn {finding, index} ->
+ validate_query_preview_finding(finding, path ++ [index])
+ end)
+ end
+
+ defp validate_query_preview_findings(_findings, path) do
+ [
+ Error.new(
+ :invalid_query_preview_finding,
+ "composer_query_preview findings must be a list",
+ path: path
+ )
+ ]
+ end
+
+ defp validate_query_preview_finding(finding, path) when is_map(finding) or is_list(finding) do
+ finding = normalize_map(finding)
+ id = fetch(finding, :id) || fetch(finding, :finding_id)
+ confidence = fetch(finding, :confidence)
+
+ []
+ |> maybe_add(
+ not non_empty_string?(id) or not positive_integer?(fetch(finding, :n)) or
+ not non_empty_string?(fetch(finding, :snippet)),
+ Error.new(
+ :invalid_query_preview_finding,
+ "composer_query_preview finding requires id, positive n, and snippet",
+ path: path,
+ details: %{
+ id: inspect(id),
+ n: inspect(fetch(finding, :n)),
+ snippet: inspect(fetch(finding, :snippet))
+ }
+ )
+ )
+ |> maybe_add(
+ not (is_number(confidence) and confidence >= 0.0 and confidence <= 1.0),
+ Error.new(
+ :invalid_query_preview_finding,
+ "composer_query_preview finding confidence must be in 0.0..1.0",
+ path: path ++ [:confidence],
+ details: %{confidence: inspect(confidence)}
+ )
+ )
+ end
+
+ defp validate_query_preview_finding(_finding, path) do
+ [
+ Error.new(
+ :invalid_query_preview_finding,
+ "composer_query_preview findings must be maps",
+ path: path
+ )
+ ]
+ end
+
defp validate_selection_options(options, path) when is_list(options) do
options
|> Enum.with_index()
@@ -1513,10 +1720,47 @@ defmodule UnifiedIUR.Validate do
defp has_forbidden_progress_card_key_deep?(_value), do: false
+ defp has_forbidden_query_preview_key?(value) when is_map(value) do
+ value
+ |> Map.keys()
+ |> Enum.any?(&(to_string(&1) in @query_preview_forbidden_keys))
+ end
+
+ defp has_forbidden_query_preview_key?(_value), do: false
+
+ defp has_forbidden_query_preview_key_deep?(%_{} = struct) do
+ struct
+ |> Map.from_struct()
+ |> has_forbidden_query_preview_key_deep?()
+ end
+
+ defp has_forbidden_query_preview_key_deep?(value) when is_map(value) do
+ has_forbidden_query_preview_key?(value) or
+ Enum.any?(Map.values(value), &has_forbidden_query_preview_key_deep?/1)
+ end
+
+ defp has_forbidden_query_preview_key_deep?(value) when is_list(value) do
+ Enum.any?(value, &has_forbidden_query_preview_key_deep?/1)
+ end
+
+ defp has_forbidden_query_preview_key_deep?(_value), do: false
+
+ defp valid_query_preview_state?(state) when state in @query_preview_states, do: true
+
+ defp valid_query_preview_state?(state) when is_binary(state) do
+ state in Enum.map(@query_preview_states, &Atom.to_string/1)
+ end
+
+ defp valid_query_preview_state?(_state), do: false
+
+ defp ready_query_preview?(state), do: state in [:ready, "ready"]
+
defp invalid_subject_progress?(value),
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_negative_integer?(value), do: is_integer(value) and value >= 0
+ defp positive_integer?(value), do: is_integer(value) and value > 0
defp active_panel_in_panels?(active_panel, panels) when is_list(panels) do
Enum.any?(panels, fn
diff --git a/packages/unified_iur/lib/unified_iur/widgets/components.ex b/packages/unified_iur/lib/unified_iur/widgets/components.ex
index 10059c46..f398f7c4 100644
--- a/packages/unified_iur/lib/unified_iur/widgets/components.ex
+++ b/packages/unified_iur/lib/unified_iur/widgets/components.ex
@@ -73,9 +73,12 @@ defmodule UnifiedIUR.Widgets.Components do
:sidebar_section,
:sidebar_item,
:right_rail,
- :command_palette
+ :command_palette,
+ :composer_query_preview
]
+ @query_preview_states [:loading, :ready, :empty, :error]
+
@redline_code_kinds [
:redline_inline,
:code_block_syntax_highlighted
@@ -1057,6 +1060,63 @@ defmodule UnifiedIUR.Widgets.Components do
)
end
+ @spec composer_query_preview(opts()) :: Element.t()
+ def composer_query_preview(opts \\ []) do
+ opts = normalize_opts(opts)
+
+ composer_id =
+ required_string!(
+ opts,
+ :composer_id,
+ "composer_query_preview requires a non-empty :composer_id string"
+ )
+
+ query =
+ required_string!(
+ opts,
+ :query,
+ "composer_query_preview requires a non-empty :query string"
+ )
+
+ preview_state = normalize_query_preview_state!(option(opts, :preview_state, :empty))
+ max_findings_shown = option(opts, :max_findings_shown, 2)
+ validate_positive_integer!(max_findings_shown, "max_findings_shown")
+
+ explanation = option(opts, :explanation)
+ metrics = normalize_query_preview_metrics!(option(opts, :metrics))
+ findings = normalize_query_preview_findings!(option(opts, :findings, []))
+
+ if preview_state == :ready and not non_empty_string?(explanation) do
+ raise ArgumentError,
+ "composer_query_preview requires a non-empty :explanation string when preview_state is :ready"
+ end
+
+ opts = put_query_preview_interactions(opts, composer_id, query)
+
+ build_component(
+ :composer_query_preview,
+ :layer_shell_and_callout,
+ %{
+ query_preview:
+ %{
+ composer_id: composer_id,
+ query: query,
+ preview_state: preview_state,
+ max_findings_shown: max_findings_shown,
+ findings: findings
+ }
+ |> maybe_put(:explanation, explanation)
+ |> maybe_put(:metrics, metrics)
+ |> maybe_put(:error_message, option(opts, :error_message))
+ |> maybe_put(:loading_label, option(opts, :loading_label, "Searching"))
+ |> maybe_put(:empty_label, option(opts, :empty_label, "No results for this query."))
+ |> maybe_put(:open_label, option(opts, :open_label, "Open query"))
+ |> maybe_put(:save_label, option(opts, :save_label, "Save query"))
+ },
+ opts
+ )
+ end
+
defp build_component(kind, family, kind_attributes, opts) do
opts = normalize_opts(opts)
@@ -1181,6 +1241,41 @@ defmodule UnifiedIUR.Widgets.Components do
end
end
+ defp put_query_preview_interactions(opts, composer_id, query) do
+ cond do
+ Map.has_key?(opts, :interactions) or Map.has_key?(opts, "interactions") or
+ Map.has_key?(opts, :interaction) or Map.has_key?(opts, "interaction") ->
+ opts
+
+ true ->
+ Map.put(opts, :interactions, query_preview_interactions(opts, composer_id, query))
+ end
+ end
+
+ defp query_preview_interactions(opts, composer_id, query) do
+ [
+ Interaction.close(
+ intent: option(opts, :dismiss_intent, :dismiss_query_preview),
+ element_id: option(opts, :id),
+ entity: composer_id,
+ value: query
+ ),
+ Interaction.open(
+ intent: option(opts, :open_intent, :open_query_preview),
+ element_id: option(opts, :id),
+ entity: composer_id,
+ value: query
+ ),
+ Interaction.command(
+ intent: option(opts, :save_intent, :save_query),
+ element_id: option(opts, :id),
+ entity: composer_id,
+ command: :save_query,
+ value: query
+ )
+ ]
+ end
+
defp normalize_thread_participants!(nil), do: []
defp normalize_thread_participants!(participants) when is_list(participants) do
@@ -1221,6 +1316,111 @@ defmodule UnifiedIUR.Widgets.Components do
raise ArgumentError, "thread_card :progress_pct must be a number"
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
+ state
+ |> String.to_existing_atom()
+ |> normalize_query_preview_state!()
+ rescue
+ ArgumentError ->
+ raise ArgumentError,
+ "composer_query_preview :preview_state must be one of #{inspect(@query_preview_states)}"
+ end
+
+ defp normalize_query_preview_state!(_state) do
+ raise ArgumentError,
+ "composer_query_preview :preview_state must be one of #{inspect(@query_preview_states)}"
+ end
+
+ defp normalize_query_preview_metrics!(nil), do: nil
+
+ defp normalize_query_preview_metrics!(metrics) when is_map(metrics) or is_list(metrics) do
+ metrics = normalize_map(metrics)
+ results_count = option(metrics, :results_count, option(metrics, :findings_count))
+ duration_ms = option(metrics, :duration_ms)
+ sources_visited = option(metrics, :sources_visited, option(metrics, :grains_visited))
+
+ %{}
+ |> maybe_put(:results_count, normalize_non_negative_integer!(results_count, "results_count"))
+ |> maybe_put(:duration_ms, normalize_non_negative_integer!(duration_ms, "duration_ms"))
+ |> maybe_put(
+ :sources_visited,
+ normalize_non_negative_integer!(sources_visited, "sources_visited")
+ )
+ |> empty_map_to_nil()
+ end
+
+ defp normalize_query_preview_metrics!(_metrics) do
+ raise ArgumentError, "composer_query_preview :metrics must be a map"
+ end
+
+ defp normalize_query_preview_findings!(findings) when is_list(findings) do
+ Enum.map(findings, &normalize_query_preview_finding!/1)
+ end
+
+ defp normalize_query_preview_findings!(_findings) do
+ raise ArgumentError, "composer_query_preview :findings must be a list of maps"
+ end
+
+ defp normalize_query_preview_finding!(finding) when is_map(finding) or is_list(finding) do
+ finding = normalize_map(finding)
+ id = option(finding, :id, option(finding, :finding_id))
+ n = option(finding, :n)
+ snippet = option(finding, :snippet)
+ confidence = option(finding, :confidence)
+
+ unless non_empty_string?(id) do
+ raise ArgumentError, "composer_query_preview findings require a non-empty :id string"
+ end
+
+ unless positive_integer?(n) do
+ raise ArgumentError, "composer_query_preview findings require positive integer :n values"
+ end
+
+ unless non_empty_string?(snippet) do
+ raise ArgumentError, "composer_query_preview findings require non-empty :snippet strings"
+ end
+
+ unless normalized_confidence?(confidence) do
+ raise ArgumentError, "composer_query_preview finding :confidence must be in 0.0..1.0"
+ end
+
+ %{
+ id: id,
+ n: n,
+ snippet: snippet,
+ confidence: confidence / 1
+ }
+ end
+
+ defp normalize_query_preview_finding!(_finding) do
+ raise ArgumentError, "composer_query_preview findings must be maps"
+ end
+
+ defp required_string!(opts, key, message) do
+ case option(opts, key) do
+ value when is_binary(value) and value != "" -> value
+ _other -> raise ArgumentError, message
+ end
+ end
+
+ defp validate_positive_integer!(value, field) do
+ unless positive_integer?(value) do
+ raise ArgumentError, "composer_query_preview :#{field} must be a positive integer"
+ end
+ end
+
+ defp normalize_non_negative_integer!(nil, _field), do: nil
+
+ defp normalize_non_negative_integer!(value, _field) when is_integer(value) and value >= 0 do
+ value
+ end
+
+ defp normalize_non_negative_integer!(_value, field) do
+ raise ArgumentError, "composer_query_preview metrics.#{field} must be a non-negative integer"
+ end
+
defp empty_to_nil([]), do: nil
defp empty_to_nil(value), do: value
@@ -1229,6 +1429,13 @@ defmodule UnifiedIUR.Widgets.Components do
defp non_empty_string?(value), do: is_binary(value) and byte_size(value) > 0
defp non_negative_integer?(value), do: is_integer(value) and value >= 0
+ defp positive_integer?(value), do: is_integer(value) and value > 0
+
+ defp normalized_confidence?(value) when is_integer(value) or is_float(value) do
+ value >= 0.0 and value <= 1.0
+ end
+
+ defp normalized_confidence?(_value), do: false
defp normalize_metadata(opts) do
opts
diff --git a/packages/unified_iur/test/unified_iur/validate_test.exs b/packages/unified_iur/test/unified_iur/validate_test.exs
index e8f9b6c8..bf9642dc 100644
--- a/packages/unified_iur/test/unified_iur/validate_test.exs
+++ b/packages/unified_iur/test/unified_iur/validate_test.exs
@@ -147,6 +147,57 @@ defmodule UnifiedIUR.ValidateTest do
assert panel_error.code == :invalid_rail_panel
end
+ test "validates canonical composer query preview shape" do
+ valid_preview =
+ Components.composer_query_preview(
+ id: :query_preview,
+ composer_id: "composer-main",
+ query: "release blockers",
+ preview_state: :ready,
+ explanation: "Two release checks need attention.",
+ metrics: %{results_count: 2, duration_ms: 34, sources_visited: 4},
+ findings: [%{id: "finding-1", n: 1, snippet: "CI is still pending.", confidence: 0.82}]
+ )
+
+ event_leak =
+ Element.new(:widget, :composer_query_preview,
+ attributes: %{
+ component: %{family: :layer_shell_and_callout, kind: :composer_query_preview},
+ query_preview: %{
+ "on_open_in_ask" => "open",
+ composer_id: "composer-main",
+ query: "release blockers",
+ preview_state: :ready,
+ explanation: "Two release checks need attention.",
+ max_findings_shown: 2,
+ findings: []
+ }
+ }
+ )
+
+ invalid_finding =
+ Element.new(:widget, :composer_query_preview,
+ attributes: %{
+ component: %{family: :layer_shell_and_callout, kind: :composer_query_preview},
+ query_preview: %{
+ composer_id: "composer-main",
+ query: "release blockers",
+ preview_state: :ready,
+ explanation: "Two release checks need attention.",
+ max_findings_shown: 2,
+ findings: [%{id: "finding-1", n: 1, snippet: "", confidence: 1.2}]
+ }
+ }
+ )
+
+ assert :ok = Validate.element(valid_preview)
+ assert {:error, [preview_error]} = Validate.element(event_leak)
+ assert preview_error.code == :invalid_query_preview
+
+ assert {:error, finding_errors} = Validate.element(invalid_finding)
+ assert Enum.all?(finding_errors, &(&1.code == :invalid_query_preview_finding))
+ end
+
test "validates first-class artifact row fields" do
valid_artifact =
Components.artifact_row("ADR", [],
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 ef31b007..ae64d808 100644
--- a/packages/unified_iur/test/unified_iur/widgets/components_test.exs
+++ b/packages/unified_iur/test/unified_iur/widgets/components_test.exs
@@ -49,7 +49,8 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do
:sidebar_section,
:sidebar_item,
:right_rail,
- :command_palette
+ :command_palette,
+ :composer_query_preview
]
assert Components.redline_code_kinds() == [
@@ -258,6 +259,40 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do
end
end
+ test "validates canonical composer query preview shape" do
+ assert_raise ArgumentError, ~r/non-empty :composer_id/, fn ->
+ Components.composer_query_preview(query: "status")
+ end
+
+ assert_raise ArgumentError, ~r/non-empty :query/, fn ->
+ Components.composer_query_preview(composer_id: "composer-main")
+ end
+
+ assert_raise ArgumentError, ~r/preview_state must be one of/, fn ->
+ Components.composer_query_preview(
+ composer_id: "composer-main",
+ query: "status",
+ preview_state: :stale
+ )
+ end
+
+ assert_raise ArgumentError, ~r/explanation.*ready/, fn ->
+ Components.composer_query_preview(
+ composer_id: "composer-main",
+ query: "status",
+ preview_state: :ready
+ )
+ end
+
+ assert_raise ArgumentError, ~r/confidence must be in 0\.0\.\.1\.0/, fn ->
+ Components.composer_query_preview(
+ composer_id: "composer-main",
+ query: "status",
+ findings: [%{id: "finding-1", n: 1, snippet: "bad", confidence: 1.2}]
+ )
+ end
+ end
+
test "represents workflow, layer, callout, redline, and code components" do
stepper =
Components.pipeline_stepper_horizontal(
@@ -300,6 +335,19 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do
action_intent: :inspect_event
)
+ query_preview =
+ Components.composer_query_preview(
+ id: "preview-search",
+ composer_id: "composer-main",
+ query: "release blockers",
+ preview_state: :ready,
+ explanation: "Three likely blockers found.",
+ metrics: %{results_count: 3, duration_ms: 42, sources_visited: 8},
+ findings: [
+ %{id: "finding-1", n: 1, snippet: "Conformance missing", confidence: 0.91}
+ ]
+ )
+
redline = Components.redline_inline([%{state: :insert, text: "new"}])
code =
@@ -364,6 +412,34 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do
}
assert [%{element: %Element{kind: :button}}] = callout.children
+
+ assert query_preview.attributes.component == %{
+ family: :layer_shell_and_callout,
+ kind: :composer_query_preview
+ }
+
+ assert query_preview.attributes.query_preview == %{
+ composer_id: "composer-main",
+ query: "release blockers",
+ preview_state: :ready,
+ max_findings_shown: 2,
+ findings: [
+ %{id: "finding-1", n: 1, snippet: "Conformance missing", confidence: 0.91}
+ ],
+ explanation: "Three likely blockers found.",
+ metrics: %{results_count: 3, duration_ms: 42, sources_visited: 8},
+ loading_label: "Searching",
+ empty_label: "No results for this query.",
+ open_label: "Open query",
+ save_label: "Save query"
+ }
+
+ assert [
+ %Interaction{family: :close, intent: :dismiss_query_preview},
+ %Interaction{family: :open, intent: :open_query_preview},
+ %Interaction{family: :command, intent: :save_query}
+ ] = query_preview.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 d92198fa..878783a9 100644
--- a/packages/unified_ui/lib/unified_ui/compiler/pipeline.ex
+++ b/packages/unified_ui/lib/unified_ui/compiler/pipeline.ex
@@ -1006,6 +1006,25 @@ defmodule UnifiedUi.Compiler.Pipeline do
])
)
+ :composer_query_preview ->
+ Widgets.Components.composer_query_preview(
+ common_opts(node, attachments, [
+ :composer_id,
+ :query,
+ :preview_state,
+ :explanation,
+ :metrics,
+ :findings,
+ :max_findings_shown,
+ :error_message,
+ :loading_label,
+ :empty_label,
+ :open_intent,
+ :save_intent,
+ :dismiss_intent
+ ])
+ )
+
:right_rail ->
lower_right_rail(node, context, visited, attachments)
@@ -1547,6 +1566,7 @@ defmodule UnifiedUi.Compiler.Pipeline do
)
end
+ defp default_dismiss_interaction(%Node{kind: :composer_query_preview}), do: nil
defp default_dismiss_interaction(%Node{dismiss_intent: nil}), do: nil
defp default_dismiss_interaction(node) do
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 842a788e..d9a9acdb 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
@@ -219,6 +219,28 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do
action_intent: [type: :atom, required: false],
summary: [type: :string, required: false]
),
+ leaf(
+ :composer_query_preview,
+ @layer_family,
+ composer_id: [type: :string, required: true],
+ query: [type: :string, required: true],
+ preview_state: [
+ type: {:in, [:loading, :ready, :empty, :error]},
+ required: false,
+ default: :empty
+ ],
+ explanation: [type: :string, required: false],
+ metrics: [type: :any, required: false],
+ findings: [type: :any, required: false, default: []],
+ max_findings_shown: [type: :integer, required: false, default: 2],
+ error_message: [type: :string, required: false],
+ loading_label: [type: :string, required: false, default: "Searching"],
+ empty_label: [type: :string, required: false, default: "No results for this query."],
+ open_intent: [type: :atom, required: false],
+ save_intent: [type: :atom, required: false],
+ dismiss_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 6c2dabd0..fbd714db 100644
--- a/packages/unified_ui/lib/unified_ui/dsl/node.ex
+++ b/packages/unified_ui/lib/unified_ui/dsl/node.ex
@@ -206,6 +206,17 @@ defmodule UnifiedUi.Dsl.Node do
collapsible?: boolean() | nil,
panel_select_intent: atom() | nil,
collapse_intent: atom() | nil,
+ composer_id: String.t() | nil,
+ query: String.t() | nil,
+ preview_state: atom() | nil,
+ explanation: String.t() | nil,
+ findings: list() | nil,
+ max_findings_shown: integer() | nil,
+ error_message: String.t() | nil,
+ loading_label: String.t() | nil,
+ empty_label: String.t() | nil,
+ open_intent: atom() | nil,
+ save_intent: atom() | nil,
density: atom() | nil,
children: [t()]
}
@@ -392,6 +403,17 @@ defmodule UnifiedUi.Dsl.Node do
collapsible?: nil,
panel_select_intent: nil,
collapse_intent: nil,
+ composer_id: nil,
+ query: nil,
+ preview_state: nil,
+ explanation: nil,
+ findings: nil,
+ max_findings_shown: nil,
+ error_message: nil,
+ loading_label: nil,
+ empty_label: nil,
+ open_intent: nil,
+ save_intent: nil,
density: nil,
children: []
@@ -500,6 +522,18 @@ defmodule UnifiedUi.Dsl.Node do
collapsible?: node.collapsible?,
panel_select_intent: node.panel_select_intent,
collapse_intent: node.collapse_intent,
+ composer_id: node.composer_id,
+ query: node.query,
+ preview_state: node.preview_state,
+ explanation: node.explanation,
+ metrics: summary_metrics(node),
+ findings: node.findings,
+ max_findings_shown: node.max_findings_shown,
+ error_message: node.error_message,
+ loading_label: node.loading_label,
+ empty_label: node.empty_label,
+ open_intent: node.open_intent,
+ save_intent: node.save_intent,
density: node.density,
current: node.current,
minimum: node.minimum,
@@ -541,5 +575,15 @@ defmodule UnifiedUi.Dsl.Node do
dismiss_intent
end
+ defp summary_dismiss_intent(%__MODULE__{
+ kind: :composer_query_preview,
+ dismiss_intent: dismiss_intent
+ }) do
+ dismiss_intent
+ end
+
defp summary_dismiss_intent(_node), do: nil
+
+ defp summary_metrics(%__MODULE__{kind: :composer_query_preview, metrics: metrics}), do: metrics
+ defp summary_metrics(_node), do: nil
end
diff --git a/packages/unified_ui/lib/unified_ui/dsl/placement.ex b/packages/unified_ui/lib/unified_ui/dsl/placement.ex
index aa44ae47..6183484b 100644
--- a/packages/unified_ui/lib/unified_ui/dsl/placement.ex
+++ b/packages/unified_ui/lib/unified_ui/dsl/placement.ex
@@ -96,7 +96,8 @@ defmodule UnifiedUi.Dsl.Placement do
:workflow_stage_list_vertical,
:meter_thin,
:redline_inline,
- :code_block_syntax_highlighted
+ :code_block_syntax_highlighted,
+ :composer_query_preview
]
@advanced_leaf_kinds [
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 7ee7d5f4..7dbdcd34 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
@@ -40,6 +40,7 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do
runtime_module
url
]
+ @query_preview_states [:loading, :ready, :empty, :error]
@spec verify(map()) :: :ok | {:error, Spark.Error.DslError.t()}
def verify(dsl) do
@@ -337,6 +338,51 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do
end
end
+ def validate_node(%Node{
+ kind: :composer_query_preview,
+ id: id,
+ composer_id: composer_id,
+ query: query,
+ preview_state: preview_state,
+ explanation: explanation,
+ metrics: metrics,
+ findings: findings,
+ max_findings_shown: max_findings_shown
+ }) do
+ cond do
+ not is_binary(composer_id) or composer_id == "" ->
+ {:error, [:composition, :composer_query_preview, id],
+ "composer_query_preview #{inspect(id)} composer_id must be a non-empty string"}
+
+ not is_binary(query) or query == "" ->
+ {:error, [:composition, :composer_query_preview, id],
+ "composer_query_preview #{inspect(id)} query must be a non-empty string"}
+
+ preview_state not in @query_preview_states ->
+ {:error, [:composition, :composer_query_preview, id],
+ "composer_query_preview #{inspect(id)} preview_state must be one of #{inspect(@query_preview_states)}"}
+
+ preview_state == :ready and (not is_binary(explanation) or explanation == "") ->
+ {:error, [:composition, :composer_query_preview, id],
+ "composer_query_preview #{inspect(id)} explanation is required for ready previews"}
+
+ not valid_optional_query_preview_metrics?(metrics) ->
+ {:error, [:composition, :composer_query_preview, id],
+ "composer_query_preview #{inspect(id)} metrics must be a map with non-negative integer counts"}
+
+ not valid_query_preview_findings?(findings) ->
+ {:error, [:composition, :composer_query_preview, id],
+ "composer_query_preview #{inspect(id)} findings must be a list of maps with id, n, snippet, and confidence"}
+
+ not valid_positive_integer?(max_findings_shown) ->
+ {:error, [:composition, :composer_query_preview, id],
+ "composer_query_preview #{inspect(id)} max_findings_shown must be a positive integer"}
+
+ true ->
+ :ok
+ end
+ end
+
def validate_node(%Node{kind: :redline_inline, id: id, segments: segments}) do
if valid_redline_segments?(segments) do
:ok
@@ -615,6 +661,40 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do
defp valid_rail_panel?(_panel), do: false
+ defp valid_optional_query_preview_metrics?(nil), do: true
+
+ defp valid_optional_query_preview_metrics?(metrics) when is_map(metrics) or is_list(metrics) do
+ metrics = normalize_map(metrics)
+
+ [:results_count, :findings_count, :duration_ms, :sources_visited, :grains_visited]
+ |> Enum.all?(fn key ->
+ case field(metrics, key) do
+ nil -> true
+ value -> valid_non_negative_integer?(value)
+ end
+ end)
+ end
+
+ defp valid_optional_query_preview_metrics?(_metrics), do: false
+
+ defp valid_query_preview_findings?(findings) when is_list(findings) do
+ Enum.all?(findings, &valid_query_preview_finding?/1)
+ end
+
+ defp valid_query_preview_findings?(_findings), do: false
+
+ defp valid_query_preview_finding?(finding) when is_map(finding) or is_list(finding) do
+ finding = normalize_map(finding)
+ id = field(finding, :id) || field(finding, :finding_id)
+ confidence = field(finding, :confidence)
+
+ is_binary(id) and id != "" and valid_positive_integer?(field(finding, :n)) and
+ is_binary(field(finding, :snippet)) and field(finding, :snippet) != "" and
+ is_number(confidence) and confidence >= 0.0 and confidence <= 1.0
+ end
+
+ defp valid_query_preview_finding?(_finding), do: false
+
defp active_rail_panel?(active_panel, panels) do
valid_scalar?(active_panel) and
Enum.any?(panels, fn panel ->
@@ -658,6 +738,7 @@ defmodule UnifiedUi.Dsl.Verifiers.ValidateWidgetComponents do
defp valid_non_negative_integer?(nil), do: true
defp valid_non_negative_integer?(value), do: is_integer(value) and value >= 0
+ defp valid_positive_integer?(value), do: is_integer(value) and value > 0
defp valid_subject_dependency_names?(nil), do: true
diff --git a/packages/unified_ui/lib/unified_ui/widget_components.ex b/packages/unified_ui/lib/unified_ui/widget_components.ex
index 877dd2fe..abd3666a 100644
--- a/packages/unified_ui/lib/unified_ui/widget_components.ex
+++ b/packages/unified_ui/lib/unified_ui/widget_components.ex
@@ -227,6 +227,13 @@ defmodule UnifiedUi.WidgetComponents do
"Keyboard-driven command palette overlay with open state, filterable items, and children.",
aliases: []
},
+ %{
+ kind: :composer_query_preview,
+ family: :layer_shell_and_callout,
+ summary:
+ "Inline composer-adjacent query preview with state, result summary, metrics, findings, and canonical 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 f086e4c2..a0079f8b 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
@@ -181,6 +181,7 @@ defmodule UnifiedUi.AdvancedWidgetFamiliesTest do
:sticky_frosted_header,
:slide_over_panel,
:event_callout,
+ :composer_query_preview,
: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 ef17c952..c93b4b3a 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
@@ -122,6 +122,19 @@ defmodule UnifiedUi.OperationalWidgetComponentsTest do
end
end
+ composer_query_preview :query_preview do
+ composer_id("composer-main")
+ query("release blockers")
+ preview_state(:ready)
+ explanation("Three likely blockers found.")
+ metrics(%{results_count: 3, duration_ms: 42, sources_visited: 8})
+ findings([%{id: "finding-1", n: 1, snippet: "Conformance missing", confidence: 0.91}])
+ max_findings_shown(2)
+ open_intent(:open_query_preview)
+ save_intent(:save_query)
+ dismiss_intent(:dismiss_query_preview)
+ end
+
redline_inline :copy_redline do
segments([
%{state: :keep, text: "Keep this "},
@@ -160,6 +173,7 @@ defmodule UnifiedUi.OperationalWidgetComponentsTest do
:sticky_frosted_header,
:slide_over_panel,
:event_callout,
+ :composer_query_preview,
:right_rail
]
@@ -192,6 +206,10 @@ defmodule UnifiedUi.OperationalWidgetComponentsTest do
by_id.details_panel.modal?} ==
{:layer_shell_and_callout, :slide_over_panel, true, false}
+ assert {by_id.query_preview.family, by_id.query_preview.kind, by_id.query_preview.query,
+ by_id.query_preview.preview_state} ==
+ {:layer_shell_and_callout, :composer_query_preview, "release blockers", :ready}
+
assert {by_id.copy_redline.family, by_id.copy_redline.kind, by_id.copy_redline.text_safety} ==
{:redline_and_code, :redline_inline, :plain_text}
@@ -246,6 +264,17 @@ defmodule UnifiedUi.OperationalWidgetComponentsTest do
dismiss_intent: :close_details
} = summary.details_panel
+ assert %{
+ family: :layer_shell_and_callout,
+ kind: :composer_query_preview,
+ composer_id: "composer-main",
+ query: "release blockers",
+ preview_state: :ready,
+ open_intent: :open_query_preview,
+ save_intent: :save_query,
+ dismiss_intent: :dismiss_query_preview
+ } = summary.query_preview
+
assert %{
family: :redline_and_code,
kind: :code_block_syntax_highlighted,
@@ -316,6 +345,18 @@ defmodule UnifiedUi.OperationalWidgetComponentsTest do
assert panel_message =~ "must remain non-modal"
+ assert {:error, [:composition, :composer_query_preview, :bad_query_preview],
+ query_preview_message} =
+ ValidateWidgetComponents.validate_node(%Node{
+ kind: :composer_query_preview,
+ id: :bad_query_preview,
+ composer_id: "composer-main",
+ query: "status",
+ preview_state: :ready
+ })
+
+ assert query_preview_message =~ "explanation is required"
+
assert {:error, [:composition, :redline_inline, :bad_redline], redline_message} =
ValidateWidgetComponents.validate_node(%Node{
kind: :redline_inline,
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 9deadc18..2c7c2bdb 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
@@ -46,7 +46,8 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do
:sidebar_section,
:sidebar_item,
:right_rail,
- :command_palette
+ :command_palette,
+ :composer_query_preview
],
redline_and_code: [:redline_inline, :code_block_syntax_highlighted],
composition_behavior: [:list_repeat]
@@ -64,6 +65,7 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do
assert :right_rail in kinds
assert :unread_badge in kinds
assert :command_palette in kinds
+ assert :composer_query_preview 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 7f9e6d77..4767a8d5 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
@@ -117,6 +117,18 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do
action_intent(:inspect_event)
end
+ composer_query_preview :query_preview do
+ composer_id("composer-main")
+ query("release blockers")
+ preview_state(:ready)
+ explanation("Two release checks need attention.")
+ metrics(%{results_count: 2, duration_ms: 34, sources_visited: 4})
+ findings([%{id: "finding-1", n: 1, snippet: "CI is still pending.", confidence: 0.82}])
+ open_intent(:open_query_preview)
+ save_intent(:save_query)
+ dismiss_intent(:dismiss_query_preview)
+ end
+
right_rail :workspace_rail do
side(:right)
@@ -160,6 +172,7 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do
artifact = Tree.find_by_id(iur, :artifact)
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)
rail = Tree.find_by_id(iur, :workspace_rail)
code = Tree.find_by_id(iur, :code_sample)
@@ -227,6 +240,27 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do
dismiss_intent: :close_panel
}
+ assert query_preview.attributes.component == %{
+ family: :layer_shell_and_callout,
+ kind: :composer_query_preview
+ }
+
+ assert query_preview.attributes.query_preview == %{
+ composer_id: "composer-main",
+ query: "release blockers",
+ preview_state: :ready,
+ explanation: "Two release checks need attention.",
+ metrics: %{results_count: 2, duration_ms: 34, sources_visited: 4},
+ findings: [
+ %{id: "finding-1", n: 1, snippet: "CI is still pending.", confidence: 0.82}
+ ],
+ max_findings_shown: 2,
+ loading_label: "Searching",
+ empty_label: "No results for this query.",
+ open_label: "Open query",
+ save_label: "Save query"
+ }
+
assert rail.attributes.component == %{
family: :layer_shell_and_callout,
kind: :right_rail
@@ -265,6 +299,7 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do
stepper = Tree.find_by_id(iur, :release_steps)
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)
rail = Tree.find_by_id(iur, :workspace_rail)
assert [%Interaction{family: :selection, intent: :select_status} = selection] =
@@ -306,6 +341,12 @@ defmodule UnifiedUi.WidgetComponentsCompilerLoweringTest do
assert [%Interaction{family: :click, intent: :inspect_event}] =
callout.attributes.interactions
+ assert Enum.map(query_preview.attributes.interactions, &{&1.family, &1.intent}) == [
+ {:close, :dismiss_query_preview},
+ {:open, :open_query_preview},
+ {:command, :save_query}
+ ]
+
assert [
%Interaction{family: :selection, intent: :select_rail_panel} = panel_select,
%Interaction{family: :change, intent: :toggle_rail} = collapse_change
diff --git a/specs/contracts/canonical_widget_components_contract.md b/specs/contracts/canonical_widget_components_contract.md
index ebac237c..196bc9e9 100644
--- a/specs/contracts/canonical_widget_components_contract.md
+++ b/specs/contracts/canonical_widget_components_contract.md
@@ -39,6 +39,7 @@ Ash UI MUST adopt these canonical widget-component kinds:
| `meter_thin` | workflow progress and status | - |
| `slide_over_panel` | layer shell and callout | - |
| `event_callout` | layer shell and callout | - |
+| `composer_query_preview` | layer shell and callout | - |
| `redline_inline` | redline and code | - |
| `code_block_syntax_highlighted` | redline and code | - |
| `chat_composer` | form control and composer | - |
diff --git a/test/ash_ui/phase_31_canonical_conversion_test.exs b/test/ash_ui/phase_31_canonical_conversion_test.exs
index 6fe723b8..2839caec 100644
--- a/test/ash_ui/phase_31_canonical_conversion_test.exs
+++ b/test/ash_ui/phase_31_canonical_conversion_test.exs
@@ -43,6 +43,17 @@ defmodule AshUI.Phase31CanonicalConversionTest do
{:sticky_frosted_header, :layer_shell_and_callout, %{title: "Workspace"}, :shell},
{:slide_over_panel, :layer_shell_and_callout, %{label: "Details", open?: true}, :panel},
{:event_callout, :layer_shell_and_callout, %{message: "Deployment paused"}, :callout},
+ {:composer_query_preview, :layer_shell_and_callout,
+ %{
+ composer_id: "composer-main",
+ query: "release blockers",
+ preview_state: :ready,
+ explanation: "Three likely blockers found.",
+ metrics: %{results_count: 3, duration_ms: 42, sources_visited: 8},
+ findings: [
+ %{id: "finding-1", n: 1, snippet: "Conformance missing", confidence: 0.91}
+ ]
+ }, :query_preview},
{: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 2ef5a65a..ad21d44d 100644
--- a/test/ash_ui/phase_31_package_boundary_test.exs
+++ b/test/ash_ui/phase_31_package_boundary_test.exs
@@ -64,7 +64,8 @@ defmodule AshUI.Phase31PackageBoundaryTest do
:sidebar_section,
:sidebar_item,
:right_rail,
- :command_palette
+ :command_palette,
+ :composer_query_preview
]
assert families.redline_and_code == [:redline_inline, :code_block_syntax_highlighted]
diff --git a/test/ash_ui/phase_33_resource_admission_test.exs b/test/ash_ui/phase_33_resource_admission_test.exs
new file mode 100644
index 00000000..55a29bd3
--- /dev/null
+++ b/test/ash_ui/phase_33_resource_admission_test.exs
@@ -0,0 +1,65 @@
+defmodule AshUI.Phase33ResourceAdmissionTest do
+ use ExUnit.Case, async: true
+
+ alias AshUI.DSL.Storage
+ alias AshUI.Resources.Validations.Authoring
+
+ @moduletag :conformance
+
+ describe "Section 33 - composer query preview admission" do
+ test "resource and persisted DSL authoring admit composer_query_preview" do
+ definition = %{
+ type: :composer_query_preview,
+ props: %{composer_id: "composer-main", query: "release blockers"},
+ variants: [],
+ metadata: %{}
+ }
+
+ assert Authoring.validate_element_definition!(definition) == definition
+
+ assert Storage.canonical_widget_type(:composer_query_preview) ==
+ {:ok, "composer_query_preview"}
+
+ assert Storage.validate_write(%{
+ type: "composer_query_preview",
+ props: %{"composer_id" => "composer-main", "query" => "release blockers"},
+ children: [],
+ signals: [],
+ metadata: %{}
+ }) == :ok
+ end
+
+ test "composer_query_preview exposes click-based action admission" do
+ definition = %{type: :composer_query_preview, props: %{}, variants: [], metadata: %{}}
+
+ assert :ok =
+ Authoring.validate_element_authority!(
+ definition,
+ [],
+ [
+ %{
+ id: :open_query_preview,
+ signal: :click,
+ source: "query_preview",
+ target: "preview"
+ }
+ ]
+ )
+
+ assert_raise ArgumentError, ~r/supported signals/, fn ->
+ Authoring.validate_element_authority!(
+ definition,
+ [],
+ [
+ %{
+ id: :submit_query_preview,
+ signal: :submit,
+ source: "query_preview",
+ target: "preview"
+ }
+ ]
+ )
+ end
+ end
+ end
+end
diff --git a/test/ash_ui/rendering/live_ui_adapter_test.exs b/test/ash_ui/rendering/live_ui_adapter_test.exs
index dfc5bb91..05bc55a8 100644
--- a/test/ash_ui/rendering/live_ui_adapter_test.exs
+++ b/test/ash_ui/rendering/live_ui_adapter_test.exs
@@ -1241,6 +1241,74 @@ defmodule AshUI.Rendering.LiveUIAdapterTest do
end
end
+ describe "composer_query_preview adapter dispatch" do
+ test "generates dedicated composer_query_preview fallback markup" do
+ iur = %{
+ "type" => "composer_query_preview",
+ "id" => "query-preview-adapter-test",
+ "props" => %{
+ "query_preview" => %{
+ "composer_id" => "composer-main",
+ "query" => "release blockers",
+ "preview_state" => "ready",
+ "explanation" => "Three likely blockers found.",
+ "metrics" => %{
+ "results_count" => 3,
+ "duration_ms" => 42,
+ "sources_visited" => 8
+ },
+ "findings" => [
+ %{
+ "id" => "finding-1",
+ "n" => 1,
+ "snippet" => "Conformance missing",
+ "confidence" => 0.91
+ }
+ ]
+ }
+ },
+ "children" => [],
+ "metadata" => %{}
+ }
+
+ {:ok, heex} = LiveUIAdapter.render(iur, force_fallback: true)
+
+ assert heex =~ "ash-composer-query-preview"
+ assert heex =~ ~s(data-live-ui-widget="composer-query-preview")
+ assert heex =~ ~s(data-composer-id="composer-main")
+ assert heex =~ "Three likely blockers found."
+ assert heex =~ "Conformance missing"
+ assert heex =~ "3"
+ assert heex =~ "0.04s"
+ refute heex =~ "data-live-ui-intent"
+ refute heex =~ "open in Ask"
+ end
+
+ test "escapes query preview fallback content" do
+ iur = %{
+ "type" => "composer_query_preview",
+ "id" => "query-preview-escape-test",
+ "props" => %{
+ "query_preview" => %{
+ "composer_id" => "composer-main",
+ "query" => "",
+ "preview_state" => "error",
+ "error_message" => "bad"
+ }
+ },
+ "children" => [],
+ "metadata" => %{}
+ }
+
+ {:ok, heex} = LiveUIAdapter.render(iur, force_fallback: true)
+
+ assert heex =~ "<script>alert(1)</script>"
+ assert heex =~ "<b>bad</b>"
+ refute heex =~ "