Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Check colour contrast (WCAG AA)
run: node scripts/check-contrast.js
continue-on-error: false

- name: Lint
run: pnpm run lint
continue-on-error: false
Expand Down
39 changes: 39 additions & 0 deletions client/design.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,42 @@
https://www.figma.com/design/EyFeqHjAPlmTAr8SEDIW2n/VeriWin?node-id=942-6498&t=KFfOXvT5FWkDRnWi-0

## Colour Tokens

Design tokens defined in `client/src/index.css` via `@theme` and `:root` / `.dark` blocks. All token pairs pass WCAG AA (4.5:1 normal text, 3:1 large text / UI components).

### Light mode

| Token | CSS Variable | Value | Used On |
|-------|-------------|-------|---------|
| surface | `--color-surface` | `#f3f4f6` | Button background |
| surface-hover | `--color-surface-hover` | `#e5e7eb` | Button hover background |
| border | `--color-border` | `#6b7280` | Button border (3.0:1 on surface) |
| icon | `--color-icon` | `#1f2937` | Icon / text on surface |
| icon-hover | `--color-icon-hover` | `#000000` | Icon / text on hover |
| bg | `--color-bg` | `#ffffff` | Page background |
| text-body | `--color-text-body` | `#1a1a1a` | Body text |

### Dark mode

| Token | CSS Variable | Effective Value | Used On |
|-------|-------------|----------------|---------|
| surface | `--color-surface` | `rgba(255,255,255,0.05)` → `#2f2f2f` | Button background |
| surface-hover | `--color-surface-hover` | `#d1d5db` | Button hover background |
| border | `--color-border` | `rgba(255,255,255,0.40)` → `#828282` (3.1:1 on surface) | Button border |
| icon | `--color-icon` | `rgba(255,255,255,0.80)` → `#d5d5d5` (9.4:1 on surface) | Icon / text on surface |
| icon-hover | `--color-icon-hover` | `#111827` | Icon / text on hover |
| bg | `--color-bg` | `#242424` | Page background |
| text-body | `--color-text-body` | `rgba(255,255,255,0.87)` → `#e2e2e2` | Body text |

### Contrast verification

All foreground–background pairs are verified by `scripts/check-contrast.js` in CI. To run locally:

```sh
node scripts/check-contrast.js
```

Thresholds: **4.5:1** for normal text, **3.0:1** for large text (≥18px / 14px bold) and UI components (borders, icons).



224 changes: 224 additions & 0 deletions client/scripts/check-contrast.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
#!/usr/bin/env node

import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const CSS_PATH = join(__dirname, '..', 'src', 'index.css');

const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
const c = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
const red = (s) => c('31', s);
const green = (s) => c('32', s);
const yellow = (s) => c('33', s);
const dim = (s) => c('2', s);

function parseHex(hex) {
hex = hex.replace(/^#/, '');
let r, g, b;
if (hex.length === 3) {
r = parseInt(hex[0] + hex[0], 16);
g = parseInt(hex[1] + hex[1], 16);
b = parseInt(hex[2] + hex[2], 16);
} else {
r = parseInt(hex.slice(0, 2), 16);
g = parseInt(hex.slice(2, 4), 16);
b = parseInt(hex.slice(4, 6), 16);
}
return { r, g, b, a: 1 };
}

function parseRgba(str) {
const m = str.match(
/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$/,
);
if (!m) return null;
return {
r: parseInt(m[1], 10),
g: parseInt(m[2], 10),
b: parseInt(m[3], 10),
a: m[4] !== undefined ? parseFloat(m[4]) : 1,
};
}

function parseColor(raw) {
raw = raw.trim();
if (raw.startsWith('#')) return parseHex(raw);
const rgba = parseRgba(raw);
if (rgba) return rgba;
throw new Error(`Cannot parse colour value: ${raw}`);
}

function srgbToLinear(v) {
v /= 255;
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}

function relativeLuminance({ r, g, b }) {
return (
0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b)
);
}

function contrastRatio(l1, l2) {
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}

function alphaBlend(fg, bg) {
const a = fg.a;
return {
r: Math.round(fg.r * a + bg.r * (1 - a)),
g: Math.round(fg.g * a + bg.g * (1 - a)),
b: Math.round(fg.b * a + bg.b * (1 - a)),
};
}

function extractVars(css, selectorPattern) {
const combined = {};
const re = new RegExp(`${selectorPattern}\\s*\\{([^}]*)\\}`, 'gs');
let match;
while ((match = re.exec(css)) !== null) {
const declRe = /(--[\w-]+)\s*:\s*([^;]+);/g;
let d;
while ((d = declRe.exec(match[1])) !== null) {
combined[d[1].trim()] = d[2].trim();
}
}
return combined;
}

function resolveColorValue(key, vars) {
const raw = vars[key];
if (!raw) throw new Error(`Missing CSS variable: ${key}`);
return parseColor(raw);
}

function blendChain(keys, vars) {
const colors = keys.map((k) => resolveColorValue(k, vars));
let result = {
r: colors[colors.length - 1].r,
g: colors[colors.length - 1].g,
b: colors[colors.length - 1].b,
};
for (let i = colors.length - 2; i >= 0; i--) {
result = alphaBlend(colors[i], result);
}
return result;
}

