Repository navigation
Design Tokens
This page explains how to build the Design Language with shadcn/ui, Tailwind CSS v4 and Vite. The why is on that page; this page covers the how.
There is one source of truth: src/index.css. Every colour, radius, font and duration is a CSS variable defined there. Components, the SQL editor, the grid and the charts all read from it, so changing the look never means hunting through component code.
- Set up shadcn/ui
src/index.css- Theme switching without a flash
- The SQL editor (CodeMirror 6)
- Canvas surfaces: grid and charts
- The engine lamp
- Rules for changing tokens
- A contrast test in CI
- Token reference
These steps run once, at the Milestone 0 scaffold.
-
Choose the configuration at ui.shadcn.com/create, then run the
initcommand it generates:Option Value Framework Vite Component library Base UI (ADR-009) Style Mira (Nova as the fallback) Icon library Lucide Base colour Any. It's replaced in step 3. Radius 0.375rem Running init directly also works:
pnpm dlx shadcn@latest initpicks Base UI by default. Don't pass-b radix, and don't install Radix packages alongside Base UI. -
Install the fonts, which are self-hosted with no runtime requests to a font CDN:
pnpm add @fontsource-variable/atkinson-hyperlegible-next @fontsource-variable/atkinson-hyperlegible-mono
-
Replace
src/index.csswith the file in section 2. -
Add components only when you need them, for example:
pnpm dlx shadcn@latest add button tabs tooltip kbd resizable sidebar command dropdown-menu alert empty fieldChange a component's look through tokens and variants, never by editing colours inside the component file. If a component can't do what you need, raise it in a discussion before forking it. For now, Blinkql builds no UI components of its own.
The values below come from the verified palette (Design Language §4). The shadcn/ui token names are kept, so every shadcn component works unchanged; Blinkql's additions are marked.
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css"; /* data-open, data-checked… variants the Base UI components use */
@import "@fontsource-variable/atkinson-hyperlegible-next";
@import "@fontsource-variable/atkinson-hyperlegible-mono";
@custom-variant dark (&:is(.dark *));
:root {
color-scheme: light;
--radius: 0.375rem; /* 6px: controls. Docked panes use 0, floating layers use radius-xl (10px) */
/* shadcn/ui semantic tokens */
--background: oklch(0.99 0.002 150);
--foreground: oklch(0.235 0.012 150);
--card: oklch(0.99 0.002 150);
--card-foreground: oklch(0.235 0.012 150);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.235 0.012 150);
--primary: oklch(0.5 0.141 250);
--primary-foreground: oklch(0.99 0.004 250);
--secondary: oklch(0.93 0.006 150);
--secondary-foreground: oklch(0.235 0.012 150);
--muted: oklch(0.935 0.006 150);
--muted-foreground: oklch(0.475 0.012 150);
--accent: oklch(0.925 0.008 150);
--accent-foreground: oklch(0.235 0.012 150);
--destructive: oklch(0.53 0.195 27);
--destructive-foreground: oklch(0.99 0.004 27);
--border: oklch(0.885 0.007 150);
--input: oklch(0.62 0.01 150);
--ring: oklch(0.56 0.15 250);
/* Blinkql additions */
--chrome: oklch(0.958 0.005 150);
--selection: oklch(0.935 0.032 250);
--warning: oklch(0.8 0.15 75);
--warning-foreground: oklch(0.3 0.07 60);
--warning-text: oklch(0.52 0.116 65);
--success: oklch(0.53 0.125 155);
--success-foreground: oklch(0.99 0.004 155);
--lamp-off: oklch(0.78 0.008 150);
/* sidebar = machine-grey chrome */
--sidebar: oklch(0.958 0.005 150);
--sidebar-foreground: oklch(0.235 0.012 150);
--sidebar-primary: oklch(0.5 0.141 250);
--sidebar-primary-foreground: oklch(0.99 0.004 250);
--sidebar-accent: oklch(0.925 0.008 150);
--sidebar-accent-foreground: oklch(0.235 0.012 150);
--sidebar-border: oklch(0.885 0.007 150);
--sidebar-ring: oklch(0.56 0.15 250);
/* data colours: charts and grid */
--data-ink: oklch(0.43 0.025 250);
--data-ink-muted: oklch(0.76 0.015 250);
--data-reference: oklch(0.42 0.01 150);
--gridline: oklch(0.92 0.006 150);
--chart-1: oklch(0.64 0.159 245); /* #0993E5 */
--chart-2: oklch(0.48 0.111 60); /* #8A4C04 */
--chart-3: oklch(0.56 0.17 5); /* #C13D67 */
--chart-4: oklch(0.56 0.1 220); /* #0A819D */
--chart-5: oklch(0.64 0.131 80); /* #B58209 */
--chart-6: oklch(0.48 0.17 275); /* #464DBB */
--chart-seq-1: oklch(0.95 0.023 250);
--chart-seq-2: oklch(0.87 0.052 250);
--chart-seq-3: oklch(0.79 0.075 250);
--chart-seq-4: oklch(0.7 0.1 250);
--chart-seq-5: oklch(0.61 0.125 250);
--chart-seq-6: oklch(0.52 0.145 250);
--chart-seq-7: oklch(0.43 0.12 250);
--chart-div-1: oklch(0.5 0.122 55);
--chart-div-2: oklch(0.66 0.11 60);
--chart-div-3: oklch(0.82 0.06 65);
--chart-div-4: oklch(0.95 0.004 150);
--chart-div-5: oklch(0.82 0.05 245);
--chart-div-6: oklch(0.66 0.1 248);
--chart-div-7: oklch(0.5 0.13 250);
/* SQL editor (CodeMirror) */
--syntax-keyword: oklch(0.5 0.141 250);
--syntax-string: oklch(0.48 0.105 65);
--syntax-number: oklch(0.48 0.16 300);
--syntax-function: oklch(0.42 0.071 195);
--syntax-comment: oklch(0.54 0.012 150);
/* motion */
--duration-fast: 100ms; /* hover and press colour changes */
--duration-base: 160ms; /* menus, popovers, tooltips */
--duration-slow: 240ms; /* dialogs and sheets */
--ease-out: cubic-bezier(0.2, 0, 0, 1); /* entering: fast start, soft landing */
--ease-in: cubic-bezier(0.4, 0, 1, 1); /* leaving: gets out of the way */
--lamp-blink: 1000ms; /* 1 Hz, far below the 3-flashes-per-second limit */
--lamp-breathe: 1600ms; /* engine loading: slower rhythm than a running query */
}
.dark {
color-scheme: dark;
/* shadcn/ui semantic tokens */
--background: oklch(0.245 0.006 150);
--foreground: oklch(0.935 0.006 150);
--card: oklch(0.245 0.006 150);
--card-foreground: oklch(0.935 0.006 150);
--popover: oklch(0.285 0.007 150);
--popover-foreground: oklch(0.935 0.006 150);
--primary: oklch(0.735 0.125 250);
--primary-foreground: oklch(0.2 0.04 250);
--secondary: oklch(0.3 0.007 150);
--secondary-foreground: oklch(0.935 0.006 150);
--muted: oklch(0.29 0.007 150);
--muted-foreground: oklch(0.735 0.01 150);
--accent: oklch(0.315 0.008 150);
--accent-foreground: oklch(0.935 0.006 150);
--destructive: oklch(0.68 0.17 25);
--destructive-foreground: oklch(0.2 0.04 25);
--border: oklch(0.345 0.008 150);
--input: oklch(0.555 0.01 150);
--ring: oklch(0.68 0.13 250);
/* Blinkql additions */
--chrome: oklch(0.218 0.006 150);
--selection: oklch(0.335 0.06 250);
--warning: oklch(0.8 0.14 75);
--warning-foreground: oklch(0.25 0.059 60);
--warning-text: oklch(0.82 0.13 78);
--success: oklch(0.72 0.14 155);
--success-foreground: oklch(0.2 0.04 155);
--lamp-off: oklch(0.42 0.008 150);
/* sidebar = machine-grey chrome */
--sidebar: oklch(0.218 0.006 150);
--sidebar-foreground: oklch(0.935 0.006 150);
--sidebar-primary: oklch(0.735 0.125 250);
--sidebar-primary-foreground: oklch(0.2 0.04 250);
--sidebar-accent: oklch(0.315 0.008 150);
--sidebar-accent-foreground: oklch(0.935 0.006 150);
--sidebar-border: oklch(0.345 0.008 150);
--sidebar-ring: oklch(0.68 0.13 250);
/* data colours: charts and grid */
--data-ink: oklch(0.78 0.03 250);
--data-ink-muted: oklch(0.45 0.02 250);
--data-reference: oklch(0.78 0.01 150);
--gridline: oklch(0.3 0.007 150);
--chart-1: oklch(0.82 0.092 250); /* #96C9FE */
--chart-2: oklch(0.7 0.142 65); /* #DA892A */
--chart-3: oklch(0.62 0.17 5); /* #D55078 */
--chart-4: oklch(0.62 0.111 220); /* #0C95B4 */
--chart-5: oklch(0.82 0.105 95); /* #D9C472 */
--chart-6: oklch(0.7 0.167 290); /* #9F89FE */
--chart-seq-1: oklch(0.3 0.044 250);
--chart-seq-2: oklch(0.38 0.066 250);
--chart-seq-3: oklch(0.47 0.092 250);
--chart-seq-4: oklch(0.56 0.117 250);
--chart-seq-5: oklch(0.65 0.142 250);
--chart-seq-6: oklch(0.74 0.138 250);
--chart-seq-7: oklch(0.83 0.086 250);
--chart-div-1: oklch(0.78 0.13 60);
--chart-div-2: oklch(0.62 0.11 58);
--chart-div-3: oklch(0.44 0.07 55);
--chart-div-4: oklch(0.3 0.006 150);
--chart-div-5: oklch(0.44 0.07 248);
--chart-div-6: oklch(0.62 0.11 248);
--chart-div-7: oklch(0.78 0.11 248);
/* SQL editor (CodeMirror) */
--syntax-keyword: oklch(0.76 0.115 250);
--syntax-string: oklch(0.82 0.11 75);
--syntax-number: oklch(0.78 0.11 300);
--syntax-function: oklch(0.8 0.09 190);
--syntax-comment: oklch(0.66 0.012 150);
}
@theme inline {
--font-sans: "Atkinson Hyperlegible Next Variable", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Atkinson Hyperlegible Mono Variable", ui-monospace, "Cascadia Mono", Consolas, monospace;
--font-heading: var(--font-sans); /* shadcn components use font-heading for titles */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chrome: var(--chrome);
--color-selection: var(--selection);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-warning-text: var(--warning-text);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-lamp-off: var(--lamp-off);
--color-data-ink: var(--data-ink);
--color-data-ink-muted: var(--data-ink-muted);
--color-data-reference: var(--data-reference);
--color-gridline: var(--gridline);
--color-syntax-keyword: var(--syntax-keyword);
--color-syntax-string: var(--syntax-string);
--color-syntax-number: var(--syntax-number);
--color-syntax-function: var(--syntax-function);
--color-syntax-comment: var(--syntax-comment);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-chart-6: var(--chart-6);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground font-sans;
font-size: 0.875rem; /* 14px */
line-height: 1.25rem; /* 20px */
font-optical-sizing: auto;
}
/* Numbers that sit in columns or update live never change width (or use Tailwind's tabular-nums) */
td, [data-numeric] {
font-variant-numeric: tabular-nums;
}
/* lucide's default stroke (2) is heavy next to 14px Atkinson; shadcn already sizes icons to 16px */
svg.lucide {
stroke-width: 1.75;
}
}
/* The engine lamp: one element, five states (see Design Language, section 8) */
@layer components {
.lamp {
@apply inline-block size-2.5 rounded-full;
background: var(--lamp-off);
}
.lamp[data-state="loading"] { background: var(--primary); animation: lamp-breathe var(--lamp-breathe) ease-in-out infinite; }
.lamp[data-state="running"] { background: var(--primary); animation: lamp-blink var(--lamp-blink) steps(1, end) 5; }
.lamp[data-state="done"] { background: var(--lamp-off); animation: lamp-done 400ms var(--ease-out) 1; }
.lamp[data-state="warning"] { background: var(--warning); }
.lamp[data-state="error"] { background: var(--destructive); }
}
@keyframes lamp-blink { 50% { opacity: 0.2; } }
@keyframes lamp-breathe { 50% { opacity: 0.35; } }
@keyframes lamp-done { 0% { background: var(--primary); box-shadow: 0 0 0 0 color-mix(in oklch, var(--primary) 60%, transparent); }
100% { box-shadow: 0 0 0 8px transparent; } }
@media (prefers-reduced-motion: reduce) {
.lamp { animation: none !important; }
*, *::before, *::after { transition-duration: 0ms !important; animation-duration: 0ms !important; }
}
@media (forced-colors: active) {
.lamp { forced-color-adjust: none; outline: 1px solid CanvasText; }
}Notes
-
--accentin shadcn/ui means the hover surface, not a brand accent. Blinkql's signal blue is--primary. -
--chromeis Blinkql's own token for the top bar, toolbars and grid header. shadcn's--sidebartokens point at the same values, so theSidebarcomponent matches the rest of the chrome. -
Tailwind utilities come for free:
bg-chrome,bg-selection,text-warning-text,bg-chart-6,text-syntax-keywordand so on, through the@theme inlineblock. -
Two lines come from shadcn/ui, not the palette.
@import "shadcn/tailwind.css"defines thedata-open,data-closed,data-checkedand similar variants that the Base UI components style themselves with; without it, menus and popovers lose their open and closed states.--font-headingis the family shadcn components use for titles, pointed at the body font. Theshadcnpackage that provides the import is installed byshadcn init. -
The font family names (
"Atkinson Hyperlegible Next Variable") are the ones Fontsource registers. Thelatinsubsets are 34 KB and 18 KB as WOFF2.
The theme is the .dark class on <html>. There are three settings: System (the default), Light and Dark.
The class has to be set before the first paint. Otherwise people who use dark mode see a white flash on every load, which also counts against the CLS budget. Settings normally live in IndexedDB, but IndexedDB is asynchronous, so the theme choice is also mirrored to localStorage, which can be read synchronously.
Load this as a small same-origin file in <head>, before the CSS. A separate file keeps the Content Security Policy strict, because an inline script would need a hash added to script-src (Privacy & Security).
<!-- index.html -->
<script src="/theme-init.js"></script>// public/theme-init.js
try {
const choice = localStorage.getItem("blinkql-theme") ?? "system";
const dark =
choice === "dark" ||
(choice === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
} catch {
// Storage can be blocked (private windows, strict settings). Fall back to light.
}In the app, follow shadcn's Vite dark-mode guide for the theme provider. When the choice is System, listen for matchMedia("(prefers-color-scheme: dark)") changes so the app follows the operating system live. Whenever the theme changes, tell the canvas surfaces to refresh their colours (section 5).
CodeMirror styles accept CSS variables, so the editor follows the theme without reconfiguring anything.
// src/features/editor/theme.ts
import { EditorView } from "@codemirror/view";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags as t } from "@lezer/highlight";
const mono = '"Atkinson Hyperlegible Mono Variable", ui-monospace, Consolas, monospace';
const chrome = EditorView.theme({
"&": { color: "var(--foreground)", backgroundColor: "var(--background)", fontSize: "14px" },
".cm-content": { fontFamily: mono, lineHeight: "21px", caretColor: "var(--primary)" },
".cm-gutters": { backgroundColor: "var(--background)", color: "var(--muted-foreground)", border: "none" },
".cm-activeLine": { backgroundColor: "color-mix(in oklab, var(--muted) 70%, transparent)" },
".cm-activeLineGutter": { backgroundColor: "transparent", color: "var(--foreground)" },
".cm-cursor": { borderLeftColor: "var(--primary)", borderLeftWidth: "2px" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": { backgroundColor: "var(--selection)" },
".cm-tooltip": {
backgroundColor: "var(--popover)", color: "var(--foreground)",
border: "1px solid var(--border)", borderRadius: "10px",
},
".cm-tooltip-autocomplete ul li[aria-selected]": { backgroundColor: "var(--accent)", color: "var(--foreground)" },
});
const highlight = HighlightStyle.define([
{ tag: t.keyword, color: "var(--syntax-keyword)", fontWeight: "600" },
{ tag: [t.string, t.special(t.string)], color: "var(--syntax-string)" },
{ tag: [t.number, t.bool, t.null], color: "var(--syntax-number)" },
{ tag: [t.function(t.variableName), t.standard(t.name)], color: "var(--syntax-function)" },
{ tag: [t.lineComment, t.blockComment], color: "var(--syntax-comment)", fontStyle: "italic" },
{ tag: [t.operator, t.punctuation], color: "var(--muted-foreground)" },
]);
export const blinkqlEditorTheme = [chrome, syntaxHighlighting(highlight)];Every syntax colour is at least 4.5:1 against the editor background in both themes (see the token reference). Don't enable bracket rainbows or other extra colour layers. In the control-room direction, colour has to mean something.
A <canvas> can't read CSS variables, and ECharts computes hover and emphasis colours itself, which needs plain RGB. So canvas code reads the tokens once and converts them to RGB each time the theme changes.
// src/lib/tokens.ts
const probe = document.createElement("canvas").getContext("2d", { willReadFrequently: true })!;
/** Resolve any CSS colour (including oklch) to "rgb(r g b)". */
function toRgb(color: string): string {
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = color;
probe.fillRect(0, 0, 1, 1);
const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
return `rgb(${r} ${g} ${b})`;
}
export function readTokens(root: Element = document.documentElement) {
const css = getComputedStyle(root);
const v = (name: string) => toRgb(css.getPropertyValue(`--${name}`).trim());
return {
background: v("background"), foreground: v("foreground"), muted: v("muted-foreground"),
border: v("border"), gridline: v("gridline"), selection: v("selection"), ring: v("ring"),
popover: v("popover"), chrome: v("chrome"),
dataInk: v("data-ink"), dataInkMuted: v("data-ink-muted"), dataReference: v("data-reference"),
chart: [1, 2, 3, 4, 5, 6].map((i) => v(`chart-${i}`)),
sequential: [1, 2, 3, 4, 5, 6, 7].map((i) => v(`chart-seq-${i}`)),
diverging: [1, 2, 3, 4, 5, 6, 7].map((i) => v(`chart-div-${i}`)),
};
}
export type Tokens = ReturnType<typeof readTokens>;ECharts. Use the canvas renderer (ADR-006), and register one theme per mode from the tokens:
import * as echarts from "echarts/core";
import { readTokens } from "@/lib/tokens";
export function registerChartTheme(name: "blinkql-light" | "blinkql-dark") {
const k = readTokens();
const font = '"Atkinson Hyperlegible Next Variable", system-ui, sans-serif';
echarts.registerTheme(name, {
color: k.chart,
backgroundColor: "transparent",
textStyle: { fontFamily: font, color: k.muted, fontSize: 12 },
title: { textStyle: { color: k.foreground, fontSize: 18, fontWeight: 600 } },
categoryAxis: {
axisLine: { lineStyle: { color: k.border } }, axisTick: { show: false },
axisLabel: { color: k.muted }, splitLine: { show: false },
},
valueAxis: {
axisLine: { show: false }, axisLabel: { color: k.muted },
splitLine: { lineStyle: { color: k.gridline } },
},
bar: { itemStyle: { color: k.dataInk, borderRadius: [2, 2, 0, 0] } },
line: { lineStyle: { width: 2 }, symbolSize: 6 },
tooltip: { backgroundColor: k.popover, borderColor: k.border, textStyle: { color: k.foreground } },
});
}
// In every chart option: { animation: false } (charts appear in their final state; see Design Language §8.4)Single-measure charts set series colours explicitly. Use dataInk for the important items and dataInkMuted for the rest, and draw reference lines with dataReference (dashed, with a text label). The categorical palette (k.chart) is only for genuine categories.
The results grid reads the same Tokens object. Paint rows on background, dividers with gridline, selection with selection and the focused cell with a 2 px ring. Repaint everything when the theme changes.
The lamp is a styled <span> placed inside shadcn components such as Badge or Button. It isn't a component of its own. Its states and keyframes are already in index.css; the state comes from the engine store.
<Badge variant="outline" className="gap-2">
<span className="lamp" data-state={engineState} aria-hidden="true" />
<span>{statusText}</span>
</Badge>
<div role="status" aria-live="polite" className="sr-only">{announcement}</div>-
engineStateis one ofloading,running,done,warningorerror. Leave it unset for ready. -
statusTextalways carries the meaning in words: "Running 3.4 s", "40 rows in 212 ms". -
announcementchanges once per query ("Query finished, 40 rows in 212 ms"), never on every tick. -
runningblinks five times, then stays lit. To replaydoneon the next result, re-mount the element or toggle the attribute off and on.
-
Name tokens by role, never by colour:
--warning, not--amber;--data-ink, not--slate. - Define every token in both
:rootand.dark. -
Never put hex or
oklch()values in component code. Use a token or a Tailwind utility built from one. -
Mix colours in OKLab, not OKLCH:
color-mix(in oklab, var(--destructive) 7%, var(--background)). Mixing a strong hue with a near-grey in OKLCH interpolates the hue angle, so a red tint passes through yellow and comes out olive. OKLab mixes straight across and stays red. (Mixing withtransparentis fine in either space.) - Check contrast in both themes before merging (section 8). Text needs 4.5:1. Field edges, focus rings and chart marks need 3:1.
- Status colours stay reserved. A new feature that needs colour uses signal blue (if it's actionable) or data colours (if it's data). It never borrows red, amber or green.
This turns the colour rules into a regression check (goal E3). It lives in src/contrast.test.ts and runs with pnpm test, using culori (a dev dependency only) and Vitest. Add a row to pairs whenever a new token is meant to carry text or marks.
// src/contrast.test.ts
// @vitest-environment node
// Turns the colour rules in the Design Tokens wiki page (section 8) into a regression check:
// every text, control and chart colour must keep its WCAG contrast ratio in both themes.
import { readFileSync } from "node:fs";
import { wcagContrast } from "culori";
import { describe, expect, it } from "vitest";
const css = readFileSync(new URL("./index.css", import.meta.url), "utf8");
/** Custom properties declared in the top-level rule for `selector`, e.g. ":root" or ".dark". */
function tokens(selector: string): Record<string, string> {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const body = css.match(new RegExp(`^${escaped}\\s*\\{([^}]*)\\}`, "m"))?.[1];
if (body === undefined) throw new Error(`No "${selector}" block in index.css`);
return Object.fromEntries(
[...body.matchAll(/--([\w-]+):\s*([^;]+);/g)].map(([, name, value]) => [name, value?.trim()]),
);
}
const themes = { light: tokens(":root"), dark: tokens(".dark") };
// [foreground, background, minimum ratio]: 4.5:1 for text, 3:1 for field edges, rings and marks
const pairs: [string, string, number][] = [
["foreground", "background", 4.5],
["foreground", "chrome", 4.5],
["muted-foreground", "background", 4.5],
["muted-foreground", "chrome", 4.5],
["muted-foreground", "muted", 4.5],
["primary-foreground", "primary", 4.5],
["primary", "background", 4.5],
["destructive-foreground", "destructive", 4.5],
["destructive", "background", 4.5],
["warning-foreground", "warning", 4.5],
["warning-text", "background", 4.5],
["success-foreground", "success", 4.5],
["foreground", "selection", 4.5],
["foreground", "popover", 4.5],
["syntax-keyword", "background", 4.5],
["syntax-string", "background", 4.5],
["syntax-number", "background", 4.5],
["syntax-function", "background", 4.5],
["syntax-comment", "background", 4.5],
["input", "background", 3],
["input", "chrome", 3],
["ring", "background", 3],
["ring", "chrome", 3],
["data-ink", "background", 3],
["data-reference", "background", 4.5],
...[1, 2, 3, 4, 5, 6].map((i): [string, string, number] => [`chart-${i}`, "background", 3]),
];
describe.each(Object.entries(themes))("%s theme", (_, theme) => {
it.each(pairs)("%s on %s ≥ %d:1", (fg, bg, min) => {
const foreground = theme[fg];
const background = theme[bg];
if (foreground === undefined || background === undefined) {
throw new Error(`Missing token --${foreground === undefined ? fg : bg}`);
}
expect(wcagContrast(foreground, background)).toBeGreaterThanOrEqual(min);
});
});These are the same values as index.css, as hex, for anything outside CSS: design files, slides, screenshots. The CSS is the source of truth. Contrast is measured against --background, the work surface.
Surfaces and text
| Token | Light | Dark | Light contrast | Dark contrast |
|---|---|---|---|---|
--chrome |
#EFF2EF oklch(0.958 0.005 150)
|
#181B19 oklch(0.218 0.006 150)
|
1.10:1 | 1.07:1 |
--background |
#FBFCFB oklch(0.99 0.002 150)
|
#1E211F oklch(0.245 0.006 150)
|
1.00:1 | 1.00:1 |
--card |
#FBFCFB oklch(0.99 0.002 150)
|
#1E211F oklch(0.245 0.006 150)
|
1.00:1 | 1.00:1 |
--popover |
#FFFFFF oklch(1 0 0)
|
#282B28 oklch(0.285 0.007 150)
|
1.03:1 | 1.13:1 |
--muted |
#E7EBE7 oklch(0.935 0.006 150)
|
#292C29 oklch(0.29 0.007 150)
|
1.17:1 | 1.15:1 |
--accent |
#E3E8E3 oklch(0.925 0.008 150)
|
#2F332F oklch(0.315 0.008 150)
|
1.21:1 | 1.27:1 |
--border |
#D6DAD7 oklch(0.885 0.007 150)
|
#363A37 oklch(0.345 0.008 150)
|
1.37:1 | 1.41:1 |
--input |
#828883 oklch(0.62 0.01 150)
|
#6F7570 oklch(0.555 0.01 150)
|
3.52:1 | 3.45:1 |
--foreground |
#1A201B oklch(0.235 0.012 150)
|
#E7EBE7 oklch(0.935 0.006 150)
|
16.12:1 | 13.49:1 |
--muted-foreground |
#585E59 oklch(0.475 0.012 150)
|
#A5ABA6 oklch(0.735 0.01 150)
|
6.46:1 | 6.94:1 |
--lamp-off |
#B4B9B5 oklch(0.78 0.008 150)
|
#4A4E4B oklch(0.42 0.008 150)
|
1.94:1 | 1.92:1 |
Signal blue
| Token | Light | Dark | Light contrast | Dark contrast |
|---|---|---|---|---|
--primary |
#0165AF oklch(0.5 0.141 250)
|
#68AEF5 oklch(0.735 0.125 250)
|
5.86:1 | 6.93:1 |
--primary-foreground |
#FAFCFE oklch(0.99 0.004 250)
|
#071727 oklch(0.2 0.04 250)
|
1.00:1 | 1.11:1 |
--ring |
#1577C8 oklch(0.56 0.15 250)
|
#549DE5 oklch(0.68 0.13 250)
|
4.53:1 | 5.67:1 |
--selection |
#DAECFF oklch(0.935 0.032 250)
|
#1D3954 oklch(0.335 0.06 250)
|
1.17:1 | 1.37:1 |
Status
| Token | Light | Dark | Light contrast | Dark contrast |
|---|---|---|---|---|
--destructive |
#C32324 oklch(0.53 0.195 27)
|
#EF6661 oklch(0.68 0.17 25)
|
5.69:1 | 5.22:1 |
--destructive-foreground |
#FEFBFA oklch(0.99 0.004 27)
|
#250E0D oklch(0.2 0.04 25)
|
1.00:1 | 1.13:1 |
--warning |
#F5AE39 oklch(0.8 0.15 75)
|
#F2AF48 oklch(0.8 0.14 75)
|
1.86:1 | 8.51:1 |
--warning-foreground |
#472400 oklch(0.3 0.07 60)
|
#351900 oklch(0.25 0.059 60)
|
13.43:1 | 1.00:1 |
--warning-text |
#965802 oklch(0.52 0.116 65)
|
#F2B95A oklch(0.82 0.13 78)
|
5.53:1 | 9.18:1 |
--success |
#13804A oklch(0.53 0.125 155)
|
#4EBE7D oklch(0.72 0.14 155)
|
4.85:1 | 6.95:1 |
--success-foreground |
#FAFDFB oklch(0.99 0.004 155)
|
#051B0E oklch(0.2 0.04 155)
|
1.00:1 | 1.11:1 |
Data
| Token | Light | Dark | Light contrast | Dark contrast |
|---|---|---|---|---|
--data-ink |
#46515D oklch(0.43 0.025 250)
|
#A9B9CA oklch(0.78 0.03 250)
|
7.87:1 | 8.11:1 |
--data-ink-muted |
#AAB2BA oklch(0.76 0.015 250)
|
#4D5660 oklch(0.45 0.02 250)
|
2.09:1 | 2.18:1 |
--data-reference |
#494F4A oklch(0.42 0.01 150)
|
#B3B9B4 oklch(0.78 0.01 150)
|
8.17:1 | 8.14:1 |
--gridline |
#E2E6E2 oklch(0.92 0.006 150)
|
#2B2F2C oklch(0.3 0.007 150)
|
1.23:1 | 1.20:1 |
SQL syntax
| Token | Light | Dark | Light contrast | Dark contrast |
|---|---|---|---|---|
--syntax-keyword |
#0165AF oklch(0.5 0.141 250)
|
#76B6F7 oklch(0.76 0.115 250)
|
5.86:1 | 7.59:1 |
--syntax-string |
#854F06 oklch(0.48 0.105 65)
|
#EEBA70 oklch(0.82 0.11 75)
|
6.55:1 | 9.20:1 |
--syntax-number |
#6D41A9 oklch(0.48 0.16 300)
|
#C2A7F4 oklch(0.78 0.11 300)
|
6.85:1 | 7.84:1 |
--syntax-function |
#025959 oklch(0.42 0.071 195)
|
#73D1CA oklch(0.8 0.09 190)
|
7.93:1 | 9.05:1 |
--syntax-comment |
#6A716B oklch(0.54 0.012 150)
|
#8D948E oklch(0.66 0.012 150)
|
4.88:1 | 5.23:1 |
Charts
| Token | Light | Dark |
|---|---|---|
--chart-1 |
#0993E5 |
#96C9FE |
--chart-2 |
#8A4C04 |
#DA892A |
--chart-3 |
#C13D67 |
#D55078 |
--chart-4 |
#0A819D |
#0C95B4 |
--chart-5 |
#B58209 |
#D9C472 |
--chart-6 |
#464DBB |
#9F89FE |
--chart-seq-1 |
#E3F0FE |
#1C2F43 |
--chart-seq-2 |
#BBD8F6 |
#254564 |
--chart-seq-3 |
#96BFE9 |
#2E5E8C |
--chart-seq-4 |
#6DA3DA |
#3878B6 |
--chart-seq-5 |
#4187CB |
#4193E2 |
--chart-seq-6 |
#056BB8 |
#61B0FE |
--chart-seq-7 |
#03528E |
#9DCCFE |
--chart-div-1 |
#964D04 |
#F4A25C |
--chart-div-2 |
#C38148 |
#B7743D |
--chart-div-3 |
#E0BD9C |
#70482B |
--chart-div-4 |
#EDEFED |
#2C2F2C |
--chart-div-5 |
#AAC8E3 |
#315677 |
--chart-div-6 |
#5E97CD |
#4C8BC5 |
--chart-div-7 |
#1666AA |
#7DBDFA |
⚡ Blinkql: queries in the blink of an eye · MIT licensed · Live app · Issues · Discussions
Project
Engineering
- Tech Stack
- Architecture
- Performance & Benchmarks
- Decision Records (ADRs)
- Privacy & Security
- Browser Support & Limitations
Design
Community