A theme switch keeps the stream's resolution, and the console wears the whole theme #437
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
Reference in New Issue
Block a user