From 2662dcd0eee57c16c8a328a48aeff8a20ec82f45 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pawe=C5=82=20Kuna?=
<1282324+codecalm@users.noreply.github.com>
Date: Tue, 1 Sep 2026 23:31:51 +0200
Subject: [PATCH] Fail the lint on css that reads an undefined custom property
(#2926)
---
.build/check-css-vars.ts | 87 ++++++++++++++++++++++++++++++++++++
.build/css-vars-baseline.txt | 69 ++++++++++++++++++++++++++++
package.json | 3 +-
3 files changed, 158 insertions(+), 1 deletion(-)
create mode 100644 .build/check-css-vars.ts
create mode 100644 .build/css-vars-baseline.txt
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",