From a9bc88e73f1269d6e42dfed0910d9df0e21c92c7 Mon Sep 17 00:00:00 2001 From: jibraaan Date: Sun, 4 Oct 2026 18:42:00 +0500 Subject: [PATCH 1/3] feat: dark mode with a System / Light / Dark setting Dark colors are generated at build time by a small PostCSS plugin (src/build/dark-theme.ts) instead of hand-written overrides for ~560 hard-coded colors. Every rule with color declarations gets a twin under :root[data-theme='dark'] that flips OKLab lightness while keeping hue, lifts mid-tones for AA contrast, keeps shadows dark, and preserves media queries and cascade order. Intentionally dark surfaces (the call view) are marked /* theme: fixed */. - public/theme.js applies the saved theme before first paint (an external file because the CSP disallows inline scripts), so there is no flash. - Settings has an Appearance control; System follows the device and updates live. The choice is stored per browser. - Measured in dark: secondary text 4.98:1, notes 4.83:1, headings 8.4:1. Light mode is unchanged. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + index.html | 1 + package-lock.json | 31 +------- package.json | 1 + public/theme.js | 13 ++++ src/build/dark-theme.ts | 132 +++++++++++++++++++++++++++++++++ src/client/WorkspaceDialog.tsx | 33 +++++++++ src/client/main.tsx | 2 + src/client/style.css | 56 ++++++++++++++ src/client/theme.ts | 37 +++++++++ tests/dark-theme.test.ts | 84 +++++++++++++++++++++ vite.config.ts | 2 + 12 files changed, 363 insertions(+), 30 deletions(-) create mode 100644 public/theme.js create mode 100644 src/build/dark-theme.ts create mode 100644 src/client/theme.ts create mode 100644 tests/dark-theme.test.ts diff --git a/README.md b/README.md index 49dfde40..a2b7f0f2 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,7 @@ See [Setup](docs/SETUP.md) for configuration, Slack, calls, the browser service, | Background work | Scheduled server-side turns in their original conversation, with pause and retry controls | | Browser | Separate read-only public-page service with page capture and navigation limits | | Dot computers | Per-Dot browser profiles, files, shell, takeover, permissions, and action records through OpenBot | +| Appearance | System, light, and dark themes; dark colors are derived at build time from the light styles | | Memory | User-managed preferences that permitted Dots can use | | Automatic Learning | Per-Dot Learning containers, conversation evidence routing, and published-skill delivery; see [setup](docs/SETUP.md#automatic-learning) | | Deployment | Local Node setup and separate application/browser containers | diff --git a/index.html b/index.html index dbe91b9f..3e37f29e 100644 --- a/index.html +++ b/index.html @@ -10,6 +10,7 @@ content="OpenDots is a self-hosted template for your documents and personal assistants." /> OpenDots +
diff --git a/package-lock.json b/package-lock.json index 895d8ce0..1408f0fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "concurrently": "^10.0.5", "eslint": "^10.11.0", "eslint-plugin-react-hooks": "^7.1.1", + "postcss": "^8.5.28", "prettier": "^3.9.9", "tsx": "^4.23.15", "typescript": "^6.0.3", @@ -4179,9 +4180,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4198,9 +4196,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4217,9 +4212,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4236,9 +4228,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4255,9 +4244,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4274,9 +4260,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10129,9 +10112,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10152,9 +10132,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10175,9 +10152,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10198,9 +10172,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 3020c50b..4db522a9 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "concurrently": "^10.0.5", "eslint": "^10.11.0", "eslint-plugin-react-hooks": "^7.1.1", + "postcss": "^8.5.28", "prettier": "^3.9.9", "tsx": "^4.23.15", "typescript": "^6.0.3", diff --git a/public/theme.js b/public/theme.js new file mode 100644 index 00000000..b1722ed3 --- /dev/null +++ b/public/theme.js @@ -0,0 +1,13 @@ +// Applies the saved theme before the first paint so dark mode never flashes +// white. Kept in sync with src/client/theme.ts, which handles later changes. +(function () { + var preference = 'system'; + try { + preference = localStorage.getItem('opendots-theme') || 'system'; + } catch (error) {} + var dark = + preference === 'dark' || + (preference !== 'light' && + window.matchMedia('(prefers-color-scheme: dark)').matches); + document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light'); +})(); diff --git a/src/build/dark-theme.ts b/src/build/dark-theme.ts new file mode 100644 index 00000000..785e9617 --- /dev/null +++ b/src/build/dark-theme.ts @@ -0,0 +1,132 @@ +import type { AtRule, Declaration, Plugin, Rule } from 'postcss'; +// Build-time dark theme. Every rule with colors gets a sibling rule under +// :root[data-theme='dark'] with only its color declarations, mapped by +// flipping perceptual (OKLab) lightness and keeping hue. New styles get a +// dark variant automatically, and cascade order is preserved because every +// dark rule gains the same specificity and sits right after its original. +// Wrap intentionally dark surfaces in /* theme: fixed */ ... /* theme: end */. +export const DARK = ":root[data-theme='dark']"; +const toLinear = (c: number) => + c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +const toSrgb = (c: number) => + c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055; +function rgbToOklab([r, g, b]: number[]) { + const [lr, lg, lb] = [r, g, b].map(toLinear); + const l = Math.cbrt( + 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb, + ); + const m = Math.cbrt( + 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb, + ); + const s = Math.cbrt( + 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb, + ); + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + ]; +} +function oklabToRgb([L, a, b]: number[]) { + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ].map((c) => Math.min(1, Math.max(0, toSrgb(c)))); +} +function parseHex(hex: string) { + let value = hex.slice(1); + if (value.length <= 4) value = [...value].map((char) => char + char).join(''); + const channels = value.match(/../g)!.map((pair) => parseInt(pair, 16) / 255); + return { rgb: channels.slice(0, 3), alpha: channels[3] }; +} +const hex = (rgb: number[], alpha?: number) => + '#' + + [...rgb, ...(alpha === undefined ? [] : [alpha])] + .map((c) => + Math.round(c * 255) + .toString(16) + .padStart(2, '0'), + ) + .join(''); +// White maps to #1b1b1b, black to #eeeeee. The exponent lifts mid-tones so +// secondary text keeps AA contrast on the dark canvas. +export function darkColor(value: string) { + const { rgb, alpha } = parseHex(value); + const [L, a, b] = rgbToOklab(rgb); + const next = 0.95 - 0.73 * L ** 1.3; + return hex(oklabToRgb([next, a * 0.85, b * 0.85]), alpha); +} +const named: Record = { white: '#ffffff', black: '#000000' }; +const colorToken = /#[0-9a-fA-F]{3,8}\b|\b(?:white|black)\b/g; +export function darkValue(prop: string, value: string) { + // Shadows stay shadows: keep their alpha, drop their tint. + const shadow = /shadow/.test(prop) || value.includes('drop-shadow('); + return value.replace(colorToken, (token) => { + const color = named[token] ?? token; + if (!shadow) return darkColor(color); + const { alpha } = parseHex(color); + return hex([0, 0, 0], Math.min(1, (alpha ?? 0.35) * 1.6)); + }); +} +const colorProp = + /^(color|background(-color|-image)?|border(-(top|right|bottom|left))?(-color)?|outline(-color)?|fill|stroke|accent-color|caret-color|text-decoration(-color)?|box-shadow|text-shadow)$/; +// Every declaration of a color property gets a twin, even `none` or +// `var(--x)`: otherwise a later light rule without a literal color would +// lose to an earlier rule's dark twin and the cascade would invert. +const themed = (decl: Declaration) => + !decl.value.includes('url(') && + (new RegExp(colorToken.source).test(decl.value) || + (colorProp.test(decl.prop) && !decl.prop.startsWith('--'))); +export function darkSelector(selector: string) { + return selector + .split(',') + .map((part) => { + const item = part.trim(); + if (item.startsWith(':root')) return DARK + item.slice(5); + if (/^html\b/.test(item)) + return `html[data-theme='dark']${item.slice(4)}`; + return `${DARK} ${item}`; + }) + .join(', '); +} +export function darkTheme(): Plugin { + return { + postcssPlugin: 'opendots-dark-theme', + Once(root) { + let fixed = false; + const rules: Rule[] = []; + root.each(function visit(node): void { + if (node.type === 'comment') { + if (/^\s*theme:\s*fixed\s*$/.test(node.text)) fixed = true; + if (/^\s*theme:\s*end\s*$/.test(node.text)) fixed = false; + return; + } + if (fixed) return; + if (node.type === 'atrule') { + // Animations interpolate their own colors; leave them alone. + if (!/keyframes$/.test((node as AtRule).name)) + (node as AtRule).each(visit); + return; + } + if (node.type === 'rule' && !node.selector.startsWith(DARK)) + rules.push(node); + }); + for (const rule of rules) { + const colors = rule.nodes.filter( + (node): node is Declaration => node.type === 'decl' && themed(node), + ); + if (!colors.length) continue; + const dark = rule.clone({ selector: darkSelector(rule.selector) }); + dark.removeAll(); + for (const decl of colors) + dark.append(decl.clone({ value: darkValue(decl.prop, decl.value) })); + rule.after(dark); + } + }, + }; +} +darkTheme.postcss = true; diff --git a/src/client/WorkspaceDialog.tsx b/src/client/WorkspaceDialog.tsx index 234fa4d2..7f9919f9 100644 --- a/src/client/WorkspaceDialog.tsx +++ b/src/client/WorkspaceDialog.tsx @@ -1,6 +1,11 @@ import { useEffect, useRef, useState } from 'react'; import { X } from 'lucide-react'; import type { Dot, Memory, State, WorkspaceState } from '../shared/types'; +import { + setThemePreference, + themePreference, + type ThemePreference, +} from './theme'; export type Dialog = | { type: 'space' } | { type: 'dot'; dot?: Dot; spaceId: string } @@ -55,6 +60,7 @@ export function WorkspaceDialog({ ); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); + const [theme, setTheme] = useState(themePreference); const container = useRef(null); useEffect(() => { const previous = @@ -358,6 +364,33 @@ export function WorkspaceDialog({

)} + {dialog.type === 'settings' && ( +
+ Appearance +
+ {(['system', 'light', 'dark'] as ThemePreference[]).map( + (option) => ( + + ), + )} +
+

+ Saved in this browser. System follows your device. +

+
+ )} {dialog.type === 'settings' && (
Service setup diff --git a/src/client/main.tsx b/src/client/main.tsx index f384a752..84ecd822 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -3,6 +3,8 @@ import { createRoot } from 'react-dom/client'; import { App } from './App'; import './style.css'; import './editor.css'; +import { watchSystemTheme } from './theme'; +watchSystemTheme(); createRoot(document.getElementById('root')!).render( diff --git a/src/client/style.css b/src/client/style.css index 0b11b0b1..5128f0e4 100644 --- a/src/client/style.css +++ b/src/client/style.css @@ -3788,6 +3788,7 @@ h3 { color: #a33e35; } +/* theme: fixed */ /* A focused voice surface, with chat one click away. */ .call-view { position: fixed; @@ -3993,3 +3994,58 @@ h3 { font-size: 14px; color: #c9e8dd; } + +/* theme: end */ + +/* Dark theme: colors are derived at build time (src/build/dark-theme.ts); + this only tells the browser to draw native controls dark too. */ +:root[data-theme='dark'] { + color-scheme: dark; +} +.appearance-fields { + border: 0; + margin: 18px 0 0; + padding: 0; +} +.appearance-fields legend { + margin-bottom: 8px; + font-size: 12px; + font-weight: 600; +} +.segmented { + display: inline-flex; + padding: 3px; + border: 1px solid var(--line); + border-radius: 10px; + background: #f4f4f4; +} +.segmented label { + position: relative; +} +.segmented input { + position: absolute; + opacity: 0; + inset: 0; + margin: 0; + cursor: pointer; +} +.segmented span { + display: block; + padding: 6px 14px; + border-radius: 7px; + font-size: 12px; + color: #6b6b6b; +} +.segmented input:checked + span { + background: #fff; + color: #242424; + box-shadow: 0 1px 2px #0000001a; +} +.segmented input:focus-visible + span { + outline: 2px solid #242424; + outline-offset: 1px; +} +.appearance-fields .muted { + margin: 6px 0 0; + font-size: 11px; +} diff --git a/src/client/theme.ts b/src/client/theme.ts new file mode 100644 index 00000000..850cbde3 --- /dev/null +++ b/src/client/theme.ts @@ -0,0 +1,37 @@ +export type ThemePreference = 'system' | 'light' | 'dark'; +const key = 'opendots-theme'; +const query = () => window.matchMedia('(prefers-color-scheme: dark)'); +// Storage can be unavailable (private windows, blocked site data); the theme +// then follows the system for this visit. +export function themePreference(): ThemePreference { + try { + const value = localStorage.getItem(key); + if (value === 'light' || value === 'dark') return value; + } catch { + // Fall through to the system theme. + } + return 'system'; +} +export function resolveTheme(preference: ThemePreference, systemDark: boolean) { + return preference === 'system' ? (systemDark ? 'dark' : 'light') : preference; +} +function apply() { + const theme = resolveTheme(themePreference(), query().matches); + document.documentElement.dataset.theme = theme; + document + .querySelector('meta[name="theme-color"]') + ?.setAttribute('content', theme === 'dark' ? '#1b1b1b' : '#f8f7f4'); +} +export function setThemePreference(preference: ThemePreference) { + try { + if (preference === 'system') localStorage.removeItem(key); + else localStorage.setItem(key, preference); + } catch { + // Applies for this visit only. + } + apply(); +} +export function watchSystemTheme() { + apply(); + query().addEventListener('change', apply); +} diff --git a/tests/dark-theme.test.ts b/tests/dark-theme.test.ts new file mode 100644 index 00000000..2fe25694 --- /dev/null +++ b/tests/dark-theme.test.ts @@ -0,0 +1,84 @@ +import { expect, it } from 'vitest'; +import postcss from 'postcss'; +import { + darkColor, + darkSelector, + darkTheme, + darkValue, +} from '../src/build/dark-theme'; +import { resolveTheme } from '../src/client/theme'; +const run = (css: string) => + postcss([darkTheme()]).process(css, { from: undefined }).css; +const lightness = (hex: string) => { + const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + return (r + g + b) / 3; +}; +it('flips lightness, keeps hue, and preserves alpha', () => { + expect(darkColor('#ffffff')).toBe('#1b1b1b'); + expect(darkColor('#000000')).toBe('#eeeeee'); + expect(darkColor('#fff')).toBe('#1b1b1b'); + expect(lightness(darkColor('#f4f4f4'))).toBeLessThan(40); + const green = darkColor('#496d61'); + expect(parseInt(green.slice(3, 5), 16)).toBeGreaterThan( + parseInt(green.slice(1, 3), 16), + ); + expect(darkColor('#ffffff80')).toBe('#1b1b1b80'); +}); +it('keeps shadows dark instead of turning them into glows', () => { + expect(darkValue('box-shadow', '0 1px 2px #20263418')).toMatch( + /^0 1px 2px #000000[0-9a-f]{2}$/, + ); + expect(darkValue('border', '1px solid white')).toBe('1px solid #1b1b1b'); +}); +it('scopes selectors to the dark theme attribute', () => { + expect(darkSelector('.a, .b:hover')).toBe( + ":root[data-theme='dark'] .a, :root[data-theme='dark'] .b:hover", + ); + expect(darkSelector(':root')).toBe(":root[data-theme='dark']"); + expect(darkSelector('html body')).toBe("html[data-theme='dark'] body"); +}); +it('twins only color declarations, inside the same media query', () => { + const css = run(` + .card { padding: 4px; color: #333; border-radius: 8px; } + @media (max-width: 700px) { .card { background: #fff; margin: 0; } } + `); + expect(css).toContain(":root[data-theme='dark'] .card { color: #"); + expect(css).not.toMatch(/data-theme='dark'\] \.card \{[^}]*padding/); + expect(css).toMatch( + /@media \(max-width: 700px\) \{[^@]*:root\[data-theme='dark'\] \.card \{ background: #1b1b1b; \}/, + ); +}); +it('keeps the light cascade order when later rules have no literal color', () => { + const css = run(` + button { background: #fff; } + .icon-button { background: none; } + .primary { background: var(--accent); } + `); + // Without these twins, button's dark rule would beat .icon-button. + expect(css).toContain( + ":root[data-theme='dark'] .icon-button { background: none; }", + ); + expect(css).toContain( + ":root[data-theme='dark'] .primary { background: var(--accent); }", + ); +}); +it('leaves fixed surfaces, keyframes, and images alone', () => { + const css = run(` + /* theme: fixed */ + .call-view { background: #1c544c; } + /* theme: end */ + .after { color: #000; } + @keyframes glow { from { color: #fff; } } + .logo { background: url(/dot.png); } + `); + expect(css).not.toContain("'dark'] .call-view"); + expect(css).toContain(":root[data-theme='dark'] .after { color: #eeeeee; }"); + expect(css).not.toMatch(/dark'\] from/); + expect(css).not.toContain("'dark'] .logo"); +}); +it('resolves the system preference', () => { + expect(resolveTheme('system', true)).toBe('dark'); + expect(resolveTheme('system', false)).toBe('light'); + expect(resolveTheme('light', true)).toBe('light'); + expect(resolveTheme('dark', false)).toBe('dark'); +}); diff --git a/vite.config.ts b/vite.config.ts index 409451b8..41a739d9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,9 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import { darkTheme } from './src/build/dark-theme'; export default defineConfig({ plugins: [react()], + css: { postcss: { plugins: [darkTheme()] } }, build: { outDir: 'dist/client' }, server: { strictPort: true, From 79b4fb5fc50887e1f7741ad7af715cdab8dab017 Mon Sep 17 00:00:00 2001 From: jibraaan Date: Tue, 6 Oct 2026 03:32:08 +0500 Subject: [PATCH 2/3] fix: readable dark text on its own, stable call surface, theme without storage Review fixes for the dark mode PR. - Readable without the polish PR: text colors get a lightness floor in dark (AA on #1b1b1b-#2a2a2a surfaces), and a rule that sets both its text and background is checked as a pair and adjusted to 4.5:1 (better text extreme, then a darker mid-tone background if needed). Measured in Chromium with this PR alone: service setup text 2.44 -> 6.45:1, enabled Save label 3.28 -> 4.69:1. - Fixed call surface: rules in /* theme: fixed */ now get dark twins with unchanged values, so they keep the generated rules' specificity and the global button twin no longer paints the call controls (#1b1b1b -> unchanged transparent / 7% white, same as light). - The chosen theme is kept in memory, so Dark applies even when localStorage throws; persistence failure is separate. - package-lock.json is upstream's plus only the postcss devDependency line (restores the libc metadata). Co-Authored-By: Claude Opus 5.5 --- src/build/dark-theme.ts | 74 +++++++++++++++++++++++++++++++++--- src/client/theme.ts | 4 ++ tests/dark-theme.test.ts | 82 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 151 insertions(+), 9 deletions(-) diff --git a/src/build/dark-theme.ts b/src/build/dark-theme.ts index 785e9617..41bda554 100644 --- a/src/build/dark-theme.ts +++ b/src/build/dark-theme.ts @@ -60,6 +60,41 @@ export function darkColor(value: string) { const next = 0.95 - 0.73 * L ** 1.3; return hex(oklabToRgb([next, a * 0.85, b * 0.85]), alpha); } +// WCAG relative luminance and contrast, for checking generated pairs. +const luminance = (rgb: number[]) => { + const [r, g, b] = rgb.map(toLinear); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; +export function contrast(a: string, b: string) { + const [x, y] = [luminance(parseHex(a).rgb), luminance(parseHex(b).rgb)]; + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); +} +const withLightness = (value: string, next: (L: number) => number) => { + const { rgb, alpha } = parseHex(value); + const [L, a, b] = rgbToOklab(rgb); + return hex(oklabToRgb([next(L), a, b]), alpha); +}; +// Dark text sits on surfaces from #1b1b1b to about #2a2a2a; this floor +// keeps any text color at 4.5:1 or better there. +const TEXT_FLOOR = 0.72; +export const readableText = (value: string) => + withLightness(value, (L) => Math.max(L, TEXT_FLOOR)); +const LIGHT_TEXT = '#f2f2f2'; +const DARK_TEXT = '#141414'; +// A rule that sets both its text and background must stay readable after +// mapping: pick the better text extreme, then darken a mid-tone background +// until the pair reaches AA. +export function readablePair(text: string, background: string) { + if (contrast(text, background) >= 4.5) return { text, background }; + const best = [LIGHT_TEXT, DARK_TEXT].reduce((a, b) => + contrast(a, background) >= contrast(b, background) ? a : b, + ); + if (contrast(best, background) >= 4.5) return { text: best, background }; + let next = background; + while (contrast(LIGHT_TEXT, next) < 4.5) + next = withLightness(next, (L) => L - 0.02); + return { text: LIGHT_TEXT, background: next }; +} const named: Record = { white: '#ffffff', black: '#000000' }; const colorToken = /#[0-9a-fA-F]{3,8}\b|\b(?:white|black)\b/g; export function darkValue(prop: string, value: string) { @@ -93,19 +128,42 @@ export function darkSelector(selector: string) { }) .join(', '); } +const solid = (value: string) => { + const token = value.trim(); + const color = named[token] ?? token; + return /^#[0-9a-fA-F]{3,8}$/.test(color) ? color : undefined; +}; +function darkDeclarations(colors: Declaration[]) { + const mapped = colors.map((decl) => + decl.clone({ value: darkValue(decl.prop, decl.value) }), + ); + const text = mapped.find((decl) => decl.prop === 'color'); + const textColor = text && solid(text.value); + if (!text || !textColor) return mapped; + const background = mapped.find( + (decl) => /^background(-color)?$/.test(decl.prop) && solid(decl.value), + ); + if (!background) { + text.value = readableText(textColor); + return mapped; + } + const pair = readablePair(textColor, solid(background.value)!); + text.value = pair.text; + background.value = pair.background; + return mapped; +} export function darkTheme(): Plugin { return { postcssPlugin: 'opendots-dark-theme', Once(root) { let fixed = false; - const rules: Rule[] = []; + const rules: { rule: Rule; fixed: boolean }[] = []; root.each(function visit(node): void { if (node.type === 'comment') { if (/^\s*theme:\s*fixed\s*$/.test(node.text)) fixed = true; if (/^\s*theme:\s*end\s*$/.test(node.text)) fixed = false; return; } - if (fixed) return; if (node.type === 'atrule') { // Animations interpolate their own colors; leave them alone. if (!/keyframes$/.test((node as AtRule).name)) @@ -113,17 +171,21 @@ export function darkTheme(): Plugin { return; } if (node.type === 'rule' && !node.selector.startsWith(DARK)) - rules.push(node); + rules.push({ rule: node, fixed }); }); - for (const rule of rules) { + for (const { rule, fixed } of rules) { const colors = rule.nodes.filter( (node): node is Declaration => node.type === 'decl' && themed(node), ); if (!colors.length) continue; const dark = rule.clone({ selector: darkSelector(rule.selector) }); dark.removeAll(); - for (const decl of colors) - dark.append(decl.clone({ value: darkValue(decl.prop, decl.value) })); + // Fixed surfaces keep their colors, but still get a twin with the + // same specificity so generated global rules cannot override them. + for (const decl of fixed + ? colors.map((decl) => decl.clone()) + : darkDeclarations(colors)) + dark.append(decl); rule.after(dark); } }, diff --git a/src/client/theme.ts b/src/client/theme.ts index 850cbde3..a4fed90b 100644 --- a/src/client/theme.ts +++ b/src/client/theme.ts @@ -1,9 +1,12 @@ export type ThemePreference = 'system' | 'light' | 'dark'; const key = 'opendots-theme'; +// The choice for this page, even if storage refuses to keep it. +let chosen: ThemePreference | undefined; const query = () => window.matchMedia('(prefers-color-scheme: dark)'); // Storage can be unavailable (private windows, blocked site data); the theme // then follows the system for this visit. export function themePreference(): ThemePreference { + if (chosen) return chosen; try { const value = localStorage.getItem(key); if (value === 'light' || value === 'dark') return value; @@ -23,6 +26,7 @@ function apply() { ?.setAttribute('content', theme === 'dark' ? '#1b1b1b' : '#f8f7f4'); } export function setThemePreference(preference: ThemePreference) { + chosen = preference; try { if (preference === 'system') localStorage.removeItem(key); else localStorage.setItem(key, preference); diff --git a/tests/dark-theme.test.ts b/tests/dark-theme.test.ts index 2fe25694..4e61a0f1 100644 --- a/tests/dark-theme.test.ts +++ b/tests/dark-theme.test.ts @@ -1,6 +1,7 @@ -import { expect, it } from 'vitest'; +import { expect, it, vi } from 'vitest'; import postcss from 'postcss'; import { + contrast, darkColor, darkSelector, darkTheme, @@ -62,7 +63,7 @@ it('keeps the light cascade order when later rules have no literal color', () => ":root[data-theme='dark'] .primary { background: var(--accent); }", ); }); -it('leaves fixed surfaces, keyframes, and images alone', () => { +it('keeps fixed surfaces as they are, ahead of generated global rules', () => { const css = run(` /* theme: fixed */ .call-view { background: #1c544c; } @@ -71,7 +72,11 @@ it('leaves fixed surfaces, keyframes, and images alone', () => { @keyframes glow { from { color: #fff; } } .logo { background: url(/dot.png); } `); - expect(css).not.toContain("'dark'] .call-view"); + // Same colors, but with the generated rules' specificity, so a global + // button twin cannot repaint the call controls. + expect(css).toContain( + ":root[data-theme='dark'] .call-view { background: #1c544c; }", + ); expect(css).toContain(":root[data-theme='dark'] .after { color: #eeeeee; }"); expect(css).not.toMatch(/dark'\] from/); expect(css).not.toContain("'dark'] .logo"); @@ -82,3 +87,74 @@ it('resolves the system preference', () => { expect(resolveTheme('light', true)).toBe('light'); expect(resolveTheme('dark', false)).toBe('dark'); }); + +const declared = (css: string, selector: string, prop: string) => + css.match( + new RegExp( + `${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} \\{[^}]*?${prop}: (#[0-9a-f]{6})`, + ), + )?.[1]; +it('keeps text readable on its own, without the separate polish layer', () => { + // The Settings service-setup note: text and surface come from two rules. + const note = run(` + .config-note { background: #f5f4f8; } + .config-note p { color: #a2a0ad; } + `); + const surface = declared(note, "'dark'] .config-note", 'background')!; + const text = declared(note, "'dark'] .config-note p", 'color')!; + expect(contrast(text, surface)).toBeGreaterThanOrEqual(4.5); + // Any text color stays readable on the darkest and lightest dark surfaces. + for (const light of ['#ffffff', '#f8f7f4', '#e9e8e6']) + for (const ink of ['#a2a0ad', '#c7cee9', '#b0b0b7', '#999eaa']) { + const css = run(`.a { color: ${ink}; }`); + expect( + contrast(declared(css, "'dark'] .a", 'color')!, darkColor(light)), + ).toBeGreaterThanOrEqual(4.5); + } +}); +it("keeps a rule's own text and background pair at AA", () => { + // The enabled Save button: mid-tone lavender with white text. + for (const background of ['#7689d3', '#8292d6', '#496d61', '#c4473a']) { + const css = run(`.primary { background: ${background}; color: white; }`); + const fill = declared(css, "'dark'] .primary", 'background')!; + const text = declared(css, "'dark'] .primary", 'color')!; + expect(contrast(text, fill)).toBeGreaterThanOrEqual(4.5); + } +}); +it('applies the chosen theme even when storage is unavailable', async () => { + const dataset: Record = {}; + const listeners: (() => void)[] = []; + const failing = () => { + throw new Error('SecurityError'); + }; + vi.stubGlobal('localStorage', { + getItem: failing, + setItem: failing, + removeItem: failing, + }); + vi.stubGlobal('window', { + matchMedia: () => ({ + matches: false, + addEventListener: (_: string, listener: () => void) => + listeners.push(listener), + }), + }); + vi.stubGlobal('document', { + documentElement: { dataset }, + querySelector: () => null, + }); + try { + vi.resetModules(); + const theme = await import('../src/client/theme'); + theme.watchSystemTheme(); + expect(dataset.theme).toBe('light'); + theme.setThemePreference('dark'); + expect(dataset.theme).toBe('dark'); + expect(theme.themePreference()).toBe('dark'); + // A system change does not undo the choice for this page. + listeners.forEach((listener) => listener()); + expect(dataset.theme).toBe('dark'); + } finally { + vi.unstubAllGlobals(); + } +}); From 4e9ba397e78c8c2968249fa94333ac5af1f8278c Mon Sep 17 00:00:00 2001 From: jibraaan Date: Wed, 7 Oct 2026 03:28:27 +0500 Subject: [PATCH 3/3] fix: check variable-backed text pairs; keep fixed surfaces fully light Review fixes for the dark mode PR, against current main (including #82). - Variable backgrounds: the plugin collects custom-property colors and resolves `background: var(--x)` when checking a rule's text/background pair. The enabled Save button (`var(--accent)`, white text) now gets dark text on its light accent: Chromium 1.44 -> 9.98:1. The variable stays a variable unless AA requires changing it. - Fixed surfaces: `/* theme: fixed .call-view */` names the container, every generated rule excludes it (`:not(.call-view, .call-view *)`, placed before any pseudo-element, including legacy `:before`), and fixed rules get no dark rules. Global rules such as the focus ring keep their light values inside: keyboard-focused call control in dark is #b8c4fa 3px, identical to light (was #373e64). - Tests cover the variable pair, focus-ring exclusion, ::placeholder, :before, and ::-webkit-scrollbar-thumb:hover selector placement. Co-Authored-By: Claude Opus 5.5 --- src/build/dark-theme.ts | 75 +++++++++++++++++++++++++++++++++------- src/client/style.css | 2 +- tests/dark-theme.test.ts | 47 +++++++++++++++++++++---- 3 files changed, 105 insertions(+), 19 deletions(-) diff --git a/src/build/dark-theme.ts b/src/build/dark-theme.ts index 41bda554..6129aa93 100644 --- a/src/build/dark-theme.ts +++ b/src/build/dark-theme.ts @@ -133,7 +133,18 @@ const solid = (value: string) => { const color = named[token] ?? token; return /^#[0-9a-fA-F]{3,8}$/.test(color) ? color : undefined; }; -function darkDeclarations(colors: Declaration[]) { +// Light values of custom properties, so `var(--x)` backgrounds can be +// checked against their text in dark (last definition wins, like :root). +type Variables = Map; +const variable = /^var\(\s*(--[\w-]+)\s*\)$/; +function backgroundColor(value: string, variables: Variables) { + const literal = solid(value); + if (literal) return { color: literal, literal: true }; + const name = value.trim().match(variable)?.[1]; + const light = name && variables.get(name); + return light ? { color: darkColor(light), literal: false } : undefined; +} +function darkDeclarations(colors: Declaration[], variables: Variables) { const mapped = colors.map((decl) => decl.clone({ value: darkValue(decl.prop, decl.value) }), ); @@ -141,26 +152,58 @@ function darkDeclarations(colors: Declaration[]) { const textColor = text && solid(text.value); if (!text || !textColor) return mapped; const background = mapped.find( - (decl) => /^background(-color)?$/.test(decl.prop) && solid(decl.value), + (decl) => + /^background(-color)?$/.test(decl.prop) && + backgroundColor(decl.value, variables), ); if (!background) { text.value = readableText(textColor); return mapped; } - const pair = readablePair(textColor, solid(background.value)!); + const fill = backgroundColor(background.value, variables)!; + const pair = readablePair(textColor, fill.color); text.value = pair.text; - background.value = pair.background; + // A variable background stays a variable unless it must change for AA. + if (fill.literal || pair.background !== fill.color) + background.value = pair.background; return mapped; } +// Generated rules skip fixed surfaces and everything inside them, so global +// rules (like a focus ring) keep their light values there. +export function excludeFixed(selector: string, containers: string[]) { + if (!containers.length) return selector; + const not = `:not(${containers.flatMap((item) => [item, `${item} *`]).join(', ')})`; + return selector + .split(',') + .map((part) => { + const item = part.trim(); + // :not() must come before any pseudo-element, including the legacy + // single-colon forms and ones followed by states (::thumb:hover). + const pseudo = item.search( + /::[\w-]+|:(?:before|after|first-line|first-letter)(?![\w-])/, + ); + return pseudo < 0 + ? `${item}${not}` + : `${item.slice(0, pseudo)}${not}${item.slice(pseudo)}`; + }) + .join(', '); +} export function darkTheme(): Plugin { return { postcssPlugin: 'opendots-dark-theme', Once(root) { let fixed = false; + const containers: string[] = []; + const variables: Variables = new Map(); const rules: { rule: Rule; fixed: boolean }[] = []; root.each(function visit(node): void { if (node.type === 'comment') { - if (/^\s*theme:\s*fixed\s*$/.test(node.text)) fixed = true; + // `/* theme: fixed .call-view */` names the surface it keeps. + const start = node.text.match(/^\s*theme:\s*fixed\s+(.+?)\s*$/); + if (start) { + fixed = true; + containers.push(start[1]); + } if (/^\s*theme:\s*end\s*$/.test(node.text)) fixed = false; return; } @@ -170,21 +213,29 @@ export function darkTheme(): Plugin { (node as AtRule).each(visit); return; } - if (node.type === 'rule' && !node.selector.startsWith(DARK)) + if (node.type === 'rule' && !node.selector.startsWith(DARK)) { rules.push({ rule: node, fixed }); + if (!fixed) + node.each((decl) => { + if (decl.type === 'decl' && decl.prop.startsWith('--')) { + const color = solid(decl.value); + if (color) variables.set(decl.prop, color); + } + }); + } }); for (const { rule, fixed } of rules) { + // Fixed surfaces keep their light colors and get no dark rules. + if (fixed) continue; const colors = rule.nodes.filter( (node): node is Declaration => node.type === 'decl' && themed(node), ); if (!colors.length) continue; - const dark = rule.clone({ selector: darkSelector(rule.selector) }); + const dark = rule.clone({ + selector: excludeFixed(darkSelector(rule.selector), containers), + }); dark.removeAll(); - // Fixed surfaces keep their colors, but still get a twin with the - // same specificity so generated global rules cannot override them. - for (const decl of fixed - ? colors.map((decl) => decl.clone()) - : darkDeclarations(colors)) + for (const decl of darkDeclarations(colors, variables)) dark.append(decl); rule.after(dark); } diff --git a/src/client/style.css b/src/client/style.css index e4da2bb9..6cf572a9 100644 --- a/src/client/style.css +++ b/src/client/style.css @@ -3807,7 +3807,7 @@ h3 { color: #a33e35; } -/* theme: fixed */ +/* theme: fixed .call-view */ /* A focused voice surface, with chat one click away. */ .call-view { position: fixed; diff --git a/tests/dark-theme.test.ts b/tests/dark-theme.test.ts index 4e61a0f1..b9072b80 100644 --- a/tests/dark-theme.test.ts +++ b/tests/dark-theme.test.ts @@ -63,21 +63,43 @@ it('keeps the light cascade order when later rules have no literal color', () => ":root[data-theme='dark'] .primary { background: var(--accent); }", ); }); -it('keeps fixed surfaces as they are, ahead of generated global rules', () => { +it('keeps fixed surfaces in their light colors, untouched by global rules', () => { const css = run(` - /* theme: fixed */ + button:focus-visible { outline: 3px solid #b8c4fa; } + input::placeholder { color: #999; } + .row:not(:last-child):before { background: #eee; } + ::-webkit-scrollbar-thumb:hover { background: #ccc; } + /* theme: fixed .call-view */ .call-view { background: #1c544c; } + .call-view button { color: #fff; } /* theme: end */ .after { color: #000; } @keyframes glow { from { color: #fff; } } .logo { background: url(/dot.png); } `); - // Same colors, but with the generated rules' specificity, so a global - // button twin cannot repaint the call controls. + // Nothing is generated for the fixed surface itself... + expect(css).not.toMatch(/dark'\] \.call-view \{/); + // ...and generated global rules skip it, so a keyboard focus ring inside + // keeps its light color (the reviewer's #373e64 regression). expect(css).toContain( - ":root[data-theme='dark'] .call-view { background: #1c544c; }", + ":root[data-theme='dark'] button:focus-visible:not(.call-view, .call-view *) { outline: 3px solid #", + ); + expect(css).toContain( + ":root[data-theme='dark'] input:not(.call-view, .call-view *)::placeholder", + ); + expect(css).toContain( + ":root[data-theme='dark'] .after:not(.call-view, .call-view *) { color: #eeeeee; }", + ); + expect(css).toContain( + ":root[data-theme='dark'] .row:not(:last-child):not(.call-view, .call-view *):before", + ); + expect(css).toContain( + ":root[data-theme='dark'] :not(.call-view, .call-view *)::-webkit-scrollbar-thumb:hover", + ); + // A pseudo-element cannot be followed by :not() (the minifier rejects it). + expect(css).not.toMatch( + /(::[\w-]+|:(before|after|first-line|first-letter))[^,{]*:not\(\.call-view/, ); - expect(css).toContain(":root[data-theme='dark'] .after { color: #eeeeee; }"); expect(css).not.toMatch(/dark'\] from/); expect(css).not.toContain("'dark'] .logo"); }); @@ -158,3 +180,16 @@ it('applies the chosen theme even when storage is unavailable', async () => { vi.unstubAllGlobals(); } }); +it('checks text against a variable background in the same rule', () => { + // Current main: the enabled Save button is background: var(--accent). + const css = run(` + .template-app { --accent: #242424; } + .template-app .primary { background: var(--accent); color: #fff; } + `); + const text = declared(css, "'dark'] .template-app .primary", 'color')!; + // The variable keeps its own dark mapping; the text is chosen for it. + expect(css).toMatch( + /\.template-app \.primary \{ background: var\(--accent\);/, + ); + expect(contrast(text, darkColor('#242424'))).toBeGreaterThanOrEqual(4.5); +});