diff --git a/config/stylelint.config.mjs b/config/stylelint.config.mjs index a5c30e9221f9..0d6ca9b049ea 100644 --- a/config/stylelint.config.mjs +++ b/config/stylelint.config.mjs @@ -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, }, diff --git a/package.json b/package.json index d2e33beca329..6f8d7e3bd231 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-changed.mts b/scripts/check-changed.mts index 4adf1929f883..3a6a29afeb02 100644 --- a/scripts/check-changed.mts +++ b/scripts/check-changed.mts @@ -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 = diff --git a/scripts/check-control-ui-performance.mts b/scripts/check-control-ui-performance.mts index 6e2cfbcf30e0..0f6f5d1b86a6 100644 --- a/scripts/check-control-ui-performance.mts +++ b/scripts/check-control-ui-performance.mts @@ -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. diff --git a/scripts/lib/ci-node-test-plan.mts b/scripts/lib/ci-node-test-plan.mts index 29ee705df047..3a1d7c7b3381 100644 --- a/scripts/lib/ci-node-test-plan.mts +++ b/scripts/lib/ci-node-test-plan.mts @@ -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", diff --git a/scripts/run-lint.mts b/scripts/run-lint.mts index ee9b990657cd..ad318644b7bd 100644 --- a/scripts/run-lint.mts +++ b/scripts/run-lint.mts @@ -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" }, ); diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 82f6f5668fda..3f177b89e3cd 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -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" }, diff --git a/test/scripts/ci-changed-node-test-plan.test.ts b/test/scripts/ci-changed-node-test-plan.test.ts index fe180f642043..2997de8582f9 100644 --- a/test/scripts/ci-changed-node-test-plan.test.ts +++ b/test/scripts/ci-changed-node-test-plan.test.ts @@ -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", () => { diff --git a/ui/index.html b/ui/index.html index fb5cf8d47a61..3665003ce36f 100644 --- a/ui/index.html +++ b/ui/index.html @@ -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 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); diff --git a/ui/public/themes/absolutely.css b/ui/public/themes/absolutely.css new file mode 100644 index 000000000000..6a11f92168b5 --- /dev/null +++ b/ui/public/themes/absolutely.css @@ -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; +} diff --git a/ui/public/themes/beacon.css b/ui/public/themes/beacon.css new file mode 100644 index 000000000000..6b0516bf42c6 --- /dev/null +++ b/ui/public/themes/beacon.css @@ -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); +} diff --git a/ui/public/themes/dash.css b/ui/public/themes/dash.css new file mode 100644 index 000000000000..71181836faaa --- /dev/null +++ b/ui/public/themes/dash.css @@ -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); +} diff --git a/ui/public/themes/knot.css b/ui/public/themes/knot.css new file mode 100644 index 000000000000..ca3611cfe74f --- /dev/null +++ b/ui/public/themes/knot.css @@ -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); +} diff --git a/ui/public/themes/phosphor.css b/ui/public/themes/phosphor.css new file mode 100644 index 000000000000..24e38d386d82 --- /dev/null +++ b/ui/public/themes/phosphor.css @@ -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); +} diff --git a/ui/public/themes/tide.css b/ui/public/themes/tide.css new file mode 100644 index 000000000000..89083abd88d8 --- /dev/null +++ b/ui/public/themes/tide.css @@ -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); +} diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts index b33362921d56..589c3511e504 100644 --- a/ui/src/app/bootstrap.ts +++ b/ui/src/app/bootstrap.ts @@ -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): 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 diff --git a/ui/src/app/public-assets.ts b/ui/src/app/public-assets.ts index 5c3a85665779..4cebf35c8bf1 100644 --- a/ui/src/app/public-assets.ts +++ b/ui/src/app/public-assets.ts @@ -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` diff --git a/ui/src/app/theme.test.ts b/ui/src/app/theme.test.ts index 319a83437079..c20024752ac9 100644 --- a/ui/src/app/theme.test.ts +++ b/ui/src/app/theme.test.ts @@ -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 })); diff --git a/ui/src/app/theme.ts b/ui/src/app/theme.ts index 86ad2fb7412b..2568089ab691 100644 --- a/ui/src/app/theme.ts +++ b/ui/src/app/theme.ts @@ -53,6 +53,7 @@ const THEME_FONT_STYLESHEETS: Partial phosphor: "fonts/phosphor.css", }; type ControlUiFontStylesheet = `fonts/${string}.css`; + const VALID_THEME_MODES = new Set(["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); + } +} diff --git a/ui/src/e2e/theme-typography.e2e.test.ts b/ui/src/e2e/theme-typography.e2e.test.ts index 3ed2fc669e22..02f956a933dc 100644 --- a/ui/src/e2e/theme-typography.e2e.test.ts +++ b/ui/src/e2e/theme-typography.e2e.test.ts @@ -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((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((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( + / { + 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 , 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") }); diff --git a/ui/src/styles/base-theme-contrast.node.test.ts b/ui/src/styles/base-theme-contrast.node.test.ts index 3fd24cf430c1..ff854ce46db3 100644 --- a/ui/src/styles/base-theme-contrast.node.test.ts +++ b/ui/src/styles/base-theme-contrast.node.test.ts @@ -101,9 +101,25 @@ type TokenMap = Map; 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 { const blocks = new Map(); - 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 { /** Compose each selectable theme the way theme.ts layers blocks over :root. */ function resolveThemes(blocks: Map): Map { 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", () => { diff --git a/ui/src/styles/base-theme-tokens.node.test.ts b/ui/src/styles/base-theme-tokens.node.test.ts index ec90d49e933f..4053164200a4 100644 --- a/ui/src/styles/base-theme-tokens.node.test.ts +++ b/ui/src/styles/base-theme-tokens.node.test.ts @@ -108,7 +108,7 @@ describe("Control UI base theme tokens", () => { describe("Control UI theme --bg mirrors", () => { const RESOLVED_THEME_BG_SELECTOR = new Map([ ["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 { - 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(); for (const [resolved, selector] of RESOLVED_THEME_BG_SELECTOR) { const value = readBlockToken(baseCss, selector, "--bg"); diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index 45696ce7b11a..c92a1a8157ba 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -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 diff --git a/ui/src/styles/theme-contrast.test.ts b/ui/src/styles/theme-contrast.test.ts index c4a82d5e048e..6d96b91fcaff 100644 --- a/ui/src/styles/theme-contrast.test.ts +++ b/ui/src/styles/theme-contrast.test.ts @@ -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"]',