Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .github/workflows/site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ jobs:
node-version-file: .nvmrc
- run: node scripts/check-file-size.mjs

# Also runs in ci.yml, deliberately. That job is gated on `code`, which a
# site-only pull request sets to false, so without this the site's own
# contrast could regress with nothing looking at it. The script checks
# both surfaces and takes milliseconds, so running it twice costs nothing
# and neither workflow can be the one that forgot.
- name: Design token contrast
run: node scripts/design-tokens.mjs

# A ratchet on dead CSS, not a target. `css-shadowing.mjs` proves a
# declaration can never affect the page -- same selector, same media
# context, a later rule of equal-or-greater importance. It was written
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ its heading and collects entries; the date and the link go on with the tag.

### Fixed

- **The site's small grey text was unreadable on card surfaces.** The docs
footer, built-with row and mobile section labels use `--sl-color-gray-3`,
much of it at 12px. It was raised once already to clear 4.5:1 against the
page background, but nothing had checked it against the slightly darker card
surface, where it sat at 4.44:1. It now clears every light surface.
- **Muted text, and several status colours, were below the readability bar.**
Eight colour tokens failed WCAG AA (4.5:1) against surfaces they are actually
painted on. The worst were in the light theme, which gets far less use than
Expand Down
217 changes: 142 additions & 75 deletions scripts/design-tokens.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/usr/bin/env node
//
// Reads the desktop app's real colour tokens out of `tokens.css`, checks every
// foreground against every surface for WCAG contrast, and writes the
// `design-tokens.json` that the frontend checker reads.
// Reads the real colour tokens out of the CSS for both surfaces -- the desktop
// app and the site -- checks every foreground against every surface for WCAG
// contrast, and writes the `design-tokens.json` each frontend checker reads.
//
// node scripts/design-tokens.mjs # check (CI)
// node scripts/design-tokens.mjs --write # regenerate the JSON
Expand All @@ -28,45 +28,84 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const cssPath = path.join(repoRoot, 'apps/netscli-gui/src/styles/tokens.css');
const jsonPath = path.join(repoRoot, 'apps/netscli-gui/design-tokens.json');

const THEMES = {
dark: '.container',
light: '.container.theme-light',
};

/** Tokens that are painted as text or as an icon on top of a surface. */
const FOREGROUNDS = ['text-primary', 'text-secondary', 'text-muted', 'mint', 'cyan', 'red', 'amber'];
/** Tokens used as a background behind that text. */
const SURFACES = ['bg-body', 'bg-base', 'bg-elevated', 'bg-input', 'bg-toolbar', 'bg-selected'];

