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"""
<span class={["scoria-badge", "scoria-badge--#{@tone}", not @dot && "scoria-badge--bare", @class]} {@rest}>
{@label}{render_slot(@inner_block)}
</span>
"""
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"""
<button
type={@type}
class={["scoria-button", "scoria-button--#{@variant}", @size == :sm && "scoria-button--sm", @class]}
{@rest}
>
{render_slot(@inner_block)}
</button>
"""
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"""
<p class={["scoria-eyebrow", @class]}>{render_slot(@inner_block)}</p>
"""
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"""
<section class={["scoria-panel", @variant == :raised && "scoria-panel--raised", @class]} {@rest}>
<div :if={@eyebrow != [] or @title != [] or @actions != []} class="scoria-panel__header">
<div>
<p :if={@eyebrow != []} class="scoria-eyebrow">{render_slot(@eyebrow)}</p>
<h2 :if={@title != []}>{render_slot(@title)}</h2>
</div>
<div :if={@actions != []} class="flex items-center gap-2">{render_slot(@actions)}</div>
</div>
{render_slot(@inner_block)}
</section>
"""
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"""
<div class={["scoria-metric", @class]}>
<p class="scoria-metric__label">{@label}</p>
<p class="scoria-metric__value">{@value}</p>
<p :if={@delta} class={["scoria-metric__delta", "scoria-metric__delta--#{@delta_tone}"]}>{@delta}</p>
</div>
"""
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"""
<span class={["scoria-id", @class]} phx-hook="CopyId" id={@id} data-copy={@copy || @value} title={@title}>
{@value}
</span>
"""
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 `<a>` 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"""
<a href={@path} class={["scoria-attention-card", "scoria-attention-card--#{@tone}", @class]}>
<span class="scoria-attention-card__count">{@count}</span>
<span class="scoria-attention-card__body">
<span class="scoria-attention-card__label">{@label}</span>
<span class="scoria-attention-card__detail">{@detail}</span>
</span>
<span class="scoria-attention-card__cta">{@cta}</span>
</a>
"""
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"""
<header
id={@id || "scoria-object-header-#{:erlang.phash2(@object_id)}"}
class={["scoria-object-header", @class]}
phx-hook="RecordRecentObject"
data-scoria-kind={@object_type}
data-scoria-id={@object_id}
data-scoria-label={"#{@object_type} #{@display_id}"}
{@rest}
>
<nav class="scoria-object-header__crumbs" aria-label="Object breadcrumbs">
<a href={@parent_path}>{@parent_label}</a>
<span class="scoria-breadcrumbs__sep" aria-hidden="true">/</span>
<span title={@object_id}>{@display_id}</span>
</nav>
<div class="scoria-object-header__identity">
<.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} />
<span :if={@key_scalar} class="scoria-object-header__scalar">{@key_scalar}</span>
</div>
<div :if={@provenance || @origin_label} class="scoria-object-header__context">
<p :if={@provenance} class="scoria-object-header__provenance">{@provenance}</p>
<a :if={@origin_label} class="scoria-object-header__origin" href={@origin.path}>
{@origin_label}
</a>
</div>
</header>
"""
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"""
<section class={["scoria-stub", @class]}>
<div class="scoria-stub__header">
<h1>{@title}</h1>
<.badge tone={:neutral} label="Soon" />
</div>
<p class="scoria-stub__description">{@description}</p>
<div class="scoria-stub__today">
<h2>What works today</h2>
<ul>
<li :for={item <- @works_today}>
<a href={Map.fetch!(item, :path)}>{Map.fetch!(item, :label)}</a>
</li>
</ul>
</div>
<a :if={@tracking_url} class="scoria-stub__track" href={@tracking_url}>
Track progress
</a>
</section>
"""
end
attr(:class, :string, default: nil)
slot(:inner_block, required: true)
@doc "Keyboard shortcut chip. Renders a `<kbd>` 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"""
<kbd class={["scoria-kbd", @class]}>{render_slot(@inner_block)}</kbd>
"""
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"""
<div
id={@id}
class={["scoria-command", @class]}
role="dialog"
aria-modal="true"
aria-labelledby={"#{@id}-title"}
phx-hook="CommandPalette"
data-state="closed"
>
<div class="scoria-command__scrim" data-command-close aria-hidden="true"></div>
<section class="scoria-command__panel">
<header class="scoria-command__header">
<h2 id={"#{@id}-title"}>{@title}</h2>
<.kbd>Esc</.kbd>
</header>
<input
id={"#{@id}-input"}
class="scoria-command__input"
type="search"
placeholder={@placeholder}
autocomplete="off"
data-command-input
/>
<div
id={"#{@id}-listbox"}
class="scoria-command__list"
role="listbox"
aria-activedescendant={@active_id}
data-command-list
>
<section :for={section <- @sections} class="scoria-command__section" data-command-section>
<h3>{Map.fetch!(section, :label)}</h3>
<div class="scoria-command__rows">
<%= for row <- Map.get(section, :rows, []) do %>
<a
:if={command_row_path(row)}
id={command_row_id(row)}
href={command_row_path(row)}
class="scoria-command__row"
role="option"
aria-selected="false"
data-command-row
data-command-kbd={command_row_kbd(row)}
data-command-search={command_row_search(row)}
>
<span>
<span class="scoria-command__label">{command_row_label(row)}</span>
<.badge :if={Map.get(row, :soon?, false)} tone={:neutral} label="Soon" />
</span>
<.kbd :if={command_row_kbd(row)}>{command_row_kbd(row)}</.kbd>
</a>
<button
:if={!command_row_path(row)}
id={command_row_id(row)}
type="button"
class="scoria-command__row"
role="option"
aria-selected="false"
data-command-row
data-command-action={Map.get(row, :action)}
data-command-kbd={command_row_kbd(row)}
data-command-search={command_row_search(row)}
>
<span class="scoria-command__label">{command_row_label(row)}</span>
<.kbd :if={command_row_kbd(row)}>{command_row_kbd(row)}</.kbd>
</button>
<% end %>
</div>
</section>
<p class="scoria-command__empty" data-command-empty hidden>{@empty_copy}</p>
</div>
</section>
</div>
"""
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"""
<div class={["scoria-empty", @class]}>
<p class="scoria-empty__title">{@title}</p>
<div :if={@inner_block != []}>{render_slot(@inner_block)}</div>
<div :if={@action != []} class="mt-4 flex justify-center">{render_slot(@action)}</div>
</div>
"""
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"""
<div :if={@show} id={@id} class="scoria-modal" phx-window-keydown={@on_dismiss} phx-key="Escape" {@rest}>
<div class="scoria-scrim" phx-click={@on_dismiss} aria-hidden="true"></div>
<div
class="scoria-modal__panel"
role="dialog"
aria-modal="true"
aria-labelledby={(@title_slot != [] or @title != nil) && "#{@id}-title"}
style={"max-width: #{@max_width}"}
>
<div class="scoria-modal__header">
<div>
<h2 :if={@title_slot != []} id={"#{@id}-title"}>{render_slot(@title_slot)}</h2>
<h2 :if={@title_slot == [] and @title != nil} id={"#{@id}-title"}>{@title}</h2>
</div>
<button
autofocus
phx-click={@on_dismiss}
class="scoria-button scoria-button--ghost scoria-button--sm"
aria-label="Close dialog"
title="Close dialog"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">
<path fill-rule="evenodd" d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06z" clip-rule="evenodd" />
</svg>
</button>
</div>
<div class="scoria-modal__body">
{render_slot(@inner_block)}
</div>
<div :if={@footer != []} class="scoria-modal__footer">
{render_slot(@footer)}
</div>
</div>
</div>
"""
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"""
<div :if={@show} id={@id} class="scoria-drawer-shell" {@rest}>
<div
class="scoria-scrim"
phx-click={@on_dismiss}
phx-window-keydown={@on_dismiss}
phx-key="Escape"
aria-hidden="true"
></div>
<aside
class="scoria-drawer"
role="dialog"
aria-modal="true"
aria-labelledby={(@title_slot != [] or @title != nil) && "#{@id}-title"}
>
<div class="scoria-drawer__header">
<div class="scoria-drawer__header-text">
<p :if={@eyebrow != []} class="scoria-eyebrow">{render_slot(@eyebrow)}</p>
<h2 :if={@title_slot != []} id={"#{@id}-title"}>{render_slot(@title_slot)}</h2>
<h2 :if={@title_slot == [] and @title != nil} id={"#{@id}-title"}>{@title}</h2>
</div>
<div class="scoria-drawer__header-actions">
<div :if={@actions != []}>{render_slot(@actions)}</div>
<button
phx-click={@on_dismiss}
class="scoria-button scoria-button--ghost scoria-button--sm"
>
Close drawer
</button>
</div>
</div>
<div class="scoria-drawer__body">
{render_slot(@inner_block)}
</div>
</aside>
</div>
"""
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"""
<div class="scoria-field" {@rest}>
<label for={@id} class="scoria-field__label">
{@label}
<span :if={@required} aria-hidden="true" style="color: var(--scoria-danger-action)">*</span>
<span :if={@required} class="sr-only">(required)</span>
</label>
{render_slot(@inner_block)}
<p :if={@help} class="scoria-field__help">{@help}</p>
<p :if={@error} class="scoria-field__error">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" fill="currentColor">
<path fill-rule="evenodd" d="M6 1a5 5 0 1 0 0 10A5 5 0 0 0 6 1zM5.25 4a.75.75 0 0 1 1.5 0v2.25a.75.75 0 0 1-1.5 0V4zm.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5z" clip-rule="evenodd" />
</svg>
{@error}
</p>
</div>
"""
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"""
<section class="scoria-form-section" {@rest}>
<h3 class="scoria-form-section__title">{@title}</h3>
<p :if={@description} class="scoria-form-section__description">{@description}</p>
{render_slot(@inner_block)}
</section>
"""
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"""
<div class="scoria-skeleton-group" aria-label="Loading…" role="status" {@rest}>
<div :for={_ <- 1..@rows//1} class={["scoria-skeleton", "scoria-skeleton--text", @class]}></div>
</div>
"""
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"""
<div
id={@id}
class={["scoria-toast", "scoria-toast--#{@tone}"]}
role="status"
phx-mounted={JS.hide(transition: {"scoria-fade", "opacity-100", "opacity-0"}, time: @duration_ms)}
>
{toast_icon(@tone)}
<p>{@message}</p>
<button
phx-click={JS.hide(to: "##{@id}", transition: {"scoria-fade", "opacity-100", "opacity-0"}, time: 100)}
class="scoria-button scoria-button--ghost scoria-button--sm"
aria-label="Dismiss"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">
<path fill-rule="evenodd" d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06z" clip-rule="evenodd" />
</svg>
</button>
</div>
"""
end
# 16×16 inline SVG tone icons for toast — status never by color alone (a11y DS-05).
defp toast_icon(:pass) do
assigns = %{}
~H"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">
<path fill-rule="evenodd" d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm3.78 5.78a.75.75 0 0 0-1.06-1.06L7 9.44 5.28 7.72a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.06 0l4.25-4.25z" clip-rule="evenodd" />
</svg>
"""
end
defp toast_icon(:fail) do
assigns = %{}
~H"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">
<path fill-rule="evenodd" d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zM7 5a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0V5zm1 6.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5z" clip-rule="evenodd" />
</svg>
"""
end
defp toast_icon(:warn) do
assigns = %{}
~H"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">
<path fill-rule="evenodd" d="M8.22 1.3a.25.25 0 0 0-.44 0L.36 14.26a.25.25 0 0 0 .22.37h14.84a.25.25 0 0 0 .22-.37L8.22 1.3zm-.72 4.7a.5.5 0 0 1 1 0v3a.5.5 0 0 1-1 0V6zm.75 5.5a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0z" clip-rule="evenodd" />
</svg>
"""
end
defp toast_icon(:info) do
assigns = %{}
~H"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">
<path fill-rule="evenodd" d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm-1 4a1 1 0 0 1 1-1h.01a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V8z" clip-rule="evenodd" />
</svg>
"""
end
defp toast_icon(_tone) do
assigns = %{}
~H"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">
<path fill-rule="evenodd" d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm-1 4a1 1 0 0 1 1-1h.01a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V8z" clip-rule="evenodd" />
</svg>
"""
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 <nav role='tablist'> tab bar with one <button role='tab'> per <:tab> slot.
The tab matching @selected_tab carries aria-selected='true'. Clicking a tab emits
phx-click={@on_tab_change} phx-value-tab={key}. When @empty is true, the :empty_slot
body is rendered instead of the tab bar and panel."
def notebook(assigns) do
# WR-05: a notebook with more than one tab but no on_tab_change handler would
# render multiple inert (phx-click=nil) controls that look clickable but cannot
# switch panels. Fail loudly at render rather than ship a silently-broken control.
if length(assigns.tab) > 1 and is_nil(assigns.on_tab_change) do
raise ArgumentError,
"<.notebook> with more than one tab requires on_tab_change; " <>
"got #{length(assigns.tab)} tabs and on_tab_change=nil"
end
# WR-04: select the first tab whenever selected_tab is nil OR points at a key
# that no current <:tab> exposes (stale value after the tab set changed, or a
# typo). Without this the tablist renders with every tab aria-selected=false and
# the panel comprehension yields nothing, so the tabpanel body silently vanishes.
assigns =
if assigns.tab != [] and not Enum.any?(assigns.tab, &(&1.key == assigns.selected_tab)) do
assign(assigns, :selected_tab, hd(assigns.tab).key)
else
assigns
end
~H"""
<div id={@id} class="scoria-notebook" {@rest}>
<div class="scoria-notebook__header">
<p :if={@eyebrow} class="scoria-eyebrow">{@eyebrow}</p>
<h2 class="scoria-notebook__title">{@title}</h2>
</div>
<%= if @empty do %>
<div class="scoria-notebook__panel">
{render_slot(@empty_slot)}
</div>
<% else %>
<nav class="scoria-notebook__tabbar" role="tablist">
<%= for tab <- @tab do %>
<%= if @on_tab_change do %>
<button
role="tab"
id={"#{@id}-tab-#{tab.key}"}
aria-selected={to_string(tab.key == @selected_tab)}
class={["scoria-notebook__tab", tab.key == @selected_tab && "scoria-notebook__tab--active"]}
phx-click={@on_tab_change}
phx-value-tab={tab.key}
>
{tab.label}
</button>
<% else %>
<%!-- WR-05: no on_tab_change handler (single-tab notebook); render a
non-interactive span so the control does not present as a clickable
button that is actually inert. --%>
<span
role="tab"
id={"#{@id}-tab-#{tab.key}"}
aria-selected={to_string(tab.key == @selected_tab)}
class={["scoria-notebook__tab", tab.key == @selected_tab && "scoria-notebook__tab--active"]}
>
{tab.label}
</span>
<% end %>
<% end %>
</nav>
<%= for tab <- @tab, tab.key == @selected_tab do %>
<div
role="tabpanel"
class="scoria-notebook__panel"
aria-labelledby={"#{@id}-tab-#{tab.key}"}
>
{render_slot(tab)}
</div>
<% end %>
<% end %>
</div>
"""
end
attr(:label, :string, default: "Advanced raw evidence")
slot(:inner_block, required: true)
@doc "Raw evidence details/pre block (DS-04).
Renders a <details>/<summary> with a <pre> code block. Background and font
tokens are applied via CSS class — not overridden in HEEx."
def raw_evidence(assigns) do
~H"""
<details class="scoria-raw-evidence">
<summary class="scoria-raw-evidence__summary">{@label}</summary>
<pre class="scoria-raw-evidence__pre">{render_slot(@inner_block)}</pre>
</details>
"""
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"""
<section class={["scoria-evidence-section", @class]} {@rest}>
<div class="scoria-evidence-section__header">
<div class="scoria-evidence-section__heading">
<div class="scoria-evidence-section__title-row">
<h3 class="scoria-evidence-section__title">{@title}</h3>
<.badge :if={@badge} tone={@tone} label={@badge} />
</div>
<p :if={@description} class="scoria-evidence-section__description">{@description}</p>
</div>
<div :if={@actions != []} class="scoria-evidence-section__actions">
{render_slot(@actions)}
</div>
</div>
<div class="scoria-evidence-section__body">
{render_slot(@inner_block)}
</div>
</section>
"""
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 `<dl>` of `<dt>/<dd>` 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"""
<dl class={["scoria-evidence-rows", @class]} {@rest}>
<div :for={row <- @normalized_rows} class="scoria-evidence-row">
<dt class="scoria-evidence-row__label">{row.label}</dt>
<dd class="scoria-evidence-row__value">{row.value}</dd>
</div>
</dl>
"""
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 `<a>` or `<.button>`
elements inside."
def evidence_action_row(assigns) do
~H"""
<div class={["scoria-evidence-action-row", @class]} {@rest}>
{render_slot(@inner_block)}
</div>
"""
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"""
<div class={["scoria-evidence-empty", @class]} {@rest}>
<p class="scoria-evidence-empty__title">{@title}</p>
<div class="scoria-evidence-empty__body">{render_slot(@inner_block)}</div>
</div>
"""
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"""
<div class={["scoria-table-shell", @mobile_summary != [] && "scoria-table-shell--has-summary"]}>
<div :if={@filter != []} class="scoria-table__filter">
{render_slot(@filter)}
</div>
<div :if={@on_density_change} class="scoria-table__density-toggle" role="group" aria-label="Row density">
<button
:for={density_opt <- [:compact, :default, :comfortable]}
phx-click={@on_density_change}
phx-value-density={density_opt}
class={[
if(@density == density_opt,
do: "scoria-button scoria-button--primary scoria-button--sm",
else: "scoria-button scoria-button--ghost scoria-button--sm"
)
]}
>
{density_opt |> Atom.to_string() |> String.capitalize()}
</button>
</div>
<%!-- Overflow viewport: keyboard-reachable horizontal scroll container (D-13).
sticky thead still works inside overflow-x: auto (vertical sticky is unaffected). --%>
<div class="scoria-table__viewport" tabindex="0">
<table class={["scoria-table", density_class(@density)]} id={@id} {@rest}>
<thead>
<tr>
<th
:for={column <- @col}
class={["scoria-table__th", Map.get(column, :class)]}
phx-click={@on_sort && Map.get(column, :key) && @on_sort}
phx-value-by={@on_sort && Map.get(column, :key)}
aria-sort={
if @on_sort && Map.get(column, :key) != nil do
if @sort_by == Map.get(column, :key) do
if @sort_dir == :desc, do: "descending", else: "ascending"
else
"none"
end
end
}
>
{column.label}
<svg
:if={@on_sort && Map.get(column, :key) != nil}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width="16"
height="16"
aria-hidden="true"
fill="currentColor"
style={
if @sort_by == Map.get(column, :key),
do: "color: var(--scoria-action)",
else: "color: var(--scoria-text-subtle)"
}
>
<path
:if={@sort_by == Map.get(column, :key) and @sort_dir == :desc}
fill-rule="evenodd"
d="M8 4.25a.75.75 0 0 1 .75.75v6.19l1.47-1.47a.75.75 0 1 1 1.06 1.06l-2.75 2.75a.75.75 0 0 1-1.06 0L4.72 11.28a.75.75 0 1 1 1.06-1.06L7.25 11.19V5a.75.75 0 0 1 .75-.75z"
clip-rule="evenodd"
/>
<path
:if={not (@sort_by == Map.get(column, :key) and @sort_dir == :desc)}
fill-rule="evenodd"
d="M8 11.75a.75.75 0 0 1-.75-.75V4.81L5.78 6.28a.75.75 0 1 1-1.06-1.06l2.75-2.75a.75.75 0 0 1 1.06 0l2.75 2.75a.75.75 0 1 1-1.06 1.06L8.75 4.81V11a.75.75 0 0 1-.75.75z"
clip-rule="evenodd"
/>
</svg>
</th>
<th :if={@action != []} class="scoria-table__th scoria-table__th--actions"></th>
</tr>
</thead>
<tbody>
<tr :if={@rows == []}>
<td colspan={length(@col) + if(@action != [], do: 1, else: 0)}>
<%= if @empty != [] do %>
{render_slot(@empty)}
<% else %>
<.empty_state title="No records found">
Adjust your filters or check back when data is available.
</.empty_state>
<% end %>
</td>
</tr>
<tr :for={row <- @rows}>
<td :for={column <- @col} class={Map.get(column, :class)}>{render_slot(column, row)}</td>
<td :if={@action != []} class="scoria-table__td--actions">{render_slot(@action, row)}</td>
</tr>
</tbody>
</table>
</div>
<%!-- 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. --%>
<div :if={@mobile_summary != []} class="scoria-table__mobile-summaries">
<div :for={row <- @rows}>
{render_slot(@mobile_summary, row)}
</div>
</div>
<nav :if={@total_pages > 1} aria-label="Pagination" class="scoria-table__pagination">
<button
class="scoria-button scoria-button--ghost scoria-button--sm"
phx-click={@on_page_change}
phx-value-page={@page - 1}
disabled={@page <= 1}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">
<path fill-rule="evenodd" d="M9.78 3.22a.75.75 0 0 0-1.06 0L4.47 7.47a.75.75 0 0 0 0 1.06l4.25 4.25a.75.75 0 0 0 1.06-1.06L6.06 8l3.72-3.72a.75.75 0 0 0 0-1.06z" clip-rule="evenodd" />
</svg>
</button>
<span class="scoria-table__page-label">Page {@page} of {@total_pages}</span>
<button
class="scoria-button scoria-button--ghost scoria-button--sm"
phx-click={@on_page_change}
phx-value-page={@page + 1}
disabled={@page >= @total_pages}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">
<path fill-rule="evenodd" d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06z" clip-rule="evenodd" />
</svg>
</button>
</nav>
</div>
"""
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"""
<div
:for={{kind, message} <- @flash}
id={"flash-#{kind}"}
role="alert"
class={["scoria-flash", flash_modifier(kind)]}
>
{flash_icon(kind)}
{message}
</div>
"""
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"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">
<path fill-rule="evenodd" d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zM7 5a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0V5zm1 6.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5z" clip-rule="evenodd" />
</svg>
"""
end
defp flash_icon("info") do
assigns = %{}
~H"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">
<path fill-rule="evenodd" d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm-1 4a1 1 0 0 1 1-1h.01a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V8z" clip-rule="evenodd" />
</svg>
"""
end
defp flash_icon("success") do
assigns = %{}
~H"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">
<path fill-rule="evenodd" d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm3.78 5.78a.75.75 0 0 0-1.06-1.06L7 9.44 5.28 7.72a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.06 0l4.25-4.25z" clip-rule="evenodd" />
</svg>
"""
end
defp flash_icon(_kind) do
assigns = %{}
~H"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">
<path fill-rule="evenodd" d="M8.22 1.3a.25.25 0 0 0-.44 0L.36 14.26a.25.25 0 0 0 .22.37h14.84a.25.25 0 0 0 .22-.37L8.22 1.3zm-.72 4.7a.5.5 0 0 1 1 0v3a.5.5 0 0 1-1 0V6zm.75 5.5a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0z" clip-rule="evenodd" />
</svg>
"""
end
end