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"""
<.badge tone={:neutral} label={@object_type} /> <.id id={"object-id-#{:erlang.phash2(@object_id)}"} value={@display_id} copy={@object_id} title={@object_id} class="scoria-object-header__id" /> <.badge :if={@status_text} tone={@status_tone} label={@status_text} /> {@key_scalar}

{@provenance}

{@origin_label}
""" end attr(:title, :string, required: true) attr(:description, :string, required: true) attr(:works_today, :list, default: []) attr(:tracking_url, :string, default: nil) attr(:class, :string, default: nil) @doc "Honest reserved-capability stub page. Future-tense only; no fake rows or charts." def stub_page(assigns) do ~H"""

{@title}

<.badge tone={:neutral} label="Soon" />

{@description}

What works today

Track progress
""" end attr(:class, :string, default: nil) slot(:inner_block, required: true) @doc "Keyboard shortcut chip. Renders a `` element styled with the brand-book monospace treatment. Used inline in command palette rows and help text to label key bindings (e.g. `⌘K`, `Escape`, `↑↓`)." def kbd(assigns) do ~H""" {render_slot(@inner_block)} """ end attr(:id, :string, required: true) attr(:sections, :list, default: []) attr(:title, :string, default: "Open command palette") attr(:placeholder, :string, default: "Search screens, recent objects, and actions") attr(:empty_copy, :string, default: "No matches. The palette covers screens, recent objects, and actions — full object search lands in a later release." ) attr(:class, :string, default: nil) @doc "Server-rendered command palette shell. Client-side filtering lives in assets/js/scoria.js." def command_palette(assigns) do assigns = assign(assigns, :active_id, first_command_id(assigns.sections)) ~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\"` `