/**
* The keys the frontend checker requires, mapped onto this app's names.
* The two surfaces with their own palettes. They share nothing but this
* script: the desktop app has one `tokens.css` on `.container`, the site
* layers Starlight's `--sl-color-*` over a small shared `:root` block.
*
* `surface-base` is deliberately the *tightest* surface per theme rather than
* the nominal base one: bg-elevated is the lightest dark surface and bg-body
* the darkest light one, so each is where contrast is worst. Mapping the easy
* surface would let the gate pass while the dialog it actually ships stayed
* unreadable -- bg-base scored text-muted at 4.54 while bg-elevated, the
* settings dialog's own background, was at 4.13.
* the nominal base one, because that is where contrast is worst. For the app,
* bg-base scored text-muted at 4.54 while bg-elevated -- the settings dialog's
* own background -- was at 4.13, so mapping the flattering surface would have
* passed the gate with the dialog still unreadable.
*/
const CHECKER_KEYS = {
dark: { 'surface-base': 'bg-elevated', 'text-main': 'text-primary', 'text-muted': 'text-muted', accent: 'mint' },
light: { 'surface-base': 'bg-body', 'text-main': 'text-primary', 'text-muted': 'text-muted', accent: 'mint' },
};
const TARGETS = [
{
name: 'desktop app',
css: ['apps/netscli-gui/src/styles/tokens.css'],
json: 'apps/netscli-gui/design-tokens.json',
themes: { dark: '.container', light: '.container.theme-light' },
// The light block restates only what it overrides.
inheritsFrom: 'dark',
foregrounds: ['text-primary', 'text-secondary', 'text-muted', 'mint', 'cyan', 'red', 'amber'],
surfaces: ['bg-body', 'bg-base', 'bg-elevated', 'bg-input', 'bg-toolbar', 'bg-selected'],
keys: {
dark: { 'surface-base': 'bg-elevated', 'text-main': 'text-primary', 'text-muted': 'text-muted', accent: 'mint' },
light: { 'surface-base': 'bg-body', 'text-main': 'text-primary', 'text-muted': 'text-muted', accent: 'mint' },
},
},
{
name: 'site',
// tokens.css carries the brand accent under `:root`; the Starlight file
// reaches it with `--sl-color-accent: var(--netscli-accent)`, which is why
// values are resolved through one level of var() before use.
css: ['site/src/styles/tokens.css', 'site/src/styles/starlight/01-tokens-and-header.css'],
json: 'site/design-tokens.json',
themes: { dark: "html[data-theme='dark']", light: "html[data-theme='light']" },
sharedSelector: ':root',
// Starlight inverts its own naming in the light theme -- `--sl-color-white`
// is #111827 there -- so white/gray-1..3 are the text ramp in both themes
// and black/bg/gray-6 are the surfaces in both.
foregrounds: ['sl-color-white', 'sl-color-gray-1', 'sl-color-gray-2', 'sl-color-gray-3', 'sl-color-accent'],
surfaces: ['sl-color-bg', 'sl-color-bg-sidebar', 'sl-color-black', 'sl-color-gray-6'],
keys: {
dark: { 'surface-base': 'sl-color-bg', 'text-main': 'sl-color-gray-2', 'text-muted': 'sl-color-gray-3', 'text-strong': 'sl-color-white', accent: 'sl-color-accent' },
light: { 'surface-base': 'sl-color-gray-6', 'text-main': 'sl-color-gray-2', 'text-muted': 'sl-color-gray-3', 'text-strong': 'sl-color-white', accent: 'sl-color-accent' },
},
},
];

