From 55b1dc848d518079ef2765fb5402ebb67227e69a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 28 Aug 2026 23:38:08 +0200 Subject: [PATCH] A theme switch keeps the stream's resolution, and the console wears the whole theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from an Omarchy field session, one root cause each. ## The output resolution reset on every theme change `omarchy-theme-set` ends in `omarchy-restart-hyprctl`, which is `hyprctl reload` — and a reload drops EVERY runtime `hyprctl keyword`, our monitor rule for the streamed head included. This crate already documented that fact (it is what `restore_heads` relies on to re-light an `exclusive` session's monitors); nothing put the rule back. So the head silently fell to its default mode mid-stream. Resizing the client window appeared to fix it only because a resize on Linux re-creates the output, which runs `set_monitor_rule` again. The Hyprland backend now subscribes to the compositor's event socket and re-applies the rule on `configreloaded`. Any reload gets it — the operator's own `hyprctl reload`, `omarchy-refresh-config`, a theme switch — not just the one that was reported. The watcher is a socket read, not a poll: an idle session costs nothing, and its guard shuts the socket down on teardown so the thread cannot outlive the output (sessions are minted on every mid-stream resize). The mode only. A reload also undoes `topology: exclusive`'s head disables, but re-disabling them from this watcher risks a permanently dark desk — teardown's own `restore_heads` runs a reload to re-light them, and nothing orders the two today. Noted in the code where the fix belongs. ## The console did not follow the theme, and only half wore it Two separate causes: * `useUiConfig` was `staleTime: Infinity`. The ports on that payload genuinely cannot change without a restart, but the theme can, so the console sat in the old palette until someone reloaded it. Now polled at 2 s (and refetched on focus). Polled and not pushed because the console's SSE stream is a proxy of the HOST's, and the host reads nothing about themes. * Only `accent` and `mode` were carried. The template has rendered `background` and `foreground` all along and they were dropped on the floor, which is why a themed button sat on the console's own violet chrome. All three colours now reach the page, and a `[data-omarchy]` block in styles.css expands them into every surface with `color-mix(in oklab, …)` — cards, hovers, borders, the accent surface — plus the three tints of the lens mark, whose fills were hard-coded violet and now follow the brand tokens. One block covers light and dark: `:root[data-omarchy]` outranks both `:root` and `.dark`, and the mixes take their direction from the theme's own background/foreground pair. `--success` / `--warning` / `--destructive` deliberately do not follow: they encode meaning, and a theme whose accent is red must not leave "delete" and "save" the same colour. Every ratio was measured, not chosen: `web/tools/check-omarchy-palette.mjs` reads them back out of the stylesheet, redoes the mixes and asserts WCAG contrast across six shipped themes (Tokyo Night, Gruvbox, Nord, Catppuccin Latte, Rose Pine Dawn, Everforest Light). It runs in `postbuild` beside check-i18n, because nothing else in the repo can catch an unreadable palette — the mixes resolve in the browser, so the typecheck, the tests and the build all pass on one. Everforest Light is in that table on purpose: its own foreground is 5.2:1 against its own background, which is what caps a derived muted colour at 3.8:1 there. That ceiling is recorded in the stylesheet. ## Verification * pf-vdisplay: 273 tests pass on linux-gnu (docker), `xcheck.sh linux clippy` clean. The new `is_config_reload` test proved non-vacuous by relaxing the matcher to a `contains` and watching it fail. * web: build + `tsc --noEmit` + 37 tests + biome all clean. The theme-file validator test proved non-vacuous by dropping the background check; the palette gate proved non-vacuous by re-tuning a ratio. * On glass: the trigger chain confirmed on the Omarchy box (`omarchy-theme-set` → `omarchy-restart-hyprctl` → `hyprctl reload`), `configreloaded` present in the Hyprland 0.56.2 binary, and the event socket found and connectable at exactly the path the new code computes. The built console served the box's live theme end to end over `/_auth/ui-config`. * NOT yet verified on glass: the re-apply itself. A stream was live on the box and a `hyprctl reload` would have disrupted it. --- .../src/vdisplay/linux/hyprland.rs | 120 ++++++++++++ .../linux/omarchy/themed/punktfunk.json.tpl | 9 +- web/package.json | 2 +- web/server/util/omarchyTheme.test.ts | 72 +++++-- web/server/util/omarchyTheme.ts | 28 ++- web/src/api/uiConfig.ts | 17 +- web/src/components/brand-mark.tsx | 13 +- web/src/components/logo.tsx | 2 +- web/src/routes/__root.tsx | 24 ++- web/src/styles.css | 69 +++++++ web/tools/check-omarchy-palette.mjs | 180 ++++++++++++++++++ 11 files changed, 494 insertions(+), 42 deletions(-) create mode 100644 web/tools/check-omarchy-palette.mjs 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`, +); -- 2.54.0