From 0618991776cf5a34b03aee9e4017eb641c6909d4 Mon Sep 17 00:00:00 2001 From: cevheri Date: Sun, 6 Sep 2026 19:32:14 +0300 Subject: [PATCH 1/5] test(theme): measure accent contrast instead of recording it in prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every contrast figure in the repository is prose: measured once by hand and written into a comment. Prose does not fail when the value it describes moves, which is how a token tuned against a near-black ground survived into a light theme at 1.46:1 (#402). tests/helpers/contrast.ts does the arithmetic the browser does — oklch through Oklab to clipped sRGB, alpha compositing in the gamma-encoded space, WCAG luminance in the linear one — and reads the Tailwind palette out of node_modules rather than transcribing it, so a Tailwind upgrade that restyles blue-400 reports itself as the dark-mode change it is. theme.css gains the accent layer #384 left out of scope: four state roles with two text steps, a wash and a filled ground each, plus a fifteen-hue identity palette selected per mode the way charts/palette.ts selects. Light floor 4.79:1, dark floor 4.97:1, and the light identity set is no tighter anywhere than the dark set it is modelled on. --- src/styles/theme.css | 261 ++++++++++++++++ tests/helpers/contrast.ts | 198 +++++++++++++ tests/unit/theme-accent-contrast.test.ts | 361 +++++++++++++++++++++++ 3 files changed, 820 insertions(+) create mode 100644 tests/helpers/contrast.ts create mode 100644 tests/unit/theme-accent-contrast.test.ts diff --git a/src/styles/theme.css b/src/styles/theme.css index 53d56cb6..e6988d44 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -74,6 +74,118 @@ --studio-fg-subtle: #a1a1aa; /* Below the ramp: decoration and hints — separator dots, empty-state subtext. */ --studio-fg-faint: #d4d4d8; + + /* + * ── Accents: state ──────────────────────────────────────────────────── + * #402. The neutral ramp was tokenised in #384; the accents were not, so they + * kept values selected against a near-black ground and were then asked to serve + * two. `text-blue-300` on `bg-blue-500/15` reads at 9.5:1 over `#0a0a0a` and at + * 1.46:1 over `#fafafa` — the same two class names, a chip you can see and a + * word you cannot read. + * + * Four roles, each with two text steps. `-bright` is the emphasised one, and it + * inverts exactly like `fg-bright` does: brighter than its base in dark, darker + * in light. Both directions mean "further from the ground". + * + * `-tint` is the wash the roles are painted over (`bg-accent-tint/15`), and it is + * the SAME value in both palettes on purpose — a wash is alpha over whatever is + * behind it, so it already adapts, and the text step above is what has to move. + * `-solid` and `-solid-hover` are the filled-button grounds, mode-independent for + * the same reason: a button's label sits on the button, not on the page. + * + * Every value here was selected by measurement, not by eye, and + * `tests/unit/theme-accent-contrast.test.ts` re-measures it on every run. + */ + --studio-accent: #1447e6; + --studio-accent-bright: #193cb8; + --studio-accent-tint: #2b7fff; + --studio-accent-solid: #155dfc; + --studio-accent-solid-hover: #2b7fff; + + --studio-warning: #973c00; + --studio-warning-bright: #7b3306; + --studio-warning-tint: #fe9a00; + --studio-warning-solid: #e17100; + --studio-warning-solid-hover: #fe9a00; + + --studio-success: #006045; + --studio-success-bright: #004f3b; + --studio-success-tint: #00bc7d; + --studio-success-solid: #009966; + --studio-success-solid-hover: #00bc7d; + + --studio-danger: #9f0712; + --studio-danger-bright: #82181a; + --studio-danger-tint: #fb2c36; + --studio-danger-solid: #e7000b; + --studio-danger-solid-hover: #fb2c36; + + /* + * ── Accents: identity ───────────────────────────────────────────────── + * Colour that answers WHICH THING, not what state: which engine, which bottom + * panel, added vs removed, primary key vs foreign key, number vs boolean. Folding + * these into the four state roles would repaint nineteen engines in four colours, + * so they get their own family — SELECTED per mode, the way `charts/palette.ts` + * selects rather than flipping a ramp, and for the same reason: the two modes run + * out of room in different places. Dark's tightest pair is amber/yellow; light's + * is blue/indigo. + * + * `-alt` is a second step of the same hue. For sky, yellow, emerald and teal it + * is a second IDENTITY — there are more engines than there are hues, and + * `db-ui-config`'s own test asserts every engine colour differs — and those four + * therefore join the separation set. Elsewhere it is emphasis on one identity. + * + * Two deviations from "the lightest step that clears AA", both forced by + * separation rather than by contrast: amber sits at -900 because -800 is already + * an orange at that lightness and collides with orange itself, and yellow's alt + * drops to -950 for the same reason. The warm hues converge as they darken; the + * cold ones do not. + */ + --studio-hue-blue: #1447e6; + --studio-hue-blue-alt: #193cb8; + --studio-hue-blue-tint: #2b7fff; + --studio-hue-sky: #00598a; + --studio-hue-sky-alt: #024a70; + --studio-hue-sky-tint: #00a6f4; + --studio-hue-indigo: #432dd7; + --studio-hue-indigo-alt: #372aac; + --studio-hue-indigo-tint: #615fff; + --studio-hue-violet: #7008e7; + --studio-hue-violet-alt: #5d0ec0; + --studio-hue-violet-tint: #8e51ff; + --studio-hue-purple: #8200db; + --studio-hue-purple-alt: #6e11b0; + --studio-hue-purple-tint: #ad46ff; + --studio-hue-pink: #a3004c; + --studio-hue-pink-alt: #861043; + --studio-hue-pink-tint: #f6339a; + --studio-hue-rose: #a50036; + --studio-hue-rose-alt: #8b0836; + --studio-hue-rose-tint: #ff2056; + --studio-hue-red: #9f0712; + --studio-hue-red-alt: #82181a; + --studio-hue-red-tint: #fb2c36; + --studio-hue-orange: #9f2d00; + --studio-hue-orange-alt: #7e2a0c; + --studio-hue-orange-tint: #ff6900; + --studio-hue-amber: #7b3306; + --studio-hue-amber-alt: #461901; + --studio-hue-amber-tint: #fe9a00; + --studio-hue-yellow: #894b00; + --studio-hue-yellow-alt: #432004; + --studio-hue-yellow-tint: #f0b100; + --studio-hue-green: #016630; + --studio-hue-green-alt: #0d542b; + --studio-hue-green-tint: #00c950; + --studio-hue-emerald: #006045; + --studio-hue-emerald-alt: #004f3b; + --studio-hue-emerald-tint: #00bc7d; + --studio-hue-teal: #005f5a; + --studio-hue-teal-alt: #022f2e; + --studio-hue-teal-tint: #00bba7; + --studio-hue-cyan: #005f78; + --studio-hue-cyan-alt: #104e64; + --studio-hue-cyan-tint: #00b8db; } .dark { @@ -108,6 +220,85 @@ --studio-fg-muted: #71717a; --studio-fg-subtle: #52525b; --studio-fg-faint: #3f3f46; + + /* + * ── Accents ─────────────────────────────────────────────────────────── + * Every value below is the literal the components carried before #402 — the -400 + * step for a base, -300 for `-bright` and `-alt` — reproduced exactly. Switching + * a component onto an accent token must not change how it looks in dark, and + * `tests/unit/theme-accent-contrast.test.ts` resolves these against the installed + * Tailwind palette rather than against transcribed hexes, so a Tailwind upgrade + * that restyles `blue-400` reports itself as the dark-mode change it is. + */ + --studio-accent: #51a2ff; + --studio-accent-bright: #8ec5ff; + --studio-accent-tint: #2b7fff; + --studio-accent-solid: #155dfc; + --studio-accent-solid-hover: #2b7fff; + + --studio-warning: #ffb900; + --studio-warning-bright: #ffd230; + --studio-warning-tint: #fe9a00; + --studio-warning-solid: #e17100; + --studio-warning-solid-hover: #fe9a00; + + --studio-success: #00d492; + --studio-success-bright: #5ee9b5; + --studio-success-tint: #00bc7d; + --studio-success-solid: #009966; + --studio-success-solid-hover: #00bc7d; + + --studio-danger: #ff6467; + --studio-danger-bright: #ffa2a2; + --studio-danger-tint: #fb2c36; + --studio-danger-solid: #e7000b; + --studio-danger-solid-hover: #fb2c36; + + --studio-hue-blue: #51a2ff; + --studio-hue-blue-alt: #8ec5ff; + --studio-hue-blue-tint: #2b7fff; + --studio-hue-sky: #00bcff; + --studio-hue-sky-alt: #74d4ff; + --studio-hue-sky-tint: #00a6f4; + --studio-hue-indigo: #7c86ff; + --studio-hue-indigo-alt: #a3b3ff; + --studio-hue-indigo-tint: #615fff; + --studio-hue-violet: #a684ff; + --studio-hue-violet-alt: #c4b4ff; + --studio-hue-violet-tint: #8e51ff; + --studio-hue-purple: #c27aff; + --studio-hue-purple-alt: #dab2ff; + --studio-hue-purple-tint: #ad46ff; + --studio-hue-pink: #fb64b6; + --studio-hue-pink-alt: #fda5d5; + --studio-hue-pink-tint: #f6339a; + --studio-hue-rose: #ff637e; + --studio-hue-rose-alt: #ffa1ad; + --studio-hue-rose-tint: #ff2056; + --studio-hue-red: #ff6467; + --studio-hue-red-alt: #ffa2a2; + --studio-hue-red-tint: #fb2c36; + --studio-hue-orange: #ff8904; + --studio-hue-orange-alt: #ffb86a; + --studio-hue-orange-tint: #ff6900; + --studio-hue-amber: #ffb900; + --studio-hue-amber-alt: #ffd230; + --studio-hue-amber-tint: #fe9a00; + --studio-hue-yellow: #fdc700; + --studio-hue-yellow-alt: #ffdf20; + --studio-hue-yellow-tint: #f0b100; + --studio-hue-green: #05df72; + --studio-hue-green-alt: #7bf1a8; + --studio-hue-green-tint: #00c950; + --studio-hue-emerald: #00d492; + --studio-hue-emerald-alt: #5ee9b5; + --studio-hue-emerald-tint: #00bc7d; + --studio-hue-teal: #00d5be; + --studio-hue-teal-alt: #46ecd5; + --studio-hue-teal-tint: #00bba7; + --studio-hue-cyan: #00d3f2; + --studio-hue-cyan-alt: #53eafd; + --studio-hue-cyan-tint: #00b8db; } /* @@ -144,4 +335,74 @@ --color-fg-muted: var(--studio-fg-muted); --color-fg-subtle: var(--studio-fg-subtle); --color-fg-faint: var(--studio-fg-faint); + + --color-accent: var(--studio-accent); + --color-accent-bright: var(--studio-accent-bright); + --color-accent-tint: var(--studio-accent-tint); + --color-accent-solid: var(--studio-accent-solid); + --color-accent-solid-hover: var(--studio-accent-solid-hover); + + --color-warning: var(--studio-warning); + --color-warning-bright: var(--studio-warning-bright); + --color-warning-tint: var(--studio-warning-tint); + --color-warning-solid: var(--studio-warning-solid); + --color-warning-solid-hover: var(--studio-warning-solid-hover); + + --color-success: var(--studio-success); + --color-success-bright: var(--studio-success-bright); + --color-success-tint: var(--studio-success-tint); + --color-success-solid: var(--studio-success-solid); + --color-success-solid-hover: var(--studio-success-solid-hover); + + --color-danger: var(--studio-danger); + --color-danger-bright: var(--studio-danger-bright); + --color-danger-tint: var(--studio-danger-tint); + --color-danger-solid: var(--studio-danger-solid); + --color-danger-solid-hover: var(--studio-danger-solid-hover); + + --color-hue-blue: var(--studio-hue-blue); + --color-hue-blue-alt: var(--studio-hue-blue-alt); + --color-hue-blue-tint: var(--studio-hue-blue-tint); + --color-hue-sky: var(--studio-hue-sky); + --color-hue-sky-alt: var(--studio-hue-sky-alt); + --color-hue-sky-tint: var(--studio-hue-sky-tint); + --color-hue-indigo: var(--studio-hue-indigo); + --color-hue-indigo-alt: var(--studio-hue-indigo-alt); + --color-hue-indigo-tint: var(--studio-hue-indigo-tint); + --color-hue-violet: var(--studio-hue-violet); + --color-hue-violet-alt: var(--studio-hue-violet-alt); + --color-hue-violet-tint: var(--studio-hue-violet-tint); + --color-hue-purple: var(--studio-hue-purple); + --color-hue-purple-alt: var(--studio-hue-purple-alt); + --color-hue-purple-tint: var(--studio-hue-purple-tint); + --color-hue-pink: var(--studio-hue-pink); + --color-hue-pink-alt: var(--studio-hue-pink-alt); + --color-hue-pink-tint: var(--studio-hue-pink-tint); + --color-hue-rose: var(--studio-hue-rose); + --color-hue-rose-alt: var(--studio-hue-rose-alt); + --color-hue-rose-tint: var(--studio-hue-rose-tint); + --color-hue-red: var(--studio-hue-red); + --color-hue-red-alt: var(--studio-hue-red-alt); + --color-hue-red-tint: var(--studio-hue-red-tint); + --color-hue-orange: var(--studio-hue-orange); + --color-hue-orange-alt: var(--studio-hue-orange-alt); + --color-hue-orange-tint: var(--studio-hue-orange-tint); + --color-hue-amber: var(--studio-hue-amber); + --color-hue-amber-alt: var(--studio-hue-amber-alt); + --color-hue-amber-tint: var(--studio-hue-amber-tint); + --color-hue-yellow: var(--studio-hue-yellow); + --color-hue-yellow-alt: var(--studio-hue-yellow-alt); + --color-hue-yellow-tint: var(--studio-hue-yellow-tint); + --color-hue-green: var(--studio-hue-green); + --color-hue-green-alt: var(--studio-hue-green-alt); + --color-hue-green-tint: var(--studio-hue-green-tint); + --color-hue-emerald: var(--studio-hue-emerald); + --color-hue-emerald-alt: var(--studio-hue-emerald-alt); + --color-hue-emerald-tint: var(--studio-hue-emerald-tint); + --color-hue-teal: var(--studio-hue-teal); + --color-hue-teal-alt: var(--studio-hue-teal-alt); + --color-hue-teal-tint: var(--studio-hue-teal-tint); + --color-hue-cyan: var(--studio-hue-cyan); + --color-hue-cyan-alt: var(--studio-hue-cyan-alt); + --color-hue-cyan-tint: var(--studio-hue-cyan-tint); } diff --git a/tests/helpers/contrast.ts b/tests/helpers/contrast.ts new file mode 100644 index 00000000..e33728ed --- /dev/null +++ b/tests/helpers/contrast.ts @@ -0,0 +1,198 @@ +/** + * Colour arithmetic for the theme tests. + * + * Every contrast figure this repository has ever recorded — the chart palette's + * header, the login showcase comment, `AgentRail`'s "3.98:1" — is PROSE. It was + * measured once, by hand, in a browser, and written down. Prose does not fail when + * somebody changes the value it describes, which is how a token tuned against a + * near-black ground survived into a light theme at 1.46:1 (#402). + * + * So this is deliberately not a mock or a fixture: it is the measurement, run in + * the test process, from the same numbers the browser paints from. + * + * Three things it has to get right, all of which are easy to get subtly wrong: + * + * 1. Tailwind v4 ships its palette as `oklch()`, not hex. The conversion has to + * go oklch → Oklab → LMS → linear sRGB → gamma-encoded sRGB, and the vivid + * steps land outside the sRGB gamut and are CLIPPED per channel — which is + * what browsers do, and what makes the result equal Tailwind's own published + * hexes. Skipping the clip silently shifts every ratio. + * + * 2. An opacity modifier (`bg-accent-tint/15`) is not a colour, it is a colour + * that has to be composited over whatever is behind it before it can be + * measured. `text-x on bg-y/15` is a question about the COMPOSITE, and it is + * the question #402 turned on: blue-300 over `blue-500/15` is 9.48:1 on the + * dark ground and 1.46:1 on the light one — same two class names. + * + * 3. WCAG relative luminance is defined on LINEAR sRGB, while CSS alpha + * compositing happens in the gamma-encoded space. Compositing in the wrong + * one moves the answer by enough to flip a pass/fail at the 4.5:1 boundary. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +export type Rgb = readonly [number, number, number]; + +const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); + +/** sRGB transfer function, gamma-encoded → linear. */ +const toLinear = (channel: number) => + channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + +/** sRGB transfer function, linear → gamma-encoded. */ +const toGamma = (channel: number) => + channel <= 0.0031308 ? 12.92 * channel : 1.055 * channel ** (1 / 2.4) - 0.055; + +/** + * Oklab → linear sRGB (Björn Ottosson's matrices). Kept separate from the oklch + * entry point because the identity-palette separation check needs Oklab itself, + * not a colour derived from it. + */ +function oklabToLinearSrgb(L: number, a: number, b: number): Rgb { + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ]; +} + +/** Linear sRGB → Oklab, the inverse of the above. */ +export function toOklab([r, g, b]: Rgb): Rgb { + const lr = toLinear(r); + const lg = toLinear(g); + const lb = toLinear(b); + const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); + const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); + const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 0.2429228246 * m - 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + ]; +} + +/** + * Perceptual distance between two colours. Plain Euclidean distance in Oklab — + * which is what Oklab is FOR, and what "these two identity hues are still + * distinguishable" has to be asked in. Asking it in sRGB would call + * `#00c951` and `#00d492` far apart and `#1447e6` and `#193cb8` close. + */ +export function deltaEOk(a: Rgb, b: Rgb): number { + const [l1, a1, b1] = toOklab(a); + const [l2, a2, b2] = toOklab(b); + return Math.hypot(l1 - l2, a1 - a2, b1 - b2); +} + +/** oklch(L% C H) → gamma-encoded sRGB, clipped to gamut the way a browser clips. */ +export function oklchToRgb(lightnessPercent: number, chroma: number, hueDegrees: number): Rgb { + const hue = (hueDegrees * Math.PI) / 180; + const linear = oklabToLinearSrgb( + lightnessPercent / 100, + chroma * Math.cos(hue), + chroma * Math.sin(hue), + ); + return linear.map((channel) => toGamma(clamp01(channel))) as unknown as Rgb; +} + +export const toHex = (rgb: Rgb): string => + `#${rgb.map((channel) => Math.round(channel * 255).toString(16).padStart(2, "0")).join("")}`; + +/** + * Parse the colour syntaxes the token layer actually writes: `#rgb`, `#rrggbb`, + * `rgb(r g b / a)` and `oklch(L% C H)`. Returns the colour and its alpha + * separately, because a token carrying its own alpha (`--studio-hairline`) has to + * be composited before it can be measured, exactly like an opacity modifier. + */ +export function parseColor(value: string): { rgb: Rgb; alpha: number } { + const text = value.trim(); + + const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(text); + if (hex) { + const digits = + hex[1].length === 3 + ? hex[1] + .split("") + .map((d) => d + d) + .join("") + : hex[1]; + const rgb = [0, 2, 4].map((i) => Number.parseInt(digits.slice(i, i + 2), 16) / 255) as unknown as Rgb; + return { rgb, alpha: 1 }; + } + + const rgbFn = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[/,]\s*([\d.]+%?)\s*)?\)$/i.exec(text); + if (rgbFn) { + const rgb = [rgbFn[1], rgbFn[2], rgbFn[3]].map((n) => Number(n) / 255) as unknown as Rgb; + return { rgb, alpha: parseAlpha(rgbFn[4]) }; + } + + // `none` is a real oklch component value, and Tailwind writes it for the + // achromatic steps (`oklch(98.5% 0 none)` — white has no hue). CSS resolves it + // to zero in a plain colour, so parsing it as anything else drops the step. + const oklch = /^oklch\(\s*([\d.]+)%\s+([\d.]+|none)\s+([\d.]+|none)\s*(?:\/\s*([\d.]+%?)\s*)?\)$/i.exec(text); + if (oklch) { + const component = (raw: string) => (raw.toLowerCase() === "none" ? 0 : Number(raw)); + return { + rgb: oklchToRgb(Number(oklch[1]), component(oklch[2]), component(oklch[3])), + alpha: parseAlpha(oklch[4]), + }; + } + + throw new Error(`contrast: cannot parse colour ${JSON.stringify(value)}`); +} + +const parseAlpha = (raw: string | undefined): number => { + if (raw === undefined) return 1; + return raw.endsWith("%") ? Number(raw.slice(0, -1)) / 100 : Number(raw); +}; + +/** + * Lay `foreground` at `alpha` over `background`, source-over, in the + * gamma-encoded space the browser composites in. A tint is only a colour once + * this has happened; before that it is a question with two answers, one per + * theme, which is the whole of #402. + */ +export function composite(foreground: Rgb, background: Rgb, alpha: number): Rgb { + return foreground.map((channel, i) => channel * alpha + background[i] * (1 - alpha)) as unknown as Rgb; +} + +/** WCAG 2.1 relative luminance — defined on linear sRGB, not on the hex digits. */ +export function luminance([r, g, b]: Rgb): number { + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); +} + +/** WCAG 2.1 contrast ratio. Order-independent by construction. */ +export function contrast(a: Rgb, b: Rgb): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +/** + * The installed Tailwind palette, read from the package rather than transcribed. + * + * Transcribing it would make the "dark is a no-op" assertion a statement about a + * number somebody typed in 2026, not about what the components render. A Tailwind + * upgrade that restyles `blue-400` IS a dark-mode change, and this is what makes + * the suite say so instead of staying green. + */ +export function tailwindPalette(root: string): Map { + const css = readFileSync(join(root, "node_modules", "tailwindcss", "theme.css"), "utf8"); + const palette = new Map(); + for (const [, name, body] of css.matchAll(/--color-([a-z]+-\d{2,3}):\s*(oklch\([^)]*\));/g)) { + palette.set(name, parseColor(body).rgb); + } + if (palette.size < 100) { + throw new Error(`contrast: only ${palette.size} Tailwind colours parsed — the theme.css format moved`); + } + return palette; +} + +/** `blue-400` → its sRGB value, or a loud failure naming the step. */ +export function tailwindStep(palette: Map, step: string): Rgb { + const rgb = palette.get(step); + if (!rgb) throw new Error(`contrast: Tailwind has no colour named ${step}`); + return rgb; +} diff --git a/tests/unit/theme-accent-contrast.test.ts b/tests/unit/theme-accent-contrast.test.ts new file mode 100644 index 00000000..e4d3a35e --- /dev/null +++ b/tests/unit/theme-accent-contrast.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + composite, + contrast, + deltaEOk, + parseColor, + tailwindPalette, + tailwindStep, + toHex, +} from "../helpers/contrast"; + +/** + * #402: the accents kept dark-tuned values on light grounds. + * + * #384 moved every NEUTRAL colour behind a token and left accents out of scope, so + * `bg-blue-500/15 text-blue-300` — a chip you can read on `#0a0a0a` at 9.5:1 — went + * on rendering over `#fafafa`, where the same two class names measure 1.46:1. The + * pair was never a colour choice; it was a question whose answer depends on what is + * behind it, and only one of the two answers had ever been looked at. + * + * This file is the answer being looked at, every run. It is deliberately NOT a + * snapshot of the values: it recomputes the ratios from the tokens as declared and + * from the Tailwind palette as installed, so it fails when either moves. + * + * Three separate contracts, and they fail for different reasons: + * + * 1. LEGIBILITY — every accent that carries text clears WCAG AA (4.5:1) on every + * ground it can land on, in BOTH palettes. This is the acceptance criterion. + * 2. NO-OP IN DARK — the dark value of each token is exactly the Tailwind step the + * components carried before the migration. #384's rule, and the one #384 itself + * broke once (the editor scrollbar). Resolved from `node_modules`, not + * transcribed, so a Tailwind upgrade that restyles `blue-400` reports itself as + * what it is: a dark-mode change. + * 3. SEPARATION — the identity hues encode WHICH THING, not what state, so they + * have to stay tellable apart. Selected per mode like `charts/palette.ts`, and + * held to the shipped dark set's own minimum rather than to a number invented + * here. + */ + +const ROOT = join(import.meta.dir, "..", ".."); +const theme = readFileSync(join(ROOT, "src", "styles", "theme.css"), "utf8"); +const palette = tailwindPalette(ROOT); + +const stripComments = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, ""); + +function declarations(selector: string): Map { + const css = stripComments(theme); + const start = css.indexOf(`${selector} {`); + expect(start).toBeGreaterThan(-1); + const end = css.indexOf("\n}", start); + expect(end).toBeGreaterThan(start); + return new Map( + Array.from(css.slice(start, end).matchAll(/(--studio-[a-z0-9-]+)\s*:\s*([^;]+);/g), (m) => [ + m[1], + m[2].trim(), + ]), + ); +} + +const light = declarations(":root"); +const dark = declarations(".dark"); + +const value = (palette: Map, token: string): string => { + const declared = palette.get(token); + if (!declared) throw new Error(`theme.css declares no ${token}`); + return declared; +}; + +const rgb = (palettes: Map, token: string) => parseColor(value(palettes, token)).rgb; + +/** + * The grounds a coloured word can actually land on. + * + * Not a cartesian product of every colour against every other: measured that way, + * the SHIPPED dark values fail, which would make the bar wrong rather than the + * palette. This is the population as it exists in `src` — the five studio grounds, + * a wash of the token's own hue (the #402 failure class), and the accent tile, the + * one cross-hue ground that provably carries every identity hue at once (the + * selected-engine tile in `ConnectionModal`, `bg-blue-600/10`). + * + * `/20` is the ceiling because no tint heavier than that carries coloured text + * anywhere in `src`; `bg-blue-500/30` and `/50` exist, but as fills, never as a + * ground under a word. + */ +const GROUNDS = { + light: ["--studio-canvas", "--studio-sunken", "--studio-surface", "--studio-raised", "--studio-overlay"], + dark: ["--studio-canvas", "--studio-sunken", "--studio-surface", "--studio-raised", "--studio-overlay"], +} as const; +const TINT_ALPHAS = [0.05, 0.1, 0.15, 0.2]; +const ACCENT_TILE: ReadonlyArray = [ + ["--studio-accent-tint", 0.05], + ["--studio-accent-tint", 0.1], + ["--studio-accent-solid", 0.1], +]; + +const AA = 4.5; + +/** The worst ratio `token` reaches anywhere it is allowed to be painted. */ +function worstGround( + palettes: Map, + token: string, + ownTint: string, +): { ratio: number; where: string } { + const foreground = rgb(palettes, token); + let ratio = Number.POSITIVE_INFINITY; + let where = ""; + const record = (candidate: number, label: string) => { + if (candidate < ratio) { + ratio = candidate; + where = label; + } + }; + + for (const groundToken of GROUNDS.light) { + const ground = rgb(palettes, groundToken); + record(contrast(foreground, ground), groundToken); + for (const alpha of TINT_ALPHAS) { + record( + contrast(foreground, composite(rgb(palettes, ownTint), ground, alpha)), + `${groundToken} + ${ownTint}/${alpha * 100}`, + ); + } + for (const [tile, alpha] of ACCENT_TILE) { + record( + contrast(foreground, composite(rgb(palettes, tile), ground, alpha)), + `${groundToken} + ${tile}/${alpha * 100}`, + ); + } + } + return { ratio, where }; +} + +/** + * The contract, one row per token: which Tailwind step each palette reproduces, and + * which tint the token is washed over when it sits on its own colour. + * + * The dark column is the whole of "dark does not move" — every one of these is the + * literal a component carried before this migration. + */ +const TEXT_TOKENS: ReadonlyArray<{ token: string; light: string; dark: string; tint: string }> = [ + { token: "--studio-accent", light: "blue-700", dark: "blue-400", tint: "--studio-accent-tint" }, + { token: "--studio-accent-bright", light: "blue-800", dark: "blue-300", tint: "--studio-accent-tint" }, + { token: "--studio-warning", light: "amber-800", dark: "amber-400", tint: "--studio-warning-tint" }, + { token: "--studio-warning-bright", light: "amber-900", dark: "amber-300", tint: "--studio-warning-tint" }, + { token: "--studio-success", light: "emerald-800", dark: "emerald-400", tint: "--studio-success-tint" }, + { token: "--studio-success-bright", light: "emerald-900", dark: "emerald-300", tint: "--studio-success-tint" }, + { token: "--studio-danger", light: "red-800", dark: "red-400", tint: "--studio-danger-tint" }, + { token: "--studio-danger-bright", light: "red-900", dark: "red-300", tint: "--studio-danger-tint" }, +]; + +/** + * The identity palette. `-alt` is a second step of the same hue: sometimes a second + * IDENTITY (db-ui-config has more engines than there are hues, and its own test + * asserts all of them differ), sometimes just emphasis on one. + */ +const HUES: ReadonlyArray<{ hue: string; light: string; lightAlt: string }> = [ + { hue: "blue", light: "blue-700", lightAlt: "blue-800" }, + { hue: "sky", light: "sky-800", lightAlt: "sky-900" }, + { hue: "indigo", light: "indigo-700", lightAlt: "indigo-800" }, + { hue: "violet", light: "violet-700", lightAlt: "violet-800" }, + { hue: "purple", light: "purple-700", lightAlt: "purple-800" }, + { hue: "pink", light: "pink-800", lightAlt: "pink-900" }, + { hue: "rose", light: "rose-800", lightAlt: "rose-900" }, + { hue: "red", light: "red-800", lightAlt: "red-900" }, + { hue: "orange", light: "orange-800", lightAlt: "orange-900" }, + { hue: "amber", light: "amber-900", lightAlt: "amber-950" }, + { hue: "yellow", light: "yellow-800", lightAlt: "yellow-950" }, + { hue: "green", light: "green-800", lightAlt: "green-900" }, + { hue: "emerald", light: "emerald-800", lightAlt: "emerald-900" }, + { hue: "teal", light: "teal-800", lightAlt: "teal-950" }, + { hue: "cyan", light: "cyan-800", lightAlt: "cyan-900" }, +]; + +/** + * The four hues where two engines share a hue and are held apart only by step, so + * the `-alt` is a distinct IDENTITY and has to join the separation set. Pinned by + * `tests/unit/lib/db-ui-config.test.ts`, which asserts every engine colour differs. + */ +const IDENTITY_ALTS = ["sky", "yellow", "emerald", "teal"] as const; + +describe("accent text clears WCAG AA on every ground it can land on", () => { + for (const { token, tint } of TEXT_TOKENS) { + test(`${token} in light`, () => { + const { ratio, where } = worstGround(light, token, tint); + expect({ token, worst: `${ratio.toFixed(2)}:1`, where }).toEqual({ + token, + worst: `${ratio.toFixed(2)}:1`, + where, + }); + expect(ratio).toBeGreaterThanOrEqual(AA); + }); + + test(`${token} in dark`, () => { + expect(worstGround(dark, token, tint).ratio).toBeGreaterThanOrEqual(AA); + }); + } + + for (const { hue } of HUES) { + for (const suffix of ["", "-alt"]) { + const token = `--studio-hue-${hue}${suffix}`; + test(`${token} in both palettes`, () => { + expect(worstGround(light, token, `--studio-hue-${hue}-tint`).ratio).toBeGreaterThanOrEqual(AA); + expect(worstGround(dark, token, `--studio-hue-${hue}-tint`).ratio).toBeGreaterThanOrEqual(AA); + }); + } + } +}); + +/** + * The regression #402 is about, stated as itself rather than as a floor: the pairing + * from the issue's own table. If someone reverts the accent to a -300/-400 step, + * every "clears AA" test above fails too — but this one names the defect. + */ +describe("the pairing from the issue", () => { + test("an accent word on an accent chip is readable in light, not just in dark", () => { + for (const palettes of [light, dark]) { + const chip = composite(rgb(palettes, "--studio-accent-tint"), rgb(palettes, "--studio-surface"), 0.15); + expect(contrast(rgb(palettes, "--studio-accent-bright"), chip)).toBeGreaterThanOrEqual(AA); + } + }); + + test("the value that failed is gone from the light palette", () => { + // blue-300 over blue-500/15 over #fafafa measured 1.46:1. Not a floor test — + // this asserts the specific value is no longer what light mode paints. + expect(value(light, "--studio-accent-bright")).not.toBe(toHex(tailwindStep(palette, "blue-300"))); + }); +}); + +describe("the dark palette reproduces the literals the components carried", () => { + for (const { token, dark: step } of TEXT_TOKENS) { + test(`${token} is still ${step}`, () => { + expect(value(dark, token)).toBe(toHex(tailwindStep(palette, step))); + }); + } + + for (const { hue } of HUES) { + test(`--studio-hue-${hue} is still ${hue}-400, and its -alt still ${hue}-300`, () => { + expect(value(dark, `--studio-hue-${hue}`)).toBe(toHex(tailwindStep(palette, `${hue}-400`))); + expect(value(dark, `--studio-hue-${hue}-alt`)).toBe(toHex(tailwindStep(palette, `${hue}-300`))); + }); + + /** + * A wash is the -500 step at some alpha in both palettes — the value the + * components already carried as `bg--500/10`. Transcribing thirty of these + * by hand got `green-500` wrong by one digit on the first attempt; resolving + * them is what caught it. + */ + test(`--studio-hue-${hue}-tint is ${hue}-500 in both palettes`, () => { + const step = toHex(tailwindStep(palette, `${hue}-500`)); + expect(value(dark, `--studio-hue-${hue}-tint`)).toBe(step); + expect(value(light, `--studio-hue-${hue}-tint`)).toBe(step); + }); + } + + /** + * The tints and the solid grounds are the same value in both palettes on purpose: + * a wash is alpha over whatever is behind it, so it already adapts, and a filled + * button's label contrast does not depend on the page. Declaring them twice is + * what the palette-parity test requires; declaring them DIFFERENTLY would be a + * silent dark-mode change. + */ + for (const role of ["accent", "warning", "success", "danger"]) { + for (const suffix of ["tint", "solid", "solid-hover"]) { + test(`--studio-${role}-${suffix} is mode-independent`, () => { + expect(value(light, `--studio-${role}-${suffix}`)).toBe(value(dark, `--studio-${role}-${suffix}`)); + }); + } + } +}); + +describe("the light values are the ones that were selected", () => { + for (const { token, light: step } of TEXT_TOKENS) { + test(`${token} is ${step}`, () => { + expect(value(light, token)).toBe(toHex(tailwindStep(palette, step))); + }); + } + + for (const { hue, light: step, lightAlt } of HUES) { + test(`--studio-hue-${hue} is ${step}, its -alt ${lightAlt}`, () => { + expect(value(light, `--studio-hue-${hue}`)).toBe(toHex(tailwindStep(palette, step))); + expect(value(light, `--studio-hue-${hue}-alt`)).toBe(toHex(tailwindStep(palette, lightAlt))); + }); + } +}); + +describe("the identity hues stay tellable apart", () => { + const closestPair = (palettes: Map, tokens: string[]) => { + let closest = Number.POSITIVE_INFINITY; + let pair = ""; + for (let i = 0; i < tokens.length; i++) { + for (let j = i + 1; j < tokens.length; j++) { + const distance = deltaEOk(rgb(palettes, tokens[i]), rgb(palettes, tokens[j])); + if (distance < closest) { + closest = distance; + pair = `${tokens[i]} vs ${tokens[j]}`; + } + } + } + return { closest, pair }; + }; + + const identitySet = HUES.map(({ hue }) => `--studio-hue-${hue}`).concat( + IDENTITY_ALTS.map((hue) => `--studio-hue-${hue}-alt`), + ); + + /** + * The bar is the SHIPPED dark set's own minimum, not a number chosen here. Dark is + * what users look at today and nobody has called it muddy, so a light set that is + * no tighter anywhere is a light set that is at least as legible — and the claim + * survives someone later retuning dark, because the bar moves with it. + */ + test("the light set is no tighter than the dark set it is modelled on", () => { + const darkest = closestPair(dark, identitySet); + const lightest = closestPair(light, identitySet); + expect({ + bar: darkest.closest.toFixed(4), + barPair: darkest.pair, + light: lightest.closest.toFixed(4), + lightPair: lightest.pair, + }).toEqual({ + bar: darkest.closest.toFixed(4), + barPair: darkest.pair, + light: lightest.closest.toFixed(4), + lightPair: lightest.pair, + }); + expect(lightest.closest).toBeGreaterThanOrEqual(darkest.closest); + }); + + /** + * `-alt` exists to be a DIFFERENT colour from its base — two engines, or an icon + * beside the value it labels. A pair that collapses defeats the reason the token + * was added, and does it invisibly: both still clear AA. + */ + test("every -alt is distinguishable from its own base, in both palettes", () => { + for (const { hue } of HUES) { + for (const palettes of [light, dark]) { + expect(deltaEOk(rgb(palettes, `--studio-hue-${hue}`), rgb(palettes, `--studio-hue-${hue}-alt`))).toBeGreaterThan( + 0.05, + ); + } + } + }); + + /** + * The emphasis steps have the same job inside the state roles. In dark `-bright` + * is literally brighter; in light it is darker — the same inversion `fg-bright` + * already makes, and the reason the token is not called `-light`. + */ + test("every state -bright is distinguishable from its own base, in both palettes", () => { + for (const role of ["accent", "warning", "success", "danger"]) { + for (const palettes of [light, dark]) { + expect(deltaEOk(rgb(palettes, `--studio-${role}`), rgb(palettes, `--studio-${role}-bright`))).toBeGreaterThan( + 0.05, + ); + } + } + }); +}); From 5fe6d3ea5453b434387375ebc1980fcc1d8a749e Mon Sep 17 00:00:00 2001 From: cevheri Date: Sun, 6 Sep 2026 20:11:23 +0300 Subject: [PATCH 2/5] fix(theme): tokenise the accents, so light stops painting dark-tuned colour (#402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 697 colour literals across 68 files move onto the token layer #384 built. Two families, because colour here answers two different questions: brand/warning/ success/danger where it tracks a condition, and a fifteen-hue identity palette where it labels a thing — which engine, which panel, added vs removed, primary key vs foreign key. The state family is brand, not accent. shadcn owns --accent (its neutral hover ground, #f5f5f5) and globals.css maps --color-accent to it AFTER importing the studio layer, so a token of that name loses the cascade in silence and paints near-white text on a white page. The compiled stylesheet is the only place that shows; no unit test can see it. Dark is a byte-for-byte no-op except where a -500 text literal was drift — the codebase used three steps of one hue for one meaning, and those collapse onto the base token. Every such site is listed in the PR. Also: the tests that asserted a hue name now assert the token, and are renamed where the name said 'returns green'. --- docs/ui/theming.md | 69 ++++++-- src/app/admin/error.tsx | 2 +- src/app/error.tsx | 2 +- src/app/login/login-form.tsx | 16 +- src/app/not-found.tsx | 2 +- src/components/CodeGenerator.tsx | 4 +- src/components/CommandPalette.tsx | 18 +- src/components/ConnectionModal.tsx | 100 +++++------ src/components/CreateTableModal.tsx | 28 ++-- src/components/DataCharts.tsx | 16 +- src/components/DataImportModal.tsx | 34 ++-- src/components/DataProfiler.tsx | 30 ++-- src/components/DatabaseDocs.tsx | 26 +-- src/components/MaskingSettings.tsx | 12 +- src/components/MobileNav.tsx | 6 +- src/components/PivotTable.tsx | 8 +- src/components/QueryEditor.tsx | 6 +- src/components/QueryHistory.tsx | 34 ++-- src/components/QuerySafetyDialog.tsx | 52 +++--- src/components/ResultsGrid.tsx | 36 ++-- src/components/SaveQueryModal.tsx | 10 +- src/components/SavedQueries.tsx | 8 +- src/components/SchemaDiagram.tsx | 14 +- src/components/SchemaDiff.tsx | 48 +++--- src/components/SnapshotTimeline.tsx | 8 +- src/components/Studio.tsx | 10 +- src/components/TestDataGenerator.tsx | 10 +- src/components/VisualExplain.tsx | 87 +++++----- src/components/admin/AdminDashboard.tsx | 8 +- src/components/admin/tabs/AuditTab.tsx | 26 +-- src/components/admin/tabs/OperationsTab.tsx | 69 ++++---- src/components/admin/tabs/OverviewTab.tsx | 86 +++++----- src/components/admin/tabs/SecurityTab.tsx | 24 +-- src/components/agent/AgentRail.tsx | 83 +++++---- src/components/agent/AnswerCard.tsx | 53 +++--- src/components/agent/ConsentCard.tsx | 10 +- src/components/agent/SafetyStrip.tsx | 6 +- src/components/community-section.tsx | 8 +- src/components/copy-button.tsx | 2 +- src/components/login/connection-signature.tsx | 2 +- .../monitoring/MonitoringDashboard.tsx | 2 +- .../monitoring/tabs/OverviewTab.tsx | 8 +- .../monitoring/tabs/PerformanceTab.tsx | 27 ++- src/components/monitoring/tabs/PoolTab.tsx | 6 +- src/components/monitoring/tabs/QueriesTab.tsx | 2 +- .../monitoring/tabs/SessionsTab.tsx | 10 +- src/components/monitoring/tabs/StorageTab.tsx | 14 +- src/components/monitoring/tabs/TablesTab.tsx | 4 +- src/components/results-grid/ResultCard.tsx | 6 +- .../results-grid/RowDetailSheet.tsx | 16 +- src/components/results-grid/StatsBar.tsx | 24 +-- .../results-grid/renderers/binary.ts | 2 +- src/components/results-grid/renderers/json.ts | 2 +- .../results-grid/renderers/scalar.ts | 8 +- src/components/schema-diagram/TableNode.tsx | 14 +- src/components/schema-explorer/ColumnList.tsx | 4 +- .../schema-explorer/SchemaExplorer.tsx | 18 +- src/components/schema-explorer/TableItem.tsx | 16 +- src/components/sidebar/ConnectionItem.tsx | 13 +- src/components/sidebar/Sidebar.tsx | 4 +- src/components/studio/BottomPanel.tsx | 26 +-- src/components/studio/QueryToolbar.tsx | 22 +-- src/components/studio/StudioDesktopHeader.tsx | 18 +- src/components/studio/StudioMobileHeader.tsx | 39 ++--- src/components/studio/StudioTabBar.tsx | 4 +- src/lib/db-ui-config.ts | 34 ++-- src/lib/monitoring-thresholds.ts | 6 +- src/styles/theme.css | 123 +++++++------- src/workspace/StudioWorkspace.tsx | 8 +- tests/components/CommandPalette.test.tsx | 4 +- .../ConnectionModal.mobile.test.tsx | 8 +- tests/components/ConnectionModal.test.tsx | 16 +- tests/components/DataProfiler.test.tsx | 36 ++-- tests/components/MaskingSettings.test.tsx | 4 +- tests/components/QueryHistory.test.tsx | 14 +- tests/components/QuerySafetyDialog.test.tsx | 20 +-- tests/components/ResultsGrid.test.tsx | 2 +- tests/components/SchemaDiagram.test.tsx | 34 ++-- tests/components/SchemaDiff.test.tsx | 34 ++-- tests/components/SnapshotTimeline.test.tsx | 6 +- tests/components/VisualExplain.test.tsx | 22 +-- tests/components/admin/OperationsTab.test.tsx | 2 +- tests/components/admin/OverviewTab.test.tsx | 4 +- tests/components/agent/AgentRail.test.tsx | 2 +- .../monitoring/PerformanceTab.test.tsx | 22 +-- .../results-grid/RowDetailSheet.test.tsx | 8 +- .../schema-explorer/ColumnList.test.tsx | 6 +- .../sidebar/ConnectionItem.test.tsx | 6 +- .../sidebar/ConnectionsList.test.tsx | 10 +- tests/components/studio/BottomPanel.test.tsx | 4 +- tests/components/studio/QueryToolbar.test.tsx | 4 +- .../studio/StudioDesktopHeader.test.tsx | 16 +- tests/components/studio/StudioTabBar.test.tsx | 10 +- tests/helpers/contrast.ts | 37 ++-- tests/unit/components/column-list.test.tsx | 10 +- .../components/results-grid-renderers.test.ts | 12 +- .../components/results-grid-utils.test.ts | 32 ++-- .../components/studio-mobile-header.test.tsx | 58 ++++--- tests/unit/lib/monitoring-thresholds.test.ts | 12 +- tests/unit/theme-accent-contrast.test.ts | 158 +++++++++++------- tests/unit/theme-token-usage.test.ts | 124 ++++++++++++++ 101 files changed, 1273 insertions(+), 1017 deletions(-) create mode 100644 tests/unit/theme-token-usage.test.ts diff --git a/docs/ui/theming.md b/docs/ui/theming.md index c5ae405d..483d3ac9 100644 --- a/docs/ui/theming.md +++ b/docs/ui/theming.md @@ -55,6 +55,8 @@ editor frame — is written in the semantic tokens of `src/styles/theme.css`. Tw |------|-----------------------------------------------------| | Surface | `canvas` · `sunken` · `surface` · `raised` · `overlay` (plus `panel`, the translucent card ground) | | Text | `fg-bright` · `fg` · `fg-secondary` · `fg-tertiary` · `fg-muted` · `fg-subtle` · `fg-faint` | +| Accent, state | `brand` · `warning` · `success` · `danger`, each with `-bright`, `-tint`, `-solid`, `-solid-hover` | +| Accent, identity | `hue-` for fifteen hues, some with `-alt` and `-tint` | Alongside them: `hairline` / `hairline-strong` for structural rules, `edge` / `edge-hover` for the border of a control the user is meant to see, and `fill-subtle` / `fill` / `fill-strong` @@ -67,6 +69,34 @@ literals the components carried before the layer existed, so **moving a componen must be a no-op in dark** — any visible dark-mode change is a bug unless it is deliberate and called out. +### State or identity + +The two accent families answer different questions, and picking the wrong one is the mistake +that costs something later. Note the state family is `brand`, not `accent`: shadcn already owns +`--accent` — its neutral hover ground, `#f5f5f5` — and `globals.css` maps `--color-accent` to it +*after* importing this layer, so a studio token of that name loses the cascade silently and paints +near-white text on a white page. + +- **State** — the colour tracks a changing condition. A run failed, a row is selected, a + statement is risky. Four roles: `brand`, `warning`, `success`, `danger`. `-bright` is the + emphasised step and inverts exactly like `fg-bright` — brighter than its base in dark, darker + in light, both meaning *further from the ground*. +- **Identity** — the colour is a fixed label for a thing. Which engine, which bottom panel, + added versus removed, primary key versus foreign key, number versus boolean. `hue-`, and + `hue--alt` where two identities share a hue: there are more engines than there are hues, + and `db-ui-config`'s own test asserts every engine colour differs. + +Folding an identity into a state role repaints nineteen engines in four colours. Folding a state +into an identity hue means the next person to change what "error" looks like has to find every +red in the codebase. The identity set is **selected per mode**, the way `lib/charts/palette.ts` +selects rather than flipping a ramp — the two modes run out of room in different places, so a +ramp flip produces collisions in one of them. + +`-tint` is the wash a role is painted over (`bg-brand-tint/15`) and is the same value in both +palettes on purpose: a wash is alpha over whatever is behind it, so it already adapts. `-solid` +is a filled control's ground, mode-independent for the same reason — a button's label sits on +the button, not on the page. + ### Surfaces that cannot read CSS Monaco, Recharts and the `@xyflow` ER diagram paint their own canvas from a JS palette, so they @@ -308,15 +338,17 @@ shadcn/ui buttons use theme variables automatically: ### Step 1: Define Variables +Both palettes, always. A token declared in one resolves to nothing in the other, which is invalid +at computed-value time — the ground falls to transparent, the hairline to `currentColor`, and only +in the theme nobody happened to be looking at. + ```css :root { - --warning: #f59e0b; - --warning-foreground: #ffffff; + --studio-hue-lime: #3f6212; } .dark { - --warning: #d97706; - --warning-foreground: #ffffff; + --studio-hue-lime: #9ae600; } ``` @@ -324,19 +356,36 @@ shadcn/ui buttons use theme variables automatically: ```css @theme inline { - --color-warning: var(--warning); - --color-warning-foreground: var(--warning-foreground); + --color-hue-lime: var(--studio-hue-lime); } ``` +`inline` is required: a plain `@theme` resolves the value at build time and freezes whichever +palette was in scope. + ### Step 3: Use in Components ```jsx -
- Warning message -
+ ``` +### Step 4: Let the suite check it + +Do not verify a colour by eye, and do not write the ratio into a comment. `tests/unit/theme-accent-contrast.test.ts` +measures every accent token on every ground it can land on, in both palettes, and +`tests/unit/theme-token-usage.test.ts` fails on a colour literal in `src` and on a token nothing +reaches. The bars a new token has to clear: + +- **4.5:1** on the five studio grounds, on a wash of its own hue up to `/20`, and on the brand + tile — WCAG AA for text, in both palettes. +- **Separation** no tighter than the shipped dark set's own minimum, if it joins the identity + palette. The bar is measured, not chosen, so it moves if dark is ever retuned. +- **A call site.** A token nobody uses is a value nobody has checked. + +The helper the tests measure with is `tests/helpers/contrast.ts`. It reads the Tailwind palette out +of `node_modules` rather than transcribing it, so a Tailwind upgrade that restyles `blue-400` +reports itself as the dark-mode change it is. + ## Troubleshooting ### Colors Not Applying @@ -413,4 +462,4 @@ shadcn/ui buttons use theme variables automatically: --- -*Last updated: June 2026* +*Last updated: September 2026* diff --git a/src/app/admin/error.tsx b/src/app/admin/error.tsx index 40bc01a1..c35ad1d7 100644 --- a/src/app/admin/error.tsx +++ b/src/app/admin/error.tsx @@ -18,7 +18,7 @@ export default function AdminError({ error, reset }: { error: Error & { digest?:
diff --git a/src/app/error.tsx b/src/app/error.tsx index 712eb3d1..d737f84c 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -19,7 +19,7 @@ export default function ErrorPage({ error, reset }: { error: Error & { digest?:
diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index 488085d7..f2a99c4c 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -93,8 +93,8 @@ function LoginFormInner({ authProvider }: { authProvider: string }) { /> {/* Ambient glow orbs — blue accent family */} -
-
+
+
{/* Right edge separator */}
@@ -109,9 +109,9 @@ function LoginFormInner({ authProvider }: { authProvider: string }) { className="flex items-center gap-3 group w-fit" >
- +
- + LibreDB Studio @@ -186,13 +186,13 @@ function LoginFormInner({ authProvider }: { authProvider: string }) { className="flex flex-col items-center gap-4 lg:hidden group" >
-
-
- +
+
+
-

+

LibreDB Studio

Open-source SQL IDE for cloud-native teams

diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 80c703c9..f8f998b8 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -10,7 +10,7 @@ export default function NotFound() {
Go to Studio diff --git a/src/components/CodeGenerator.tsx b/src/components/CodeGenerator.tsx index 5555cbd0..67b7553d 100644 --- a/src/components/CodeGenerator.tsx +++ b/src/components/CodeGenerator.tsx @@ -266,7 +266,7 @@ export function CodeGenerator({ isOpen, onClose, tableName, tableSchema, databas
- + Code Generator {tableName} {databaseType && {databaseType}} @@ -296,7 +296,7 @@ export function CodeGenerator({ isOpen, onClose, tableName, tableSchema, databas }} className={cn( "w-full text-left px-3 py-1.5 text-xs hover:bg-fill transition-colors", - language === lang.id ? "text-purple-400" : "text-fg-tertiary", + language === lang.id ? "text-hue-purple" : "text-fg-tertiary", )} > {lang.label} diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 0886a6c4..be6c4137 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -113,7 +113,7 @@ export function CommandPalette({ {/* Quick Actions */} runAction(onExecuteQuery)}> - + Run Query Ctrl+Enter @@ -132,30 +132,30 @@ export function CommandPalette({ carries the editor's statement in with it (review of #331 T3). */ runAction(onAskAgent)}> - + Ask the agent about this query )} runAction(onAddConnection)}> - + New Connection runAction(onNavigateHealth)}> - + Health Dashboard runAction(onNavigateMonitoring)}> - + Monitoring {activeConnection && ( runAction(onShowDiagram)}> - + Schema Diagram (ERD) )} runAction(onLogout)}> - + Logout @@ -170,7 +170,7 @@ export function CommandPalette({ {conn.name} {activeConnection?.id === conn.id && ( - Active + Active )} ); @@ -199,7 +199,7 @@ export function CommandPalette({ {savedQueries.map((sq: SavedQuery) => ( runAction(() => onLoadSavedQuery(sq.query))}> - + {sq.name} {sq.query.substring(0, 40)}... diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index b334994a..16c98128 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -195,11 +195,11 @@ export function ConnectionModal({ const formContent = ( <> {/* Progress bar — fixed top */} -
+
@@ -207,8 +207,8 @@ export function ConnectionModal({
-
- +
+

{isEditMode ? "Edit Connection" : "New Connection"} @@ -223,7 +223,7 @@ export function ConnectionModal({ {!isEditMode && ( @@ -281,7 +281,7 @@ export function ConnectionModal({ value={name} onChange={(e) => setName(e.target.value)} placeholder="My Database" - className="h-10 bg-panel border-hairline focus:border-blue-500/50 transition-all text-xs" + className="h-10 bg-panel border-hairline focus:border-brand-tint/50 transition-all text-xs" />

@@ -322,7 +322,7 @@ export function ConnectionModal({ className={cn( "flex flex-col items-center justify-center p-3 md:p-4 rounded-xl border transition-all duration-200 gap-2 group", type === db.value - ? "bg-blue-600/10 border-blue-500/50 shadow-[0_0_20px_rgba(59,130,246,0.1)]" + ? "bg-brand-solid/10 border-brand-tint/50 shadow-[0_0_20px_rgba(59,130,246,0.1)]" : "bg-panel border-hairline hover:border-hairline-strong hover:bg-raised", isEditMode && type !== db.value && "opacity-30 cursor-not-allowed", )} @@ -353,7 +353,7 @@ export function ConnectionModal({ className={cn( "flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-xs font-medium transition-all", mongoConnectionMode === "host" - ? "bg-blue-600/20 text-blue-400 border border-blue-500/30" + ? "bg-brand-solid/20 text-brand border border-brand-tint/30" : "text-fg-muted hover:text-fg-secondary", )} > @@ -365,7 +365,7 @@ export function ConnectionModal({ className={cn( "flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-xs font-medium transition-all", mongoConnectionMode === "connectionString" - ? "bg-blue-600/20 text-blue-400 border border-blue-500/30" + ? "bg-brand-solid/20 text-brand border border-brand-tint/30" : "text-fg-muted hover:text-fg-secondary", )} > @@ -389,7 +389,7 @@ export function ConnectionModal({ value={connectionString} onChange={(e) => setConnectionString(e.target.value)} placeholder={connectionUriPlaceholder} - className="h-10 bg-panel border-hairline focus:border-blue-500/50 transition-all text-xs font-mono" + className="h-10 bg-panel border-hairline focus:border-brand-tint/50 transition-all text-xs font-mono" />
@@ -404,7 +404,7 @@ export function ConnectionModal({ value={database} onChange={(e) => setDatabase(e.target.value)} placeholder="Extracted from URI if not provided" - className="h-10 bg-panel border-hairline focus:border-blue-500/50 transition-all text-xs font-mono" + className="h-10 bg-panel border-hairline focus:border-brand-tint/50 transition-all text-xs font-mono" />
@@ -421,7 +421,7 @@ export function ConnectionModal({ value={database} onChange={(e) => setDatabase(e.target.value)} placeholder="/path/to/database file" - className="h-10 bg-panel border-hairline focus:border-blue-500/50 transition-all text-xs font-mono" + className="h-10 bg-panel border-hairline focus:border-brand-tint/50 transition-all text-xs font-mono" />
) : ( @@ -440,14 +440,14 @@ export function ConnectionModal({ onChange={(e) => setHost(e.target.value)} placeholder="localhost" autoComplete="off" - className="md:col-span-3 h-10 bg-panel border-hairline focus:border-blue-500/50 transition-all text-xs" + className="md:col-span-3 h-10 bg-panel border-hairline focus:border-brand-tint/50 transition-all text-xs" /> setPort(e.target.value)} autoComplete="off" - className="h-10 bg-panel border-hairline focus:border-blue-500/50 transition-all text-xs font-mono" + className="h-10 bg-panel border-hairline focus:border-brand-tint/50 transition-all text-xs font-mono" />
@@ -472,7 +472,7 @@ export function ConnectionModal({ onChange={(e) => setUser(e.target.value)} placeholder="user" autoComplete="off" - className="h-10 bg-panel border-hairline focus:border-blue-500/50 transition-all text-xs" + className="h-10 bg-panel border-hairline focus:border-brand-tint/50 transition-all text-xs" />
)} @@ -492,7 +492,7 @@ export function ConnectionModal({ // Server credential, not the user's own login: "new-password" is the only // value Chrome honours to keep saved site passwords out of the field. autoComplete="new-password" - className="h-10 bg-panel border-hairline focus:border-blue-500/50 transition-all text-xs" + className="h-10 bg-panel border-hairline focus:border-brand-tint/50 transition-all text-xs" /> {/* Measured on Trino 476 with authentication DISABLED: a request @@ -537,7 +537,7 @@ export function ConnectionModal({ value={database} onChange={(e) => setDatabase(e.target.value)} placeholder={databaseFieldPlaceholder} - className="h-10 bg-panel border-hairline focus:border-blue-500/50 transition-all text-xs font-mono" + className="h-10 bg-panel border-hairline focus:border-brand-tint/50 transition-all text-xs font-mono" /> {isTrino && (

@@ -573,7 +573,7 @@ export function ConnectionModal({ value={authSource} onChange={(e) => setAuthSource(e.target.value)} placeholder="admin" - className="h-10 bg-panel border-hairline focus:border-blue-500/50 transition-all text-xs font-mono" + className="h-10 bg-panel border-hairline focus:border-brand-tint/50 transition-all text-xs font-mono" />

The database the user was created in, usually admin. Leave empty when the credentials live in @@ -602,7 +602,7 @@ export function ConnectionModal({ value={localDataCenter} onChange={(e) => setLocalDataCenter(e.target.value)} placeholder="datacenter1" - className="h-10 bg-panel border-hairline focus:border-blue-500/50 transition-all text-xs font-mono" + className="h-10 bg-panel border-hairline focus:border-brand-tint/50 transition-all text-xs font-mono" />

Required: the Cassandra driver refuses to connect without it. A stock single-node install @@ -623,10 +623,10 @@ export function ConnectionModal({ onClick={() => setShowAdvanced(!showAdvanced)} className="flex items-center gap-2 w-full px-3 py-2 rounded-lg border border-hairline hover:border-hairline-strong bg-panel text-xs font-medium text-fg-tertiary hover:text-fg transition-all" > - + Advanced {(serviceName || instanceName) && ( - + SET )} @@ -640,7 +640,7 @@ export function ConnectionModal({ exit={{ height: 0, opacity: 0 }} className="overflow-hidden" > -

+
{type === "oracle" && (
@@ -648,7 +648,7 @@ export function ConnectionModal({ value={serviceName} onChange={(e) => setServiceName(e.target.value)} placeholder="ORCL or XEPDB1" - className="h-9 bg-panel border-hairline focus:border-orange-500/50 text-xs" + className="h-9 bg-panel border-hairline focus:border-hue-orange-tint/50 text-xs" />

If empty, the Database Name field is used as the service name. @@ -662,7 +662,7 @@ export function ConnectionModal({ value={instanceName} onChange={(e) => setInstanceName(e.target.value)} placeholder="SQLEXPRESS" - className="h-9 bg-panel border-hairline focus:border-orange-500/50 text-xs" + className="h-9 bg-panel border-hairline focus:border-hue-orange-tint/50 text-xs" />

For named instances (e.g. SQLEXPRESS). Leave empty for default instance. @@ -685,10 +685,10 @@ export function ConnectionModal({ onClick={() => setShowSSL(!showSSL)} className="flex items-center gap-2 w-full px-3 py-2 rounded-lg border border-hairline hover:border-hairline-strong bg-panel text-xs font-medium text-fg-tertiary hover:text-fg transition-all" > - + SSL / TLS {sslMode !== "disable" && ( - + {sslMode.toUpperCase()} )} @@ -702,7 +702,7 @@ export function ConnectionModal({ exit={{ height: 0, opacity: 0 }} className="overflow-hidden" > -

+
@@ -715,7 +715,7 @@ export function ConnectionModal({ className={cn( "px-2.5 py-1.5 rounded-md text-xs font-mediumr transition-all border", sslMode === mode - ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400" + ? "border-hue-emerald-tint/30 bg-hue-emerald-tint/10 text-hue-emerald" : "border-transparent text-fg-muted hover:text-fg-secondary hover:bg-fill", )} > @@ -737,7 +737,7 @@ export function ConnectionModal({ onChange={(e) => setCaCert(e.target.value)} placeholder="-----BEGIN CERTIFICATE----- Paste CA cert content here... -----END CERTIFICATE-----" rows={3} - className="w-full rounded-md bg-panel border border-hairline focus:border-emerald-500/50 text-xs font-mono text-fg-secondary p-2 resize-none placeholder:text-fg-subtle" + className="w-full rounded-md bg-panel border border-hairline focus:border-hue-emerald-tint/50 text-xs font-mono text-fg-secondary p-2 resize-none placeholder:text-fg-subtle" />
{(sslMode === "verify-ca" || sslMode === "verify-full") && ( @@ -749,7 +749,7 @@ export function ConnectionModal({ onChange={(e) => setClientCert(e.target.value)} placeholder="-----BEGIN CERTIFICATE----- Optional client cert... -----END CERTIFICATE-----" rows={3} - className="w-full rounded-md bg-panel border border-hairline focus:border-emerald-500/50 text-xs font-mono text-fg-secondary p-2 resize-none placeholder:text-fg-subtle" + className="w-full rounded-md bg-panel border border-hairline focus:border-hue-emerald-tint/50 text-xs font-mono text-fg-secondary p-2 resize-none placeholder:text-fg-subtle" />
@@ -759,7 +759,7 @@ export function ConnectionModal({ onChange={(e) => setClientKey(e.target.value)} placeholder="-----BEGIN PRIVATE KEY----- Optional client key... -----END PRIVATE KEY-----" rows={3} - className="w-full rounded-md bg-panel border border-hairline focus:border-emerald-500/50 text-xs font-mono text-fg-secondary p-2 resize-none placeholder:text-fg-subtle" + className="w-full rounded-md bg-panel border border-hairline focus:border-hue-emerald-tint/50 text-xs font-mono text-fg-secondary p-2 resize-none placeholder:text-fg-subtle" />
@@ -777,10 +777,10 @@ export function ConnectionModal({ onClick={() => setShowSSH(!showSSH)} className="flex items-center gap-2 w-full px-3 py-2 rounded-lg border border-hairline hover:border-hairline-strong bg-panel text-xs font-medium text-fg-tertiary hover:text-fg transition-all" > - + SSH Tunnel {sshEnabled && ( - + ON )} @@ -794,7 +794,7 @@ export function ConnectionModal({ exit={{ height: 0, opacity: 0 }} className="overflow-hidden" > -
+
@@ -823,7 +823,7 @@ export function ConnectionModal({ value={sshPort} onChange={(e) => setSSHPort(e.target.value)} autoComplete="off" - className="h-9 bg-panel border-hairline focus:border-purple-500/50 text-xs font-mono" + className="h-9 bg-panel border-hairline focus:border-hue-purple-tint/50 text-xs font-mono" />
@@ -834,7 +834,7 @@ export function ConnectionModal({ onChange={(e) => setSSHUsername(e.target.value)} placeholder="ubuntu" autoComplete="off" - className="h-9 bg-panel border-hairline focus:border-purple-500/50 text-xs" + className="h-9 bg-panel border-hairline focus:border-hue-purple-tint/50 text-xs" />
@@ -846,7 +846,7 @@ export function ConnectionModal({ className={cn( "flex-1 px-3 py-1.5 rounded-md text-xs font-mediumr transition-all border", sshAuthMethod === "password" - ? "border-purple-500/30 bg-purple-500/10 text-purple-400" + ? "border-hue-purple-tint/30 bg-hue-purple-tint/10 text-hue-purple" : "border-transparent text-fg-muted hover:text-fg-secondary hover:bg-fill", )} > @@ -858,7 +858,7 @@ export function ConnectionModal({ className={cn( "flex-1 px-3 py-1.5 rounded-md text-xs font-mediumr transition-all border", sshAuthMethod === "privateKey" - ? "border-purple-500/30 bg-purple-500/10 text-purple-400" + ? "border-hue-purple-tint/30 bg-hue-purple-tint/10 text-hue-purple" : "border-transparent text-fg-muted hover:text-fg-secondary hover:bg-fill", )} > @@ -875,7 +875,7 @@ export function ConnectionModal({ onChange={(e) => setSSHPassword(e.target.value)} placeholder="••••••••" autoComplete="new-password" - className="h-9 bg-panel border-hairline focus:border-purple-500/50 text-xs" + className="h-9 bg-panel border-hairline focus:border-hue-purple-tint/50 text-xs" />
) : ( @@ -887,7 +887,7 @@ export function ConnectionModal({ onChange={(e) => setSSHPrivateKey(e.target.value)} placeholder="-----BEGIN OPENSSH PRIVATE KEY----- Paste private key here... -----END OPENSSH PRIVATE KEY-----" rows={4} - className="w-full rounded-md bg-panel border border-hairline focus:border-purple-500/50 text-xs font-mono text-fg-secondary p-2 resize-none placeholder:text-fg-subtle" + className="w-full rounded-md bg-panel border border-hairline focus:border-hue-purple-tint/50 text-xs font-mono text-fg-secondary p-2 resize-none placeholder:text-fg-subtle" />
@@ -898,7 +898,7 @@ export function ConnectionModal({ onChange={(e) => setSSHPassphrase(e.target.value)} placeholder="Key passphrase (if encrypted)" autoComplete="new-password" - className="h-9 bg-panel border-hairline focus:border-purple-500/50 text-xs" + className="h-9 bg-panel border-hairline focus:border-hue-purple-tint/50 text-xs" />
@@ -927,10 +927,10 @@ export function ConnectionModal({ className={cn( "flex items-center gap-2 p-3 rounded-lg border text-xs", testResult.tone === "success" - ? "bg-emerald-500/5 border-emerald-500/20 text-emerald-400" + ? "bg-success-tint/5 border-success-tint/20 text-success" : testResult.tone === "warning" - ? "bg-amber-500/5 border-amber-500/20 text-amber-400" - : "bg-red-500/5 border-red-500/20 text-red-400", + ? "bg-warning-tint/5 border-warning-tint/20 text-warning" + : "bg-danger-tint/5 border-danger-tint/20 text-danger", )} > {testResult.tone === "success" ? ( @@ -983,7 +983,7 @@ export function ConnectionModal({ mongoConnectionMode === "connectionString" && !connectionString.trim()) } - className="w-full md:w-auto min-w-0 md:min-w-[140px] bg-blue-600 hover:bg-blue-500 text-white font-medium text-xs h-10 shadow-lg shadow-blue-900/20 group relative overflow-hidden" + className="w-full md:w-auto min-w-0 md:min-w-[140px] bg-brand-solid hover:bg-brand-solid-hover text-white font-medium text-xs h-10 shadow-lg shadow-blue-900/20 group relative overflow-hidden" > {isTesting ? ( diff --git a/src/components/CreateTableModal.tsx b/src/components/CreateTableModal.tsx index e79067c2..40136574 100644 --- a/src/components/CreateTableModal.tsx +++ b/src/components/CreateTableModal.tsx @@ -107,8 +107,8 @@ export function CreateTableModal({ isOpen, onClose, onTableCreated }: CreateTabl
-
- +
+
Create New Table @@ -121,7 +121,7 @@ export function CreateTableModal({ isOpen, onClose, onTableCreated }: CreateTabl {/* Table Name Section */}
- +
@@ -133,7 +133,7 @@ export function CreateTableModal({ isOpen, onClose, onTableCreated }: CreateTabl placeholder="e.g. customers, orders, analytics_logs" value={tableName} onChange={(e) => setTableName(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, "_"))} - className="bg-raised border-hairline focus-visible:ring-blue-500/50" + className="bg-raised border-hairline focus-visible:ring-brand-tint/50" />
@@ -142,14 +142,14 @@ export function CreateTableModal({ isOpen, onClose, onTableCreated }: CreateTabl
- +
@@ -186,7 +186,7 @@ export function CreateTableModal({ isOpen, onClose, onTableCreated }: CreateTabl ))} {col.isPrimary && ( - + SERIAL (Auto-Inc) )} @@ -205,7 +205,7 @@ export function CreateTableModal({ isOpen, onClose, onTableCreated }: CreateTabl isNullable: checked ? false : col.isNullable, }) } - className="border-edge data-[state=checked]:bg-yellow-500 data-[state=checked]:border-yellow-500" + className="border-edge data-[state=checked]:bg-hue-yellow-tint data-[state=checked]:border-hue-yellow-tint" />
@@ -213,7 +213,7 @@ export function CreateTableModal({ isOpen, onClose, onTableCreated }: CreateTabl updateColumn(index, { isNullable: !!checked })} - className="border-edge data-[state=checked]:bg-blue-500 data-[state=checked]:border-blue-500" + className="border-edge data-[state=checked]:bg-hue-blue-tint data-[state=checked]:border-hue-blue-tint" />
@@ -221,7 +221,7 @@ export function CreateTableModal({ isOpen, onClose, onTableCreated }: CreateTabl updateColumn(index, { isUnique: !!checked })} - className="border-edge data-[state=checked]:bg-purple-500 data-[state=checked]:border-purple-500" + className="border-edge data-[state=checked]:bg-hue-purple-tint data-[state=checked]:border-hue-purple-tint" />
@@ -229,7 +229,7 @@ export function CreateTableModal({ isOpen, onClose, onTableCreated }: CreateTabl @@ -1127,10 +1129,10 @@ export function DataCharts({ result, spec = null }: DataChartsProps) { Numeric: {analysis.numericFields.length} {chartType === "pie" && chartData.length > MAX_SERIES && ( - Showing top {MAX_SERIES} values + Showing top {MAX_SERIES} values )} {MULTI_SERIES_CHART_TYPES.has(chartType) && droppedYAxisCount > 0 && ( - + Showing first {MAX_SERIES} of {yAxis.length} series — see the Results grid for the rest )} diff --git a/src/components/DataImportModal.tsx b/src/components/DataImportModal.tsx index 7a83e308..89f032b9 100644 --- a/src/components/DataImportModal.tsx +++ b/src/components/DataImportModal.tsx @@ -300,7 +300,7 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp - + {"Import Data"} {fileName && {fileName}} @@ -314,9 +314,9 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp className={cn( "flex items-center gap-1.5 text-xs font-mediumr", step === s - ? "text-blue-400" + ? "text-brand" : idx < ["upload", "preview", "configure", "ready"].indexOf(step) - ? "text-emerald-400" + ? "text-success" : "text-fg-subtle", )} > @@ -324,9 +324,9 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp className={cn( "w-5 h-5 rounded-full flex items-center justify-center text-[0.625rem]", step === s - ? "bg-blue-500/20 border border-blue-500/40" + ? "bg-brand-tint/20 border border-brand-tint/40" : idx < ["upload", "preview", "configure", "ready"].indexOf(step) - ? "bg-emerald-500/20 border border-emerald-500/40" + ? "bg-success-tint/20 border border-success-tint/40" : "bg-fill border border-hairline-strong", )} > @@ -354,7 +354,7 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp onDrop={handleDrop} onDragOver={handleDragOver} onClick={() => fileInputRef.current?.click()} - className="w-full border-2 border-dashed border-hairline-strong rounded-xl p-12 text-center cursor-pointer hover:border-blue-500/30 hover:bg-blue-500/5 transition-all" + className="w-full border-2 border-dashed border-hairline-strong rounded-xl p-12 text-center cursor-pointer hover:border-brand-tint/30 hover:bg-brand-tint/5 transition-all" >

Drop a file here or click to browse

@@ -381,9 +381,9 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp }} /> {error && ( -
- - {error} +
+ + {error}
)}
@@ -395,9 +395,9 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp
{fileType === "json" ? ( - + ) : ( - + )}

{fileName}

@@ -458,7 +458,7 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp