yak(theme-explicit-light-is-overridden-on-dark-os-browsers-chip-backgrounds-are-dropped-pku7): theme: explicit light is overridden on dark-OS browsers; chip backgrounds are dropped

This commit is contained in:
Willem van den Ende
2026-10-29 13:07:08 +00:00
parent edba66239e
commit f5faa8a3af
4 changed files with 248 additions and 26 deletions
@@ -0,0 +1,188 @@
defmodule FirehoseWeb.ThemeStylesheetTest do
@moduledoc """
Guards the theme's source files against the two defects the theme yak records.
daisyUI 5 stores a *complete* colour in every `--color-*` custom property
(`--color-base-content: oklch(25% 0.015 50)`), so `oklch(var(--color-…))`
re-wraps a complete value and the browser drops the whole declaration. The
visible symptom was a transparent chip background and unstyled links.
Each theme's `prefersdark` option makes daisyUI emit an *unscoped*
`@media (prefers-color-scheme: dark) { :root { … } }`, which lands after the
light block and wins on equal specificity: an explicit `data-theme="light"`
rendered dark on a dark-OS browser. Theme selection now lives in the inline
script in `root.html.heex`, which always writes an explicit `data-theme`
before first paint, so no stylesheet rule may follow the OS on its own.
These read the two source files as text: they are regression guards, not
behavioural tests. The authoritative check for *rendered* colour and theme
selection is the factory probe (`mastra-factory/scripts/theme-probe.mjs`),
which needs a browser; `make verify` never parses or renders CSS.
"""
use ExUnit.Case, async: true
# `__DIR__`-relative so the assertions do not depend on the working directory.
@stylesheet Path.expand("../../assets/css/app.css", __DIR__)
@root_layout Path.expand("../../lib/firehose_web/components/layouts/root.html.heex", __DIR__)
@layouts Path.expand("../../lib/firehose_web/components/layouts.ex", __DIR__)
# Measured by the factory probe (chromium, 2026-10-29) for `.blogex-post-meta`
# in the light theme: base-content over base-100 composited at 0.6 opacity is
# 4.16:1, below the 4.5:1 acceptance floor. The alpha the floor needs is
# 0.625; anything below that fails.
@meta_min_opacity 0.625
# Same probe, same day, for `--color-primary` as *text* on `--color-base-100`
# in the light theme - what pagination links, body links and the post-title
# hover now use. 58% lightness measured 4.18:1; 56% is 4.54:1, too thin a
# margin; 54% is 4.93:1 (and 4.52:1 on base-200, the lightest chip ground).
@light_primary_max_lightness 0.54
describe "colour tokens" do
test "theme colour tokens are used as complete values" do
offenders =
@stylesheet
|> read()
|> String.split("\n")
|> Enum.with_index(1)
|> Enum.filter(fn {line, _number} -> line =~ ~r/oklch\(\s*var\(--color-/ end)
assert offenders == [],
"""
oklch(var(--color-…)) is invalid: the --color-* properties already hold a
complete oklch(…) value, so the declaration is dropped. Use var(--color-…)
directly, or color-mix() where a derived colour is genuinely needed.
Offending line(s):
#{Enum.map_join(offenders, "\n", fn {line, number} -> " #{number}: #{String.trim(line)}" end)}
"""
end
end
describe "theme selection" do
test "the head script always writes an explicit data-theme" do
script = theme_script()
# "system" is resolved here, from the OS preference, before first paint.
assert script =~ ~r/matchMedia\(\s*["']\(prefers-color-scheme:\s*dark\)["']\s*\)/
assert script =~ ~r/setAttribute\(\s*["']data-theme["']/
# Removing the attribute is what let the unstyled root fall back to the
# OS-driven media block, so "system" must never be expressed that way.
refute script =~ "removeAttribute"
end
test "an OS preference change re-resolves only while 'system' is the choice" do
assert theme_script() =~
~r/addEventListener\(\s*["']change["'][\s\S]*?stored\(\)\s*===\s*["']system["']/
end
test "the toggle knob follows the choice, not the resolved palette" do
# A system choice can resolve to the dark palette; the indicator must still
# sit on the system icon, or the toggle lies about what is selected.
layouts = read(@layouts)
assert layouts =~ "[[data-theme-choice=light]_&]:left-1/3"
assert layouts =~ "[[data-theme-choice=dark]_&]:left-2/3"
refute layouts =~ "[[data-theme=light]_&]"
refute layouts =~ "[[data-theme=dark]_&]"
end
# The human's decision (REVIEW.md, Q1): "default to light theme, and make
# setting explicit". Light is what an unset preference resolves to; "system"
# is an explicit choice, not the default.
test "no stored preference means the light theme" do
assert theme_script() =~ ~r/apply\(\s*stored\(\)\s*\|\|\s*["']light["']\s*\)/
end
test "no theme follows the OS preference on its own" do
# `prefersdark: true` mints the unscoped media block that outranked the
# explicit attribute. Selection is the script's job; the stylesheet's
# default (light) must apply whenever an attribute is absent.
assert theme_option("light", "prefersdark") == "false"
assert theme_option("dark", "prefersdark") == "false"
end
end
describe "post meta contrast" do
test "the post meta is dimmed no further than the contrast floor allows" do
opacity = declaration(".blogex-post-meta", "opacity")
assert opacity >= @meta_min_opacity,
"""
.blogex-post-meta has opacity #{opacity}, which composites base-content over
base-100 below the 4.5:1 floor (probe: 0.6 -> 4.16:1, 0.625 -> 4.51:1).
Raise it, or the meta line is unreadable in the light theme.
"""
end
end
describe "light theme link colour" do
test "primary is dark enough to be text on base-100" do
lightness = lightness(theme_option("light", "--color-primary"))
assert lightness <= @light_primary_max_lightness,
"""
The light theme's --color-primary has #{lightness} lightness, so link text
(pagination, body links, the post-title hover) composites below 4.5:1 on
base-100: the probe measured 4.18:1 at 0.58. 0.54 measures 4.93:1.
"""
end
end
defp read(path), do: File.read!(path)
# OKLCH lightness as a 0..1 fraction; daisyUI writes either `54%` or `0.54`.
defp lightness(value) do
case Regex.run(~r/^oklch\(\s*([\d.]+)\s*(%?)/, value) do
[_, number, "%"] -> number |> Float.parse() |> elem(0) |> Kernel./(100)
[_, number, ""] -> number |> Float.parse() |> elem(0)
_ -> flunk("cannot read the lightness of #{inspect(value)}")
end
end
# The declarations of a single plain rule (`selector { … }`).
defp declaration(selector, property) do
selector
|> rule_body()
|> then(fn body ->
case Regex.run(~r/^\s*#{property}:\s*([^;]+);/m, body) do
[_, value] -> value |> String.trim() |> String.to_float()
nil -> flunk("no #{property} in #{selector}")
end
end)
end
defp rule_body(selector) do
case Regex.run(~r/#{Regex.escape(selector)}\s*\{([^}]*)\}/, read(@stylesheet)) do
[_, body] -> body
nil -> flunk("no rule for #{selector} in #{@stylesheet}")
end
end
# The inline script in <head>, not the deferred `app.js` tag.
defp theme_script do
@root_layout
|> read()
|> then(&Regex.scan(~r/<script[^>]*>(.*?)<\/script>/s, &1, capture: :all_but_first))
|> List.flatten()
|> Enum.find(fn body -> body =~ "data-theme" end) ||
flunk("no inline theme script in #{@root_layout}")
end
# The body of the `@plugin "../vendor/daisyui-theme" { … }` block for `name`.
defp theme_block(name) do
@stylesheet
|> read()
|> String.split(~s(@plugin "../vendor/daisyui-theme" {))
|> Enum.find(fn block ->
Enum.any?(String.split(block, "\n"), &(&1 |> String.trim() == ~s(name: "#{name}";)))
end) || flunk("no daisyui-theme block named #{inspect(name)} in #{@stylesheet}")
end
defp theme_option(name, option) do
case Regex.run(~r/^\s*#{option}:\s*([^;]+);/m, theme_block(name)) do
[_, value] -> String.trim(value)
nil -> nil
end
end
end