lib/openagents_web/components/ui.ex

main at 58e6347eeb72 · 54 KB

defmodule OpenAgentsWeb.UI do
  @moduledoc """
  OpenAgents interface primitives.

  Every component here wraps a vendored Basecoat class and exposes OpenAgents
  vocabulary rather than Basecoat's. Product surfaces build interface only from
  these components; they do not author component-level CSS classes. See
  `assets/css/openagents.css` and
  `docs/decisions/0005-use-basecoat-and-one-component-system.md`.

  Basecoat expresses variants as data attributes (`data-variant`, `data-size`),
  so each `attr` maps straight onto the DOM with no class-merging utility.
  `attr` values are constrained, which makes an unknown variant a compile error
  rather than an unstyled control.

  Two rules from `DESIGN.md` are enforced by these signatures rather than by
  review:

    * There is no icon-only control, so `button/1` exposes no icon size.
    * Semantic color reinforces a text label and never replaces one, so
      `status_indicator/1` requires a label.

  No component accepts provider identifiers or private recall content. Tool
  activity reaches `event_header/1` only as the bounded, already-scrubbed
  durable projection that `INVARIANTS.md` UI-002 sanctions — derived in
  `OpenAgentsWeb.ToolActivity`, byte-capped before it touches a template.
  """

  use Phoenix.Component

  alias Phoenix.LiveView.JS

  @doc """
  A boxed control.

  `:link` renders OpenAgents inline underlined action rather than a boxed button
  and ignores `size`; pair it with `tone={:danger}` for a destructive inline
  action.

  `:chip` is the compact keycap treatment: a quiet raised surface with a
  hairline border, for a secondary control that should read as an object in the
  chrome rather than as a link.

  `:notched` is a port of Arwes' octagon button: two opposite corners cut away,
  a bright edge, and a glow that follows the cut shape. Arwes draws that outline
  as an SVG behind the content; here it is a `clip-path` polygon, which needs no
  runtime. Reserved for a page's single primary action — it is loud, and the
  product only has room for one.

  Given `href`, it renders an `<a>` with the same visual treatment — like
  `text_button/1` — so a download or plain GET destination can wear any boxed
  variant without hand-written markup.
  """
  attr :variant, :atom,
    values: [:primary, :secondary, :outline, :ghost, :destructive, :chip, :notched, :link],
    default: :primary

  attr :size, :atom, values: [:default, :xs, :sm, :lg], default: :default
  attr :tone, :atom, values: [:default, :danger], default: :default
  attr :type, :string, default: "button"
  attr :class, :any, default: nil

  attr :rest, :global,
    include:
      ~w(disabled form name value popovertarget popovertargetaction download href navigate patch rel target)

  slot :inner_block, required: true

  def button(assigns) do
    ~H"""
    <.link
      :if={@rest[:href] || @rest[:navigate] || @rest[:patch]}
      class={["btn", @class]}
      data-variant={@variant}
      data-size={@size != :default && @size}
      data-tone={@tone != :default && @tone}
      {@rest}
    >
      {render_slot(@inner_block)}
    </.link>
    <button
      :if={!(@rest[:href] || @rest[:navigate] || @rest[:patch])}
      type={@type}
      class={["btn", @class]}
      data-variant={@variant}
      data-size={@size != :default && @size}
      data-tone={@tone != :default && @tone}
      {@rest}
    >
      {render_slot(@inner_block)}
    </button>
    """
  end

  @doc """
  An inline underlined action. Shorthand for `button/1` with `variant={:link}`.

  Renders an `<a>` when given `navigate`, `patch`, or `href`, so export and
  download links carry the same affordance as in-page actions.
  """
  attr :tone, :atom, values: [:default, :danger], default: :default
  attr :type, :string, default: "button"
  attr :class, :any, default: nil
  attr :rest, :global, include: ~w(disabled form name value href download navigate patch)
  slot :inner_block, required: true

  def text_button(assigns) do
    ~H"""
    <.link
      :if={@rest[:href] || @rest[:navigate] || @rest[:patch]}
      class={["btn", @class]}
      data-variant="link"
      data-tone={@tone != :default && @tone}
      {@rest}
    >
      {render_slot(@inner_block)}
    </.link>
    <button
      :if={!(@rest[:href] || @rest[:navigate] || @rest[:patch])}
      type={@type}
      class={["btn", @class]}
      data-variant="link"
      data-tone={@tone != :default && @tone}
      {@rest}
    >
      {render_slot(@inner_block)}
    </button>
    """
  end

  @doc "A form-aware input or an unwrapped single-line text control."
  attr :id, :any, default: nil
  attr :name, :any, default: nil
  attr :label, :string, default: nil
  attr :value, :any, default: nil

  attr :type, :string,
    default: "text",
    values: ~w(checkbox color date datetime-local email file month number password
               search select tel text textarea time url week hidden)

  attr :field, Phoenix.HTML.FormField,
    default: nil,
    doc: "a form field struct retrieved from the form, for example: @form[:email]"

  attr :errors, :list, default: []
  attr :checked, :boolean, default: nil
  attr :prompt, :string, default: nil
  attr :options, :list, default: []
  attr :multiple, :boolean, default: false
  attr :class, :any, default: nil
  attr :error_class, :any, default: nil

  attr :rest, :global,
    include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
                multiple pattern placeholder readonly required rows size step)

  def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
    errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []

    assigns
    |> assign(field: nil, id: assigns.id || field.id)
    |> assign(:errors, Enum.map(errors, &translate_error/1))
    |> assign(
      :name,
      assigns.name || if(assigns.multiple, do: field.name <> "[]", else: field.name)
    )
    |> assign(:value, if(is_nil(assigns.value), do: field.value, else: assigns.value))
    |> input()
  end

  def input(%{type: "hidden"} = assigns) do
    ~H"""
    <input type="hidden" id={@id} name={@name} value={@value} {@rest} />
    """
  end

  def input(%{type: "checkbox"} = assigns) do
    assigns =
      assign_new(assigns, :checked, fn ->
        Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
      end)

    ~H"""
    <.field class="mb-2">
      <input
        type="hidden"
        name={@name}
        value="false"
        disabled={@rest[:disabled]}
        form={@rest[:form]}
      />
      <.label for={@id} class="inline-flex items-center gap-2">
        <input
          type="checkbox"
          id={@id}
          name={@name}
          value="true"
          checked={@checked}
          class={@class || "size-4 shrink-0 accent-primary"}
          {@rest}
        />
        {@label}
      </.label>
      <.error :for={message <- @errors}>{message}</.error>
    </.field>
    """
  end

  def input(%{type: "select"} = assigns) do
    ~H"""
    <.field class="mb-2">
      <.label :if={@label} for={@id}>{@label}</.label>
      <select
        id={@id}
        name={@name}
        class={[@class || "input w-full", "aria-invalid:border-destructive", @error_class]}
        aria-invalid={@errors != [] && "true"}
        multiple={@multiple}
        {@rest}
      >
        <option :if={@prompt} value="">{@prompt}</option>
        {Phoenix.HTML.Form.options_for_select(@options, @value)}
      </select>
      <.error :for={message <- @errors}>{message}</.error>
    </.field>
    """
  end

  def input(%{type: "textarea"} = assigns) do
    ~H"""
    <.field class="mb-2">
      <.label :if={@label} for={@id}>{@label}</.label>
      <.textarea
        id={@id}
        name={@name}
        value={Phoenix.HTML.Form.normalize_value("textarea", @value)}
        class={[@class || "w-full", "aria-invalid:border-destructive", @error_class]}
        aria-invalid={@errors != [] && "true"}
        {@rest}
      />
      <.error :for={message <- @errors}>{message}</.error>
    </.field>
    """
  end

  def input(%{label: nil, errors: []} = assigns) do
    ~H"""
    <input
      type={@type}
      id={@id}
      name={@name}
      value={Phoenix.HTML.Form.normalize_value(@type, @value)}
      class={["input", @class]}
      {@rest}
    />
    """
  end

  def input(assigns) do
    ~H"""
    <.field class="mb-2">
      <.label :if={@label} for={@id}>{@label}</.label>
      <input
        type={@type}
        id={@id}
        name={@name}
        value={Phoenix.HTML.Form.normalize_value(@type, @value)}
        class={[@class || "input w-full", "aria-invalid:border-destructive", @error_class]}
        aria-invalid={@errors != [] && "true"}
        {@rest}
      />
      <.error :for={message <- @errors}>{message}</.error>
    </.field>
    """
  end

  @doc "A multiline text control."
  attr :id, :string, default: nil
  attr :name, :string, default: nil
  attr :value, :string, default: nil
  attr :class, :any, default: nil

  attr :rest, :global,
    include: ~w(autocomplete disabled maxlength placeholder readonly required rows)

  def textarea(assigns) do
    ~H"""
    <textarea id={@id} name={@name} class={["textarea", @class]} {@rest}>{@value}</textarea>
    """
  end

  @doc "A label bound to a control."
  attr :for, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def label(assigns) do
    ~H"""
    <label for={@for} class={["label", @class]} {@rest}>{render_slot(@inner_block)}</label>
    """
  end

  @doc "A labelled control group."
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def field(assigns) do
    ~H"""
    <div class={["field", @class]} {@rest}>{render_slot(@inner_block)}</div>
    """
  end

  @doc "A page heading with optional supporting text and actions."
  slot :inner_block, required: true
  slot :subtitle
  slot :actions

  def header(assigns) do
    ~H"""
    <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
      <div>
        <h1 class="text-lg font-semibold leading-8">{render_slot(@inner_block)}</h1>
        <p :if={@subtitle != []} class="text-sm text-muted-foreground">
          {render_slot(@subtitle)}
        </p>
      </div>
      <div class="flex-none">{render_slot(@actions)}</div>
    </header>
    """
  end

  @doc "A responsive table for regular lists or LiveView streams."
  attr :id, :string, required: true
  attr :rows, :list, required: true
  attr :row_id, :any, default: nil
  attr :row_item, :any, default: &Function.identity/1

  slot :col, required: true do
    attr :label, :string
  end

  slot :action

  def table(assigns) do
    assigns =
      with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
        assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
      end

    ~H"""
    <div class="table-container">
      <table class="table text-sm">
        <thead>
          <tr>
            <th :for={column <- @col} class="px-3 py-2 text-left text-muted-foreground">
              {column[:label]}
            </th>
            <th :if={@action != []} class="px-3 py-2"><span class="sr-only">Actions</span></th>
          </tr>
        </thead>
        <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
          <tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="hover:bg-muted/40">
            <%!-- No row-level click. A `phx-click` on a `td` is not reachable
            by keyboard and announces nothing, so a table built that way is
            usable only with a mouse. Put a real control in a cell instead. --%>
            <td :for={column <- @col} class="px-3 py-2">
              {render_slot(column, @row_item.(row))}
            </td>
            <td :if={@action != []} class="w-0 px-3 py-2 font-semibold">
              <div class="flex gap-4">
                <%= for action <- @action do %>
                  {render_slot(action, @row_item.(row))}
                <% end %>
              </div>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
    """
  end

  @doc "A title and description list."
  slot :item, required: true do
    attr :title, :string, required: true
  end

  def list(assigns) do
    ~H"""
    <ul class="divide-y divide-border">
      <li :for={item <- @item} class="flex items-start gap-4 py-3">
        <div class="min-w-0 grow">
          <div class="font-semibold">{item.title}</div>
          <div class="text-muted-foreground">{render_slot(item)}</div>
        </div>
      </li>
    </ul>
    """
  end

  defp error(assigns) do
    ~H"""
    <p class="mt-1.5 flex items-center gap-2 text-sm text-destructive">
      <.icon name="warning" class="size-5" />
      {render_slot(@inner_block)}
    </p>
    """
  end

  @doc """
  An inline notice.

  Always renders in flow. `DESIGN.md` forbids toast-only errors, so there is no
  floating or auto-dismissing variant.

    * `:row` — a full-bleed rule inside the app shell (composer error, memory status)
    * `:notice` — a bordered box carrying prose (authentication error)
    * `:box` — a bordered box with a label column (flash notices)
  """
  attr :id, :string, default: nil
  attr :variant, :atom, values: [:info, :success, :warning, :danger], default: :info
  attr :appearance, :atom, values: [:box, :row, :notice], default: :box
  attr :label, :string, default: nil
  attr :class, :any, default: nil
  # Declared rather than left to `:global` so an explicit role replaces the
  # variant default instead of rendering the attribute twice.
  attr :role, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true
  slot :action

  def alert(assigns) do
    ~H"""
    <div
      id={@id}
      class={["alert", @class]}
      data-variant={@variant}
      data-appearance={@appearance != :box && @appearance}
      role={@role || alert_role(@variant)}
      {@rest}
    >
      <strong :if={@label} data-title>{@label}</strong>
      <section>{render_slot(@inner_block)}</section>
      <span :if={@action != []}>{render_slot(@action)}</span>
    </div>
    """
  end

  defp alert_role(:danger), do: "alert"
  defp alert_role(_variant), do: "status"

  @doc """
  A short typographic status label.

  OpenAgents states status as text; the badge carries the reserved semantic color
  and never stands in for the words.
  """
  attr :variant, :atom,
    values: [:default, :info, :success, :warning, :danger, :done, :dim],
    default: :default

  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def badge(assigns) do
    ~H"""
    <span class={["badge", @class]} data-variant={@variant != :default && @variant} {@rest}>
      {render_slot(@inner_block)}
    </span>
    """
  end

  @doc """
  A softened content panel.

  Room around it, one hairline border, and a restrained lift. Still not a
  bubble and never a heavy floating box.

  (Careful with wording here. Tailwind scans this file for class candidates and
  harvests bare tokens out of prose, so naming a utility in a comment emits that
  utility into the shipped bundle. Describe geometry rather than naming a class.)
  """
  attr :id, :string, default: nil
  attr :variant, :atom, values: [:default, :danger], default: :default
  attr :frame, :atom, values: [:none, :corners], default: :none
  attr :state, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def card(assigns) do
    ~H"""
    <article
      id={@id}
      class={["card", @class]}
      data-variant={@variant != :default && @variant}
      data-frame={@frame != :none && @frame}
      data-state={@state}
      {@rest}
    >
      {render_slot(@inner_block)}
    </article>
    """
  end

  @doc """
  An identity avatar.

  Takes a validated image URL or falls back to an initial. Avatars are the only
  circular geometry in the product.
  """
  attr :src, :string, default: nil
  attr :alt, :string, default: ""
  attr :size, :atom, values: [:default, :sm, :lg], default: :default
  attr :tone, :atom, values: [:default, :accent], default: :default
  attr :fallback, :string, default: nil
  attr :label, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global

  def avatar(assigns) do
    ~H"""
    <span
      class={["avatar", @class]}
      data-size={@size != :default && @size}
      data-tone={@tone != :default && @tone}
      role={@label && "img"}
      aria-label={@label}
      {@rest}
    >
      <img
        :if={@src}
        src={@src}
        alt={@alt}
        loading="lazy"
        decoding="async"
        referrerpolicy="no-referrer"
      />
      <span :if={!@src}>{@fallback}</span>
    </span>
    """
  end

  @doc """
  One row of bounded activity.

  Accepts only a public label, a lifecycle status, and an optional terminal
  executor disclosure. Tool activity in the transcript renders through
  `event_header/1` now; this stays the compact status-row primitive.
  """
  attr :id, :string, default: nil
  attr :status, :string, required: true
  attr :label, :string, required: true
  attr :detail, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global

  def item(assigns) do
    ~H"""
    <div id={@id} class={["item", @class]} data-status={@status} {@rest}>
      <.status_indicator state={@status} label={@label} decorative />
      <section>{@label}</section>
      <aside :if={@detail}>{@detail}</aside>
    </div>
    """
  end

  @doc """
  One durable event as a disclosure row.

  The collapsed row is a chevron disclosure button, a lit status dot, a quiet
  bounded one-line title saying what actually ran, an optional short status
  note in text (so color never carries the outcome alone), optional outcome
  chips, and a hover-revealed timestamp. The expansion slot carries the bounded
  durable details — arguments, result/error, executor identity and disclosure,
  timestamps.

  Expansion is pure client state: the button toggles `aria-expanded` on itself
  and `data-expanded` on the row, and the stylesheet shows the details region.
  A stream re-insert collapses the row again, which is the honest default.

  Accepts only the bounded, already-scrubbed durable projection that
  `INVARIANTS.md` UI-002 sanctions — never a provider identifier or private
  recall content.
  """
  attr :id, :string, required: true
  attr :status, :string, required: true
  attr :title, :string, required: true
  attr :title_attribute, :string, default: nil
  attr :status_note, :string, default: nil
  attr :timestamp, :any, default: nil
  attr :class, :any, default: nil
  attr :rest, :global
  slot :chips
  slot :inner_block, required: true

  def event_header(assigns) do
    ~H"""
    <div id={@id} class={["event-header", @class]} data-status={@status} data-expanded="false" {@rest}>
      <button
        type="button"
        class="event-header__row"
        aria-expanded="false"
        aria-controls={"#{@id}-details"}
        phx-click={
          JS.toggle_attribute({"data-expanded", "true", "false"}, to: "##{@id}")
          |> JS.toggle_attribute({"aria-expanded", "true", "false"})
        }
      >
        <.icon name="chevron-right-md" class="event-header__chevron" />
        <.status_indicator state={@status} label={@status} decorative />
        <span class="event-header__title" title={@title_attribute || @title}>{@title}</span>
        <span :if={@status_note} class="event-header__note">{@status_note}</span>
        <span :if={@chips != []} class="event-header__chips">{render_slot(@chips)}</span>
        <time
          :if={@timestamp}
          class="event-header__time"
          datetime={DateTime.to_iso8601(@timestamp)}
        >
          {Calendar.strftime(@timestamp, "%H:%M")}
        </time>
      </button>
      <div id={"#{@id}-details"} class="event-header__details">
        {render_slot(@inner_block)}
      </div>
    </div>
    """
  end

  @doc """
  When something happened, drawn as how long ago and nothing more.

  A repository's latest commit said `2026-08-22` while the issue list on the
  next tab said `4m ago` for work of the same age, so the forge spoke two time
  dialects on adjacent pages (#27). This is the one answer both now read: the
  coarse span from `OpenAgentsWeb.RelativeTime` — minutes for the first hour,
  hours for the first day, days after that.

  Shortening what is drawn never removes precision, only moves it. The exact
  moment stays one hover away in `title` and machine-readable in `datetime`, so
  a reader who needs the second still has it and a parser never sees prose.

  A moment the component cannot read renders nothing at all, because an empty
  `<time>` element claims a fact it does not have.
  """
  attr :at, :any,
    default: nil,
    doc: "a `DateTime`, a `NaiveDateTime` read as UTC, or an ISO 8601 string"

  attr :class, :any, default: nil
  attr :rest, :global

  def time_ago(assigns) do
    assigns = assign(assigns, :span, OpenAgentsWeb.RelativeTime.ago(assigns.at))

    ~H"""
    <time
      :if={@span}
      class={@class}
      datetime={OpenAgentsWeb.RelativeTime.machine(@at)}
      title={OpenAgentsWeb.RelativeTime.exact(@at)}
      {@rest}
    >{@span}</time>
    """
  end

  @doc "An empty state explaining what would appear here and how."
  attr :id, :string, default: nil
  attr :title, :string, required: true
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def empty(assigns) do
    ~H"""
    <div id={@id} class={["empty", @class]} {@rest}>
      <header>
        <h2>{@title}</h2>
        <p>{render_slot(@inner_block)}</p>
      </header>
    </div>
    """
  end

  @doc "A key name stated as text."
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def kbd(assigns) do
    ~H"""
    <kbd class={["kbd", @class]} {@rest}>{render_slot(@inner_block)}</kbd>
    """
  end

  @doc """
  A bounded disclosure anchored to a trigger.

  Uses the native `popover` API. Basecoat's JavaScript dropdown is deliberately
  not adopted: this control must work without custom client-side JavaScript.
  Render the matching trigger with `button/1` and
  `popovertarget={id} popovertargetaction="toggle"`.

  Carries OpenAgents own `.menu` class rather than Basecoat's `.popover`. In
  Basecoat, `.popover` is the *anchor* (`position: relative`) and the floating
  panel is `[data-popover]`, positioned by its JavaScript. Putting `.popover` on
  the panel gave it author-origin `display: inline-flex`, which outranks the
  user agent's `[popover]:not(:popover-open) { display: none }` and leaves the
  menu permanently open and in flow. OpenAgents styles the native element directly.
  """
  attr :id, :string, required: true
  attr :label, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def menu(assigns) do
    ~H"""
    <div id={@id} class={["menu", @class]} popover="auto" role="menu" aria-label={@label} {@rest}>
      {render_slot(@inner_block)}
    </div>
    """
  end

  @doc """
  A decorative corner frame around a region.

  A port of Arwes' `corners` frame: eight strokes, two at each corner, at a
  fixed length that does not scale with the region. Arwes evaluates percentage
  expressions against a measured element and draws into an SVG; the same
  geometry is eight background gradients here, which needs no measurement and
  no JavaScript.

  Decorative only. It carries no state and no meaning, so it is hidden from
  assistive technology by the fact that it renders nothing readable.
  """
  attr :variant, :atom, values: [:corners], default: :corners
  attr :class, :any, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def frame(assigns) do
    ~H"""
    <div class={["frame", @class]} data-variant={@variant} {@rest}>
      {render_slot(@inner_block)}
    </div>
    """
  end

  @doc """
  A lit semantic state marker.

  `DESIGN.md`: color reinforces state but never replaces the words, so a label
  is required. Pass `decorative` when an adjacent element already states the
  same label in text, which hides the marker from assistive technology instead
  of announcing it twice.
  """
  attr :state, :string, required: true
  attr :label, :string, required: true
  attr :decorative, :boolean, default: false
  attr :class, :any, default: nil
  attr :rest, :global

  def status_indicator(assigns) do
    ~H"""
    <span
      class={["status-indicator", @class]}
      data-state={@state}
      role={!@decorative && "img"}
      aria-label={!@decorative && @label}
      aria-hidden={@decorative && "true"}
      {@rest}
    />
    """
  end

  @doc """
  A native audio player for one stored recording.

  Basecoat has no audio component and OpenAgents does not ship a custom transport, so
  this wraps the browser's own `<audio controls>` rather than rebuilding play,
  pause, and volume in JavaScript. Native controls are already keyboard operable
  and already announced, which a hand-rolled transport would have to re-earn.

  `label` is required because a page of recordings is a page of near-identical
  players; without a name, every one of them announces as "audio". `preload` is
  metadata-only so opening the panel does not pull megabytes of audio for calls
  the operator never plays.
  """
  attr :id, :string, default: nil
  attr :src, :string, required: true
  attr :label, :string, required: true
  attr :class, :any, default: nil
  attr :rest, :global

  def audio_player(assigns) do
    ~H"""
    <audio
      id={@id}
      class={["audio-player", @class]}
      src={@src}
      controls
      preload="metadata"
      aria-label={@label}
      {@rest}
    />
    """
  end

  @doc """
  A trail of ancestor links ending in the current page.

  The last item is the current page: it renders as `aria-current="page"` and is
  not a link, because linking a page to itself is a dead control that still
  looks live. Separators carry `aria-hidden` so assistive technology reads the
  trail as a list of places rather than a stream of glyphs.
  """
  attr :class, :any, default: nil
  attr :label, :string, default: "Breadcrumb"
  attr :rest, :global

  slot :item, required: true do
    attr :navigate, :string
    attr :patch, :string
    attr :href, :string
  end

  def breadcrumb(assigns) do
    assigns = assign(assigns, :last_index, length(assigns.item) - 1)

    ~H"""
    <nav class={["breadcrumb", @class]} aria-label={@label} {@rest}>
      <ol>
        <li :for={{item, index} <- Enum.with_index(@item)}>
          <span :if={index > 0} class="breadcrumb__separator" aria-hidden="true">/</span>
          <.link
            :if={index < @last_index}
            navigate={item[:navigate]}
            patch={item[:patch]}
            href={item[:href]}
            class="breadcrumb__link"
          >
            {render_slot(item)}
          </.link>
          <span :if={index == @last_index} aria-current="page" class="breadcrumb__current">
            {render_slot(item)}
          </span>
        </li>
      </ol>
    </nav>
    """
  end

  @doc """
  The section bar under a repository's name: Code, Issues, Pull requests, and
  whatever else that repository publishes.

  Page navigation rather than a tab widget. Every entry changes the URL, so the
  selected one carries `aria-current="page"` and none of them carries a tab
  role -- a tab role promises panels that swap in place, and a reader who takes
  that promise and reaches for the arrow keys gets nothing.

  A count lives in its own element rather than inside the label, so "Issues"
  stays findable by that word alone and the number can be toned down, or
  dropped at a narrow width, without rewriting the string.
  """
  attr :label, :string, default: "Repository sections"
  attr :class, :any, default: nil
  attr :rest, :global

  slot :tab, required: true do
    attr :icon, :string
    attr :count, :integer
    attr :current, :boolean
    attr :navigate, :string
    attr :patch, :string
    attr :href, :string
  end

  def repo_tabs(assigns) do
    ~H"""
    <nav class={["repo-tabs", @class]} aria-label={@label} {@rest}>
      <.link
        :for={tab <- @tab}
        navigate={tab[:navigate]}
        patch={tab[:patch]}
        href={tab[:href]}
        aria-current={tab[:current] && "page"}
        class="repo-tabs__tab"
      >
        <.icon :if={tab[:icon]} name={tab.icon} />
        <span class="repo-tabs__label">{render_slot(tab)}</span>
        <span :if={tab[:count]} class="repo-tabs__count">{tab.count}</span>
      </.link>
    </nav>
    """
  end

  @doc """
  The map of one pull request stack: every layer in order, top of the stack
  first, ending at the trunk the whole stack targets.

  Layers arrive top-first because that is how a stack reads — the newest work
  sits on top and the trunk anchors the bottom, the way the branches actually
  chain. Each layer carries its pull request state as a glyph beside the
  title, so a reader sees at a glance which layers are merged, open, draft,
  or closed.

  The layer for the page the reader is on renders as text with
  `aria-current="page"` rather than as a link, for the same reason
  `breadcrumb/1` does: a page linking to itself is a dead control that still
  looks live. Every other layer is a link to its pull request.

  The trunk row is a destination too when `trunk_navigate` or `trunk_href` is
  given — the branch the stack lands on is a real place — and plain text when
  it is not, as in the catalog where there is nowhere to send the reader.
  """
  attr :id, :string, required: true
  attr :number, :integer, required: true, doc: "the stack number, scoped to the repository"
  attr :trunk, :string, required: true, doc: "the branch the whole stack targets"
  attr :trunk_navigate, :string, default: nil
  attr :trunk_href, :string, default: nil
  attr :add_navigate, :string, default: nil, doc: "where a new layer on top of the stack begins"
  attr :add_href, :string, default: nil
  attr :class, :any, default: nil

  attr :layers, :list,
    required: true,
    doc:
      "`[%{title, number, branch, state}]` from top of the stack to bottom, " <>
        "each optionally carrying `navigate` or `href` for its destination and " <>
        "`current: true` for the layer being viewed; `state` is `open`, " <>
        "`merged`, `closed`, or `draft`"

  attr :rest, :global

  slot :action, doc: "controls at the trailing edge of the header, such as unstack"

  def stack_map(assigns) do
    ~H"""
    <section id={@id} class={["stack-map", @class]} aria-label={"Stack ##{@number}"} {@rest}>
      <header class="stack-map__header">
        <span class="stack-map__title">Stack #{@number}</span>
        <span class="stack-map__count">{layer_count_label(length(@layers))}</span>
        <span :if={@action != []} class="stack-map__actions">{render_slot(@action)}</span>
      </header>
      <ol class="stack-map__layers">
        <li :if={@add_navigate || @add_href} class="stack-map__add">
          <.link navigate={@add_navigate} href={@add_href} class="stack-map__add-link">
            <.icon name="plus" class="stack-map__add-icon" /> Add to stack
          </.link>
        </li>
        <li
          :for={layer <- @layers}
          class="stack-map__layer"
          data-state={layer.state}
          aria-current={layer[:current] && "page"}
        >
          <.icon name={pull_request_state_icon(layer.state)} class="stack-map__state" />
          <.link
            :if={!layer[:current]}
            navigate={layer[:navigate]}
            href={layer[:href]}
            class="stack-map__layer-link"
          >
            {layer.title}
          </.link>
          <span :if={layer[:current]} class="stack-map__layer-link">{layer.title}</span>
          <span class="stack-map__layer-meta">
            <span class="stack-map__layer-number">#{layer.number}</span>
            <span class="stack-map__branch">{layer.branch}</span>
          </span>
        </li>
        <li class="stack-map__trunk">
          <.icon name="branch" class="stack-map__state" />
          <.link
            :if={@trunk_navigate || @trunk_href}
            navigate={@trunk_navigate}
            href={@trunk_href}
            class="stack-map__branch"
          >
            {@trunk}
          </.link>
          <span :if={!(@trunk_navigate || @trunk_href)} class="stack-map__branch">{@trunk}</span>
        </li>
      </ol>
    </section>
    """
  end

  defp layer_count_label(1), do: "1 layer"
  defp layer_count_label(count), do: "#{count} layers"

  @doc """
  The vendored glyph for one pull-request state.

  Public because the stack map is not the only surface that draws it: a pull
  request is an issue row on this forge, so anywhere a number is rendered has
  to be able to say which of the two kinds it is (#120). One mapping, so two
  surfaces cannot disagree about what merged looks like.
  """
  def pull_request_state_icon("merged"), do: "pull-request-merged"
  def pull_request_state_icon("closed"), do: "pull-request-closed"
  def pull_request_state_icon("draft"), do: "pull-request-draft"
  def pull_request_state_icon(_open), do: "pull-request-open"

  @doc """
  A repository's file table: the ref bar, the latest commit, and the entries.

  Adapted from the GitHub-shaped clones catalogued in
  `docs/audits/2026-08-19-github-clone-harvest-candidates.md` -- `gh-next`
  (MIT, Fredkiss3/gh-next) for the Tailwind repo home's header and rail, and
  Gitea's `view_list.tmpl` for what a tree row must actually carry.

  Directories sort above files, which is what `Browse.tree/3` already returns
  and what makes a deep repository scannable: a reader looks for the folder
  first and only then for the file.

  The latest-commit bar sits above the table rather than inside it. It
  describes the tree as a whole, and a row that describes the whole table but
  looks like a row is the reason GitHub's own version of this reads oddly on
  first sight.

  Per-row commit messages are optional, because GitHub fills them by walking
  history once per path, which is one process per file. An entry that carries
  `message` and `updated` gets them; when no entry does, those two columns are
  not rendered at all rather than emitted empty, so the cheap tree keeps the
  markup it already had.
  """
  attr :owner, :string, required: true
  attr :repo, :string, required: true
  attr :ref, :string, required: true
  attr :path, :string, default: ""

  attr :entries, :list,
    required: true,
    doc:
      "`[%{name, kind, size}]` from `Browse.tree/3`, each optionally carrying " <>
        "`message` and `updated` from that path's last commit"

  attr :branches, :integer, default: nil
  attr :tags, :integer, default: nil
  attr :commits, :integer, default: nil, doc: "commits on this ref, shown beside the latest one"
  attr :class, :any, default: nil
  slot :commit, doc: "the latest commit, shown above the table"
  slot :actions, doc: "controls at the trailing edge of the ref bar"

  def file_table(assigns) do
    assigns =
      assign(assigns, :history?, Enum.any?(assigns.entries, &(&1[:message] || &1[:updated])))

    ~H"""
    <div class={["file-table", @class]}>
      <div class="file-table__bar">
        <span class="file-table__ref">
          <.icon name="branch" /> {@ref}
        </span>
        <span :if={@branches} class="file-table__count">
          <strong>{@branches}</strong> {plural(@branches, "Branch", "Branches")}
        </span>
        <span :if={@tags} class="file-table__count">
          <strong>{@tags}</strong> {plural(@tags, "Tag", "Tags")}
        </span>
        <span :if={@actions != []} class="file-table__actions">{render_slot(@actions)}</span>
      </div>

      <div :if={@commit != [] or @commits} class="file-table__commit">
        {render_slot(@commit)}
        <span :if={@commits} class="file-table__commits">
          <.icon name="history" />
          <strong>{@commits}</strong> {plural(@commits, "Commit", "Commits")}
        </span>
      </div>

      <table class="file-table__list">
        <caption class="visually-hidden">
          Files in {(@path == "" && "the repository root") || @path}
        </caption>
        <tbody>
          <tr :for={entry <- @entries} class="file-row" data-kind={entry.kind}>
            <td class="file-row__name">
              <.link navigate={entry_path(@owner, @repo, @ref, @path, entry)} class="file-row__link">
                <.icon name={if entry.kind == "tree", do: "folder", else: "file-document"} />
                {entry.name}
              </.link>
            </td>
            <td :if={@history?} class="file-row__message" title={entry[:message]}>
              {entry[:message]}
            </td>
            <td class="file-row__size">{size_label(entry)}</td>
            <td :if={@history?} class="file-row__age">{entry[:updated]}</td>
          </tr>
        </tbody>
      </table>

      <p :if={@entries == []} class="file-table__empty">Nothing at this path.</p>
    </div>
    """
  end

  # A directory goes to `tree`, a file to `blob`. GitHub's grammar, which the
  # harvest audit treats as the compatibility target rather than a style
  # choice: an existing link, bookmark, or `gh`-shaped tool should keep working.
  defp entry_path(owner, repo, ref, path, %{kind: kind, name: name}) do
    noun = if kind == "tree", do: "tree", else: "blob"
    joined = if path == "", do: name, else: path <> "/" <> name
    "/#{owner}/#{repo}/#{noun}/#{ref}/#{joined}"
  end

  # `UI` is not a gettext backend, and these two words are the only plurals it
  # needs.
  defp plural(1, singular, _plural), do: singular
  defp plural(_count, _singular, plural), do: plural

  defp size_label(%{kind: "tree"}), do: nil
  defp size_label(%{size: nil}), do: nil

  defp size_label(%{size: bytes}) when is_integer(bytes) do
    cond do
      bytes < 1_024 -> "#{bytes} B"
      bytes < 1_048_576 -> "#{Float.round(bytes / 1_024, 1)} KB"
      true -> "#{Float.round(bytes / 1_048_576, 1)} MB"
    end
  end

  defp size_label(_entry), do: nil

  @doc """
  The rail beside a repository: what it is, how it is licensed, what it is made of.

  Adapted from `gh-next`'s repo home. Every row is optional, and an absent one
  renders nothing rather than a placeholder -- a rail that says "no description"
  is louder than one that simply does not mention it.
  """
  attr :description, :string, default: nil
  attr :license, :string, default: nil

  attr :contributors, :integer,
    default: nil,
    doc: "the total, when more people committed than there are faces to show"

  attr :class, :any, default: nil

  slot :link, doc: "one related destination" do
    attr :icon, :string
    attr :navigate, :string
    attr :href, :string
  end

  slot :stat, doc: "one count" do
    attr :icon, :string
  end

  slot :contributor, doc: "one contributor, drawn as a face" do
    attr :name, :string, required: true
    attr :src, :string
  end

  slot :language, doc: "one language" do
    attr :percent, :float, required: true
  end

  def repo_about(assigns) do
    assigns =
      assign(
        assigns,
        :overflow,
        max((assigns.contributors || 0) - length(assigns.contributor), 0)
      )

    ~H"""
    <aside class={["repo-about", @class]} aria-label="About this repository">
      <h2 class="repo-about__title">About</h2>
      <p :if={@description} class="repo-about__description">{@description}</p>

      <ul :if={@link != [] or @license} class="repo-about__links">
        <li :if={@license}>
          <.icon name="scales" /> {@license}
        </li>
        <li :for={link <- @link}>
          <.link navigate={link[:navigate]} href={link[:href]}>
            <.icon :if={link[:icon]} name={link.icon} /> {render_slot(link)}
          </.link>
        </li>
      </ul>

      <ul :if={@stat != []} class="repo-about__stats">
        <li :for={stat <- @stat}>
          <.icon :if={stat[:icon]} name={stat.icon} /> {render_slot(stat)}
        </li>
      </ul>

      <div :if={@contributor != []} class="repo-about__contributors">
        <h3>
          Contributors <span class="repo-about__count">{@contributors || length(@contributor)}</span>
        </h3>
        <%!-- The count is the point. Six faces alone says the repository has
        six contributors, which is usually wrong. --%>
        <ul class="contributor-cluster">
          <li :for={person <- @contributor}>
            <.avatar
              src={person[:src]}
              fallback={String.first(person.name)}
              size={:sm}
              label={person.name}
            />
          </li>
          <li :if={@overflow > 0} class="contributor-cluster__count">+{@overflow}</li>
        </ul>
      </div>

      <div :if={@language != []} class="repo-about__languages">
        <h3>Languages</h3>
        <%!-- One bar, not a stack of bars: the proportions are the point, and
        they only read as proportions when they share a length. --%>
        <div class="language-bar" role="img" aria-label="Language breakdown">
          <span
            :for={{language, index} <- Enum.with_index(@language)}
            class="language-bar__segment"
            data-index={rem(index, 6)}
            style={"width: #{language.percent}%"}
          ></span>
        </div>
        <ul class="repo-about__language-list">
          <li :for={{language, index} <- Enum.with_index(@language)}>
            <span class="language-dot" data-index={rem(index, 6)} aria-hidden="true"></span>
            {render_slot(language)} <span class="repo-about__percent">{language.percent}%</span>
          </li>
        </ul>
      </div>
    </aside>
    """
  end

  @doc """
  A repository's home page, assembled.

  The pieces already exist on their own -- `breadcrumb/1` for the owner trail,
  `repo_tabs/1` for the sections, `file_table/1` for the tree, `repo_about/1`
  for the rail. This holds them in one frame so that a surface showing a
  repository does not reassemble that frame by hand and drift from the next
  surface that shows one.

  The rail is a second grid column above 1024px and falls below the tree under
  it. Provenance -- what this is, how it is licensed, who wrote it -- is what a
  reader wants beside the file list on a desktop and after it on a phone, and
  the source order is already the phone order.

  Composition is by slot rather than by attribute, so this owns the frame and
  nothing else: a caller that needs a tree with no rail, or a commit list where
  the tree usually goes, passes that instead without a flag being added here.
  """
  attr :owner, :string, required: true
  attr :repo, :string, required: true

  attr :owner_path, :string,
    default: nil,
    doc: """
    Where the owner's name leads. Absent means it leads nowhere and is drawn as
    plain text, because there is not necessarily anything at `/OWNER` -- this
    defaulted to that path, and on a deployment with no namespace page every
    repository header carried a link to a 404.
    """

  attr :visibility, :atom, values: [:public, :private], default: :public
  attr :class, :any, default: nil
  attr :rest, :global

  slot :tabs, doc: "the section bar, normally one `repo_tabs/1`"
  slot :inner_block, required: true, doc: "the main column, normally one `file_table/1`"
  slot :about, doc: "the trailing rail, normally one `repo_about/1`"

  def repo_view(assigns) do
    ~H"""
    <div class={["repo-page", @class]} {@rest}>
      <header class="repo-page__identity">
        <%!-- Decorative: the owner's name is the next thing in the trail, and an
        initial announced ahead of it reads as a stray letter. --%>
        <.avatar
          fallback={String.upcase(String.first(@owner))}
          tone={:accent}
          aria-hidden="true"
        />
        <.breadcrumb class="repo-page__trail" label={"#{@owner} / #{@repo}"}>
          <:item :if={@owner_path} navigate={@owner_path}>{@owner}</:item>
          <:item :if={is_nil(@owner_path)}>{@owner}</:item>
          <:item>{@repo}</:item>
        </.breadcrumb>
        <.badge variant={:dim}>{visibility_label(@visibility)}</.badge>
      </header>

      {render_slot(@tabs)}

      <div class="repo-view">
        <div class="repo-view__main">{render_slot(@inner_block)}</div>
        <div :if={@about != []} class="repo-view__rail">{render_slot(@about)}</div>
      </div>
    </div>
    """
  end

  defp visibility_label(:private), do: "Private"
  defp visibility_label(_visibility), do: "Public"

  @doc """
  One file's diff: a header, its hunks, and every line numbered on both sides.

  Adapted from Pierre's `FileDiff` (`@pierre/diffs`, Apache 2.0,
  `pierrecomputer/pierre`). What carried over is the model rather than the
  code -- see `docs/2026-08-20-pierre-code-surfaces-port.md`. Takes an
  `OpenAgents.Diff.File`, which `OpenAgents.Diff.parse/1` produces from the
  output of `git diff-tree -p -M`.

  Unified rather than split. A split view needs roughly twice the width to say
  the same thing, and on a narrow screen it either scrolls sideways or squeezes
  both sides into columns too thin to read. The two line-number gutters carry
  what the split layout is for: which line this was, and which line it is now.

  Every line is addressable. A line's new-side number is a link to itself, so a
  reader can point someone at a line rather than describing where it is. The
  anchor is scoped by path, since one page holds many files.

  Colour is not the only carrier of meaning: an inserted line is marked `+` and
  a deleted one `-` in the gutter, so the diff survives greyscale and a reader
  who cannot separate the two tints.

  Collapsible through native `<details>`, open by default. A reviewer opening a
  commit wants to see it, and a large file is the one they most want to fold
  away -- so the control is there without costing a click on arrival.
  """
  attr :file, :map, required: true, doc: "an `OpenAgents.Diff.File`"
  attr :open, :boolean, default: true
  attr :class, :any, default: nil
  attr :rest, :global

  def diff_file(assigns) do
    assigns = assign(assigns, :slug, diff_slug(assigns.file.path))

    ~H"""
    <details id={"diff-#{@slug}"} class={["diff-file", @class]} open={@open} {@rest}>
      <summary class="diff-file__header">
        <.icon name="chevron-right" class="diff-file__caret" />
        <span class="diff-file__path">
          <span :if={@file.old_path} class="diff-file__from">{@file.old_path} →</span>
          {@file.path}
        </span>
        <span class="diff-file__status" data-status={@file.status}>{@file.status}</span>
        <span :if={@file.insertions > 0} class="diff-file__count" data-kind="insert">
          +{@file.insertions}
        </span>
        <span :if={@file.deletions > 0} class="diff-file__count" data-kind="delete">
          -{@file.deletions}
        </span>
      </summary>

      <p :if={@file.binary?} class="diff-file__note">
        Binary file. Nothing to show as text.
      </p>

      <p :if={not @file.binary? and @file.hunks == []} class="diff-file__note">
        No content change.
      </p>

      <div :for={hunk <- @file.hunks} class="diff-hunk">
        <p class="diff-hunk__header">
          <span class="diff-hunk__range">
            @@ -{hunk.old_start},{hunk.old_count} +{hunk.new_start},{hunk.new_count} @@
          </span>
          <span :if={hunk.heading} class="diff-hunk__heading">{hunk.heading}</span>
        </p>

        <table class="diff-lines">
          <tbody>
            <tr
              :for={line <- hunk.lines}
              id={line_id(@slug, line)}
              class="diff-line"
              data-kind={line.kind}
            >
              <td class="diff-line__number diff-line__number--old">{line.old_number}</td>
              <td class="diff-line__number diff-line__number--new">
                <a :if={line.new_number} href={"##{line_id(@slug, line)}"}>{line.new_number}</a>
                <span :if={is_nil(line.new_number)}>{nil}</span>
              </td>
              <td class="diff-line__marker" aria-hidden="true">{marker(line.kind)}</td>
              <td class="diff-line__text">
                <pre><code>{line.text}</code></pre>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
    </details>
    """
  end

  # A path is not a DOM id: slashes and dots make `#a/b.ex` an invalid
  # fragment, so the anchor uses a flattened form. Path-scoped rather than
  # global, because one page holds many files and `#L12` alone would be
  # ambiguous across them.
  defp diff_slug(path), do: String.replace(path, ~r/[^A-Za-z0-9]+/, "-")

  defp line_id(slug, %{new_number: number}) when is_integer(number), do: "#{slug}-L#{number}"
  defp line_id(slug, %{old_number: number}) when is_integer(number), do: "#{slug}-R#{number}"
  defp line_id(_slug, _line), do: nil

  defp marker(:insert), do: "+"
  defp marker(:delete), do: "-"
  defp marker(_kind), do: " "

  @doc """
  The GitHub sign-in control.

  A real form POST, not a link: signing in starts an OAuth round-trip, and a
  control labelled "log in" that navigates somewhere else instead is lying
  about what it does.

  The round-trip leaves the page, so there is a window where the button looks
  idle and clickable while a redirect is already in flight. Submitting swaps
  the mark for a spinner and disables the control, which both reports that
  something is happening and stops a second submission creating a second OAuth
  attempt.

  The pending state is applied by a hook, but it is also expressed for
  `:disabled` alone, so a browser that re-enables the button on back-navigation
  or runs no script still shows the right thing.
  """
  attr :id, :string, required: true
  attr :label, :string, default: "Log in with GitHub"

  attr :variant, :atom,
    values: [:primary, :secondary, :outline, :ghost, :destructive, :chip, :notched, :link],
    default: :primary

  # Every atom attr states its values. Without a `values:` list Phoenix cannot
  # check call sites, and this one defaulted to `:md` -- not one of
  # `button/1`'s sizes -- so it rendered `data-size="md"`, matched no size
  # rule, and left the control with no height, padding or type scale at all.
  attr :size, :atom, values: [:default, :xs, :sm, :lg], default: :default

  attr :action, :string,
    default: "/auth/github",
    doc: "where the sign-in posts; the caller owns the route"

  attr :class, :any, default: nil
  attr :rest, :global

  def github_login(assigns) do
    ~H"""
    <.form
      for={%{}}
      as={:auth}
      id={"#{@id}-form"}
      action={@action}
      method="post"
      class="login-form"
      phx-hook=".LoginPending"
      {@rest}
    >
      <.button id={@id} type="submit" variant={@variant} size={@size} class={["login-button", @class]}>
        <.icon name="brand-github" class="login-button__mark" />
        <.icon name="circle-dashed" class="login-button__spinner" />
        {@label}
      </.button>
    </.form>
    <script :type={Phoenix.LiveView.ColocatedHook} name=".LoginPending">
      export default {
        mounted() {
          this.button = this.el.querySelector("button[type=submit]")
          this.onSubmit = () => {
            if (!this.button) return
            this.button.dataset.pending = "true"
            // Disabled after the event, not during it: disabling a submit
            // button inside its own submit handler cancels the submission in
            // some browsers.
            window.setTimeout(() => { this.button.disabled = true }, 0)
          }
          this.el.addEventListener("submit", this.onSubmit)
        },
        destroyed() {
          this.el.removeEventListener("submit", this.onSubmit)
        },
      }
    </script>
    """
  end

  @doc """
  A control that copies text to the clipboard and reports that it did.

  The confirmation is the point. A copy button that changes nothing on click
  leaves the reader unsure whether it worked, so this flips `data-copied` and
  swaps the glyph for a tick, then returns. The state lives on the element
  rather than in the LiveView because it is presentational and per-visitor.
  """
  attr :id, :string, required: true
  attr :text, :string, required: true, doc: "the text placed on the clipboard"
  attr :label, :string, default: "Copy"
  attr :copied_label, :string, default: "Copied"
  attr :class, :any, default: nil
  attr :rest, :global

  def copy_button(assigns) do
    ~H"""
    <button
      id={@id}
      type="button"
      class={["btn copy-button", @class]}
      data-variant="secondary"
      data-size="sm"
      data-copied="false"
      data-copy-text={@text}
      data-copied-label={@copied_label}
      aria-label={@label}
      phx-hook=".CopyToClipboard"
      {@rest}
    >
      <.icon name="copy" class="copy-button__idle" />
      <.icon name="check" class="copy-button__done" />
      <span class="copy-button__label">{@label}</span>
    </button>
    <script :type={Phoenix.LiveView.ColocatedHook} name=".CopyToClipboard">
      export default {
        mounted() {
          this.el.addEventListener("click", async () => {
            try {
              await navigator.clipboard.writeText(this.el.dataset.copyText)
            } catch (_error) {
              return
            }
            const label = this.el.querySelector(".copy-button__label")
            const original = label && label.textContent
            this.el.dataset.copied = "true"
            if (label) label.textContent = this.el.dataset.copiedLabel
            clearTimeout(this.resetTimer)
            this.resetTimer = setTimeout(() => {
              this.el.dataset.copied = "false"
              if (label) label.textContent = original
            }, 1600)
          })
        },
        destroyed() {
          clearTimeout(this.resetTimer)
        }
      }
    </script>
    """
  end

  @doc """
  One glyph from the governed two-tier icon set.

  Renders inline SVG at `1em` in `currentColor`, so a glyph takes the size and
  color of the text around it. Apps SDK UI glyphs come from `priv/icons`.
  `hero-*` names are the documented fallback when that set has no suitable
  concept. Adding a preferred glyph is a re-vendor, never inline SVG in a
  surface. See `docs/ICONS.md`.

  Decorative by default. Most glyphs sit beside a word that already names the
  control, and announcing both is noise, so an icon with no `label` is hidden
  from assistive technology.

  When a control is icon-only, the accessible name belongs on the control, not
  on the glyph:

      <.button aria-label="Send"><.icon name="arrow-up" /></.button>

  Pass `label` only when the glyph itself is the whole message and nothing
  adjacent says it.
  """
  attr :name, :string, required: true
  attr :label, :string, default: nil
  attr :class, :any, default: nil
  attr :rest, :global

  def icon(%{name: "hero-" <> _} = assigns) do
    ~H"""
    <span
      class={[@name, "icon", @class]}
      role={@label && "img"}
      aria-label={@label}
      aria-hidden={is_nil(@label) && "true"}
      data-icon={@name}
      {@rest}
    />
    """
  end

  def icon(assigns) do
    {view_box, inner} = OpenAgentsWeb.Icons.fetch!(assigns.name)

    assigns = assign(assigns, view_box: view_box, inner: inner)

    ~H"""
    <svg
      class={["icon", @class]}
      viewBox={@view_box}
      width="1em"
      height="1em"
      fill="currentColor"
      role={@label && "img"}
      aria-label={@label}
      aria-hidden={is_nil(@label) && "true"}
      focusable="false"
      data-icon={@name}
      {@rest}
    >{Phoenix.HTML.raw(@inner)}</svg>
    """
  end

  defp translate_error({message, options}) do
    if count = options[:count] do
      Gettext.dngettext(OpenAgentsWeb.Gettext, "errors", message, message, count, options)
    else
      Gettext.dgettext(OpenAgentsWeb.Gettext, "errors", message, options)
    end
  end
end