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
alias ScoriaWeb.Copy
@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 not_scored inconclusive) ->
: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 (curated D-25 operator vocabulary, falling back to
a title-cased/underscore-to-space transform for anything not curated).
ADDITIVE upgrade (D-24a): curated clauses sit above the generic fallback and the
generic fallback + `"Unknown"` catch-all are retained deliberately — this function
is the atom fallback inside `evidence_text/1` and `object_header/1` across ≥6
disjoint domains, so a closed allow-list would raise `FunctionClauseError` inside
`render/1` on any unseen status (a page-500). Does NOT curate `"rejected"` — the
operator word "Denied" is approval-domain only (D-24d, `ApprovalCopy.decision_outcome/1`).
"""
def status_label(status), do: Copy.status_label(status)
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(:variant, :atom, default: :ghost, 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-disable-with disabled autofocus)
)
slot(:inner_block, required: true)
@doc "Icon-only button. Use `size: :md` for chrome controls and `size: :sm` for inline utilities."
def icon_button(assigns) do
~H"""
"""
end
attr(:class, :string, default: nil)
slot(:inner_block)
@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(:flush, :boolean, default: false)
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(:class, :string, default: nil)
attr(:rest, :global)
slot(:eyebrow)
slot(:title)
slot(:description)
slot(:actions)
slot(:inner_block, required: true)
@doc """
Open-air top-level page section.
Use this for primary page regions that should breathe on the canvas. Use
`panel/1` only when the UI needs a contained object, evidence, table, drawer,
or modal surface.
"""
def page_section(assigns) do
~H"""
{render_slot(@eyebrow)}
{render_slot(@title)}
{render_slot(@description)}
{render_slot(@actions)}
{render_slot(@inner_block)}
"""
end
attr(:title, :string, required: true)
attr(:class, :string, default: nil)
attr(:rest, :global)
slot(:summary)
slot(:actions)
@doc """
Page-level heading — owns the single `
` for a dashboard page.
`title` is a required plain-string attr, not a slot, so schema/module names or
opaque IDs cannot be interpolated into the page's only `
` (D-01/D-23/D-26).
Use the `:summary` slot for a one-line operator-orientation `
` and the
`:actions` slot for at most one header action (a primary action or a
ghost/secondary nav link, e.g. review_queue's "Back to dashboard" link).
"""
def page_header(assigns) do
~H"""
{@title}
{render_slot(@actions)}
{render_slot(@summary)}
"""
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(:label, :string, required: true)
attr(:class, :string, default: nil)
attr(:rest, :global)
slot(:stat, required: true) do
attr(:label, :string, required: true)
attr(:value, :string, required: true)
attr(:tone, :atom)
end
@doc """
Plain-language overview stats for page-level operational summaries.
Use this instead of `metric/1` when a count needs context or an operator
needs to understand what action the number implies.
"""
def overview_stats(assigns) do
~H"""
{stat.label}
{stat.value}
{render_slot(stat)}
"""
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}
aria-live="polite"
>
{@value}
"""
end
attr(:at, :any, required: true)
attr(:mode, :atom, default: :absolute, values: [:absolute, :elapsed])
attr(:fallback, :string, default: "Not recorded")
attr(:class, :string, default: nil)
attr(:rest, :global)
@doc "Machine-readable time with operator-friendly visible text and exact timestamp tooltip."
def time(assigns) do
assigns =
assigns
|> assign(:datetime, datetime_attr(assigns.at))
|> assign(:exact, exact_time(assigns.at, assigns.fallback))
|> assign(:label, time_label(assigns.at, assigns.mode, assigns.fallback))
~H"""
{@label}
"""
end
defp datetime_attr(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp datetime_attr(_), do: nil
defp time_label(%DateTime{} = dt, :elapsed, _fallback), do: "Waiting #{elapsed_label(dt)}"
defp time_label(%DateTime{} = dt, _mode, _fallback), do: exact_time(dt, nil)
defp time_label(nil, _mode, fallback), do: fallback
defp time_label(other, _mode, _fallback), do: to_string(other)
defp exact_time(%DateTime{} = dt, _fallback) do
dt
|> DateTime.shift_zone!("Etc/UTC")
|> Calendar.strftime("%Y-%m-%d %H:%M UTC")
end
defp exact_time(nil, fallback), do: fallback
defp exact_time(other, _fallback), do: to_string(other)
defp elapsed_label(%DateTime{} = dt) do
now = DateTime.utc_now()
seconds = max(DateTime.diff(now, dt, :second), 0)
cond do
seconds < 60 -> "just now"
seconds < 3_600 -> "#{div(seconds, 60)}m"
seconds < 86_400 -> "#{div(seconds, 3_600)}h"
seconds < 604_800 -> "#{div(seconds, 86_400)}d"
true -> Calendar.strftime(dt, "%Y-%m-%d")
end
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(:href, :string, default: nil)
attr(:selected, :boolean, default: false)
attr(:tone, :atom, default: :neutral)
attr(:class, :string, default: nil)
attr(:type, :string, default: "button")
attr(:rest, :global)
slot(:title, required: true)
slot(:status)
slot(:meta)
@doc "Selectable object card for list/detail navigation. Use links for routed objects."
def selectable_card(assigns) do
assigns =
assign(
assigns,
:card_class,
[
"scoria-selectable-card",
"scoria-selectable-card--#{assigns.tone}",
assigns.selected && "scoria-selectable-card--selected",
assigns.class
]
)
~H"""
{render_slot(@title)}{render_slot(@meta)}{render_slot(@status)}
"""
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.
D-10: the panel body is wrapped in `focus_wrap/1` (distinct id `\#{@id}-focus`,
nested INSIDE this `role=\"dialog\"` element — never reusing `@id`) so Tab/
Shift+Tab wrap inside the dialog and never land on the page behind the scrim.
`phx-mounted={JS.focus_first()}` moves focus in on open (replacing the old
bare `autofocus` attribute so there is exactly one tab-in mechanism, not two
racing). `phx-remove={JS.pop_focus()}` on the outer shell restores focus to
whatever opener called `JS.push_focus()` when this element leaves the DOM —
i.e. whenever the caller's `on_dismiss` flips `show` back to false, covering
the close button, scrim click, and Escape dismiss paths from one place."
def modal(assigns) do
~H"""
"""
end
attr(:id, :string, required: true)
attr(:show, :boolean, required: true)
attr(:on_dismiss, :string, required: true)
attr(:title, :string, default: nil)
attr(:keydown_enabled, :boolean,
default: true,
doc:
"CR-01 (phase 40 review): set to false when a modal (or other overlay) is stacked " <>
"on top of this drawer. Both modal/1 and drawer/1 attach a WINDOW-scoped Escape " <>
"listener, so with two overlays mounted at once a single Escape keypress fires " <>
"BOTH dismiss handlers — ejecting the operator from the drawer the topmost modal " <>
"was meant to be scoped to. Setting keydown_enabled={false} omits this drawer's " <>
"phx-window-keydown/phx-key entirely so only the topmost overlay owns Escape; " <>
"the close button and scrim click are left as-is since a stacked modal already " <>
"covers them visually/positionally."
)
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 button + scrim click + Escape key.
The caller owns all dismiss events via on_dismiss.
D-10: same focus-trap/restore wiring as `modal/1` — `focus_wrap/1` at the
distinct id `\#{@id}-focus` nested inside this `role=\"dialog\"` `