import { execFileSync } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { removesOutline, splitTopLevel, walkDeclarations, withoutNegations, } from './check-focus-visible.mjs'; /** * Every keyboard focus indicator is drawn in the app ring colour: the * `focus-ring-declarations` mixin from `libs/ui/styles/_focus-ring.scss`, or * `var(--app-focus-ring)` (also inside `color-mix()`, where every other * colour mixed in must be neutral). Indicators drawn in * their own colour drifted from it: the selection blue many components used * falls to 2.6:1 on the stronger selection tint, where the token keeps 3:1 * on every surface. A focus rule's outline, its ring- or line-shaped shadows * (unblurred) and its border colours are checked; a neutral boundary and a * blurred lift shadow are not indicators. Surfaces over video keep the * player's `--pc-*` palette; the few deliberate exceptions are listed below. */ /** Every app and library stylesheet. */ const SCANNED_PATHSPECS = [ ':(glob)apps/web/src/**/*.scss', ':(glob)libs/**/*.scss', ]; /** A rule that styles focus. */ const FOCUS_SELECTOR = /:focus(?:-visible|-within)?\b|\.is-focused\b|\.cdk-keyboard-focused\b/i; /** The custom property a `var()` reads (its fallback aside), or null. */ function propertyRead(colour) { return /^var\(\s*(--[\w-]+)\s*[,)]/i.exec(colour)?.[1] ?? null; } /** * The app ring token, or the player's own palette over video, by exact * name: `--app-focus-ring-other` is not the token. A fallback is never * drawn, since these tokens are always declared, so it is not checked. */ function isAppRing(colour) { const property = propertyRead(colour); return property === '--app-focus-ring' || /^--pc-[\w-]+$/.test(property); } /** Boundary tokens that do not signal focus. */ const NEUTRAL_TOKENS = new Set([ '--app-separator', '--app-widget-border', '--app-rail-border', '--app-search-border', ]); /** A boundary colour that does not signal focus. */ function isNeutral(colour) { return ( /^(?:transparent|none)$/i.test(colour) || NEUTRAL_TOKENS.has(propertyRead(colour)) ); } /** Text colour: fine for a border, a colour of its own for a ring. */ const TEXT_COLOUR = /^(?:currentcolor|inherit)$/i; /** A literal length in any CSS unit (or a unitless zero/number). */ const LENGTH = /^-?(?:\d+|\d*\.\d+)(?:px|em|rem|pt|pc|in|cm|mm|q|ch|ex|ic|lh|rlh|cap|vw|vh|vi|vb|vmin|vmax|[sld]v(?:w|h|i|b|min|max)|cq(?:w|h|i|b|min|max))?$/i; const WIDTH_KEYWORD = /^(?:thin|medium|thick)$/i; const LINE_STYLE = /^(?:auto|none|hidden|solid|dashed|dotted|double|groove|ridge|inset|outset)$/i; /** The plain colours of a colour expression: each `color-mix()` argument. */ export function colourAtoms(expression) { return splitTopLevel(expression, /\s/).flatMap((token) => { const mix = /^color-mix\((.*)\)$/is.exec(token); if (!mix) return [token]; // The first argument is the colour space; each other one is a // colour with an optional percentage on either side. return splitTopLevel(mix[1], /,/) .slice(1) .flatMap((argument) => colourAtoms( argument.replace(/(?:^|\s)-?[\d.]+%(?=\s|$)/g, ' ').trim() ) ); }); } /** A length computed by a math function: `calc(1px + 1px)`, `max(…)`. */ const LENGTH_FUNCTION = /^(?:calc|min|max|clamp)\(/i; /** Tokens of a value that may name colours: not lengths, widths or styles. */ function colourTokens(value) { return splitTopLevel(value, /\s/).filter( (token) => !LENGTH.test(token) && !LENGTH_FUNCTION.test(token) && !WIDTH_KEYWORD.test(token) && !LINE_STYLE.test(token) ); } /** * The colour of a shorthand with one colour slot (an outline, a border, a * shadow), where a `var()` may hold a width or a style instead. A literal * colour is the colour; without one, an accepted token is (the others are * widths); otherwise every candidate is reported, since none is proven. */ function shorthandColour(tokens) { const literal = tokens.filter((token) => !/^var\(/i.test(token)); if (literal.length) return literal; const accepted = tokens.filter( (token) => isAppRing(token) || isNeutral(token) ); return accepted.length ? accepted : tokens; } /** * The plain colours a focus rule's declaration draws its indicator with, or * an empty list when it draws none: an outline, the shadows that are rings * or lines (no blur, with a spread or an offset), and border colours. An * outline or shadow without a colour is drawn in `currentcolor`. */ export function indicatorColours(declaration) { const match = /^([a-z-]+)\s*:\s*(.+?)\s*(?:!important)?$/i.exec( declaration ); if (!match) return []; const property = match[1].toLowerCase(); const value = match[2]; if (/^outline(?:-color)?$/.test(property)) { if (removesOutline(declaration)) return []; const tokens = colourTokens(value); const colours = property === 'outline' ? shorthandColour(tokens) : tokens; return (colours.length ? colours : ['currentcolor']).flatMap( colourAtoms ); } if (property === 'box-shadow') { return splitTopLevel(value, /,/).flatMap((shadow) => { const tokens = splitTopLevel(shadow, /\s/).filter( (token) => token.toLowerCase() !== 'inset' ); const isLength = (token) => LENGTH.test(token) || LENGTH_FUNCTION.test(token); // Lengths by type, not place: the colour comes first or last, // never between the lengths, and a missing blur is zero. A // `var()` may be a length or the colour, so it counts as a // possible length. Not a ring only when provably so: the first // three possible lengths are literal with a non-zero blur (a // lift shadow), or every length is a literal zero and so are the // first four possible lengths (or there is no `var()`). const literal = (token) => LENGTH.test(token); const zero = (token) => literal(token) && parseFloat(token) === 0; const lengths = tokens.filter(isLength); const possible = tokens.filter( (token) => isLength(token) || /^var\(/i.test(token) ); const blurred = possible.length >= 3 && possible.slice(0, 3).every(literal) && parseFloat(possible[2]) !== 0; const flat = lengths.every(zero) && (possible.length === lengths.length || possible.slice(0, 4).every(zero)); if (blurred || flat) return []; const colours = shorthandColour( tokens.filter((token) => !isLength(token)) ); return (colours.length ? colours : ['currentcolor']).flatMap( colourAtoms ); }); } // Every border that carries a colour: the shorthand, the physical and // logical sides (`border-inline-start`, `border-block-end-color`…). if ( /^border(?:-(?:top|right|bottom|left|(?:block|inline)(?:-(?:start|end))?))?(?:-color)?$/.test( property ) ) { // `border-color` lists a colour per side; the other borders have one. const tokens = colourTokens(value); const colours = /-color$/.test(property) ? tokens : shorthandColour(tokens); return colours.flatMap(colourAtoms); } return []; } /** Rings that deliberately differ, matched by file and value. */ export const RING_EXCEPTIONS = [ { file: 'libs/ui/playback/src/lib/player-controls/player-controls.component.scss', value: '#ffffff', reason: 'the play button rings white around its own blue fill', }, { file: 'libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss', value: '#ffb24c', reason: "the diagnostic's amber accent on its near-black scrim (a spec loads this stylesheet as raw CSS)", }, ]; /** The exception that allows this exact colour in this file, if any. */ function exceptionFor(file, colour) { return RING_EXCEPTIONS.find( (exception) => exception.file === file && exception.value.toLowerCase() === colour.toLowerCase() ); } /** * Focus indicators in one stylesheet, each with the colours it draws that * are not the app ring, the player palette or a neutral boundary, before * exceptions apply. */ function offTokenIndicators(file, source) { return walkDeclarations(source).flatMap( ({ selectors, declaration, line, mixin }) => { // A focus mixin's body (the shared `focus-ring-declarations`) // draws rings wherever it is included. // `:hover:not(:focus-visible)` excludes focus: only a focus // condition outside `:not()` makes a focus rule. const focus = selectors.find((selector) => FOCUS_SELECTOR.test(withoutNegations(selector)) ) ?? (mixin && /focus/i.test(mixin) ? `@mixin ${mixin}` : null); if (!focus) return []; const border = /^border/i.test(declaration); const colours = indicatorColours(declaration).filter( (colour) => !isAppRing(colour) && !isNeutral(colour) && !(border && TEXT_COLOUR.test(colour)) ); return colours.length ? [{ file, line, selector: focus, declaration, colours }] : []; } ); } /** * Focus indicators in one stylesheet that bypass the app ring colour. An * exception excuses its own colour only: another colour in the same * declaration (a second shadow, a mix) is still reported. */ export function findOffTokenRings(file, source) { return offTokenIndicators(file, source) .filter(({ colours }) => colours.some((colour) => !exceptionFor(file, colour)) ) .map(({ colours, ...indicator }) => indicator); } /** * Exceptions that no longer excuse any off-token focus colour, so the list * cannot rot: the same value elsewhere (a text colour) does not count. */ export function findUnusedExceptions(sources) { const used = new Set( sources.flatMap(({ file, source }) => offTokenIndicators(file, source) .flatMap(({ colours }) => colours.map((colour) => exceptionFor(file, colour)) ) .filter(Boolean) ) ); return RING_EXCEPTIONS.filter((exception) => !used.has(exception)); } /** Tracked app and library stylesheets under `rootDir`. */ export function listScannedFiles(rootDir) { // No shell: `cmd.exe` treats single quotes as literal characters, so a // POSIX-quoted pathspec reaches git intact on Windows and matches nothing. return execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], { cwd: rootDir, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, }) .trim() .split('\n') .filter(Boolean); } /** Every problem across the stylesheets, as printable lines. */ export function checkFocusRingColour(sources) { return [ ...sources.flatMap(({ file, source }) => findOffTokenRings(file, source).map( ({ file: at, line, selector, declaration }) => `${at}:${line} \`${selector}\` sets \`${declaration}\`` ) ), ...findUnusedExceptions(sources).map( ({ file, value }) => `${file}: the exception for \`${value}\` matches nothing; remove it from RING_EXCEPTIONS` ), ]; } const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); if (isMain) { const rootDir = process.cwd(); const files = listScannedFiles(rootDir); const sources = await Promise.all( files.map(async (file) => ({ file, source: await readFile(path.join(rootDir, file), 'utf8'), })) ); const problems = checkFocusRingColour(sources); if (problems.length > 0) { console.error( 'Focus rings bypass the app ring. Use `@include focus-ring.focus-ring-declarations` (libs/ui/styles/_focus-ring.scss) or `var(--app-focus-ring)`:' ); for (const problem of problems) console.error(`- ${problem}`); process.exitCode = 1; } else { console.log( `Checked ${files.length} stylesheets; every focus ring uses the app ring.` ); } }