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' && (
+
+ )}
{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);
+});