defmodule ScoriaWeb.UI do
@moduledoc """
Scoria's shared dashboard component vocabulary.
Function components emit the brand-book semantic classes (see `assets/css/04-components.css`)
driven entirely by design tokens. This is the single home for tone/status → color mapping,
replacing the per-component `badge_class/status_color/trace_badge_class/flash_kind_class`
helpers that previously drifted across the codebase.
Import into a LiveView/component with `import ScoriaWeb.UI`.
"""
use Phoenix.Component
alias Phoenix.LiveView.JS
@doc """
Maps a domain status/kind string (or atom) to a semantic tone atom.
Tones: `:pass | :info | :warn | :fail | :trace | :brand | :neutral`. Unknown values
fall back to `:neutral`. This is the single source of truth for status coloring.
"""
def tone(status) when is_atom(status), do: status |> Atom.to_string() |> tone()
def tone(status) when is_binary(status) do
case status do
s
when s in ~w(completed complete success succeeded online healthy ok pass passed available active resolved approved) ->
:pass
s
when s in ~w(running streaming in_progress executing scheduled queued info reference retrieval) ->
:info
s
when s in ~w(waiting_for_approval pending_approval retrying warning warn drift degraded stale pending approval_requested needs_review) ->
:warn
s
when s in ~w(failed failure error offline denied rejected regression cancelled canceled unhealthy expired) ->
:fail
s when s in ~w(replay experiment branch candidate trace promotion_candidate online_eval) ->
:trace
_ ->
:neutral
end
end
def tone(_), do: :neutral
@doc "Human label for a status string (title-cased, underscores → spaces)."
def status_label(status) when is_atom(status), do: status |> Atom.to_string() |> status_label()
def status_label(status) when is_binary(status) do
status |> String.replace("_", " ") |> String.capitalize()
end
def status_label(_), do: "Unknown"
attr(:tone, :atom, default: :neutral)
attr(:label, :string, default: nil)
attr(:dot, :boolean, default: true)
attr(:class, :string, default: nil)
attr(:rest, :global)
slot(:inner_block)
@doc "Status badge. Always renders a text label alongside color (a11y: never color-alone)."
def badge(assigns) do
~H"""
{@label}{render_slot(@inner_block)}
"""
end
attr(:variant, :atom, default: :primary, values: [:primary, :ghost, :danger])
attr(:size, :atom, default: :md, values: [:md, :sm])
attr(:type, :string, default: "button")
attr(:class, :string, default: nil)
attr(:rest, :global,
include:
~w(phx-click phx-target phx-value-id phx-value-approval-id phx-value-dataset-id phx-disable-with disabled form name value href) ++
~w(aria-current)
)
slot(:inner_block, required: true)
@doc "Primary/ghost/danger button (brand book §8.5)."
def button(assigns) do
~H"""
"""
end
attr(:class, :string, default: nil)
slot(:inner_block, required: true)
@doc "Small uppercase category/status label (brand book card eyebrow).
Used in panel headers, object headers, and card hierarchy labeling to
provide a typographic tier above the primary title."
def eyebrow(assigns) do
~H"""
{render_slot(@inner_block)}
"""
end
attr(:variant, :atom, default: :flat, values: [:flat, :raised])
attr(:class, :string, default: nil)
attr(:rest, :global)
slot(:eyebrow)
slot(:title)
slot(:actions)
slot(:inner_block, required: true)
@doc "Panel/card surface with optional eyebrow + title + actions header."
def panel(assigns) do
~H"""
{render_slot(@eyebrow)}
{render_slot(@title)}
{render_slot(@actions)}
{render_slot(@inner_block)}
"""
end
attr(:label, :string, required: true)
attr(:value, :string, required: true)
attr(:delta, :string, default: nil)
attr(:delta_tone, :atom, default: :neutral)
attr(:class, :string, default: nil)
@doc "Metric card: label, big value, explicit delta (brand book §11.3 — never a magic score)."
def metric(assigns) do
~H"""
{@label}
{@value}
{@delta}
"""
end
attr(:value, :string, required: true)
attr(:copy, :string, default: nil)
attr(:id, :string, default: nil)
attr(:title, :string, default: "Click to copy")
attr(:class, :string, default: nil)
@doc "Copyable monospace identifier (run/trace/actor IDs). Uses the CopyId JS hook.
The DOM id must be stable across renders so LiveView/morphdom patches the existing
element in place rather than tearing down and re-mounting the CopyId hook. When no
caller-supplied id is given it is derived deterministically from the displayed value;
prefer passing an explicit id where two identical values can appear on one page."
def id(assigns) do
assigns =
assign_new(assigns, :id, fn ->
"id-" <> Integer.to_string(:erlang.phash2(assigns.value))
end)
~H"""
{@value}
"""
end
attr(:count, :any, required: true)
attr(:label, :string, required: true)
attr(:detail, :string, required: true)
attr(:cta, :string, required: true)
attr(:path, :string, required: true)
attr(:tone, :atom, default: :neutral)
attr(:class, :string, default: nil)
@doc "Status Home attention strip card for nonzero actionable states.
Renders as an `` tag — callers pass a `path`, not a click handler.
Attrs: `count` (numeric highlight), `label` (category name), `detail`
(supporting copy), `cta` (call-to-action text), `path` (link target),
`tone` (semantic tone atom controlling color treatment, default `:neutral`)."
def attention_card(assigns) do
~H"""
{@count}{@label}{@detail}{@cta}
"""
end
attr(:parent_label, :string, required: true)
attr(:parent_path, :string, required: true)
attr(:object_type, :string, required: true)
attr(:object_id, :string, required: true)
attr(:status, :any, default: nil)
attr(:key_scalar, :string, default: nil)
attr(:provenance, :string, default: nil)
attr(:origin, :map, default: nil)
attr(:class, :string, default: nil)
attr(:id, :string, default: nil)
attr(:rest, :global)
@doc "Object-page orientation header: parent crumb, copyable ID, status, provenance, and return context."
def object_header(assigns) do
assigns =
assigns
|> assign(:display_id, middle_truncate(assigns.object_id))
|> assign(:status_text, assigns.status && status_label(assigns.status))
|> assign(:status_tone, assigns.status && tone(assigns.status))
|> assign(:origin_label, origin_label(assigns.origin))
~H"""
"""
end
attr(:title, :string, required: true)
attr(:class, :string, default: nil)
slot(:inner_block)
slot(:action)
@doc "Empty state: status + learning cue + optional primary action (NN/g)."
def empty_state(assigns) do
~H"""
{@title}
{render_slot(@inner_block)}
{render_slot(@action)}
"""
end
# ---------------------------------------------------------------------------
# DS-02: <.modal> and <.drawer> — slot-based overlay shells (plan 12-03)
# ---------------------------------------------------------------------------
attr(:id, :string, required: true)
attr(:show, :boolean, required: true)
attr(:on_dismiss, :string, required: true)
attr(:title, :string, default: nil)
attr(:max_width, :string, default: "560px")
attr(:rest, :global)
slot(:title_slot)
slot(:inner_block, required: true)
slot(:footer)
@doc "Slot-based modal dialog shell (DS-02).
Renders nothing when show=false. When show=true, renders a scrim + panel with
a consistent triple dismiss contract: close button + scrim click + Escape key.
The caller owns all dismiss events via on_dismiss."
def modal(assigns) do
~H"""
{render_slot(@title_slot)}
{@title}
{render_slot(@inner_block)}
"""
end
attr(:id, :string, required: true)
attr(:show, :boolean, required: true)
attr(:on_dismiss, :string, required: true)
attr(:title, :string, default: nil)
attr(:rest, :global)
slot(:eyebrow)
slot(:title_slot)
slot(:actions)
slot(:inner_block, required: true)
@doc "Slot-based drawer panel shell (DS-02).
Renders nothing when show=false. When show=true, renders a scrim + aside panel with
a consistent triple dismiss contract: Close drawer button + scrim click + Escape key.
The caller owns all dismiss events via on_dismiss."
def drawer(assigns) do
~H"""
"""
end
# ---------------------------------------------------------------------------
# DS-03: <.field> and <.form_section> — form control wrappers (plan 12-03)
# ---------------------------------------------------------------------------
attr(:id, :string, required: true)
attr(:label, :string, required: true)
attr(:help, :string, default: nil)
attr(:error, :string, default: nil)
attr(:required, :boolean, default: false)
attr(:rest, :global)
slot(:inner_block, required: true)
@doc "Form field wrapper (DS-03).
Renders a label + caller-provided input slot + optional help text + validation error.
Error is surfaced via an exclamation icon + text (never error by color alone).
Required fields include an aria-hidden asterisk and a visually-hidden '(required)' span.
The caller provides the actual input/select/textarea element via the inner_block slot."
def field(assigns) do
~H"""
{render_slot(@inner_block)}
{@help}
{@error}
"""
end
attr(:title, :string, required: true)
attr(:description, :string, default: nil)
attr(:rest, :global)
slot(:inner_block, required: true)
@doc "Form section group (DS-03).
Groups related <.field> components under a section heading with an optional description."
def form_section(assigns) do
~H"""
{@title}
{@description}
{render_slot(@inner_block)}
"""
end
# ---------------------------------------------------------------------------
# DS-05: <.skeleton> and <.toast> — loading/transient feedback (plan 12-04)
# ---------------------------------------------------------------------------
attr(:class, :string, default: nil)
attr(:rows, :integer, default: 1)
attr(:rest, :global)
@doc "Loading skeleton placeholder (DS-05).
Renders stacked skeleton lines with aria-label='Loading…' and role='status'.
Pulse animation + prefers-reduced-motion suppression are handled by CSS."
def skeleton(assigns) do
~H"""
"""
end
attr(:id, :string, required: true)
attr(:tone, :atom, default: :neutral, values: [:pass, :fail, :warn, :info, :neutral])
attr(:message, :string, required: true)
attr(:duration_ms, :integer, default: 4000)
@doc "Transient toast notification (DS-05).
Driven by a server @toasts assign. Auto-dismisses via phx-mounted={JS.hide(...)}.
Manual dismiss X button is also provided. Does NOT use a JS hook (untestable in LiveViewTest).
The phx-mounted auto-dismiss omits 'to:' so it self-targets the toast div (Pitfall 3);
the dismiss button MUST target the toast by id (to: '#id') — a bare JS.hide there would
hide the button, not the toast."
def toast(assigns) do
~H"""
{toast_icon(@tone)}
{@message}
"""
end
# 16×16 inline SVG tone icons for toast — status never by color alone (a11y DS-05).
defp toast_icon(:pass) do
assigns = %{}
~H"""
"""
end
defp toast_icon(:fail) do
assigns = %{}
~H"""
"""
end
defp toast_icon(:warn) do
assigns = %{}
~H"""
"""
end
defp toast_icon(:info) do
assigns = %{}
~H"""
"""
end
defp toast_icon(_tone) do
assigns = %{}
~H"""
"""
end
# ---------------------------------------------------------------------------
# DS-04: <.notebook> and <.raw_evidence> — unified evidence panel shell (plan 12-04)
# ---------------------------------------------------------------------------
attr(:id, :string, required: true)
attr(:title, :string, required: true)
attr(:eyebrow, :string, default: nil)
attr(:empty, :boolean, default: false)
attr(:selected_tab, :string, default: nil)
attr(:on_tab_change, :string, default: nil)
attr(:rest, :global)
slot :tab, doc: "One tab panel" do
attr(:key, :string, required: true)
attr(:label, :string, required: true)
end
slot(:empty_slot)
@doc "Unified tabbed evidence panel shell (DS-04).
Renders a