mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
perf(control-ui): load built-in theme palettes on demand (#130473)
* perf(control-ui): load built-in theme palettes on demand
Every built-in theme's tokens shipped in the startup stylesheet, so the default
path downloaded six palettes it never painted and each new theme taxed everyone.
That is what pushed the startup CSS ceiling from 45 to 47 KiB when Tide, Beacon,
and Phosphor landed.
Moves the twelve non-default palettes into public/themes/<family>.css, one file
per family covering both modes. Claw stays inline because its tokens are the
:root defaults, so the default path loses nothing and gains the bytes back.
The first-paint story this needed: index.html's boot script now links the active
family's palette during head parsing, which makes it render-blocking exactly
like the app stylesheet, so a persisted theme paints its own colours on the
first frame instead of flashing the default. The href is built from the mount
prefix the gateway already stamps on <html>, so it follows a configured Control
UI base path without the script having to know one. theme.ts keeps the link
correct when the theme changes at runtime, reusing the helper the webfont
stylesheets already use.
The nested resolve-theme ternary became a family table in the same script, since
it now picks an asset as well as a data-theme value.
startup CSS 45.8 -> 42.2 KiB gzip, below the 44.3 KiB it measured before the
three themes landed; ceiling restored 47 -> 45 KiB
base.css 64.0 -> 35.8 KiB raw
Adds a regression test that blocks every bundle script and asserts the palette
still applies, so moving this back into the app bundle fails instead of silently
reintroducing the flash. Verified it catches that: with the boot-script link
removed the assertion reports `expected null to be '/themes/tide.css'`.
* fix(control-ui): publish themes after their palettes load
* fix(control-ui): clean up palette completion listeners
* refactor(control-ui): consolidate theme name resolution
This commit is contained in:
committed by
GitHub
parent
6ae89b5a8e
commit
9ccbbf83f2
@@ -29,7 +29,7 @@ export default {
|
||||
},
|
||||
{
|
||||
// Theme token definitions are the one source of stylesheet hex colors.
|
||||
files: ["../ui/src/styles/base.css"],
|
||||
files: ["../ui/src/styles/base.css", "../ui/public/themes/*.css"],
|
||||
rules: {
|
||||
"color-no-hex": null,
|
||||
},
|
||||
|
||||
+1
-1
@@ -1699,7 +1699,7 @@
|
||||
"lint:ui:i18n": "pnpm ui:i18n:verify",
|
||||
"lint:ui:lit": "lit-analyzer \"ui/src/**/*.ts\" --quiet",
|
||||
"lint:ui:no-raw-window-open": "node scripts/run-oxlint.mjs --openclaw-focused-config --config config/oxlint/boundary-guards.json ui/src",
|
||||
"lint:ui:styles": "stylelint --config config/stylelint.config.mjs \"ui/src/**/*.css\" \"ui/src/**/*.ts\"",
|
||||
"lint:ui:styles": "stylelint --config config/stylelint.config.mjs \"ui/src/**/*.css\" \"ui/src/**/*.ts\" \"ui/public/themes/*.css\"",
|
||||
"lint:web-fetch-provider-boundaries": "node --import tsx scripts/check-web-fetch-provider-boundaries.mts",
|
||||
"lint:webhook:no-low-level-body-read": "node --import tsx scripts/check-webhook-auth-body-order.mts",
|
||||
"mac:open": "open dist/OpenClaw.app",
|
||||
|
||||
@@ -112,10 +112,10 @@ const TARGETED_LINT_PATH_LIMIT = 8;
|
||||
const LINTABLE_CORE_PATH_RE = /^(?:src|ui|packages)\/.+\.[cm]?[jt]sx?$/u;
|
||||
const LINTABLE_EXTENSION_PATH_RE = /^extensions\/[^/]+\/.+\.[cm]?[jt]sx?$/u;
|
||||
const LINTABLE_SCRIPT_PATH_RE = /^scripts\/.+\.[cm]?[jt]sx?$/u;
|
||||
const LINTABLE_UI_STYLE_PATH_RE = /^ui\/src\/.+\.(?:css|ts)$/u;
|
||||
const LINTABLE_UI_STYLE_PATH_RE = /^ui\/(?:src\/.+\.(?:css|ts)|public\/themes\/[^/]+\.css)$/u;
|
||||
const MARKDOWN_LINT_OPTIMIZATION_NEUTRAL_PATH_RE = /^(?:docs\/|README\.md$|.*\.mdx?$)/u;
|
||||
const CORE_LINT_OPTIMIZATION_NEUTRAL_PATH_RE =
|
||||
/^(?:scripts|test\/scripts)\/|^\.github\/workflows\/ci\.yml$|^ui\/src\/.+\.css$/u;
|
||||
/^(?:scripts|test\/scripts)\/|^\.github\/workflows\/ci\.yml$|^ui\/(?:src\/.+|public\/themes\/[^/]+)\.css$/u;
|
||||
const EXTENSION_LINT_OPTIMIZATION_NEUTRAL_PATH_RE =
|
||||
/^(?:test\/scripts\/|\.github\/workflows\/ci\.yml$)/u;
|
||||
const SCRIPT_LINT_OPTIMIZATION_NEUTRAL_PATH_RE =
|
||||
|
||||
@@ -31,13 +31,11 @@ const controlUiPerformanceBudgets = {
|
||||
startupJsGzipBytes: 350 * KIB,
|
||||
// 45 KiB CSS ceilings maintainer-approved 2026-07 alongside the interleaved
|
||||
// sidebar zone styling; headroom over the ~36.5 KiB post-diet baseline.
|
||||
// Raised to 47 KiB 2026-08-26 for the Tide/Beacon/Phosphor palettes: every
|
||||
// built-in theme's tokens ship in the startup sheet, so each new theme costs
|
||||
// ~0.5 KiB gzip whether or not anyone selects it. Moving per-theme palettes
|
||||
// to lazily linked stylesheets (as theme webfonts already are) would take
|
||||
// this back under the old ceiling; it needs a first-paint story first, since
|
||||
// a late palette flashes default colors where a late font only swaps.
|
||||
startupCssGzipBytes: 47 * KIB,
|
||||
// Briefly 47 KiB for the Tide/Beacon/Phosphor palettes, restored to 45 KiB
|
||||
// once those palettes moved out of the startup sheet into public/themes and
|
||||
// measured 42.2 KiB — below where they started. Adding a built-in theme no
|
||||
// longer costs the default path anything, so this ceiling should stay put.
|
||||
startupCssGzipBytes: 45 * KIB,
|
||||
largestJsGzipBytes: 215 * KIB,
|
||||
// Composer multiline surface (stack #124301) legitimately grew boot CSS;
|
||||
// operator decision 2026-08-25 rejected boot splitting due to precedence risk.
|
||||
|
||||
@@ -85,8 +85,13 @@ const policyTestWatches = [
|
||||
},
|
||||
{
|
||||
testFile: "ui/src/styles/base-theme-tokens.node.test.ts",
|
||||
ownerGlobs: ["ui/src/**/*.css"],
|
||||
watchGlobs: ["ui/src/**/*.css", "ui/src/**/*.ts"],
|
||||
ownerGlobs: ["ui/src/**/*.css", "ui/public/themes/*.css"],
|
||||
watchGlobs: ["ui/src/**/*.css", "ui/src/**/*.ts", "ui/public/themes/*.css"],
|
||||
},
|
||||
{
|
||||
testFile: "ui/src/styles/base-theme-contrast.node.test.ts",
|
||||
ownerGlobs: ["ui/src/styles/base.css", "ui/public/themes/*.css"],
|
||||
watchGlobs: ["ui/src/styles/base.css", "ui/public/themes/*.css"],
|
||||
},
|
||||
{
|
||||
testFile: "ui/src/styles/cursor-policy.node.test.ts",
|
||||
|
||||
@@ -56,6 +56,7 @@ if (uiI18nStatus !== 0) {
|
||||
path.resolve("scripts", "run-stylelint.mts"),
|
||||
"ui/src/**/*.css",
|
||||
"ui/src/**/*.ts",
|
||||
"ui/public/themes/*.css",
|
||||
],
|
||||
{ env: process.env, stdio: "inherit" },
|
||||
);
|
||||
|
||||
@@ -792,6 +792,12 @@ describe("scripts/changed-lanes", () => {
|
||||
oxlintTargets: [],
|
||||
stylelintTargets: ["ui/src/styles/base.css"],
|
||||
},
|
||||
{
|
||||
name: "public theme palette only",
|
||||
paths: ["ui/public/themes/tide.css"],
|
||||
oxlintTargets: [],
|
||||
stylelintTargets: ["ui/public/themes/tide.css"],
|
||||
},
|
||||
])("targets style lint for $name without broad core lint", (testCase) => {
|
||||
const plan = createChangedCheckPlan(detectChangedLanes(testCase.paths), {
|
||||
env: { PATH: "/usr/bin" },
|
||||
|
||||
@@ -80,14 +80,26 @@ it.each([
|
||||
});
|
||||
|
||||
describe("CI changed Node test plan", () => {
|
||||
it("routes Control UI style changes through source-scanning policy tests", () => {
|
||||
const shards = createChangedNodeTestShards(["ui/src/styles/chat/layout.css"]);
|
||||
it.each([
|
||||
{
|
||||
source: "ui/src/styles/chat/layout.css",
|
||||
targets: [
|
||||
"ui/src/styles/base-theme-tokens.node.test.ts",
|
||||
"ui/src/styles/cursor-policy.node.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "ui/public/themes/tide.css",
|
||||
targets: [
|
||||
"ui/src/styles/base-theme-tokens.node.test.ts",
|
||||
"ui/src/styles/base-theme-contrast.node.test.ts",
|
||||
],
|
||||
},
|
||||
])("routes $source through source-scanning policy tests", ({ source, targets: expected }) => {
|
||||
const shards = createChangedNodeTestShards([source]);
|
||||
const targets = shards?.flatMap((shard) => shard.targets ?? []) ?? [];
|
||||
|
||||
expect(targets).toEqual([
|
||||
"ui/src/styles/base-theme-tokens.node.test.ts",
|
||||
"ui/src/styles/cursor-policy.node.test.ts",
|
||||
]);
|
||||
expect(targets).toEqual(expected);
|
||||
});
|
||||
|
||||
it("routes cron alert sanitization changes through alert policy suites", () => {
|
||||
|
||||
+31
-28
@@ -52,34 +52,37 @@
|
||||
if (mode === "system") {
|
||||
mode = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||
}
|
||||
var resolved =
|
||||
theme === "knot"
|
||||
? mode === "light"
|
||||
? "openknot-light"
|
||||
: "openknot"
|
||||
: theme === "dash"
|
||||
? mode === "light"
|
||||
? "dash-light"
|
||||
: "dash"
|
||||
: theme === "absolutely"
|
||||
? mode === "light"
|
||||
? "absolutely-light"
|
||||
: "absolutely"
|
||||
: theme === "tide"
|
||||
? mode === "light"
|
||||
? "tide-light"
|
||||
: "tide"
|
||||
: theme === "beacon"
|
||||
? mode === "light"
|
||||
? "beacon-light"
|
||||
: "beacon"
|
||||
: theme === "phosphor"
|
||||
? mode === "light"
|
||||
? "phosphor-light"
|
||||
: "phosphor"
|
||||
: mode === "light"
|
||||
? "light"
|
||||
: "dark";
|
||||
// family -> [dark, light] resolved names. Kept in lockstep with
|
||||
// resolveTheme() in app/theme.ts and the files in public/themes.
|
||||
var FAMILIES = {
|
||||
knot: ["openknot", "openknot-light"],
|
||||
dash: ["dash", "dash-light"],
|
||||
absolutely: ["absolutely", "absolutely-light"],
|
||||
tide: ["tide", "tide-light"],
|
||||
beacon: ["beacon", "beacon-light"],
|
||||
phosphor: ["phosphor", "phosphor-light"],
|
||||
};
|
||||
var pair = FAMILIES[theme];
|
||||
var resolved = pair
|
||||
? pair[mode === "light" ? 1 : 0]
|
||||
: mode === "light"
|
||||
? "light"
|
||||
: "dark";
|
||||
if (pair) {
|
||||
// Script-created links need explicit render blocking before first paint.
|
||||
// The gateway stamps the mount prefix on <html> before this runs.
|
||||
var base =
|
||||
document.documentElement.getAttribute("data-openclaw-control-ui-base-path") || "";
|
||||
var palette = document.createElement("link");
|
||||
palette.rel = "stylesheet";
|
||||
palette.setAttribute("blocking", "render");
|
||||
palette.id = "openclaw-theme-palette-" + theme;
|
||||
palette.onerror = function () {
|
||||
palette.remove();
|
||||
};
|
||||
palette.href = base + "/themes/" + theme + ".css";
|
||||
document.head.appendChild(palette);
|
||||
}
|
||||
document.documentElement.setAttribute("data-theme", resolved);
|
||||
var resolvedMode = resolved.includes("light") ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme-mode", resolvedMode);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/* Absolutely palette; selected by index.html and app/theme.ts. */
|
||||
|
||||
:root[data-theme="absolutely"] {
|
||||
/*
|
||||
* Accent — terracotta clay on warm graphite
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #1c1c1a, relative luminance ≈ 0.0115):
|
||||
* --accent #d97757 L ≈ 0.257 contrast ≈ 5.5:1 AA text ✓
|
||||
* --accent-hover #e68b6b L ≈ 0.338 contrast ≈ 6.7:1 ✓
|
||||
* --primary-foreground #241f1b on #d97757 button: 5.2:1 AA ✓
|
||||
* --primary-foreground #241f1b on #e68b6b hover: 6.4:1 AA ✓
|
||||
* --destructive-foreground #fafafa on #d32f2f button: 4.77:1 AA ✓
|
||||
* focus-ring at 75% opacity: ≈ 3.6:1 against bg ✓ (non-text 3:1 required)
|
||||
*
|
||||
* The surface ramp sits a step below the original #262624 launch palette,
|
||||
* which was the lightest dark theme in the set by a wide margin. Every
|
||||
* pairing improved: worst text 4.68 -> 5.42:1, accent on --card 4.47 ->
|
||||
* 5.05:1 (AA where it previously was not). The accent still dims below AA on
|
||||
* --bg-muted (4.3:1), so it stays a fill/indicator color there, not body copy.
|
||||
*/
|
||||
--ring: #d97757;
|
||||
--accent: #d97757;
|
||||
--accent-hover: #e68b6b;
|
||||
--accent-muted: #d97757;
|
||||
--accent-subtle: rgba(217, 119, 87, 0.14);
|
||||
--accent-glow: rgba(217, 119, 87, 0.22);
|
||||
--primary: #d97757;
|
||||
--primary-hover: #e68b6b;
|
||||
--primary-foreground: #241f1b;
|
||||
|
||||
/* Deepen only the text-bearing destructive fill; keep danger accents vivid. */
|
||||
--destructive: #d32f2f;
|
||||
--destructive-hover: #bc2a2a;
|
||||
|
||||
/* Focus — clay ring */
|
||||
--focus: rgba(217, 119, 87, 0.2);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 75%, transparent);
|
||||
|
||||
/* Surfaces — warm graphite, a shade off true neutral */
|
||||
--bg: #1c1c1a;
|
||||
--bg-accent: #232320;
|
||||
--bg-elevated: #292825;
|
||||
--bg-hover: #302f2b;
|
||||
--bg-muted: #302f2b;
|
||||
|
||||
--card: #232320;
|
||||
--card-foreground: #ede8dd;
|
||||
--card-highlight: rgba(255, 246, 232, 0.04);
|
||||
--popover: #292825;
|
||||
--popover-foreground: #ede8dd;
|
||||
|
||||
--panel: #1c1c1a;
|
||||
--panel-strong: #292825;
|
||||
--panel-hover: #302f2b;
|
||||
--chrome: rgba(28, 28, 26, 0.96);
|
||||
--chrome-strong: rgba(28, 28, 26, 0.98);
|
||||
|
||||
--text: #e4dfd4;
|
||||
--text-strong: #f5f1e8;
|
||||
--chat-text: #e4dfd4;
|
||||
--muted: #aba498;
|
||||
--muted-strong: #b8b1a4;
|
||||
--muted-foreground: #aba498;
|
||||
|
||||
--border: #2e2c27;
|
||||
--border-strong: #3d3a33;
|
||||
--border-hover: #514d44;
|
||||
--input: #2e2c27;
|
||||
|
||||
--secondary: #232320;
|
||||
--secondary-foreground: #ede8dd;
|
||||
--accent-2: #b8926a;
|
||||
--accent-2-muted: rgba(184, 146, 106, 0.7);
|
||||
--accent-2-subtle: rgba(184, 146, 106, 0.12);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(12, 10, 8, 0.35);
|
||||
--shadow-md: 0 4px 16px rgba(12, 10, 8, 0.45);
|
||||
--shadow-lg: 0 12px 32px rgba(12, 10, 8, 0.55);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(255, 246, 232, 0.03);
|
||||
/* Typography is this theme's signature: a grotesque for chrome, a reading
|
||||
serif for chat prose (styles/fonts.css self-hosts both). The stacks keep
|
||||
system fallbacks so a failed font load degrades instead of blanking text. */
|
||||
--font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-chat: "Lora", Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
:root[data-theme="absolutely-light"] {
|
||||
/*
|
||||
* Accent — burnt clay on ivory paper
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #faf9f5, relative luminance ≈ 0.934):
|
||||
* --accent #a8452a L ≈ 0.126 contrast ≈ 5.6:1 AA text ✓
|
||||
* --accent-hover #8e3822 L ≈ 0.086 contrast ≈ 7.3:1 ✓
|
||||
* --primary-foreground #ffffff on #a8452a button: 5.9:1 AA ✓
|
||||
* focus-ring at 70% opacity: ≈ 3.2:1 against ivory bg ✓
|
||||
*/
|
||||
--ring: #a8452a;
|
||||
--accent: #a8452a;
|
||||
--accent-hover: #8e3822;
|
||||
--accent-muted: #a8452a;
|
||||
--accent-subtle: rgba(168, 69, 42, 0.1);
|
||||
--accent-glow: rgba(168, 69, 42, 0.14);
|
||||
--primary: #a8452a;
|
||||
|
||||
/* Surfaces — ivory paper with a faint warm cast */
|
||||
--bg: #faf9f5;
|
||||
--bg-accent: #f3f1e9;
|
||||
--bg-hover: #edeae0;
|
||||
--bg-muted: #edeae0;
|
||||
--bg-content: #f3f1e9;
|
||||
|
||||
--card-foreground: #1f1d1a;
|
||||
--card-highlight: rgba(60, 50, 30, 0.02);
|
||||
--popover-foreground: #1f1d1a;
|
||||
--session-hovercard-progress-surface: color-mix(
|
||||
in srgb,
|
||||
var(--card) 50%,
|
||||
var(--panel-strong) 50%
|
||||
);
|
||||
|
||||
--panel: #faf9f5;
|
||||
--panel-strong: #f3f1e9;
|
||||
--panel-hover: #e6e2d6;
|
||||
--chrome: rgba(250, 249, 245, 0.96);
|
||||
--chrome-strong: rgba(250, 249, 245, 0.98);
|
||||
|
||||
--text: #3d3a33;
|
||||
--text-strong: #1f1d1a;
|
||||
--chat-text: #3d3a33;
|
||||
--muted: #6b655b;
|
||||
--muted-strong: #555047;
|
||||
--muted-foreground: #6b655b;
|
||||
|
||||
--border: #e3dfd2;
|
||||
--border-strong: #cdc7b6;
|
||||
--border-hover: #ada694;
|
||||
--input: #e3dfd2;
|
||||
|
||||
--secondary: #f3f1e9;
|
||||
--secondary-foreground: #3d3a33;
|
||||
--accent-2: #8a6a44;
|
||||
--accent-2-muted: rgba(138, 106, 68, 0.75);
|
||||
--accent-2-subtle: rgba(138, 106, 68, 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(60, 50, 30, 0.05);
|
||||
--shadow-md: 0 4px 12px rgba(60, 50, 30, 0.07);
|
||||
--shadow-lg: 0 12px 28px rgba(60, 50, 30, 0.09);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(60, 50, 30, 0.04);
|
||||
/* Typography is this theme's signature: a grotesque for chrome, a reading
|
||||
serif for chat prose (styles/fonts.css self-hosts both). The stacks keep
|
||||
system fallbacks so a failed font load degrades instead of blanking text. */
|
||||
--font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-chat: "Lora", Georgia, "Times New Roman", serif;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/* Beacon palette; selected by index.html and app/theme.ts. */
|
||||
|
||||
/*
|
||||
* Beacon targets WCAG AAA (7:1), not the AA 4.5:1 floor the other themes hold,
|
||||
* for low vision, direct sunlight, projectors, and poor panels.
|
||||
*
|
||||
* That forces one deliberate break from the status-token convention in :root:
|
||||
* elsewhere each --x-subtle is --x at 8%, but a label sitting on a tint of its
|
||||
* own hue caps the achievable contrast, because the tint pulls the background
|
||||
* toward the text. Beacon tints neutrally instead and lets the label carry the
|
||||
* contrast, which is what buys AAA on the status pairs.
|
||||
*/
|
||||
:root[data-theme="beacon"] {
|
||||
/*
|
||||
* Accent — high-visibility amber on pure black
|
||||
*
|
||||
* WCAG 2.1 AAA audit (bg = #000000):
|
||||
* --accent #ffc233 contrast ≈ 13.0:1 AAA ✓
|
||||
* --primary-foreground #000000 on #ffc233 button: 13.0:1 ✓
|
||||
* worst text pairing --muted on --bg-muted: 10.29:1 ✓
|
||||
* worst status label on its neutral subtle tint: 7.05:1 ✓
|
||||
*/
|
||||
--ring: #ffc233;
|
||||
--accent: #ffc233;
|
||||
--accent-hover: #ffd45e;
|
||||
--accent-muted: #ffc233;
|
||||
--accent-subtle: rgba(255, 255, 255, 0.1);
|
||||
--accent-glow: rgba(255, 194, 51, 0.3);
|
||||
--primary: #ffc233;
|
||||
--primary-hover: #ffd45e;
|
||||
--primary-foreground: #000000;
|
||||
|
||||
/* Focus is 3px and fully opaque: a translucent ring is the first thing to
|
||||
disappear on a poor panel, which is exactly this theme's audience. */
|
||||
--focus: rgba(255, 194, 51, 0.35);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 5px var(--ring);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 5px var(--ring);
|
||||
|
||||
--bg: #000000;
|
||||
--bg-accent: #0a0a0a;
|
||||
--bg-elevated: #141414;
|
||||
--bg-hover: #1c1c1c;
|
||||
--bg-muted: #1c1c1c;
|
||||
|
||||
--card: #0a0a0a;
|
||||
--card-foreground: #ffffff;
|
||||
--card-highlight: rgba(255, 255, 255, 0.06);
|
||||
--popover: #141414;
|
||||
--popover-foreground: #ffffff;
|
||||
|
||||
--panel: #000000;
|
||||
--panel-strong: #141414;
|
||||
--panel-hover: #1c1c1c;
|
||||
--chrome: rgba(0, 0, 0, 0.98);
|
||||
--chrome-strong: #000000;
|
||||
|
||||
--text: #ffffff;
|
||||
--text-strong: #ffffff;
|
||||
--chat-text: #ffffff;
|
||||
--muted: #c9c9c9;
|
||||
--muted-strong: #dcdcdc;
|
||||
--muted-foreground: #c9c9c9;
|
||||
|
||||
/* Borders are structure here, not decoration: hairlines vanish first. */
|
||||
--border: #4a4a4a;
|
||||
--border-strong: #6e6e6e;
|
||||
--border-hover: #9a9a9a;
|
||||
--input: #4a4a4a;
|
||||
|
||||
--secondary: #141414;
|
||||
--secondary-foreground: #ffffff;
|
||||
--accent-2: #8ecdff;
|
||||
--accent-2-muted: rgba(142, 205, 255, 0.85);
|
||||
--accent-2-subtle: rgba(255, 255, 255, 0.1);
|
||||
|
||||
/* Neutral status tints — see the block comment above. */
|
||||
--ok: #5ee88a;
|
||||
--ok-muted: rgba(94, 232, 138, 0.85);
|
||||
--ok-subtle: rgba(255, 255, 255, 0.1);
|
||||
--destructive: #ffabab;
|
||||
--destructive-hover: #ffc4c4;
|
||||
--destructive-foreground: #000000;
|
||||
--warn: #ffc233;
|
||||
--warn-muted: rgba(255, 194, 51, 0.85);
|
||||
--warn-subtle: rgba(255, 255, 255, 0.1);
|
||||
--danger: #ffabab;
|
||||
--danger-muted: rgba(255, 171, 171, 0.85);
|
||||
--danger-subtle: rgba(255, 255, 255, 0.1);
|
||||
--info: #8ecdff;
|
||||
--info-subtle: rgba(255, 255, 255, 0.1);
|
||||
--run: #5ee88a;
|
||||
|
||||
/* Diff ink is a status pair too: it sits on the same neutral tints. */
|
||||
--hljs-deletion: #ffaba3;
|
||||
--hljs-addition: #7ee787;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.9);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.9);
|
||||
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.95);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
--overlay-border: var(--border-strong);
|
||||
|
||||
--grid-line: rgba(255, 255, 255, 0.08);
|
||||
/* Atkinson Hyperlegible Next everywhere, prose included: the face is part of
|
||||
the accessibility contract, not an accent (see styles fonts/beacon.css). */
|
||||
--font-body:
|
||||
"Atkinson Hyperlegible Next", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-chat: var(--font-body);
|
||||
}
|
||||
|
||||
:root[data-theme="beacon-light"] {
|
||||
/*
|
||||
* Accent — dark amber on pure white
|
||||
*
|
||||
* WCAG 2.1 AAA audit (bg = #ffffff):
|
||||
* --accent #6e4a00 contrast ≈ 8.0:1 AAA ✓
|
||||
* --primary-foreground #ffffff on #6e4a00 button: 8.0:1 ✓
|
||||
* worst text pairing --muted on --bg-muted: 9.72:1 ✓
|
||||
* worst status label on its neutral subtle tint: 7.02:1 ✓
|
||||
*/
|
||||
--ring: #6e4a00;
|
||||
--accent: #6e4a00;
|
||||
--accent-hover: #573a00;
|
||||
--accent-muted: #6e4a00;
|
||||
--accent-subtle: rgba(0, 0, 0, 0.07);
|
||||
--accent-glow: rgba(110, 74, 0, 0.2);
|
||||
--primary: #6e4a00;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--focus: rgba(110, 74, 0, 0.35);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 5px var(--ring);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 5px var(--ring);
|
||||
|
||||
--bg: #ffffff;
|
||||
--bg-accent: #f4f4f4;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-hover: #ededed;
|
||||
--bg-muted: #ededed;
|
||||
--bg-content: #f4f4f4;
|
||||
|
||||
--card: #ffffff;
|
||||
--card-foreground: #000000;
|
||||
--card-highlight: rgba(0, 0, 0, 0.04);
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #000000;
|
||||
--session-hovercard-surface: var(--card);
|
||||
--session-hovercard-progress-surface: var(--bg-accent);
|
||||
|
||||
--panel: #ffffff;
|
||||
--panel-strong: #f4f4f4;
|
||||
--panel-hover: #e4e4e4;
|
||||
--chrome: rgba(255, 255, 255, 0.98);
|
||||
--chrome-strong: #ffffff;
|
||||
|
||||
--text: #000000;
|
||||
--text-strong: #000000;
|
||||
--chat-text: #000000;
|
||||
--muted: #3a3a3a;
|
||||
--muted-strong: #262626;
|
||||
--muted-foreground: #3a3a3a;
|
||||
|
||||
--border: #8a8a8a;
|
||||
--border-strong: #5a5a5a;
|
||||
--border-hover: #2e2e2e;
|
||||
--input: #8a8a8a;
|
||||
|
||||
--secondary: #f4f4f4;
|
||||
--secondary-foreground: #000000;
|
||||
--accent-2: #09428d;
|
||||
--accent-2-muted: rgba(9, 66, 141, 0.85);
|
||||
--accent-2-subtle: rgba(0, 0, 0, 0.07);
|
||||
|
||||
/* Neutral status tints — see the block comment above. */
|
||||
--ok: #0c5024;
|
||||
--ok-muted: rgba(12, 80, 36, 0.85);
|
||||
--ok-subtle: rgba(0, 0, 0, 0.07);
|
||||
--destructive: #8c0f0f;
|
||||
--destructive-foreground: #ffffff;
|
||||
--warn: #613d00;
|
||||
--warn-muted: rgba(97, 61, 0, 0.85);
|
||||
--warn-subtle: rgba(0, 0, 0, 0.07);
|
||||
--danger: #8c0f0f;
|
||||
--danger-muted: rgba(140, 15, 15, 0.85);
|
||||
--danger-subtle: rgba(0, 0, 0, 0.07);
|
||||
--info: #09428d;
|
||||
--info-subtle: rgba(0, 0, 0, 0.07);
|
||||
--run: #0c5024;
|
||||
|
||||
/* Diff ink is a status pair too: it sits on the same neutral tints. */
|
||||
--hljs-deletion: #8b0c20;
|
||||
--hljs-addition: #0e5021;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.28);
|
||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.3);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
--overlay-border: var(--border-strong);
|
||||
|
||||
--grid-line: rgba(0, 0, 0, 0.12);
|
||||
/* Atkinson Hyperlegible Next everywhere, prose included: the face is part of
|
||||
the accessibility contract, not an accent (see styles fonts/beacon.css). */
|
||||
--font-body:
|
||||
"Atkinson Hyperlegible Next", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-chat: var(--font-body);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/* Dash palette; selected by index.html and app/theme.ts. */
|
||||
|
||||
:root[data-theme="dash"] {
|
||||
/*
|
||||
* Accent — chocolate brown on deep cocoa
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #1a1210, relative luminance ≈ 0.0069):
|
||||
* --accent #b47840 L ≈ 0.235 contrast ≈ 5.0:1 AA text ✓ (floor: #a8703c → 4.4:1, fails)
|
||||
* --accent-hover #c8885a L ≈ 0.306 contrast ≈ 6.3:1 ✓
|
||||
* --primary-foreground #1a1210 on #b47840 button: 5.0:1 AA ✓
|
||||
* --primary-foreground #1a1210 on #c8885a hover: 6.26:1 AA ✓
|
||||
* --destructive-foreground #fafafa on #d32f2f button: 4.77:1 AA ✓
|
||||
* --destructive-foreground #fafafa on #bc2a2a hover: 5.74:1 AA ✓
|
||||
* focus-ring at 80% opacity: ≈ 3.5:1 against bg ✓ (non-text 3:1 required)
|
||||
*/
|
||||
--ring: #b47840;
|
||||
--accent: #b47840;
|
||||
--accent-hover: #c8885a;
|
||||
--accent-muted: #b47840;
|
||||
--accent-subtle: rgba(180, 120, 64, 0.16);
|
||||
--accent-glow: rgba(180, 120, 64, 0.24);
|
||||
--primary: #b47840;
|
||||
--primary-foreground: #1a1210;
|
||||
|
||||
/* Deepen only the text-bearing destructive fill; keep danger accents vivid. */
|
||||
--destructive: #d32f2f;
|
||||
--destructive-hover: #bc2a2a;
|
||||
|
||||
/* Focus — match chocolate accent, slightly higher ring opacity for dark bg visibility */
|
||||
--focus: rgba(180, 120, 64, 0.2);
|
||||
|
||||
/* Surfaces — deep cocoa tones */
|
||||
--bg: #1a1210;
|
||||
--bg-accent: #201816;
|
||||
--bg-elevated: #28201c;
|
||||
--bg-hover: #302822;
|
||||
--bg-muted: #302822;
|
||||
|
||||
--card: #221a16;
|
||||
--card-foreground: #ece0d8;
|
||||
--card-highlight: rgba(255, 240, 225, 0.04);
|
||||
--popover: #28201c;
|
||||
--popover-foreground: #ece0d8;
|
||||
|
||||
--panel: #1a1210;
|
||||
--panel-strong: #28201c;
|
||||
--panel-hover: #302822;
|
||||
--chrome: rgba(26, 18, 16, 0.96);
|
||||
--chrome-strong: rgba(26, 18, 16, 0.98);
|
||||
|
||||
--text: #d8c8b8;
|
||||
--text-strong: #f0e4da;
|
||||
--chat-text: #d8c8b8;
|
||||
--muted: #a18f80;
|
||||
--muted-strong: #a08f7f;
|
||||
--muted-foreground: #a18f80;
|
||||
|
||||
--border: #302418;
|
||||
--border-strong: #443828;
|
||||
--border-hover: #5a4c3a;
|
||||
--input: #302418;
|
||||
|
||||
--secondary: #221a16;
|
||||
--secondary-foreground: #ece0d8;
|
||||
--accent-2: #a88050;
|
||||
--accent-2-muted: rgba(168, 128, 80, 0.7);
|
||||
--accent-2-subtle: rgba(168, 128, 80, 0.12);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(10, 6, 4, 0.35);
|
||||
--shadow-md: 0 4px 16px rgba(10, 6, 4, 0.45);
|
||||
--shadow-lg: 0 12px 32px rgba(10, 6, 4, 0.55);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(255, 240, 225, 0.03);
|
||||
}
|
||||
|
||||
:root[data-theme="dash-light"] {
|
||||
/*
|
||||
* Accent — deep chocolate on warm parchment
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #f7f2ec, relative luminance ≈ 0.893):
|
||||
* --accent #6e4828 L ≈ 0.081 contrast ≈ 7.2:1 AAA text ✓
|
||||
* --accent-hover #5c3c20 L ≈ 0.054 contrast ≈ 8.9:1 ✓
|
||||
* --primary-foreground #ffffff on #6e4828 button: 8.0:1 AAA ✓
|
||||
* focus-ring at 70% opacity: ≈ 3.4:1 against parchment bg ✓
|
||||
*/
|
||||
--ring: #6e4828;
|
||||
--accent: #6e4828;
|
||||
--accent-hover: #5c3c20;
|
||||
--accent-muted: #6e4828;
|
||||
--accent-subtle: rgba(110, 72, 40, 0.12);
|
||||
--accent-glow: rgba(110, 72, 40, 0.16);
|
||||
--primary: #6e4828;
|
||||
|
||||
/* Surfaces — warm parchment tones */
|
||||
--bg: #f7f2ec;
|
||||
--bg-accent: #f0e8e0;
|
||||
--bg-hover: #e8ddd2;
|
||||
--bg-muted: #e8ddd2;
|
||||
--bg-content: #f0e8e0;
|
||||
|
||||
--card-foreground: #2c2118;
|
||||
--card-highlight: rgba(80, 50, 20, 0.02);
|
||||
--popover-foreground: #2c2118;
|
||||
--session-hovercard-progress-surface: color-mix(
|
||||
in srgb,
|
||||
var(--card) 48%,
|
||||
var(--panel-strong) 52%
|
||||
);
|
||||
|
||||
--panel: #f7f2ec;
|
||||
--panel-strong: #f0e8e0;
|
||||
--panel-hover: #e0d4c8;
|
||||
--chrome: rgba(247, 242, 236, 0.96);
|
||||
--chrome-strong: rgba(247, 242, 236, 0.98);
|
||||
|
||||
--text: #4a3828;
|
||||
--text-strong: #2c2118;
|
||||
--chat-text: #4a3828;
|
||||
--muted: #725d4d;
|
||||
--muted-strong: #604838;
|
||||
--muted-foreground: #725d4d;
|
||||
|
||||
--border: #ddd0c2;
|
||||
--border-strong: #c8b8a6;
|
||||
--border-hover: #b0a090;
|
||||
--input: #ddd0c2;
|
||||
|
||||
--secondary: #f0e8e0;
|
||||
--secondary-foreground: #4a3828;
|
||||
--accent-2: #6a4e30;
|
||||
--accent-2-muted: rgba(106, 78, 48, 0.75);
|
||||
--accent-2-subtle: rgba(106, 78, 48, 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(60, 40, 20, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(60, 40, 20, 0.08);
|
||||
--shadow-lg: 0 12px 28px rgba(60, 40, 20, 0.1);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(80, 50, 20, 0.04);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/* Knot palette; selected by index.html and app/theme.ts. */
|
||||
|
||||
/* Theme families override accent tokens while keeping shared surfaces/layout. */
|
||||
:root[data-theme="openknot"] {
|
||||
/*
|
||||
* Accent — crimson red on true black
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #080808, relative luminance ≈ 0.003):
|
||||
* --accent #e5243b L ≈ 0.118 contrast ≈ 5.8:1 AA text ✓
|
||||
* --accent-hover #f03e52 L ≈ 0.163 contrast ≈ 7.2:1 ✓
|
||||
* --primary-foreground #fafafa on #d92a3f button: 4.62:1 AA ✓
|
||||
* --primary-foreground #fafafa on #c22537 hover: 5.57:1 AA ✓
|
||||
* --destructive-foreground #fafafa on #d32f2f button: 4.77:1 AA ✓
|
||||
* --destructive-foreground #fafafa on #bc2a2a hover: 5.74:1 AA ✓
|
||||
* focus-ring at 70% opacity: ≈ 3.4:1 against bg ✓ (non-text 3:1 required)
|
||||
*/
|
||||
--ring: #e5243b;
|
||||
--accent: #e5243b;
|
||||
--accent-hover: #f03e52;
|
||||
--accent-muted: #e5243b;
|
||||
--accent-subtle: rgba(229, 36, 59, 0.12);
|
||||
--accent-glow: rgba(229, 36, 59, 0.24);
|
||||
--primary: #d92a3f;
|
||||
--primary-hover: #c22537;
|
||||
--primary-foreground: #fafafa;
|
||||
|
||||
/* Deepen only the text-bearing destructive fill; keep danger accents vivid. */
|
||||
--destructive: #d32f2f;
|
||||
--destructive-hover: #bc2a2a;
|
||||
|
||||
/* Focus — crimson ring */
|
||||
--focus: rgba(229, 36, 59, 0.2);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 70%, transparent);
|
||||
|
||||
/* Surfaces — true black canvas */
|
||||
--bg: #080808;
|
||||
--bg-accent: #0d0d0f;
|
||||
--bg-elevated: #141416;
|
||||
--bg-hover: #1a1a1e;
|
||||
--bg-muted: #1a1a1e;
|
||||
|
||||
--card: #111113;
|
||||
--card-highlight: rgba(255, 255, 255, 0.03);
|
||||
--popover: #141416;
|
||||
|
||||
--panel: #080808;
|
||||
--panel-strong: #141416;
|
||||
--panel-hover: #1a1a1e;
|
||||
--chrome: rgba(8, 8, 8, 0.96);
|
||||
--chrome-strong: rgba(8, 8, 8, 0.98);
|
||||
|
||||
/* Text — high contrast on black */
|
||||
--text: #e0e0e2;
|
||||
--text-strong: #f5f5f7;
|
||||
--chat-text: #e0e0e2;
|
||||
--muted: #8a8a94;
|
||||
--muted-strong: #888892;
|
||||
--muted-foreground: #8a8a94;
|
||||
|
||||
/* Borders — whisper-thin, barely visible */
|
||||
--border: #1a1a1e;
|
||||
--border-strong: #2a2a30;
|
||||
--border-hover: #3a3a42;
|
||||
--input: #1a1a1e;
|
||||
|
||||
--secondary: #111113;
|
||||
--accent-2: #8b1a2b;
|
||||
--accent-2-muted: rgba(139, 26, 43, 0.7);
|
||||
--accent-2-subtle: rgba(139, 26, 43, 0.12);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.6);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
:root[data-theme="openknot-light"] {
|
||||
/*
|
||||
* Accent — deep red on cool white
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #f9f9fb, relative luminance ≈ 0.941):
|
||||
* --accent #c41e30 L ≈ 0.078 contrast ≈ 7.5:1 AAA text ✓
|
||||
* --accent-hover #a8192a L ≈ 0.054 contrast ≈ 9.1:1 ✓
|
||||
* --primary-foreground #ffffff on #c41e30 button: 5.0:1 AA ✓
|
||||
* focus-ring at 60% opacity: ≈ 3.2:1 against bg ✓ (non-text 3:1 required)
|
||||
*/
|
||||
--ring: #c41e30;
|
||||
--accent: #c41e30;
|
||||
--accent-hover: #a8192a;
|
||||
--accent-muted: #c41e30;
|
||||
--accent-subtle: rgba(196, 30, 48, 0.08);
|
||||
--accent-glow: rgba(196, 30, 48, 0.12);
|
||||
--primary: #c41e30;
|
||||
|
||||
/* Surfaces — cool white with subtle warmth */
|
||||
--bg: #f9f9fb;
|
||||
--bg-accent: #f2f2f5;
|
||||
--bg-hover: #eaeaef;
|
||||
--bg-muted: #eaeaef;
|
||||
--bg-content: #f2f2f5;
|
||||
|
||||
--card-foreground: #18181b;
|
||||
--card-highlight: rgba(0, 0, 0, 0.02);
|
||||
--popover-foreground: #18181b;
|
||||
--session-hovercard-progress-surface: color-mix(
|
||||
in srgb,
|
||||
var(--card) 52%,
|
||||
var(--panel-strong) 48%
|
||||
);
|
||||
|
||||
--panel: #f9f9fb;
|
||||
--panel-strong: #f2f2f5;
|
||||
--panel-hover: #e4e4ea;
|
||||
--chrome: rgba(249, 249, 251, 0.96);
|
||||
--chrome-strong: rgba(249, 249, 251, 0.98);
|
||||
|
||||
/* Text — crisp dark on white */
|
||||
--text: #3a3a42;
|
||||
--text-strong: #18181b;
|
||||
--chat-text: #3a3a42;
|
||||
--muted: #68676f;
|
||||
--muted-strong: #52525a;
|
||||
--muted-foreground: #68676f;
|
||||
|
||||
/* Borders — clean, minimal */
|
||||
--border: #e2e2e8;
|
||||
--border-strong: #ccccd4;
|
||||
--border-hover: #adadb8;
|
||||
--input: #e2e2e8;
|
||||
|
||||
--secondary: #f2f2f5;
|
||||
--secondary-foreground: #3a3a42;
|
||||
--accent-2: #7a1420;
|
||||
--accent-2-muted: rgba(122, 20, 32, 0.75);
|
||||
--accent-2-subtle: rgba(122, 20, 32, 0.08);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.08);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/* Phosphor palette; selected by index.html and app/theme.ts. */
|
||||
|
||||
:root[data-theme="phosphor"] {
|
||||
/*
|
||||
* Accent — phosphor green on green-cast black
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #0a0f0a, relative luminance ≈ 0.0045):
|
||||
* --accent #4ade80 contrast ≈ 11.1:1 AAA ✓
|
||||
* --primary-foreground #07120a on #4ade80 button: 11.0:1 ✓
|
||||
* --destructive-foreground #fafafa on #d32f2f button: 4.77:1 AA ✓
|
||||
* worst text pairing --muted on --bg-muted: 6.69:1 ✓
|
||||
* inherited status label on own subtle tint, worst surface: 5.32:1 ✓
|
||||
*/
|
||||
--ring: #4ade80;
|
||||
--accent: #4ade80;
|
||||
--accent-hover: #6ee79b;
|
||||
--accent-muted: #4ade80;
|
||||
--accent-subtle: rgba(74, 222, 128, 0.12);
|
||||
--accent-glow: rgba(74, 222, 128, 0.22);
|
||||
--primary: #4ade80;
|
||||
--primary-hover: #6ee79b;
|
||||
--primary-foreground: #07120a;
|
||||
|
||||
--destructive: #d32f2f;
|
||||
--destructive-hover: #bc2a2a;
|
||||
|
||||
--focus: rgba(74, 222, 128, 0.2);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 75%, transparent);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow);
|
||||
|
||||
--bg: #0a0f0a;
|
||||
--bg-accent: #0e150e;
|
||||
--bg-elevated: #121a12;
|
||||
--bg-hover: #18221a;
|
||||
--bg-muted: #18221a;
|
||||
|
||||
--card: #0e150e;
|
||||
--card-foreground: #e8f5e9;
|
||||
--card-highlight: rgba(200, 255, 210, 0.04);
|
||||
--popover: #121a12;
|
||||
--popover-foreground: #e8f5e9;
|
||||
|
||||
--panel: #0a0f0a;
|
||||
--panel-strong: #121a12;
|
||||
--panel-hover: #18221a;
|
||||
--chrome: rgba(10, 15, 10, 0.96);
|
||||
--chrome-strong: rgba(10, 15, 10, 0.98);
|
||||
|
||||
--text: #cfe0cf;
|
||||
--text-strong: #e8f5e9;
|
||||
--chat-text: #cfe0cf;
|
||||
--muted: #93ac95;
|
||||
--muted-strong: #a4bba6;
|
||||
--muted-foreground: #93ac95;
|
||||
|
||||
--border: #1d291f;
|
||||
--border-strong: #2c3d2f;
|
||||
--border-hover: #3e5442;
|
||||
--input: #1d291f;
|
||||
|
||||
--secondary: #0e150e;
|
||||
--secondary-foreground: #e8f5e9;
|
||||
--accent-2: #8fd6a5;
|
||||
--accent-2-muted: rgba(143, 214, 165, 0.7);
|
||||
--accent-2-subtle: rgba(143, 214, 165, 0.12);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 6, 2, 0.4);
|
||||
--shadow-md: 0 4px 16px rgba(0, 6, 2, 0.5);
|
||||
--shadow-lg: 0 12px 32px rgba(0, 6, 2, 0.6);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(200, 255, 210, 0.04);
|
||||
|
||||
/* The whole surface is one face, prose included. Mono is a deliberate
|
||||
character choice for an operator console, not a readability claim; it is
|
||||
why this theme is opt-in. --mono already named JetBrains Mono, but nothing
|
||||
shipped it until this theme did. */
|
||||
--mono:
|
||||
"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
--font-body: var(--mono);
|
||||
--font-chat: var(--mono);
|
||||
}
|
||||
|
||||
:root[data-theme="phosphor-light"] {
|
||||
/*
|
||||
* Accent — deep green on cool paper
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #f4f7f4, relative luminance ≈ 0.897):
|
||||
* --accent #10693a contrast ≈ 6.3:1 AA text ✓
|
||||
* --primary-foreground #ffffff on #10693a button: 6.8:1 AA ✓
|
||||
* worst text pairing --muted on --bg-muted: 4.76:1 ✓
|
||||
*/
|
||||
--ring: #10693a;
|
||||
--accent: #10693a;
|
||||
--accent-hover: #0b512c;
|
||||
--accent-muted: #10693a;
|
||||
--accent-subtle: rgba(16, 105, 58, 0.1);
|
||||
--accent-glow: rgba(16, 105, 58, 0.14);
|
||||
--primary: #10693a;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--bg: #f4f7f4;
|
||||
--bg-accent: #ecf1ec;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-hover: #e4ebe5;
|
||||
--bg-muted: #e4ebe5;
|
||||
--bg-content: #ecf1ec;
|
||||
|
||||
--card: #ffffff;
|
||||
--card-foreground: #16201a;
|
||||
--card-highlight: rgba(20, 50, 30, 0.02);
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #16201a;
|
||||
--session-hovercard-surface: var(--card);
|
||||
--session-hovercard-progress-surface: color-mix(
|
||||
in srgb,
|
||||
var(--card) 52%,
|
||||
var(--panel-strong) 48%
|
||||
);
|
||||
|
||||
--panel: #f4f7f4;
|
||||
--panel-strong: #ecf1ec;
|
||||
--panel-hover: #dde6de;
|
||||
--chrome: rgba(244, 247, 244, 0.96);
|
||||
--chrome-strong: rgba(244, 247, 244, 0.98);
|
||||
|
||||
--text: #2a352b;
|
||||
--text-strong: #16201a;
|
||||
--chat-text: #2a352b;
|
||||
--muted: #566b58;
|
||||
--muted-strong: #455947;
|
||||
--muted-foreground: #566b58;
|
||||
|
||||
--border: #dbe4dc;
|
||||
--border-strong: #bfcdc1;
|
||||
--border-hover: #9aad9d;
|
||||
--input: #dbe4dc;
|
||||
|
||||
--secondary: #ecf1ec;
|
||||
--secondary-foreground: #2a352b;
|
||||
--accent-2: #2f6b47;
|
||||
--accent-2-muted: rgba(47, 107, 71, 0.75);
|
||||
--accent-2-subtle: rgba(47, 107, 71, 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(20, 50, 30, 0.05);
|
||||
--shadow-md: 0 4px 12px rgba(20, 50, 30, 0.07);
|
||||
--shadow-lg: 0 12px 28px rgba(20, 50, 30, 0.09);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(20, 50, 30, 0.04);
|
||||
|
||||
--mono:
|
||||
"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
--font-body: var(--mono);
|
||||
--font-chat: var(--mono);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/* Tide palette; selected by index.html and app/theme.ts. */
|
||||
|
||||
:root[data-theme="tide"] {
|
||||
/*
|
||||
* Accent — steel cyan on deep slate
|
||||
*
|
||||
* The first cool-primary theme in the set; every other palette accents warm.
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #10151b, relative luminance ≈ 0.0093):
|
||||
* --accent #5ab6d8 contrast ≈ 8.0:1 AA text ✓
|
||||
* --primary-foreground #0b1116 on #5ab6d8 button: 8.2:1 AAA ✓
|
||||
* --primary-foreground #0b1116 on #7ac8e4 hover: 10.1:1 ✓
|
||||
* --destructive-foreground #fafafa on #d32f2f button: 4.77:1 AA ✓
|
||||
* worst text pairing --muted on --bg-muted: 6.12:1 ✓
|
||||
* inherited status label on own subtle tint, worst surface: 4.65:1 ✓
|
||||
*/
|
||||
--ring: #5ab6d8;
|
||||
--accent: #5ab6d8;
|
||||
--accent-hover: #7ac8e4;
|
||||
--accent-muted: #5ab6d8;
|
||||
--accent-subtle: rgba(90, 182, 216, 0.12);
|
||||
--accent-glow: rgba(90, 182, 216, 0.2);
|
||||
--primary: #5ab6d8;
|
||||
--primary-hover: #7ac8e4;
|
||||
--primary-foreground: #0b1116;
|
||||
|
||||
/* Deepen only the text-bearing destructive fill; keep danger accents vivid. */
|
||||
--destructive: #d32f2f;
|
||||
--destructive-hover: #bc2a2a;
|
||||
|
||||
--focus: rgba(90, 182, 216, 0.2);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 70%, transparent);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow);
|
||||
|
||||
/* Surfaces — cold slate, low saturation */
|
||||
--bg: #10151b;
|
||||
--bg-accent: #151b23;
|
||||
--bg-elevated: #1b232c;
|
||||
--bg-hover: #212b36;
|
||||
--bg-muted: #212b36;
|
||||
|
||||
--card: #161d25;
|
||||
--card-foreground: #eef3f8;
|
||||
--card-highlight: rgba(210, 232, 255, 0.04);
|
||||
--popover: #1b232c;
|
||||
--popover-foreground: #eef3f8;
|
||||
|
||||
--panel: #10151b;
|
||||
--panel-strong: #1b232c;
|
||||
--panel-hover: #212b36;
|
||||
--chrome: rgba(16, 21, 27, 0.96);
|
||||
--chrome-strong: rgba(16, 21, 27, 0.98);
|
||||
|
||||
--text: #dce3ea;
|
||||
--text-strong: #f2f6fa;
|
||||
--chat-text: #dce3ea;
|
||||
--muted: #9dabb9;
|
||||
--muted-strong: #aab7c4;
|
||||
--muted-foreground: #9dabb9;
|
||||
|
||||
--border: #222c37;
|
||||
--border-strong: #33414f;
|
||||
--border-hover: #465767;
|
||||
--input: #222c37;
|
||||
|
||||
--secondary: #161d25;
|
||||
--secondary-foreground: #eef3f8;
|
||||
--accent-2: #7f9bb5;
|
||||
--accent-2-muted: rgba(127, 155, 181, 0.7);
|
||||
--accent-2-subtle: rgba(127, 155, 181, 0.12);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(2, 6, 12, 0.4);
|
||||
--shadow-md: 0 4px 16px rgba(2, 6, 12, 0.5);
|
||||
--shadow-lg: 0 12px 32px rgba(2, 6, 12, 0.6);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(210, 232, 255, 0.03);
|
||||
}
|
||||
|
||||
:root[data-theme="tide-light"] {
|
||||
/*
|
||||
* Accent — deep teal-blue on cool paper
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #f7f9fb, relative luminance ≈ 0.928):
|
||||
* --accent #1f6f8f contrast ≈ 5.3:1 AA text ✓
|
||||
* --accent-hover #175a75 contrast ≈ 7.6:1 AAA ✓
|
||||
* --primary-foreground #ffffff on #1f6f8f button: 5.6:1 AA ✓
|
||||
* worst text pairing --muted on --bg-muted: 4.63:1 ✓
|
||||
*/
|
||||
--ring: #1f6f8f;
|
||||
--accent: #1f6f8f;
|
||||
--accent-hover: #175a75;
|
||||
--accent-muted: #1f6f8f;
|
||||
--accent-subtle: rgba(31, 111, 143, 0.1);
|
||||
--accent-glow: rgba(31, 111, 143, 0.14);
|
||||
--primary: #1f6f8f;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--bg: #f7f9fb;
|
||||
--bg-accent: #eef2f7;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-hover: #e8edf2;
|
||||
--bg-muted: #e8edf2;
|
||||
--bg-content: #eef2f7;
|
||||
|
||||
--card: #ffffff;
|
||||
--card-foreground: #1b232b;
|
||||
--card-highlight: rgba(20, 40, 60, 0.02);
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1b232b;
|
||||
--session-hovercard-surface: var(--card);
|
||||
--session-hovercard-progress-surface: color-mix(
|
||||
in srgb,
|
||||
var(--card) 52%,
|
||||
var(--panel-strong) 48%
|
||||
);
|
||||
|
||||
--panel: #f7f9fb;
|
||||
--panel-strong: #eef2f7;
|
||||
--panel-hover: #dfe6ee;
|
||||
--chrome: rgba(247, 249, 251, 0.96);
|
||||
--chrome-strong: rgba(247, 249, 251, 0.98);
|
||||
|
||||
--text: #333c45;
|
||||
--text-strong: #1b232b;
|
||||
--chat-text: #333c45;
|
||||
--muted: #5f6b76;
|
||||
--muted-strong: #4c5761;
|
||||
--muted-foreground: #5f6b76;
|
||||
|
||||
--border: #dfe5ec;
|
||||
--border-strong: #c5cfd9;
|
||||
--border-hover: #a3b0bd;
|
||||
--input: #dfe5ec;
|
||||
|
||||
--secondary: #eef2f7;
|
||||
--secondary-foreground: #333c45;
|
||||
--accent-2: #3d6d88;
|
||||
--accent-2-muted: rgba(61, 109, 136, 0.75);
|
||||
--accent-2-subtle: rgba(61, 109, 136, 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(20, 40, 60, 0.05);
|
||||
--shadow-md: 0 4px 12px rgba(20, 40, 60, 0.07);
|
||||
--shadow-lg: 0 12px 28px rgba(20, 40, 60, 0.09);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(20, 40, 60, 0.04);
|
||||
}
|
||||
+20
-6
@@ -70,7 +70,12 @@ import {
|
||||
resolveApplicationStartupSettings,
|
||||
} from "./startup-settings.ts";
|
||||
import { startThemeTransition } from "./theme-transition.ts";
|
||||
import { resolveTheme, syncThemeFontStylesheet, type ThemeMode } from "./theme.ts";
|
||||
import {
|
||||
resolveTheme,
|
||||
syncThemeFontStylesheet,
|
||||
syncThemePaletteStylesheet,
|
||||
type ThemeMode,
|
||||
} from "./theme.ts";
|
||||
import { createWebPushCapability } from "./web-push.ts";
|
||||
|
||||
function applyThemePresentation(settings: ReturnType<typeof loadSettings>): void {
|
||||
@@ -108,11 +113,19 @@ function createApplicationTheme(
|
||||
let systemThemeCleanup: (() => void) | undefined;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
let presentationGeneration = 0;
|
||||
const publish = () => {
|
||||
applyThemePresentation(settings);
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
const generation = ++presentationGeneration;
|
||||
syncThemePaletteStylesheet(settings.theme, () => {
|
||||
// A slower palette cannot overwrite a newer selection or a disposed app.
|
||||
if (generation !== presentationGeneration) {
|
||||
return;
|
||||
}
|
||||
applyThemePresentation(settings);
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const detachSystemThemeListener = () => {
|
||||
@@ -141,6 +154,7 @@ function createApplicationTheme(
|
||||
};
|
||||
|
||||
syncSystemThemeListener();
|
||||
publish();
|
||||
|
||||
return {
|
||||
get settings() {
|
||||
@@ -185,6 +199,7 @@ function createApplicationTheme(
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
dispose() {
|
||||
presentationGeneration += 1;
|
||||
detachSystemThemeListener();
|
||||
listeners.clear();
|
||||
},
|
||||
@@ -413,7 +428,6 @@ export function bootstrapApplication(
|
||||
initialUserMessage,
|
||||
});
|
||||
const chatAttachmentHandoff = createChatAttachmentHandoff();
|
||||
applyThemePresentation(settings);
|
||||
const router = createApplicationRouter();
|
||||
let routerStarted = false;
|
||||
// Pre-start navigations are invisible to history; retain the latest request so
|
||||
|
||||
@@ -6,6 +6,7 @@ import { resolveControlUiPaths } from "./browser.ts";
|
||||
type ControlUiPublicAsset =
|
||||
| ControlUiRootPublicAsset
|
||||
| `fonts/${string}.css`
|
||||
| `themes/${string}.css`
|
||||
| `provider-icons/ProviderIcon-${string}.svg`
|
||||
| `file-icons/${string}.svg`
|
||||
| `plugin-art/${string}.webp`
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
// @vitest-environment node
|
||||
// Control UI tests cover theme behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { parseThemeSelection, resolveTheme } from "./theme.ts";
|
||||
import { parseThemeSelection, resolveTheme, type ThemeName } from "./theme.ts";
|
||||
|
||||
describe("resolveTheme", () => {
|
||||
it("resolves named theme families when mode is provided", () => {
|
||||
expect(resolveTheme("knot", "dark")).toBe("openknot");
|
||||
expect(resolveTheme("dash", "light")).toBe("dash-light");
|
||||
});
|
||||
it.each([
|
||||
["claw", "dark", "light"],
|
||||
["knot", "openknot", "openknot-light"],
|
||||
["dash", "dash", "dash-light"],
|
||||
["absolutely", "absolutely", "absolutely-light"],
|
||||
["tide", "tide", "tide-light"],
|
||||
["beacon", "beacon", "beacon-light"],
|
||||
["phosphor", "phosphor", "phosphor-light"],
|
||||
["custom", "custom", "custom-light"],
|
||||
] satisfies [ThemeName, string, string][])(
|
||||
"resolves %s in both explicit modes",
|
||||
(theme, dark, light) => {
|
||||
expect(resolveTheme(theme, "dark")).toBe(dark);
|
||||
expect(resolveTheme(theme, "light")).toBe(light);
|
||||
},
|
||||
);
|
||||
|
||||
it("uses system preference when mode is system", () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: true }));
|
||||
|
||||
+39
-19
@@ -53,6 +53,7 @@ const THEME_FONT_STYLESHEETS: Partial<Record<ThemeName, ControlUiFontStylesheet>
|
||||
phosphor: "fonts/phosphor.css",
|
||||
};
|
||||
type ControlUiFontStylesheet = `fonts/${string}.css`;
|
||||
|
||||
const VALID_THEME_MODES = new Set<ThemeMode>(["system", "light", "dark"]);
|
||||
|
||||
function prefersLightScheme(): boolean {
|
||||
@@ -87,25 +88,8 @@ export function resolveTheme(theme: ThemeName, mode: ThemeMode): ResolvedTheme {
|
||||
if (theme === "claw") {
|
||||
return resolvedMode === "light" ? "light" : "dark";
|
||||
}
|
||||
if (theme === "knot") {
|
||||
return resolvedMode === "light" ? "openknot-light" : "openknot";
|
||||
}
|
||||
if (theme === "dash") {
|
||||
return resolvedMode === "light" ? "dash-light" : "dash";
|
||||
}
|
||||
if (theme === "absolutely") {
|
||||
return resolvedMode === "light" ? "absolutely-light" : "absolutely";
|
||||
}
|
||||
if (theme === "tide") {
|
||||
return resolvedMode === "light" ? "tide-light" : "tide";
|
||||
}
|
||||
if (theme === "beacon") {
|
||||
return resolvedMode === "light" ? "beacon-light" : "beacon";
|
||||
}
|
||||
if (theme === "phosphor") {
|
||||
return resolvedMode === "light" ? "phosphor-light" : "phosphor";
|
||||
}
|
||||
return resolvedMode === "light" ? "custom-light" : "custom";
|
||||
const family = theme === "knot" ? "openknot" : theme;
|
||||
return resolvedMode === "light" ? `${family}-light` : family;
|
||||
}
|
||||
|
||||
/** Loads (or drops) the webfont stylesheet a theme declares. Idempotent. */
|
||||
@@ -132,3 +116,39 @@ export function syncThemeFontStylesheet(theme: ThemeName): void {
|
||||
link.href = href;
|
||||
document.head.append(link);
|
||||
}
|
||||
|
||||
/** Publish theme colors only after their stylesheet is available. */
|
||||
export function syncThemePaletteStylesheet(theme: ThemeName, ready: () => void): void {
|
||||
if (typeof document === "undefined" || theme === "claw" || theme === "custom") {
|
||||
ready();
|
||||
return;
|
||||
}
|
||||
// Retain the six built-in families once visited. Their exclusive selectors
|
||||
// leave the previous theme intact during loading and make repeat switches synchronous.
|
||||
const id = `openclaw-theme-palette-${theme}`;
|
||||
const existing = document.getElementById(id);
|
||||
if (existing instanceof HTMLLinkElement && existing.sheet) {
|
||||
ready();
|
||||
return;
|
||||
}
|
||||
const link = existing instanceof HTMLLinkElement ? existing : document.createElement("link");
|
||||
const finish = (event: Event) => {
|
||||
link.removeEventListener("load", finish);
|
||||
link.removeEventListener("error", finish);
|
||||
if (event.type === "error") {
|
||||
// Failed assets must not strand startup; normal CSS defaults stay readable.
|
||||
// Remove the failed link so a later selection can retry rather than wait forever.
|
||||
console.error(`Theme palette failed to load; reload to retry: ${link.href}`);
|
||||
link.remove();
|
||||
}
|
||||
ready();
|
||||
};
|
||||
link.addEventListener("load", finish);
|
||||
link.addEventListener("error", finish);
|
||||
if (!existing) {
|
||||
link.id = id;
|
||||
link.rel = "stylesheet";
|
||||
link.href = inferControlUiPublicAssetPath(`themes/${theme}.css`);
|
||||
document.head.append(link);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,18 +58,18 @@ async function openThemedChat(theme: string, mode: "dark" | "light", basePath =
|
||||
},
|
||||
);
|
||||
const page = await context.newPage();
|
||||
const fontRequests: string[] = [];
|
||||
const themeRequests: string[] = [];
|
||||
page.on("response", (response) => {
|
||||
const url = response.url();
|
||||
if (url.includes("/fonts/")) {
|
||||
fontRequests.push(`${url.split("/").pop()} ${response.status()}`);
|
||||
if (url.includes("/fonts/") || url.includes("/themes/")) {
|
||||
themeRequests.push(`${url.split("/").pop()} ${response.status()}`);
|
||||
}
|
||||
});
|
||||
const gateway = await installMockGateway(page, {
|
||||
...(basePath ? { basePath } : {}),
|
||||
methodResponses: { "config.get": themeConfigResponse(theme, mode) },
|
||||
});
|
||||
return { fontRequests, gateway, page };
|
||||
return { themeRequests, gateway, page };
|
||||
}
|
||||
|
||||
async function renderAssistantProse(
|
||||
@@ -100,7 +100,7 @@ suite.define(() => {
|
||||
if (captureUiProof) {
|
||||
await mkdir(proofDirectory, { recursive: true });
|
||||
}
|
||||
const { fontRequests, gateway, page } = await openThemedChat("absolutely", "dark");
|
||||
const { themeRequests, gateway, page } = await openThemedChat("absolutely", "dark");
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await renderAssistantProse(gateway, page);
|
||||
|
||||
@@ -125,7 +125,7 @@ suite.define(() => {
|
||||
expect(report.bodyFontFamily).toBe("Space Grotesk");
|
||||
expect(report.chatFontFamily).toBe("Lora");
|
||||
expect(new Set(report.loaded)).toEqual(new Set(["Space Grotesk", "Lora"]));
|
||||
expect(fontRequests.every((entry) => entry.endsWith(" 200"))).toBe(true);
|
||||
expect(themeRequests.every((entry) => entry.endsWith(" 200"))).toBe(true);
|
||||
|
||||
if (captureUiProof) {
|
||||
await page.screenshot({ path: path.join(proofDirectory, "absolutely-chat-dark.png") });
|
||||
@@ -151,7 +151,7 @@ suite.define(() => {
|
||||
if (captureUiProof) {
|
||||
await mkdir(proofDirectory, { recursive: true });
|
||||
}
|
||||
const { fontRequests, gateway, page } = await openThemedChat(spec.theme, "dark");
|
||||
const { themeRequests, gateway, page } = await openThemedChat(spec.theme, "dark");
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await renderAssistantProse(gateway, page);
|
||||
|
||||
@@ -173,13 +173,112 @@ suite.define(() => {
|
||||
expect(report.bodyFontFamily).toBe(spec.body);
|
||||
expect(report.chatFontFamily).toBe(spec.chat);
|
||||
expect(new Set(report.loaded)).toEqual(new Set(spec.families));
|
||||
expect(fontRequests.every((entry) => entry.endsWith(" 200"))).toBe(true);
|
||||
expect(themeRequests.every((entry) => entry.endsWith(" 200"))).toBe(true);
|
||||
|
||||
if (captureUiProof) {
|
||||
await page.screenshot({ path: path.join(proofDirectory, `${spec.theme}-chat-dark.png`) });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["knot", "openknot", "#080808", "#f9f9fb"],
|
||||
["dash", "dash", "#1a1210", "#f7f2ec"],
|
||||
["absolutely", "absolutely", "#1c1c1a", "#faf9f5"],
|
||||
["tide", "tide", "#10151b", "#f7f9fb"],
|
||||
["beacon", "beacon", "#000000", "#ffffff"],
|
||||
["phosphor", "phosphor", "#0a0f0a", "#f4f7f4"],
|
||||
])(
|
||||
"loads %s before paint in both modes without the app bundle",
|
||||
async (theme, resolved, dark, light) => {
|
||||
// Bundle aborts isolate the boot document; resource timing verifies the
|
||||
// browser actually blocks rendering, not merely that a link exists later.
|
||||
for (const mode of ["dark", "light"] as const) {
|
||||
const { page } = await openThemedChat(theme, mode);
|
||||
await page.route("**/assets/**.js", (route) => route.abort());
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const report = await page.evaluate(() => ({
|
||||
background: getComputedStyle(document.documentElement).getPropertyValue("--bg").trim(),
|
||||
resolvedTheme: document.documentElement.dataset.theme,
|
||||
palette: performance
|
||||
.getEntriesByType("resource")
|
||||
.filter((entry) => new URL(entry.name).pathname.includes("/themes/"))
|
||||
.map((entry) => ({
|
||||
pathname: new URL(entry.name).pathname,
|
||||
blocking: (entry as PerformanceResourceTiming & { renderBlockingStatus: string })
|
||||
.renderBlockingStatus,
|
||||
})),
|
||||
}));
|
||||
expect(report.resolvedTheme).toBe(mode === "dark" ? resolved : `${resolved}-light`);
|
||||
expect(report.background).toBe(mode === "dark" ? dark : light);
|
||||
expect(report.palette).toEqual([
|
||||
{ pathname: `/themes/${theme}.css`, blocking: "blocking" },
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("publishes a runtime palette only when its colors are ready and ignores superseded loads", async () => {
|
||||
const { page, gateway } = await openThemedChat("knot", "dark");
|
||||
let releasePalette!: () => void;
|
||||
const paletteGate = new Promise<void>((resolve) => {
|
||||
releasePalette = resolve;
|
||||
});
|
||||
await page.route("**/themes/tide.css", async (route) => {
|
||||
await paletteGate;
|
||||
await route.continue();
|
||||
});
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await page.locator(".agent-chat__composer-combobox textarea").waitFor();
|
||||
await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
new MutationObserver(() => {
|
||||
if (root.dataset.theme === "tide") {
|
||||
root.dataset.observedThemeBackground = getComputedStyle(root)
|
||||
.getPropertyValue("--bg")
|
||||
.trim();
|
||||
}
|
||||
}).observe(root, { attributes: true, attributeFilter: ["data-theme"] });
|
||||
});
|
||||
const changeTheme = async (theme: string) => {
|
||||
await gateway.setMethodResponse("config.get", themeConfigResponse(theme, "dark"));
|
||||
await gateway.emitGatewayEvent("config.changed", { hash: `theme-${theme}`, ts: Date.now() });
|
||||
};
|
||||
try {
|
||||
const request = page.waitForRequest("**/themes/tide.css");
|
||||
await changeTheme("tide");
|
||||
await request;
|
||||
expect(await page.locator("html").getAttribute("data-theme")).toBe("openknot");
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--bg").trim(),
|
||||
),
|
||||
).toBe("#080808");
|
||||
await changeTheme("beacon");
|
||||
await expect.poll(() => page.locator("html").getAttribute("data-theme")).toBe("beacon");
|
||||
const response = page.waitForResponse("**/themes/tide.css");
|
||||
releasePalette();
|
||||
await response;
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
expect(await page.locator("html").getAttribute("data-theme")).toBe("beacon");
|
||||
await changeTheme("tide");
|
||||
await expect
|
||||
.poll(() => page.locator("html").getAttribute("data-observed-theme-background"))
|
||||
.toBe("#10151b");
|
||||
expect(await page.locator('meta[name="theme-color"]').first().getAttribute("content")).toBe(
|
||||
"#10151b",
|
||||
);
|
||||
await changeTheme("claw");
|
||||
await expect.poll(() => page.locator("html").getAttribute("data-theme")).toBe("dark");
|
||||
} finally {
|
||||
releasePalette();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the font stylesheet against a configured mount path", async () => {
|
||||
// A gateway mounted at a base path serves the bundle below that prefix, so
|
||||
// root-absolute font URLs 404 there and the theme silently falls back to
|
||||
@@ -187,6 +286,29 @@ suite.define(() => {
|
||||
const basePath = "/openclaw";
|
||||
const { page } = await openThemedChat("absolutely", "dark", basePath);
|
||||
const requested: string[] = [];
|
||||
// The preview server does not stamp Gateway HTML. Reproduce the actual
|
||||
// document contract, rather than letting runtime repair a wrong boot URL.
|
||||
await page.route("**/*", async (route) => {
|
||||
if (
|
||||
route.request().resourceType() !== "document" ||
|
||||
!new URL(route.request().url()).pathname.startsWith(`${basePath}/`)
|
||||
) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const response = await route.fetch();
|
||||
const html = (await response.text()).replace(
|
||||
/<html\b/u,
|
||||
`<html data-openclaw-control-ui-base-path="${basePath}"`,
|
||||
);
|
||||
await route.fulfill({ response, body: html });
|
||||
});
|
||||
await page.route(`**${basePath}/themes/**`, async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
requested.push(url.pathname);
|
||||
url.pathname = url.pathname.slice(basePath.length);
|
||||
await route.fulfill({ response: await route.fetch({ url: url.href }) });
|
||||
});
|
||||
await page.route(`**${basePath}/fonts/**`, async (route) => {
|
||||
const { pathname } = new URL(route.request().url());
|
||||
requested.push(pathname);
|
||||
@@ -203,6 +325,19 @@ suite.define(() => {
|
||||
);
|
||||
|
||||
expect(linkHref).toBe(`${basePath}/fonts/absolutely.css`);
|
||||
// The palette link is built in the first-paint script from the mount prefix
|
||||
// the gateway stamps on <html>, so it has to follow the mount too.
|
||||
const paletteHref = await page.evaluate(
|
||||
() =>
|
||||
document.getElementById("openclaw-theme-palette-absolutely")?.getAttribute("href") ?? null,
|
||||
);
|
||||
expect(paletteHref).toBe(`${basePath}/themes/absolutely.css`);
|
||||
expect(requested).toContain(`${basePath}/themes/absolutely.css`);
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--bg").trim(),
|
||||
),
|
||||
).toBe("#1c1c1a");
|
||||
// The browser must actually fetch below the mount, not at the root.
|
||||
await expect.poll(() => requested).toContain(`${basePath}/fonts/absolutely.css`);
|
||||
});
|
||||
@@ -211,7 +346,7 @@ suite.define(() => {
|
||||
if (captureUiProof) {
|
||||
await mkdir(proofDirectory, { recursive: true });
|
||||
}
|
||||
const { fontRequests, gateway, page } = await openThemedChat("claw", "dark");
|
||||
const { themeRequests, gateway, page } = await openThemedChat("claw", "dark");
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await renderAssistantProse(gateway, page);
|
||||
|
||||
@@ -230,7 +365,7 @@ suite.define(() => {
|
||||
expect(report.loaded).toEqual([]);
|
||||
expect(report.bodyFontFamily).not.toBe("Space Grotesk");
|
||||
// The default path must not fetch a font asset at all.
|
||||
expect(fontRequests).toEqual([]);
|
||||
expect(themeRequests).toEqual([]);
|
||||
|
||||
if (captureUiProof) {
|
||||
await page.screenshot({ path: path.join(proofDirectory, "claw-chat-dark.png") });
|
||||
|
||||
@@ -101,9 +101,25 @@ type TokenMap = Map<string, string>;
|
||||
type RGB = readonly [red: number, green: number, blue: number];
|
||||
type Color = { rgb: RGB; alpha: number };
|
||||
|
||||
/*
|
||||
* Built-in palettes other than the default live in public/themes so they stay
|
||||
* out of the startup stylesheet. Contrast guarantees still cover every theme,
|
||||
* so the palette sources are read back together here.
|
||||
*/
|
||||
function readPaletteSources(stylesRoot: string): string {
|
||||
const themesDir = path.join(stylesRoot, "..", "..", "public", "themes");
|
||||
const palettes = fs
|
||||
.readdirSync(themesDir)
|
||||
.filter((entry) => entry.endsWith(".css"))
|
||||
.toSorted()
|
||||
.map((entry) => fs.readFileSync(path.join(themesDir, entry), "utf8"));
|
||||
return [fs.readFileSync(path.join(stylesRoot, "base.css"), "utf8"), ...palettes].join("\n");
|
||||
}
|
||||
|
||||
function parseThemeBlocks(baseCss: string): Map<string, TokenMap> {
|
||||
const blocks = new Map<string, TokenMap>();
|
||||
const blockPattern = /(:root(?:\[data-theme(?:-mode)?="[^"]+"\])?)\s*\{([^}]*)\}/g;
|
||||
const blockPattern =
|
||||
/(:root(?::where\(\[data-theme-mode="light"\]\)|\[data-theme(?:-mode)?="[^"]+"\])?)\s*\{([^}]*)\}/g;
|
||||
for (const match of baseCss.matchAll(blockPattern)) {
|
||||
const selector = match[1] ?? "";
|
||||
const body = match[2] ?? "";
|
||||
@@ -127,7 +143,7 @@ function parseThemeBlocks(baseCss: string): Map<string, TokenMap> {
|
||||
/** Compose each selectable theme the way theme.ts layers blocks over :root. */
|
||||
function resolveThemes(blocks: Map<string, TokenMap>): Map<string, TokenMap> {
|
||||
const root = blocks.get(":root") ?? new Map();
|
||||
const light = blocks.get(':root[data-theme-mode="light"]') ?? new Map();
|
||||
const light = blocks.get(':root:where([data-theme-mode="light"])') ?? new Map();
|
||||
const layer = (...overrides: (TokenMap | undefined)[]): TokenMap => {
|
||||
const merged: TokenMap = new Map(root);
|
||||
for (const override of overrides) {
|
||||
@@ -402,7 +418,7 @@ function readBubbleBackgrounds(groupedCss: string): {
|
||||
}
|
||||
|
||||
describe("Control UI theme contrast", () => {
|
||||
const baseCss = fs.readFileSync(path.join(stylesDir, "base.css"), "utf8");
|
||||
const baseCss = readPaletteSources(stylesDir);
|
||||
const themes = resolveThemes(parseThemeBlocks(baseCss));
|
||||
|
||||
it("keeps every text token at WCAG AA on every theme surface, AAA on themes that promise it", () => {
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("Control UI base theme tokens", () => {
|
||||
describe("Control UI theme --bg mirrors", () => {
|
||||
const RESOLVED_THEME_BG_SELECTOR = new Map<string, string>([
|
||||
["dark", ":root"],
|
||||
["light", ':root[data-theme-mode="light"]'],
|
||||
["light", ':root:where([data-theme-mode="light"])'],
|
||||
]);
|
||||
|
||||
function readBlockToken(css: string, selector: string, token: string): string | undefined {
|
||||
@@ -122,7 +122,17 @@ describe("Control UI theme --bg mirrors", () => {
|
||||
|
||||
/** Every theme's canonical --bg, keyed by the resolved data-theme value. */
|
||||
function readCanonicalBackgrounds(): Map<string, string> {
|
||||
const baseCss = fs.readFileSync(path.join(stylesDir, "base.css"), "utf8");
|
||||
// Palettes other than the default now live in public/themes; the mirrors
|
||||
// must track them there too.
|
||||
const themesDir = path.join(stylesDir, "..", "..", "public", "themes");
|
||||
const baseCss = [
|
||||
fs.readFileSync(path.join(stylesDir, "base.css"), "utf8"),
|
||||
...fs
|
||||
.readdirSync(themesDir)
|
||||
.filter((entry) => entry.endsWith(".css"))
|
||||
.toSorted()
|
||||
.map((entry) => fs.readFileSync(path.join(themesDir, entry), "utf8")),
|
||||
].join("\n");
|
||||
const backgrounds = new Map<string, string>();
|
||||
for (const [resolved, selector] of RESOLVED_THEME_BG_SELECTOR) {
|
||||
const value = readBlockToken(baseCss, selector, "--bg");
|
||||
|
||||
+2
-948
@@ -502,7 +502,8 @@
|
||||
}
|
||||
|
||||
/* Light theme tokens apply to every light-mode family. */
|
||||
:root[data-theme-mode="light"] {
|
||||
/* Defaults must lose to named palettes even when their link precedes the app CSS. */
|
||||
:root:where([data-theme-mode="light"]) {
|
||||
/*
|
||||
* Warm paper light mode - terracotta accent on ivory
|
||||
*
|
||||
@@ -643,953 +644,6 @@
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Theme families override accent tokens while keeping shared surfaces/layout. */
|
||||
:root[data-theme="openknot"] {
|
||||
/*
|
||||
* Accent — crimson red on true black
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #080808, relative luminance ≈ 0.003):
|
||||
* --accent #e5243b L ≈ 0.118 contrast ≈ 5.8:1 AA text ✓
|
||||
* --accent-hover #f03e52 L ≈ 0.163 contrast ≈ 7.2:1 ✓
|
||||
* --primary-foreground #fafafa on #d92a3f button: 4.62:1 AA ✓
|
||||
* --primary-foreground #fafafa on #c22537 hover: 5.57:1 AA ✓
|
||||
* --destructive-foreground #fafafa on #d32f2f button: 4.77:1 AA ✓
|
||||
* --destructive-foreground #fafafa on #bc2a2a hover: 5.74:1 AA ✓
|
||||
* focus-ring at 70% opacity: ≈ 3.4:1 against bg ✓ (non-text 3:1 required)
|
||||
*/
|
||||
--ring: #e5243b;
|
||||
--accent: #e5243b;
|
||||
--accent-hover: #f03e52;
|
||||
--accent-muted: #e5243b;
|
||||
--accent-subtle: rgba(229, 36, 59, 0.12);
|
||||
--accent-glow: rgba(229, 36, 59, 0.24);
|
||||
--primary: #d92a3f;
|
||||
--primary-hover: #c22537;
|
||||
--primary-foreground: #fafafa;
|
||||
|
||||
/* Deepen only the text-bearing destructive fill; keep danger accents vivid. */
|
||||
--destructive: #d32f2f;
|
||||
--destructive-hover: #bc2a2a;
|
||||
|
||||
/* Focus — crimson ring */
|
||||
--focus: rgba(229, 36, 59, 0.2);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 70%, transparent);
|
||||
|
||||
/* Surfaces — true black canvas */
|
||||
--bg: #080808;
|
||||
--bg-accent: #0d0d0f;
|
||||
--bg-elevated: #141416;
|
||||
--bg-hover: #1a1a1e;
|
||||
--bg-muted: #1a1a1e;
|
||||
|
||||
--card: #111113;
|
||||
--card-highlight: rgba(255, 255, 255, 0.03);
|
||||
--popover: #141416;
|
||||
|
||||
--panel: #080808;
|
||||
--panel-strong: #141416;
|
||||
--panel-hover: #1a1a1e;
|
||||
--chrome: rgba(8, 8, 8, 0.96);
|
||||
--chrome-strong: rgba(8, 8, 8, 0.98);
|
||||
|
||||
/* Text — high contrast on black */
|
||||
--text: #e0e0e2;
|
||||
--text-strong: #f5f5f7;
|
||||
--chat-text: #e0e0e2;
|
||||
--muted: #8a8a94;
|
||||
--muted-strong: #888892;
|
||||
--muted-foreground: #8a8a94;
|
||||
|
||||
/* Borders — whisper-thin, barely visible */
|
||||
--border: #1a1a1e;
|
||||
--border-strong: #2a2a30;
|
||||
--border-hover: #3a3a42;
|
||||
--input: #1a1a1e;
|
||||
|
||||
--secondary: #111113;
|
||||
--accent-2: #8b1a2b;
|
||||
--accent-2-muted: rgba(139, 26, 43, 0.7);
|
||||
--accent-2-subtle: rgba(139, 26, 43, 0.12);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.6);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
:root[data-theme="openknot-light"] {
|
||||
/*
|
||||
* Accent — deep red on cool white
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #f9f9fb, relative luminance ≈ 0.941):
|
||||
* --accent #c41e30 L ≈ 0.078 contrast ≈ 7.5:1 AAA text ✓
|
||||
* --accent-hover #a8192a L ≈ 0.054 contrast ≈ 9.1:1 ✓
|
||||
* --primary-foreground #ffffff on #c41e30 button: 5.0:1 AA ✓
|
||||
* focus-ring at 60% opacity: ≈ 3.2:1 against bg ✓ (non-text 3:1 required)
|
||||
*/
|
||||
--ring: #c41e30;
|
||||
--accent: #c41e30;
|
||||
--accent-hover: #a8192a;
|
||||
--accent-muted: #c41e30;
|
||||
--accent-subtle: rgba(196, 30, 48, 0.08);
|
||||
--accent-glow: rgba(196, 30, 48, 0.12);
|
||||
--primary: #c41e30;
|
||||
|
||||
/* Surfaces — cool white with subtle warmth */
|
||||
--bg: #f9f9fb;
|
||||
--bg-accent: #f2f2f5;
|
||||
--bg-hover: #eaeaef;
|
||||
--bg-muted: #eaeaef;
|
||||
--bg-content: #f2f2f5;
|
||||
|
||||
--card-foreground: #18181b;
|
||||
--card-highlight: rgba(0, 0, 0, 0.02);
|
||||
--popover-foreground: #18181b;
|
||||
--session-hovercard-progress-surface: color-mix(
|
||||
in srgb,
|
||||
var(--card) 52%,
|
||||
var(--panel-strong) 48%
|
||||
);
|
||||
|
||||
--panel: #f9f9fb;
|
||||
--panel-strong: #f2f2f5;
|
||||
--panel-hover: #e4e4ea;
|
||||
--chrome: rgba(249, 249, 251, 0.96);
|
||||
--chrome-strong: rgba(249, 249, 251, 0.98);
|
||||
|
||||
/* Text — crisp dark on white */
|
||||
--text: #3a3a42;
|
||||
--text-strong: #18181b;
|
||||
--chat-text: #3a3a42;
|
||||
--muted: #68676f;
|
||||
--muted-strong: #52525a;
|
||||
--muted-foreground: #68676f;
|
||||
|
||||
/* Borders — clean, minimal */
|
||||
--border: #e2e2e8;
|
||||
--border-strong: #ccccd4;
|
||||
--border-hover: #adadb8;
|
||||
--input: #e2e2e8;
|
||||
|
||||
--secondary: #f2f2f5;
|
||||
--secondary-foreground: #3a3a42;
|
||||
--accent-2: #7a1420;
|
||||
--accent-2-muted: rgba(122, 20, 32, 0.75);
|
||||
--accent-2-subtle: rgba(122, 20, 32, 0.08);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.08);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
:root[data-theme="dash"] {
|
||||
/*
|
||||
* Accent — chocolate brown on deep cocoa
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #1a1210, relative luminance ≈ 0.0069):
|
||||
* --accent #b47840 L ≈ 0.235 contrast ≈ 5.0:1 AA text ✓ (floor: #a8703c → 4.4:1, fails)
|
||||
* --accent-hover #c8885a L ≈ 0.306 contrast ≈ 6.3:1 ✓
|
||||
* --primary-foreground #1a1210 on #b47840 button: 5.0:1 AA ✓
|
||||
* --primary-foreground #1a1210 on #c8885a hover: 6.26:1 AA ✓
|
||||
* --destructive-foreground #fafafa on #d32f2f button: 4.77:1 AA ✓
|
||||
* --destructive-foreground #fafafa on #bc2a2a hover: 5.74:1 AA ✓
|
||||
* focus-ring at 80% opacity: ≈ 3.5:1 against bg ✓ (non-text 3:1 required)
|
||||
*/
|
||||
--ring: #b47840;
|
||||
--accent: #b47840;
|
||||
--accent-hover: #c8885a;
|
||||
--accent-muted: #b47840;
|
||||
--accent-subtle: rgba(180, 120, 64, 0.16);
|
||||
--accent-glow: rgba(180, 120, 64, 0.24);
|
||||
--primary: #b47840;
|
||||
--primary-foreground: #1a1210;
|
||||
|
||||
/* Deepen only the text-bearing destructive fill; keep danger accents vivid. */
|
||||
--destructive: #d32f2f;
|
||||
--destructive-hover: #bc2a2a;
|
||||
|
||||
/* Focus — match chocolate accent, slightly higher ring opacity for dark bg visibility */
|
||||
--focus: rgba(180, 120, 64, 0.2);
|
||||
|
||||
/* Surfaces — deep cocoa tones */
|
||||
--bg: #1a1210;
|
||||
--bg-accent: #201816;
|
||||
--bg-elevated: #28201c;
|
||||
--bg-hover: #302822;
|
||||
--bg-muted: #302822;
|
||||
|
||||
--card: #221a16;
|
||||
--card-foreground: #ece0d8;
|
||||
--card-highlight: rgba(255, 240, 225, 0.04);
|
||||
--popover: #28201c;
|
||||
--popover-foreground: #ece0d8;
|
||||
|
||||
--panel: #1a1210;
|
||||
--panel-strong: #28201c;
|
||||
--panel-hover: #302822;
|
||||
--chrome: rgba(26, 18, 16, 0.96);
|
||||
--chrome-strong: rgba(26, 18, 16, 0.98);
|
||||
|
||||
--text: #d8c8b8;
|
||||
--text-strong: #f0e4da;
|
||||
--chat-text: #d8c8b8;
|
||||
--muted: #a18f80;
|
||||
--muted-strong: #a08f7f;
|
||||
--muted-foreground: #a18f80;
|
||||
|
||||
--border: #302418;
|
||||
--border-strong: #443828;
|
||||
--border-hover: #5a4c3a;
|
||||
--input: #302418;
|
||||
|
||||
--secondary: #221a16;
|
||||
--secondary-foreground: #ece0d8;
|
||||
--accent-2: #a88050;
|
||||
--accent-2-muted: rgba(168, 128, 80, 0.7);
|
||||
--accent-2-subtle: rgba(168, 128, 80, 0.12);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(10, 6, 4, 0.35);
|
||||
--shadow-md: 0 4px 16px rgba(10, 6, 4, 0.45);
|
||||
--shadow-lg: 0 12px 32px rgba(10, 6, 4, 0.55);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(255, 240, 225, 0.03);
|
||||
}
|
||||
|
||||
:root[data-theme="dash-light"] {
|
||||
/*
|
||||
* Accent — deep chocolate on warm parchment
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #f7f2ec, relative luminance ≈ 0.893):
|
||||
* --accent #6e4828 L ≈ 0.081 contrast ≈ 7.2:1 AAA text ✓
|
||||
* --accent-hover #5c3c20 L ≈ 0.054 contrast ≈ 8.9:1 ✓
|
||||
* --primary-foreground #ffffff on #6e4828 button: 8.0:1 AAA ✓
|
||||
* focus-ring at 70% opacity: ≈ 3.4:1 against parchment bg ✓
|
||||
*/
|
||||
--ring: #6e4828;
|
||||
--accent: #6e4828;
|
||||
--accent-hover: #5c3c20;
|
||||
--accent-muted: #6e4828;
|
||||
--accent-subtle: rgba(110, 72, 40, 0.12);
|
||||
--accent-glow: rgba(110, 72, 40, 0.16);
|
||||
--primary: #6e4828;
|
||||
|
||||
/* Surfaces — warm parchment tones */
|
||||
--bg: #f7f2ec;
|
||||
--bg-accent: #f0e8e0;
|
||||
--bg-hover: #e8ddd2;
|
||||
--bg-muted: #e8ddd2;
|
||||
--bg-content: #f0e8e0;
|
||||
|
||||
--card-foreground: #2c2118;
|
||||
--card-highlight: rgba(80, 50, 20, 0.02);
|
||||
--popover-foreground: #2c2118;
|
||||
--session-hovercard-progress-surface: color-mix(
|
||||
in srgb,
|
||||
var(--card) 48%,
|
||||
var(--panel-strong) 52%
|
||||
);
|
||||
|
||||
--panel: #f7f2ec;
|
||||
--panel-strong: #f0e8e0;
|
||||
--panel-hover: #e0d4c8;
|
||||
--chrome: rgba(247, 242, 236, 0.96);
|
||||
--chrome-strong: rgba(247, 242, 236, 0.98);
|
||||
|
||||
--text: #4a3828;
|
||||
--text-strong: #2c2118;
|
||||
--chat-text: #4a3828;
|
||||
--muted: #725d4d;
|
||||
--muted-strong: #604838;
|
||||
--muted-foreground: #725d4d;
|
||||
|
||||
--border: #ddd0c2;
|
||||
--border-strong: #c8b8a6;
|
||||
--border-hover: #b0a090;
|
||||
--input: #ddd0c2;
|
||||
|
||||
--secondary: #f0e8e0;
|
||||
--secondary-foreground: #4a3828;
|
||||
--accent-2: #6a4e30;
|
||||
--accent-2-muted: rgba(106, 78, 48, 0.75);
|
||||
--accent-2-subtle: rgba(106, 78, 48, 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(60, 40, 20, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(60, 40, 20, 0.08);
|
||||
--shadow-lg: 0 12px 28px rgba(60, 40, 20, 0.1);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(80, 50, 20, 0.04);
|
||||
}
|
||||
|
||||
:root[data-theme="absolutely"] {
|
||||
/*
|
||||
* Accent — terracotta clay on warm graphite
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #1c1c1a, relative luminance ≈ 0.0115):
|
||||
* --accent #d97757 L ≈ 0.257 contrast ≈ 5.5:1 AA text ✓
|
||||
* --accent-hover #e68b6b L ≈ 0.338 contrast ≈ 6.7:1 ✓
|
||||
* --primary-foreground #241f1b on #d97757 button: 5.2:1 AA ✓
|
||||
* --primary-foreground #241f1b on #e68b6b hover: 6.4:1 AA ✓
|
||||
* --destructive-foreground #fafafa on #d32f2f button: 4.77:1 AA ✓
|
||||
* focus-ring at 75% opacity: ≈ 3.6:1 against bg ✓ (non-text 3:1 required)
|
||||
*
|
||||
* The surface ramp sits a step below the original #262624 launch palette,
|
||||
* which was the lightest dark theme in the set by a wide margin. Every
|
||||
* pairing improved: worst text 4.68 -> 5.42:1, accent on --card 4.47 ->
|
||||
* 5.05:1 (AA where it previously was not). The accent still dims below AA on
|
||||
* --bg-muted (4.3:1), so it stays a fill/indicator color there, not body copy.
|
||||
*/
|
||||
--ring: #d97757;
|
||||
--accent: #d97757;
|
||||
--accent-hover: #e68b6b;
|
||||
--accent-muted: #d97757;
|
||||
--accent-subtle: rgba(217, 119, 87, 0.14);
|
||||
--accent-glow: rgba(217, 119, 87, 0.22);
|
||||
--primary: #d97757;
|
||||
--primary-hover: #e68b6b;
|
||||
--primary-foreground: #241f1b;
|
||||
|
||||
/* Deepen only the text-bearing destructive fill; keep danger accents vivid. */
|
||||
--destructive: #d32f2f;
|
||||
--destructive-hover: #bc2a2a;
|
||||
|
||||
/* Focus — clay ring */
|
||||
--focus: rgba(217, 119, 87, 0.2);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 75%, transparent);
|
||||
|
||||
/* Surfaces — warm graphite, a shade off true neutral */
|
||||
--bg: #1c1c1a;
|
||||
--bg-accent: #232320;
|
||||
--bg-elevated: #292825;
|
||||
--bg-hover: #302f2b;
|
||||
--bg-muted: #302f2b;
|
||||
|
||||
--card: #232320;
|
||||
--card-foreground: #ede8dd;
|
||||
--card-highlight: rgba(255, 246, 232, 0.04);
|
||||
--popover: #292825;
|
||||
--popover-foreground: #ede8dd;
|
||||
|
||||
--panel: #1c1c1a;
|
||||
--panel-strong: #292825;
|
||||
--panel-hover: #302f2b;
|
||||
--chrome: rgba(28, 28, 26, 0.96);
|
||||
--chrome-strong: rgba(28, 28, 26, 0.98);
|
||||
|
||||
--text: #e4dfd4;
|
||||
--text-strong: #f5f1e8;
|
||||
--chat-text: #e4dfd4;
|
||||
--muted: #aba498;
|
||||
--muted-strong: #b8b1a4;
|
||||
--muted-foreground: #aba498;
|
||||
|
||||
--border: #2e2c27;
|
||||
--border-strong: #3d3a33;
|
||||
--border-hover: #514d44;
|
||||
--input: #2e2c27;
|
||||
|
||||
--secondary: #232320;
|
||||
--secondary-foreground: #ede8dd;
|
||||
--accent-2: #b8926a;
|
||||
--accent-2-muted: rgba(184, 146, 106, 0.7);
|
||||
--accent-2-subtle: rgba(184, 146, 106, 0.12);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(12, 10, 8, 0.35);
|
||||
--shadow-md: 0 4px 16px rgba(12, 10, 8, 0.45);
|
||||
--shadow-lg: 0 12px 32px rgba(12, 10, 8, 0.55);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(255, 246, 232, 0.03);
|
||||
/* Typography is this theme's signature: a grotesque for chrome, a reading
|
||||
serif for chat prose (styles/fonts.css self-hosts both). The stacks keep
|
||||
system fallbacks so a failed font load degrades instead of blanking text. */
|
||||
--font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-chat: "Lora", Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
:root[data-theme="absolutely-light"] {
|
||||
/*
|
||||
* Accent — burnt clay on ivory paper
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #faf9f5, relative luminance ≈ 0.934):
|
||||
* --accent #a8452a L ≈ 0.126 contrast ≈ 5.6:1 AA text ✓
|
||||
* --accent-hover #8e3822 L ≈ 0.086 contrast ≈ 7.3:1 ✓
|
||||
* --primary-foreground #ffffff on #a8452a button: 5.9:1 AA ✓
|
||||
* focus-ring at 70% opacity: ≈ 3.2:1 against ivory bg ✓
|
||||
*/
|
||||
--ring: #a8452a;
|
||||
--accent: #a8452a;
|
||||
--accent-hover: #8e3822;
|
||||
--accent-muted: #a8452a;
|
||||
--accent-subtle: rgba(168, 69, 42, 0.1);
|
||||
--accent-glow: rgba(168, 69, 42, 0.14);
|
||||
--primary: #a8452a;
|
||||
|
||||
/* Surfaces — ivory paper with a faint warm cast */
|
||||
--bg: #faf9f5;
|
||||
--bg-accent: #f3f1e9;
|
||||
--bg-hover: #edeae0;
|
||||
--bg-muted: #edeae0;
|
||||
--bg-content: #f3f1e9;
|
||||
|
||||
--card-foreground: #1f1d1a;
|
||||
--card-highlight: rgba(60, 50, 30, 0.02);
|
||||
--popover-foreground: #1f1d1a;
|
||||
--session-hovercard-progress-surface: color-mix(
|
||||
in srgb,
|
||||
var(--card) 50%,
|
||||
var(--panel-strong) 50%
|
||||
);
|
||||
|
||||
--panel: #faf9f5;
|
||||
--panel-strong: #f3f1e9;
|
||||
--panel-hover: #e6e2d6;
|
||||
--chrome: rgba(250, 249, 245, 0.96);
|
||||
--chrome-strong: rgba(250, 249, 245, 0.98);
|
||||
|
||||
--text: #3d3a33;
|
||||
--text-strong: #1f1d1a;
|
||||
--chat-text: #3d3a33;
|
||||
--muted: #6b655b;
|
||||
--muted-strong: #555047;
|
||||
--muted-foreground: #6b655b;
|
||||
|
||||
--border: #e3dfd2;
|
||||
--border-strong: #cdc7b6;
|
||||
--border-hover: #ada694;
|
||||
--input: #e3dfd2;
|
||||
|
||||
--secondary: #f3f1e9;
|
||||
--secondary-foreground: #3d3a33;
|
||||
--accent-2: #8a6a44;
|
||||
--accent-2-muted: rgba(138, 106, 68, 0.75);
|
||||
--accent-2-subtle: rgba(138, 106, 68, 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(60, 50, 30, 0.05);
|
||||
--shadow-md: 0 4px 12px rgba(60, 50, 30, 0.07);
|
||||
--shadow-lg: 0 12px 28px rgba(60, 50, 30, 0.09);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(60, 50, 30, 0.04);
|
||||
/* Typography is this theme's signature: a grotesque for chrome, a reading
|
||||
serif for chat prose (styles/fonts.css self-hosts both). The stacks keep
|
||||
system fallbacks so a failed font load degrades instead of blanking text. */
|
||||
--font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-chat: "Lora", Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
:root[data-theme="tide"] {
|
||||
/*
|
||||
* Accent — steel cyan on deep slate
|
||||
*
|
||||
* The first cool-primary theme in the set; every other palette accents warm.
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #10151b, relative luminance ≈ 0.0093):
|
||||
* --accent #5ab6d8 contrast ≈ 8.0:1 AA text ✓
|
||||
* --primary-foreground #0b1116 on #5ab6d8 button: 8.2:1 AAA ✓
|
||||
* --primary-foreground #0b1116 on #7ac8e4 hover: 10.1:1 ✓
|
||||
* --destructive-foreground #fafafa on #d32f2f button: 4.77:1 AA ✓
|
||||
* worst text pairing --muted on --bg-muted: 6.12:1 ✓
|
||||
* inherited status label on own subtle tint, worst surface: 4.65:1 ✓
|
||||
*/
|
||||
--ring: #5ab6d8;
|
||||
--accent: #5ab6d8;
|
||||
--accent-hover: #7ac8e4;
|
||||
--accent-muted: #5ab6d8;
|
||||
--accent-subtle: rgba(90, 182, 216, 0.12);
|
||||
--accent-glow: rgba(90, 182, 216, 0.2);
|
||||
--primary: #5ab6d8;
|
||||
--primary-hover: #7ac8e4;
|
||||
--primary-foreground: #0b1116;
|
||||
|
||||
/* Deepen only the text-bearing destructive fill; keep danger accents vivid. */
|
||||
--destructive: #d32f2f;
|
||||
--destructive-hover: #bc2a2a;
|
||||
|
||||
--focus: rgba(90, 182, 216, 0.2);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 70%, transparent);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow);
|
||||
|
||||
/* Surfaces — cold slate, low saturation */
|
||||
--bg: #10151b;
|
||||
--bg-accent: #151b23;
|
||||
--bg-elevated: #1b232c;
|
||||
--bg-hover: #212b36;
|
||||
--bg-muted: #212b36;
|
||||
|
||||
--card: #161d25;
|
||||
--card-foreground: #eef3f8;
|
||||
--card-highlight: rgba(210, 232, 255, 0.04);
|
||||
--popover: #1b232c;
|
||||
--popover-foreground: #eef3f8;
|
||||
|
||||
--panel: #10151b;
|
||||
--panel-strong: #1b232c;
|
||||
--panel-hover: #212b36;
|
||||
--chrome: rgba(16, 21, 27, 0.96);
|
||||
--chrome-strong: rgba(16, 21, 27, 0.98);
|
||||
|
||||
--text: #dce3ea;
|
||||
--text-strong: #f2f6fa;
|
||||
--chat-text: #dce3ea;
|
||||
--muted: #9dabb9;
|
||||
--muted-strong: #aab7c4;
|
||||
--muted-foreground: #9dabb9;
|
||||
|
||||
--border: #222c37;
|
||||
--border-strong: #33414f;
|
||||
--border-hover: #465767;
|
||||
--input: #222c37;
|
||||
|
||||
--secondary: #161d25;
|
||||
--secondary-foreground: #eef3f8;
|
||||
--accent-2: #7f9bb5;
|
||||
--accent-2-muted: rgba(127, 155, 181, 0.7);
|
||||
--accent-2-subtle: rgba(127, 155, 181, 0.12);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(2, 6, 12, 0.4);
|
||||
--shadow-md: 0 4px 16px rgba(2, 6, 12, 0.5);
|
||||
--shadow-lg: 0 12px 32px rgba(2, 6, 12, 0.6);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(210, 232, 255, 0.03);
|
||||
}
|
||||
|
||||
:root[data-theme="tide-light"] {
|
||||
/*
|
||||
* Accent — deep teal-blue on cool paper
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #f7f9fb, relative luminance ≈ 0.928):
|
||||
* --accent #1f6f8f contrast ≈ 5.3:1 AA text ✓
|
||||
* --accent-hover #175a75 contrast ≈ 7.6:1 AAA ✓
|
||||
* --primary-foreground #ffffff on #1f6f8f button: 5.6:1 AA ✓
|
||||
* worst text pairing --muted on --bg-muted: 4.63:1 ✓
|
||||
*/
|
||||
--ring: #1f6f8f;
|
||||
--accent: #1f6f8f;
|
||||
--accent-hover: #175a75;
|
||||
--accent-muted: #1f6f8f;
|
||||
--accent-subtle: rgba(31, 111, 143, 0.1);
|
||||
--accent-glow: rgba(31, 111, 143, 0.14);
|
||||
--primary: #1f6f8f;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--bg: #f7f9fb;
|
||||
--bg-accent: #eef2f7;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-hover: #e8edf2;
|
||||
--bg-muted: #e8edf2;
|
||||
--bg-content: #eef2f7;
|
||||
|
||||
--card: #ffffff;
|
||||
--card-foreground: #1b232b;
|
||||
--card-highlight: rgba(20, 40, 60, 0.02);
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1b232b;
|
||||
--session-hovercard-surface: var(--card);
|
||||
--session-hovercard-progress-surface: color-mix(
|
||||
in srgb,
|
||||
var(--card) 52%,
|
||||
var(--panel-strong) 48%
|
||||
);
|
||||
|
||||
--panel: #f7f9fb;
|
||||
--panel-strong: #eef2f7;
|
||||
--panel-hover: #dfe6ee;
|
||||
--chrome: rgba(247, 249, 251, 0.96);
|
||||
--chrome-strong: rgba(247, 249, 251, 0.98);
|
||||
|
||||
--text: #333c45;
|
||||
--text-strong: #1b232b;
|
||||
--chat-text: #333c45;
|
||||
--muted: #5f6b76;
|
||||
--muted-strong: #4c5761;
|
||||
--muted-foreground: #5f6b76;
|
||||
|
||||
--border: #dfe5ec;
|
||||
--border-strong: #c5cfd9;
|
||||
--border-hover: #a3b0bd;
|
||||
--input: #dfe5ec;
|
||||
|
||||
--secondary: #eef2f7;
|
||||
--secondary-foreground: #333c45;
|
||||
--accent-2: #3d6d88;
|
||||
--accent-2-muted: rgba(61, 109, 136, 0.75);
|
||||
--accent-2-subtle: rgba(61, 109, 136, 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(20, 40, 60, 0.05);
|
||||
--shadow-md: 0 4px 12px rgba(20, 40, 60, 0.07);
|
||||
--shadow-lg: 0 12px 28px rgba(20, 40, 60, 0.09);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(20, 40, 60, 0.04);
|
||||
}
|
||||
|
||||
/*
|
||||
* Beacon targets WCAG AAA (7:1), not the AA 4.5:1 floor the other themes hold,
|
||||
* for low vision, direct sunlight, projectors, and poor panels.
|
||||
*
|
||||
* That forces one deliberate break from the status-token convention in :root:
|
||||
* elsewhere each --x-subtle is --x at 8%, but a label sitting on a tint of its
|
||||
* own hue caps the achievable contrast, because the tint pulls the background
|
||||
* toward the text. Beacon tints neutrally instead and lets the label carry the
|
||||
* contrast, which is what buys AAA on the status pairs.
|
||||
*/
|
||||
:root[data-theme="beacon"] {
|
||||
/*
|
||||
* Accent — high-visibility amber on pure black
|
||||
*
|
||||
* WCAG 2.1 AAA audit (bg = #000000):
|
||||
* --accent #ffc233 contrast ≈ 13.0:1 AAA ✓
|
||||
* --primary-foreground #000000 on #ffc233 button: 13.0:1 ✓
|
||||
* worst text pairing --muted on --bg-muted: 10.29:1 ✓
|
||||
* worst status label on its neutral subtle tint: 7.05:1 ✓
|
||||
*/
|
||||
--ring: #ffc233;
|
||||
--accent: #ffc233;
|
||||
--accent-hover: #ffd45e;
|
||||
--accent-muted: #ffc233;
|
||||
--accent-subtle: rgba(255, 255, 255, 0.1);
|
||||
--accent-glow: rgba(255, 194, 51, 0.3);
|
||||
--primary: #ffc233;
|
||||
--primary-hover: #ffd45e;
|
||||
--primary-foreground: #000000;
|
||||
|
||||
/* Focus is 3px and fully opaque: a translucent ring is the first thing to
|
||||
disappear on a poor panel, which is exactly this theme's audience. */
|
||||
--focus: rgba(255, 194, 51, 0.35);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 5px var(--ring);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 5px var(--ring);
|
||||
|
||||
--bg: #000000;
|
||||
--bg-accent: #0a0a0a;
|
||||
--bg-elevated: #141414;
|
||||
--bg-hover: #1c1c1c;
|
||||
--bg-muted: #1c1c1c;
|
||||
|
||||
--card: #0a0a0a;
|
||||
--card-foreground: #ffffff;
|
||||
--card-highlight: rgba(255, 255, 255, 0.06);
|
||||
--popover: #141414;
|
||||
--popover-foreground: #ffffff;
|
||||
|
||||
--panel: #000000;
|
||||
--panel-strong: #141414;
|
||||
--panel-hover: #1c1c1c;
|
||||
--chrome: rgba(0, 0, 0, 0.98);
|
||||
--chrome-strong: #000000;
|
||||
|
||||
--text: #ffffff;
|
||||
--text-strong: #ffffff;
|
||||
--chat-text: #ffffff;
|
||||
--muted: #c9c9c9;
|
||||
--muted-strong: #dcdcdc;
|
||||
--muted-foreground: #c9c9c9;
|
||||
|
||||
/* Borders are structure here, not decoration: hairlines vanish first. */
|
||||
--border: #4a4a4a;
|
||||
--border-strong: #6e6e6e;
|
||||
--border-hover: #9a9a9a;
|
||||
--input: #4a4a4a;
|
||||
|
||||
--secondary: #141414;
|
||||
--secondary-foreground: #ffffff;
|
||||
--accent-2: #8ecdff;
|
||||
--accent-2-muted: rgba(142, 205, 255, 0.85);
|
||||
--accent-2-subtle: rgba(255, 255, 255, 0.1);
|
||||
|
||||
/* Neutral status tints — see the block comment above. */
|
||||
--ok: #5ee88a;
|
||||
--ok-muted: rgba(94, 232, 138, 0.85);
|
||||
--ok-subtle: rgba(255, 255, 255, 0.1);
|
||||
--destructive: #ffabab;
|
||||
--destructive-hover: #ffc4c4;
|
||||
--destructive-foreground: #000000;
|
||||
--warn: #ffc233;
|
||||
--warn-muted: rgba(255, 194, 51, 0.85);
|
||||
--warn-subtle: rgba(255, 255, 255, 0.1);
|
||||
--danger: #ffabab;
|
||||
--danger-muted: rgba(255, 171, 171, 0.85);
|
||||
--danger-subtle: rgba(255, 255, 255, 0.1);
|
||||
--info: #8ecdff;
|
||||
--info-subtle: rgba(255, 255, 255, 0.1);
|
||||
--run: #5ee88a;
|
||||
|
||||
/* Diff ink is a status pair too: it sits on the same neutral tints. */
|
||||
--hljs-deletion: #ffaba3;
|
||||
--hljs-addition: #7ee787;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.9);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.9);
|
||||
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.95);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
--overlay-border: var(--border-strong);
|
||||
|
||||
--grid-line: rgba(255, 255, 255, 0.08);
|
||||
/* Atkinson Hyperlegible Next everywhere, prose included: the face is part of
|
||||
the accessibility contract, not an accent (see styles fonts/beacon.css). */
|
||||
--font-body:
|
||||
"Atkinson Hyperlegible Next", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-chat: var(--font-body);
|
||||
}
|
||||
|
||||
:root[data-theme="beacon-light"] {
|
||||
/*
|
||||
* Accent — dark amber on pure white
|
||||
*
|
||||
* WCAG 2.1 AAA audit (bg = #ffffff):
|
||||
* --accent #6e4a00 contrast ≈ 8.0:1 AAA ✓
|
||||
* --primary-foreground #ffffff on #6e4a00 button: 8.0:1 ✓
|
||||
* worst text pairing --muted on --bg-muted: 9.72:1 ✓
|
||||
* worst status label on its neutral subtle tint: 7.02:1 ✓
|
||||
*/
|
||||
--ring: #6e4a00;
|
||||
--accent: #6e4a00;
|
||||
--accent-hover: #573a00;
|
||||
--accent-muted: #6e4a00;
|
||||
--accent-subtle: rgba(0, 0, 0, 0.07);
|
||||
--accent-glow: rgba(110, 74, 0, 0.2);
|
||||
--primary: #6e4a00;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--focus: rgba(110, 74, 0, 0.35);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 5px var(--ring);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 5px var(--ring);
|
||||
|
||||
--bg: #ffffff;
|
||||
--bg-accent: #f4f4f4;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-hover: #ededed;
|
||||
--bg-muted: #ededed;
|
||||
--bg-content: #f4f4f4;
|
||||
|
||||
--card: #ffffff;
|
||||
--card-foreground: #000000;
|
||||
--card-highlight: rgba(0, 0, 0, 0.04);
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #000000;
|
||||
--session-hovercard-surface: var(--card);
|
||||
--session-hovercard-progress-surface: var(--bg-accent);
|
||||
|
||||
--panel: #ffffff;
|
||||
--panel-strong: #f4f4f4;
|
||||
--panel-hover: #e4e4e4;
|
||||
--chrome: rgba(255, 255, 255, 0.98);
|
||||
--chrome-strong: #ffffff;
|
||||
|
||||
--text: #000000;
|
||||
--text-strong: #000000;
|
||||
--chat-text: #000000;
|
||||
--muted: #3a3a3a;
|
||||
--muted-strong: #262626;
|
||||
--muted-foreground: #3a3a3a;
|
||||
|
||||
--border: #8a8a8a;
|
||||
--border-strong: #5a5a5a;
|
||||
--border-hover: #2e2e2e;
|
||||
--input: #8a8a8a;
|
||||
|
||||
--secondary: #f4f4f4;
|
||||
--secondary-foreground: #000000;
|
||||
--accent-2: #09428d;
|
||||
--accent-2-muted: rgba(9, 66, 141, 0.85);
|
||||
--accent-2-subtle: rgba(0, 0, 0, 0.07);
|
||||
|
||||
/* Neutral status tints — see the block comment above. */
|
||||
--ok: #0c5024;
|
||||
--ok-muted: rgba(12, 80, 36, 0.85);
|
||||
--ok-subtle: rgba(0, 0, 0, 0.07);
|
||||
--destructive: #8c0f0f;
|
||||
--destructive-foreground: #ffffff;
|
||||
--warn: #613d00;
|
||||
--warn-muted: rgba(97, 61, 0, 0.85);
|
||||
--warn-subtle: rgba(0, 0, 0, 0.07);
|
||||
--danger: #8c0f0f;
|
||||
--danger-muted: rgba(140, 15, 15, 0.85);
|
||||
--danger-subtle: rgba(0, 0, 0, 0.07);
|
||||
--info: #09428d;
|
||||
--info-subtle: rgba(0, 0, 0, 0.07);
|
||||
--run: #0c5024;
|
||||
|
||||
/* Diff ink is a status pair too: it sits on the same neutral tints. */
|
||||
--hljs-deletion: #8b0c20;
|
||||
--hljs-addition: #0e5021;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.28);
|
||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.3);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
--overlay-border: var(--border-strong);
|
||||
|
||||
--grid-line: rgba(0, 0, 0, 0.12);
|
||||
/* Atkinson Hyperlegible Next everywhere, prose included: the face is part of
|
||||
the accessibility contract, not an accent (see styles fonts/beacon.css). */
|
||||
--font-body:
|
||||
"Atkinson Hyperlegible Next", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-chat: var(--font-body);
|
||||
}
|
||||
|
||||
:root[data-theme="phosphor"] {
|
||||
/*
|
||||
* Accent — phosphor green on green-cast black
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #0a0f0a, relative luminance ≈ 0.0045):
|
||||
* --accent #4ade80 contrast ≈ 11.1:1 AAA ✓
|
||||
* --primary-foreground #07120a on #4ade80 button: 11.0:1 ✓
|
||||
* --destructive-foreground #fafafa on #d32f2f button: 4.77:1 AA ✓
|
||||
* worst text pairing --muted on --bg-muted: 6.69:1 ✓
|
||||
* inherited status label on own subtle tint, worst surface: 5.32:1 ✓
|
||||
*/
|
||||
--ring: #4ade80;
|
||||
--accent: #4ade80;
|
||||
--accent-hover: #6ee79b;
|
||||
--accent-muted: #4ade80;
|
||||
--accent-subtle: rgba(74, 222, 128, 0.12);
|
||||
--accent-glow: rgba(74, 222, 128, 0.22);
|
||||
--primary: #4ade80;
|
||||
--primary-hover: #6ee79b;
|
||||
--primary-foreground: #07120a;
|
||||
|
||||
--destructive: #d32f2f;
|
||||
--destructive-hover: #bc2a2a;
|
||||
|
||||
--focus: rgba(74, 222, 128, 0.2);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 75%, transparent);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow);
|
||||
|
||||
--bg: #0a0f0a;
|
||||
--bg-accent: #0e150e;
|
||||
--bg-elevated: #121a12;
|
||||
--bg-hover: #18221a;
|
||||
--bg-muted: #18221a;
|
||||
|
||||
--card: #0e150e;
|
||||
--card-foreground: #e8f5e9;
|
||||
--card-highlight: rgba(200, 255, 210, 0.04);
|
||||
--popover: #121a12;
|
||||
--popover-foreground: #e8f5e9;
|
||||
|
||||
--panel: #0a0f0a;
|
||||
--panel-strong: #121a12;
|
||||
--panel-hover: #18221a;
|
||||
--chrome: rgba(10, 15, 10, 0.96);
|
||||
--chrome-strong: rgba(10, 15, 10, 0.98);
|
||||
|
||||
--text: #cfe0cf;
|
||||
--text-strong: #e8f5e9;
|
||||
--chat-text: #cfe0cf;
|
||||
--muted: #93ac95;
|
||||
--muted-strong: #a4bba6;
|
||||
--muted-foreground: #93ac95;
|
||||
|
||||
--border: #1d291f;
|
||||
--border-strong: #2c3d2f;
|
||||
--border-hover: #3e5442;
|
||||
--input: #1d291f;
|
||||
|
||||
--secondary: #0e150e;
|
||||
--secondary-foreground: #e8f5e9;
|
||||
--accent-2: #8fd6a5;
|
||||
--accent-2-muted: rgba(143, 214, 165, 0.7);
|
||||
--accent-2-subtle: rgba(143, 214, 165, 0.12);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 6, 2, 0.4);
|
||||
--shadow-md: 0 4px 16px rgba(0, 6, 2, 0.5);
|
||||
--shadow-lg: 0 12px 32px rgba(0, 6, 2, 0.6);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(200, 255, 210, 0.04);
|
||||
|
||||
/* The whole surface is one face, prose included. Mono is a deliberate
|
||||
character choice for an operator console, not a readability claim; it is
|
||||
why this theme is opt-in. --mono already named JetBrains Mono, but nothing
|
||||
shipped it until this theme did. */
|
||||
--mono:
|
||||
"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
--font-body: var(--mono);
|
||||
--font-chat: var(--mono);
|
||||
}
|
||||
|
||||
:root[data-theme="phosphor-light"] {
|
||||
/*
|
||||
* Accent — deep green on cool paper
|
||||
*
|
||||
* WCAG 2.1 AA audit (bg = #f4f7f4, relative luminance ≈ 0.897):
|
||||
* --accent #10693a contrast ≈ 6.3:1 AA text ✓
|
||||
* --primary-foreground #ffffff on #10693a button: 6.8:1 AA ✓
|
||||
* worst text pairing --muted on --bg-muted: 4.76:1 ✓
|
||||
*/
|
||||
--ring: #10693a;
|
||||
--accent: #10693a;
|
||||
--accent-hover: #0b512c;
|
||||
--accent-muted: #10693a;
|
||||
--accent-subtle: rgba(16, 105, 58, 0.1);
|
||||
--accent-glow: rgba(16, 105, 58, 0.14);
|
||||
--primary: #10693a;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--bg: #f4f7f4;
|
||||
--bg-accent: #ecf1ec;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-hover: #e4ebe5;
|
||||
--bg-muted: #e4ebe5;
|
||||
--bg-content: #ecf1ec;
|
||||
|
||||
--card: #ffffff;
|
||||
--card-foreground: #16201a;
|
||||
--card-highlight: rgba(20, 50, 30, 0.02);
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #16201a;
|
||||
--session-hovercard-surface: var(--card);
|
||||
--session-hovercard-progress-surface: color-mix(
|
||||
in srgb,
|
||||
var(--card) 52%,
|
||||
var(--panel-strong) 48%
|
||||
);
|
||||
|
||||
--panel: #f4f7f4;
|
||||
--panel-strong: #ecf1ec;
|
||||
--panel-hover: #dde6de;
|
||||
--chrome: rgba(244, 247, 244, 0.96);
|
||||
--chrome-strong: rgba(244, 247, 244, 0.98);
|
||||
|
||||
--text: #2a352b;
|
||||
--text-strong: #16201a;
|
||||
--chat-text: #2a352b;
|
||||
--muted: #566b58;
|
||||
--muted-strong: #455947;
|
||||
--muted-foreground: #566b58;
|
||||
|
||||
--border: #dbe4dc;
|
||||
--border-strong: #bfcdc1;
|
||||
--border-hover: #9aad9d;
|
||||
--input: #dbe4dc;
|
||||
|
||||
--secondary: #ecf1ec;
|
||||
--secondary-foreground: #2a352b;
|
||||
--accent-2: #2f6b47;
|
||||
--accent-2-muted: rgba(47, 107, 71, 0.75);
|
||||
--accent-2-subtle: rgba(47, 107, 71, 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(20, 50, 30, 0.05);
|
||||
--shadow-md: 0 4px 12px rgba(20, 50, 30, 0.07);
|
||||
--shadow-lg: 0 12px 28px rgba(20, 50, 30, 0.09);
|
||||
--overlay-shadow: var(--shadow-md);
|
||||
|
||||
--grid-line: rgba(20, 50, 30, 0.04);
|
||||
|
||||
--mono:
|
||||
"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
--font-body: var(--mono);
|
||||
--font-chat: var(--mono);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
/* scrollbar-width doesn't inherit (unlike scrollbar-color above), so every
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -95,7 +95,15 @@ function readOpacity(ruleBody: string): number {
|
||||
}
|
||||
|
||||
describe("Control UI theme contrast", () => {
|
||||
const baseCss = readFileSync(path.join(here, "base.css"), "utf8");
|
||||
const themesDir = path.join(here, "..", "..", "public", "themes");
|
||||
// Palettes moved out of the startup stylesheet; read them back for coverage.
|
||||
const baseCss = [
|
||||
readFileSync(path.join(here, "base.css"), "utf8"),
|
||||
...readdirSync(themesDir)
|
||||
.filter((entry) => entry.endsWith(".css"))
|
||||
.toSorted()
|
||||
.map((entry) => readFileSync(path.join(themesDir, entry), "utf8")),
|
||||
].join("\n");
|
||||
const groupedCss = readFileSync(path.join(here, "chat", "grouped.css"), "utf8");
|
||||
const chatLayoutCss = readFileSync(path.join(here, "chat", "layout.css"), "utf8");
|
||||
const layoutCss = readFileSync(path.join(here, "layout.css"), "utf8");
|
||||
@@ -141,7 +149,7 @@ describe("Control UI theme contrast", () => {
|
||||
expect(sessionLabelRule).toMatch(/color:\s*var\(--muted\)/);
|
||||
expect(readOpacity(sessionLabelRule)).toBe(1);
|
||||
|
||||
const light = readCssVarBlock(baseCss, ':root[data-theme-mode="light"]');
|
||||
const light = readCssVarBlock(baseCss, ':root:where([data-theme-mode="light"])');
|
||||
for (const selector of [
|
||||
null,
|
||||
':root[data-theme="openknot-light"]',
|
||||
|
||||
Reference in New Issue
Block a user