diff --git a/.build/check-css-vars.ts b/.build/check-css-vars.ts new file mode 100644 index 000000000..ac6b78c87 --- /dev/null +++ b/.build/check-css-vars.ts @@ -0,0 +1,87 @@ +// Fails when the css references a custom property that nothing defines. +// +// `color: var(--tblr-nav-link-active-color)` where that name is never declared +// is invalid at computed-value time: the browser drops the whole declaration and +// the element falls back to the inherited or initial value. Nothing reports it — +// not sass, not stylelint, not the browser console — so it renders wrong and +// quietly. Three shipped bugs came from exactly this: the button focus ring +// (#2662), the active nav link (#2287) and the disabled button border. +// +// A `var()` with a fallback cannot break, so those references are not checked. +// +// The scan compiles the same scss entry points the build does, so it needs no +// dist/ and sees what actually ships. Names that stay unprefixed belong to +// third-party stylesheets that declare them themselves (see cssVarIgnore), so +// only `--tblr-` names are checked. +// +// Known offenders are listed in css-vars-baseline.txt. Anything not on that list +// fails the check; so does a name on the list that no longer dangles, which +// keeps the file from going stale — delete the line when you fix one. +// +// Usage: tsx .build/check-css-vars.ts +/// +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { compile as compileSass } from 'sass' +import postcss from 'postcss' +import prefixCustomProperties from 'postcss-prefix-custom-properties' +import { cssVarIgnore, cssVarPrefix, inlineValueComments } from './css-var-prefix' + +const scssDir = 'core/scss' +const baselineFile = '.build/css-vars-baseline.txt' + +const definition = /(--[\w-]+)\s*:/g +const reference = /var\(\s*(--[\w-]+)\s*([,)])/g +const registration = /@property\s+(--[\w-]+)/g + +const defined = new Set() +// name → stylesheets referencing it without a fallback +const referenced = new Map>() + +async function scan(entry: string) { + const compiled = compileSass(join(scssDir, entry), { loadPaths: ['node_modules'], style: 'expanded' }) + const { css } = await postcss([inlineValueComments, prefixCustomProperties({ prefix: cssVarPrefix, ignore: cssVarIgnore })]).process(compiled.css, { from: undefined, map: false }) + const file = entry.replace('.scss', '.css') + + for (const [, name] of css.matchAll(definition)) defined.add(name) + for (const [, name] of css.matchAll(registration)) defined.add(name) + for (const [, name, next] of css.matchAll(reference)) { + if (next !== ')') continue + if (!name.startsWith(`--${cssVarPrefix}`)) continue + const files = referenced.get(name) ?? new Set() + files.add(file) + referenced.set(name, files) + } +} + +async function main() { + const entries = readdirSync(scssDir).filter((file) => file.endsWith('.scss') && !file.startsWith('_')) + for (const entry of entries) await scan(entry) + + const dangling = new Map([...referenced].filter(([name]) => !defined.has(name)).sort()) + const baseline = readFileSync(baselineFile, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('--')) + + const added = [...dangling.keys()].filter((name) => !baseline.includes(name)) + const fixed = baseline.filter((name) => !dangling.has(name)) + + for (const name of added) { + console.error(`✗ ${name} — referenced by ${[...dangling.get(name)!].join(', ')}, defined nowhere`) + } + if (added.length > 0) { + console.error(`\n${added.length} new dangling custom ${added.length === 1 ? 'property' : 'properties'}. Define the property, give the var() a fallback, or drop the declaration.`) + } + if (fixed.length > 0) { + console.error(`\n${fixed.join(', ')} no longer dangling — delete ${fixed.length === 1 ? 'that line' : 'those lines'} from ${baselineFile}.`) + } + if (added.length > 0 || fixed.length > 0) process.exit(1) + + console.log(`OK — ${referenced.size} custom properties referenced without a fallback, all defined (${baseline.length} known exceptions in ${baselineFile}).`) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/.build/css-vars-baseline.txt b/.build/css-vars-baseline.txt new file mode 100644 index 000000000..cb0be0102 --- /dev/null +++ b/.build/css-vars-baseline.txt @@ -0,0 +1,69 @@ +# Custom properties the css references but never defines. Each one makes the +# declaration that reads it invalid, so the browser drops it - see +# check-css-vars.ts. The check fails on any name that is not listed here, and +# also on a listed name that no longer dangles, so fixing one means deleting +# its line. +# +# Everything below is a real bug waiting to be fixed, not an accepted exception. + +# `.list-group-item-{color}` reads subtle/emphasis tokens that bootstrap only +# builds for $theme-colors, so these variants render with no background and no +# text color at all. +--tblr-azure-bg-subtle +--tblr-azure-border-subtle +--tblr-azure-text-emphasis +--tblr-blue-bg-subtle +--tblr-blue-border-subtle +--tblr-blue-text-emphasis +--tblr-cyan-bg-subtle +--tblr-cyan-border-subtle +--tblr-cyan-text-emphasis +--tblr-green-bg-subtle +--tblr-green-border-subtle +--tblr-green-text-emphasis +--tblr-indigo-bg-subtle +--tblr-indigo-border-subtle +--tblr-indigo-text-emphasis +--tblr-lime-bg-subtle +--tblr-lime-border-subtle +--tblr-lime-text-emphasis +--tblr-muted-bg-subtle +--tblr-muted-border-subtle +--tblr-muted-text-emphasis +--tblr-orange-bg-subtle +--tblr-orange-border-subtle +--tblr-orange-text-emphasis +--tblr-pink-bg-subtle +--tblr-pink-border-subtle +--tblr-pink-text-emphasis +--tblr-purple-bg-subtle +--tblr-purple-border-subtle +--tblr-purple-text-emphasis +--tblr-red-bg-subtle +--tblr-red-border-subtle +--tblr-red-text-emphasis +--tblr-teal-bg-subtle +--tblr-teal-border-subtle +--tblr-teal-text-emphasis +--tblr-yellow-bg-subtle +--tblr-yellow-border-subtle +--tblr-yellow-text-emphasis + +# tabler-marketing.css: `.shape-*` backgrounds, borders and headings reach for +# tokens that only ever existed in the marketing prototype. +--tblr-black-lt +--tblr-border-color-dark +--tblr-border-color-light +--tblr-border-radius-circle +--tblr-font-weight-black +--tblr-gray-dark-lt +--tblr-gray-lt + +# `body { text-align }` - bootstrap leaves the token unset by default, so the +# declaration never applies. +--tblr-body-text-align + +# `.nav-link` font size and the active nav link color (#2287): both drop, so a +# nav link takes the body font size and keeps the inherited color when active. +--tblr-nav-link-active-color +--tblr-nav-link-font-size diff --git a/package.json b/package.json index b5fcea641..d4699bdc3 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "import-payments": "tsx .build/import-payments.ts", "generate-tokens:check": "tsx .build/generate-tokens.ts check", "check-docs-links": "tsx .build/check-docs-links.ts", - "lint": "pnpm run lint-md && pnpm run check-docs-links && pnpm run lint-prettier && pnpm run lint-scss-vars && pnpm run generate-tokens:check", + "check-css-vars": "tsx .build/check-css-vars.ts", + "lint": "pnpm run lint-md && pnpm run check-docs-links && pnpm run lint-prettier && pnpm run lint-scss-vars && pnpm run check-css-vars && pnpm run generate-tokens:check", "lint:fix": "(pnpm run lint-md-fix || true) && (pnpm run lint:scss:fix || true) && (pnpm run format-prettier || true)", "lint:scss": "pnpm --filter @tabler/core lint:scss", "lint:scss:fix": "pnpm --filter @tabler/core lint:scss:fix",