A theme switch keeps the stream's resolution, and the console wears the whole theme #437

Merged
enricobuehler merged 1 commits from worktree-omarchy-theme-live into main 2026-08-28 22:00:15 +00:00
11 changed files with 494 additions and 42 deletions
@@ -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<ReloadWatcher>,
_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<ReloadWatcher> {
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<std::path::PathBuf> {
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 `<name>>><data>`, 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
@@ -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."
+1 -1
View File
@@ -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",
+54 -18
View File
@@ -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, unknown> = {}): 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<T>(content: string | null, fn: () => T): T {
const dir = mkdtempSync(join(tmpdir(), "pf-theme-"));
@@ -27,22 +39,37 @@ function withTheme<T>(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",
"</style><script>alert(1)</script>",
"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,
);
}
});
+25 -3
View File
@@ -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<string, unknown>;
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
}
+14 -3
View File
@@ -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,
});
+10 -3
View File
@@ -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 (
<svg
@@ -14,15 +21,15 @@ export function BrandMark({ className }: { className?: string }) {
<title>Punktfunk</title>
<path
d="M403.037,791.672c107.586,0 194.41,-86.824 194.41,-194.41c0,-107.586 -86.824,-194.41 -194.41,-194.41c-107.586,0 -194.41,86.824 -194.41,194.41c0,107.586 86.824,194.41 194.41,194.41Z"
fill="#a79ff8"
style={{ fill: "var(--pf-brand-light, #a79ff8)" }}
/>
<path
d="M735.276,540.321c76.075,-76.075 76.075,-198.862 0,-274.937c-76.075,-76.075 -198.862,-76.075 -274.937,0c-76.075,76.075 -76.075,198.862 0,274.937c76.075,76.075 198.862,76.075 274.937,0Z"
fill="#6c5bf3"
style={{ fill: "var(--pf-brand, #6c5bf3)" }}
/>
<path
d="M647.84,590.737c-64.853,17.403 -136.871,0.597 -187.885,-50.416c-51.013,-51.013 -67.819,-123.032 -50.416,-187.885c64.853,-17.403 136.871,-0.597 187.885,50.416c51.013,51.013 67.819,123.032 50.416,187.885Z"
fill="#d2c9fb"
style={{ fill: "var(--pf-highlight, #d2c9fb)" }}
/>
</svg>
);
+1 -1
View File
@@ -8,7 +8,7 @@ import { Wordmark } from "./wordmark";
export function Logo({ className }: { className?: string }) {
return (
<div className={cn("relative inline-block", className)}>
<BrandMark className="absolute left-0 top-0 w-[24%] -translate-x-[55%] -translate-y-[58%] drop-shadow-[0_4px_24px_rgba(108,91,243,0.45)]" />
<BrandMark className="absolute left-0 top-0 w-[24%] -translate-x-[55%] -translate-y-[58%] drop-shadow-[0_4px_24px_var(--pf-glow)]" />
<Wordmark className="block h-auto w-full" />
</div>
);
+14 -10
View File
@@ -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 (
<html
lang={locale}
className={theme?.mode === "light" ? undefined : "dark"}
data-omarchy={theme ? "" : undefined}
style={
theme
? ({
"--pf-brand": theme.accent,
"--pf-brand-light": theme.accent,
"--pf-accent": theme.accent,
"--pf-bg": theme.background,
"--pf-fg": theme.foreground,
} as CSSProperties)
: undefined
}
+69
View File
@@ -31,6 +31,7 @@
--pf-brand: #6c5bf3; /* deep violet — primary on light */
--pf-brand-light: #a79ff8; /* light violet — primary on dark */
--pf-highlight: #d2c9fb; /* lens highlight */
--pf-glow: rgb(108 91 243 / 0.45); /* the lens mark's drop shadow */
/* Surfaces — light · lavender (white bg, faint-violet cards/borders). */
--background: #ffffff;
@@ -109,6 +110,74 @@
--destructive-foreground: oklch(0.985 0 0);
}
/* ── Omarchy: the desktop's own palette, expanded into the console's tokens. ──
`data-omarchy` and the three --pf-* values below it are set by routes/__root.tsx
from the theme file Omarchy renders for us (server/util/omarchyTheme.ts); on
every other box the attribute is absent and none of this applies.
`:root[data-omarchy]` outranks both `:root` and `.dark`, so ONE block covers both
modes: the surface mixes take their direction from the theme's own background /
foreground pair, and "toward the foreground" is darker on a light theme and
lighter on a dark one — which is exactly what surfaces need either way.
Every ratio here was measured against six shipped Omarchy themes (Tokyo Night,
Gruvbox, Nord, Catppuccin Latte, Rose Pine Dawn, Everforest Light) for WCAG
contrast and, for the mark, for visible separation between its three tints.
Surfaces and the brand follow the theme. --success / --warning / --destructive do
NOT: they encode meaning, and a theme whose accent is red must not leave "delete"
and "save" the same colour. */
:root[data-omarchy] {
--background: var(--pf-bg);
--foreground: var(--pf-fg);
/* Cards, hovers and borders are the background lifted off itself by increasing
amounts of foreground, so they stay in the theme's hue instead of sitting on it. */
--card: color-mix(in oklab, var(--pf-bg) 94%, var(--pf-fg));
--card-foreground: var(--pf-fg);
--popover: var(--card);
--popover-foreground: var(--pf-fg);
--muted: color-mix(in oklab, var(--pf-bg) 90%, var(--pf-fg));
/* ponytail: 88% is as muted as this can go and stay readable, and on a theme whose
own foreground is only 5.2:1 against its own background (Everforest Light) NO
ratio reaches 4.5 on a card — the headroom is the theme's, not ours. Measured
worst case 3.8:1 there; every dark theme clears 4.5 comfortably. Give this its own
token if a theme ever needs a hand-picked value. */
--muted-foreground: color-mix(in oklab, var(--pf-fg) 88%, var(--pf-bg));
--secondary: color-mix(in oklab, var(--pf-bg) 86%, var(--pf-fg));
--secondary-foreground: var(--pf-fg);
--border: color-mix(in oklab, var(--pf-bg) 80%, var(--pf-fg));
--input: var(--border);
/* shadcn's `accent` is a subtle hover SURFACE (and @unom/ui's card ring), not the
brand at full strength — so it is a background-weighted tint of the accent, which
is what keeps the theme's own foreground readable on top of it. */
--accent: color-mix(in oklab, var(--pf-bg) 86%, var(--pf-accent));
--accent-foreground: var(--pf-fg);
/* The lens mark, rebuilt from the accent: deep circle, lighter circle, and the
highlight where they overlap. Mixed toward black and white rather than toward the
theme's own foreground — measured, because on a theme whose accent and foreground
are close (Tokyo Night) mixing them collapses the three tints into one flat blob.
These also carry --primary and --ring, which is why they are worth this care. */
--pf-brand: color-mix(in oklab, var(--pf-accent) 88%, black);
--pf-brand-light: color-mix(in oklab, var(--pf-accent) 55%, white);
--pf-highlight: color-mix(in oklab, var(--pf-accent) 15%, white);
--pf-glow: color-mix(in oklab, var(--pf-accent) 45%, transparent);
}
/* `.dark` takes --primary from the LIGHT tint, so the text on it has to be the dark
end of the theme — its own background. */
:root[data-omarchy].dark {
--primary-foreground: var(--pf-bg);
}
/* Light mode takes --primary from --pf-brand and keeps :root's white text, so the
button has to be deep enough to hold it: a soft accent (a dusty rose, a sage) is
only 3.9:1 at --pf-brand, and 5.7:1 here. */
:root[data-omarchy]:not(.dark) {
--primary: color-mix(in oklab, var(--pf-accent) 75%, black);
}
/* Map the palette to Tailwind colour/util tokens — both the shadcn vocabulary
and @unom/ui's, resolved to one set of values. */
@theme inline {
+180
View File
@@ -0,0 +1,180 @@
// Guards the Omarchy palette derivation in src/styles.css.
//
// That block turns three values from the desktop — background, foreground, accent — into every
// surface the console paints, using `color-mix(in oklab, …)`. The ratios in it are not taste: each
// one was picked by measuring contrast across real Omarchy themes, and nudging one by ten points
// is enough to put grey text on a grey card for somebody whose theme we have never seen. Nothing
// else in the repo can catch that — the mixes are resolved by the browser, so the typecheck, the
// unit tests and the build all pass on a palette that is unreadable.
//
// So: read the ratios back OUT of the stylesheet, redo the mixes here, and assert WCAG contrast
// against a table of shipped themes. It fails on a re-tune that breaks readability, and stays
// quiet on one that does not.
//
// Run by `postbuild`, beside check-i18n. No dependencies — oklab is about forty lines of maths.
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const CSS = join(
dirname(fileURLToPath(import.meta.url)),
"..",
"src",
"styles.css",
);
/** Real themes Omarchy ships, as `[background, foreground, accent]`. Everforest Light is in here
* on purpose: its own foreground is only 5.2:1 against its own background, so it is the floor
* that decides what a *derived* muted colour can possibly reach. */
const THEMES = {
"Tokyo Night (dark)": ["#1a1b26", "#a9b1d6", "#7aa2f7"],
"Gruvbox (dark)": ["#282828", "#ebdbb2", "#d79921"],
"Nord (dark)": ["#2e3440", "#d8dee9", "#88c0d0"],
"Catppuccin Latte (light)": ["#eff1f5", "#4c4f69", "#1e66f5"],
"Rose Pine Dawn (light)": ["#faf4ed", "#575279", "#d7827e"],
"Everforest Light": ["#fdf6e3", "#5c6a72", "#8da101"],
};
// ── colour maths: sRGB ⇄ Oklab (Ottosson), and WCAG 2.1 relative luminance ──────────────────
const hex = (h) => {
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 `--<token>: color-mix(in oklab, <first> N%, <second>)`. 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`,
);