Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ CopilotKit SDK telemetry collects usage metadata separately from conversation pe
| 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 |
| Connections | Per-Dot MCP servers, per-tool access, and owner approval for non-read-only actions |
| 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 |
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
content="OpenDots is a self-hosted template for your documents and personal assistants."
/>
<title>OpenDots</title>
<script src="/theme.js"></script>
</head>
<body>
<div id="root"></div>
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"cross-env": "^10.1.0",
"eslint": "^10.11.0",
"eslint-plugin-react-hooks": "^7.1.1",
"postcss": "^8.5.28",
"prettier": "^3.9.9",
"react-test-renderer": "^19.3.0",
"tsx": "^4.23.15",
Expand Down
13 changes: 13 additions & 0 deletions public/theme.js
Original file line number Diff line number Diff line change
@@ -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');
})();
245 changes: 245 additions & 0 deletions src/build/dark-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
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;
Comment thread
jerelvelarde marked this conversation as resolved.
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<string, string> = { 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(', ');
}
const solid = (value: string) => {
const token = value.trim();
const color = named[token] ?? token;
return /^#[0-9a-fA-F]{3,8}$/.test(color) ? color : undefined;
};
// 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<string, string>;
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) }),
);
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) &&
backgroundColor(decl.value, variables),
);
if (!background) {
text.value = readableText(textColor);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Check text against CSS variable backgrounds. With current main, Settings’ enabled Save button uses background: var(--accent). This branch treats it as having no background, producing #a4a4a4 text on the mapped #c5c5c5 accent: Chromium measures only 1.44:1 contrast. Open Settings in dark mode after integrating current main to reproduce. Resolve or explicitly handle the variable-backed text/background pair and test the resulting computed contrast.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e9ba39, verified against current main with #82 merged in.

The plugin now collects custom-property colors from the stylesheet and resolves background: var(--x) when it checks a rule's text/background pair. For .template-app .primary { background: var(--accent); color: #fff }, the text is now chosen against --accent's dark value, instead of being lifted as if it sat on the dark canvas. The background stays var(--accent) unless AA would require changing it.

Measured in Chromium in dark Settings, from computed styles with backgrounds composited:

  • Enabled Save: text #1b1b1b on #c5c5c5, 1.44 → 9.98:1
  • Service setup text: 4.83:1

New test: checks text against a variable background in the same rule.

return mapped;
}
const fill = backgroundColor(background.value, variables)!;
const pair = readablePair(textColor, fill.color);
text.value = pair.text;
// 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') {
// `/* 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;
}
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({ 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: excludeFixed(darkSelector(rule.selector), containers),
});
dark.removeAll();
for (const decl of darkDeclarations(colors, variables))
dark.append(decl);
rule.after(dark);
}
},
};
}
darkTheme.postcss = true;
33 changes: 33 additions & 0 deletions src/client/WorkspaceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { useEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import type { Dot, Memory, State, WorkspaceState } from '../shared/types';
import { ConnectionsSection } from './ConnectionsSection';
import {
setThemePreference,
themePreference,
type ThemePreference,
} from './theme';
export type Dialog =
| { type: 'space' }
| { type: 'dot'; dot?: Dot; spaceId: string }
Expand Down Expand Up @@ -56,6 +61,7 @@ export function WorkspaceDialog({
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [theme, setTheme] = useState(themePreference);
const container = useRef<HTMLElement>(null);
useEffect(() => {
const previous =
Expand Down Expand Up @@ -363,6 +369,33 @@ export function WorkspaceDialog({
</p>
</>
)}
{dialog.type === 'settings' && (
<fieldset className="appearance-fields">
<legend>Appearance</legend>
<div className="segmented" role="radiogroup">
{(['system', 'light', 'dark'] as ThemePreference[]).map(
(option) => (
<label key={option}>
<input
type="radio"
name="theme"
value={option}
checked={theme === option}
onChange={() => {
setTheme(option);
setThemePreference(option);
}}
/>
<span>{option[0].toUpperCase() + option.slice(1)}</span>
</label>
),
)}
</div>
<p className="muted">
Saved in this browser. System follows your device.
</p>
</fieldset>
)}
{dialog.type === 'settings' && (
<div className="config-note">
<strong>Service setup</strong>
Expand Down
2 changes: 2 additions & 0 deletions src/client/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<React.StrictMode>
<App />
Expand Down
Loading
Loading