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:
+28
-14
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user