initial colors and theme improvements (#7009)

## What this does

Consolidates the frontend's colour/theme system into a small,
well-defined token layer and reworks the theme picker. The goal was a
minimal, scalable set of semantic tokens that the editor **and** the
Processor/portal (and Storybook) all share, plus a theme model that's
easy to reason about.

## Token architecture (`core/theme/`)

A four-file layer, imported once via `index.css`:

| File | Role |
|---|---|
| `primitives.css` | The raw palette — the **only** place literal
colours live (neutral ramps `--p-gray-*`/`--p-zinc-*` + status hues). |
| `colors.css` | ~21 semantic `--c-*` tokens (surfaces, text, borders,
primary, status) mapped from primitives per theme. **Reference these.**
|
| `compat.css` | Legacy names (`--bg-*`, `--text-*`, `--color-*`)
aliased onto `--c-*` via `:root:root` so ~200 existing files keep
working. |
| `dimensions.css` | All non-colour tokens (spacing, radius, z-index,
type, motion) — single source, resolving prior collisions. |

A blocking linter (`scripts/lint/theme-lint.mjs`, run in
`frontend:lint`) enforces "literals only in `primitives.css`" within
`core/theme/`, and has a non-blocking WCAG contrast report. See
`core/theme/README.md`.

## Theme model

- **Mode** (`light` / `dark` / `system`) and **accent** are independent.
Each mode has its own accent (`lightPrimary` / `darkPrimary`).
- The editor is always `data-app-theme="custom"`; `ThemeProvider`
injects the accent as `--user-primary` and sets `data-accent`.
- **Two accent states:**
- A **colour** (preset or custom hex) → tints every surface that hue
(whole-app theming).
- The **`default`** sentinel → neutral surfaces (white/grey light, zinc
black/grey dark) with blue buttons, no tint. (`data-accent="default"`
opts surfaces out of the tint.)
- Accent contrast guardrails (`utils/customPrimary.ts`): lightness
clamps so an accent can't collapse into the base, a contrast-picked
on-primary foreground, and an accent-as-foreground variant so accent
text is never dark-on-dark.

## Theme picker (Settings → General)

- 3×5 grid: a distinct **Default** icon chip (not a colour) + 14 curated
accents, in a dropdown per mode.
- **Custom** colour via the shared `ColorInput`, with a live gamut clamp
(`clampValue`) that refuses white/grey/black — the picker handle sticks
at the boundary and preserves the working hue at achromatic extremes.
- "Restore theme to default" resets both modes.

## Other

- Dark mode is a true neutral zinc (no navy "midnight" tint); the
Mantine dark ramp and Tailwind dark channels were neutralised to match.
- Pre-paint inline script in `index.html` applies theme + accent before
first paint (no FOUC); portal and editor now share the same
`preferences.theme` source of truth.
- High-visibility surfaces migrated to tokens (FAB, landing upload
buttons, portal hero banners); scattered per-component colour swaps were
intentionally **left for a follow-up** to keep this PR focused.

## Testing

- `task frontend:check:all` (typecheck all variants + eslint + prettier
+ colour-lint) green.
- Verified light/dark, default vs tinted accents, and the custom clamp
via computed styles in the dev preview.

> Note: the `prerender-og` build step failing in the e2e/deploy jobs is
unrelated to this diff — it's in `vite.config.ts` (untouched here) and
builds cleanly locally.
This commit is contained in:
EthanHealy01
2026-07-15 12:11:00 +00:00
committed by GitHub
parent 7f01bcdc44
commit b86e963c9d
33 changed files with 1611 additions and 927 deletions
+13
View File
@@ -203,6 +203,19 @@ tasks:
cmds:
- task: lint:eslint
- task: lint:dpdm
- task: lint:colors
lint:colors:
desc: "Enforce theme tokens — colours in core/theme route through the palette"
deps: [install]
cmds:
- node editor/scripts/lint/theme-lint.mjs
contrast:
desc: "Report low-contrast theme token pairs (warning only, never blocks)"
deps: [install]
cmds:
- node editor/scripts/lint/theme-lint.mjs contrast
lint:eslint:
desc: "Run ESLint linting"
+2
View File
@@ -155,6 +155,8 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
Before touching colours or theming (tokens, dark mode, accent colours), read @frontend/editor/src/core/theme/README.md — it explains the palette/`--c-*` token system and the rule that literal colours live only in `primitives.css`.
```typescript
// ✅ CORRECT - Use @app/* for all imports
import { AppLayout } from "@app/components/AppLayout";
+4
View File
@@ -38,6 +38,10 @@ const config: StorybookConfig = {
// modules (e.g. the auth supabase client that moved into proprietary).
"@proprietary": resolve(__dirname, "../editor/src/proprietary"),
"@core": resolve(__dirname, "../editor/src/core"),
// Public assets (e.g. the en-US translation TOML loaded ?raw by preview.tsx).
// No src alias covers public/, so this lets the config use an alias rather
// than a relative path.
"@public": resolve(__dirname, "../editor/public"),
};
// Editor stories import via @app/* (proprietary→core fallback), @core/* and
// @proprietary/*. Resolve them exactly the way the editor's own build does —
+17 -2
View File
@@ -24,11 +24,11 @@ import { initReactI18next } from "react-i18next";
import { parse as parseToml } from "smol-toml";
// Load the real English copy so stories render human text, not raw keys. Bundled
// synchronously via ?raw so it's present on the very first render (no async flash).
// eslint-disable-next-line no-restricted-imports -- Storybook-only: read the public i18n asset; no @-alias covers editor/public/.
import enTranslationToml from "../editor/public/locales/en-US/translation.toml?raw";
import enTranslationToml from "@public/locales/en-US/translation.toml?raw";
import "@mantine/core/styles.css";
import "@core/tokens/tokens.css";
import "@core/theme/index.css";
import "@core/tokens/base.css";
// Storybook-only: init react-i18next with the real English resources parsed from
@@ -124,6 +124,20 @@ function ThemeBridge({
return <>{children}</>;
}
/**
* Sets the theme attributes colors.css needs — always `data-app-theme="custom"`
* with the fixed default accent (data-accent="default"), matching the editor.
*/
function SchemeSetup({ scheme }: { scheme: "light" | "dark" }) {
useEffect(() => {
const root = document.documentElement;
root.setAttribute("data-app-theme", "custom");
root.setAttribute("data-accent", "default");
root.setAttribute("data-mantine-color-scheme", scheme);
}, [scheme]);
return null;
}
const withProviders: Decorator = (Story, context) => {
const tier = (context.globals.tier as Tier) ?? "pro";
const linkState =
@@ -138,6 +152,7 @@ const withProviders: Decorator = (Story, context) => {
return (
<MemoryRouter initialEntries={["/"]}>
<ThemeProvider>
<SchemeSetup scheme={colorScheme} />
<ThemeBridge theme={colorScheme}>
<SuiProvider colorScheme={colorScheme}>
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
+2 -1
View File
@@ -12,7 +12,8 @@
],
"@core/*": ["../editor/src/core/*"],
"@proprietary/*": ["../editor/src/proprietary/*"],
"@portal/*": ["../editor/src/portal/*"]
"@portal/*": ["../editor/src/portal/*"],
"@public/*": ["../editor/public/*"]
},
"resolveJsonModule": true,
"esModuleInterop": true,
+28
View File
@@ -14,6 +14,34 @@
<link rel="manifest" href="manifest.json" />
<title>Stirling PDF</title>
<!-- Apply theme before first paint (no FOUC); mirrors ThemeProvider, which then refines it. -->
<script>
(function () {
try {
var p = JSON.parse(
localStorage.getItem("stirlingpdf_preferences") || "{}",
);
var mode = p.theme || "system";
var scheme =
mode === "light"
? "light"
: mode === "dark"
? "dark"
: window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
var r = document.documentElement;
r.setAttribute("data-theme", scheme);
r.setAttribute("data-app-theme", "custom");
r.setAttribute("data-accent", "default");
r.setAttribute("data-mantine-color-scheme", scheme);
} catch (e) {
/* first visit / unavailable storage: ThemeProvider handles it */
}
})();
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
@@ -8997,9 +8997,18 @@ languageDescription = "Choose the display language"
logout = "Log out"
setAsDefault = "Set as Default"
theme = "Theme"
themeAccent = "Accent colour"
themeAccentCustom = "Custom colour"
themeAccentCustomHint = "Custom (defaults recommended)"
themeAccentDark = "Dark mode accent colour"
themeAccentDefault = "Default"
themeAccentDefaultHint = "Default theme (recommended)"
themeAccentDescription = "Buttons, links and highlights follow it, and it subtly tints the app. Light and dark each have their own. System uses whichever is active."
themeAccentLight = "Light mode accent colour"
themeDark = "Dark"
themeDescription = "Choose light, dark, or follow your system"
themeDescription = "Choose light, dark, or follow your system so it switches automatically."
themeLight = "Light"
themeReset = "Restore theme to default"
themeSystem = "System"
title = "General"
user = "User"
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env node
// Theme colour lint — guards the theme SYSTEM (core/theme/). Two modes:
//
// node theme-lint.mjs enforce: literal colours live ONLY in
// primitives.css; colors/compat/dimensions must
// reference tokens; no duplicate primitives.
// (blocking)
// node theme-lint.mjs contrast warn-only WCAG contrast report (never blocks)
//
// Scope is deliberately just core/theme/ — the palette + token layer this PR
// owns, which is clean, so no baseline file is needed. Enforcing "no hardcoded
// colours" across the whole app (260+ existing sites) is a separate migration.
//
// Structural black / white / transparent (shadows, scrims) are always allowed.
import { readFileSync, readdirSync } from "node:fs";
import { relative, resolve, join } from "node:path";
const THEME = resolve(process.cwd(), "editor/src/core/theme");
const PRIMITIVES = "editor/src/core/theme/primitives.css";
// Fixed list of theme CSS files to check, so every read takes a constant path
// (no directory-listing feeding into a file read). readdir is used only to fail
// if a new .css is added without being registered — coverage can't silently
// lapse — but its output is never passed to readFileSync.
const THEME_FILES = [
"primitives.css",
"colors.css",
"compat.css",
"dimensions.css",
"index.css",
];
// ── colour helpers ─────────────────────────────────────────────────────────
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const FUNC_RE = /\b(?:rgba?|hsla?)\(\s*[^)]*\)/g;
const NAMED_RE =
/\b(?:white|black|red|green|blue|orange|yellow|purple|gray|grey|silver|transparent)\b/g;
function expandHex(hex) {
let h = hex.slice(1).toLowerCase();
if (h.length === 3) h = [...h].map((c) => c + c).join("");
if (h.length === 4) h = [...h].map((c) => c + c).join("");
return "#" + h;
}
function normalizeColor(raw) {
const s = raw.trim().toLowerCase();
if (s.startsWith("#")) return expandHex(s);
const nums = s.match(/[\d.]+%?/g);
if (!nums) return s;
return `${s.startsWith("hsl") ? "hsl" : "rgb"}(${nums.join(",")})`;
}
function isStructuralColor(norm) {
return (
norm === "transparent" ||
/^#000000(00)?$/.test(norm) ||
/^#ffffff(ff)?$/.test(norm) ||
/^rgb\(0,0,0[,)]/.test(norm) ||
/^rgb\(255,255,255[,)]/.test(norm)
);
}
function isStructuralName(name) {
return /^(?:white|black|transparent)$/i.test(name.trim());
}
function stripComments(text) {
return text.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
}
// ── enforce: literals only in primitives.css, no duplicate primitives ────────
function check() {
const violations = [];
const primitiveValues = new Map();
const lineOf = (text, index) => text.slice(0, index).split("\n").length;
// Fail if a theme .css exists that isn't registered above (readdir is only
// compared here — never used to build a path passed to readFileSync).
const known = new Set(THEME_FILES);
for (const name of readdirSync(THEME)) {
if (name.endsWith(".css") && !known.has(name)) {
violations.push({
file: relative(process.cwd(), join(THEME, name)),
line: 1,
msg: `unregistered theme CSS — add "${name}" to THEME_FILES in theme-lint.mjs`,
});
}
}
for (const name of THEME_FILES) {
const rel = relative(process.cwd(), join(THEME, name));
const isPrimitives = rel === PRIMITIVES;
const text = stripComments(readFileSync(join(THEME, name), "utf8"));
for (const re of [HEX_RE, FUNC_RE]) {
re.lastIndex = 0;
let m;
while ((m = re.exec(text)) !== null) {
const norm = normalizeColor(m[0]);
if (isStructuralColor(norm)) continue;
if (isPrimitives) {
if (primitiveValues.has(norm)) {
violations.push({
file: rel,
line: lineOf(text, m.index),
msg: `duplicate primitive value ${norm} (also ${primitiveValues.get(norm)})`,
});
} else {
primitiveValues.set(norm, m[0]);
}
} else {
violations.push({
file: rel,
line: lineOf(text, m.index),
msg: `raw colour ${m[0]} — define it in primitives.css and use var()`,
});
}
}
}
// Named colours, only in value position.
if (!isPrimitives) {
text.split("\n").forEach((line, i) => {
const colon = line.indexOf(":");
if (colon < 0 || /[{}]/.test(line)) return;
// Property must be a lone identifier — a custom prop (--x) OR a standard
// property (color, border) — so `color: red` is checked, not just tokens,
// while selectors (`.foo:hover`) with a colon are skipped.
if (!/^\s*(?:--)?[a-z][a-z0-9-]*\s*$/i.test(line.slice(0, colon)))
return;
const value = line
.slice(colon + 1)
.replace(/--[a-z0-9-]+/gi, " ")
.replace(/url\([^)]*\)/g, " ")
.replace(/["'][^"']*["']/g, " ");
for (const nm of value.match(NAMED_RE) || []) {
if (isStructuralName(nm)) continue;
violations.push({
file: rel,
line: i + 1,
msg: `named colour "${nm}" — define it in primitives.css and use var()`,
});
}
});
}
}
return violations;
}
// ── contrast report (warn-only): resolve --c-* per theme, check legibility ───
function reportContrast() {
const primitivesCss = readFileSync(join(THEME, "primitives.css"), "utf8");
const colorsCss = readFileSync(join(THEME, "colors.css"), "utf8");
const primitives = {};
for (const m of primitivesCss.matchAll(
/(--p-[a-z0-9-]+)\s*:\s*(#[0-9a-fA-F]{3,8})\s*;/g,
))
primitives[m[1]] = m[2];
const blocks = [];
for (const m of colorsCss.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
const decls = {};
for (const d of m[2].matchAll(/(--c-[a-z0-9-]+)\s*:\s*([^;]+);/g))
decls[d[1]] = d[2].trim();
if (Object.keys(decls).length)
blocks.push({ selector: m[1].trim(), decls });
}
// The editor always renders data-app-theme="custom"; the accent (--user-*) is
// injected at runtime, so seed the DEFAULT blue to resolve the custom tint
// statically. Themes = the ones that actually render: editor custom light/dark
// (data-app-theme="custom") and the portal's neutral dark (data-theme="dark").
const SEED = {
"--user-primary": "#3b82f6",
"--user-primary-on": "#ffffff",
"--user-accent-fg": "#3b82f6",
};
const pick = (re) => blocks.filter((b) => re.test(b.selector));
const lightBase = pick(/:root/);
const customBase = blocks.filter(
(b) =>
/app-theme="custom"/.test(b.selector) &&
!/color-scheme="dark"/.test(b.selector),
);
const customDark = pick(
/app-theme="custom"\]\[data-mantine-color-scheme="dark"/,
);
const midnight = pick(/data-theme="dark"/);
const themes = {
"editor light": [...lightBase, ...customBase],
"editor dark": [...lightBase, ...customBase, ...customDark],
"portal dark": [...lightBase, ...midnight],
};
const flatten = (list) =>
Object.assign({ ...SEED }, ...list.map((b) => b.decls));
const hexToRgb = (h) => {
h = h.replace("#", "");
if (h.length === 3)
h = h
.split("")
.map((c) => c + c)
.join("");
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: 1,
};
};
const over = (f, b) => ({
r: f.r * f.a + b.r * (1 - f.a),
g: f.g * f.a + b.g * (1 - f.a),
b: f.b * f.a + b.b * (1 - f.a),
a: 1,
});
const mix = (a, b, p) => ({
r: (a.r * p + b.r * (100 - p)) / 100,
g: (a.g * p + b.g * (100 - p)) / 100,
b: (a.b * p + b.b * (100 - p)) / 100,
a: 1,
});
// Resolve any token value: hex, rgb(a), var(--x[, fallback]) (--p-* → palette,
// else the theme map/seed), or color-mix(in srgb, A n%, B|transparent).
function resolveValue(v, t, seen) {
if (v == null) return null;
v = v.trim();
let m;
if (v.startsWith("#")) return hexToRgb(v);
if ((m = v.match(/^rgba?\(([^)]+)\)$/))) {
const n = m[1]
.split(/[,/\s]+/)
.map(Number)
.filter((x) => !Number.isNaN(x));
return { r: n[0], g: n[1], b: n[2], a: n[3] ?? 1 };
}
if ((m = v.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,\s*([\s\S]+))?\)$/))) {
return resolveVar(m[1], m[2], t, seen);
}
if ((m = v.match(/^color-mix\(in srgb,\s*(.+?)\s+(\d+)%\s*,\s*(.+)\)$/))) {
const a = resolveValue(m[1], t, seen);
if (!a) return null;
if (m[3].trim() === "transparent") return { ...a, a: +m[2] / 100 };
const b = resolveValue(m[3].trim(), t, seen);
return b ? mix(a, b, +m[2]) : null;
}
return null;
}
function resolveVar(name, fallback, t, seen) {
if (name.startsWith("--p-")) {
return primitives[name]
? hexToRgb(primitives[name])
: fallback
? resolveValue(fallback, t, seen)
: null;
}
if (!seen.has(name) && t[name] !== undefined) {
const next = new Set(seen).add(name);
const r = resolveValue(t[name], t, next);
if (r) return r;
}
return fallback ? resolveValue(fallback, t, seen) : null;
}
const resolve = (token, t) =>
resolveValue(t[token] ?? null, t, new Set([token]));
const lum = ({ r, g, b }) => {
const f = (v) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const contrast = (t1, t2, t) => {
const surface = resolve("--c-surface", t);
let a = resolve(t1, t);
let b = resolve(t2, t);
if (!a || !b || !surface) return null;
if (a.a < 1) a = over(a, surface);
if (b.a < 1) b = over(b, surface);
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
const PAIRS = [
["--c-text", "--c-surface", 4.5],
["--c-text-muted", "--c-surface", 4.5],
["--c-text-subtle", "--c-surface", 4.5],
["--c-text", "--c-bg", 4.5],
["--c-text-on-primary", "--c-primary", 3.0],
];
let warnings = 0;
console.log("contrast report (warning-only, default accent):\n");
for (const name of Object.keys(themes)) {
const t = flatten(themes[name]);
console.log(` ${name}`);
for (const [t1, t2, floor] of PAIRS) {
const r = contrast(t1, t2, t);
if (r == null) {
console.log(` ? ${t1} on ${t2} (unresolved)`);
continue;
}
if (r < floor) warnings++;
console.log(
` ${r < floor ? "⚠ " : " "}${r.toFixed(2).padStart(5)} (floor ${floor}) ${t1} on ${t2}`,
);
}
console.log("");
}
console.log(
warnings
? `${warnings} pair(s) below floor — review, not blocking.`
: "✓ all pairs clear their floor.",
);
}
// ── CLI ──────────────────────────────────────────────────────────────────────
if (process.argv.includes("contrast")) {
reportContrast();
process.exit(0); // never blocks
}
const violations = check();
if (violations.length) {
console.error(
`\n✖ theme-lint: ${violations.length} raw/duplicate colour(s) in core/theme/:\n`,
);
for (const v of violations) console.error(` ${v.file}:${v.line} ${v.msg}`);
console.error(
`\nDefine every colour once in core/theme/primitives.css and reference it with var(--p-…).\n`,
);
process.exit(1);
}
console.log(
"✓ theme-lint: core/theme colours all route through the primitive palette",
);
@@ -332,7 +332,7 @@
max-width: 260px;
height: calc(310px - 0.5rem);
margin: 0.5rem auto 0;
background: var(--file-card-bg);
background: var(--c-bg);
border: 1.5px solid var(--border-default);
border-radius: 12px;
box-shadow: var(--shadow-md);
@@ -34,12 +34,7 @@ export function LandingActions({
<Button
className="landing-btn-primary"
leftSection={
<LocalIcon
icon={icons.uploadIconName}
width="1rem"
height="1rem"
style={{ color: "white" }}
/>
<LocalIcon icon={icons.uploadIconName} width="1rem" height="1rem" />
}
onClick={(e) => {
e.stopPropagation();
@@ -52,14 +47,7 @@ export function LandingActions({
<Button
variant="secondary"
className="landing-btn-secondary"
leftSection={
<LocalIcon
icon="add"
width="1rem"
height="1rem"
className="text-[var(--accent-interactive)]"
/>
}
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />}
onClick={(e) => {
e.stopPropagation();
openFilesModal();
@@ -112,7 +112,7 @@
/* ── Action buttons ──────────────────────────────────────── */
.landing-btn-primary {
background: var(--landing-hero-gradient) !important;
color: #ffffff !important;
color: var(--c-text-on-primary) !important;
border: none !important;
border-radius: 0.75rem !important;
font-weight: 600 !important;
@@ -10,9 +10,12 @@ export function LoadingFallback() {
alignItems: "center",
height: "100vh",
fontSize: "18px",
// Theme-aware so the splash follows light/dark instead of forcing white.
backgroundColor: "var(--mantine-color-body)",
color: "var(--mantine-color-text)",
// Use our own tokens, not --mantine-color-*: this splash renders inside
// Suspense before MantineProvider sets its scheme, so --mantine-color-body
// is still Mantine's light default (white flash in dark mode). --c-bg/
// --c-text come from the pre-paint attributes on <html>, so they're right.
backgroundColor: "var(--c-bg)",
color: "var(--c-text)",
}}
>
Loading...
@@ -20,6 +20,7 @@ import {
} from "@app/constants/theme";
// SUI shared design-system tokens (used by @app/ui); key on `data-theme`.
import "@app/tokens/tokens.css";
import "@app/theme/index.css";
interface ThemeContextType {
themeMode: ThemeMode;
@@ -68,12 +69,17 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
return () => media.removeEventListener("change", update);
}, [themeMode]);
// The theme preference resolved to a concrete light/dark scheme.
// The mode resolved to a concrete light/dark base.
const colorScheme = resolveColorScheme(themeMode, systemScheme);
// Mantine drives `data-mantine-color-scheme`; mirror it to `data-theme` for SUI tokens.
// Mirror the scheme to <html>. The accent is fixed to the default (neutral
// surfaces + blue buttons): data-accent="default" and no --user-* overrides,
// so colors.css resolves --c-primary to its static blue fallback.
useIsomorphicEffect(() => {
document.documentElement.setAttribute("data-theme", colorScheme);
const root = document.documentElement;
root.setAttribute("data-theme", colorScheme);
root.setAttribute("data-app-theme", "custom");
root.setAttribute("data-accent", "default");
}, [colorScheme]);
const value = useMemo<ThemeContextType>(
@@ -20,11 +20,11 @@ import { usePreferences } from "@app/contexts/PreferencesContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useTheme } from "@app/components/shared/ThemeProvider";
import LanguageSelector from "@app/components/shared/LanguageSelector";
import type { ThemeMode } from "@app/constants/theme";
import { type ThemeMode } from "@app/constants/theme";
import type { ToolPanelMode } from "@app/constants/toolPanel";
import type {
StartupView,
ViewerZoomSetting,
import {
type StartupView,
type ViewerZoomSetting,
} from "@app/services/preferencesService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import LocalIcon from "@app/components/shared/LocalIcon";
@@ -482,7 +482,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
<Text size="xs" c="dimmed" mt={4}>
{t(
"settings.general.themeDescription",
"Choose light, dark, or follow your system",
"Choose light, dark, or follow your system so it switches automatically.",
)}
</Text>
</div>
@@ -505,29 +505,33 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
]}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t("settings.general.language", "Language")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"settings.general.languageDescription",
"Choose the display language",
)}
</Text>
</div>
<LanguageSelector position="bottom-end" offset={6} />
</div>
</Stack>
</Paper>
{/* Language */}
<Paper withBorder p="md" radius="md">
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t("settings.general.language", "Language")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"settings.general.languageDescription",
"Choose the display language",
)}
</Text>
</div>
<LanguageSelector position="bottom-end" offset={6} />
</div>
</Paper>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div
+5 -5
View File
@@ -1,9 +1,8 @@
// Theme constants and utilities
// Stored theme preference. "system" follows the OS.
export type ThemeMode = "light" | "dark" | "system";
// The concrete scheme applied to the UI.
// The concrete light/dark base applied to Mantine + the neutral ramp.
export type ColorScheme = "light" | "dark";
// Detect the OS theme preference. Never throws: if the environment can't be
@@ -24,12 +23,13 @@ export function getSystemTheme(): ColorScheme {
}
}
// Resolve a theme preference to a concrete light/dark scheme.
// Falls back to systemScheme for unrecognised values (e.g. stale "rainbow").
// Resolve the theme MODE to the concrete light/dark base Mantine uses.
// "system" follows the OS; anything unrecognised falls back to it too.
export function resolveColorScheme(
mode: ThemeMode,
systemScheme: ColorScheme,
): ColorScheme {
if (mode === "light" || mode === "dark") return mode;
if (mode === "light") return "light";
if (mode === "dark") return "dark";
return systemScheme;
}
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
# Theme system (`core/theme/`)
Read this before touching colours or theming anywhere in the frontend.
## TL;DR rules
1. **Never write a raw colour** (`#hex`, `rgb()`, `hsl()`, or a named colour like `red`) in a component, inline style, or stylesheet. Use a token:
- `var(--c-…)` — a **semantic** token (preferred: `--c-text`, `--c-surface`, `--c-primary`, …).
- `var(--p-…)` — a **palette** primitive, only when no semantic token fits.
2. **The only file allowed to contain literal colours is `primitives.css`.** If you need a new hue, add it there first, then reference it.
3. Structural `black` / `white` / `transparent` (shadows, scrims, overlays) are allowed anywhere.
4. Colours must adapt to light/dark automatically. If you're reaching for a hardcoded colour "just for dark mode", you're doing it wrong — pick the right `--c-*` token.
5. `task frontend:lint:colors` enforces rules 13 **inside `core/theme/`** (see [Linter](#linter)).
## Files
| File | Role |
|---|---|
| `primitives.css` | **The palette.** 41 literal colours (`--p-*`) — one neutral ramp (`--p-gray-*` light, `--p-zinc-*` dark) + status hues (blue/green/amber/red). The ONLY place literals live. |
| `colors.css` | **Semantic tokens** (`--c-*`), mapped from primitives per theme. This is what you should reference. |
| `compat.css` | **Legacy-name aliases**`--c-*`. Lets old files keep their `--bg-surface` / `--color-text-1` / etc. names working. Uses `:root:root` so it out-ranks legacy files. |
| `dimensions.css` | Non-colour tokens: spacing, radius, z-index, type, motion. |
| `index.css` | Barrel that `@import`s the above. Imported by `ThemeProvider` (and Storybook). |
| `mantineTheme.ts` | Mantine theme object wiring. |
Two **legacy** colour files still exist outside this folder and are being phased out — prefer `--c-*` over their tokens, and don't add to them:
`core/styles/theme.css` (editor `--bg-*`/`--text-*` vocab) and `core/tokens/tokens.css` (SUI `--color-*` vocab, gradients, code palette). `compat.css` already overrides their surface/text/border tokens, so most of what they define is dead weight.
## Semantic tokens (`--c-*`)
Reference these, not primitives, wherever possible:
- **Surfaces (elevation):** `--c-bg` (canvas) < `--c-bg-raised` (sidebars/toolbars) < `--c-surface` (cards/modals) < `--c-surface-raised` < `--c-surface-sunken`; plus `--c-input-bg`, `--c-hover`, `--c-active`, `--c-overlay`.
- **Text:** `--c-text`, `--c-text-muted`, `--c-text-subtle`, `--c-text-on-primary` (foreground on a filled primary).
- **Borders:** `--c-border`, `--c-border-subtle`, `--c-border-strong`.
- **Accent:** `--c-primary`, `--c-primary-hover`, `--c-primary-subtle`, and `--c-accent-fg` (see below).
- **Status:** `--c-success`, `--c-danger`.
## Theme model
The **mode** and the **accent colour** are independent.
- `preferences.theme` is the mode: `light` | `dark` | `system` (System follows the OS). There is no separate "custom" or "midnight" mode any more.
- Light and dark each have their **own accent**: `preferences.lightPrimary` / `preferences.darkPrimary` (both default `#3b82f6` blue).
- `ThemeProvider` resolves the mode to a concrete `light`/`dark` base, picks that side's accent, and **always** sets `data-app-theme="custom"` on `<html>`. So the custom-tint blocks in `colors.css` are the only themed blocks that apply — the chosen accent drives every accent **and** a subtle app-wide surface tint. With the default blue the tint is near-neutral.
- Selection attributes on `<html>`: `data-theme` = `light|dark` (SUI + the tint blocks), `data-mantine-color-scheme` = `light|dark` (Mantine).
### Custom-theme contrast guardrails (`core/utils/customPrimary.ts`)
Because the accent is user-chosen, `deriveAccessiblePrimary(pick, base)` clamps it and injects three vars on `<html>`:
- `--user-primary``--c-primary`. Lightness-clamped so it can't collapse into the base (dark floor `L ≥ 0.42`, light ceil `L ≤ 0.6`). Used for **fills**.
- `--user-primary-on``--c-text-on-primary`. White by default; flips to **black only for genuinely light picks** (relative-luminance cutoff `0.62`, so saturated amber/green/cyan keep white text).
- `--user-accent-fg``--c-accent-fg`. The accent tuned as a **foreground** (text/icon on the app surface): forced light on dark bases (`L ≥ 0.62`), dark on light (`L ≤ 0.45`), so accent text never goes dark-on-dark.
**Rule of thumb:** a filled control's background uses `--c-primary` with its label on `--c-text-on-primary`; an accent used **as text/icon on a surface** (nav selection, links, tool-header text) uses `--c-accent-fg`. Because `compat.css` (`:root:root`) wins on specificity, accent-as-text legacy tokens are routed there as `var(--c-accent-fg, var(--c-primary))` — the fallback keeps non-custom builds on the raw primary.
The FAB / logo mark is a deliberate exception: it's pinned to white (`--p-white`), not the on-primary flip — a brand mark, not body text.
## Linter
One file: `editor/scripts/lint/theme-lint.mjs` (no baseline). Run via `task frontend:lint:colors` (part of `task frontend:lint`).
- **Default (blocking):** enforces "literals only in `primitives.css`; everything else in `core/theme/` references tokens; no duplicate primitives." Scope is deliberately just `core/theme/` — the layer this owns, which is clean.
- **`node theme-lint.mjs contrast`** (task `frontend:contrast`, non-blocking): WCAG contrast report for text-on-surface / on-primary pairs per theme.
App-wide "no hardcoded colours in components" is **not** enforced yet (there are 260+ legacy sites); that's a separate migration. Don't add new hardcoded colours regardless.
## Gotchas
- **`--mantine-*` vars are consumed by Mantine at runtime**, not via our `var()`. A source scan can't see their use — never delete them as "unused", and set them (not raw colours) when overriding Mantine.
- **`--accent-*` (categorical hues) are used dynamically** via `` `var(--accent-${hue})` `` in `utils/accentColors.ts`. A literal search won't find them — don't treat them as unused.
- **Tailwind consumes some vars** (`--gray-*`, `--color-*`, `--background`, `--border`) via `editor/tailwind.config.js` using `rgb(var(--x))`. That file is outside the usual scan roots — check it before removing those.
- **Specificity:** `compat.css` uses `:root:root` (0,2,0) to beat legacy files. If a token you set in `colors.css` (`html[data-app-theme="custom"]`, 0,1,1) isn't winning, a `:root:root` or a `[data-mantine-color-scheme]`-compound block is probably overriding it — set it in `compat.css` or the more specific block.
- **Adding a colour:** put the literal in `primitives.css`, map it to a `--c-*` in `colors.css` if it's a new semantic role, and reference the `--c-*` (or an existing `compat` alias) from components. Don't skip straight to a `--p-*` in a component unless there's genuinely no semantic fit.
+264
View File
@@ -0,0 +1,264 @@
/* COLORS — canonical semantic core (~21 --c-* tokens) mapped from primitives.css per theme; compat.css aliases legacy names onto these. Editor: always data-app-theme="custom" + data-mantine-color-scheme=light|dark. Portal/Storybook: data-theme=light|dark → LIGHT/MIDNIGHT. */
/* Surface elevation: --c-bg (canvas) < --c-bg-raised (sidebars) < --c-surface (cards) < --c-surface-raised < --c-surface-sunken; plus --c-input-bg, --c-hover, --c-active, --c-overlay. */
/* ── LIGHT ───────────────────────────────────────────────────────────────── */
:root,
[data-theme="light"],
html[data-app-theme="light"] {
--c-bg: var(--p-gray-50);
--c-bg-raised: var(--p-white);
--c-surface: var(--p-white);
--c-surface-raised: var(--p-white);
--c-surface-sunken: var(--p-gray-100);
--c-input-bg: var(--p-white);
--c-hover: var(--p-gray-50);
--c-active: var(--p-gray-100);
--c-overlay: rgba(0, 0, 0, 0.5);
--c-text: var(--p-gray-900);
--c-text-muted: var(--p-gray-600);
--c-text-subtle: var(--p-gray-500);
--c-text-on-primary: var(--p-white);
--c-border: var(--p-gray-250);
--c-border-subtle: var(--p-gray-200);
--c-border-strong: var(--p-gray-400);
--c-primary: var(--p-blue-500);
--c-primary-hover: var(--p-blue-600);
--c-primary-subtle: color-mix(in srgb, var(--p-blue-500) 10%, transparent);
--c-success: var(--p-green-600);
--c-danger: var(--p-red-600);
}
/* ── MIDNIGHT (original navy) — also the default portal/Storybook dark ────── */
[data-theme="dark"],
html[data-app-theme="midnight"] {
--c-bg: var(--p-zinc-900);
--c-bg-raised: var(--p-zinc-850);
--c-surface: var(--p-zinc-800);
--c-surface-raised: var(--p-zinc-650);
--c-surface-sunken: var(--p-zinc-850);
--c-input-bg: var(--p-zinc-650);
--c-hover: var(--p-gray-800);
--c-active: var(--p-gray-800);
--c-overlay: rgba(0, 0, 0, 0.6);
--c-text: var(--p-zinc-100);
--c-text-muted: var(--p-zinc-200);
--c-text-subtle: var(--p-zinc-300);
--c-text-on-primary: var(--p-white);
--c-border: var(--p-zinc-650);
--c-border-subtle: rgba(255, 255, 255, 0.05);
--c-border-strong: var(--p-zinc-500);
--c-primary: var(--p-blue-500);
--c-primary-hover: var(--p-blue-700);
--c-primary-subtle: color-mix(in srgb, var(--p-blue-500) 15%, transparent);
--c-success: var(--p-green-500);
--c-danger: var(--p-red-500);
}
/* ── Base accent — fixed default (blue buttons); [data-accent="default"] blocks at the end keep surfaces neutral. LIGHT base here, DARK base next. ── */
html[data-app-theme="custom"] {
--c-primary: var(--p-blue-500);
--c-primary-hover: color-mix(in srgb, var(--c-primary) 85%, var(--p-black));
--c-primary-subtle: color-mix(in srgb, var(--c-primary) 14%, transparent);
--c-text-on-primary: var(--p-white);
--c-accent-fg: var(--c-primary);
/* Primary-tinted surfaces (light base). Neutralised by the default override. */
--c-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-gray-50));
--c-bg-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white));
--c-surface: color-mix(in srgb, var(--c-primary) 3%, var(--p-white));
--c-surface-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white));
--c-surface-sunken: color-mix(
in srgb,
var(--c-primary) 8%,
var(--p-gray-100)
);
--c-input-bg: color-mix(in srgb, var(--c-primary) 2%, var(--p-white));
--c-hover: color-mix(in srgb, var(--c-primary) 9%, var(--p-gray-50));
--c-active: color-mix(in srgb, var(--c-primary) 13%, var(--p-gray-100));
--c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-gray-250));
--c-border-subtle: color-mix(
in srgb,
var(--c-primary) 10%,
var(--p-gray-200)
);
/* Mantine primary ramp (--color-primary-*) derived from --c-primary. */
--color-primary-50: color-mix(in srgb, var(--c-primary) 12%, var(--p-white));
--color-primary-100: color-mix(in srgb, var(--c-primary) 20%, var(--p-white));
--color-primary-200: color-mix(in srgb, var(--c-primary) 35%, var(--p-white));
--color-primary-300: color-mix(in srgb, var(--c-primary) 55%, var(--p-white));
--color-primary-400: color-mix(in srgb, var(--c-primary) 78%, var(--p-white));
--color-primary-500: var(--c-primary);
--color-primary-600: color-mix(in srgb, var(--c-primary) 88%, var(--p-black));
--color-primary-700: color-mix(in srgb, var(--c-primary) 74%, var(--p-black));
--color-primary-800: color-mix(in srgb, var(--c-primary) 60%, var(--p-black));
--color-primary-900: color-mix(in srgb, var(--c-primary) 46%, var(--p-black));
--mantine-primary-color-filled: var(--c-primary);
--mantine-primary-color-filled-hover: var(--c-primary-hover);
/* Mantine "light" variant surfaces (chips, subtle buttons, some panels). */
--mantine-primary-color-light: color-mix(
in srgb,
var(--c-primary) 12%,
transparent
);
--mantine-primary-color-light-hover: color-mix(
in srgb,
var(--c-primary) 18%,
transparent
);
--mantine-primary-color-light-color: var(--c-primary);
/* Brand-tint family — re-derived from --c-primary so legacy accents harmonise to the chosen hue. */
/* Fills / borders — text uses --c-text-on-primary. */
--btn-open-file: var(--c-primary);
--header-selected-bg: var(--c-primary);
--header-selected-fg: var(--c-text-on-primary);
--checkbox-checked-bg: var(--c-primary);
--card-selected-border: var(--c-primary);
/* Accent-as-text/icon tokens live in compat.css (:root:root wins); only --icon-files-color is owned here. */
--icon-files-color: var(--c-accent-fg);
/* Subtle surface tints (mix with the base surface so they adapt light/dark) */
--tool-header-border: color-mix(
in srgb,
var(--c-primary) 28%,
var(--c-surface)
);
--tool-header-badge-bg: color-mix(
in srgb,
var(--c-primary) 20%,
var(--c-surface)
);
--tooltip-title-bg: color-mix(
in srgb,
var(--c-primary) 12%,
var(--c-surface)
);
--landing-inner-paper-bg: color-mix(
in srgb,
var(--c-primary) 8%,
var(--c-surface)
);
--landing-inner-paper-border: color-mix(
in srgb,
var(--c-primary) 25%,
var(--c-surface)
);
--landing-button-border: color-mix(
in srgb,
var(--c-primary) 22%,
var(--c-surface)
);
--landing-hero-gradient: linear-gradient(
135deg,
var(--c-primary) 0%,
var(--c-primary-hover) 100%
);
/* Translucent state tints */
--color-nav-active: color-mix(in srgb, var(--c-primary) 12%, transparent);
--modal-nav-item-active-bg: color-mix(
in srgb,
var(--c-primary) 10%,
transparent
);
--pdf-selection-bg: color-mix(in srgb, var(--c-primary) 20%, transparent);
--pdf-selection-ring: color-mix(in srgb, var(--c-primary) 28%, transparent);
--tool-panel-search-bg: color-mix(
in srgb,
var(--c-primary) 6%,
var(--c-surface)
);
}
/* ── DARK — editor dark theme: neutral text/borders/icons + accent-tinted surfaces (default override opts out). After :root so it wins for dark. ── */
html[data-app-theme="custom"][data-mantine-color-scheme="dark"] {
/* Neutral text / borders / overlay (not accent-tinted). */
--c-text: var(--p-zinc-100);
--c-text-muted: var(--p-zinc-200);
--c-text-subtle: var(--p-zinc-300);
--c-border-strong: var(--p-zinc-500);
--c-overlay: rgba(0, 0, 0, 0.6);
/* Accent-tinted surfaces (dark base). Neutralised by the default override. */
--c-bg: color-mix(in srgb, var(--c-primary) 8%, var(--p-zinc-950));
--c-bg-raised: color-mix(in srgb, var(--c-primary) 9%, var(--p-zinc-850));
--c-surface: color-mix(in srgb, var(--c-primary) 8%, var(--p-zinc-800));
--c-surface-raised: color-mix(
in srgb,
var(--c-primary) 9%,
var(--p-zinc-775)
);
--c-surface-sunken: color-mix(
in srgb,
var(--c-primary) 8%,
var(--p-zinc-900)
);
--c-input-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-zinc-900));
--c-hover: color-mix(in srgb, var(--c-primary) 12%, var(--p-zinc-750));
--c-active: color-mix(in srgb, var(--c-primary) 15%, var(--p-zinc-700));
--c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-zinc-650));
--c-border-subtle: color-mix(
in srgb,
var(--c-primary) 10%,
var(--p-zinc-700)
);
/* Dark-tuned status shades (lighter than the light-theme :root values). */
--c-success: var(--p-green-500);
--c-danger: var(--p-red-400);
/* Category icon chips → neutral. */
--icon-tools-bg: var(--c-surface-raised);
--icon-inactive-bg: var(--c-surface-raised);
--icon-tools-color: var(--c-text-muted);
--icon-files-color: var(--c-text-muted);
--icon-inactive-color: var(--c-text-muted);
--onboarding-step-inactive: var(--c-border-strong);
/* Point Mantine's dark ramp at the --c-* surfaces so components follow the theme. */
--mantine-color-dark-4: var(--c-border);
--mantine-color-dark-5: var(--c-surface-raised);
--mantine-color-dark-6: var(--c-surface);
--mantine-color-dark-7: var(--c-bg);
--mantine-color-body: var(--c-bg);
--mantine-color-default: var(--c-surface);
--mantine-color-default-hover: var(--c-hover);
}
/* ── DEFAULT (no tint) — surfaces opt out of the accent tint (neutral white/grey light, zinc dark); --c-primary stays for buttons. Extra [data-accent="default"] beats the tinted blocks. ── */
html[data-app-theme="custom"][data-accent="default"] {
--c-bg: var(--p-gray-50);
--c-bg-raised: var(--p-white);
--c-surface: var(--p-white);
--c-surface-raised: var(--p-white);
--c-surface-sunken: var(--p-gray-100);
--c-input-bg: var(--p-white);
--c-hover: var(--p-gray-50);
--c-active: var(--p-gray-100);
--c-border: var(--p-gray-250);
--c-border-subtle: var(--p-gray-200);
}
html[data-app-theme="custom"][data-accent="default"][data-mantine-color-scheme="dark"] {
--c-bg: var(--p-zinc-950);
--c-bg-raised: var(--p-zinc-850);
--c-surface: var(--p-zinc-800);
--c-surface-raised: var(--p-zinc-775);
--c-surface-sunken: var(--p-zinc-900);
--c-input-bg: var(--p-zinc-900);
--c-hover: var(--p-zinc-750);
--c-active: var(--p-zinc-700);
--c-border: var(--p-zinc-650);
--c-border-subtle: var(--p-zinc-700);
}
+148
View File
@@ -0,0 +1,148 @@
/* COMPAT — legacy token names (editor --bg-/--text-, SUI/portal --color-) aliased onto the canonical --c- set so existing files keep working. :root:root (0,2,0) out-ranks the legacy blocks; aliases are theme-agnostic. */
:root:root {
/* ── Canvas (darkest / centre) ── */
--bg-background: var(--c-bg);
--bg-file-manager: var(--c-bg);
--color-bg: var(--c-bg);
/* ── Raised chrome — sidebars, toolbars, headers, side nav. ── */
--bg-toolbar: var(--c-bg-raised);
--color-sidebar-bg: var(--c-bg-raised);
--color-header-bg: var(--c-bg-raised);
--modal-nav-bg: var(--c-bg-raised);
--unsupported-bar-bg: var(--c-bg-raised);
/* ── Surfaces — cards, modals, dropdowns, popovers ── */
--bg-surface: var(--c-surface);
--bg-file-list: var(--c-surface);
--modal-content-bg: var(--c-surface);
--bulk-panel-bg: var(--c-surface);
--bulk-card-bg: var(--c-surface);
--api-keys-card-bg: var(--c-surface);
--color-surface: var(--c-surface);
--color-dropdown-bg: var(--c-surface);
--information-text-bg: var(--c-surface);
--bg-raised: var(--c-surface-raised);
--file-card-bg: var(--c-surface-raised);
--color-surface-alt: var(--c-surface-raised);
--landing-inner-paper-bg: var(--c-surface-raised);
--accordion-item-bg: var(--c-surface-raised);
--bg-muted: var(--c-surface-sunken);
--color-bg-muted: var(--c-surface-sunken);
--tool-panel-search-bg: var(--c-surface-sunken);
--compare-page-label-bg: var(--c-surface-sunken);
/* ── Inputs ── */
--input-bg: var(--c-input-bg);
--api-keys-input-bg: var(--c-input-bg);
/* ── Text ── */
--text-primary: var(--c-text);
--color-text-1: var(--c-text);
--color-header-text: var(--c-text);
--color-logo-text: var(--c-text);
--tools-text-and-icon-color: var(--c-text);
--text-secondary: var(--c-text-muted);
--color-text-2: var(--c-text-muted);
--color-nav-hover-text: var(--c-text-muted);
--text-muted: var(--c-text-subtle);
--color-text-3: var(--c-text-subtle);
--color-text-4: var(--c-text-subtle);
--color-text-5: var(--c-text-subtle);
--color-text-6: var(--c-text-subtle);
--color-text-muted: var(--c-text-subtle);
--color-nav-text: var(--c-text-subtle);
--search-text-and-icon-color: var(--c-text-subtle);
/* ── Borders ── */
--border-default: var(--c-border);
--color-border: var(--c-border);
--color-border-input: var(--c-border);
--border-subtle: var(--c-border-subtle);
--color-border-light: var(--c-border-subtle);
--color-divider: var(--c-border-subtle);
--border-strong: var(--c-border-strong);
--border-hover: var(--c-border-strong);
--color-border-hover: var(--c-border-strong);
/* More borders/dividers that were still navy in neutral dark. */
--color-header-border: var(--c-border);
--color-sidebar-border: var(--c-border);
--color-dropdown-border: var(--c-border);
--color-search-border: var(--c-border);
--tool-header-border: var(--c-border);
--tool-panel-search-border-bottom: var(--c-border);
--landing-inner-paper-border: var(--c-border);
--unsupported-bar-border: var(--c-border);
--api-keys-card-border: var(--c-border);
--api-keys-input-border: var(--c-border);
--color-usage-track: var(--c-border);
--color-sidebar-divider: var(--c-border-subtle);
--tool-subcategory-rule-color: var(--c-border-subtle);
--modal-header-border: var(--c-border-subtle);
/* ── More surfaces that were still navy in neutral dark ── */
--color-bg-alt: var(--c-bg-raised);
--color-search-bg: var(--c-surface-sunken);
--color-dropdown-hover: var(--c-hover);
--tool-header-badge-bg: var(--c-surface-raised);
--color-nav-active: var(--c-primary-subtle);
--modal-nav-item-active-bg: var(--c-primary-subtle);
/* ── More text tokens that were still navy in neutral dark ── */
--onboarding-title: var(--c-text);
--onboarding-body: var(--c-text-muted);
--modal-nav-section-title: var(--c-text-subtle);
--tool-subcategory-text-color: var(--c-text-subtle);
--dropdown-trigger-text-disabled: var(--c-text-subtle);
--tool-header-badge-text: var(--c-accent-fg, var(--c-primary));
--tooltip-title-bg: color-mix(
in srgb,
var(--c-primary) 12%,
var(--c-surface)
);
--tooltip-title-color: var(--c-text);
/* ── Interaction states ── */
--hover-bg: var(--c-hover);
--color-bg-hover: var(--c-hover);
--color-nav-hover: var(--c-hover);
--automation-entry-hover-bg: var(--c-hover);
--active-bg: var(--c-active);
/* ── Primary / accent — fills/borders use raw --c-primary; text/icon-on-surface uses --c-accent-fg (falls back to --c-primary outside the custom theme). ── */
--color-blue: var(--c-primary);
--accent-blue: var(--c-primary);
--accent-interactive: var(--c-primary);
--btn-open-file: var(--c-primary);
--card-selected-border: var(--c-primary);
--checkbox-checked-bg: var(--c-primary);
--color-blue-dark: var(--c-primary-hover);
--modal-nav-item-active: var(--c-accent-fg, var(--c-primary));
--color-nav-active-text: var(--c-accent-fg, var(--c-primary));
--tool-header-text: var(--c-accent-fg, var(--c-primary));
--text-instruction: var(--c-accent-fg, var(--c-primary));
--landing-button-color: var(--c-accent-fg, var(--c-primary));
/* SUI "blue" accent border/light (secondary buttons, chips) — derived from the primary. */
--color-blue-border: color-mix(
in srgb,
var(--c-primary) 38%,
var(--c-surface)
);
--color-blue-light: color-mix(
in srgb,
var(--c-primary) 12%,
var(--c-surface)
);
/* ── Status ── */
--accent-green: var(--c-success);
--accent-red: var(--c-danger);
}
@@ -0,0 +1,65 @@
/* DIMENSIONS — single source for every non-colour length (spacing, radius, sizing, borders, z-index, shadow, type, motion). Theme-agnostic. */
:root {
/* ── Spacing — 4px grid. Canonical numeric scale + named aliases ── */
--space-0: 0;
--space-0_5: 0.125rem; /* 2px */
--space-1: 0.25rem; /* 4px */
--space-1_5: 0.375rem; /* 6px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
/* T-shirt aliases (legacy editor names) → the numeric scale above. */
--space-xs: var(--space-1); /* 4px */
--space-sm: var(--space-2); /* 8px */
--space-md: var(--space-4); /* 16px */
--space-lg: var(--space-6); /* 24px */
--space-xl: var(--space-8); /* 32px */
/* ── Radius — one coherent scale (resolves the 8px-vs-6px collision) ── */
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-pill: 9999px;
/* ── Layout sizing ── */
--footer-height: 2rem;
--landing-stack-w: 224px;
--landing-stack-h: 176px;
/* --shadow-* is intentionally NOT defined here: editor (drop shadows) and SUI/portal (inset hairlines) reuse the name with different values. */
/* ── Typography ── */
--font-sans:
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
sans-serif;
--font-mono:
"SF Mono", "Fira Code", Menlo, Consolas, "DejaVu Sans Mono", monospace;
--font-brand: "Alumni Sans", "Inter", sans-serif;
--font-weight-medium: 500;
--font-weight-semibold: 600;
/* ── Motion ── */
--motion-fast: 0.15s ease;
--motion-base: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--motion-slow: 0.3s ease;
--motion-enter: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
--fullscreen-anim-duration-in: 0.28s;
--fullscreen-anim-duration-out: 0.22s;
/* ── Z-index ladder ── */
--z-dropdown: 25;
--z-drawer: 50;
--z-toast: 200;
/* Fullscreen tool-picker surfaces (editor) */
--z-fullscreen-icon-svg: 1;
--z-toolpicker-star: 1;
--z-fullscreen-surface: 1200;
}
+5
View File
@@ -0,0 +1,5 @@
/* Consolidated theme entry. Order matters: primitives → dimensions → colors → compat (legacy aliases, :root:root wins). */
@import "./primitives.css";
@import "./dimensions.css";
@import "./colors.css";
@import "./compat.css";
+12 -15
View File
@@ -58,21 +58,18 @@ const gray: MantineColorsTuple = [
"var(--color-gray-900)",
];
// Navy-indigo dark scale replaces Mantine's neutral gray defaults so all
// dark-mode components (SegmentedControl, inputs, dropdowns, etc.) use the
// portal palette automatically via --mantine-color-dark-*.
// dark-0..3 = text/icon shades, dark-4..7 = surface elevations, dark-8..9 = deepest bg.
// Neutral dark scale (zinc, mirroring --p-zinc-*) replacing Mantine's default gray ramp; colors.css re-points dark-4..7 at the --c-* surfaces. 0..3 text, 4..7 surfaces, 8..9 deepest.
const dark: MantineColorsTuple = [
"#c2c8e0", // dark-0 — primary text on dark bg
"#9299b0", // dark-1 — secondary text
"#6e7898", // dark-2 — muted text / icons
"#4a5282", // dark-3 — subtle text / dividers
"#1c2340", // dark-4 — elevated surface / selected bg (e.g. SegmentedControl indicator)
"#131729", // dark-5 — card / panel surface
"#0d1020", // dark-6 — toolbar / sidebar bg (e.g. SegmentedControl root)
"#090b18", // dark-7 — page background (deepest reachable surface)
"#07091a", // dark-8
"#050714", // dark-9
"#f4f4f5", // dark-0 — primary text on dark bg (zinc-100)
"#a1a1aa", // dark-1 — secondary text (zinc-200)
"#71717a", // dark-2 — muted text / icons (zinc-300)
"#52525b", // dark-3 — subtle text / dividers (zinc-400)
"#2a2a2e", // dark-4 — elevated surface / selected bg (zinc-650)
"#202023", // dark-5 — card / panel surface (zinc-775)
"#18181b", // dark-6 — toolbar / sidebar bg (zinc-800)
"#0f0f10", // dark-7 — page background (zinc-950)
"#070708", // dark-8 — deeper than the reachable surfaces
"#050506", // dark-9 — deepest
];
export const mantineTheme = createTheme({
@@ -292,7 +289,7 @@ export const mantineTheme = createTheme({
tooltip: {
backgroundColor: "var( --tooltip-title-bg)",
color: "var( --tooltip-title-color)",
border: "1px solid var(--tooltip-borderp)",
border: "1px solid var(--tooltip-border)",
fontSize: "0.75rem",
fontWeight: "500",
boxShadow: "var(--shadow-md)",
@@ -0,0 +1,45 @@
/* PRIMITIVES — the raw palette (theme-agnostic). The ONE place literal colours live: neutral ramps (gray=light, zinc=dark) + status hues; colors.css and compat.css reference these. */
:root {
--p-white: #ffffff;
--p-black: #000000;
--p-gray-50: #f9fafb;
--p-gray-100: #f3f4f6;
--p-gray-150: #eef0f2;
--p-gray-200: #e5e7eb;
--p-gray-250: #e2e8f0;
--p-gray-300: #d1d5db;
--p-gray-400: #9ca3af;
--p-gray-500: #6b7280;
--p-gray-600: #4b5563;
--p-gray-700: #374151;
--p-gray-800: #1f2937;
--p-gray-900: #111827;
--p-zinc-950: #0f0f10;
--p-zinc-900: #101012;
--p-zinc-850: #131315;
--p-zinc-800: #18181b;
--p-zinc-775: #202023;
--p-zinc-750: #1f1f23;
--p-zinc-700: #27272a;
--p-zinc-650: #2a2a2e;
--p-zinc-600: #333338;
--p-zinc-500: #3f3f46;
--p-zinc-400: #52525b;
--p-zinc-300: #71717a;
--p-zinc-200: #a1a1aa;
--p-zinc-100: #f4f4f5;
--p-blue-400: #60a5fa;
--p-blue-500: #3b82f6;
--p-blue-600: #2563eb;
--p-blue-700: #1d4ed8;
--p-green-500: #22c55e;
--p-green-600: #16a34a;
--p-green-700: #15803d;
--p-amber-400: #fbbf24;
--p-amber-500: #f59e0b;
--p-amber-600: #d97706;
--p-red-400: #f87171;
--p-red-500: #ef4444;
--p-red-600: #dc2626;
}
@@ -92,8 +92,10 @@ export const Colours: Story = {
<Group
heading="Brand & status"
swatches={[
{ label: "Blue", varName: "--color-blue" },
{ label: "Blue dark", varName: "--color-blue-dark" },
// The accent — follows the custom light/dark theme colour, not a fixed
// blue (maps to --c-primary / --c-primary-hover). Will change the name in a future PR to reflect that it's the chosen accent, not a fixed blue.
{ label: "Primary", varName: "--color-blue" },
{ label: "Primary hover", varName: "--color-blue-dark" },
{ label: "Purple", varName: "--color-purple" },
{ label: "Green", varName: "--color-green" },
{ label: "Amber", varName: "--color-amber" },
+1 -3
View File
@@ -16,9 +16,7 @@ body,
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition:
background var(--motion-slow),
color var(--motion-slow);
transition: background var(--motion-slow);
}
button {
+87 -211
View File
@@ -13,18 +13,11 @@
:root,
[data-theme="light"] {
/* Text hierarchy */
--color-text-1: #0f172a;
--color-text-2: #1a202c;
--color-text-3: #475569;
--color-text-4: #64748b;
/* text-5 was #94a3b8 (≈2.85:1 on white — fails WCAG AA). Bumped to #64748b
so meta/timestamp copy at small sizes still passes contrast. The visual
hierarchy now collapses text-4 and text-5 into the same shade — use weight
or size to differentiate in components instead. */
--color-text-5: #64748b;
--color-text-6: #6b7280;
--color-text-muted: #8b92a1;
--color-text-placeholder: #9ca3af;
--color-text-placeholder: var(--p-gray-400);
/* Body text colour for use on coloured / gradient backgrounds (blue buttons,
assistant header, onboarding "done" badges). Theme-stable: even in dark
@@ -32,105 +25,70 @@
--color-text-on-accent: #ffffff;
/* Brand / status */
--color-blue: #3b82f6;
--color-blue-dark: #2563eb;
--color-blue-light: #eff6ff;
--color-blue-border: #bfdbfe;
--color-purple: #8b5cf6;
--color-purple-light: #f5f3ff;
--color-purple-border: #ddd6fe;
--color-purple-dark: #7c3aed;
--color-green: #10b981;
--color-green-light: #ecfdf5;
--color-green-border: #a7f3d0;
--color-green-dark: #16a34a;
--color-red: #ef4444;
--color-red-light: #fee2e2;
--color-red-border: #fecaca;
--color-red-dark: #dc2626;
--color-amber: #f59e0b;
--color-amber-light: #fef3c7;
--color-amber-border: #fde68a;
--color-amber-dark: #92400e;
--color-orange: #f97316;
--color-orange-light: #fff7ed;
--color-orange-border: #fed7aa;
--color-orange-dark: #9a3412;
--color-purple: var(--p-blue-400);
--color-purple-light: var(--p-blue-400);
--color-purple-border: var(--p-blue-400);
--color-purple-dark: var(--p-blue-600);
--color-green: var(--p-green-500);
--color-green-light: var(--p-green-500);
--color-green-border: var(--p-green-500);
--color-green-dark: var(--p-green-600);
--color-red: var(--p-red-500);
--color-red-light: var(--p-red-400);
--color-red-border: var(--p-red-400);
--color-red-dark: var(--p-red-600);
--color-amber: var(--p-amber-500);
--color-amber-light: var(--p-amber-400);
--color-amber-border: var(--p-amber-400);
--color-amber-dark: var(--p-amber-600);
--color-orange: var(--p-amber-600);
--color-orange-light: var(--p-amber-400);
--color-orange-border: var(--p-amber-400);
--color-orange-dark: var(--p-red-600);
/* Category accents (theme-stable) */
--color-cat-insurance: #0ea5e9;
--color-cat-compliance: #6366f1;
--color-cat-finance: #10b981;
--color-cat-legal: #3b82f6;
--color-cat-healthcare: #8b5cf6;
--color-cat-government: #dc2626;
--color-cat-operations: #ec4899;
--color-cat-hr: #f59e0b;
--color-cat-realestate: #84cc16;
--color-cat-energy: #f97316;
--color-cat-extraction: #14b8a6;
--color-cat-insurance: var(--p-blue-500);
--color-cat-compliance: var(--p-blue-500);
--color-cat-finance: var(--p-green-500);
--color-cat-legal: var(--p-blue-500);
--color-cat-healthcare: var(--p-blue-400);
--color-cat-government: var(--p-red-600);
--color-cat-operations: var(--p-red-400);
--color-cat-hr: var(--p-amber-500);
--color-cat-realestate: var(--p-green-500);
--color-cat-energy: var(--p-amber-600);
--color-cat-extraction: var(--p-blue-500);
/* Surfaces */
--color-bg: #f8f9fb;
--color-bg-alt: #f6f8fa;
--color-surface: #ffffff;
--color-surface-alt: #ffffff;
--color-border: #e3e8ee;
--color-border-light: #eef0f2;
--color-border-input: #e2e8f0;
--color-border-hover: #cbd5e1;
--color-divider: #f0f0f0;
--color-bg-subtle: #f9fafb;
--color-bg-hover: #f8fafc;
--color-bg-muted: #f3f4f6;
--color-bg-code: #f1f5f9;
--color-bg-subtle: var(--p-gray-50);
--color-bg-code: var(--p-gray-100);
/* Dropdowns / tooltips */
--color-dropdown-bg: #ffffff;
--color-dropdown-border: #e5e7eb;
--color-dropdown-hover: #f9fafb;
--color-tooltip-bg: #1e293b;
--color-tooltip-text: #f8fafc;
--color-tooltip-bg: var(--p-gray-800);
--color-tooltip-text: var(--p-gray-50);
/* Navigation */
--color-nav-active: #eff6ff;
--color-nav-active-text: #3b82f6;
--color-nav-text: #4b5563;
--color-nav-hover: #f6f8fa;
--color-nav-hover-text: #1a202c;
--color-section-label: #9ca3af;
--color-logo-text: #111827;
--color-section-label: var(--p-gray-400);
/* Header */
--color-header-bg: #ffffff;
--color-header-border: #f0f0f0;
--color-header-text: #111827;
--color-search-bg: #f9fafb;
--color-search-border: #e5e7eb;
--color-search-border-hover: #d1d5db;
--color-search-text: #9ca3af;
--color-search-border-hover: var(--p-gray-300);
--color-search-text: var(--p-gray-400);
/* Sidebar */
--color-sidebar-bg: #ffffff;
--color-sidebar-border: #e5e7eb;
--color-sidebar-divider: #f0f0f0;
--color-usage-text: #6b7280;
--color-usage-value: #374151;
--color-usage-track: #f3f4f6;
--color-usage-text: var(--p-gray-500);
--color-usage-value: var(--p-gray-700);
--color-toggle-off: #cbd5e1;
--color-badge-red: #ef4444;
/* Segmented control — semantic tokens so dark mode can decouple from blue surfaces */
--color-toggle-off: var(--p-gray-300);
--color-badge-red: var(--p-red-500);
/* Shadows */
--shadow-sm: inset 0 0 0 1px #e3e8ee;
--shadow-md: inset 0 0 0 1px #e3e8ee, 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow-lg: inset 0 0 0 1px #e3e8ee, 0 4px 12px rgba(15, 23, 42, 0.06);
--shadow-blue:
0 1px 2px rgba(59, 130, 246, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.2);
--shadow-blue-hover:
0 2px 6px rgba(59, 130, 246, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.25);
--shadow-sm: inset 0 0 0 1px var(--p-gray-250);
--shadow-md:
inset 0 0 0 1px var(--p-gray-250),
0 1px 2px color-mix(in srgb, var(--p-blue-700) 4%, transparent);
--shadow-lg:
inset 0 0 0 1px var(--p-gray-250),
0 4px 12px color-mix(in srgb, var(--p-blue-700) 6%, transparent);
/* Gradients */
--grad-blue-btn: linear-gradient(
@@ -163,103 +121,58 @@
#ef4444 50%,
#dc2626 100%
);
--grad-banner: linear-gradient(135deg, #eef2ff 0%, #ddd6fe 50%, #fce7f3 100%);
}
[data-theme="dark"] {
--color-text-1: #f1f5f9;
--color-text-2: #e2e8f0;
--color-text-3: #94a3b8;
--color-text-4: #64748b;
/* Bumped from #475569 (fails AA) — see light-theme note. */
--color-text-5: #7c869a;
--color-text-6: #94a3b8;
--color-text-muted: #64748b;
--color-text-placeholder: #475569;
--color-text-placeholder: var(--p-gray-600);
--color-blue: #60a5fa;
--color-blue-dark: #3b82f6;
--color-blue-light: #1e293b;
--color-blue-border: #1e3a5f;
--color-purple: #a78bfa;
--color-purple-light: #1e1b2e;
--color-purple-border: #2e2650;
--color-purple-dark: #8b5cf6;
--color-green: #34d399;
--color-green-light: #0d2818;
--color-green-border: #065f46;
--color-green-dark: #22c55e;
--color-red: #f87171;
--color-red-light: #2d1215;
--color-red-border: #7f1d1d;
--color-red-dark: #ef4444;
--color-amber: #fbbf24;
--color-amber-light: #2d2006;
--color-amber-border: #78350f;
--color-purple: var(--p-blue-400);
--color-purple-light: var(--p-zinc-750);
--color-purple-border: var(--p-zinc-600);
--color-purple-dark: var(--p-blue-400);
--color-green: var(--p-green-500);
--color-green-light: var(--p-green-700);
--color-green-border: var(--p-green-700);
--color-green-dark: var(--p-green-500);
--color-red: var(--p-red-400);
--color-red-light: var(--p-zinc-800);
--color-red-border: var(--p-red-600);
--color-red-dark: var(--p-red-500);
--color-amber: var(--p-amber-400);
--color-amber-light: var(--p-amber-600);
--color-amber-border: var(--p-amber-600);
/* Deeper than the base so it stays distinct (hover shade + amber text on
dark surfaces). ~7.4:1 on --color-amber-light, passes WCAG AA. Was
#fbbf24 — identical to the base — which left Mantine's amber hover a
no-op in dark mode. */
--color-amber-dark: #f59e0b;
--color-orange: #fb923c;
--color-orange-light: #2a1408;
--color-orange-border: #7c2d12;
--color-orange-dark: #fdba74;
--color-amber-dark: var(--p-amber-500);
--color-orange: var(--p-amber-500);
--color-orange-light: var(--p-amber-600);
--color-orange-border: var(--p-red-600);
--color-orange-dark: var(--p-amber-400);
--color-bg: #090c14;
--color-bg-alt: #0d1120;
--color-surface: #151c2e;
--color-surface-alt: #1c2640;
--color-border: #283248;
--color-border-light: #1e2840;
--color-border-input: #2e3c55;
--color-border-hover: #3d4f6a;
--color-divider: #1e2840;
--color-bg-subtle: #111827;
--color-bg-hover: #1c2640;
--color-bg-muted: #243044;
--color-bg-code: #0b0f1a;
--color-bg-subtle: var(--p-gray-900);
--color-bg-code: var(--p-zinc-900);
--color-dropdown-bg: #151c2e;
--color-dropdown-border: #2e3c55;
--color-dropdown-hover: #1c2640;
--color-tooltip-bg: #e2e8f0;
--color-tooltip-text: #0f172a;
--color-tooltip-bg: var(--p-gray-250);
--color-tooltip-text: var(--p-blue-700);
--color-nav-active: #172044;
--color-nav-active-text: #60a5fa;
--color-nav-text: #94a3b8;
--color-nav-hover: #1c2640;
--color-nav-hover-text: #e2e8f0;
--color-section-label: #475569;
--color-logo-text: #f1f5f9;
--color-section-label: var(--p-gray-600);
--color-header-bg: #0d1120;
--color-header-border: #1e2840;
--color-header-text: #f1f5f9;
--color-search-bg: #151c2e;
--color-search-border: #2e3c55;
--color-search-border-hover: #3d4f6a;
--color-search-text: #64748b;
--color-search-border-hover: var(--p-gray-600);
--color-search-text: var(--p-gray-500);
--color-sidebar-bg: #0d1120;
--color-sidebar-border: #1e2840;
--color-sidebar-divider: #1e2840;
--color-usage-text: #64748b;
--color-usage-value: #94a3b8;
--color-usage-track: #1e2840;
--color-usage-text: var(--p-gray-500);
--color-usage-value: var(--p-gray-400);
--color-toggle-off: #3d4f6a;
--color-toggle-off: var(--p-gray-600);
/* Segmented control — blue-toned dark to match app surface colors */
--shadow-sm: inset 0 0 0 1px #283248;
--shadow-md: inset 0 0 0 1px #283248, 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: inset 0 0 0 1px #283248, 0 8px 24px rgba(0, 0, 0, 0.4);
--shadow-blue:
0 1px 3px rgba(59, 130, 246, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.1);
--shadow-blue-hover:
0 2px 8px rgba(59, 130, 246, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.15);
--shadow-sm: inset 0 0 0 1px var(--p-zinc-600);
--shadow-md: inset 0 0 0 1px var(--p-zinc-600), 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: inset 0 0 0 1px var(--p-zinc-600), 0 8px 24px rgba(0, 0, 0, 0.4);
--grad-blue-btn: linear-gradient(
180deg,
@@ -267,7 +180,6 @@
#2563eb 50%,
#1d4ed8 100%
);
--grad-banner: linear-gradient(135deg, #0f172a 0%, #111827 50%, #1a1535 100%);
}
/* Code palette — theme-aware: a light GitHub-style box in light mode, the dark
@@ -312,54 +224,18 @@
/* Radii / typography / motion / spacing / z-index — theme-stable */
:root {
/* Home hero strip navy. Theme-stable by design — the hero keeps this deep
navy in both light and dark (it's a branded surface, like the assistant
header), so it's defined once here rather than in the light/dark blocks. */
/* Home hero strip navy. Theme-stable by design — the white hero CTA keeps
this deep navy text in both light and dark (branded surface). The hero
background itself is accent-responsive (see EditorStatusCard/WelcomeBanner). */
--color-hero-navy: #16213e;
--radius-xs: 0.1875rem;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-pill: 0.625rem;
--font-sans:
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
sans-serif;
--font-mono:
"SF Mono", "Fira Code", Menlo, Consolas, "DejaVu Sans Mono", monospace;
--font-brand: "Alumni Sans", "Inter", sans-serif;
--motion-fast: 0.15s ease;
--motion-base: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--motion-slow: 0.3s ease;
--motion-enter: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
/* 4-px grid spacing scale (rem so it tracks font-size changes). Use these
in layout primitives + component CSS instead of bare 0.5rem / 0.75rem. */
--space-0: 0;
--space-0_5: 0.125rem;
--space-1: 0.25rem;
--space-1_5: 0.375rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
/* Z-index hierarchy. Pick the highest tier that still puts you below the
thing that's supposed to win. Modals beat drawers beat dropdowns. */
--z-base: 0;
--z-sticky: 10;
--z-dropdown: 25;
--z-fixed-overlay: 35;
--z-drawer: 50;
--z-modal: 100;
--z-toast: 200;
}
/* Keyframes used across the app. */
@@ -6,10 +6,10 @@
height: 56px;
border-radius: 16px;
border: none;
background: var(--color-blue, #3b82f6);
color: #fff;
background: var(--c-primary);
color: var(--p-white);
cursor: pointer;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.4);
box-shadow: 0 4px 16px color-mix(in srgb, var(--c-primary) 40%, transparent);
transition:
transform 180ms cubic-bezier(0.32, 0.72, 0, 1),
box-shadow 180ms ease;
@@ -20,7 +20,7 @@
.chat-fab-btn:hover {
transform: scale(1.09);
box-shadow: 0 6px 22px rgba(59, 130, 246, 0.52);
box-shadow: 0 6px 22px color-mix(in srgb, var(--c-primary) 52%, transparent);
}
.chat-fab-btn:active {
+4
View File
@@ -9,6 +9,10 @@
--_bd: var(--color-blue-border);
--_tint: color-mix(in srgb, var(--color-blue) 12%, transparent);
}
html[data-app-theme="custom"] .sui-acc-default {
--_on: var(--c-text-on-primary);
}
.sui-acc-danger {
--_solid: var(--color-red);
--_solid-hover: var(--color-red-dark);
@@ -15,7 +15,7 @@
align-items: center;
gap: 1.25rem;
padding: 1rem 1.25rem;
background: var(--color-hero-navy);
background: color-mix(in srgb, var(--c-primary) 22%, var(--p-zinc-950));
}
.portal-editor-hero__logo {
@@ -18,7 +18,7 @@
gap: 1rem;
flex-wrap: wrap;
padding: 0.875rem 1.25rem;
background: var(--color-hero-navy);
background: color-mix(in srgb, var(--c-primary) 22%, var(--p-zinc-950));
}
.portal-welcome__brand {
@@ -6,6 +6,12 @@ import {
useState,
type ReactNode,
} from "react";
import { preferencesService } from "@app/services/preferencesService";
import {
getSystemTheme,
resolveColorScheme,
type ThemeMode,
} from "@app/constants/theme";
export type Theme = "light" | "dark";
@@ -17,33 +23,43 @@ interface ThemeContextValue {
const ThemeContext = createContext<ThemeContextValue | null>(null);
const STORAGE_KEY = "stirling.portal.theme";
function readInitialTheme(): Theme {
if (typeof window === "undefined") return "light";
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(readInitialTheme);
// Mirror the shared theme MODE; re-read on cross-tab storage changes and,
// while on "system", on OS scheme changes.
const [mode, setMode] = useState<ThemeMode>(() =>
preferencesService.getPreference("theme"),
);
const [systemScheme, setSystemScheme] = useState(getSystemTheme);
useEffect(() => {
const syncMode = () => setMode(preferencesService.getPreference("theme"));
window.addEventListener("storage", syncMode);
const media = window.matchMedia?.("(prefers-color-scheme: dark)");
const syncSystem = () => setSystemScheme(media?.matches ? "dark" : "light");
media?.addEventListener("change", syncSystem);
return () => {
window.removeEventListener("storage", syncMode);
media?.removeEventListener("change", syncSystem);
};
}, []);
const theme = resolveColorScheme(mode, systemScheme);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
window.localStorage.setItem(STORAGE_KEY, theme);
}, [theme]);
const value = useMemo<ThemeContextValue>(
() => ({
const value = useMemo<ThemeContextValue>(() => {
const setTheme = (next: Theme) => {
preferencesService.setPreference("theme", next);
setMode(next);
};
return {
theme,
setTheme,
toggle: () => setTheme((t) => (t === "light" ? "dark" : "light")),
}),
[theme],
);
toggle: () => setTheme(theme === "light" ? "dark" : "light"),
};
}, [theme]);
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
@@ -2,25 +2,29 @@
:root {
/* Auth page colors (light mode) */
--auth-bg-color-light-only: #f3f4f6;
--auth-bg-color-light-only: var(--p-gray-100);
--auth-card-bg: #ffffff;
--auth-card-bg-light-only: #ffffff;
--auth-label-text-light-only: #374151;
--auth-input-border-light-only: #d1d5db;
--auth-label-text-light-only: var(--p-gray-700);
--auth-input-border-light-only: var(--p-gray-300);
--auth-input-bg-light-only: #ffffff;
--auth-input-text-light-only: #111827;
--auth-border-focus-light-only: #3b82f6;
--auth-focus-ring-light-only: rgba(59, 130, 246, 0.1);
--auth-button-bg-light-only: #af3434;
--auth-input-text-light-only: var(--p-gray-900);
--auth-border-focus-light-only: var(--p-blue-500);
--auth-focus-ring-light-only: color-mix(
in srgb,
var(--p-blue-500) 10%,
transparent
);
--auth-button-bg-light-only: var(--p-red-600);
--auth-button-text-light-only: #ffffff;
--auth-magic-button-bg-light-only: #e5e7eb;
--auth-magic-button-text-light-only: #374151;
--auth-text-primary-light-only: #111827;
--auth-text-secondary-light-only: #6b7280;
--auth-magic-button-bg-light-only: var(--p-gray-200);
--auth-magic-button-text-light-only: var(--p-gray-700);
--auth-text-primary-light-only: var(--p-gray-900);
--auth-text-secondary-light-only: var(--p-gray-500);
--text-divider-rule-rgb-light: 229, 231, 235;
--text-divider-label-rgb-light: 156, 163, 175;
--tool-subcategory-rule-color-light: #e5e7eb;
--tool-subcategory-text-color-light: #9ca3af;
--tool-subcategory-rule-color-light: var(--p-gray-200);
--tool-subcategory-text-color-light: var(--p-gray-400);
}
[data-mantine-color-scheme="dark"] {
@@ -31,9 +35,13 @@
--auth-input-border-light-only: var(--border-default);
--auth-input-bg-light-only: var(--bg-raised);
--auth-input-text-light-only: var(--text-primary);
--auth-border-focus-light-only: #3b82f6;
--auth-focus-ring-light-only: rgba(59, 130, 246, 0.2);
--auth-button-bg-light-only: #af3434;
--auth-border-focus-light-only: var(--p-blue-500);
--auth-focus-ring-light-only: color-mix(
in srgb,
var(--p-blue-500) 20%,
transparent
);
--auth-button-bg-light-only: var(--p-red-600);
--auth-button-text-light-only: #ffffff;
--auth-magic-button-bg-light-only: var(--bg-raised);
--auth-magic-button-text-light-only: var(--text-primary);
+111 -85
View File
@@ -2,101 +2,125 @@
:root {
/* Orange scale (used for warning toasts) */
--color-orange-50: #fff4ed;
--color-orange-100: #ffe1cc;
--color-orange-200: #ffb089;
--color-orange-300: #ff7a45;
--color-orange-400: #d84a1b;
--color-orange-50: var(--p-amber-400);
--color-orange-100: var(--p-amber-400);
--color-orange-200: var(--p-red-400);
--color-orange-300: var(--p-red-400);
--color-orange-400: var(--p-red-600);
/* Amber scale (trial/warning emphasis) */
--color-amber-50: #fffbeb;
--color-amber-100: #fef3c7;
--color-amber-200: #fde68a;
--color-amber-300: #fcd34d;
--color-amber-400: #fbbf24;
--color-amber-500: #f59e0b;
--color-amber-600: #d97706;
--color-amber-700: #b45309;
--color-amber-800: #92400e;
--color-amber-900: #78350f;
--color-amber-50: var(--p-amber-400);
--color-amber-100: var(--p-amber-400);
--color-amber-200: var(--p-amber-400);
--color-amber-300: var(--p-amber-400);
--color-amber-400: var(--p-amber-400);
--color-amber-500: var(--p-amber-500);
--color-amber-600: var(--p-amber-600);
--color-amber-700: var(--p-amber-600);
--color-amber-800: var(--p-amber-600);
--color-amber-900: var(--p-amber-600);
/* Subcategory / divider vars (light) */
--tool-subcategory-text-color-light: #9ca3af;
--tool-subcategory-rule-color-light: #e5e7eb;
--text-divider-rule-color: var(--color-gray-200);
--text-divider-label-color: var(--color-gray-400);
--text-divider-rule-color-light: var(--color-gray-200);
--text-divider-label-color-light: var(--color-gray-400);
--tool-subcategory-text-color-light: var(--p-gray-400);
--tool-subcategory-rule-color-light: var(--p-gray-200);
/* Auth color vars */
--auth-input-bg: #f9fafb;
--auth-input-border: #e5e7eb;
--auth-input-text: #1f2937;
--auth-label-text: #2b3230;
--auth-button-bg: #af3434;
--auth-input-bg: var(--p-gray-50);
--auth-input-border: var(--p-gray-200);
--auth-input-text: var(--p-gray-800);
--auth-label-text: var(--p-zinc-650);
--auth-button-bg: var(--p-red-600);
--auth-button-text: #ffffff;
--auth-magic-button-bg: #af3434;
--auth-magic-button-bg: var(--p-red-600);
--auth-magic-button-text: #ffffff;
/* Light-only auth colors (no dark mode equivalents) used for login/signup */
--auth-input-bg-light-only: #f9fafb;
--auth-input-border-light-only: #e5e7eb;
--auth-input-text-light-only: #1f2937;
--auth-label-text-light-only: #2b3230;
--auth-button-bg-light-only: #af3434;
--auth-input-bg-light-only: var(--p-gray-50);
--auth-input-border-light-only: var(--p-gray-200);
--auth-input-text-light-only: var(--p-gray-800);
--auth-label-text-light-only: var(--p-zinc-650);
--auth-button-bg-light-only: var(--p-red-600);
--auth-button-text-light-only: #ffffff;
--auth-magic-button-bg-light-only: #af3434;
--auth-magic-button-bg-light-only: var(--p-red-600);
--auth-magic-button-text-light-only: #ffffff;
--auth-bg-color-light-only: #ffffff;
--auth-card-bg-light-only: #ffffff;
--auth-text-primary-light-only: #2b3230;
--auth-text-secondary-light-only: #1f2937;
--auth-border-color-light-only: #e5e7eb;
--auth-border-focus-light-only: #cbd5e1;
--auth-focus-ring-light-only: rgba(59, 130, 246, 0.15);
--auth-text-primary-light-only: var(--p-zinc-650);
--auth-text-secondary-light-only: var(--p-gray-800);
--auth-border-focus-light-only: var(--p-gray-300);
--auth-focus-ring-light-only: color-mix(
in srgb,
var(--p-blue-500) 15%,
transparent
);
/* App Config Modal colors (light mode) */
--modal-nav-bg: #f5f6f8;
--modal-nav-section-title: #6b7280;
--modal-nav-item: #374151;
--modal-nav-item-active: #0a8bff;
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.08);
--modal-nav-bg: var(--p-gray-100);
--modal-nav-section-title: var(--p-gray-500);
--modal-nav-item: var(--p-gray-700);
--modal-nav-item-active: var(--p-blue-500);
--modal-nav-item-active-bg: color-mix(
in srgb,
var(--p-blue-500) 8%,
transparent
);
--modal-content-bg: #ffffff;
--modal-header-border: rgba(0, 0, 0, 0.06);
/* API usage progress bar colors (light mode) */
--usage-weekly-active: #3b82f6;
--usage-bought-active: #14b8a6;
--usage-total-used: #3b82f6;
--usage-inactive: #e5e7eb;
--usage-inactive: var(--p-gray-200);
/* API Keys section colors (light mode) */
--api-keys-card-bg: #ffffff;
--api-keys-card-border: #e0e0e0;
--api-keys-card-border: var(--p-gray-200);
--api-keys-card-shadow: rgba(0, 0, 0, 0.06);
--api-keys-input-bg: #f8f8f8;
--api-keys-input-border: #e0e0e0;
--api-keys-input-bg: var(--p-gray-50);
--api-keys-input-border: var(--p-gray-200);
}
[data-mantine-color-scheme="dark"] {
/* Compare highlight colors (dark mode) */
--spdf-compare-removed-bg: rgba(255, 107, 107, 0.45);
--spdf-compare-added-bg: rgba(81, 207, 102, 0.35);
--spdf-compare-removed-badge-bg: rgba(255, 59, 48, 0.15);
--spdf-compare-removed-bg: color-mix(
in srgb,
var(--p-red-400) 45%,
transparent
);
--spdf-compare-added-bg: color-mix(
in srgb,
var(--p-green-500) 35%,
transparent
);
--spdf-compare-removed-badge-bg: color-mix(
in srgb,
var(--p-red-500) 15%,
transparent
);
--spdf-compare-removed-badge-fg: var(--color-red-500);
--spdf-compare-added-badge-bg: rgba(52, 199, 89, 0.18);
--spdf-compare-added-badge-bg: color-mix(
in srgb,
var(--p-green-500) 18%,
transparent
);
--spdf-compare-added-badge-fg: var(--color-green-500);
--spdf-compare-inline-removed-bg: rgba(255, 59, 48, 0.25);
--spdf-compare-inline-added-bg: rgba(52, 199, 89, 0.25);
--compare-page-label-bg: #0d1020;
--compare-page-label-fg: #c2c8e0;
--spdf-compare-inline-removed-bg: color-mix(
in srgb,
var(--p-red-500) 25%,
transparent
);
--spdf-compare-inline-added-bg: color-mix(
in srgb,
var(--p-green-500) 25%,
transparent
);
--compare-page-label-bg: var(--p-zinc-850);
--compare-page-label-fg: var(--p-gray-300);
/* Orange scale (dark mode mirrors light values to match UI) */
--color-orange-50: #fff4ed;
--color-orange-100: #ffe1cc;
--color-orange-200: #ffb089;
--color-orange-300: #ff7a45;
--color-orange-400: #d84a1b;
--color-orange-50: var(--p-amber-400);
--color-orange-100: var(--p-amber-400);
--color-orange-200: var(--p-red-400);
--color-orange-300: var(--p-red-400);
--color-orange-400: var(--p-red-600);
/* Auth page colors (dark mode) — mirror proprietary so the auth card themes dark */
--auth-bg-color-light-only: var(--bg-muted);
@@ -106,9 +130,13 @@
--auth-input-border-light-only: var(--border-default);
--auth-input-bg-light-only: var(--bg-raised);
--auth-input-text-light-only: var(--text-primary);
--auth-border-focus-light-only: #3b82f6;
--auth-focus-ring-light-only: rgba(59, 130, 246, 0.2);
--auth-button-bg-light-only: #af3434;
--auth-border-focus-light-only: var(--p-blue-500);
--auth-focus-ring-light-only: color-mix(
in srgb,
var(--p-blue-500) 20%,
transparent
);
--auth-button-bg-light-only: var(--p-red-600);
--auth-button-text-light-only: #ffffff;
--auth-magic-button-bg-light-only: var(--bg-raised);
--auth-magic-button-text-light-only: var(--text-primary);
@@ -116,31 +144,29 @@
--auth-text-secondary-light-only: var(--text-secondary);
--text-divider-rule-rgb-light: 229, 231, 235;
--text-divider-label-rgb-light: 156, 163, 175;
--tool-subcategory-rule-color-light: #e5e7eb;
--tool-subcategory-text-color-light: #9ca3af;
--tool-subcategory-rule-color-light: var(--p-gray-200);
--tool-subcategory-text-color-light: var(--p-gray-400);
/* API usage progress bar colors (dark mode) */
--usage-weekly-active: #4f8ef5;
--usage-bought-active: #34d399;
--usage-total-used: #e8eaf6;
--usage-inactive: #1c2340;
--usage-inactive: var(--p-zinc-650);
/* API Keys section colors (dark mode) */
--api-keys-card-bg: #131729;
--api-keys-card-border: #1c2340;
--api-keys-card-bg: var(--p-zinc-800);
--api-keys-card-border: var(--p-zinc-650);
--api-keys-card-shadow: none;
--api-keys-input-bg: #0d1020;
--api-keys-input-border: #1c2340;
--text-divider-rule-color: var(--tool-subcategory-rule-color);
--text-divider-label-color: var(--text-muted);
--api-keys-input-bg: var(--p-zinc-850);
--api-keys-input-border: var(--p-zinc-650);
/* App Config Modal colors (dark mode) */
--modal-nav-bg: #0d1020;
--modal-nav-section-title: #5b6280;
--modal-nav-item: #c2c8e0;
--modal-nav-item-active: #4f8ef5;
--modal-nav-item-active-bg: rgba(79, 142, 245, 0.15);
--modal-content-bg: #131729;
--modal-nav-bg: var(--p-zinc-850);
--modal-nav-section-title: var(--p-zinc-300);
--modal-nav-item: var(--p-gray-300);
--modal-nav-item-active: var(--p-blue-500);
--modal-nav-item-active-bg: color-mix(
in srgb,
var(--p-blue-500) 15%,
transparent
);
--modal-content-bg: var(--p-zinc-800);
--modal-header-border: rgba(255, 255, 255, 0.05);
}