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:
@@ -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
|
||||
Reference in New Issue
Block a user