function main() {
let css;
try {
css = readFileSync(CSS_PATH, 'utf-8');
} catch (err) {
console.error(`${red('✖')} Cannot read ${CSS_PATH}: ${err.message}`);
process.exit(1);
}

const themeVars = extractVars(css, '@theme');
const rootVars = extractVars(css, ':root');
const darkVars = extractVars(css, '\\.dark');

const light = { ...themeVars, ...rootVars };
const dark = { ...themeVars, ...darkVars };

const pairs = [
{
mode: 'light', label: 'Body text on page background',
chain: ['--color-text-body', '--color-bg'],
threshold: 4.5,
},
{
mode: 'light', label: 'Icon on button surface',
chain: ['--color-icon', '--color-surface'],
threshold: 4.5,
},
{
mode: 'light', label: 'Icon hover on button hover surface',
chain: ['--color-icon-hover', '--color-surface-hover'],
threshold: 4.5,
},
{
mode: 'light', label: 'Border on button surface',
chain: ['--color-border', '--color-surface'],
threshold: 3.0,
},
{
mode: 'dark', label: 'Body text on page background',
chain: ['--color-text-body', '--color-bg'],
threshold: 4.5,
},
{
mode: 'dark', label: 'Icon on button surface',
chain: ['--color-icon', '--color-surface', '--color-bg'],
threshold: 4.5,
},
{
mode: 'dark', label: 'Icon hover on button hover surface',
chain: ['--color-icon-hover', '--color-surface-hover'],
threshold: 4.5,
},
{
mode: 'dark', label: 'Border on button surface',
chain: ['--color-border', '--color-surface', '--color-bg'],
threshold: 3.0,
},
];

const env = { light, dark };
let failures = 0;

console.log(`\n ${dim('Colour Contrast Checker')}\n`);

for (const pair of pairs) {
const vars = env[pair.mode];

let fgColor, bgColor;
try {
fgColor = blendChain(pair.chain, vars);
bgColor = blendChain(pair.chain.slice(1), vars);
} catch (err) {
console.log(` ${red('✖')} ${pair.label} (${pair.mode}): ${err.message}`);
failures++;
continue;
}

const lFg = relativeLuminance(fgColor);
const lBg = relativeLuminance(bgColor);
const ratio = contrastRatio(lFg, lBg);
const pass = ratio >= pair.threshold;

if (!pass) {
console.log(
` ${red('✖')} ${pair.label} (${pair.mode}): ${ratio.toFixed(2)}:1 — ` +
`requires ≥ ${pair.threshold}:1`,
);
failures++;
} else {
console.log(
` ${green('✔')} ${pair.label} (${pair.mode}): ${ratio.toFixed(2)}:1`,
);
}
}

if (failures === 0) {
console.log(`\n ${green('All colour pairs pass WCAG AA contrast.')}\n`);
process.exit(0);
}

console.log(
`\n ${red(`${failures} colour pair(s) failed WCAG AA contrast check.`)}\n`,
);
process.exit(1);
}

try {
main();
} catch (err) {
console.error(`${red('✖')} Unexpected error: ${err.message}`);
process.exit(1);
}
3 changes: 1 addition & 2 deletions client/src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,10 @@ export default function ThemeToggle() {
return (
<button
onClick={toggleTheme}
className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-300 dark:border-white/10 bg-gray-100 dark:bg-white/5 text-gray-800 dark:text-white/80 transition hover:bg-gray-200 dark:hover:bg-gray-300 dark:bg-white/10 hover:text-black dark:hover:text-gray-900 dark:text-white"
className="flex h-9 w-9 items-center justify-center rounded-full border border-border bg-surface text-icon transition hover:bg-surface-hover hover:text-icon-hover"
aria-label="Toggle theme"
title={`Current theme: ${theme}`}
>
{/* Display Moon when it resolves to Dark, Sun otherwise */}
{(theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches)) ? (
<Moon size={18} />
) : (
Expand Down
33 changes: 22 additions & 11 deletions client/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,39 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

@theme {
--color-surface: #f3f4f6;
--color-surface-hover: #e5e7eb;
--color-border: #6b7280;
--color-icon: #1f2937;
--color-icon-hover: #000000;
}

.dark {
--color-surface: rgba(255, 255, 255, 0.05);
--color-surface-hover: #d1d5db;
--color-border: rgba(255, 255, 255, 0.40);
--color-icon: rgba(255, 255, 255, 0.80);
--color-icon-hover: #111827;
--color-bg: #242424;
--color-text-body: rgba(255, 255, 255, 0.87);
}

:root {
--color-bg: #ffffff;
--color-text-body: #1a1a1a;
font-family: "IBM Plex Sans", "Space Mono", system-ui, Avenir, Helvetica,
Arial, sans-serif;
line-height: 1.5;
font-weight: 400;

color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;

font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

:root {
color: #1a1a1a;
background-color: #ffffff;
}

.dark {
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
color: var(--color-text-body);
background-color: var(--color-bg);
}
Loading