31.2 KiB · text History b868907
defmodule GitGudWeb.RepoLive.Badges do
  @moduledoc """
  The badge sheet: a repository's status badges in every style and
  theme, with a contrast check and snippets to embed them.

  "This repository now" shows every badge the repository has, grouped,
  and doubles as a picker: switch on the README block, choose badges,
  and one card holds the Markdown (or HTML) for all of them, in order,
  ready to paste.

  Its options (style, corners, scale, GG) are path segments, the same
  ones the badge images take (`GitGud.Badges.Spec`), so any view of the
  sheet can be linked: `/r/neiam/gitgud/badges/dot/2.5x/gg`.
  """
  use GitGudWeb, :live_view

  alias GitGud.Badges
  alias GitGud.Badges.Palette
  alias GitGud.Badges.Spec
  alias GitGud.Badges.Svg
  alias GitGud.Repositories
  alias GitGudWeb.BadgeCatalog
  alias Phoenix.LiveView.JS

  @style_notes %{
    "split" => "Solid blocks. Reads best in a row of badges at the top of a README.",
    "dot" =>
      "Quiet: the dot carries the status and the text stays in the page's own color. Suits docs and running text.",
    "outline" => "A colored outline. Stands out on a busy page without a solid fill."
  }

  @coverage_yaml """
  jobs:
    test:
      runs-on: docker
      outputs:
        gitgud-coverage: ${{ steps.coverage.outputs.percent }}
      steps:
        - uses: actions/checkout@v4
        - run: mix test --cover
        - id: coverage
          run: echo "percent=87.3" >> "$GITHUB_OUTPUT"
    audit:
      runs-on: docker
      outputs:
        gitgud-conclusion: ${{ steps.scan.outputs.conclusion }}
        gitgud-summary: ${{ steps.scan.outputs.summary }}
      steps:
        - uses: actions/checkout@v4
        - id: scan
          run: |
            echo "conclusion=warning" >> "$GITHUB_OUTPUT"
            echo "summary=1 no fix" >> "$GITHUB_OUTPUT"\
  """

  @impl true
  def mount(%{"owner" => owner, "name" => name}, _session, socket) do
    repo =
      Repositories.get_repository_by_path!(owner, name)
      |> GitGud.Repo.preload([:owner, :organization])

    {:ok,
     socket
     |> assign(:repo, repo)
     |> assign(:handle, BadgeCatalog.handle(repo))
     |> GitGudWeb.RepoLive.Header.assign_chrome(repo)
     |> assign_items(repo)
     |> assign(:snippet_params, %{"kind" => "ci", "theme" => "auto", "format" => "svg"})
     |> assign(:readme_open, false)
     |> assign(:readme_params, %{"theme" => "auto", "format" => "svg"})
     |> assign(:page_title, "Badges — #{owner}/#{name}")}
  end

  @impl true
  def handle_params(params, _uri, socket) do
    case Spec.parse_options(Map.get(params, "options", [])) do
      {:ok, spec} ->
        {:noreply,
         socket
         |> assign(:spec, spec)
         |> assign_snippets(socket.assigns.snippet_params)
         |> assign_readme()}

      :error ->
        {:noreply, push_patch(socket, to: sheet_path(socket.assigns, %Spec{}))}
    end
  end

  @impl true
  def handle_event("snippet", %{"snippet" => params}, socket) do
    {:noreply, assign_snippets(socket, params)}
  end

  def handle_event("toggle_readme", _params, socket) do
    {:noreply, assign(socket, :readme_open, !socket.assigns.readme_open)}
  end

  def handle_event("toggle_item", %{"key" => key}, socket) do
    selected = socket.assigns.selected

    selected =
      cond do
        key in selected -> MapSet.delete(selected, key)
        Enum.any?(socket.assigns.items, &(&1.key == key)) -> MapSet.put(selected, key)
        true -> selected
      end

    {:noreply, socket |> assign(:selected, selected) |> assign_readme()}
  end

  def handle_event("readme_all", _params, socket) do
    {:noreply,
     socket
     |> assign(:selected, MapSet.new(socket.assigns.items, & &1.key))
     |> assign_readme()}
  end

  def handle_event("readme_none", _params, socket) do
    {:noreply, socket |> assign(:selected, MapSet.new()) |> assign_readme()}
  end

  def handle_event("readme_options", %{"readme" => params}, socket) do
    params = %{
      "theme" => pick(params["theme"], Spec.themes(), "auto"),
      "format" => pick(params["format"], Spec.formats(), "svg")
    }

    {:noreply, socket |> assign(:readme_params, params) |> assign_readme()}
  end

  # Every badge the repository has (`BadgeCatalog.items/1`), each with
  # what it says right now.
  defp assign_items(socket, repo) do
    items = BadgeCatalog.items(repo)
    keys = MapSet.new(items, & &1.key)

    socket
    |> assign(:items, items)
    |> assign(:current, Map.new(items, &{&1.key, &1.content}))
    |> assign(
      :selected,
      BadgeCatalog.readme_defaults() |> MapSet.new() |> MapSet.intersection(keys)
    )
  end

  # The README card: the selected badges, in the order shown, as one line
  # of Markdown and as HTML.
  defp assign_readme(socket) do
    %{items: items, selected: selected, readme_params: params} = socket.assigns
    chosen = Enum.filter(items, &(&1.key in selected))

    %{handle: handle, repo: repo, spec: spec} = socket.assigns

    embeds =
      Enum.map(chosen, fn item ->
        BadgeCatalog.embed(
          repo,
          handle,
          BadgeCatalog.spec(spec, item.key, params["theme"], params["format"])
        )
      end)

    # The preview is the block's own images, so it shows exactly what the
    # Markdown will: that theme, that format, `auto` following the
    # viewer's system setting as it will for a README's reader.
    previews = Enum.map(embeds, &%{path: &1.path, alt: &1.alt})

    socket
    |> assign(:readme_items, chosen)
    |> assign(:readme_previews, previews)
    |> assign(:readme_form, to_form(params, as: :readme))
    |> assign(:readme_markdown, Enum.map_join(embeds, " ", & &1.markdown))
    |> assign(:readme_html, Enum.map_join(embeds, "\n", & &1.html))
  end

  defp assign_snippets(socket, params) do
    kind = pick(params["kind"], Enum.map(kind_options(socket.assigns), &elem(&1, 1)), "ci")
    theme = pick(params["theme"], Spec.themes(), "auto")
    format = pick(params["format"], Spec.formats(), "svg")
    params = %{"kind" => kind, "theme" => theme, "format" => format}

    %{repo: repo, handle: handle, spec: spec} = socket.assigns
    badge = BadgeCatalog.spec(spec, kind, theme, format)
    %{url: url, link: link, alt: alt} = e = BadgeCatalog.embed(repo, handle, badge)

    snippets = [
      %{id: "markdown", title: "Markdown", text: e.markdown},
      %{id: "html", title: "HTML, separate dark and light images", text: e.html},
      %{
        id: "rst",
        title: "reStructuredText",
        text: ".. image:: #{url}\n   :target: #{link}\n   :alt: #{alt}"
      },
      %{id: "asciidoc", title: "AsciiDoc", text: "image:#{url}[#{alt},link=#{link}]"},
      %{id: "url", title: "Image URL", text: url}
    ]

    socket
    |> assign(:snippet_params, params)
    |> assign(:snippet_form, to_form(params, as: :snippet))
    |> assign(:snippet_preview, e.path)
    |> assign(:snippets, snippets)
  end

  defp pick(value, allowed, default), do: if(value in allowed, do: value, else: default)

  defp item_path(%{handle: handle, repo: repo, spec: spec}, key),
    do: BadgeCatalog.image_path(handle, repo, BadgeCatalog.spec(spec, key, "auto", "svg"))

  # The snippet builder's badge choices: the plain kinds, then the
  # repository's own workflows and packages by key.
  defp kind_options(assigns) do
    labels = BadgeCatalog.kind_labels()

    for(kind <- Spec.kinds(), kind not in ~w(workflow package), do: {labels[kind], kind}) ++
      for(
        %{kind: "workflow", key: key, title: title} <- assigns.items,
        do: {"Workflow: #{title}", key}
      ) ++
      for %{kind: "package", key: key, title: title} <- assigns.items do
        {_kind, _name, registry} = BadgeCatalog.split_key(key)
        {"Package: #{title} (#{registry})", key}
      end
  end

  # ── paths ─────────────────────────────────────────────────────────────

  defp sheet_path(%{handle: handle, repo: repo}, spec) do
    case Spec.option_segments(spec) do
      [] -> ~p"/r/#{handle}/#{repo.name}/badges"
      segments -> ~p"/r/#{handle}/#{repo.name}/badges/#{segments}"
    end
  end

  defp sample_path(spec, kind, key, theme) do
    ~p"/badges/sample/#{kind}/#{key}/#{Spec.explicit_segments(%{spec | kind: kind, theme: theme})}"
  end

  # A fixed theme previews on its own background; `auto` on the page's.
  defp readme_preview_style("auto"), do: nil

  defp readme_preview_style(theme),
    do:
      "background:#{Palette.token_hex(theme, "base-100")};color:#{Palette.token_hex(theme, "base-content")}"

  # `id`: a page may hold the same badge twice, and each needs its own
  # clip path id.
  defp inline_badge(content, spec, id),
    do: Phoenix.HTML.raw(Svg.render(content, spec, :inline, id: "ggb-" <> id))

  # ── template ──────────────────────────────────────────────────────────

  @impl true
  def render(assigns) do
    assigns =
      assigns
      |> assign(:style_notes, @style_notes)
      |> assign(:kind_labels, BadgeCatalog.kind_labels())
      |> assign(:kinds, BadgeCatalog.kinds())
      |> assign(:kind_options, kind_options(assigns))
      |> assign(:coverage_yaml, @coverage_yaml)
      |> assign(:groups, BadgeCatalog.groups())

    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <style id="badge-theme-styles">
        <%= Phoenix.HTML.raw(Palette.inline_stylesheet()) %>
      </style>
      <div class="space-y-10">
        <GitGudWeb.RepoLive.Header.header
          repo={@repo}
          handle={@handle}
          current_scope={@current_scope}
          has_packages?={@has_packages?}
          can_admin?={@can_admin?}
          latest_run={@latest_run}
          open_pulls={@open_pulls}
          security_alerts={assigns[:security_alerts]}
        />

        <header class="space-y-2">
          <h1 class="text-xl font-semibold">Badges</h1>
          <p class="opacity-70 max-w-prose">
            Status badges for <span class="font-mono">{@repo.default_branch}</span>
            to embed in a README or anywhere else that takes an image. Every option
            below is part of the page's address, so you can link to any view of it.
          </p>
        </header>

        <nav
          id="badge-options"
          aria-label="Badge options"
          class="flex flex-wrap gap-x-8 gap-y-4 items-start rounded-box border border-base-300 bg-base-200/60 p-4"
        >
          <.option_group label="Style" id="badge-style">
            <.option_link
              :for={style <- Spec.styles()}
              id={"badge-style-#{style}"}
              patch={sheet_path(assigns, %{@spec | style: style})}
              active={@spec.style == style}
            >
              {String.capitalize(style)}
            </.option_link>
          </.option_group>
          <.option_group label="Corners" id="badge-corners">
            <.option_link
              :for={corners <- Spec.corners()}
              id={"badge-corners-#{corners}"}
              patch={sheet_path(assigns, %{@spec | corners: corners})}
              active={@spec.corners == corners}
            >
              {if corners == "round", do: "Theme radius", else: "Square"}
            </.option_link>
          </.option_group>
          <.option_group label="Scale" id="badge-scale">
            <.option_link
              :for={scale <- Spec.scales()}
              id={"badge-scale-#{String.replace(scale, ".", "_")}"}
              patch={sheet_path(assigns, %{@spec | scale: scale})}
              active={@spec.scale == scale}
            >
              {String.replace(scale, "x", "×")}
            </.option_link>
          </.option_group>
          <.option_group label="GG mark" id="badge-gg">
            <.option_link
              id="badge-gg-off"
              patch={sheet_path(assigns, %{@spec | gg: false})}
              active={!@spec.gg}
            >
              Off
            </.option_link>
            <.option_link
              id="badge-gg-on"
              patch={sheet_path(assigns, %{@spec | gg: true})}
              active={@spec.gg}
            >
              On
            </.option_link>
          </.option_group>
          <p id="badge-style-note" class="basis-full text-sm opacity-70">
            {@style_notes[@spec.style]}
          </p>
        </nav>

        <section id="badges-current" class="space-y-5">
          <div class="flex flex-wrap items-end justify-between gap-3">
            <div>
              <h2 class="text-lg font-semibold">This repository now</h2>
              <p class="text-sm opacity-70 max-w-prose">
                Every badge this repository has, drawn in your current theme. GitGud pages show
                badges this way, so they change with the reader's theme.
              </p>
            </div>
            <button
              type="button"
              id="readme-toggle"
              phx-click="toggle_readme"
              aria-pressed={to_string(@readme_open)}
              aria-controls="readme-card"
              class={[
                "inline-flex items-center gap-2 rounded-field border px-3 py-1.5 text-sm transition-colors duration-150",
                if(@readme_open,
                  do: "bg-primary text-primary-content border-primary",
                  else: "border-base-300 hover:border-primary"
                )
              ]}
            >
              <.icon name="hero-document-text" class="size-4" />
              {if @readme_open, do: "Done choosing", else: "Build a README block"}
            </button>
          </div>

          <p :if={@readme_open} id="readme-hint" class="text-sm opacity-70">
            Select badges to add them to the block below. They appear in the order shown here.
          </p>

          <div :for={{group, group_label} <- @groups} class="space-y-2">
            <% group_items = Enum.filter(@items, &(&1.group == group)) %>
            <h3
              :if={group_items != []}
              class="font-mono text-[0.68rem] uppercase tracking-wider opacity-70"
            >
              {group_label}
            </h3>
            <ul
              :if={group_items != []}
              id={"badges-group-#{group}"}
              class="grid gap-2 grid-cols-[repeat(auto-fill,minmax(min(100%,15rem),1fr))]"
            >
              <li :for={item <- group_items} id={"badge-item-#{item.index}"} data-badge={item.key}>
                <.badge_tile
                  item={item}
                  spec={@spec}
                  href={item_path(assigns, item.key)}
                  choosing={@readme_open}
                  selected={item.key in @selected}
                />
              </li>
            </ul>
          </div>

          <p :if={@current["coverage"].value == "none"} class="text-sm opacity-70">
            No coverage yet. See <a href="#badges-coverage" class="link">reporting to badges</a>
            below.
          </p>

          <div
            :if={@readme_open}
            id="readme-card"
            class="rounded-box border border-base-300 bg-base-200/60 p-4 space-y-4"
          >
            <div class="flex flex-wrap items-end justify-between gap-3">
              <div>
                <h3 class="font-semibold">README block</h3>
                <p class="text-sm opacity-70">
                  {length(@readme_items)} {if length(@readme_items) == 1,
                    do: "badge",
                    else: "badges"}, with the style, corners, scale and GG setting above.
                </p>
              </div>
              <div class="flex flex-wrap items-end gap-3">
                <.form
                  for={@readme_form}
                  id="readme-form"
                  phx-change="readme_options"
                  class="flex gap-3 items-end"
                >
                  <div class="w-36">
                    <.input
                      field={@readme_form[:theme]}
                      type="select"
                      label="Theme"
                      options={Spec.themes()}
                    />
                  </div>
                  <div class="w-28">
                    <.input
                      field={@readme_form[:format]}
                      type="select"
                      label="Format"
                      options={[{"SVG", "svg"}, {"PNG", "png"}]}
                    />
                  </div>
                </.form>
                <div class="flex gap-2 pb-3">
                  <button
                    type="button"
                    id="readme-all"
                    phx-click="readme_all"
                    class="btn btn-accent"
                  >
                    Select all
                  </button>
                  <button
                    type="button"
                    id="readme-none"
                    phx-click="readme_none"
                    class="btn btn-accent"
                  >
                    Clear
                  </button>
                </div>
              </div>
            </div>

            <%= if @readme_items == [] do %>
              <p id="readme-empty" class="text-sm opacity-70">
                Nothing selected yet. Select badges above to build the block.
              </p>
            <% else %>
              <div
                id="readme-preview"
                class="flex flex-wrap gap-1.5 items-center rounded-box p-3 border border-base-300 transition-colors duration-200"
                style={readme_preview_style(@readme_params["theme"])}
              >
                <img
                  :for={preview <- @readme_previews}
                  src={preview.path}
                  alt={preview.alt}
                  class="block max-w-full"
                />
              </div>

              <div id="readme-markdown" class="space-y-1.5">
                <div class="flex items-center justify-between gap-2">
                  <span class="font-mono text-xs uppercase tracking-wider opacity-70">
                    Markdown
                  </span>
                  <button
                    type="button"
                    id="readme-copy-markdown"
                    class="font-mono text-xs px-2.5 py-1 rounded border border-base-300 bg-base-100 hover:border-primary transition-colors"
                    phx-click={
                      JS.dispatch("git_gud:copy", detail: %{text: @readme_markdown})
                      |> JS.transition("scale-95", time: 150)
                    }
                  >
                    Copy
                  </button>
                </div>
                <pre class="bg-base-100 rounded px-3 py-2 text-xs whitespace-pre-wrap break-all"><code>{@readme_markdown}</code></pre>
              </div>

              <details id="readme-html" class="group">
                <summary class="cursor-pointer font-mono text-xs uppercase tracking-wider opacity-70 hover:opacity-100">
                  HTML, separate dark and light images
                </summary>
                <div class="mt-2 space-y-1.5">
                  <div class="flex justify-end">
                    <button
                      type="button"
                      id="readme-copy-html"
                      class="font-mono text-xs px-2.5 py-1 rounded border border-base-300 bg-base-100 hover:border-primary transition-colors"
                      phx-click={JS.dispatch("git_gud:copy", detail: %{text: @readme_html})}
                    >
                      Copy
                    </button>
                  </div>
                  <pre class="bg-base-100 rounded px-3 py-2 text-xs overflow-x-auto"><code>{@readme_html}</code></pre>
                </div>
              </details>
            <% end %>
          </div>
        </section>

        <section id="badges-themes" class="space-y-3">
          <div>
            <h2 class="text-lg font-semibold">Every theme</h2>
            <p class="text-sm opacity-70">
              Fixed to one theme, for embedding where you know the background. Each card sits on
              its theme's own background. Select a badge to open its image.
            </p>
          </div>
          <div class="grid gap-4 grid-cols-[repeat(auto-fill,minmax(min(100%,22rem),1fr))]">
            <article
              :for={theme <- Palette.themes()}
              id={"badge-theme-#{theme}"}
              class="rounded-box p-4 space-y-3 min-w-0 border border-black/10"
              style={"background:#{Palette.token_hex(theme, "base-100")};color:#{Palette.token_hex(theme, "base-content")}"}
            >
              <div class="flex items-baseline justify-between gap-2">
                <h3 class="font-mono font-semibold">{theme}</h3>
                <span class="font-mono text-xs opacity-70">
                  {Palette.scheme(theme)} · {if Palette.radius_px(theme) >= 10,
                    do: "pill",
                    else: "#{trunc(Palette.radius_px(theme))}px"}
                </span>
              </div>
              <div
                :for={kind <- @kinds ++ ["workflow", "package", "mark"]}
                class="grid grid-cols-[5.5rem_1fr] gap-2 items-start"
              >
                <span class="font-mono text-[0.68rem] uppercase tracking-wider opacity-70 pt-1">
                  {@kind_labels[kind]}
                </span>
                <div class="flex flex-wrap gap-1.5 items-center min-w-0">
                  <a
                    :for={key <- Badges.sample_keys(kind)}
                    href={sample_path(@spec, kind, key, theme)}
                    target="_blank"
                    rel="noopener"
                    class="transition-transform duration-150 hover:-translate-y-0.5"
                  >
                    <img
                      src={sample_path(@spec, kind, key, theme)}
                      alt={"#{@kind_labels[kind]} example: #{key}"}
                      loading="lazy"
                      class="block max-w-full"
                    />
                  </a>
                </div>
              </div>
            </article>
          </div>
        </section>

        <section id="badges-contrast" class="space-y-3">
          <div>
            <h2 class="text-lg font-semibold">Contrast, {@spec.style} style</h2>
            <p class="text-sm opacity-70 max-w-prose">
              Badge text is 11px, so it needs 4.5:1. The status dot is a shape and needs 3:1.
              Where a theme's usual text color falls short, badges use another, named under the ratio.
            </p>
          </div>
          <div class="overflow-x-auto rounded-box border border-base-300">
            <table id="badge-contrast-table" class="w-full text-sm tabular-nums">
              <thead class="bg-base-200 text-xs uppercase tracking-wider font-mono">
                <tr>
                  <th class="px-3 py-2 text-left font-normal opacity-70">Theme</th>
                  <th
                    :for={{name, _fg, _bg, _min} <- Palette.audit_pairs(@spec.style)}
                    class="px-3 py-2 text-left font-normal opacity-70 whitespace-nowrap"
                  >
                    {name}
                  </th>
                </tr>
              </thead>
              <tbody>
                <tr :for={theme <- Palette.themes()} class="border-t border-base-300">
                  <td class="px-3 py-2 font-mono">{theme}</td>
                  <td
                    :for={{name, fg, bg, min} <- Palette.audit_pairs(@spec.style)}
                    class="px-3 py-2 whitespace-nowrap"
                  >
                    <% ratio = Palette.contrast(theme, fg, bg) %>
                    <span class="inline-flex items-center gap-2">
                      <span
                        class="font-mono text-xs px-1.5 py-0.5 rounded"
                        style={"background:#{Palette.hex(theme, bg)};color:#{Palette.hex(theme, fg)}"}
                      >
                        {if String.ends_with?(name, "dot"), do: "●", else: "Aa"}
                      </span>
                      <span class="leading-tight">
                        <span class={[
                          "font-mono",
                          if(ratio >= min, do: "text-success", else: "text-error font-bold")
                        ]}>
                          {:erlang.float_to_binary(ratio, decimals: 2)}
                        </span>
                        <span class="block font-mono text-[0.65rem] opacity-60">
                          {Palette.token(theme, fg)}
                        </span>
                      </span>
                    </span>
                  </td>
                </tr>
              </tbody>
            </table>
          </div>
        </section>

        <section id="badges-embed" class="space-y-4">
          <div>
            <h2 class="text-lg font-semibold">Embed</h2>
            <p class="text-sm opacity-70 max-w-prose">
              Uses the style, corners, scale and GG setting above. <span class="font-mono">auto</span>
              follows the reader's light or dark setting on any site that shows SVG. A PNG can't, so
              the HTML snippet offers a dark and a light image instead.
            </p>
          </div>
          <.form
            for={@snippet_form}
            id="badge-snippet-form"
            phx-change="snippet"
            class="flex flex-wrap gap-4 items-end"
          >
            <div class="w-40">
              <.input
                field={@snippet_form[:kind]}
                type="select"
                label="Badge"
                options={@kind_options}
              />
            </div>
            <div class="w-40">
              <.input
                field={@snippet_form[:theme]}
                type="select"
                label="Theme"
                options={Spec.themes()}
              />
            </div>
            <div class="w-32">
              <.input
                field={@snippet_form[:format]}
                type="select"
                label="Format"
                options={[{"SVG", "svg"}, {"PNG", "png"}]}
              />
            </div>
          </.form>
          <div id="badge-snippet-preview" class="min-h-12 flex items-center">
            <img src={@snippet_preview} alt="Preview of the selected badge" />
          </div>
          <div class="space-y-3">
            <div :for={snippet <- @snippets} id={"badge-snippet-#{snippet.id}"} class="space-y-1.5">
              <div class="flex items-center justify-between gap-2">
                <span class="font-mono text-xs uppercase tracking-wider opacity-70">
                  {snippet.title}
                </span>
                <button
                  type="button"
                  id={"badge-copy-#{snippet.id}"}
                  class="font-mono text-xs px-2.5 py-1 rounded border border-base-300 hover:border-primary transition-colors"
                  phx-click={JS.dispatch("git_gud:copy", detail: %{text: snippet.text})}
                >
                  Copy
                </button>
              </div>
              <pre class="bg-base-200 rounded px-3 py-2 text-xs overflow-x-auto"><code>{snippet.text}</code></pre>
            </div>
          </div>
        </section>

        <section id="badges-coverage" class="space-y-3">
          <div>
            <h2 class="text-lg font-semibold">Reporting to badges</h2>
            <p class="text-sm opacity-70 max-w-prose">
              Two job outputs feed the badges. <span class="font-mono">gitgud-coverage</span>
              takes a percentage: when the job succeeds, the commit gets a
              <span class="font-mono">coverage</span>
              status and the coverage badge shows the newest one on {@repo.default_branch}.
              <span class="font-mono">gitgud-summary</span>
              says in a few words what a job found, like <span class="font-mono">1 no fix</span>
              from an audit: the workflow's badge shows it in place of
              <span class="font-mono">warning</span>
              or <span class="font-mono">failing</span>, and so does the job's check on the commit.
            </p>
          </div>
          <pre
            id="badge-coverage-example"
            class="bg-base-200 rounded px-3 py-2 text-xs overflow-x-auto"
          ><code>{@coverage_yaml}</code></pre>
        </section>
      </div>
    </Layouts.app>
    """
  end

  attr :item, :map, required: true
  attr :spec, :map, required: true
  attr :href, :string, required: true
  attr :choosing, :boolean, default: false
  attr :selected, :boolean, default: false

  # One badge with what it is. Choosing for the README block, it toggles
  # the badge in or out; otherwise it opens the badge's image.
  defp badge_tile(assigns) do
    ~H"""
    <%= if @choosing do %>
      <button
        type="button"
        phx-click="toggle_item"
        phx-value-key={@item.key}
        aria-pressed={to_string(@selected)}
        class={[
          "w-full h-full text-left rounded-box border p-3 flex items-start gap-3 transition-all duration-150",
          if(@selected,
            do: "border-primary bg-primary/10 shadow-sm",
            else: "border-base-300 hover:border-primary/60 opacity-80 hover:opacity-100"
          )
        ]}
      >
        <span class={[
          "mt-0.5 size-4 shrink-0 rounded border flex items-center justify-center transition-colors",
          if(@selected,
            do: "bg-primary border-primary text-primary-content",
            else: "border-base-content/40"
          )
        ]}>
          <.icon :if={@selected} name="hero-check" class="size-3" />
        </span>
        <.tile_body item={@item} spec={@spec} />
      </button>
    <% else %>
      <a
        href={@href}
        target="_blank"
        rel="noopener"
        class="w-full h-full rounded-box border border-base-300 p-3 flex items-start gap-3 transition-all duration-150 hover:border-primary/60 hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-primary"
      >
        <.tile_body item={@item} spec={@spec} />
      </a>
    <% end %>
    """
  end

  attr :item, :map, required: true
  attr :spec, :map, required: true

  defp tile_body(assigns) do
    ~H"""
    <span class="min-w-0 space-y-1.5">
      <span class="block max-w-full overflow-x-auto">
        {inline_badge(@item.content, @spec, "item-#{@item.index}")}
      </span>
      <span class="block text-sm font-medium leading-tight truncate">{@item.title}</span>
      <span class="block text-xs opacity-60 leading-tight">{@item.hint}</span>
    </span>
    """
  end

  attr :label, :string, required: true
  attr :id, :string, required: true
  slot :inner_block, required: true

  defp option_group(assigns) do
    ~H"""
    <div id={@id} class="space-y-1.5">
      <span class="block font-mono text-[0.68rem] uppercase tracking-wider opacity-70">
        {@label}
      </span>
      <div class="inline-flex rounded-field border border-base-300 overflow-hidden bg-base-100">
        {render_slot(@inner_block)}
      </div>
    </div>
    """
  end

  attr :id, :string, required: true
  attr :patch, :string, required: true
  attr :active, :boolean, default: false
  slot :inner_block, required: true

  defp option_link(assigns) do
    ~H"""
    <.link
      id={@id}
      patch={@patch}
      aria-current={@active && "page"}
      class={[
        "px-3 py-1.5 text-sm transition-colors duration-150 [&:not(:first-child)]:border-l border-base-300",
        if(@active,
          do: "bg-primary text-primary-content",
          else: "hover:bg-base-200"
        )
      ]}
    >
      {render_slot(@inner_block)}
    </.link>
    """
  end
end