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 b6aa03ae..6148d972 100644
--- a/guides/user/UG-0003-widget-types-properties-and-signals.md
+++ b/guides/user/UG-0003-widget-types-properties-and-signals.md
@@ -64,6 +64,7 @@ These are the types currently accepted by `ui_element` validation.
| `hero` | Large intro section | `eyebrow`, `title`, `message` |
| `badge` | Small status label | `presentation`, `text` or `label` or `content` |
| `confidence_indicator` | Confidence score or quality meter | `value`, `thresholds`, `label`, `show_numeric?`, `show_glyph?`, `size` |
+| `diff_banner` | Comparison summary and filter chips | `new_count`, `changed_count`, `removed_count`, `active_filter`, `base_label`, `size` |
| `stat` | Metric card | `title`, `value`, `message` |
| `key_value` | Definition row | `label` or `title`, `value`, `description` |
| `info_list` | Simple list of labeled values | `items`, optional `ordered?` |
@@ -232,6 +233,7 @@ Signal support is type-specific.
| `switch` | `:change`, `:toggle` |
| `slider` | `:change`, `:input` |
| `form_builder` | `:submit` |
+| `diff_banner` | `:change`, `:input` |
| `right_rail` | `:change`, `:toggle`, `:click` |
If you declare a signal outside this matrix, authoring validation raises.
@@ -262,6 +264,7 @@ The current action-binding-capable widgets are:
- `runtime_form_shell`
- `segmented_button_group`
- `chat_composer`
+- `diff_banner`
- `context_selector`
- `right_rail`
@@ -298,7 +301,7 @@ you want to inspect the maintained behavior for each major family:
| layout and navigation | `row`, `column`, `grid`, `menu`, `tabs`, `context_selector`, `command_palette` | relationship-first composition with exact and custom navigation shells |
| display and inspection | `viewport`, `scroll_bar`, `split_pane`, `canvas` | example-only display shells that keep state changes on nested public widgets |
| overlays and data surfaces | `dialog`, `alert_dialog`, `table`, `tree_view`, `markdown_viewer`, `log_viewer` | composed review shells, explicit custom boundaries, and seeded screen persistence |
-| feedback and operational surfaces | `status`, `progress`, `sparkline`, `bar_chart`, `cluster_dashboard` | normalized status treatment, custom chart shells, and runtime-rich operational review stories |
+| feedback and operational surfaces | `status`, `progress`, `diff_banner`, `confidence_indicator`, `sparkline`, `bar_chart`, `cluster_dashboard` | normalized status treatment, comparison feedback, custom chart shells, and runtime-rich operational review stories |
If a directory name differs from the canonical Ash UI type, treat the example
directory as the review handle and the canonical type as the runtime authoring
diff --git a/lib/ash_ui/dsl/storage.ex b/lib/ash_ui/dsl/storage.ex
index 9c0f4070..4bbbfb84 100644
--- a/lib/ash_ui/dsl/storage.ex
+++ b/lib/ash_ui/dsl/storage.ex
@@ -27,6 +27,7 @@ defmodule AshUI.DSL.Storage do
"hero",
"badge",
"confidence_indicator",
+ "diff_banner",
"stat",
"key_value",
"info_list",
diff --git a/lib/ash_ui/rendering/iur_adapter.ex b/lib/ash_ui/rendering/iur_adapter.ex
index 00381903..d43e5bbd 100644
--- a/lib/ash_ui/rendering/iur_adapter.ex
+++ b/lib/ash_ui/rendering/iur_adapter.ex
@@ -693,6 +693,22 @@ defmodule AshUI.Rendering.IURAdapter do
defp base_attributes(:inline_feedback, props),
do: %{feedback: Map.drop(props, attachment_prop_keys())}
+ defp base_attributes(:diff_banner, props) do
+ %{
+ diff:
+ compact_map(%{
+ new_count: normalize_count(fetch(props, :new_count, 0)),
+ changed_count: normalize_count(fetch(props, :changed_count, 0)),
+ removed_count: normalize_count(fetch(props, :removed_count, 0)),
+ base_label: first_present(props, [:base_label]),
+ active_filter: normalize_existing_atom(first_present(props, [:active_filter])) || :all,
+ show_filter_chips?: boolean_present(props, [:show_filter_chips?], true),
+ size: normalize_existing_atom(first_present(props, [:size])) || :default,
+ filter_intent: first_present(props, [:filter_intent, :selection_intent])
+ })
+ }
+ end
+
defp base_attributes(:confidence_indicator, props) do
confidence =
%{
@@ -1067,6 +1083,18 @@ defmodule AshUI.Rendering.IURAdapter do
defp context_selector_multiple?(value) when is_integer(value), do: value > 1
defp context_selector_multiple?(_value), do: false
+ defp normalize_count(value) when is_integer(value) and value >= 0, do: value
+ defp normalize_count(value) when is_float(value) and value >= 0, do: round(value)
+
+ defp normalize_count(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {parsed, ""} when parsed >= 0 -> parsed
+ _other -> 0
+ end
+ end
+
+ defp normalize_count(_value), do: 0
+
defp normalize_optional_map(nil), do: nil
defp normalize_optional_map(value) do
diff --git a/lib/ash_ui/rendering/live_ui_adapter.ex b/lib/ash_ui/rendering/live_ui_adapter.ex
index 6b9fb9e5..afff5ab8 100644
--- a/lib/ash_ui/rendering/live_ui_adapter.ex
+++ b/lib/ash_ui/rendering/live_ui_adapter.ex
@@ -756,6 +756,50 @@ defmodule AshUI.Rendering.LiveUIAdapter do
"""
end
+ defp generate_heex(%{"type" => "diff_banner"} = iur, _opts) do
+ props = iur["props"] || %{}
+ diff = if is_map(prop(props, "diff")), do: prop(props, "diff", %{}), else: props
+ new_count = prop(diff, "new_count", 0)
+ changed_count = prop(diff, "changed_count", 0)
+ removed_count = prop(diff, "removed_count", 0)
+
+ total_count =
+ numeric_count(new_count) + numeric_count(changed_count) + numeric_count(removed_count)
+
+ active_filter = text_prop(diff, "active_filter", "all")
+ size = text_prop(diff, "size", "default")
+ base_label = escaped_text_prop(diff, ["base_label", "base"])
+
+ chips =
+ [
+ {"all", total_count},
+ {"new", new_count},
+ {"changed", changed_count},
+ {"removed", removed_count}
+ ]
+ |> Enum.map_join("\n ", fn {kind, count} ->
+ active_class = if active_filter == kind, do: " ash-diff-banner__chip--active", else: ""
+
+ ~s(#{html_escape(count)} #{kind})
+ end)
+
+ base_html =
+ if base_label && size != "compact" do
+ ~s(#{base_label})
+ else
+ ""
+ end
+
+ """
+
+ """
+ end
+
defp generate_heex(%{"type" => "ask_sidebar"} = iur, _opts) do
props = iur["props"] || %{}
sidebar_id = escaped_text_prop(props, ["sidebar_id"], "")
@@ -2973,6 +3017,18 @@ defmodule AshUI.Rendering.LiveUIAdapter do
defp confidence_number(_value, default), do: default
+ defp numeric_count(value) when is_integer(value), do: value
+ defp numeric_count(value) when is_float(value), do: round(value)
+
+ defp numeric_count(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {parsed, _rest} -> parsed
+ :error -> 0
+ end
+ end
+
+ defp numeric_count(_value), do: 0
+
defp metric_model(props) do
case prop(props, "model", %{}) do
%{} = model -> model
diff --git a/lib/ash_ui/resources/validations/authoring.ex b/lib/ash_ui/resources/validations/authoring.ex
index c85e2dfa..b8b7fbb6 100644
--- a/lib/ash_ui/resources/validations/authoring.ex
+++ b/lib/ash_ui/resources/validations/authoring.ex
@@ -35,6 +35,7 @@ defmodule AshUI.Resources.Validations.Authoring do
"runtime_form_shell",
"segmented_button_group",
"chat_composer",
+ "diff_banner",
"context_selector",
"right_rail",
"workflow_progress_status_card"
@@ -53,6 +54,7 @@ defmodule AshUI.Resources.Validations.Authoring do
"runtime_form_shell" => [:submit],
"segmented_button_group" => [:change, :input],
"chat_composer" => [:change, :input, :submit],
+ "diff_banner" => [:change, :input],
"context_selector" => [:change, :input],
"right_rail" => [:change, :toggle, :click],
"workflow_progress_status_card" => [:click, :change]
diff --git a/packages/desktop_ui/lib/desktop_ui/renderer.ex b/packages/desktop_ui/lib/desktop_ui/renderer.ex
index ddf3a647..5e31e4df 100644
--- a/packages/desktop_ui/lib/desktop_ui/renderer.ex
+++ b/packages/desktop_ui/lib/desktop_ui/renderer.ex
@@ -23,7 +23,7 @@ defmodule DesktopUi.Renderer do
end
@doc """
- All 47 canonical IUR widget kinds supported by the desktop_ui renderer.
+ All 48 canonical IUR widget kinds supported by the desktop_ui renderer.
Each kind has dedicated native widget mapping, draw kind handling, and
SDL3 rendering implementation.
"""
@@ -33,7 +33,7 @@ defmodule DesktopUi.Renderer do
# Input (10)
# Navigation (5)
# Data (7)
- # Feedback (6)
+ # Feedback (7)
# Operational (7)
# Visualization (6)
# Layout & Structure (3)
@@ -76,6 +76,7 @@ defmodule DesktopUi.Renderer do
:tree_view,
:alert_dialog,
:dialog,
+ :diff_banner,
:inline_feedback,
:progress,
:status,
diff --git a/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex b/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex
index 53ac7a53..4903396d 100644
--- a/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex
+++ b/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex
@@ -819,6 +819,27 @@ defmodule DesktopUi.Renderer.Mapper do
)}
end
+ defp map_element(%Element{type: :widget, kind: kind} = element)
+ when kind in [:diff_banner, "diff_banner"] do
+ diff = attr(element, :diff) || %{}
+
+ {:ok,
+ DesktopUi.Widgets.diff_banner(
+ element.id,
+ Keyword.merge(
+ base_opts(element),
+ new_count: map_attr(diff, :new_count, 0),
+ changed_count: map_attr(diff, :changed_count, 0),
+ removed_count: map_attr(diff, :removed_count, 0),
+ base_label: map_attr(diff, :base_label, nil),
+ active_filter: map_attr(diff, :active_filter, :all),
+ show_filter_chips?: map_attr(diff, :show_filter_chips?, true),
+ size: map_attr(diff, :size, :default),
+ on_filter: diff_banner_filter_payload(element, diff)
+ )
+ )}
+ end
+
defp map_element(%Element{type: :widget, kind: kind} = element)
when kind in [:bar_chart, "bar_chart"] do
{:ok,
@@ -1489,6 +1510,14 @@ defmodule DesktopUi.Renderer.Mapper do
(is_integer(max_selections) and max_selections > 1)
end
+ defp diff_banner_filter_payload(element, diff) do
+ interaction_payload(element, :selection) ||
+ case map_attr(diff, :filter_intent, nil) do
+ nil -> nil
+ intent -> %{family: :selection, intent: intent, mapping: %{selected_value: :filter}}
+ end
+ end
+
defp map_attr(map, key, default) when is_map(map),
do: Map.get(map, key, Map.get(map, to_string(key), default))
diff --git a/packages/desktop_ui/lib/desktop_ui/validate.ex b/packages/desktop_ui/lib/desktop_ui/validate.ex
index 75e190fb..d972615c 100644
--- a/packages/desktop_ui/lib/desktop_ui/validate.ex
+++ b/packages/desktop_ui/lib/desktop_ui/validate.ex
@@ -82,7 +82,7 @@ defmodule DesktopUi.Validate do
:renderer_supports_all_iur_kinds,
iur_widget_coverage_complete?(),
%{
- expected_count: 47,
+ expected_count: 48,
actual_count: length(DesktopUi.Renderer.supported_kinds()),
supported_kinds: DesktopUi.Renderer.supported_kinds()
}
@@ -822,10 +822,10 @@ defmodule DesktopUi.Validate do
defp traceability_includes_direct_prefix?(_result, _prefix), do: false
- # Verifies that all 47 canonical IUR widget kinds are supported by the renderer.
+ # Verifies that all 48 canonical IUR widget kinds are supported by the renderer.
# Returns true when the renderer supports the complete set of IUR widget kinds.
defp iur_widget_coverage_complete? do
- expected_count = 47
+ expected_count = 48
actual_count = length(DesktopUi.Renderer.supported_kinds())
actual_count >= expected_count and
diff --git a/packages/desktop_ui/lib/desktop_ui/widget.ex b/packages/desktop_ui/lib/desktop_ui/widget.ex
index bae667bb..4a1b1af3 100644
--- a/packages/desktop_ui/lib/desktop_ui/widget.ex
+++ b/packages/desktop_ui/lib/desktop_ui/widget.ex
@@ -80,7 +80,9 @@ defmodule DesktopUi.Widget do
:progress,
:severity,
:streaming,
- :paused
+ :paused,
+ :active_filter,
+ :size
],
bindings: [
:value,
@@ -137,7 +139,13 @@ defmodule DesktopUi.Widget do
:max_selections,
:multiple,
:multiple?,
- :label_prefix
+ :label_prefix,
+ :new_count,
+ :changed_count,
+ :removed_count,
+ :base_label,
+ :show_filter_chips?,
+ :chips
],
styles: [
:fg,
@@ -264,7 +272,7 @@ defmodule DesktopUi.Widget do
def family_for(kind) when kind in [:menu, :tabs, :breadcrumbs, :list, :context_selector],
do: :navigation
- def family_for(kind) when kind in [:status, :confidence_indicator], do: :feedback
+ def family_for(kind) when kind in [:status, :diff_banner, :confidence_indicator], do: :feedback
def family_for(_kind), do: :content
defp normalize_map(nil), do: %{}
diff --git a/packages/desktop_ui/lib/desktop_ui/widgets.ex b/packages/desktop_ui/lib/desktop_ui/widgets.ex
index c100adff..16efd0cc 100644
--- a/packages/desktop_ui/lib/desktop_ui/widgets.ex
+++ b/packages/desktop_ui/lib/desktop_ui/widgets.ex
@@ -315,6 +315,11 @@ defmodule DesktopUi.Widgets do
Feedback.progress(id, opts)
end
+ @spec diff_banner(String.t() | atom(), keyword()) :: Widget.t()
+ def diff_banner(id, opts \\ []) do
+ Feedback.diff_banner(id, opts)
+ end
+
@spec sparkline(String.t() | atom(), keyword()) :: Widget.t()
def sparkline(id, opts \\ []) do
Feedback.sparkline(id, opts)
diff --git a/packages/desktop_ui/lib/desktop_ui/widgets/feedback.ex b/packages/desktop_ui/lib/desktop_ui/widgets/feedback.ex
index b90c8bb6..8ab7f8f0 100644
--- a/packages/desktop_ui/lib/desktop_ui/widgets/feedback.ex
+++ b/packages/desktop_ui/lib/desktop_ui/widgets/feedback.ex
@@ -7,7 +7,16 @@ defmodule DesktopUi.Widgets.Feedback do
@spec kinds() :: [atom()]
def kinds do
- [:alert_dialog, :dialog, :inline_feedback, :progress, :sparkline, :status, :toast]
+ [
+ :alert_dialog,
+ :dialog,
+ :diff_banner,
+ :inline_feedback,
+ :progress,
+ :sparkline,
+ :status,
+ :toast
+ ]
end
@spec sparkline(String.t() | atom(), keyword()) :: Widget.t()
@@ -55,7 +64,8 @@ defmodule DesktopUi.Widgets.Feedback do
auto_hide: Keyword.get(opts, :auto_hide, true),
timeout_ms: Keyword.get(opts, :timeout_ms, 3_000)
},
- events: events(close: Keyword.get(opts, :on_close), dismiss: Keyword.get(opts, :on_dismiss)),
+ events:
+ events(close: Keyword.get(opts, :on_close), dismiss: Keyword.get(opts, :on_dismiss)),
styles: styles(opts)
)
end
@@ -167,6 +177,29 @@ defmodule DesktopUi.Widgets.Feedback do
)
end
+ @spec diff_banner(String.t() | atom(), keyword()) :: Widget.t()
+ def diff_banner(id, opts \\ []) do
+ Widget.new(:diff_banner,
+ id: id,
+ metadata: metadata(opts, role: :radiogroup),
+ state:
+ state(opts,
+ active_filter: Keyword.get(opts, :active_filter, :all),
+ size: Keyword.get(opts, :size, :default)
+ ),
+ attributes: %{
+ new_count: Keyword.get(opts, :new_count, 0),
+ changed_count: Keyword.get(opts, :changed_count, 0),
+ removed_count: Keyword.get(opts, :removed_count, 0),
+ base_label: Keyword.get(opts, :base_label),
+ show_filter_chips?: Keyword.get(opts, :show_filter_chips?, true),
+ chips: Keyword.get(opts, :chips, [])
+ },
+ events: events(filter: Keyword.get(opts, :on_filter)),
+ styles: styles(opts)
+ )
+ end
+
defp metadata(opts, defaults),
do: defaults |> Keyword.merge(Keyword.get(opts, :metadata, [])) |> Map.new()
diff --git a/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs b/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs
index 8fca12b5..58be0c90 100644
--- a/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs
+++ b/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs
@@ -8,7 +8,7 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do
@moduletag :phase_eleven
- @iur_widget_count 47
+ @iur_widget_count 48
@all_iur_kinds MapSet.new([
# Foundational (13)
@@ -50,9 +50,10 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do
:stat,
:table,
:tree_view,
- # Feedback (6)
+ # Feedback (7)
:alert_dialog,
:dialog,
+ :diff_banner,
:inline_feedback,
:progress,
:status,
@@ -281,7 +282,7 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do
test "canonical mapper supports all feedback and advanced widget kinds" do
supported = Renderer.supported_kinds() |> MapSet.new()
- feedback_kinds = [:status, :inline_feedback, :progress]
+ feedback_kinds = [:status, :inline_feedback, :progress, :diff_banner]
advanced_kinds = [:stream_widget, :supervision_tree_viewer, :log_viewer, :process_monitor]
Enum.each(feedback_kinds ++ advanced_kinds, fn kind ->
@@ -292,17 +293,17 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do
end
describe "11.5 Mapper coverage and diagnostics scenarios" do
- test "canonical mapper handles all 46 IUR widget kinds without fallback" do
+ test "canonical mapper handles all 48 IUR widget kinds without fallback" do
supported = Renderer.supported_kinds() |> MapSet.new()
# All IUR widget kinds should be supported
missing_kinds = MapSet.difference(@all_iur_kinds, supported)
assert MapSet.size(missing_kinds) == 0,
- "Expected all 47 IUR kinds to be supported, but missing: #{inspect(MapSet.to_list(missing_kinds))}"
+ "Expected all 48 IUR kinds to be supported, but missing: #{inspect(MapSet.to_list(missing_kinds))}"
end
- test "renderer.supported_kinds returns at least 47 kinds" do
+ test "renderer.supported_kinds returns at least 48 kinds" do
count = length(Renderer.supported_kinds())
assert count >= @iur_widget_count,
diff --git a/packages/desktop_ui/test/desktop_ui/renderer_test.exs b/packages/desktop_ui/test/desktop_ui/renderer_test.exs
index 26e5d0ee..2cdead05 100644
--- a/packages/desktop_ui/test/desktop_ui/renderer_test.exs
+++ b/packages/desktop_ui/test/desktop_ui/renderer_test.exs
@@ -67,6 +67,27 @@ defmodule DesktopUi.RendererTest do
assert widget.attributes.label == "Match confidence"
end
+ test "renderer maps diff_banner as a feedback widget" do
+ element =
+ Feedback.diff_banner(
+ id: "ask-diff",
+ new_count: 3,
+ changed_count: 5,
+ removed_count: 1,
+ active_filter: :changed,
+ filter_intent: :filter_diff
+ )
+
+ assert {:ok, widget} = DesktopUi.Renderer.render(element)
+ assert widget.kind == :diff_banner
+ assert widget.family == :feedback
+ assert widget.metadata.role == :radiogroup
+ assert widget.attributes.new_count == 3
+ assert widget.attributes.changed_count == 5
+ assert widget.attributes.removed_count == 1
+ assert widget.state.active_filter == :changed
+ end
+
test "renderer maps context_selector as a navigation widget" do
element =
Navigation.context_selector(
diff --git a/packages/elm_ui/lib/elm_ui/renderer/canonical.ex b/packages/elm_ui/lib/elm_ui/renderer/canonical.ex
index f99952ae..c2b0227b 100644
--- a/packages/elm_ui/lib/elm_ui/renderer/canonical.ex
+++ b/packages/elm_ui/lib/elm_ui/renderer/canonical.ex
@@ -616,6 +616,25 @@ defmodule ElmUi.Renderer.Canonical do
)}
end
+ defp do_render(%Element{type: :widget, kind: kind} = element)
+ when kind in [:diff_banner, "diff_banner"] do
+ diff = map_attr(element, :diff)
+
+ {:ok,
+ Widgets.diff_banner(
+ element.id,
+ Keyword.merge(base_opts(element),
+ new_count: map_get(diff, :new_count, 0),
+ changed_count: map_get(diff, :changed_count, 0),
+ removed_count: map_get(diff, :removed_count, 0),
+ base_label: map_get(diff, :base_label),
+ active_filter: map_get(diff, :active_filter, :all),
+ show_filter_chips?: map_get(diff, :show_filter_chips?, true),
+ size: map_get(diff, :size, :default)
+ )
+ )}
+ end
+
defp do_render(%Element{type: :widget, kind: kind} = element)
when kind in [:gauge, "gauge"] do
{:ok,
diff --git a/packages/elm_ui/lib/elm_ui/widget.ex b/packages/elm_ui/lib/elm_ui/widget.ex
index b0e55a48..84f39bb0 100644
--- a/packages/elm_ui/lib/elm_ui/widget.ex
+++ b/packages/elm_ui/lib/elm_ui/widget.ex
@@ -224,7 +224,9 @@ defmodule ElmUi.Widget do
do: :data
def family_for(kind) when kind in [:markdown_viewer, :log_viewer], do: :document
- def family_for(kind) when kind in [:status, :progress, :inline_feedback], do: :feedback
+
+ def family_for(kind) when kind in [:status, :progress, :inline_feedback, :diff_banner],
+ do: :feedback
def family_for(kind)
when kind in [:gauge, :sparkline, :bar_chart, :line_chart, :canvas],
diff --git a/packages/elm_ui/lib/elm_ui/widgets.ex b/packages/elm_ui/lib/elm_ui/widgets.ex
index 2bd2cc74..3526f6e8 100644
--- a/packages/elm_ui/lib/elm_ui/widgets.ex
+++ b/packages/elm_ui/lib/elm_ui/widgets.ex
@@ -400,6 +400,11 @@ defmodule ElmUi.Widgets do
Feedback.inline_feedback(id, message, opts)
end
+ @spec diff_banner(String.t() | atom(), keyword()) :: Widget.t()
+ def diff_banner(id, opts \\ []) do
+ Feedback.diff_banner(id, opts)
+ end
+
@spec gauge(String.t() | atom(), keyword()) :: Widget.t()
def gauge(id, opts \\ []) do
Visualization.gauge(id, opts)
diff --git a/packages/elm_ui/lib/elm_ui/widgets/feedback.ex b/packages/elm_ui/lib/elm_ui/widgets/feedback.ex
index 212f02d3..fbecf1a7 100644
--- a/packages/elm_ui/lib/elm_ui/widgets/feedback.ex
+++ b/packages/elm_ui/lib/elm_ui/widgets/feedback.ex
@@ -6,7 +6,7 @@ defmodule ElmUi.Widgets.Feedback do
alias ElmUi.Widgets.Builder
- @kinds [:status, :progress, :inline_feedback]
+ @kinds [:status, :progress, :inline_feedback, :diff_banner]
@spec kinds() :: [atom()]
def kinds, do: @kinds
@@ -67,4 +67,27 @@ defmodule ElmUi.Widgets.Feedback do
metadata: Builder.metadata(opts, %{native_surface: :feedback})
)
end
+
+ @spec diff_banner(String.t() | atom(), keyword() | map()) :: ElmUi.Widget.t()
+ def diff_banner(id, opts \\ []) do
+ opts = Builder.options(Map.put(Builder.options(opts), :id, id))
+
+ Builder.widget(:diff_banner,
+ id: id,
+ attributes: %{
+ new_count: Builder.option(opts, :new_count, 0),
+ changed_count: Builder.option(opts, :changed_count, 0),
+ removed_count: Builder.option(opts, :removed_count, 0),
+ base_label: Builder.option(opts, :base_label),
+ show_filter_chips?: Builder.option(opts, :show_filter_chips?, true)
+ },
+ state: %{
+ active_filter: Builder.option(opts, :active_filter, :all),
+ size: Builder.option(opts, :size, :default)
+ },
+ events: Builder.events(opts, on_filter: :selection),
+ styles: Builder.styles(opts),
+ metadata: Builder.metadata(opts, %{native_surface: :feedback})
+ )
+ end
end
diff --git a/packages/elm_ui/test/elm_ui/renderer_test.exs b/packages/elm_ui/test/elm_ui/renderer_test.exs
index e11ffc74..a38b1d28 100644
--- a/packages/elm_ui/test/elm_ui/renderer_test.exs
+++ b/packages/elm_ui/test/elm_ui/renderer_test.exs
@@ -2,6 +2,7 @@ defmodule ElmUi.RendererTest do
use ExUnit.Case, async: true
alias UnifiedIUR.Element
+ alias UnifiedIUR.Widgets.Feedback
alias UnifiedIUR.Widgets.Navigation
alias ElmUi.Renderer.Error
alias ElmUi.Widget
@@ -46,6 +47,25 @@ defmodule ElmUi.RendererTest do
assert [%{label: "Workspace", items: [%{label: "All workspaces"}]}] = widget.attributes.groups
end
+ test "renderer maps diff_banner as a native feedback widget" do
+ element =
+ Feedback.diff_banner(
+ id: "ask-diff",
+ new_count: 3,
+ changed_count: 5,
+ removed_count: 1,
+ active_filter: :new
+ )
+
+ assert {:ok, %Widget{} = widget} = ElmUi.Renderer.render(element)
+ assert widget.kind == :diff_banner
+ assert widget.family == :feedback
+ assert widget.attributes.new_count == 3
+ assert widget.attributes.changed_count == 5
+ assert widget.attributes.removed_count == 1
+ assert widget.state.active_filter == :new
+ end
+
test "renderer rejects unsupported canonical kinds with structured diagnostics" do
element =
Element.new(:widget, :timeline, id: :timeline_root, attributes: %{title: "Unsupported"})
diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex
index 38b82f47..174a61d0 100644
--- a/packages/live_ui/lib/live_ui/renderer.ex
+++ b/packages/live_ui/lib/live_ui/renderer.ex
@@ -36,6 +36,7 @@ defmodule LiveUi.Renderer do
:context_selector,
:context_menu,
:date_input,
+ :diff_banner,
:dialog,
:disclosure,
:field,
@@ -336,6 +337,35 @@ defmodule LiveUi.Renderer do
"""
end
+ def render(%{element: %Element{kind: :diff_banner}} = assigns) do
+ diff = diff_banner_attributes(assigns.element)
+
+ assigns =
+ assigns
+ |> assign(:diff, diff)
+ |> assign(:chips, diff_banner_chips(assigns.element, Map.get(assigns, :event_target)))
+ |> assign(:style_attrs, style_rest(assigns.element))
+
+ ~H"""
+
+ """
+ end
+
def render(%{element: %Element{kind: :context_selector}} = assigns) do
context_selector = context_selector_attributes(assigns.element)
@@ -1911,6 +1941,75 @@ defmodule LiveUi.Renderer do
"Segmented control"
end
+ defp diff_banner_attributes(%Element{} = element) do
+ element.attributes
+ |> Map.get(:diff, Map.get(element.attributes, "diff", %{}))
+ |> normalize_panel()
+ end
+
+ defp diff_banner_chips(%Element{} = element, event_target) do
+ diff = diff_banner_attributes(element)
+
+ [
+ {:all, "all", diff_banner_total(diff)},
+ {:new, "new", map_value(diff, :new_count, 0)},
+ {:changed, "changed", map_value(diff, :changed_count, 0)},
+ {:removed, "removed", map_value(diff, :removed_count, 0)}
+ ]
+ |> Enum.map(fn {kind, label, count} ->
+ %{
+ kind: kind,
+ label: label,
+ count: count,
+ attrs: diff_banner_chip_attrs(element, event_target, kind)
+ }
+ end)
+ end
+
+ defp diff_banner_total(diff) do
+ map_value(diff, :new_count, 0) + map_value(diff, :changed_count, 0) +
+ map_value(diff, :removed_count, 0)
+ end
+
+ defp diff_banner_chip_attrs(%Element{} = element, event_target, filter) do
+ case {diff_banner_filter_interaction(element), event_target, filter} do
+ {%Interaction{} = interaction, target, selected_filter} when not is_nil(target) ->
+ %{
+ :"phx-click" => "canonical_interaction",
+ :"phx-target" => target,
+ :"phx-value-interaction" => encode_interaction(interaction),
+ :"phx-value-element_id" => element_id(element, "diff-banner"),
+ :"phx-value-widget" => "diff_banner",
+ :"phx-value-filter" => to_string(selected_filter),
+ :"phx-value-value" => to_string(selected_filter),
+ :"phx-value-selected_value" => to_string(selected_filter)
+ }
+
+ _ ->
+ %{}
+ end
+ end
+
+ defp diff_banner_filter_interaction(%Element{} = element) do
+ primary_control_interaction(element) ||
+ case diff_banner_filter_intent(element) do
+ nil ->
+ nil
+
+ intent ->
+ Interaction.selection(
+ intent: intent,
+ element_id: element_id(element, "diff-banner"),
+ mapping: %{selected_value: :filter}
+ )
+ end
+ end
+
+ defp diff_banner_filter_intent(%Element{} = element) do
+ diff = diff_banner_attributes(element)
+ map_value(diff, :filter_intent, map_value(diff, :selection_intent))
+ end
+
defp context_selector_attributes(%Element{} = element) do
element.attributes
|> Map.get(:context_selector, Map.get(element.attributes, "context_selector", %{}))
diff --git a/packages/live_ui/lib/live_ui/widgets/diff_banner.ex b/packages/live_ui/lib/live_ui/widgets/diff_banner.ex
new file mode 100644
index 00000000..50d38048
--- /dev/null
+++ b/packages/live_ui/lib/live_ui/widgets/diff_banner.ex
@@ -0,0 +1,107 @@
+defmodule LiveUi.Widgets.DiffBanner do
+ @moduledoc """
+ Native comparison banner widget.
+ """
+
+ use LiveUi.Component,
+ family: :feedback,
+ name: :diff_banner,
+ events: [:selection]
+
+ LiveUi.Component.common_attrs()
+ attr(:new_count, :integer, default: 0)
+ attr(:changed_count, :integer, default: 0)
+ attr(:removed_count, :integer, default: 0)
+ attr(:base_label, :string, default: nil)
+ attr(:active_filter, :any, default: :all)
+ attr(:show_filter_chips?, :boolean, default: true)
+ attr(:size, :any, default: :default)
+ attr(:chips, :list, default: [])
+
+ @impl true
+ def render(assigns) do
+ chips = if assigns.chips == [], do: default_chips(assigns), else: assigns.chips
+
+ assigns =
+ assigns
+ |> assign(:chips, chips)
+ |> assign(:size_name, to_string(assigns.size))
+
+ ~H"""
+
+ """
+ end
+
+ defp default_chips(assigns) do
+ [
+ %{kind: :all, count: total_count(assigns), label: "all"},
+ %{kind: :new, count: assigns.new_count, label: "new"},
+ %{kind: :changed, count: assigns.changed_count, label: "changed"},
+ %{kind: :removed, count: assigns.removed_count, label: "removed"}
+ ]
+ end
+
+ defp total_count(assigns), do: assigns.new_count + assigns.changed_count + assigns.removed_count
+
+ defp chip_kind(chip), do: chip |> fetch(:kind, :all) |> to_string()
+ defp chip_label(chip), do: "#{fetch(chip, :count, 0)} #{fetch(chip, :label, chip_kind(chip))}"
+ defp chip_attrs(chip), do: fetch(chip, :attrs, %{})
+
+ defp chip_active?(chip, active_filter) do
+ chip_kind(chip) == to_string(active_filter)
+ end
+
+ defp fetch(source, key, default) when is_map(source), do: Map.get(source, key, default)
+ defp fetch(source, key, default) when is_list(source), do: Keyword.get(source, key, default)
+ defp fetch(_source, _key, default), do: default
+end
diff --git a/packages/live_ui/lib/live_ui/widgets/feedback.ex b/packages/live_ui/lib/live_ui/widgets/feedback.ex
index 50c2b0c3..5e615713 100644
--- a/packages/live_ui/lib/live_ui/widgets/feedback.ex
+++ b/packages/live_ui/lib/live_ui/widgets/feedback.ex
@@ -8,6 +8,7 @@ defmodule LiveUi.Widgets.Feedback do
LiveUi.Widgets.Progress,
LiveUi.Widgets.Gauge,
LiveUi.Widgets.InlineFeedback,
+ LiveUi.Widgets.DiffBanner,
LiveUi.Widgets.ConfidenceIndicator,
LiveUi.Widgets.Sparkline,
LiveUi.Widgets.BarChart,
diff --git a/packages/live_ui/test/live_ui/renderer_test.exs b/packages/live_ui/test/live_ui/renderer_test.exs
index 47ab0965..9bcd9cd0 100644
--- a/packages/live_ui/test/live_ui/renderer_test.exs
+++ b/packages/live_ui/test/live_ui/renderer_test.exs
@@ -439,4 +439,32 @@ defmodule LiveUi.RendererTest do
assert html =~ ~s(data-selected="true")
assert html =~ ~s(live-ui-workflow-progress-status-card--selected)
end
+
+ test "renderer maps canonical diff_banner through native feedback boundary" do
+ element =
+ Feedback.diff_banner(
+ id: "ask-diff",
+ new_count: 3,
+ changed_count: 5,
+ removed_count: 1,
+ active_filter: :new,
+ filter_intent: :filter_diff
+ )
+
+ html =
+ render_component(&LiveUi.Renderer.render/1, %{
+ element: element,
+ event_target: %Phoenix.LiveComponent.CID{cid: 1}
+ })
+
+ assert html =~ ~s(data-live-ui-widget-boundary="diff_banner")
+ assert html =~ ~s(data-live-ui-widget="diff-banner")
+ assert html =~ ~s(data-active-filter="new")
+ assert html =~ "9 all"
+ assert html =~ "3 new"
+ assert html =~ ~s(phx-click="canonical_interaction")
+ assert html =~ ~s(phx-value-widget="diff_banner")
+ assert html =~ ~s(phx-value-selected_value="new")
+ refute html =~ "live_ui_interaction"
+ end
end
diff --git a/packages/live_ui/test/live_ui/widgets/diff_banner_test.exs b/packages/live_ui/test/live_ui/widgets/diff_banner_test.exs
new file mode 100644
index 00000000..8f2daa2c
--- /dev/null
+++ b/packages/live_ui/test/live_ui/widgets/diff_banner_test.exs
@@ -0,0 +1,70 @@
+defmodule LiveUi.Widgets.DiffBannerTest do
+ use ExUnit.Case, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias LiveUi.Component
+
+ describe "metadata" do
+ test "is a feedback widget component" do
+ metadata = Component.metadata(LiveUi.Widgets.DiffBanner)
+
+ assert metadata.mountable?
+ assert metadata.component_module == LiveUi.Widgets.DiffBanner.Component
+ assert metadata.family == :feedback
+ assert metadata.name == :diff_banner
+ assert LiveUi.Widgets.DiffBanner in LiveUi.Widgets.Feedback.modules()
+ end
+ end
+
+ describe "rendering" do
+ test "renders all filter chips including all" do
+ html =
+ render_component(&LiveUi.Widgets.DiffBanner.component/1, %{
+ id: "diff-banner",
+ new_count: 3,
+ changed_count: 5,
+ removed_count: 1,
+ active_filter: :all
+ })
+
+ assert html =~ ~s(data-live-ui-widget-boundary="diff_banner")
+ assert html =~ ~s(data-live-ui-widget="diff-banner")
+ assert html =~ "9 all"
+ assert html =~ "3 new"
+ assert html =~ "5 changed"
+ assert html =~ "1 removed"
+ assert html =~ ~s(data-filter-kind="all")
+ assert html =~ ~s(role="radiogroup")
+ assert html =~ ~s(role="radio")
+ assert html =~ ~s(aria-checked="true")
+ end
+
+ test "renders static chips without radio semantics" do
+ html =
+ render_component(&LiveUi.Widgets.DiffBanner.component/1, %{
+ id: "static-diff-banner",
+ new_count: 0,
+ changed_count: 2,
+ removed_count: 0,
+ show_filter_chips?: false
+ })
+
+ refute html =~ ~s(role="radiogroup")
+ refute html =~ ~s(role="radio")
+ assert html =~ "live-ui-diff-banner__chip--static"
+ end
+
+ test "hides base label in compact mode" do
+ html =
+ render_component(&LiveUi.Widgets.DiffBanner.component/1, %{
+ id: "compact-diff-banner",
+ base_label: "Compared to last run",
+ size: :compact
+ })
+
+ refute html =~ "Compared to last run"
+ assert html =~ ~s(data-live-ui-size="compact")
+ end
+ end
+end
diff --git a/packages/unified_iur/lib/unified_iur/fixtures.ex b/packages/unified_iur/lib/unified_iur/fixtures.ex
index 649d90fe..ba313e12 100644
--- a/packages/unified_iur/lib/unified_iur/fixtures.ex
+++ b/packages/unified_iur/lib/unified_iur/fixtures.ex
@@ -755,6 +755,16 @@ defmodule UnifiedIUR.Fixtures do
title: "Success",
severity: :success
)},
+ {:content,
+ Feedback.diff_banner(
+ id: "diff-banner-widget",
+ new_count: 3,
+ changed_count: 2,
+ removed_count: 1,
+ base_label: "Compared to last run",
+ active_filter: :all,
+ filter_intent: :filter_diff
+ )},
{:content,
Feedback.confidence_indicator(0.87,
id: "confidence-score",
diff --git a/packages/unified_iur/lib/unified_iur/widgets.ex b/packages/unified_iur/lib/unified_iur/widgets.ex
index 3371ef13..1734cb2a 100644
--- a/packages/unified_iur/lib/unified_iur/widgets.ex
+++ b/packages/unified_iur/lib/unified_iur/widgets.ex
@@ -33,7 +33,14 @@ defmodule UnifiedIUR.Widgets do
]
@navigation_kinds [:menu, :tabs, :context_selector]
@data_view_kinds [:list, :table, :tree_view, :stat, :key_value, :info_list]
- @feedback_kinds [:status, :progress, :gauge, :inline_feedback, :confidence_indicator]
+ @feedback_kinds [
+ :status,
+ :progress,
+ :gauge,
+ :inline_feedback,
+ :diff_banner,
+ :confidence_indicator
+ ]
@advanced_kinds [
:stream_widget,
:log_viewer,
diff --git a/packages/unified_iur/lib/unified_iur/widgets/feedback.ex b/packages/unified_iur/lib/unified_iur/widgets/feedback.ex
index c6d3e6da..2f4fef7e 100644
--- a/packages/unified_iur/lib/unified_iur/widgets/feedback.ex
+++ b/packages/unified_iur/lib/unified_iur/widgets/feedback.ex
@@ -8,7 +8,9 @@ defmodule UnifiedIUR.Widgets.Feedback do
alias UnifiedIUR.Element
alias UnifiedIUR.Metadata
- @kinds [:status, :progress, :gauge, :inline_feedback, :confidence_indicator]
+ @kinds [:status, :progress, :gauge, :inline_feedback, :diff_banner, :confidence_indicator]
+ @diff_filters [:all, :new, :changed, :removed]
+ @diff_sizes [:default, :compact]
@spec kinds() :: [atom()]
def kinds do
@@ -106,6 +108,43 @@ defmodule UnifiedIUR.Widgets.Feedback do
)
end
+ @spec diff_banner(keyword() | map()) :: Element.t()
+ def diff_banner(opts \\ []) do
+ opts = normalize_opts(opts)
+
+ Element.new(:widget, :diff_banner,
+ id: option(opts, :id),
+ metadata: normalize_metadata(opts),
+ attributes:
+ %{
+ diff:
+ %{}
+ |> Map.put(
+ :new_count,
+ normalize_non_negative_count!(option(opts, :new_count, 0), :new_count)
+ )
+ |> Map.put(
+ :changed_count,
+ normalize_non_negative_count!(option(opts, :changed_count, 0), :changed_count)
+ )
+ |> Map.put(
+ :removed_count,
+ normalize_non_negative_count!(option(opts, :removed_count, 0), :removed_count)
+ )
+ |> Map.put(:active_filter, normalize_diff_filter!(option(opts, :active_filter, :all)))
+ |> Map.put(:show_filter_chips?, option(opts, :show_filter_chips?, true))
+ |> Map.put(:size, normalize_diff_size!(option(opts, :size, :default)))
+ |> maybe_put(:base_label, option(opts, :base_label))
+ |> maybe_put(
+ :filter_intent,
+ option(opts, :filter_intent, option(opts, :selection_intent))
+ )
+ }
+ |> Attachment.merge(opts, component: :diff_banner),
+ children: []
+ )
+ end
+
@spec confidence_indicator(number(), keyword() | map()) :: Element.t()
def confidence_indicator(value, opts \\ []) when is_number(value) do
opts = normalize_opts(opts)
@@ -153,6 +192,48 @@ defmodule UnifiedIUR.Widgets.Feedback do
Map.get(opts, key, Map.get(opts, Atom.to_string(key), default))
end
+ defp normalize_non_negative_count!(value, _field) when is_integer(value) and value >= 0,
+ do: value
+
+ defp normalize_non_negative_count!(value, field) do
+ raise ArgumentError,
+ "diff_banner :#{field} must be a non-negative integer, got: #{inspect(value)}"
+ end
+
+ defp normalize_diff_filter!(value) when value in @diff_filters, do: value
+
+ defp normalize_diff_filter!(value) when is_binary(value) do
+ value
+ |> String.to_existing_atom()
+ |> normalize_diff_filter!()
+ rescue
+ ArgumentError ->
+ raise ArgumentError,
+ "diff_banner :active_filter must be one of #{inspect(@diff_filters)}, got: #{inspect(value)}"
+ end
+
+ defp normalize_diff_filter!(value) do
+ raise ArgumentError,
+ "diff_banner :active_filter must be one of #{inspect(@diff_filters)}, got: #{inspect(value)}"
+ end
+
+ defp normalize_diff_size!(value) when value in @diff_sizes, do: value
+
+ defp normalize_diff_size!(value) when is_binary(value) do
+ value
+ |> String.to_existing_atom()
+ |> normalize_diff_size!()
+ rescue
+ ArgumentError ->
+ raise ArgumentError,
+ "diff_banner :size must be one of #{inspect(@diff_sizes)}, got: #{inspect(value)}"
+ end
+
+ defp normalize_diff_size!(value) do
+ raise ArgumentError,
+ "diff_banner :size must be one of #{inspect(@diff_sizes)}, got: #{inspect(value)}"
+ end
+
defp normalize_thresholds!(thresholds) when is_list(thresholds) do
thresholds
|> Enum.into(%{})
diff --git a/packages/unified_iur/test/unified_iur/widgets/diff_banner_test.exs b/packages/unified_iur/test/unified_iur/widgets/diff_banner_test.exs
new file mode 100644
index 00000000..ff345031
--- /dev/null
+++ b/packages/unified_iur/test/unified_iur/widgets/diff_banner_test.exs
@@ -0,0 +1,86 @@
+defmodule UnifiedIUR.Widgets.DiffBannerTest do
+ use ExUnit.Case, async: true
+
+ alias UnifiedIUR.Element
+ alias UnifiedIUR.Widgets.Feedback
+
+ describe "diff_banner/1" do
+ test "builds a baseline feedback element with defaults" do
+ element = Feedback.diff_banner()
+
+ assert %Element{kind: :diff_banner, type: :widget} = element
+
+ assert %{
+ diff: %{
+ new_count: 0,
+ changed_count: 0,
+ removed_count: 0,
+ active_filter: :all,
+ show_filter_chips?: true,
+ size: :default
+ }
+ } = element.attributes
+
+ refute Map.has_key?(element.attributes, :component)
+ end
+
+ test "builds a banner with counts, base label, and filter intent" do
+ element =
+ Feedback.diff_banner(
+ id: "ask-diff",
+ new_count: 4,
+ changed_count: 7,
+ removed_count: 2,
+ base_label: "Compared to last run",
+ active_filter: :changed,
+ filter_intent: :filter_diff
+ )
+
+ assert element.id == "ask-diff"
+ assert get_in(element.attributes, [:diff, :new_count]) == 4
+ assert get_in(element.attributes, [:diff, :changed_count]) == 7
+ assert get_in(element.attributes, [:diff, :removed_count]) == 2
+ assert get_in(element.attributes, [:diff, :base_label]) == "Compared to last run"
+ assert get_in(element.attributes, [:diff, :active_filter]) == :changed
+ assert get_in(element.attributes, [:diff, :filter_intent]) == :filter_diff
+ end
+
+ test "accepts every filter including all" do
+ for filter <- [:all, :new, :changed, :removed] do
+ element = Feedback.diff_banner(active_filter: filter)
+ assert get_in(element.attributes, [:diff, :active_filter]) == filter
+ end
+ end
+
+ test "accepts compact and default sizes" do
+ for size <- [:default, :compact] do
+ element = Feedback.diff_banner(size: size)
+ assert get_in(element.attributes, [:diff, :size]) == size
+ end
+ end
+
+ test "rejects invalid counts" do
+ assert_raise ArgumentError, ~r/:new_count must be a non-negative integer/, fn ->
+ Feedback.diff_banner(new_count: -1)
+ end
+
+ assert_raise ArgumentError, ~r/:changed_count must be a non-negative integer/, fn ->
+ Feedback.diff_banner(changed_count: -1)
+ end
+
+ assert_raise ArgumentError, ~r/:removed_count must be a non-negative integer/, fn ->
+ Feedback.diff_banner(removed_count: -1)
+ end
+ end
+
+ test "rejects invalid filter and size values" do
+ assert_raise ArgumentError, ~r/:active_filter must be one of/, fn ->
+ Feedback.diff_banner(active_filter: :stale)
+ end
+
+ assert_raise ArgumentError, ~r/:size must be one of/, fn ->
+ Feedback.diff_banner(size: :large)
+ end
+ end
+ end
+end
diff --git a/packages/unified_iur/test/unified_iur/widgets_test.exs b/packages/unified_iur/test/unified_iur/widgets_test.exs
index 9097c8d0..2739b57f 100644
--- a/packages/unified_iur/test/unified_iur/widgets_test.exs
+++ b/packages/unified_iur/test/unified_iur/widgets_test.exs
@@ -39,7 +39,7 @@ defmodule UnifiedIUR.WidgetsTest do
assert Widgets.data_view_kinds() == Data.kinds()
- assert [:status, :progress, :gauge, :inline_feedback, :confidence_indicator] ==
+ assert [:status, :progress, :gauge, :inline_feedback, :diff_banner, :confidence_indicator] ==
Widgets.feedback_kinds()
assert Widgets.feedback_kinds() == Feedback.kinds()
diff --git a/test/ash_ui/phase_31_package_boundary_test.exs b/test/ash_ui/phase_31_package_boundary_test.exs
index 7acbb669..8de418d1 100644
--- a/test/ash_ui/phase_31_package_boundary_test.exs
+++ b/test/ash_ui/phase_31_package_boundary_test.exs
@@ -77,6 +77,12 @@ defmodule AshUI.Phase31PackageBoundaryTest do
refute :context_selector in AshUI.WidgetComponents.kinds()
end
+ test "baseline feedback primitives stay out of the widget-component catalog" do
+ refute :diff_banner in UnifiedUi.WidgetComponents.kinds()
+ refute :diff_banner in UnifiedIUR.Widgets.Components.kinds()
+ refute :diff_banner in AshUI.WidgetComponents.kinds()
+ end
+
test "unknown names return the upstream catalog diagnostic" do
assert {:error, diagnostic} = AshUI.WidgetComponents.canonical_kind(:not_in_catalog)
diff --git a/test/ash_ui/phase_31_runtime_adapter_test.exs b/test/ash_ui/phase_31_runtime_adapter_test.exs
index 0f020c42..6543e3c4 100644
--- a/test/ash_ui/phase_31_runtime_adapter_test.exs
+++ b/test/ash_ui/phase_31_runtime_adapter_test.exs
@@ -119,6 +119,31 @@ defmodule AshUI.Phase31RuntimeAdapterTest do
refute Map.has_key?(canonical.attributes, :component)
end
+ test "IUR adapter routes diff_banner props into baseline feedback attributes" do
+ assert {:ok, canonical} =
+ IUR.new(:diff_banner,
+ id: "ask-diff",
+ props: %{
+ new_count: 3,
+ changed_count: 5,
+ removed_count: 1,
+ active_filter: :new,
+ filter_intent: :filter_diff
+ }
+ )
+ |> IURAdapter.to_canonical()
+
+ assert canonical.kind == :diff_banner
+ assert canonical.type == :widget
+ assert %{diff: diff} = canonical.attributes
+ assert diff.new_count == 3
+ assert diff.changed_count == 5
+ assert diff.removed_count == 1
+ assert diff.active_filter == :new
+ assert diff.filter_intent == :filter_diff
+ refute Map.has_key?(canonical.attributes, :component)
+ end
+
test "live_ui_adapter fallback renders confidence_indicator with meter semantics" do
assert {:ok, canonical} =
IUR.new(:screen,
@@ -182,6 +207,35 @@ defmodule AshUI.Phase31RuntimeAdapterTest do
assert heex =~ ~s(data-context-value="all")
assert heex =~ ~s(aria-selected="true")
end
+
+ test "live_ui_adapter fallback renders diff_banner with stable filter chips" do
+ assert {:ok, canonical} =
+ IUR.new(:screen,
+ id: "diff-screen",
+ name: "diff_test",
+ children: [
+ IUR.new(:diff_banner,
+ id: "ask-diff",
+ props: %{
+ new_count: 4,
+ changed_count: 7,
+ removed_count: 2,
+ active_filter: :changed
+ }
+ )
+ ]
+ )
+ |> IURAdapter.to_canonical()
+
+ assert {:ok, heex} = LiveUIAdapter.render(canonical, force_fallback: true)
+ assert heex =~ "ash-diff-banner"
+ assert heex =~ ~s(data-live-ui-widget="diff-banner")
+ assert heex =~ ~s(data-active-filter="changed")
+ assert heex =~ "13 all"
+ assert heex =~ "4 new"
+ assert heex =~ "7 changed"
+ assert heex =~ "2 removed"
+ end
end
defp canonical_component do
diff --git a/test/ash_ui/resource/diff_banner_test.exs b/test/ash_ui/resource/diff_banner_test.exs
new file mode 100644
index 00000000..1df22c64
--- /dev/null
+++ b/test/ash_ui/resource/diff_banner_test.exs
@@ -0,0 +1,33 @@
+defmodule AshUI.Resource.DiffBannerTest do
+ use ExUnit.Case, async: true
+
+ alias AshUI.DSL.Storage
+ alias AshUI.Resources.Validations.Authoring
+
+ describe "diff_banner authoring admission" do
+ test "admits diff_banner as a baseline feedback widget type" do
+ assert Storage.valid_widget_type?("diff_banner")
+ assert Storage.canonical_widget_type(:diff_banner) == {:ok, "diff_banner"}
+
+ assert %{type: :diff_banner} =
+ Authoring.validate_element_definition!(%{
+ type: :diff_banner,
+ props: %{new_count: 3, changed_count: 2, removed_count: 1}
+ })
+ end
+
+ test "allows diff_banner filter actions" do
+ definition = %{type: :diff_banner, props: %{filter_intent: :filter_diff}}
+
+ action =
+ Authoring.validate_action_definition!(%{
+ id: :filter_diff,
+ signal: :change,
+ source: %{resource: "Demo.Finding", action: "filter"},
+ target: "diff_filter"
+ })
+
+ assert :ok = Authoring.validate_element_authority!(definition, [], [action])
+ end
+ end
+end