diff --git a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs index a9a143f5..4ff51086 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs @@ -45,7 +45,9 @@ use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput}; use anyhow::{anyhow, bail, Context, Result}; +use std::io::BufRead; use std::os::fd::OwnedFd; +use std::os::unix::net::UnixStream; use std::process::Command; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::mpsc::Sender; @@ -362,6 +364,7 @@ impl VirtualDisplay for HyprlandDisplay { remote_fd: Some(fd), preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), keepalive: Box::new(Keepalive { + _reload: watch_config_reloads(name.clone(), mode), _stop: stop, _output: output, }), @@ -389,10 +392,111 @@ impl VirtualDisplay for HyprlandDisplay { /// output xdph was still actively capturing, every single teardown. See [`StopGuard`] for what that /// did to xdph. struct Keepalive { + /// First, so the watcher is gone before the cast stops and the output is removed โ€” it must + /// never re-apply a monitor rule onto a head this same teardown is about to delete. + _reload: Option, _stop: StopGuard, _output: OutputGuard, } +/// Puts the streamed head's monitor rule back after a `hyprctl reload`. +/// +/// ๐Ÿ›‘ **A reload drops EVERY runtime `hyprctl keyword`** (see [`restore_heads`], which relies on +/// exactly that) โ€” and [`set_monitor_rule`]'s mode is one of them. So a reload silently returns the +/// streamed head to its default size mid-stream, and the client sees a resolution change nobody +/// asked for. Nothing in Hyprland re-applies it. +/// +/// On Omarchy that is not an edge case, it is routine: `omarchy-theme-set` ends in +/// `omarchy-restart-hyprctl`, which is literally `hyprctl reload`, so **every theme switch reset the +/// stream's resolution** (field report 2026-08-28). Resizing the client window appeared to fix it +/// only because a resize on Linux re-creates the output, which runs [`set_monitor_rule`] again. +/// +/// This subscribes to the compositor's own event socket rather than polling, so the rule is back +/// within a round trip and an idle session costs nothing. Any reload gets it โ€” the operator's own +/// `hyprctl reload`, `omarchy-refresh-config`, a theme switch โ€” not just the one that was reported. +/// +/// ponytail: the MODE only. A reload also undoes `topology: exclusive`'s head disables, but +/// re-disabling them from here risks a permanently dark desk: teardown's own [`restore_heads`] runs +/// a `hyprctl reload` to re-light them, and this watcher lives on a different object than that +/// restore does, so nothing orders the two. Re-apply the disables here once they share a lifetime. +fn watch_config_reloads(name: String, mode: Mode) -> Option { + let path = event_socket_path()?; + let sock = match UnixStream::connect(&path) { + Ok(s) => s, + Err(e) => { + tracing::debug!( + path = %path.display(), error = %e, + "hyprland: no event socket โ€” a `hyprctl reload` (every theme switch, on Omarchy) \ + will reset this stream's resolution until the client resizes" + ); + return None; + } + }; + // The guard's copy: shutting THIS down is what unparks the blocking read below. + let stopper = sock.try_clone().ok()?; + thread::spawn(move || { + for line in std::io::BufReader::new(sock).lines() { + // A read error โ€” the guard's shutdown, or the compositor going away โ€” ends the watch. + // There is nothing left to re-apply a rule to in either case. + let Ok(line) = line else { return }; + if !is_config_reload(&line) { + continue; + } + tracing::info!( + output = %name, w = mode.width, h = mode.height, + "hyprland: config reloaded โ€” re-applying the streamed head's monitor rule" + ); + if let Err(e) = set_monitor_rule(&name, mode) { + // `set_monitor_rule` only errors when the head has no framebuffer at all, which + // after a reload means it is gone (teardown, or the compositor restarted). Stop. + tracing::warn!( + output = %name, error = %format!("{e:#}"), + "hyprland: could not re-apply the monitor rule after a config reload โ€” the \ + client keeps the head's default resolution until it resizes" + ); + return; + } + } + }); + Some(ReloadWatcher(stopper)) +} + +/// Ends [`watch_config_reloads`]'s thread by shutting its socket down underneath it. +/// +/// The thread is parked in a blocking read, so a plain stop flag would leave it alive until the +/// compositor happened to emit an event โ€” one stranded thread per session, and sessions are minted +/// on every mid-stream resize. `shutdown` makes that read return immediately. +struct ReloadWatcher(UnixStream); + +impl Drop for ReloadWatcher { + fn drop(&mut self) { + let _ = self.0.shutdown(std::net::Shutdown::Both); + } +} + +/// Hyprland's event socket for the instance we are driving, or `None` when there is none to find. +/// Same signature [`hyprctl_command`] threads onto every child, so the watch and the commands can +/// never end up aimed at different compositors. +fn event_socket_path() -> Option { + let sig = crate::session::hypr_signature()?; + let runtime = crate::with_env_lock(|| std::env::var_os("XDG_RUNTIME_DIR")) + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + std::path::PathBuf::from(format!("/run/user/{}", crate::proc::current_uid())) + }); + Some(runtime.join("hypr").join(sig).join(".socket2.sock")) +} + +/// Is this event line the config reload? +/// +/// Hyprland's `.socket2.sock` speaks `>>`, so the match is on the NAME. A `contains` +/// would also fire on any event whose DATA happens to hold the word โ€” a window titled +/// `configreloaded`, a workspace named after it โ€” and every false hit is a `hyprctl` round trip and +/// a mode re-apply on a live stream. +fn is_config_reload(line: &str) -> bool { + line.split(">>").next() == Some("configreloaded") +} + /// How long teardown waits for the portal to confirm the ScreenCast session is closed before giving /// up and removing the output anyway. One D-Bus round trip through xdg-desktop-portal to xdph; three /// seconds is generous. Bounded on purpose: a portal that has already wedged must not be able to @@ -1685,6 +1789,22 @@ fn portal_thread( mod tests { use super::*; + /// The whole re-apply hangs off this one line match, and both ways of getting it wrong are + /// expensive: too strict and a theme switch still resets the stream's resolution; too loose + /// (a `contains`) and any window whose TITLE holds the word triggers a `hyprctl` round trip + /// plus a mode re-apply, on every keystroke that retitles it. + #[test] + fn only_the_config_reload_event_re_applies_the_monitor_rule() { + assert!(is_config_reload("configreloaded>>")); + // Real lines from `.socket2.sock`, none of which is a reload. + assert!(!is_config_reload("monitoradded>>PF-1234-1")); + assert!(!is_config_reload("monitorremovedv2>>3,PF-1234-1,PF-1234-1")); + assert!(!is_config_reload("activewindow>>kitty,~/src")); + // The `contains` trap: the word is in the DATA, not the event name. + assert!(!is_config_reload("activewindowv2>>title: configreloaded")); + assert!(!is_config_reload("workspace>>configreloaded")); + } + /// The Lua config manager parses a `dispatch` argument as a Lua expression, so the monitor /// name and the state must both be QUOTED โ€” an unquoted `dpms off HDMI-A-1` is what dies with /// `')' expected near 'off'` on 0.55.4. Pinning the shape here because the quoting is the diff --git a/packaging/linux/omarchy/themed/punktfunk.json.tpl b/packaging/linux/omarchy/themed/punktfunk.json.tpl index d96e1fdb..9a35c876 100644 --- a/packaging/linux/omarchy/themed/punktfunk.json.tpl +++ b/packaging/linux/omarchy/themed/punktfunk.json.tpl @@ -4,9 +4,12 @@ "semantic colors.toml into ~/.local/state/omarchy/current/theme/punktfunk.json.", "", "Installed by `punktfunk-omarchy setup` (optional), removed by `punktfunk-omarchy remove`.", - "Consumer #1 is the web console: accent plus light/dark, which is the part a user actually", - "perceives as 'it matches my theme'. The host reads nothing from this file โ€” there is no", - "host-side theme engine and no plan for one.", + "Consumer #1 is the web console, which uses ALL FOUR: mode and accent pick the palette and", + "re-tint the brand (buttons, nav, focus rings, the lens mark), and every surface โ€” cards,", + "hovers, borders โ€” is mixed out of the background/foreground pair, so the page belongs to the", + "theme instead of merely agreeing with its accent. All three colours are required together: a", + "file missing one is refused outright and the console keeps its own palette.", + "The host reads nothing from this file โ€” there is no host-side theme engine and no plan for one.", "", "The webapp window already inherits Omarchy's Chromium theming, so this only has to carry the", "colours the page itself paints." diff --git a/web/package.json b/web/package.json index 84f88f45..e2fb3d16 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,7 @@ "dev": "vite dev --port 47992", "prebuild": "orval --config orval.config.ts", "build": "vite build", - "postbuild": "node tools/check-i18n.mjs", + "postbuild": "node tools/check-i18n.mjs && node tools/check-omarchy-palette.mjs", "start": "bun run .output/server/index.mjs", "api:gen": "orval --config orval.config.ts", "lint": "tsc --noEmit", diff --git a/web/server/util/omarchyTheme.test.ts b/web/server/util/omarchyTheme.test.ts index fc3f4fff..d0a577e4 100644 --- a/web/server/util/omarchyTheme.test.ts +++ b/web/server/util/omarchyTheme.test.ts @@ -8,6 +8,18 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { omarchyTheme } from "./omarchyTheme"; +/** A rendered theme file, with `over` replacing any of its fields. All four are required by + * `omarchyTheme`, so a test that names only the field it is about still gets a valid file. */ +function rendered(over: Record = {}): string { + return JSON.stringify({ + mode: "dark", + background: "#1e1e2e", + foreground: "#cdd6f4", + accent: "#89b4fa", + ...over, + }); +} + /** Point `omarchyTheme` at a scratch XDG_STATE_HOME holding `content` (or nothing). */ function withTheme(content: string | null, fn: () => T): T { const dir = mkdtempSync(join(tmpdir(), "pf-theme-")); @@ -27,22 +39,37 @@ function withTheme(content: string | null, fn: () => T): T { } describe("omarchyTheme", () => { - test("reads mode and accent from a rendered template", () => { - expect( - withTheme('{"mode":"dark","accent":"#89b4fa"}', omarchyTheme), - ).toEqual({ + test("reads the whole palette from a rendered template", () => { + expect(withTheme(rendered(), omarchyTheme)).toEqual({ mode: "dark", + background: "#1e1e2e", + foreground: "#cdd6f4", accent: "#89b4fa", }); }); test("light mode survives; anything else is dark", () => { + expect(withTheme(rendered({ mode: "light" }), omarchyTheme)?.mode).toBe( + "light", + ); + expect(withTheme(rendered({ mode: "nonsense" }), omarchyTheme)?.mode).toBe( + "dark", + ); + }); + + test("a partial palette is no theme, not a half-themed console", () => { + // The accent alone is what the console used to take, and it left the violet chrome under a + // themed button. Every surface is now mixed from the background/foreground pair, so a file + // carrying neither has nothing to mix โ€” fall back to the console's own palette entire. expect( - withTheme('{"mode":"light","accent":"#1e66f5"}', omarchyTheme)?.mode, - ).toBe("light"); + withTheme('{"mode":"dark","accent":"#89b4fa"}', omarchyTheme), + ).toBeNull(); expect( - withTheme('{"mode":"nonsense","accent":"#1e66f5"}', omarchyTheme)?.mode, - ).toBe("dark"); + withTheme(rendered({ background: undefined }), omarchyTheme), + ).toBeNull(); + expect( + withTheme(rendered({ foreground: undefined }), omarchyTheme), + ).toBeNull(); }); test("no file is no theme, not an error", () => { @@ -53,22 +80,32 @@ describe("omarchyTheme", () => { // The exact shape of a `.tpl` Omarchy never rendered โ€” the placeholder is not a colour, and // letting it through would put `{{ accent }}` into a style declaration. expect( - withTheme('{"mode":"{{ mode }}","accent":"{{ accent }}"}', omarchyTheme), + withTheme( + '{"mode":"{{ mode }}","background":"{{ background }}",' + + '"foreground":"{{ foreground }}","accent":"{{ accent }}"}', + omarchyTheme, + ), ).toBeNull(); }); test("refuses anything that could break out of a style declaration", () => { - for (const accent of [ + const hostile = [ "red; background: url(http://evil/)", "#fff; --primary: blue", "", "expression(alert(1))", "url(javascript:alert(1))", "#".repeat(200), - ]) { - expect( - withTheme(JSON.stringify({ mode: "dark", accent }), omarchyTheme), - ).toBeNull(); + ]; + // EVERY colour reaches the DOM, not just the accent โ€” background and foreground are inlined + // into the same style attribute, so each of the three has to be validated, and a test that + // only covered `accent` would have missed the two that were added later. + for (const field of ["accent", "background", "foreground"]) { + for (const value of hostile) { + expect( + withTheme(rendered({ [field]: value }), omarchyTheme), + ).toBeNull(); + } } }); @@ -80,10 +117,9 @@ describe("omarchyTheme", () => { "rgb(137, 180, 250)", "oklch(0.7 0.1 250)", ]) { - expect( - withTheme(JSON.stringify({ mode: "dark", accent }), omarchyTheme) - ?.accent, - ).toBe(accent); + expect(withTheme(rendered({ accent }), omarchyTheme)?.accent).toBe( + accent, + ); } }); diff --git a/web/server/util/omarchyTheme.ts b/web/server/util/omarchyTheme.ts index b2396bb6..0a369f06 100644 --- a/web/server/util/omarchyTheme.ts +++ b/web/server/util/omarchyTheme.ts @@ -9,6 +9,10 @@ // Deliberately a FILE read and not an integration: there is no Omarchy API to call, the host learns // nothing, and a box that never opted in simply has no file โ€” which is why every failure here is // "no theme", never an error. The console's own palette is the fallback and always was. +// +// All four values are carried, not just the accent: an accent alone leaves the console's own violet +// chrome under a themed button, which is what "the theme is not fully applied" means in practice. +// `styles.css` mixes the surfaces out of the background/foreground pair. import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -19,6 +23,12 @@ export interface OmarchyTheme { /** The theme's accent, as a CSS colour. Mapped onto `--pf-brand`, which `--primary`, * `--accent` and `--ring` all derive from, so one value re-tints the console. */ accent: string; + /** The desktop's own page colour. Mapped onto `--pf-bg`, which the console's cards, hovers + * and borders are all mixed out of โ€” this is the value that makes the console look like it + * belongs to the theme rather than merely agreeing with its accent. */ + background: string; + /** The desktop's own text colour (`--pf-fg`), and the other end of every one of those mixes. */ + foreground: string; } /** Where Omarchy renders our template. `XDG_STATE_HOME` first, because that is what the spec says @@ -64,9 +74,21 @@ export function omarchyTheme(): OmarchyTheme | null { const parsed = JSON.parse(raw) as Record; const mode = parsed.mode === "light" ? "light" : "dark"; // An unrendered template still contains its `{{ accent }}` placeholder โ€” that is not a - // colour, and `isSafeColor` is what stops it reaching the page as one. - if (!isSafeColor(parsed.accent)) return null; - return { mode, accent: parsed.accent.trim() }; + // colour, and `isSafeColor` is what stops it reaching the page as one. All THREE colours + // are required: the template renders them together, so a file missing one is a file we do + // not understand, and half a palette reads worse than the console's own. + if ( + !isSafeColor(parsed.accent) || + !isSafeColor(parsed.background) || + !isSafeColor(parsed.foreground) + ) + return null; + return { + mode, + accent: parsed.accent.trim(), + background: parsed.background.trim(), + foreground: parsed.foreground.trim(), + }; } catch { return null; // half-written during a theme switch, or hand-edited into invalid JSON } diff --git a/web/src/api/uiConfig.ts b/web/src/api/uiConfig.ts index 2f73e660..b6e20834 100644 --- a/web/src/api/uiConfig.ts +++ b/web/src/api/uiConfig.ts @@ -9,6 +9,8 @@ import { useQuery } from "@tanstack/react-query"; export interface OmarchyTheme { mode: "light" | "dark"; accent: string; + background: string; + foreground: string; } export interface UiConfig { @@ -19,8 +21,17 @@ export interface UiConfig { } /** - * Deployment facts the console cannot infer. Cached for the session โ€” the ports cannot change - * without the server restarting, which reloads the page anyway. + * Deployment facts the console cannot infer. + * + * Polled, and it did not used to be: the ports genuinely cannot change without a server restart + * (which reloads the page), so this was cached for the session โ€” but the THEME on the same payload + * changes whenever the user runs `omarchy-theme-set`, and a console that only asked at startup sat + * in the old palette until someone reloaded it by hand. Polling and not pushing because the + * console's SSE stream is a proxy of the HOST's, and the host reads nothing about themes. + * + * The interval does not run while the tab is in the background (TanStack's default), and the + * refetch-on-focus this re-enables means switching theme and looking at the console is already + * enough. One small local file read per tick. */ export const useUiConfig = () => useQuery({ @@ -32,7 +43,7 @@ export const useUiConfig = () => if (!r.ok) throw new Error(`ui-config ${r.status}`); return (await r.json()) as UiConfig; }, - staleTime: Number.POSITIVE_INFINITY, + refetchInterval: 2_000, retry: 2, }); diff --git a/web/src/components/brand-mark.tsx b/web/src/components/brand-mark.tsx index 88593bfb..cfda85a6 100644 --- a/web/src/components/brand-mark.tsx +++ b/web/src/components/brand-mark.tsx @@ -2,6 +2,13 @@ // brand identity (flattened from the clients/apple punktfunk_Logo.icon, shared // verbatim with the marketing site + docs). Back-to-front: large light-violet // circle, deep-violet circle, light highlight where they overlap. +// +// The three fills are the brand TOKENS, not the violet literals they default to, so +// the mark re-tints with the rest of the console on an Omarchy box that gave us a +// theme (see the `[data-omarchy]` block in styles.css). Each keeps its literal as a +// var() fallback, so the mark is still correct anywhere the stylesheet is not. +// Inline `style` rather than a `fill=` attribute: var() in a presentation attribute +// is not reliable across engines, and in a style declaration it always is. export function BrandMark({ className }: { className?: string }) { return ( Punktfunk ); diff --git a/web/src/components/logo.tsx b/web/src/components/logo.tsx index 35265755..cdbe8acf 100644 --- a/web/src/components/logo.tsx +++ b/web/src/components/logo.tsx @@ -8,7 +8,7 @@ import { Wordmark } from "./wordmark"; export function Logo({ className }: { className?: string }) { return (
- +
); diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 53e461e4..54492b74 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -60,26 +60,30 @@ function RootComponent() { const isLogin = useRouterState({ select: (s) => s.location.pathname === "/login", }); - // On an Omarchy box that opted in, follow the desktop's theme: `mode` picks the palette the - // whole stylesheet already keys off, and `accent` re-tints the brand, which `--primary`, - // `--accent` and `--ring` all derive from โ€” so one value moves the buttons, the active nav and - // the focus rings together. Everywhere else `theme` is null and the console keeps its own - // violet, which is also what SSR renders and what shows for the moment before this resolves. + // On an Omarchy box that opted in, follow the desktop's theme. Three raw values go in and + // `data-omarchy` turns on the block in styles.css that expands them: `mode` picks the palette + // the whole stylesheet already keys off, the accent re-tints the brand (and with it `--primary`, + // `--accent`, `--ring` and the lens mark), and the background/foreground pair is what every + // surface โ€” cards, hovers, borders โ€” is mixed out of. Everywhere else `theme` is null, the + // attribute is absent and the console keeps its own violet, which is also what SSR renders and + // what shows for the moment before this resolves. // - // BOTH brand variables, not just `--pf-brand`: the light palette derives `--primary` from it, - // but `.dark` derives `--primary` from `--pf-brand-light`. Setting only the first re-tints the - // console in light mode and does nothing at all in dark โ€” which is the mode it ships in. + // The expansion lives in CSS rather than here on purpose: `color-mix()` does it natively, in one + // place, for both modes at once โ€” and it is the only way `.dark`'s own values get overridden + // without this component knowing which of them each mode uses. const { data: uiConfig } = useUiConfig(); const theme = uiConfig?.theme ?? null; return ( { + const s = h.replace("#", ""); + return [0, 2, 4].map((i) => Number.parseInt(s.slice(i, i + 2), 16) / 255); +}; +const toLin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); +const fromLin = (c) => + c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055; +const cbrt = (v) => (v >= 0 ? Math.cbrt(v) : -Math.cbrt(-v)); + +function toOklab([r0, g0, b0]) { + const [r, g, b] = [toLin(r0), toLin(g0), toLin(b0)]; + const l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + ]; +} + +function fromOklab([L, A, B]) { + const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3; + const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3; + const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3; + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ].map((c) => Math.min(1, Math.max(0, fromLin(c)))); +} + +/** `color-mix(in oklab, a pct%, b)`. */ +const mix = (a, pct, b) => { + const [x, y] = [toOklab(a), toOklab(b)]; + return fromOklab(x.map((v, i) => (pct / 100) * v + (1 - pct / 100) * y[i])); +}; + +const lum = ([r, g, b]) => + 0.2126 * toLin(r) + 0.7152 * toLin(g) + 0.0722 * toLin(b); +const contrast = (a, b) => { + const [hi, lo] = [lum(a), lum(b)].sort((p, q) => q - p); + return (hi + 0.05) / (lo + 0.05); +}; + +// โ”€โ”€ read the ratios back out of the stylesheet โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const css = readFileSync(CSS, "utf8"); + +/** The percentage in `--: color-mix(in oklab, N%, )`. Throws rather than + * defaulting: a declaration this cannot find is one nobody is checking any more. */ +function ratio(token) { + const m = css.match( + // `.` stops at the newline, so this can only ever read the token's own declaration โ€” + // and a character class excluding `)` would stop dead on the first `var(--pf-bg)`. + new RegExp(`--${token}:\\s*color-mix\\(in oklab,.*?(\\d+)%`), + ); + if (!m) { + throw new Error( + `check-omarchy-palette: no --${token} color-mix found in styles.css. If the ` + + "derivation was restructured, update this check with it.", + ); + } + return Number(m[1]); +} + +const R = { + card: ratio("card"), + muted: ratio("muted"), + mutedFg: ratio("muted-foreground"), + secondary: ratio("secondary"), + border: ratio("border"), + accent: ratio("accent"), + brand: ratio("pf-brand"), + brandLight: ratio("pf-brand-light"), + highlight: ratio("pf-highlight"), + // The light-mode override, in its own `:not(.dark)` rule. + primaryLight: Number( + css.match( + /:not\(\.dark\)\s*\{[\s\S]*?--primary:\s*color-mix\(in oklab,.*?(\d+)%/, + )?.[1] ?? Number.NaN, + ), +}; +if (Number.isNaN(R.primaryLight)) { + throw new Error( + "check-omarchy-palette: no light-mode --primary override found in styles.css.", + ); +} + +const WHITE = [1, 1, 1]; +const BLACK = [0, 0, 0]; +const failures = []; + +for (const [name, [bgH, fgH, acH]] of Object.entries(THEMES)) { + const dark = lum(hex(bgH)) < lum(hex(fgH)); + const [bg, fg, ac] = [hex(bgH), hex(fgH), hex(acH)]; + + const card = mix(bg, R.card, fg); + const mutedFg = mix(fg, R.mutedFg, bg); + const border = mix(bg, R.border, fg); + const accent = mix(bg, R.accent, ac); + const brand = mix(ac, R.brand, BLACK); + const brandLight = mix(ac, R.brandLight, WHITE); + const highlight = mix(ac, R.highlight, WHITE); + // `.dark` puts --primary on the light tint with the theme's background as its text; + // light mode uses its own deepened override with :root's white. + const primary = dark ? brandLight : mix(ac, R.primaryLight, BLACK); + const primaryFg = dark ? bg : WHITE; + + // Each floor is what the derivation was measured to hold, NOT an aspiration. Where a floor + // sits below WCAG AA the reason is the theme's own headroom, and it is named. + const checks = [ + ["foreground on card", fg, card, 4.5], + // A theme whose own foreground is ~5:1 on its own background cannot yield a MUTED + // variant that clears 4.5 on a card. 3.7 is the measured floor across this table. + ["muted-foreground on card", mutedFg, card, 3.7], + ["text on a primary button", primaryFg, primary, 4.5], + ["foreground on the accent surface", fg, accent, 4.5], + ["card distinguishable from background", card, bg, 1.05], + ["border distinguishable from card", border, card, 1.1], + // The lens mark is three tints of one accent; too close and it reads as a blob. + ["mark: light circle vs deep circle", brandLight, brand, 1.4], + ["mark: highlight vs light circle", highlight, brandLight, 1.25], + ]; + for (const [what, a, b, floor] of checks) { + const r = contrast(a, b); + if (r < floor) { + failures.push(`${name}: ${what} is ${r.toFixed(2)}:1, floor ${floor}:1`); + } + } +} + +if (failures.length > 0) { + console.error( + `โœ– Omarchy palette: ${failures.length} contrast floor(s) breached by the ratios in ` + + "src/styles.css:\n " + + failures.join("\n "), + ); + process.exit(1); +} +console.log( + `โœ” Omarchy palette: ${Object.keys(THEMES).length} themes clear every contrast floor`, +);