Files
technitium-console/dev/check-colour-tokens.mjs
T
DenozordecandClaude Sonnet 5.5 f3531c64f9 Move the console onto ReUI PRO
Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move
the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel,
Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame
(DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale.

Design only: behaviour, texts and roles are unchanged. Tests that asserted the old
DOM were updated; the tests now format numbers in English wherever they run.
docs/reui-migration-plan.md records the plan and its decisions.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 00:06:06 +07:00

282 lines
13 KiB
JavaScript

/**
* The guard that keeps every colour in `src/theme/tokens.css`.
*
* `CONVENTIONS.md` has said "colours always by token, not one `#hex` outside
* `tokens.css`" from the start, and on 2026-10-01 there were 44 colour
* literals outside it, in 14 files: the hover border of a button, the veil behind
* a dialog, the shine on a ticked checkbox, eight fallbacks for the chart palette.
* None of them was wrong for the one theme the console had. Every one of them is
* a place a second theme cannot reach, which is why the rule stopped being a
* preference the day a light theme was decided. This is what measures it.
*
* It looks at the `.css`, `.ts` and `.tsx` files in `src/`, with their comments
* stripped (a comment that quotes a value is quoting, not painting), and finds:
*
* - `#hex`, 3, 4, 6 or 8 digits, and its URL-encoded form `%23hex`, which is how
* a colour hides inside an inline SVG in a `data:` URI.
* - The colour functions: `rgb()`, `rgba()`, `hsl()`, `hsla()`, `hwb()`, `lab()`,
* `lch()`, `oklab()`, `oklch()`, `color()`.
* - The CSS named colours (`white`, `black`, `red`…): in a stylesheet, as a word
* in a declaration's value; in TypeScript, as a whole string literal, which is
* how an inline style writes one.
*
* And inside `tokens.css` itself it checks the other half of the rule: a colour
* literal is only allowed in a THEME block (`:root, [data-theme='dark']` or
* `[data-theme='light']`). A colour in the plain `:root` would be one no theme
* can override. And the light block declares every property the dark block
* does: one it forgets falls back to `:root`, which carries dark, and paints a
* dark value on a light page without anything else noticing.
*
* `color-scheme` follows the same rule, because it is colour too: it decides the
* browser's own parts (scrollbars, date pickers, form controls), and an element
* that pins it draws them dark on a light page or the other way round. So a
* `color-scheme` declaration is only allowed in a theme block of `tokens.css`;
* anywhere else, a stylesheet declaration of it, or an inline style that sets it
* to a fixed string, is a finding. Setting it from the resolved theme
* (`root.style.colorScheme = resolved` in `theme/theme.ts`) is not pinning it, and
* `prefers-color-scheme` is a media query, not a declaration. There is no
* allow-list for it: an element that needs its own scheme needs a theme to say so.
*
* What it deliberately does NOT look at:
*
* - Tests (`*.test.*`, `src/test/`). A test may write a colour as data.
* - `color-mix()` and `transparent`: they compose tokens, they are not colours of
* their own. A `color-mix()` with a literal inside is still caught, by the
* literal.
* - Named colours inside a longer TypeScript string (`'1px solid white'`). None
* exists today; a parser would be needed to tell them from interface text.
*
* A literal that has to stay where it is goes in `ALLOWED`, by file AND literal,
* with its reason. An entry that no longer matches anything is a finding too: an
* allow-list that only grows stops meaning anything.
*
* Exit code is the number of findings, so it can sit in the gate.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
export const TOKENS = 'src/theme/tokens.css'
/* The selectors a colour token may be declared under, whitespace removed. */
export const THEME_BLOCKS = [":root,[data-theme='dark']", "[data-theme='light']"]
export const ALLOWED = [
{
file: 'src/screens/modals/Configure2FA.tsx',
literal: '#fff',
reason:
'The quiet zone around the QR code. A scanner needs dark modules on a light ground, '
+ 'so it is white in every theme: a token would invite a theme to change it.',
},
]
const NAMED = new Set(
(
'aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue '
+ 'blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk '
+ 'crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki '
+ 'darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen '
+ 'darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue '
+ 'dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite '
+ 'gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki '
+ 'lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan '
+ 'lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen '
+ 'lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen '
+ 'magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen '
+ 'mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream '
+ 'mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid '
+ 'palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum '
+ 'powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown '
+ 'seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen '
+ 'steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen'
).split(' '),
)
const HEX = /(?<![\w&%#-])#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})(?![\w-])/gi
const ENCODED = /%23(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})(?![\w-])/gi
const FUNCTION = /(?<![\w.$-])(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^)]*\)?/gi
const DECLARATION = /[a-z-]+\s*:\s*([^;{}]*)(?=[;}])/gi
const WORD = /(?<![\w-])[a-z]+(?![\w-])/gi
const STRING = /(['"])([a-z]+)\1/gi
const SCHEME_CSS = /(?<![\w-])color-scheme\s*:\s*[^;{}]*[^;{}\s]/gi
const SCHEME_TS = /(?<![\w$-])colorScheme\s*[:=]\s*(['"`])[^'"`]*\1|setProperty\(\s*(['"`])color-scheme\2/g
/*
Comments out, everything else in place: each comment character becomes a space
and each newline stays, so a finding keeps its line number. Strings are walked
so that a `//` inside one (`http://www.w3.org/2000/svg` in every inline SVG) is
not taken for a comment. `'` and `"` cannot span a line, so a quote inside a
regex literal can derail at most the rest of its own line.
*/
export function stripComments(text, kind) {
let out = ''
let i = 0
let quote = null
const blank = (s) => s.replace(/[^\n]/g, ' ')
while (i < text.length) {
const c = text[i]
if (quote) {
out += c
if (c === '\\') { out += text[i + 1] ?? ''; i += 2; continue }
if (c === quote || (c === '\n' && quote !== '`')) quote = null
i++
continue
}
if (c === '/' && text[i + 1] === '*') {
const end = text.indexOf('*/', i + 2)
const stop = end < 0 ? text.length : end + 2
out += blank(text.slice(i, stop))
i = stop
continue
}
if (kind === 'ts' && c === '/' && text[i + 1] === '/') {
const end = text.indexOf('\n', i)
const stop = end < 0 ? text.length : end
out += blank(text.slice(i, stop))
i = stop
continue
}
if (c === "'" || c === '"' || (kind === 'ts' && c === '`')) quote = c
out += c
i++
}
return out
}
const lineAt = (text, index) => text.slice(0, index).split('\n').length
/** The colour literals in a file's text, with their line. `kind` is `css` or `ts`. */
export function findColours(text, kind) {
const code = stripComments(text, kind)
const found = []
const add = (index, literal) => found.push({ line: lineAt(code, index), literal })
for (const re of [HEX, ENCODED, FUNCTION]) for (const m of code.matchAll(re)) add(m.index, m[0])
if (kind === 'css') {
for (const d of code.matchAll(DECLARATION)) {
const at = d.index + d[0].length - d[1].length
for (const w of d[1].matchAll(WORD)) if (NAMED.has(w[0].toLowerCase())) add(at + w.index, w[0])
}
} else {
for (const s of code.matchAll(STRING)) if (NAMED.has(s[2].toLowerCase())) add(s.index, s[0])
}
return found.sort((a, b) => a.line - b.line)
}
/** The `color-scheme` declarations in a file's text, with their line. */
export function findColourSchemes(text, kind) {
const code = stripComments(text, kind)
const re = kind === 'css' ? SCHEME_CSS : SCHEME_TS
return [...code.matchAll(re)].map((m) => ({ line: lineAt(code, m.index), literal: m[0] }))
}
/*
The top-level blocks of a stylesheet, with the selector each one hangs from.
`tokens.css` has no nesting except inside a block's own value, so counting
braces is enough.
*/
function blocks(code) {
const out = []
let depth = 0
let start = 0
for (let i = 0; i < code.length; i++) {
if (code[i] === '{') {
if (depth === 0) out.push({ selector: code.slice(start, i), from: i })
depth++
} else if (code[i] === '}') {
depth--
if (depth === 0) { out[out.length - 1].to = i; start = i + 1 }
}
}
return out.map((b) => ({ ...b, selector: b.selector.replace(/\s+/g, '') }))
}
/*
What each theme block declares, as `{ dark, light }`, each a `Map` from the
property (`--bg`, `color-scheme`) to its value as written. Shared with the tools
that measure a theme (`palette-distance.mjs`, `theme-contrast.mjs`), so the three
read the file the same way.
*/
export function themeTokens(text) {
const code = stripComments(text, 'css')
const out = {}
for (const b of blocks(code)) {
const theme = b.selector === THEME_BLOCKS[0] ? 'dark' : b.selector === THEME_BLOCKS[1] ? 'light' : null
if (!theme) continue
const decls = new Map()
for (const [, name, value] of code.slice(b.from + 1, b.to).matchAll(/(--[\w-]+|color-scheme)\s*:\s*([^;]+);/g))
decls.set(name, value.trim().replace(/\s+/g, ' '))
out[theme] = decls
}
return out
}
/*
Every property the dark block declares, the light block declares too. A token
light forgot is not an error anybody would see: `:root` carries the dark value,
so it quietly paints dark on a light page.
*/
export function findMissingInLight(text) {
const { dark = new Map(), light = new Map() } = themeTokens(text)
return [...dark.keys()].filter((name) => !light.has(name))
}
/** The colour literals and `color-scheme` declarations in `tokens.css` that are outside every theme block. */
export function findOutsideThemes(text) {
const code = stripComments(text, 'css')
const inside = blocks(code).filter((b) => THEME_BLOCKS.includes(b.selector))
return [...findColours(text, 'css'), ...findColourSchemes(text, 'css')]
.filter(({ line }) => !inside.some((b) => line >= lineAt(code, b.from) && line <= lineAt(code, b.to)))
.sort((a, b) => a.line - b.line)
}
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name)
/* `src/components/` holds the ReUI registry code exactly as shipped (shadcn add);
it is not ours to rewrite, so the token guard does not read it. */
if (e.isDirectory()) return e.name === 'test' || p.split(path.sep).slice(-2).join('/') === 'src/components' ? [] : walk(p)
return /\.(css|tsx?)$/.test(e.name) && !/\.test\./.test(e.name) ? [p] : []
})
}
/** Every finding under `root`, and the allow-list entries that matched nothing. */
export function check(root = ROOT, allowed = ALLOWED) {
const findings = []
const used = new Set()
for (const file of walk(path.join(root, 'src'))) {
const rel = path.relative(root, file).split(path.sep).join('/')
const text = fs.readFileSync(file, 'utf8')
if (rel === TOKENS) {
for (const f of findOutsideThemes(text))
findings.push(`${rel}:${f.line} ${f.literal} is outside every theme block`)
for (const name of findMissingInLight(text))
findings.push(`${rel} ${name} is declared for dark and not for light`)
continue
}
const kind = file.endsWith('.css') ? 'css' : 'ts'
for (const f of findColourSchemes(text, kind))
findings.push(`${rel}:${f.line} ${f.literal} pins color-scheme outside the theme blocks of ${TOKENS}`)
for (const f of findColours(text, kind)) {
const entry = allowed.find((a) => a.file === rel && a.literal.toLowerCase() === f.literal.toLowerCase())
if (entry) { used.add(entry); continue }
findings.push(`${rel}:${f.line} ${f.literal} is a colour outside ${TOKENS}`)
}
}
for (const a of allowed) if (!used.has(a)) findings.push(`${a.file} ${a.literal} is allowed but no longer found: remove the entry`)
return findings
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const findings = check()
if (findings.length === 0) {
console.log(`\n Every colour is a token: no colour literal and no color-scheme in src/ outside the theme blocks of ${TOKENS} (${ALLOWED.length} allowed, each with its reason).\n`)
} else {
for (const f of findings) console.log(' ' + f)
console.log(`\n ${findings.length} findings. A colour, and color-scheme, belong in a theme block of ${TOKENS}; if a colour literal really cannot, ALLOWED in dev/check-colour-tokens.mjs takes it with its reason.\n`)
}
process.exit(Math.min(findings.length, 250))
}