diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index b402df7d1b..35b387025a 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -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" diff --git a/AGENTS.md b/AGENTS.md index 7e529e4be9..259729bc04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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"; diff --git a/frontend/.storybook/manager.tsx b/frontend/.storybook/manager.tsx new file mode 100644 index 0000000000..491a7efac6 --- /dev/null +++ b/frontend/.storybook/manager.tsx @@ -0,0 +1,83 @@ +// Custom toolbar control: an accent-colour swatch picker next to the theme +// toggle. Picking a colour sets the accent for the CURRENT mode (light/dark) — +// it writes the `accentLight` / `accentDark` global, which preview.tsx feeds +// through deriveAccessiblePrimary to tint the canvas exactly like the editor. +import React from "react"; +import { addons, types, useGlobals } from "storybook/manager-api"; +import { IconButton, WithTooltip } from "storybook/internal/components"; +// Single source of truth for the 3×5 accent grid. Imported by relative path +// (not @core) because the Storybook manager bundle doesn't resolve app aliases; +// constants/theme.ts has no imports of its own, so this stays cheap to bundle. +import { THEME_ACCENT_PRESETS as PRESETS } from "../editor/src/core/constants/theme"; + +function AccentPicker() { + const [globals, updateGlobals] = useGlobals(); + const scheme = globals.theme === "dark" ? "dark" : "light"; + const key = scheme === "dark" ? "accentDark" : "accentLight"; + const current = (globals[key] as string) || "#3b82f6"; + + return ( + void }) => ( +
+ {PRESETS.map((color) => { + const selected = color.toLowerCase() === current.toLowerCase(); + return ( +
+ )} + > + + + +
+ ); +} + +addons.register("stirling/accent", () => { + addons.add("stirling/accent-toolbar", { + type: types.TOOL, + title: "Accent colour", + match: ({ viewMode }) => viewMode === "story" || viewMode === "docs", + render: () => , + }); +}); diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index a0b1549f2a..2b03d26637 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -17,6 +17,7 @@ import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; import { UIProvider } from "@portal/contexts/UIContext"; import { SuiProvider } from "@portal/theme/SuiProvider"; +import { deriveAccessiblePrimary } from "@core/utils/customPrimary"; import { handlers } from "@portal/mocks/handlers"; import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient"; import i18next from "i18next"; @@ -29,6 +30,7 @@ import enTranslationToml from "../editor/public/locales/en-US/translation.toml?r 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 +126,33 @@ function ThemeBridge({ return <>{children}; } +/** + * Tints the canvas from the toolbar accent picker (see .storybook/manager.tsx), + * exactly like the editor: always `data-app-theme="custom"`, with + * --user-primary/-on/-accent-fg derived from the active mode's accent global. + */ +function AccentInjector({ + scheme, + accent, +}: { + scheme: "light" | "dark"; + accent: string; +}) { + useEffect(() => { + const root = document.documentElement; + root.setAttribute("data-app-theme", "custom"); + root.setAttribute("data-mantine-color-scheme", scheme); + const { primary, onPrimary, accentForeground } = deriveAccessiblePrimary( + accent, + scheme, + ); + root.style.setProperty("--user-primary", primary); + root.style.setProperty("--user-primary-on", onPrimary); + root.style.setProperty("--user-accent-fg", accentForeground); + }, [scheme, accent]); + return null; +} + const withProviders: Decorator = (Story, context) => { const tier = (context.globals.tier as Tier) ?? "pro"; const linkState = @@ -135,9 +164,15 @@ const withProviders: Decorator = (Story, context) => { // anything that isn't "dark" as light — matching the addon's own // `selected || defaultTheme` fallback where defaultTheme is light. const colorScheme = context.globals.theme === "dark" ? "dark" : "light"; + // Each mode has its own accent (set by the toolbar accent picker). + const accent = + (colorScheme === "dark" + ? (context.globals.accentDark as string) + : (context.globals.accentLight as string)) ?? "#3b82f6"; return ( + {/* LinkProvider must wrap TierProvider: TierContext derives its tier @@ -182,6 +217,10 @@ const preview: Preview = { }, }, globalTypes: { + // Per-mode accent colours, driven by the custom toolbar picker in + // .storybook/manager.tsx (no built-in toolbar UI of their own). + accentLight: { name: "Accent (light)", defaultValue: "#3b82f6" }, + accentDark: { name: "Accent (dark)", defaultValue: "#3b82f6" }, tier: { name: "Tier", description: "Subscription tier — drives useTier() everywhere", diff --git a/frontend/editor/index.html b/frontend/editor/index.html index c5599cb3c8..0dfeade4bf 100644 --- a/frontend/editor/index.html +++ b/frontend/editor/index.html @@ -14,6 +14,51 @@ Stirling PDF + + + diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 9663478000..f34d22b713 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -8980,9 +8980,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" diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs new file mode 100644 index 0000000000..ed3ac96fc0 --- /dev/null +++ b/frontend/editor/scripts/lint/theme-lint.mjs @@ -0,0 +1,244 @@ +#!/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, statSync } from "node:fs"; +import { relative, join } from "node:path"; + +const THEME = join(process.cwd(), "editor/src/core/theme"); +const PRIMITIVES = "editor/src/core/theme/primitives.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 walk(dir, exts, out = []) { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) walk(p, exts, out); + else if (exts.some((e) => name.endsWith(e))) out.push(p); + } + return out; +} +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; + + for (const file of walk(THEME, [".css"])) { + const rel = relative(process.cwd(), file); + const isPrimitives = rel === PRIMITIVES; + const text = stripComments(readFileSync(file, "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"); diff --git a/frontend/editor/src/core/components/fileEditor/FileEditor.module.css b/frontend/editor/src/core/components/fileEditor/FileEditor.module.css index da407cf261..037eddf742 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditor.module.css +++ b/frontend/editor/src/core/components/fileEditor/FileEditor.module.css @@ -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); diff --git a/frontend/editor/src/core/components/shared/LandingActions.tsx b/frontend/editor/src/core/components/shared/LandingActions.tsx index 3d801f82de..407f7939ac 100644 --- a/frontend/editor/src/core/components/shared/LandingActions.tsx +++ b/frontend/editor/src/core/components/shared/LandingActions.tsx @@ -34,12 +34,7 @@ export function LandingActions({ + + +
+ {/* Default — an icon chip for the neutral theme (surfaces untinted, blue buttons); stores the sentinel, a separate state from the colour swatches. */} + {/* eslint-disable-next-line no-restricted-syntax -- an icon chip, not a text button; the shared Button's variants don't fit */} + + {THEME_ACCENT_PRESETS.map((color) => { + const selected = value.toLowerCase() === color.toLowerCase(); + return ( + // eslint-disable-next-line no-restricted-syntax -- a colour swatch, not a text button; the shared Button's variants don't fit +
+ {/* Custom colour — below the presets; our ColorInput, clamped off white/grey/black. */} +
+ + {t( + "settings.general.themeAccentCustomHint", + "Custom (defaults recommended)", + )} + + +
+
+ + ); +} + const GeneralSection: React.FC = ({ hideTitle = false, hideUpdateSection = false, @@ -482,7 +685,7 @@ const GeneralSection: React.FC = ({ {t( "settings.general.themeDescription", - "Choose light, dark, or follow your system", + "Choose light, dark, or follow your system so it switches automatically.", )} @@ -505,29 +708,95 @@ const GeneralSection: React.FC = ({ ]} /> +
- {t("settings.general.language", "Language")} + {t("settings.general.themeAccent", "Accent colour")} {t( - "settings.general.languageDescription", - "Choose the display language", + "settings.general.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.", )}
- + + + + {t("settings.general.themeLight", "Light")} + + updatePreference("lightPrimary", val)} + ariaLabel={t( + "settings.general.themeAccentLight", + "Light mode accent colour", + )} + /> + + + + {t("settings.general.themeDark", "Dark")} + + updatePreference("darkPrimary", val)} + ariaLabel={t( + "settings.general.themeAccentDark", + "Dark mode accent colour", + )} + /> + +
+ + + + {/* Language */} + +
+
+ + {t("settings.general.language", "Language")} + + + {t( + "settings.general.languageDescription", + "Choose the display language", + )} + +
+ +
+
+
= {}; + constructor() { + this.migrateLegacyTheme(); + } + + // One-time migration from the old theme model (theme: light|dark|midnight| + // custom|system + customThemeBase + customThemePrimary) to the new one + // (theme: light|dark|system + lightPrimary + darkPrimary). Runs once and + // rewrites storage; a no-op for already-migrated or empty preferences. + private migrateLegacyTheme(): void { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return; + const p = JSON.parse(stored) as Record; + const hadLegacy = + "customThemeBase" in p || + "customThemePrimary" in p || + p.theme === "custom" || + p.theme === "midnight"; + if (!hadLegacy) return; + + const legacyBase = p.customThemeBase === "light" ? "light" : "dark"; + const legacyPrimary = + typeof p.customThemePrimary === "string" + ? p.customThemePrimary + : DEFAULT_ACCENT; + + // midnight → dark; custom → whichever base it was sitting on. + if (p.theme === "midnight") p.theme = "dark"; + else if (p.theme === "custom") p.theme = legacyBase; + + // Seed the customised side from the old primary; the other side gets the default (neutral) theme. + if (p.lightPrimary === undefined) { + p.lightPrimary = legacyBase === "light" ? legacyPrimary : DEFAULT_ACCENT; + } + if (p.darkPrimary === undefined) { + p.darkPrimary = legacyBase === "dark" ? legacyPrimary : DEFAULT_ACCENT; + } + delete p.customThemeBase; + delete p.customThemePrimary; + localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); + } catch (error) { + console.error("Error migrating theme preferences:", error); + } + } + setServerDefaults(defaults: Partial): void { this.serverDefaults = defaults; } diff --git a/frontend/editor/src/core/styles/theme.css b/frontend/editor/src/core/styles/theme.css index c02965cf0b..a57c3f4e2f 100644 --- a/frontend/editor/src/core/styles/theme.css +++ b/frontend/editor/src/core/styles/theme.css @@ -1,32 +1,26 @@ :root { /* Compare highlight colors (same in light/dark) */ - --spdf-compare-removed-bg: rgba(255, 107, 107, 0.45); /* #ff6b6b @ 0.45 */ - --spdf-compare-added-bg: rgba(81, 207, 102, 0.35); /* #51cf66 @ 0.35 */ + --spdf-compare-removed-bg: color-mix(in srgb, var(--p-red-400) 45%, transparent); /* #ff6b6b @ 0.45 */ + --spdf-compare-added-bg: color-mix(in srgb, var(--p-green-500) 35%, transparent); /* #51cf66 @ 0.35 */ /* Badge colors for dropdowns */ - --spdf-compare-removed-badge-bg: rgba(255, 59, 48, 0.15); - --spdf-compare-removed-badge-fg: #b91c1c; - --spdf-compare-added-badge-bg: rgba(52, 199, 89, 0.18); - --spdf-compare-added-badge-fg: #1b5e20; + --spdf-compare-removed-badge-bg: color-mix(in srgb, var(--p-red-500) 15%, transparent); + --spdf-compare-removed-badge-fg: var(--p-red-600); + --spdf-compare-added-badge-bg: color-mix(in srgb, var(--p-green-500) 18%, transparent); + --spdf-compare-added-badge-fg: var(--p-green-700); /* Inline highlights in summary */ - --spdf-compare-inline-removed-bg: rgba(255, 59, 48, 0.25); - --spdf-compare-inline-added-bg: rgba(52, 199, 89, 0.25); + --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); /* File Manager active file colors */ - --file-active-bg: rgba( - 59, - 130, - 246, - 0.1 - ); /* Transparent blue for active file background */ --file-active-badge-bg: rgba( 34, 197, 94, 0.15 ); /* Transparent green for active badge */ - --file-active-badge-fg: #15803d; /* Green text for active badge */ + --file-active-badge-fg: var(--p-green-700); /* Green text for active badge */ --file-active-badge-border: rgba( 34, 197, @@ -42,13 +36,6 @@ --fullscreen-anim-duration-in: 0.28s; --fullscreen-anim-duration-out: 0.22s; - /* Z-index constants (added in this PR) */ - --z-over-fullscreen-surface: 1400; - --z-fullscreen-surface: 1200; - --z-fullscreen-favorite-star: 2; - --z-fullscreen-icon-svg: 1; - --z-toolpicker-star: 1; - /* Standard gray scale */ --gray-50: 249 250 251; --gray-100: 243 244 246; @@ -67,108 +54,91 @@ --border: 229 231 235; /* Colors for Mantine integration */ - --color-primary-50: #eff6ff; - --color-primary-100: #dbeafe; - --color-primary-200: #bfdbfe; - --color-primary-300: #93c5fd; - --color-primary-400: #60a5fa; - --color-primary-500: #3b82f6; - --color-primary-600: #2563eb; - --color-primary-700: #1d4ed8; - --color-primary-800: #1e40af; - --color-primary-900: #1e3a8a; + --color-primary-50: var(--p-blue-400); + --color-primary-100: var(--p-blue-400); + --color-primary-200: var(--p-blue-400); + --color-primary-300: var(--p-blue-400); + --color-primary-400: var(--p-blue-400); + --color-primary-500: var(--p-blue-500); + --color-primary-600: var(--p-blue-600); + --color-primary-700: var(--p-blue-700); + --color-primary-800: var(--p-blue-700); + --color-primary-900: var(--p-blue-700); /* Success (green) */ - --color-green-50: #f0fdf4; - --color-green-100: #dcfce7; - --color-green-200: #bbf7d0; - --color-green-300: #86efac; - --color-green-400: #4ade80; - --color-green-500: #22c55e; - --color-green-600: #16a34a; - --color-green-700: #15803d; - --color-green-800: #166534; - --color-green-900: #14532d; + --color-green-50: var(--p-green-500); + --color-green-100: var(--p-green-500); + --color-green-200: var(--p-green-500); + --color-green-300: var(--p-green-500); + --color-green-400: var(--p-green-500); + --color-green-500: var(--p-green-500); + --color-green-600: var(--p-green-600); + --color-green-700: var(--p-green-700); + --color-green-800: var(--p-green-700); + --color-green-900: var(--p-green-700); /* Warning (yellow) */ - --color-yellow-50: #fefce8; - --color-yellow-100: #fef9c3; - --color-yellow-200: #fef08a; - --color-yellow-300: #fde047; - --color-yellow-400: #facc15; + --color-yellow-50: var(--p-amber-400); + --color-yellow-100: var(--p-amber-400); + --color-yellow-200: var(--p-amber-400); + --color-yellow-300: var(--p-amber-400); + --color-yellow-400: var(--p-amber-400); /* Category colors - consistent across light and dark modes */ - --category-color-removal: #ef4444; /* Red for removal tools */ - --category-color-security: #f59e0b; /* Orange for security tools */ - --category-color-formatting: #8b5cf6; /* Purple for formatting tools */ - --category-color-extraction: #06b6d4; /* Cyan for extraction tools */ - --category-color-signing: #10b981; /* Green for signing tools */ - --category-color-general: #3b82f6; /* Blue for general tools */ - --category-color-verification: #f97316; /* Orange for verification tools */ - --category-color-automation: #ec4899; /* Pink for automation tools */ - --category-color-developer: #6b7280; /* Gray for developer tools */ - --category-color-default: #6b7280; /* Default gray */ + --category-color-removal: var(--p-red-500); /* Red for removal tools */ + --category-color-security: var(--p-amber-500); /* Orange for security tools */ + --category-color-formatting: var(--p-blue-400); /* Purple for formatting tools */ + --category-color-extraction: var(--p-blue-500); /* Cyan for extraction tools */ + --category-color-signing: var(--p-green-500); /* Green for signing tools */ + --category-color-general: var(--p-blue-500); /* Blue for general tools */ + --category-color-verification: var(--p-amber-600); /* Orange for verification tools */ + --category-color-automation: var(--p-red-400); /* Pink for automation tools */ + --category-color-developer: var(--p-gray-500); /* Gray for developer tools */ + --category-color-default: var(--p-gray-500); /* Default gray */ /* Special section colors - consistent across light and dark modes */ - --special-color-favorites: #ffc107; /* Yellow/gold for favorites */ - --special-color-recommended: #1bb1d4; /* Cyan for recommended */ - --color-yellow-500: #eab308; - --color-yellow-600: #ca8a04; - --color-yellow-700: #a16207; - --color-yellow-800: #854d0e; - --color-yellow-900: #713f12; + --special-color-favorites: var(--p-amber-400); /* Yellow/gold for favorites */ + --special-color-recommended: var(--p-blue-500); /* Cyan for recommended */ + --color-yellow-500: var(--p-amber-500); + --color-yellow-600: var(--p-amber-600); + --color-yellow-700: var(--p-amber-600); + --color-yellow-800: var(--p-amber-600); + --color-yellow-900: var(--p-amber-600); - --color-red-50: #fef2f2; - --color-red-100: #fee2e2; - --color-red-200: #fecaca; - --color-red-300: #fca5a5; - --color-red-400: #f87171; - --color-red-500: #ef4444; - --color-red-600: #dc2626; - --color-red-700: #b91c1c; - --color-red-800: #991b1b; - --color-red-900: #7f1d1d; + --color-red-50: var(--p-red-400); + --color-red-100: var(--p-red-400); + --color-red-200: var(--p-red-400); + --color-red-300: var(--p-red-400); + --color-red-400: var(--p-red-400); + --color-red-500: var(--p-red-500); + --color-red-600: var(--p-red-600); + --color-red-700: var(--p-red-600); + --color-red-800: var(--p-red-600); + --color-red-900: var(--p-red-600); - --color-gray-50: #f9fafb; - --color-gray-100: #f3f4f6; - --color-gray-200: #e5e7eb; - --color-gray-300: #d1d5db; - --color-gray-400: #9ca3af; - --color-gray-500: #6b7280; - --color-gray-600: #4b5563; - --color-gray-700: #374151; - --color-gray-800: #1f2937; - --color-gray-900: #111827; + --color-gray-50: var(--p-gray-50); + --color-gray-100: var(--p-gray-100); + --color-gray-200: var(--p-gray-200); + --color-gray-300: var(--p-gray-300); + --color-gray-400: var(--p-gray-400); + --color-gray-500: var(--p-gray-500); + --color-gray-600: var(--p-gray-600); + --color-gray-700: var(--p-gray-700); + --color-gray-800: var(--p-gray-800); + --color-gray-900: var(--p-gray-900); /* Categorical accents (sidebar groups, file-type icons, badges). One hue per token; the dark block overrides each with a LIGHTER shade for contrast on dark backgrounds. JS consumers go through utils/accentColors.ts. */ - --accent-green: #16a34a; - --accent-blue: #2563eb; - --accent-red: #dc2626; - --accent-orange: #ea580c; - --accent-purple: #9333ea; - --accent-teal: #0d9488; - --accent-pink: #db2777; - --accent-amber: #d97706; - --accent-indigo: #4f46e5; - --accent-cyan: #0891b2; - --accent-violet: #7c3aed; - --accent-gray: #6b7280; - - /* Spacing system */ - --space-xs: 4px; - --space-sm: 8px; - --space-md: 16px; - --space-lg: 24px; - --space-xl: 32px; - - /* Radius system */ - --radius-xs: 2px; - --radius-sm: 4px; - --radius-md: 8px; - --radius-lg: 12px; - --radius-xl: 16px; + --accent-orange: var(--p-amber-600); + --accent-purple: var(--p-blue-500); + --accent-teal: var(--p-blue-700); + --accent-pink: var(--p-red-500); + --accent-amber: var(--p-amber-600); + --accent-indigo: var(--p-blue-600); + --accent-cyan: var(--p-blue-600); + --accent-violet: var(--p-blue-600); + --accent-gray: var(--p-gray-500); /* Shadow system */ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05); @@ -177,201 +147,73 @@ --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1); - /* Font weights */ - --font-weight-normal: 400; - --font-weight-medium: 500; - --font-weight-semibold: 600; - --font-weight-bold: 700; - - /* Light theme semantic colors */ - --bg-surface: #ffffff; - --bg-raised: #f9fafb; - --bg-muted: #f3f4f6; - --bg-background: #f9fafb; - --bg-toolbar: #ffffff; - --bg-file-manager: #f5f6f8; - --bg-file-list: #ffffff; - --btn-open-file: #0a8bff; - --text-primary: #111827; - --text-secondary: #4b5563; - --text-muted: #6b7280; /* Always-dark text (for use on light backgrounds like alerts) - does not change in dark mode */ - --text-always-dark: #1f2937; - --text-always-dark-muted: #6b7280; - --border-subtle: #e5e7eb; - --border-default: #e2e8f0; - --border-strong: #9ca3af; - --border-hover: #9ca3af; - --hover-bg: #f9fafb; - --active-bg: #f3f4f6; - --automation-entry-hover-bg: var(--color-gray-100); + --text-always-dark: var(--p-gray-800); + --text-always-dark-muted: var(--p-gray-500); /* Icon colors for light mode */ - --icon-user-bg: #9ca3af; - --icon-user-color: #ffffff; - --icon-notifications-bg: #9ca3af; - --icon-notifications-color: #ffffff; - --icon-tools-bg: #1e88e5; + --icon-tools-bg: var(--p-blue-500); --icon-tools-color: #ffffff; - --icon-read-bg: #4caf50; - --icon-read-color: #ffffff; - --icon-sign-bg: #3ba99c; - --icon-sign-color: #ffffff; - --icon-automate-bg: #a576e3; - --icon-automate-color: #ffffff; - --icon-files-bg: #d3e7f7; - --icon-files-color: #0a8bff; - --icon-activity-bg: #d3e7f7; - --icon-activity-color: #0a8bff; - --icon-config-bg: #9ca3af; - --icon-config-color: #ffffff; + --icon-files-color: var(--p-blue-500); /* Colors for tooltips */ - --tooltip-title-bg: #dbefff; - --tooltip-title-color: #31528e; - --tooltip-header-bg: #31528e; + --tooltip-header-bg: var(--p-blue-700); --tooltip-header-color: white; --tooltip-border: var(--border-default); /* Inactive icon colors for light mode */ - --icon-inactive-bg: #9ca3af; + --icon-inactive-bg: var(--p-gray-400); --icon-inactive-color: #ffffff; - /* New theme colors for text and icons */ - --tools-text-and-icon-color: var(--text-primary); - - /* Tool picker sticky header variables (light mode) */ - --tool-header-bg: #dbefff; - --tool-header-border: #bee2ff; - --tool-header-text: #1e88e5; - --tool-header-badge-bg: #c0ddff; - --tool-header-badge-text: #004e99; - - /* Subcategory title styling (light mode) */ - --tool-subcategory-text-color: #9ca3af; /* lighter text */ - --tool-subcategory-rule-color: #e5e7eb; /* doubly lighter rule line */ - --accent-interactive: #4a90e2; - --text-instruction: #4a90e2; - --text-brand: var(--color-gray-700); - --text-brand-accent: #dc2626; +/* doubly lighter rule line */ + --text-brand-accent: var(--p-red-600); /* PDF text selection colors */ - --pdf-selection-bg: rgba(59, 130, 246, 0.2); - --pdf-selection-ring: rgba(59, 130, 246, 0.18); - - /* Placeholder text colors */ - --search-text-and-icon-color: #6b7382; - --input-bg: #ffffff; - - /* Tool panel search bar background colors */ - --tool-panel-search-bg: #eff1f4; - --tool-panel-search-border-bottom: #eff1f4; + --pdf-selection-bg: color-mix(in srgb, var(--p-blue-500) 20%, transparent); + --pdf-selection-ring: color-mix(in srgb, var(--p-blue-500) 18%, transparent); /* container */ - --landing-paper-bg: var(--bg-surface); - --landing-inner-paper-bg: #eef8ff; - --landing-inner-paper-border: #cdeaff; --landing-button-bg: var(--bg-surface); - --landing-button-color: var(--icon-tools-bg); - --landing-button-border: #e0f2f7; - --landing-button-hover-bg: rgb(251, 251, 251); + --landing-button-border: var(--p-blue-400); + --landing-button-hover-bg: var(--p-gray-50); /* drop state */ - --landing-drop-paper-bg: #e3f2fd; - --landing-drop-inner-paper-bg: #bbdefb; - --landing-drop-inner-paper-border: #90caf9; + --landing-drop-inner-paper-bg: var(--p-blue-400); + --landing-drop-inner-paper-border: var(--p-blue-400); /* landing hero & stack */ - --landing-hero-gradient: linear-gradient(135deg, #4c8bf5 0%, #3a7be8 100%); + --landing-hero-gradient: linear-gradient(135deg, var(--p-blue-500) 0%, var(--p-blue-500) 100%); --landing-stack-w: 224px; --landing-stack-h: 176px; - --landing-stack-glow-bg: radial-gradient( - circle, - rgba(74, 144, 226, 0.18) 0%, - transparent 70% - ); /* landing doc stack shadows */ --landing-doc-shadow-back-idle: 0 4px 20px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04); - --landing-doc-shadow-back-hover: - 0 12px 40px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08); --landing-doc-shadow-front-idle: 0 8px 30px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.02); - --landing-doc-shadow-front-hover: - 0 18px 48px rgba(0, 0, 0, 0.18), 0 8px 20px rgba(0, 0, 0, 0.1), - 0 0 0 1px rgba(0, 0, 0, 0.04); - - /* landing action button shadows */ - --landing-action-transition: - transform 0.28s cubic-bezier(0.4, 0, 0.2, 1), - box-shadow 0.32s cubic-bezier(0.4, 0, 0.2, 1); - --landing-action-shadow-idle: - 0 6px 18px rgba(0, 0, 0, 0), 0 2px 6px rgba(0, 0, 0, 0); - --landing-action-shadow-hover: - 0 6px 18px rgba(0, 0, 0, 0.14), 0 2px 6px rgba(0, 0, 0, 0.08); - --landing-action-primary-shadow-idle: - 0 10px 26px rgba(58, 123, 232, 0), 0 4px 12px rgba(0, 0, 0, 0); - --landing-action-primary-shadow-hover: - 0 10px 26px rgba(58, 123, 232, 0.42), 0 4px 12px rgba(0, 0, 0, 0.1); /* selected file header colors */ - --header-selected-bg: #1e88e5; /* light mode selected header matches dark */ + --header-selected-bg: var(--p-blue-500); /* light mode selected header matches dark */ --header-selected-fg: #ffffff; - --file-card-bg: #ffffff; /* file card background (light/dark paired) */ - --accordion-item-bg: #e8eaed; /* accordion item background - more distinguishable */ - /* shadows */ - --drop-shadow-color: rgba(0, 0, 0, 0.08); - --drop-shadow-color-strong: rgba(0, 0, 0, 0.04); - --drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(0, 0, 0, 0.08)) - drop-shadow(0 0.6rem 0.6rem rgba(0, 0, 0, 0.06)) - drop-shadow(0 1.2rem 1rem rgba(0, 0, 0, 0.04)); +/* slightly more blue than dark mode header */ - /* Light mode card hover and selection */ - --header-hover-bg: #3b4b6e; /* same family as selected, a touch muted for hover */ - --card-selected-border: #3fafff; /* slightly more blue than dark mode header */ - --checkbox-border: #2f83bf; - --checkbox-checked-bg: #3fafff; - --checkbox-tick: #ffffff; - - --information-text-bg: #eaeaea; - --information-text-color: #5e5e5e; - /* Bulk selection panel specific colors (light mode) */ - --bulk-panel-bg: #ffffff; /* white background for parent container */ - --bulk-card-bg: #ffffff; /* white background for cards */ - --bulk-card-border: #e5e7eb; /* light gray border for cards and buttons */ - --bulk-card-hover-border: #d1d5db; /* slightly darker on hover */ - --unsupported-bar-bg: #5a616e; - --unsupported-bar-border: #6b7280; + --information-text-color: var(--p-zinc-400); +/* white background for cards */ + --bulk-card-border: var(--p-gray-200); /* light gray border for cards and buttons */ + --bulk-card-hover-border: var(--p-gray-300); /* slightly darker on hover */ /* 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-content-bg: #ffffff; - --modal-header-border: rgba(0, 0, 0, 0.06); + --modal-nav-item: var(--p-gray-700); /* API Keys section colors (light mode) */ - --api-keys-card-bg: #ffffff; - --api-keys-card-border: #e0e0e0; --api-keys-card-shadow: rgba(0, 0, 0, 0.06); - --api-keys-input-bg: #f8f8f8; - --api-keys-input-border: #e0e0e0; /* PDF Report Colors (always light) */ - --pdf-light-header-bg: 239 246 255; - --pdf-light-accent: 59 130 246; - --pdf-light-text-primary: 30 41 59; --pdf-light-text-muted: 100 116 139; --pdf-light-box-bg: 248 250 252; --pdf-light-box-border: 226 232 240; - --pdf-light-warning: 234 179 8; - --pdf-light-danger: 248 113 113; - --pdf-light-success: 34 197 94; --pdf-light-neutral: 148 163 184; --pdf-light-status-valid-bg: 209 250 229; --pdf-light-status-valid-text: 6 95 70; @@ -381,32 +223,20 @@ --pdf-light-status-invalid-text: 153 27 27; --pdf-light-status-neutral-bg: 229 231 235; --pdf-light-status-neutral-text: 55 65 81; - --pdf-light-report-container-bg: 249 250 251; - --pdf-light-simulated-page-bg: 255 255 255; --pdf-light-simulated-page-text: 15 23 42; /* Code token colors (light mode) */ - --code-kw-color: #1d4ed8; /* blue-700 */ - --code-str-color: #16a34a; /* green-600 */ - --code-num-color: #4338ca; /* indigo-700 */ - --code-com-color: #6b7280; /* gray-500 */ - /* Compare tool specific colors - only for colors that don't have existing theme pairs */ - --compare-upload-dropzone-bg: rgba(241, 245, 249, 0.45); - --compare-upload-dropzone-border: rgba(148, 163, 184, 0.6); - --compare-upload-icon-bg: rgba(148, 163, 184, 0.2); - --compare-upload-icon-color: rgba(17, 24, 39, 0.75); - --compare-upload-divider: rgba(148, 163, 184, 0.5); + --code-kw-color: var(--p-blue-700); /* blue-700 */ + --code-str-color: var(--p-green-600); /* green-600 */ + --code-num-color: var(--p-blue-700); /* indigo-700 */ + --code-com-color: var(--p-gray-500); /* gray-500 */ /* Compare page label chip (light mode): slightly lighter than surrounding rows */ - --compare-page-label-bg: var(--bg-muted); --compare-page-label-fg: var(--text-secondary); /* Status indicator dot ring shadow */ --status-dot-ring: 0 0 0 2px rgba(0, 0, 0, 0.08); - /* Input element styling */ - --input-bg: #ffffff; - /* Select/dropdown placeholder state */ --select-placeholder-bg: var(--mantine-color-gray-1); --select-placeholder-text: var(--mantine-color-gray-6); @@ -414,7 +244,6 @@ /* Grouped format dropdown */ --dropdown-trigger-bg: var(--mantine-color-white); --dropdown-trigger-text: var(--mantine-color-dark-9); - --dropdown-trigger-text-disabled: var(--mantine-color-dark-7); --dropdown-trigger-icon: var(--mantine-color-gray-6); --dropdown-panel-bg: var(--mantine-color-white); --dropdown-panel-border: var(--mantine-color-gray-4); @@ -423,211 +252,140 @@ /* Onboarding (light mode) */ :root { - --onboarding-title: #0a0a0a; - --onboarding-body: #4a5565; - --onboarding-step-active: #1e2939; - --onboarding-step-inactive: #d1d5dc; + --onboarding-step-active: var(--p-gray-800); + --onboarding-step-inactive: var(--p-gray-300); } [data-mantine-color-scheme="dark"] { - /* Dark theme gray scale (inverted) — navy-indigo tinted */ - --gray-50: 9 11 24; - --gray-100: 13 16 32; - --gray-200: 19 23 41; - --gray-300: 28 35 64; - --gray-400: 45 55 96; - --gray-500: 90 98 128; - --gray-600: 144 153 185; - --gray-700: 195 200 230; - --gray-800: 218 222 245; - --gray-900: 238 241 252; + /* Inverted dark gray scale — RGB channels for Tailwind's rgb(var(--gray-N)). */ + --gray-50: 10 10 11; /* zinc-950 */ + --gray-100: 16 16 18; /* zinc-900 */ + --gray-200: 24 24 27; /* zinc-800 */ + --gray-300: 42 42 46; /* zinc-650 */ + --gray-400: 63 63 70; /* zinc-500 */ + --gray-500: 82 82 91; /* zinc-400 */ + --gray-600: 113 113 122; /* zinc-300 */ + --gray-700: 161 161 170; /* zinc-200 */ + --gray-800: 212 212 216; + --gray-900: 244 244 245; /* zinc-100 */ - /* Dark semantic colors for Tailwind — aligned with portal tokens.css */ - --surface: 21 28 46; /* #151c2e — portal --color-surface */ - --background: 9 12 20; /* #090c14 — portal --color-bg */ - --border: 40 50 72; /* #283248 — portal --color-border */ + /* Dark semantic channels for Tailwind. */ + --surface: 24 24 27; /* zinc-800 */ + --background: 10 10 11; /* zinc-950 */ + --border: 42 42 46; /* zinc-650 */ /* Dark theme Mantine colors */ - --color-red-50: #2d1b1b; - --color-red-100: #3a2323; - --color-red-200: #4a2d2d; - --color-red-300: #5c3535; - --color-red-400: #7c4a4a; - --color-red-500: #ef4444; - --color-red-600: #dc2626; - --color-red-700: #b91c1c; - --color-red-800: #991b1b; - --color-red-900: #7f1d1d; + --color-red-50: var(--p-zinc-775); + --color-red-100: var(--p-zinc-700); + --color-red-200: var(--p-zinc-600); + --color-red-300: var(--p-zinc-500); + --color-red-400: var(--p-zinc-400); + --color-red-500: var(--p-red-500); + --color-red-600: var(--p-red-600); + --color-red-700: var(--p-red-600); + --color-red-800: var(--p-red-600); + --color-red-900: var(--p-red-600); - --color-gray-50: #111827; - --color-gray-100: #1f2329; - --color-gray-200: #2a2f36; - --color-gray-300: #374151; - --color-gray-400: #4b5563; - --color-gray-500: #6b7280; - --color-gray-600: #9ca3af; - --color-gray-700: #d1d5db; - --color-gray-800: #e5e7eb; - --color-gray-900: #f3f4f6; + --color-gray-50: var(--p-gray-900); + --color-gray-100: var(--p-zinc-775); + --color-gray-200: var(--p-zinc-650); + --color-gray-300: var(--p-gray-700); + --color-gray-400: var(--p-gray-600); + --color-gray-500: var(--p-gray-500); + --color-gray-600: var(--p-gray-400); + --color-gray-700: var(--p-gray-300); + --color-gray-800: var(--p-gray-200); + --color-gray-900: var(--p-gray-100); /* Categorical accents: lighter shades of the same hues, for contrast on dark. */ - --accent-green: #4ade80; - --accent-blue: #60a5fa; - --accent-red: #f87171; - --accent-orange: #fb923c; - --accent-purple: #c084fc; - --accent-teal: #2dd4bf; - --accent-pink: #f472b6; - --accent-amber: #fbbf24; - --accent-indigo: #818cf8; - --accent-cyan: #22d3ee; - --accent-violet: #a78bfa; - --accent-gray: #9ca3af; + --accent-orange: var(--p-amber-500); + --accent-purple: var(--p-blue-400); + --accent-teal: var(--p-blue-400); + --accent-pink: var(--p-red-400); + --accent-amber: var(--p-amber-400); + --accent-indigo: var(--p-blue-400); + --accent-cyan: var(--p-blue-400); + --accent-violet: var(--p-blue-400); + --accent-gray: var(--p-gray-400); /* Category colors - same as light mode for consistency */ - --category-color-removal: #ef4444; /* Red for removal tools */ - --category-color-security: #f59e0b; /* Orange for security tools */ - --category-color-formatting: #8b5cf6; /* Purple for formatting tools */ - --category-color-extraction: #06b6d4; /* Cyan for extraction tools */ - --category-color-signing: #10b981; /* Green for signing tools */ - --category-color-general: #3b82f6; /* Blue for general tools */ - --category-color-verification: #f97316; /* Orange for verification tools */ - --category-color-automation: #ec4899; /* Pink for automation tools */ - --category-color-developer: #6b7280; /* Gray for developer tools */ - --category-color-default: #6b7280; /* Default gray */ + --category-color-removal: var(--p-red-500); /* Red for removal tools */ + --category-color-security: var(--p-amber-500); /* Orange for security tools */ + --category-color-formatting: var(--p-blue-400); /* Purple for formatting tools */ + --category-color-extraction: var(--p-blue-500); /* Cyan for extraction tools */ + --category-color-signing: var(--p-green-500); /* Green for signing tools */ + --category-color-general: var(--p-blue-500); /* Blue for general tools */ + --category-color-verification: var(--p-amber-600); /* Orange for verification tools */ + --category-color-automation: var(--p-red-400); /* Pink for automation tools */ + --category-color-developer: var(--p-gray-500); /* Gray for developer tools */ + --category-color-default: var(--p-gray-500); /* Default gray */ /* Special section colors - same as light mode for consistency */ - --special-color-favorites: #ffc107; /* Yellow/gold for favorites */ - --special-color-recommended: #1bb1d4; /* Cyan for recommended */ + --special-color-favorites: var(--p-amber-400); /* Yellow/gold for favorites */ + --special-color-recommended: var(--p-blue-500); /* Cyan for recommended */ /* Success (green) - dark */ - --color-green-50: #052e16; - --color-green-100: #064e3b; - --color-green-200: #065f46; - --color-green-300: #047857; - --color-green-400: #059669; - --color-green-500: #22c55e; - --color-green-600: #16a34a; - --color-green-700: #4ade80; - --color-green-800: #86efac; - --color-green-900: #bbf7d0; + --color-green-50: var(--p-green-700); + --color-green-100: var(--p-green-700); + --color-green-200: var(--p-green-700); + --color-green-300: var(--p-green-700); + --color-green-400: var(--p-green-600); + --color-green-500: var(--p-green-500); + --color-green-600: var(--p-green-600); + --color-green-700: var(--p-green-500); + --color-green-800: var(--p-green-500); + --color-green-900: var(--p-green-500); /* Warning (yellow) - dark */ - --color-yellow-50: #451a03; - --color-yellow-100: #713f12; - --color-yellow-200: #854d0e; - --color-yellow-300: #a16207; - --color-yellow-400: #ca8a04; - --color-yellow-500: #eab308; - --color-yellow-600: #facc15; - --color-yellow-700: #fde047; - --color-yellow-800: #fef08a; - --color-yellow-900: #fef9c3; + --color-yellow-50: var(--p-amber-600); + --color-yellow-100: var(--p-amber-600); + --color-yellow-200: var(--p-amber-600); + --color-yellow-300: var(--p-amber-600); + --color-yellow-400: var(--p-amber-600); + --color-yellow-500: var(--p-amber-500); + --color-yellow-600: var(--p-amber-400); + --color-yellow-700: var(--p-amber-400); + --color-yellow-800: var(--p-amber-400); + --color-yellow-900: var(--p-amber-400); - /* Dark theme semantic colors — deep navy/indigo palette, aligned with portal - tokens.css so the editor and portal (Processor) dark modes don't drift. - Chrome surfaces (--bg-toolbar, --bg-file-manager) sit at the portal's - sidebar level (#0d1120) so the left/right sidebars lift off the darker - canvas (--bg-background) instead of blending into it. */ - --bg-surface: #151c2e; /* portal --color-surface */ - --bg-raised: #0d1120; /* portal --color-bg-alt */ - --bg-muted: #0d1120; - --bg-background: #090c14; /* portal --color-bg */ - --bg-toolbar: #0d1120; /* portal --color-sidebar-bg — lifted off the canvas */ - --bg-file-manager: #0d1120; /* portal --color-sidebar-bg */ - --bg-file-list: #151c2e; /* portal --color-surface */ - --btn-open-file: #4f8ef5; - --text-primary: #e8eaf6; - --text-secondary: #9299b0; - --text-muted: #5b6280; + /* Dark theme semantic colours — the neutral zinc ramp. These legacy + `--bg-*`/`--text-*` names are shadowed by compat.css (→ --c-*), so this is + kept only for any consumer not yet on the semantic tokens. */ /* Always-dark text (for use on light backgrounds like alerts) - does not change in dark mode */ - --text-always-dark: #1f2937; - --text-always-dark-muted: #6b7280; - --border-subtle: rgba(255, 255, 255, 0.05); - --border-default: #1e2840; /* portal --color-border-light */ - --border-strong: #283248; /* portal --color-border */ - --border-hover: #3d4f6a; /* portal --color-border-hover */ - --hover-bg: #1c2640; /* portal --color-bg-hover */ - --active-bg: #243044; /* portal --color-bg-muted */ - --automation-entry-hover-bg: var(--color-gray-200); + --text-always-dark: var(--p-gray-800); + --text-always-dark-muted: var(--p-gray-500); /* Icon colors for dark mode */ - --icon-user-bg: #131729; - --icon-user-color: #6e7898; - --icon-notifications-bg: #131729; - --icon-notifications-color: #6e7898; - --icon-tools-bg: #1c2340; - --icon-tools-color: #e0e3f8; - --icon-read-bg: #1c2340; - --icon-read-color: #e0e3f8; - --icon-sign-bg: #1c2340; - --icon-sign-color: #e0e3f8; - --icon-automate-bg: #1c2340; - --icon-automate-color: #e0e3f8; - --icon-files-bg: #1c2340; - --icon-files-color: #e0e3f8; - --icon-activity-bg: #1c2340; - --icon-activity-color: #e0e3f8; - --icon-config-bg: #1c2340; - --icon-config-color: #e0e3f8; + --icon-tools-bg: var(--p-zinc-650); + --icon-tools-color: var(--p-blue-400); + --icon-files-color: var(--p-blue-400); /* Inactive icon colors for dark mode */ - --icon-inactive-bg: #131729; - --icon-inactive-color: #6e7898; + --icon-inactive-bg: var(--p-zinc-800); + --icon-inactive-color: var(--p-gray-500); /* Dark mode tooltip colors */ - --tooltip-title-bg: #1c2340; - --tooltip-title-color: #e8eaf6; --tooltip-header-bg: var(--bg-raised); --tooltip-header-color: var(--text-primary); --tooltip-border: var(--border-default); - --accent-interactive: #e8eaf6; - --text-instruction: #e8eaf6; - --text-brand: var(--color-gray-800); - --text-brand-accent: #ef4444; + --text-brand-accent: var(--p-red-500); /* Compare badge text colors (dark mode): lighter for readability */ --spdf-compare-removed-badge-fg: var(--color-red-500); --spdf-compare-added-badge-fg: var(--color-green-500); - /* container */ - --landing-paper-bg: #090b18; - --landing-inner-paper-bg: var(--bg-raised); - --landing-inner-paper-border: #1c2340; - /* landing dark overrides */ - --landing-stack-glow-bg: radial-gradient( - circle, - rgba(50, 90, 210, 0.22) 0%, - transparent 70% - ); --landing-doc-shadow-back-idle: 0 4px 20px rgba(0, 0, 0, 0.45), 0 1px 3px rgba(0, 0, 0, 0.35); - --landing-doc-shadow-back-hover: - 0 14px 44px rgba(0, 0, 0, 0.65), 0 6px 16px rgba(0, 0, 0, 0.45); --landing-doc-shadow-front-idle: 0 8px 30px rgba(0, 0, 0, 0.55), 0 4px 12px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.04); - --landing-doc-shadow-front-hover: - 0 20px 52px rgba(0, 0, 0, 0.7), 0 10px 24px rgba(0, 0, 0, 0.5), - 0 0 0 1px rgba(255, 255, 255, 0.06); - --landing-button-color: #e8eaf6; --landing-button-hover-bg: var(--bg-raised); /* selected file header colors for dark */ - --header-selected-bg: #2040a0; + --header-selected-bg: var(--p-blue-700); --header-selected-fg: #ffffff; - /* file card background (dark) */ - --file-card-bg: #0d1120; - --accordion-item-bg: #1c2340; /* accordion item background - more distinguishable */ - - /* shadows */ - --drop-shadow-color: rgba(255, 255, 255, 0.05); - --drop-shadow-color-strong: rgba(255, 255, 255, 0.03); - --drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(0, 0, 0, 0.3)) - drop-shadow(0 0.6rem 0.6rem rgba(0, 0, 0, 0.2)) - drop-shadow(0 1.2rem 1rem rgba(0, 0, 0, 0.15)); /* Adjust shadows for dark mode */ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4); @@ -636,87 +394,41 @@ --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5); --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.5); - --tools-text-and-icon-color: var(--text-primary); - - /* Tool picker sticky header variables (dark mode) */ - --tool-header-bg: #131729; - --tool-header-border: #1c2340; - --tool-header-text: #c2c8e0; - --tool-header-badge-bg: #1c2340; - --tool-header-badge-text: #e8eaf6; - - /* Subcategory title styling (dark mode) */ - --tool-subcategory-text-color: #5b6280; - --tool-subcategory-rule-color: #1c2340; - /* PDF text selection colors (dark mode) */ - --pdf-selection-bg: rgba(96, 130, 250, 0.25); - --pdf-selection-ring: rgba(96, 130, 250, 0.28); - - /* Placeholder text colors (dark mode) */ - --search-text-and-icon-color: #e8eaf6 !important; - --input-bg: #1c2340; + --pdf-selection-bg: color-mix(in srgb, var(--p-blue-400) 25%, transparent); + --pdf-selection-ring: color-mix(in srgb, var(--p-blue-400) 28%, transparent); /* Tool panel search bar background colors (dark mode) */ - --tool-panel-search-bg: #0d1020; - --tool-panel-search-border-bottom: #1c2340; - --information-text-bg: #131729; - --information-text-color: #e0e3f8; + --information-text-color: var(--p-blue-400); - /* Bulk selection panel specific colors (dark mode) */ - --bulk-panel-bg: var(--bg-raised); /* dark background for parent container */ - --bulk-card-bg: var(--bg-raised); /* dark background for cards */ +/* dark background for cards */ --bulk-card-border: var( --border-default ); /* default border for cards and buttons */ --bulk-card-hover-border: var(--border-strong); /* stronger border on hover */ - --unsupported-bar-bg: #0d1020; - --unsupported-bar-border: #1c2340; /* 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-header-border: rgba(255, 255, 255, 0.05); + --modal-nav-item: var(--p-gray-300); /* Onboarding (dark mode) */ - --onboarding-title: #e8eaf6; - --onboarding-body: #9299b0; - --onboarding-step-active: #d8dcf0; - --onboarding-step-inactive: #2d3560; + --onboarding-step-active: var(--p-gray-250); + --onboarding-step-inactive: var(--p-zinc-500); /* API Keys section colors (dark mode) */ - --api-keys-card-bg: #131729; - --api-keys-card-border: #1c2340; --api-keys-card-shadow: none; - --api-keys-input-bg: #0d1020; - --api-keys-input-border: #1c2340; /* Code token colors (dark mode - Cursor-like) */ - --code-kw-color: #c792ea; /* purple */ - --code-str-color: #c3e88d; /* green */ - --code-num-color: #f78c6c; /* orange */ - --code-com-color: #697098; /* muted gray-blue */ - /* Compare tool specific colors (dark mode) - only for colors that don't have existing theme pairs */ - --compare-upload-dropzone-bg: rgba(9, 11, 24, 0.45); - --compare-upload-dropzone-border: rgba(28, 35, 64, 0.6); - --compare-upload-icon-bg: rgba(28, 35, 64, 0.2); - --compare-upload-icon-color: rgba(232, 234, 246, 0.75); - --compare-upload-divider: rgba(28, 35, 64, 0.5); + --code-kw-color: var(--p-blue-400); /* purple */ + --code-str-color: var(--p-green-500); /* green */ + --code-num-color: var(--p-red-400); /* orange */ + --code-com-color: var(--p-gray-500); /* muted gray-blue */ /* Compare page label chip (dark mode): slightly darker than surrounding rows */ - --compare-page-label-bg: #0d1020; --compare-page-label-fg: var(--text-secondary); /* Status indicator dot ring shadow (dark) */ --status-dot-ring: 0 0 0 2px rgba(255, 255, 255, 0.15); - /* Input element styling (dark) */ - --input-bg: #1c2340; - /* Select/dropdown placeholder state (dark) */ --select-placeholder-bg: var(--mantine-color-dark-5); --select-placeholder-text: var(--mantine-color-dark-2); @@ -724,7 +436,6 @@ /* Grouped format dropdown (dark) */ --dropdown-trigger-bg: var(--mantine-color-dark-6); --dropdown-trigger-text: var(--mantine-color-dark-0); - --dropdown-trigger-text-disabled: var(--mantine-color-dark-1); --dropdown-trigger-icon: var(--mantine-color-dark-2); --dropdown-panel-bg: var(--mantine-color-dark-7); --dropdown-panel-border: var(--mantine-color-dark-4); @@ -739,7 +450,6 @@ /* Plan section card borders - only override in dark mode */ [data-mantine-color-scheme="dark"] .plan-card { - --paper-border-color: rgb(44, 52, 120) !important; } [data-mantine-color-scheme="dark"] .plan-card [data-size="sm"] { @@ -809,22 +519,22 @@ border-radius: 8px; } -/* Smooth transitions for theme switching */ +/* Smooth transitions for theme switching. Text `color` is deliberately EXCLUDED: + it must swap instantly, never fade white↔black through grey. */ * { transition: background-color 0.2s ease, - border-color 0.2s ease, - color 0.2s ease; + border-color 0.2s ease; } /* ── PDF Link Overlay (viewer) ── */ :root { - --link-hover-bg: rgba(10, 139, 255, 0.1); - --link-hover-border: rgba(10, 139, 255, 0.32); + --link-hover-bg: color-mix(in srgb, var(--p-blue-500) 10%, transparent); + --link-hover-border: color-mix(in srgb, var(--p-blue-500) 32%, transparent); --link-hover-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35), 0 1px 4px rgba(10, 139, 255, 0.12); - --link-focus-ring: rgba(10, 139, 255, 0.25); - --link-toolbar-bg: rgba(15, 23, 42, 0.88); + --link-focus-ring: color-mix(in srgb, var(--p-blue-500) 25%, transparent); + --link-toolbar-bg: color-mix(in srgb, var(--p-blue-700) 88%, transparent); --link-toolbar-border: rgba(255, 255, 255, 0.1); --link-toolbar-shadow: 0 12px 40px rgba(0, 0, 0, 0.2), 0 4px 12px rgba(0, 0, 0, 0.16), @@ -832,12 +542,12 @@ } [data-mantine-color-scheme="dark"] { - --link-hover-bg: rgba(79, 130, 245, 0.14); - --link-hover-border: rgba(79, 130, 245, 0.38); + --link-hover-bg: color-mix(in srgb, var(--p-blue-500) 14%, transparent); + --link-hover-border: color-mix(in srgb, var(--p-blue-500) 38%, transparent); --link-hover-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1), 0 1px 4px rgba(79, 142, 245, 0.18); - --link-focus-ring: rgba(79, 130, 245, 0.3); - --link-toolbar-bg: rgba(9, 11, 24, 0.94); + --link-focus-ring: color-mix(in srgb, var(--p-blue-500) 30%, transparent); + --link-toolbar-bg: color-mix(in srgb, var(--p-blue-700) 94%, transparent); --link-toolbar-border: rgba(255, 255, 255, 0.07); --link-toolbar-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 4px 12px rgba(0, 0, 0, 0.35), @@ -1021,7 +731,7 @@ } :root { - --shadow-color: rgba(15, 23, 42, 0.55); + --shadow-color: color-mix(in srgb, var(--p-blue-700) 55%, transparent); } [data-theme="dark"] { @@ -1039,9 +749,8 @@ display: block; } -/* Theme-aware image display utilities */ -/* Use .theme-img-light-only for images to show in light mode only */ -/* Use .theme-img-dark-only for images to show in dark mode only */ +/* Theme-aware image display utilities: .theme-img-light-only shows in light + mode only, .theme-img-dark-only shows in dark mode only. */ .theme-img-light-only { display: inline; } @@ -1105,8 +814,8 @@ fill: var(--mantine-color-body); } -/* Wordmark muted variant (used in empty/placeholder states) */ -/* Stirling text: 30% opacity in light mode, full opacity in dark */ +/* Wordmark muted variant (empty/placeholder states). + Stirling text: 30% opacity in light mode, full opacity in dark. */ .wordmark__text--muted { fill-opacity: 0.3; } diff --git a/frontend/editor/src/core/theme/README.md b/frontend/editor/src/core/theme/README.md new file mode 100644 index 0000000000..8abc110723 --- /dev/null +++ b/frontend/editor/src/core/theme/README.md @@ -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 ``. 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 ``: `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 ``: + +- `--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. diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css new file mode 100644 index 0000000000..0746292be7 --- /dev/null +++ b/frontend/editor/src/core/theme/colors.css @@ -0,0 +1,206 @@ +/* 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); +} + +/* ── CUSTOM — --user-primary drives every accent + a surface tint (whole app takes the hue); the [data-accent="default"] blocks at the end opt surfaces back to neutral. LIGHT base here, DARK base next. ── */ +html[data-app-theme="custom"] { + --c-primary: var(--user-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); + /* Foreground on the filled primary — contrast-picked in JS (flips white/black). */ + --c-text-on-primary: var(--user-primary-on, var(--p-white)); + /* Accent as text/icon on a surface — JS lightness-clamped for legibility; fills/borders keep raw --c-primary. */ + --c-accent-fg: var(--user-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); +} diff --git a/frontend/editor/src/core/theme/compat.css b/frontend/editor/src/core/theme/compat.css new file mode 100644 index 0000000000..77162317d2 --- /dev/null +++ b/frontend/editor/src/core/theme/compat.css @@ -0,0 +1,136 @@ +/* COMPAT — legacy token names (editor --bg-*/--text-* and 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); +} diff --git a/frontend/editor/src/core/theme/dimensions.css b/frontend/editor/src/core/theme/dimensions.css new file mode 100644 index 0000000000..8042fcade6 --- /dev/null +++ b/frontend/editor/src/core/theme/dimensions.css @@ -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; +} diff --git a/frontend/editor/src/core/theme/index.css b/frontend/editor/src/core/theme/index.css new file mode 100644 index 0000000000..dc4523c653 --- /dev/null +++ b/frontend/editor/src/core/theme/index.css @@ -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"; diff --git a/frontend/editor/src/core/theme/mantineTheme.ts b/frontend/editor/src/core/theme/mantineTheme.ts index 743142e820..7e88b7b2ef 100644 --- a/frontend/editor/src/core/theme/mantineTheme.ts +++ b/frontend/editor/src/core/theme/mantineTheme.ts @@ -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) + "#0a0a0b", // dark-7 — page background (zinc-950) + "#070708", // dark-8 — deeper than the reachable surfaces + "#050506", // dark-9 — deepest ]; export const mantineTheme = createTheme({ diff --git a/frontend/editor/src/core/theme/primitives.css b/frontend/editor/src/core/theme/primitives.css new file mode 100644 index 0000000000..4cf4be221b --- /dev/null +++ b/frontend/editor/src/core/theme/primitives.css @@ -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: #0a0a0b; + --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; +} diff --git a/frontend/editor/src/core/tokens/Tokens.stories.tsx b/frontend/editor/src/core/tokens/Tokens.stories.tsx index f83c93517c..7543d7d4d6 100644 --- a/frontend/editor/src/core/tokens/Tokens.stories.tsx +++ b/frontend/editor/src/core/tokens/Tokens.stories.tsx @@ -92,8 +92,10 @@ export const Colours: Story = { void; + /** Fires once when picking finishes (pointer up / blur), not every drag frame — for committing expensive updates. */ + onChangeEnd?: (value: string) => void; defaultValue?: string; // Behaviour @@ -30,6 +32,8 @@ export interface ColorInputProps { swatches?: string[]; swatchesPerRow?: number; withPicker?: boolean; + /** Restrict the gamut live: every change is run through this (handle sticks at the boundary), receiving the previous in-gamut value so a clamp can preserve hue at achromatic extremes. */ + clampValue?: (value: string, previous: string) => string; // Popover escape hatch — only for zIndex / offset overrides in modals popoverProps?: MantineColorInputProps["popoverProps"]; @@ -44,8 +48,8 @@ export interface ColorInputProps { required?: boolean; disabled?: boolean; readOnly?: boolean; - onFocus?: React.FocusEventHandler; - onBlur?: React.FocusEventHandler; + onFocus?: FocusEventHandler; + onBlur?: FocusEventHandler; // SUI — invalid applies error styling; FormField renders the message itself. inputSize?: ColorInputSize; @@ -57,6 +61,7 @@ type PassthroughProps = Omit< MantineColorInputProps, | "value" | "onChange" + | "onChangeEnd" | "defaultValue" | "format" | "swatches" @@ -90,6 +95,8 @@ export function ColorInput({ format = "hex", value, onChange, + onChangeEnd, + clampValue, defaultValue, swatches, swatchesPerRow, @@ -108,9 +115,27 @@ export function ColorInput({ onBlur, }: ColorInputProps) { const inputRef = useInputAria({ describedBy: ariaDescribedBy }); + + // With a clamp, this component owns the displayed value so the picker reflects the constraint live (clamped and mirrored back each frame); without one, value/handlers pass straight through. + const [clamped, setClamped] = useState(value ?? defaultValue ?? ""); + useEffect(() => { + if (value !== undefined) setClamped(value); + }, [value]); + + const constrainedProps = clampValue + ? { + value: clamped, + onChange: (next: string) => { + const c = clampValue(next, clamped); + setClamped(c); + onChange?.(c); + }, + onChangeEnd: (next: string) => onChangeEnd?.(clampValue(next, clamped)), + } + : { value, onChange, onChangeEnd }; + const passthroughProps: PassthroughProps = { - value, - onChange, + ...constrainedProps, defaultValue, format, swatches, diff --git a/frontend/editor/src/core/ui/accents.css b/frontend/editor/src/core/ui/accents.css index 75001f6777..1d47b4ec89 100644 --- a/frontend/editor/src/core/ui/accents.css +++ b/frontend/editor/src/core/ui/accents.css @@ -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); diff --git a/frontend/editor/src/core/utils/customPrimary.ts b/frontend/editor/src/core/utils/customPrimary.ts new file mode 100644 index 0000000000..d4d242c7d5 --- /dev/null +++ b/frontend/editor/src/core/utils/customPrimary.ts @@ -0,0 +1,141 @@ +// Derives an accessible custom-theme primary from a user-chosen colour: a lightness clamp keeps it visible on the base, and the on-primary colour is contrast-picked so filled content stays legible. + +import type { ColorScheme } from "@app/constants/theme"; + +export interface DerivedPrimary { + /** Contrast-safe primary to drive --user-primary. Tuned for FILLS. */ + primary: string; + /** Legible foreground for content on the filled primary. */ + onPrimary: string; + /** Accent tuned as a foreground (text/icon) on the app surface — light on dark, dark on light. */ + accentForeground: string; +} + +interface RGB { + r: number; + g: number; + b: number; +} + +const FALLBACK = "#3b82f6"; // blue-500 — fallback when a stored colour won't parse + +function parseHex(input: string): RGB | null { + const s = input.trim().replace(/^#/, ""); + const hex = + s.length === 3 + ? s + .split("") + .map((c) => c + c) + .join("") + : s; + if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null; + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16), + }; +} + +function rgbToHsl({ r, g, b }: RGB): { h: number; s: number; l: number } { + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + const d = max - min; + if (d === 0) return { h: 0, s: 0, l }; + const s = d / (1 - Math.abs(2 * l - 1)); + let h: number; + if (max === rn) h = ((gn - bn) / d) % 6; + else if (max === gn) h = (bn - rn) / d + 2; + else h = (rn - gn) / d + 4; + h *= 60; + if (h < 0) h += 360; + return { h, s, l }; +} + +function hslToHex(h: number, s: number, l: number): string { + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + const [r, g, b] = + h < 60 + ? [c, x, 0] + : h < 120 + ? [x, c, 0] + : h < 180 + ? [0, c, x] + : h < 240 + ? [0, x, c] + : h < 300 + ? [x, 0, c] + : [c, 0, x]; + const to = (v: number) => + Math.round((v + m) * 255) + .toString(16) + .padStart(2, "0"); + return `#${to(r)}${to(g)}${to(b)}`; +} + +function relLuminance({ r, g, b }: RGB): number { + const lin = (v: number) => { + const c = v / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} + +// Relative-luminance cutoff above which on-primary flips white→black (tuned high so saturated brand hues keep white text). +const ON_PRIMARY_DARK_CUTOFF = 0.62; + +// Lightness floor (dark base) / ceiling (light base) so an accent stays distinguishable from the base surfaces. +const DARK_MIN_L = 0.42; +const LIGHT_MAX_L = 0.6; + +// Stricter lightness bounds for an accent used as foreground text/icon (light on dark, dark on light). +const DARK_FG_MIN_L = 0.62; +const LIGHT_FG_MAX_L = 0.45; + +// Saturation/lightness bounds so a stored accent stays a real, visible hue (never white/grey/black). +const ACCENT_MIN_S = 0.35; +const ACCENT_MIN_L = 0.32; +const ACCENT_MAX_L = 0.68; + +// Clamp a pick into gamut; at achromatic extremes (no hue in hex) borrow the hue from `previous` so black/white/grey give the darkest/lightest/least-saturated version of the working hue, not an unrelated colour. +export function clampAccentChoice(input: string, previous?: string): string { + const rgb = parseHex(input); + if (!rgb) return FALLBACK; + const { h, s, l } = rgbToHsl(rgb); + const prevRgb = previous ? parseHex(previous) : null; + const hue = s < 1e-6 && prevRgb ? rgbToHsl(prevRgb).h : h; + return hslToHex( + hue, + Math.max(s, ACCENT_MIN_S), + Math.min(Math.max(l, ACCENT_MIN_L), ACCENT_MAX_L), + ); +} + +export function deriveAccessiblePrimary( + input: string, + base: ColorScheme, +): DerivedPrimary { + const rgb = parseHex(input) ?? parseHex(FALLBACK)!; + const { h, s } = rgbToHsl(rgb); + let { l } = rgbToHsl(rgb); + if (base === "dark" && l < DARK_MIN_L) l = DARK_MIN_L; + if (base === "light" && l > LIGHT_MAX_L) l = LIGHT_MAX_L; + + const primary = hslToHex(h, s, l); + const lum = relLuminance(parseHex(primary)!); + // White by default; flip to black only once the colour is genuinely light. + const onPrimary = lum > ON_PRIMARY_DARK_CUTOFF ? "#000000" : "#ffffff"; + + // Foreground accent: same hue, lightness pushed so it stays legible on the base surface. + let fgL = rgbToHsl(rgb).l; + if (base === "dark") fgL = Math.max(fgL, DARK_FG_MIN_L); + else fgL = Math.min(fgL, LIGHT_FG_MAX_L); + const accentForeground = hslToHex(h, s, fgL); + + return { primary, onPrimary, accentForeground }; +} diff --git a/frontend/editor/src/portal/components/EditorStatusCard.css b/frontend/editor/src/portal/components/EditorStatusCard.css index 83426bae91..557e47ff61 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.css +++ b/frontend/editor/src/portal/components/EditorStatusCard.css @@ -15,7 +15,7 @@ align-items: center; gap: 1.25rem; padding: 1rem 1.25rem; - background: #16213e; + background: color-mix(in srgb, var(--c-primary) 22%, var(--p-zinc-950)); } .portal-editor-hero__logo { diff --git a/frontend/editor/src/portal/components/WelcomeBanner.css b/frontend/editor/src/portal/components/WelcomeBanner.css index 5c63462754..c2e8cd40bc 100644 --- a/frontend/editor/src/portal/components/WelcomeBanner.css +++ b/frontend/editor/src/portal/components/WelcomeBanner.css @@ -18,7 +18,7 @@ gap: 1rem; flex-wrap: wrap; padding: 0.875rem 1.25rem; - background: #16213e; + background: color-mix(in srgb, var(--c-primary) 22%, var(--p-zinc-950)); } .portal-welcome__brand { diff --git a/frontend/editor/src/portal/contexts/ThemeContext.tsx b/frontend/editor/src/portal/contexts/ThemeContext.tsx index ad979f5e91..5b0dcf3d31 100644 --- a/frontend/editor/src/portal/contexts/ThemeContext.tsx +++ b/frontend/editor/src/portal/contexts/ThemeContext.tsx @@ -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(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(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(() => + 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( - () => ({ + const value = useMemo(() => { + 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 ( {children} diff --git a/frontend/editor/src/proprietary/auth/ui/auth-theme.css b/frontend/editor/src/proprietary/auth/ui/auth-theme.css index f62691f518..b13e19d348 100644 --- a/frontend/editor/src/proprietary/auth/ui/auth-theme.css +++ b/frontend/editor/src/proprietary/auth/ui/auth-theme.css @@ -2,25 +2,25 @@ :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 +31,9 @@ --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); diff --git a/frontend/editor/src/saas/styles/saas-theme.css b/frontend/editor/src/saas/styles/saas-theme.css index 654fe5fe17..e60670ece1 100644 --- a/frontend/editor/src/saas/styles/saas-theme.css +++ b/frontend/editor/src/saas/styles/saas-theme.css @@ -2,101 +2,93 @@ :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 +98,9 @@ --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 +108,26 @@ --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; + --api-keys-input-bg: var(--p-zinc-850); + --api-keys-input-border: var(--p-zinc-650); - --text-divider-rule-color: var(--tool-subcategory-rule-color); - --text-divider-label-color: var(--text-muted); /* 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); } diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 9793c60ac8..6a65e970d9 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -323,5 +323,10 @@ export default defineConfig( ...globals.node, }, }, + rules: { + // Standalone node scripts have no bundler, so workspace aliases (@app/*) + // don't resolve — relative imports are correct here. + "no-restricted-imports": "off", + }, }, );