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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
9.8 KiB · text
History
0d6bdf0