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
+28 -14
View File
@@ -27,7 +27,11 @@
--color-base-200: oklch(94% 0.012 75); --color-base-200: oklch(94% 0.012 75);
--color-base-300: oklch(90% 0.016 75); --color-base-300: oklch(90% 0.016 75);
--color-base-content: oklch(25% 0.015 50); --color-base-content: oklch(25% 0.015 50);
--color-primary: oklch(58% 0.16 45); /* 54% lightness, not 58%: primary is also *text* (pagination links, body
links, the post-title hover). At 58% it measured 4.18:1 on base-100, below
the 4.5:1 floor; 54% is 4.93:1 there and 4.52:1 on base-200. Same hue and
chroma - this is a contrast fix, not a re-colour. */
--color-primary: oklch(54% 0.16 45);
--color-primary-content: oklch(98% 0.01 75); --color-primary-content: oklch(98% 0.01 75);
--color-secondary: oklch(55% 0.12 30); --color-secondary: oklch(55% 0.12 30);
--color-secondary-content: oklch(98% 0.008 30); --color-secondary-content: oklch(98% 0.008 30);
@@ -53,11 +57,18 @@
--noise: 0; --noise: 0;
} }
/* Warm dark theme */ /* Warm dark theme.
`prefersdark: false` deliberately: daisyUI emits an *unscoped*
`@media (prefers-color-scheme: dark) { :root { … } }` for a prefersdark
theme, which lands after the light block and beats `[data-theme="light"]` on
equal specificity - an explicit light choice rendered dark on a dark-OS
browser. The inline script in components/layouts/root.html.heex resolves
"system" to an explicit data-theme instead, so no rule here may follow the
OS preference on its own. */
@plugin "../vendor/daisyui-theme" { @plugin "../vendor/daisyui-theme" {
name: "dark"; name: "dark";
default: false; default: false;
prefersdark: true; prefersdark: false;
color-scheme: "dark"; color-scheme: "dark";
--color-base-100: oklch(24% 0.015 50); --color-base-100: oklch(24% 0.015 50);
--color-base-200: oklch(20% 0.012 50); --color-base-200: oklch(20% 0.012 50);
@@ -144,12 +155,12 @@ body { font-family: 'Source Sans 3', sans-serif; }
} }
.blogex-post-preview header h2 a { .blogex-post-preview header h2 a {
color: oklch(var(--color-base-content)); color: var(--color-base-content);
text-decoration: none; text-decoration: none;
} }
.blogex-post-preview header h2 a:hover { .blogex-post-preview header h2 a:hover {
color: oklch(var(--color-primary)); color: var(--color-primary);
} }
.blogex-post-description { .blogex-post-description {
@@ -163,7 +174,10 @@ body { font-family: 'Source Sans 3', sans-serif; }
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
opacity: 0.6; /* 0.6 measured 4.16:1 (base-content over base-100, light theme) - below the
4.5:1 floor. 0.625 is what the floor needs; 0.65 keeps a margin without
making the meta line as loud as the description (0.7 -> 5.66:1). */
opacity: 0.65;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
@@ -172,7 +186,7 @@ body { font-family: 'Source Sans 3', sans-serif; }
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
border-radius: 9999px; border-radius: 9999px;
font-size: 0.75rem; font-size: 0.75rem;
background: oklch(var(--color-base-200)); background: var(--color-base-200);
} }
.blogex-post-header h1 { .blogex-post-header h1 {
@@ -193,9 +207,9 @@ body { font-family: 'Source Sans 3', sans-serif; }
.blogex-post-body p { margin-top: 1rem; } .blogex-post-body p { margin-top: 1rem; }
.blogex-post-body ul { list-style: disc; padding-left: 1.5rem; margin-top: 0.5rem; } .blogex-post-body ul { list-style: disc; padding-left: 1.5rem; margin-top: 0.5rem; }
.blogex-post-body ol { list-style: decimal; padding-left: 1.5rem; margin-top: 0.5rem; } .blogex-post-body ol { list-style: decimal; padding-left: 1.5rem; margin-top: 0.5rem; }
.blogex-post-body pre { background: oklch(var(--color-base-200)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-top: 1rem; } .blogex-post-body pre { background: var(--color-base-200); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-top: 1rem; }
.blogex-post-body code { font-size: 0.875em; } .blogex-post-body code { font-size: 0.875em; }
.blogex-post-body a { color: oklch(var(--color-primary)); text-decoration: underline; } .blogex-post-body a { color: var(--color-primary); text-decoration: underline; }
/* External link marker on links rewritten by Blogex.ExternalLinks (class /* External link marker on links rewritten by Blogex.ExternalLinks (class
`blogex-external`, added at compile time together with target="_blank"). `blogex-external`, added at compile time together with target="_blank").
@@ -235,7 +249,7 @@ body { font-family: 'Source Sans 3', sans-serif; }
border-width: 0; border-width: 0;
} }
.blogex-post-body blockquote { border-left: 3px solid oklch(var(--color-primary)); padding-left: 1rem; margin-top: 1rem; margin-bottom: 1rem; font-style: italic; opacity: 0.85; } .blogex-post-body blockquote { border-left: 3px solid var(--color-primary); padding-left: 1rem; margin-top: 1rem; margin-bottom: 1rem; font-style: italic; opacity: 0.85; }
.blogex-tag-list { .blogex-tag-list {
display: flex; display: flex;
@@ -248,13 +262,13 @@ body { font-family: 'Source Sans 3', sans-serif; }
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border-radius: 9999px; border-radius: 9999px;
font-size: 0.875rem; font-size: 0.875rem;
background: oklch(var(--color-base-200)); background: var(--color-base-200);
text-decoration: none; text-decoration: none;
} }
.blogex-tag-link:hover, .blogex-tag-active { .blogex-tag-link:hover, .blogex-tag-active {
background: oklch(var(--color-primary)); background: var(--color-primary);
color: oklch(var(--color-primary-content)); color: var(--color-primary-content);
} }
.blogex-pagination { .blogex-pagination {
@@ -267,7 +281,7 @@ body { font-family: 'Source Sans 3', sans-serif; }
} }
.blogex-pagination a { .blogex-pagination a {
color: oklch(var(--color-primary)); color: var(--color-primary);
text-decoration: none; text-decoration: none;
} }
+4 -1
View File
@@ -84,7 +84,10 @@ defmodule FirehoseWeb.Layouts do
def theme_toggle(assigns) do def theme_toggle(assigns) do
~H""" ~H"""
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full"> <div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 transition-[left]" /> <%!-- The knob follows the visitor's choice, not the resolved palette: in
system mode data-theme may be light or dark, but the system icon stays
selected. --%>
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme-choice=light]_&]:left-1/3 [[data-theme-choice=dark]_&]:left-2/3 transition-[left]" />
<button <button
class="flex p-2 cursor-pointer w-1/3" class="flex p-2 cursor-pointer w-1/3"
@@ -14,20 +14,37 @@
</script> </script>
<script> <script>
(() => { (() => {
const setTheme = (theme) => { // Theme selection is always explicit: the document always carries a
if (theme === "system") { // data-theme attribute, resolved here before first paint. An explicit
localStorage.removeItem("phx:theme"); // choice therefore always wins in CSS, whatever the OS preference is;
document.documentElement.removeAttribute("data-theme"); // no stylesheet rule follows the OS on its own.
} else { //
localStorage.setItem("phx:theme", theme); // data-theme-choice records what the visitor actually chose (unset,
document.documentElement.setAttribute("data-theme", theme); // system, light, dark) and is what the toggle's knob follows: in
} // "system" mode the palette may resolve to light or dark, but the
// system icon must stay selected.
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
const stored = () => localStorage.getItem("phx:theme");
const resolve = (theme) =>
theme === "system" ? (prefersDark.matches ? "dark" : "light") : theme;
const apply = (theme) => {
document.documentElement.setAttribute("data-theme", resolve(theme));
document.documentElement.setAttribute("data-theme-choice", theme);
}; };
const setTheme = (theme) => {
localStorage.setItem("phx:theme", theme);
apply(theme);
};
// Light is the default: no stored preference means light, not "follow
// the OS". "System" is an explicit choice and is stored as such.
if (!document.documentElement.hasAttribute("data-theme")) { if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system"); apply(stored() || "light");
} }
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system")); // Follow the OS only while the visitor's recorded choice is "system".
prefersDark.addEventListener("change", () => {
if (stored() === "system") apply("system");
});
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "light"));
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme)); window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
})(); })();
</script> </script>
@@ -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