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"""
<.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." 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(: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 <%= for tab <- @tab, tab.key == @selected_tab do %>
{render_slot(tab)}
<% end %> <% end %> """ end attr(:label, :string, default: "Advanced raw evidence") slot(:inner_block, required: true) @doc "Raw evidence details/pre block (DS-04). Renders a
/ with a
 code block. Background and font
  tokens are applied via CSS class — not overridden in HEEx."
  def raw_evidence(assigns) do
    ~H"""
    
{@label}
{render_slot(@inner_block)}
""" end attr(:title, :string, required: true) attr(:description, :string, default: nil) attr(:tone, :atom, default: :neutral, values: [:pass, :info, :warn, :fail, :trace, :brand, :neutral] ) attr(:badge, :string, default: nil) attr(:class, :string, default: nil) attr(:rest, :global) slot(:actions) slot(:inner_block, required: true) @doc "Notebook-scoped evidence section with optional status badge and action slot. Used inside `<.notebook>` `:tab` slot panels to group a titled block of evidence content. Attrs: `title` (section heading), `description` (optional supporting copy), `tone` (semantic tone atom for the badge color), `badge` (optional badge label — rendered only when present). Slots: `:actions` for action buttons rendered in the section header; `:inner_block` (required) for evidence rows and action rows." def evidence_section(assigns) do ~H"""

{@title}

<.badge :if={@badge} tone={@tone} label={@badge} />

{@description}

{render_slot(@actions)}
{render_slot(@inner_block)}
""" end attr(:rows, :list, required: true) attr(:class, :string, default: nil) attr(:rest, :global) @doc "Stable key-value evidence rows for adapter-projected values. Renders a `
` of `
/
` pairs. The `:rows` attr accepts a list of either `%{label: _, value: _}` maps or `{label, value}` two-tuples — both forms are normalized by `normalize_evidence_rows/1` before rendering. Use inside `<.evidence_section>` `:inner_block` for structured label-value evidence data (e.g. model name, token count, latency)." def evidence_rows(assigns) do assigns = assign(assigns, :normalized_rows, normalize_evidence_rows(assigns.rows)) ~H"""
{row.label}
{row.value}
""" end attr(:class, :string, default: nil) attr(:rest, :global) slot(:inner_block, required: true) @doc "Compact evidence action/link row. Callers own the action/link semantics. Used for per-section action links in evidence panels (e.g. \"View trace\", \"Open replay\", \"Go to approval\"). Renders `:inner_block` inside a flex container with consistent action-row spacing — place `` or `<.button>` elements inside." def evidence_action_row(assigns) do ~H"""
{render_slot(@inner_block)}
""" end attr(:title, :string, required: true) attr(:class, :string, default: nil) attr(:rest, :global) slot(:inner_block, required: true) @doc "Notebook-scoped evidence empty state. Used for empty `:tab` slot panels in `<.notebook>` when no evidence data is available for a section. The `:title` attr is required and names what is absent (e.g. \"No approvals\", \"No connector invocations\"). Optional `:inner_block` can provide supplementary copy or a link." def evidence_empty(assigns) do ~H"""

{@title}

{render_slot(@inner_block)}
""" end # --------------------------------------------------------------------------- # DS-01: <.table> — sortable, density-aware data table (plan 12-02) # --------------------------------------------------------------------------- slot :col, doc: "Table column" do attr(:label, :string, required: true) attr(:key, :atom) attr(:class, :string) end slot(:empty) slot(:action) slot(:filter) slot :mobile_summary, doc: "Opt-in per-row mobile summary rendered in a sibling container hidden at >=768px. Exposes object label, status badge text, one key scalar/time, and a primary action. When absent, the table keeps honest overflow at all widths." do end attr(:rows, :list, required: true) attr(:sort_by, :any, default: nil) attr(:sort_dir, :atom, default: :asc, values: [:asc, :desc]) attr(:density, :atom, default: :default, values: [:compact, :default, :comfortable]) attr(:id, :string, required: true) attr(:page, :integer, default: 1) attr(:total_pages, :integer, default: 1) attr(:on_sort, :string, default: nil) attr(:on_page_change, :string, default: nil) attr(:on_density_change, :string, default: nil) attr(:rest, :global) @doc "Sortable, density-aware, paginated data table (DS-01). Renders column headers from typed <:col> slots; emits sort events for keyed columns only when on_sort is supplied by the parent LiveView. Uses density modifier classes from density_class/1. Falls back to a default empty state when rows is empty (overridable via <:empty>). Pagination strip shown when total_pages > 1. Density controls render only when on_density_change is supplied by the parent LiveView." def table(assigns) do # WR-05: a paginated table with no on_page_change handler would render inert # phx-click={nil} prev/next controls (a silent no-op). Fail loudly at render # instead, matching the <.notebook> on_tab_change guard. if assigns.total_pages > 1 and is_nil(assigns.on_page_change) do raise ArgumentError, "<.table> with total_pages > 1 requires on_page_change; " <> "got total_pages=#{assigns.total_pages} and on_page_change=nil" end ~H"""
{render_slot(@filter)}
<%!-- Overflow viewport: keyboard-reachable horizontal scroll container (D-13). sticky thead still works inside overflow-x: auto (vertical sticky is unaffected). --%>
{column.label}
<%= if @empty != [] do %> {render_slot(@empty)} <% else %> <.empty_state title="No records found"> Adjust your filters or check back when data is available. <% end %>
{render_slot(column, row)} {render_slot(@action, row)}
<%!-- Opt-in per-row mobile summaries (D-08/D-10). Rendered only when the :mobile_summary slot is supplied. Hidden at >=768px via CSS; the viewport above is hidden below md on tables with summaries (scoria-table-shell--has-summary). Content comes from caller's slot — do not hardcode fields here. --%>
{render_slot(@mobile_summary, row)}
""" end defp density_class(:compact), do: "scoria-table--compact" defp density_class(:comfortable), do: "scoria-table--comfortable" defp density_class(:default), do: nil attr(:flash, :map, default: %{}) @doc "Dashboard flash banners. Single home for flash kind → tone styling (DS-05). Renders semantic scoria-flash--{tone} BEM modifier classes via string-keyed clauses (Phoenix @flash always provides string keys, not atoms). Each banner carries role=\"alert\" and a 16×16 tone icon so status is never communicated by color alone." def flash_group(assigns) do ~H""" """ end defp middle_truncate(value) when is_binary(value) do if String.length(value) > 15 do String.slice(value, 0, 8) <> "..." <> String.slice(value, -3, 3) else value end end defp middle_truncate(value), do: to_string(value) defp origin_label(%{noun: noun, id: id}) when is_binary(noun) and is_binary(id) do "← Back to #{noun} #{id}" end defp origin_label(_origin), do: nil defp normalize_evidence_rows(rows) do Enum.map(rows, fn {label, value} -> %{label: evidence_text(label), value: evidence_text(value)} row when is_map(row) -> %{ label: evidence_text(Map.get(row, :label) || Map.get(row, "label")), value: evidence_text(Map.get(row, :value) || Map.get(row, "value")) } value -> %{label: "", value: evidence_text(value)} end) end defp evidence_text(nil), do: "Not recorded" defp evidence_text(value) when is_binary(value), do: value defp evidence_text(value) when is_atom(value), do: status_label(value) defp evidence_text(value) when is_integer(value) or is_float(value), do: to_string(value) defp evidence_text(value), do: inspect(value) defp first_command_id(sections) do sections |> Enum.flat_map(&Map.get(&1, :rows, [])) |> List.first() |> case do nil -> nil row -> command_row_id(row) end end defp command_row_id(row), do: Map.get(row, :id) || "command-#{:erlang.phash2(command_row_label(row))}" defp command_row_label(row), do: Map.fetch!(row, :label) defp command_row_path(row), do: Map.get(row, :path) defp command_row_kbd(row), do: Map.get(row, :kbd) defp command_row_search(row) do aliases = row |> Map.get(:aliases, []) |> Enum.join(" ") [command_row_label(row), aliases] |> Enum.reject(&(&1 in [nil, ""])) |> Enum.join(" ") end defp flash_modifier("error"), do: "scoria-flash--fail" defp flash_modifier("info"), do: "scoria-flash--info" defp flash_modifier("success"), do: "scoria-flash--pass" defp flash_modifier(_kind), do: "scoria-flash--warn" # 16×16 inline SVG tone icons — status never by color alone (a11y DS-05). defp flash_icon("error") do assigns = %{} ~H""" """ end defp flash_icon("info") do assigns = %{} ~H""" """ end defp flash_icon("success") do assigns = %{} ~H""" """ end defp flash_icon(_kind) do assigns = %{} ~H""" """ end end