9.8 KiB · text History 0d6bdf0
defmodule GitGud.Badges.Palette do
  @moduledoc """
  Badge colors for every theme, read from `assets/css/app.css` at
  compile time so a theme edited there is a badge edited here.

  A badge is painted by **role** (`label-bg`, `success-fg`, `mark`, …),
  never straight from a token, because the token that reads well
  differs by theme. `dark` and `light` put near-white text on mid-tone
  status colors, which falls short of the 4.5:1 that 11px text needs
  (`success` on `light` is 2.3:1); the neiam themes pair pastel status
  colors with dark ink and pass easily. So each text role names its
  preferred token and falls back down a list until one reaches 4.5:1
  on its background. The rest of the app keeps its tokens; only badges
  pick differently.

  A few tokens are borrowed from another theme for badges alone
  (`@borrowed`): `dark` and `light` take their info blue from
  blueprint's primary.

  Three ways to apply the roles, one per kind of embed
  (`GitGud.Badges.Svg`):

    * a fixed theme: hex values written straight onto the shapes
    * `auto`: a `<style>` with the `dark` roles and a
      `prefers-color-scheme: light` block with the `light` ones, the
      two themes `GitGud.Themes` resolves "system" to
    * inline in a GitGud page: classes that read custom properties set
      per `[data-theme]` by `inline_stylesheet/0`, so the badge
      follows whichever theme the reader picked
  """

  alias GitGud.Badges.Oklch

  @css_path Path.expand("../../../assets/css/app.css", __DIR__)
  @external_resource @css_path

  # WCAG AA for normal-size text; a badge's 11px is nowhere near large.
  @text_min 4.5
  # WCAG non-text contrast, for the status dot.
  @shape_min 3.0

  @slots ~w(success warning error info muted)

  parse_theme = fn block ->
    [_, name] = Regex.run(~r/name:\s*"([^"]+)"/, block)
    [_, scheme] = Regex.run(~r/color-scheme:\s*"([^"]+)"/, block)

    radius =
      case Regex.run(~r/--radius-selector:\s*([\d.]+)rem/, block) do
        [_, rem] -> String.to_float(if String.contains?(rem, "."), do: rem, else: rem <> ".0")
        nil -> 0.25
      end

    colors =
      ~r/--color-([a-z0-9-]+):\s*oklch\(([\d.]+)%\s+([\d.]+)\s+([\d.]+)\)/
      |> Regex.scan(block)
      |> Map.new(fn [_, token | lch] ->
        [l, c, h] =
          Enum.map(lch, fn n ->
            {f, ""} = Float.parse(n)
            f
          end)

        lin = Oklch.to_linear(l, c, h)
        {token, %{hex: Oklch.hex(lin), y: Oklch.luminance(lin), token: token}}
      end)

    # Last resorts for text on a mid-tone status color, where neither
    # the theme's light nor its dark text reaches 4.5:1 (`error` and
    # `info` in `dark` and `light` sit right at 4.3).
    inks =
      Map.new([{"ink-black", {0.0, 0.0, 0.0}}, {"ink-white", {1.0, 1.0, 1.0}}], fn {token, lin} ->
        {token, %{hex: Oklch.hex(lin), y: Oklch.luminance(lin), token: token}}
      end)

    {name, %{scheme: scheme, radius_px: radius * 16, colors: Map.merge(colors, inks)}}
  end

  parsed =
    ~r/@plugin "[^"]*daisyui-theme" \{(.*?)\n\}/s
    |> Regex.scan(File.read!(@css_path), capture: :all_but_first)
    |> Map.new(fn [block] -> parse_theme.(block) end)

  for theme <- GitGud.Themes.all(), not Map.has_key?(parsed, theme) do
    raise CompileError,
      description: "badge palette: theme #{inspect(theme)} has no daisyui-theme block in app.css"
  end

  # Badge-only stand-ins: `{badge theme, token} => {theme, token}`. The
  # `dark` and `light` themes (and so `auto`, which is made of them) draw
  # their `info` blue, the color of counts, releases, packages and a
  # running build, in blueprint's primary, with blueprint's own dark ink
  # on it. The themes themselves keep their `info` for the rest of the
  # app.
  @borrowed %{
    {"dark", "info"} => {"blueprint", "primary"},
    {"dark", "info-content"} => {"blueprint", "primary-content"},
    {"light", "info"} => {"blueprint", "primary"},
    {"light", "info-content"} => {"blueprint", "primary-content"}
  }

  parsed =
    Map.new(parsed, fn {name, theme} ->
      colors =
        Enum.reduce(@borrowed, theme.colors, fn
          {{^name, token}, {from, from_token}}, colors ->
            Map.put(
              colors,
              token,
              parsed |> Map.fetch!(from) |> Map.fetch!(:colors) |> Map.fetch!(from_token)
            )

          _, colors ->
            colors
        end)

      {name, %{theme | colors: colors}}
    end)

  contrast = fn a, b -> Oklch.contrast(a.y, b.y) end

  # The first candidate reaching `min` on `bg`, else whichever comes
  # closest: a role always resolves, and the sheet's audit shows any
  # that still fall short.
  pick = fn colors, candidates, bg, min ->
    bg = Map.fetch!(colors, bg)
    options = Enum.map(candidates, &Map.fetch!(colors, &1))

    Enum.find(options, &(contrast.(&1, bg) >= min)) ||
      Enum.max_by(options, &contrast.(&1, bg))
  end

  resolve = fn %{colors: c} ->
    ink = fn candidates, bg -> pick.(c, candidates, bg, @text_min) end
    token = &Map.fetch!(c, &1)

    slot_roles =
      Enum.flat_map(@slots, fn slot ->
        {bg, fg, dot, line, text} =
          if slot == "muted",
            do: {"base-300", "base-content", "neutral", "neutral", "base-content"},
            else: {slot, slot <> "-content", slot, slot, slot}

        dot_color = token.(dot)

        ring =
          if contrast.(dot_color, token.("base-300")) >= @shape_min,
            do: dot_color,
            else: token.("base-content")

        [
          {"#{slot}-bg", token.(bg)},
          {"#{slot}-fg",
           ink.([fg, "base-content", "base-300", "neutral-content", "ink-black", "ink-white"], bg)},
          {"#{slot}-dot", dot_color},
          {"#{slot}-ring", ring},
          {"#{slot}-line", token.(line)},
          {"#{slot}-text", ink.([text, "base-content", "ink-black", "ink-white"], "base-300")}
        ]
      end)

    Map.new(
      [
        {"gg-bg", token.("base-300")},
        {"gg-fg", ink.(["accent", "base-content"], "base-300")},
        {"label-bg", token.("neutral")},
        {"label-fg",
         ink.(
           ["neutral-content", "base-content", "base-300", "ink-white", "ink-black"],
           "neutral"
         )},
        {"plate", token.("base-300")},
        {"edge", token.("neutral")},
        {"divider", token.("neutral")},
        {"text", ink.(["base-content"], "base-300")},
        {"mark", ink.(["accent", "base-content"], "base-300")}
      ] ++ slot_roles
    )
  end

  @themes Map.new(parsed, fn {name, theme} -> {name, Map.put(theme, :roles, resolve.(theme))} end)

  @doc "The status slots a value can take."
  def slots, do: @slots

  @doc "The themes badges can be fixed to, in `GitGud.Themes.all/0` order."
  def themes, do: GitGud.Themes.all()

  @doc ~s(`"dark"` or `"light"`.)
  def scheme(theme), do: @themes |> Map.fetch!(theme) |> Map.fetch!(:scheme)

  @doc "The theme's `--radius-selector` in pixels: badge corners follow it."
  def radius_px(theme), do: @themes |> Map.fetch!(theme) |> Map.fetch!(:radius_px)

  @doc "`#rrggbb` for `role` in `theme`."
  def hex(theme, role), do: role(theme, role).hex

  @doc "`#rrggbb` for a theme token itself, e.g. `\"base-100\"`."
  def token_hex(theme, token),
    do:
      @themes |> Map.fetch!(theme) |> Map.fetch!(:colors) |> Map.fetch!(token) |> Map.fetch!(:hex)

  @doc "The token a role resolved to in `theme`, e.g. `\"base-300\"`."
  def token(theme, role), do: role(theme, role).token

  @doc "Every role name."
  def roles, do: @themes |> Map.fetch!("dark") |> Map.fetch!(:roles) |> Map.keys() |> Enum.sort()

  @doc "WCAG contrast between two roles in `theme`."
  def contrast(theme, fg, bg), do: Oklch.contrast(role(theme, fg).y, role(theme, bg).y)

  defp role(theme, role),
    do: @themes |> Map.fetch!(theme) |> Map.fetch!(:roles) |> Map.fetch!(role)

  @doc """
  The pairs a badge in `style` puts text (or the status dot) on, as
  `{name, fg_role, bg_role, minimum}` — what the badge sheet audits.
  """
  def audit_pairs("split") do
    [{"label", "label-fg", "label-bg", @text_min}] ++
      for(slot <- @slots, do: {slot, "#{slot}-fg", "#{slot}-bg", @text_min}) ++
      [{"GG", "gg-fg", "gg-bg", @text_min}]
  end

  def audit_pairs("dot") do
    [{"text", "text", "plate", @text_min}] ++
      for(
        slot <- @slots,
        slot != "muted",
        do: {"#{slot} dot", "#{slot}-ring", "plate", @shape_min}
      ) ++
      [{"GG", "mark", "plate", @text_min}]
  end

  def audit_pairs("outline") do
    [{"label", "text", "plate", @text_min}] ++
      for(slot <- @slots, slot != "muted", do: {slot, "#{slot}-text", "plate", @text_min}) ++
      [{"GG", "mark", "plate", @text_min}]
  end

  vars = fn theme ->
    t = Map.fetch!(@themes, theme)
    r = min(t.radius_px, 10)

    Enum.map_join(Enum.sort(t.roles), ";", fn {role, %{hex: hex}} -> "--ggb-#{role}:#{hex}" end) <>
      ";--ggb-r:#{r}px"
  end

  role_names = @themes |> Map.fetch!("dark") |> Map.fetch!(:roles) |> Map.keys() |> Enum.sort()

  @inline_stylesheet Enum.join(
                       [".ggb{#{vars.("dark")}}"] ++
                         for(
                           theme <- GitGud.Themes.all(),
                           do: "[data-theme=#{theme}] .ggb{#{vars.(theme)}}"
                         ) ++
                         for(role <- role_names, do: ".ggb .f-#{role}{fill:var(--ggb-#{role})}") ++
                         for(
                           role <- role_names,
                           do: ".ggb .s-#{role}{stroke:var(--ggb-#{role})}"
                         ) ++
                         [".ggb .g-r{rx:var(--ggb-r)}"],
                       "\n"
                     )

  @doc """
  The stylesheet that lets inline badges (`Svg.render(…, :inline)`)
  follow the page's theme: per theme, one custom property per role and
  the corner radius; then one rule per role reading it. Unthemed pages
  get the `dark` values.
  """
  def inline_stylesheet, do: @inline_stylesheet
end