mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>(
|
||||
|
||||
+29
-25
@@ -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
|
||||
|
||||
@@ -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
@@ -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 1–3 **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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user