/**
* Custom properties declared for `selector`, merged across every rule that
* names it.
*
* Matching is on one comma-separated part of the selector list, not a prefix.
* Both details are load-bearing: the site writes
* `html[data-theme='dark'], html[data-theme='dark'] ::backdrop {`, which a
* `^selector\s*\{` anchor misses entirely and would have reported as "no rule";
* and a prefix match on `.container` would also match `.container.theme-light`
* and silently read the wrong theme.
*
* Later rules win, matching the cascade for equal specificity.
*/
function parseTokens(css, selector) {
// Match the selector's own block. The escape matters: `.container` would
// otherwise also match `.container.theme-light` and silently read the wrong
// theme's values.
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const block = new RegExp(`^${escaped}\\s*\\{([^}]*)\\}`, 'm').exec(css);
if (!block) throw new Error(`No rule for ${selector} in tokens.css`);
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
const tokens = {};
for (const [, name, value] of block[1].matchAll(/--([a-z0-9-]+):\s*(#[0-9a-fA-F]{3,8})\s*;/g)) {
tokens[name] = value.toLowerCase();
let found = false;
for (const [, selectorList, body] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const names = selectorList.split(',').map((part) => part.trim().replace(/\s+/g, ' '));
if (!names.includes(selector)) continue;
found = true;
for (const [, name, raw] of body.matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/g)) {
const value = raw.trim();
// Colours only: the same blocks carry sizes, z-indexes and rgb triples.
if (/^#[0-9a-fA-F]{3,8}$/.test(value) || /^var\(\s*--[a-z0-9-]+\s*\)$/.test(value)) {
tokens[name] = value.toLowerCase();
}
}
}
if (!found) throw new Error(`No rule for selector ${selector}`);
return tokens;
}

Expand All @@ -83,62 +122,90 @@ function contrast(a, b) {
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
}

const css = fs.readFileSync(cssPath, 'utf8');
const themes = Object.fromEntries(
Object.entries(THEMES).map(([name, selector]) => [name, parseTokens(css, selector)]),
);
/** Resolve one level of `var(--other-token)` against the same theme's map. */
function resolveVars(tokens) {
const resolved = { ...tokens };
for (const [name, value] of Object.entries(resolved)) {
const reference = /^var\(\s*--([a-z0-9-]+)\s*\)$/.exec(value);
if (reference && resolved[reference[1]]) resolved[name] = resolved[reference[1]];
}
return resolved;
}

// Light inherits from the dark block, so only overridden tokens are restated.
themes.light = { ...themes.dark, ...themes.light };
// Compared with line endings normalised, not byte for byte. `.gitattributes`
// sets `* text=auto`, so these files are checked out CRLF on Windows and LF on
// the Linux runner while the generator always emits LF. A byte comparison
// therefore passed CI and failed on every Windows working copy -- a guard that
// is green where nobody reads it and red where the maintainer works is worse
// than none.
const normalise = (text) => text.replace(/\r\n/g, '\n');

const write = process.argv.includes('--write');
const failures = [];
let checked = 0;
for (const [theme, tokens] of Object.entries(themes)) {
for (const fg of FOREGROUNDS) {
for (const bg of SURFACES) {
if (!tokens[fg] || !tokens[bg]) {
failures.push(`${theme}: missing token --${tokens[fg] ? bg : fg}`);
continue;
}
checked += 1;
const ratio = contrast(tokens[fg], tokens[bg]);
if (ratio < 4.5) {
failures.push(`${theme}: --${fg} (${tokens[fg]}) on --${bg} (${tokens[bg]}) = ${ratio.toFixed(2)} < 4.5`);
let checkedPairs = 0;

for (const target of TARGETS) {
const css = target.css.map((file) => fs.readFileSync(path.join(repoRoot, file), 'utf8')).join('\n');
const shared = target.sharedSelector ? parseTokens(css, target.sharedSelector) : {};

const themes = {};
for (const [theme, selector] of Object.entries(target.themes)) {
themes[theme] = { ...shared, ...parseTokens(css, selector) };
}
if (target.inheritsFrom) {
for (const theme of Object.keys(themes)) {
if (theme !== target.inheritsFrom) {
themes[theme] = { ...themes[target.inheritsFrom], ...themes[theme] };
}
}
}
}
for (const theme of Object.keys(themes)) themes[theme] = resolveVars(themes[theme]);

const generated = Object.fromEntries(
Object.entries(CHECKER_KEYS).map(([theme, mapping]) => [
theme,
Object.fromEntries(Object.entries(mapping).map(([key, token]) => [key, themes[theme][token]])),
]),
);
const serialized = `${JSON.stringify(generated, null, 2)}\n`;

// Compared with line endings normalised, not byte for byte. `.gitattributes`
// sets `* text=auto`, so this file is checked out CRLF on Windows and LF on the
// Linux runner while the generator always emits LF. A byte comparison therefore
// passed CI and failed on every Windows working copy -- a guard that is green
// where nobody reads it and red where the maintainer works is worse than none.
const normalise = (text) => text.replace(/\r\n/g, '\n');
for (const [theme, tokens] of Object.entries(themes)) {
for (const fg of target.foregrounds) {
for (const bg of target.surfaces) {
if (!tokens[fg] || !tokens[bg]) {
failures.push(`${target.name} ${theme}: missing token --${tokens[fg] ? bg : fg}`);
continue;
}
checkedPairs += 1;
const ratio = contrast(tokens[fg], tokens[bg]);
if (ratio < 4.5) {
failures.push(
`${target.name} ${theme}: --${fg} (${tokens[fg]}) on --${bg} (${tokens[bg]}) = ${ratio.toFixed(2)} < 4.5`,
);
}
}
}
}

if (process.argv.includes('--write')) {
fs.writeFileSync(jsonPath, serialized);
console.log(`Wrote ${path.relative(repoRoot, jsonPath)}`);
} else if (!fs.existsSync(jsonPath)) {
failures.push('design-tokens.json is missing; run: node scripts/design-tokens.mjs --write');
} else if (normalise(fs.readFileSync(jsonPath, 'utf8')) !== normalise(serialized)) {
failures.push(
'design-tokens.json is out of date with tokens.css; run: node scripts/design-tokens.mjs --write',
const generated = Object.fromEntries(
Object.entries(target.keys).map(([theme, mapping]) => [
theme,
Object.fromEntries(Object.entries(mapping).map(([key, token]) => [key, themes[theme][token]])),
]),
);
const serialized = `${JSON.stringify(generated, null, 2)}\n`;
const jsonPath = path.join(repoRoot, target.json);

if (write) {
fs.writeFileSync(jsonPath, serialized);
console.log(`Wrote ${target.json}`);
} else if (!fs.existsSync(jsonPath)) {
failures.push(`${target.json} is missing; run: node scripts/design-tokens.mjs --write`);
} else if (normalise(fs.readFileSync(jsonPath, 'utf8')) !== normalise(serialized)) {
failures.push(
`${target.json} is out of date with its CSS; run: node scripts/design-tokens.mjs --write`,
);
}
}

if (failures.length > 0) {
console.error('Design token check failed:');
for (const failure of failures) console.error(` ${failure}`);
process.exitCode = 1;
} else {
console.log(`Design tokens OK: ${checked} contrast pairs >= 4.5:1 across ${Object.keys(themes).length} themes.`);
console.log(
`Design tokens OK: ${checkedPairs} contrast pairs >= 4.5:1 across ${TARGETS.length} surfaces.`,
);
}
4 changes: 2 additions & 2 deletions site/design-tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
"accent": "#22c55e"
},
"light": {
"surface-base": "#fbfbfb",
"surface-base": "#f4f6f8",
"text-main": "#44516a",
"text-muted": "#667387",
"text-muted": "#647084",
"text-strong": "#111827",
"accent": "#146b2e"
}
Expand Down
15 changes: 9 additions & 6 deletions site/src/styles/starlight/01-tokens-and-header.css
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,15 @@ html[data-theme='light'] ::backdrop {
--sl-color-gray-1: #243044;
--sl-color-gray-2: #44516a;
/* gray-3 is the secondary/small-text colour used by the docs footer,
built-with row, and mobile section labels — much of it at 12px. At
the previous #718096 that was 3.88:1 on --sl-color-bg (#fbfbfb),
under the 4.5:1 WCAG AA floor for normal-size text. #667387 is
4.65:1. Do not lighten this without re-checking the ratio; the dark
theme has its own gray-3 above and is unaffected. */
--sl-color-gray-3: #667387;
built-with row, and mobile section labels — much of it at 12px. It has
now been raised twice: #718096 was 3.88:1 on --sl-color-bg (#fbfbfb),
and #667387 cleared that at 4.65:1 but was still 4.44:1 on
--sl-color-gray-6 (#f4f6f8), the card surface, which the earlier check
never looked at. #647084 clears every light surface: 4.84 on bg, 5.01
on black, 4.62 on gray-6. Do not lighten this; scripts/design-tokens.mjs
checks it against every surface now. The dark theme has its own gray-3
above and is unaffected. */
--sl-color-gray-3: #647084;
--sl-color-gray-4: #c6d0dd;
--sl-color-gray-5: #e3e8ef;
--sl-color-gray-6: #f4f6f8;
Expand Down
26 changes: 15 additions & 11 deletions site/src/styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,22 @@
* Registered first in astro.config.mjs's customCss and imported at the top of
* global.css, so both stacks read the same declaration.
*
* Changing a colour here means updating site/design-tokens.json to match.
* That file is what the frontend contrast gate reads, and until it existed
* the gate ran at the repository root, found the *desktop app's* tokens, and
* reported a pass that said nothing about this site. Its values are measured
* from a served build rather than copied from here, because several are
* reached through --sl-color-text and other indirections: the declared value
* and the painted one are not always the same. Re-measure, then update.
* site/design-tokens.json is GENERATED from this file and from
* starlight/01-tokens-and-header.css -- do not edit it by hand. Run
* `node scripts/design-tokens.mjs --write` after changing a colour, and CI
* fails if the committed copy has drifted from its source. It was
* hand-maintained until 2026-08-29, on the reasoning that values reached
* through --sl-color-text and other indirections are not always the declared
* one; generating it reproduced eight of its ten hand-measured values exactly,
* so that concern was real but did not apply to the keys the gate reads.
*
* Measured ratios against the page background: dark 11.45 / 6.26 / 17.17 /
* 8.29, light 7.71 / 4.65 / 17.14 / 6.40. The light muted grey has the least
* headroom at 4.65:1 -- see the note beside --sl-color-gray-3 in
* starlight/01-tokens-and-header.css before lightening any small grey text.
* That file is what the frontend contrast gate reads, and until it existed the
* gate ran at the repository root, found the *desktop app's* tokens, and
* reported a pass that said nothing about this site.
*
* The generator checks every text colour against every surface, not just
* against the page background. That is what caught --sl-color-gray-3 at
* 4.44:1 on the card surface while it measured 4.65:1 on the page.
*/
:root {
/* Total height of the top bar including its bottom border, on both the
Expand Down