Files
openobserve/web/scripts/check-design-consistency.mjs
T
1c849a9c71 refactor(web): migrate templates + shadows to tokens, delete the debt baseline (#13769)
## What

Deletes `web/scripts/design-debt-baseline.json` — **not by relaxing the
rules, by fixing the debt**. Every design-token category is now
zero-tolerance, SCSS is gone from the app, and 144 hand-written shadow
values collapse into one scale.

## The guard was under-reporting (three separate holes)

1. **Comment contamination.** `check-design-consistency.mjs` fed comment
text into its exemption checks, so a `keep()` comment that merely
*named* `:deep(` / `::after` / `@keyframes` silenced the rule after it —
and a `;` or `{` inside a comment corrupted the offsets, so stripping
comments afterwards couldn't help. Comments are now blanked
(offset-preserving) before the scan. **Uncovered 9 occurrences in 6
files.** Real debt was 110, not 101.
2. **`arbShadow` only scanned the template.** `box-shadow:` in a
`<style>` block and `boxShadow:` in a JS/`:style` object were invisible
— 40 literals sat there while the guard reported zero. It now scans all
three syntaxes across `.vue` and `.ts`.
3. **`rawVarInTemplate` missed the fallback form.**
`border-(--color-x,rgba(…))` and `bg-[var(--color-x,#fff)]` slipped
through. The token is always defined, so the fallback can never fire —
it only hid the read.

## stylelint had never run on 14 files

`postcss-scss` isn't installed, so `postcss-html` **silently skipped**
every `<style lang="scss">` block. The CI step named *"Lint styles
(stylelint — bans legacy --o2-* tokens)"* was a no-op for those files —
including `color-no-hex` and the `.body--dark` selector ban. All 14 are
plain CSS now and `vue/block-lang` errors on `scss`/`sass`/`less`, so it
cannot silently recur.

## What moved

| | before | after |
|---|---|---|
| `rawVarInComponent` (in `<style>`) | 110 | **0** |
| template `x-[var(--color-*)]`, utility exists | 225 | **0** |
| template reads, no utility | 27 | **0** |
| `style` / `:style` colour literals | 29 | **0** |
| `color-mix(token N%, transparent)` | 160 | **0** |
| hand-written shadows | 233 (144 distinct) | **0** |
| SFCs using SCSS | 14 | **0** |

Guard categories: 22 → 24. `rawVarInTemplate` and `arbShadow` are new;
`arbPx` was deleted because main's `local/no-hardcoded-px` ESLint rule
(#13541) now owns px across every file type.

## Shadows are a system now

Stripped of colour, the 144 values were the same handful of geometries
rewritten in px vs rem, with and without an explicit `0` spread.

```
--shadow-xs / sm / md / lg              elevation
--shadow-sticky-left / -right           directional (sticky columns)
--shadow-rail                           inset status rail
--shadow-glow-sm / -md / -lg            coloured, 3 intensities
--shadow-scroll-top / -bottom / -both   scroll affordance
--shadow-ring-hairline                  1px ring where a utility can't reach
--shadow-ai-focus                       AI composer focus glow (flips in dark)
```

Colour is a **separate axis**. Tailwind rewrites a `--shadow-*` token as
`<geometry> var(--tw-shadow-color, <default>)` — verified by compiling a
probe, and it holds for multi-layer tokens — so a template writes
`shadow-md shadow-ai-accent/35`. A plain stylesheet can't add a utility,
so the role and glow tokens read `--glow-color`; that keeps the pipeline
canvas colour-coding nodes by type from CSS and lets the status rail be
painted per row from JS.

Also reclassified: `0 0 0 2px <colour>` is a **ring**, not a shadow, and
`0 1px 0 0 <colour>` is a **border**.

## color-mix, analysed by intent

All 331 occurrences were classified rather than treated as one problem:

- **160** mixed a token with `transparent` — only an alpha change, so
it's Tailwind's `/N` modifier written the long way (verified: `/N`
compiles to `color-mix(in oklab, <token> N%, transparent)`, and mixing
with `transparent` is premultiplied, so the interpolation space doesn't
matter)
- **81** blended two colours — a derived colour, so it's a token now
(~50 minted)
- **75** were shadow/ring geometry, handled by the scale
- **8 gradients + 1 `calc()`-driven percentage** genuinely have no
utility form and remain; the guard exempts gradient/mask/filter
contexts, matching what the style-block counter already exempts

## Merge with main

Main was running the same migration in parallel (**#13541 "px to rem"**
removed all SCSS, added `no-hardcoded-px`, and built its own shadow
scale), so the merge collided in 68 files / 155 hunks. Resolved by
taking main's source files and re-applying this branch on top, with the
guards driving convergence (429 → 0) rather than hand-resolving hunks.

On shadows, **main's structure wins** — geometry in `semantic.css`
`@theme`, colours as `--shadow-*-c*` sub-tokens, wrapped in its px
disable; its `--shadow-sm/md/lg`, `--shadow-scroll-*` and
`--shadow-glow` keep their definitions. This branch adds what main
didn't have: `xs`, the directional/inset roles, and the
elevation-matched coloured glows.

## New tokens

No new *colours* — each names a value a component already hardcoded,
because a registered palette utility (`text-orange-500`) is banned by
`rawPalette`, so "just register the ramp" was never available.

`--color-splitter-hover`, `--color-status-flaky` (+`-text`),
`--color-chat-bubble-user-border`, `--color-thread-avatar-bg`,
`--color-thread-accent-strong`, `--color-menu-badge-ring`,
`--color-progress-track-fill`, `--color-ingest-accent-*` (5), ~50
derived blend tokens, plus `--color-border-strong` /
`--color-border-subtle` / `--color-warning-300` registered for utility
use.

## Visual deltas to review

Everything else is value-preserving. These are not:

- **4 hover accents** move from a fixed `primary-600` to `accent` — 600
→ 400 **in dark only** (2 chevrons, an empty-state icon fill, one link)
- **sticky table header** in `TraceDetailsSidebar` was a fixed light
grey that never flipped; now `border-default`, so it themes
- **shadow canonicalization** snaps near-identical geometries together
and moves ~40 hardcoded `rgba(0,0,0,x)` onto the scale's token colour

Highest-surface single file: `AlertSettingsHelpDrawer.vue` — a whole
style block deleted with ~44 class names moved into template utilities.

## Verification

`lint:design` · `lint:tokens` · `lint:token-purity` · `lint:styles` ·
`format:check` · `lint:ci` · `vue-tsc` — all clean.
**94 spec files covering every file this branch touches: 4395 passing, 0
failing.**

Not run: the full 1236-file unit suite, Playwright/E2E, and any browser
visual check.

---------

Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech>
Co-authored-by: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com>
Co-authored-by: ktx-abhay <abhay.padamani@kiara.tech>
Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
Co-authored-by: Bhargav <BJP232004@GMAIL.COM>
2026-08-17 11:46:07 +00:00

504 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// CI guard: ZERO occurrences of any design-token bypass category from
// O2_TOKEN_MIGRATION_PLAN.md §3 (A–R). One violation fails the build.
//
// node scripts/check-design-consistency.mjs # fail on any violation
// node scripts/check-design-consistency.mjs --list # show every violation
//
// Replaced a ratchet against design-debt-baseline.json, which allowed debt to
// persist as long as it never grew — every non-zero entry was headroom a future
// bypass could refill silently. `--strict` is accepted and ignored; zero
// tolerance subsumes it. px is owned by eslint's local/no-hardcoded-px.
//
// If this failed: do not add an exemption. Reach the token through its
// registered utility (register one in tokens/semantic.css or component.css if
// none exists), or move the rule to base-elements.css / utilities.css. The
// allowlists below are only for cases a utility physically CANNOT express.
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, extname, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC_DIR = join(__dirname, "..", "src");
// Declared homes for literal hex in .ts (§12.3). Excluded from the `tsHex`
// category because their CONSUMER cannot resolve a CSS custom property, so a
// `var(--color-*)` is impossible there — the hex is the only option:
// • ECharts option objects / canvas 2D contexts (dashboard chart converters)
// • user-editable chart-template source strings (customChartTemplates/*)
// • Monaco editor theme + language token colours (vrlLanguage*)
// • SVG/canvas trace-tree rendering
// • the theme source-of-truth itself (theme.ts defines the runtime palette)
// • JS that builds styled log/status HTML from a fixed status→colour map
// Entries ending in "/" match by directory prefix; others by exact suffix.
const TS_HEX_ALLOWLIST = [
// categorical / series palettes + theme source
"utils/dashboard/colorPalette.ts",
"constants/themes.ts",
"utils/traces/traceColors.ts",
"utils/theme.ts",
"utils/themeManager.ts",
"utils/chartTheme.ts", // sanctioned seam: light-theme fallback constants for jsdom/pre-CSS
// ECharts / canvas / geo chart builders — options are serialised, no var() resolves
"utils/dashboard/",
"composables/dashboard/",
"components/dashboards/addPanel/customChartExamples/",
"utils/traces/convertTraceData.ts",
"utils/traces/treeVisualizationEngine.ts",
"composables/useServiceGraphTree.ts",
"composables/useMetricsExplorer.ts",
// Monaco editor language/theme definitions (colour strings the editor lib reads)
"utils/query/vrlLanguage.ts",
"utils/query/vrlLanguageDefinition.ts",
// JS that builds styled HTML/canvas from a fixed status→colour map
"utils/logs/convertLogData.ts",
"utils/logs/statusParser.ts",
"utils/logs/keyValueParser.ts",
// LLM / metric / latency chart-panel series palettes (ECharts, no var())
"plugins/traces/llmTrendPanel.utils.ts",
"plugins/traces/config/",
"enterprise/components/billings/usageDailyPanelSchema.ts",
"composables/useLatencyInsightsDashboard.ts",
"composables/useMetricsCorrelationDashboard.ts",
"utils/metrics/metricPalette.ts",
"utils/traces/treeTooltipHelpers.ts",
"utils/alerts/alertChartData.ts",
"composables/useAlertForm.ts", // ECharts markLine colour
// Ingestion vendor brand accents (D12-style fixed brand colour; `tone` is a
// reserved-future field that is never read — see setup-card content types)
"components/ingestion/setupCard/content/",
"components/ingestion/ai/content/",
];
// The sanctioned dark-mode seams (§3.R.1) — the only files allowed to read theme /
// declare an isDark flag, so they are exempt from the darkMechanism count. useTheme +
// chartTheme are the JS *consumption* seams; themeManager is the theme *applier* — it
// resolves store.state.theme into the mode to apply (upstream of every token, so it
// cannot consume a token without circularity).
// • ThemeSwitcher.vue is the toggle control — can't be written without naming
// "dark" (a `darkMode` flag or `theme === 'dark'` compare), so it's the one
// canonical home for that decision, not the fragmentation the category targets.
// • convertLogData.ts reads --color-theme-accent to paint an ECharts canvas bar.
// applyThemeColors (utils/theme.ts) sets that token on document.body in dark and
// document.documentElement in light, so the consumer must know which element
// carries it — a var() can't resolve on a <canvas>.
const DARK_SEAM_ALLOWLIST = [
"composables/useTheme.ts",
"utils/chartTheme.ts",
"utils/themeManager.ts",
"components/ThemeSwitcher.vue",
"utils/logs/convertLogData.ts",
];
// Files allowed to keep an UNSCOPED <style> block. Unscoped leaks globally so it's
// debt by default — but a few blocks must reach past the component's own subtree,
// which `scoped` physically can't:
// • ViewDashboard.vue: its @media print / fullscreen rules target external ancestors
// (.o2-app-root, main, .o2-content-scroll, .scroll) outside the SFC — unscoped on
// purpose (carries a keep(complex-state) note).
const UNSCOPED_STYLE_ALLOWLIST = ["views/Dashboards/ViewDashboard.vue"];
// Files allowed to carry a literal font stack. Email markup renders inside a mail
// client, which can load neither our webfont nor our custom properties, so a
// system stack is the correct choice there (FONT_AUDIT.md §5.2).
const FONT_ALLOWLIST = [
"utils/prebuilt-templates/email.ts",
"utils/fonts.ts", // defines the fallback stacks the tokens mirror
];
const PALETTE =
"slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose";
const PROPS = "text|bg|border|ring|fill|stroke|divide|outline|decoration|placeholder|from|via|to";
// Categories evaluated over the WHOLE file text.
const WHOLE = {
rawPalette: new RegExp(
`\\b(?:dark:|hover:|focus:|group-hover:|active:)*(?:${PROPS})-(?:${PALETTE})-\\d{2,3}\\b`,
"g",
),
// PROJECT raw-ramp utilities (Part B, Ring 3): feature code reaching for our own
// primitives (grey-*/primary-*) instead of semantic tokens. The ramps stay
// REGISTERED while these consumers exist (un-registering would break them, and
// migrating is not value-preserving — e.g. accent flips 600→400 in dark), so
// this ratchet freezes the count: it can only shrink toward the Part-B ideal.
rawProjectRamp: new RegExp(
`\\b(?:dark:|hover:|focus:|group-hover:|active:|focus-visible:)*(?:${PROPS}|ring)-(?:grey|primary)-\\d{2,3}\\b`,
"g",
),
hexClass: /-\[#[0-9a-fA-F]{3,8}\]/g,
inlineHexStyle: /style="[^"]*#[0-9a-fA-F]{3,8}/g,
inlineHexBind: /:style="[^"]*#[0-9a-fA-F]{3,8}/g,
themeTernary: /theme\s*===?\s*['"]dark['"]/g,
// A bare `rounded` utility is bounded by class-list separators on BOTH sides.
// Requiring a trailing separator (space/quote/backtick/EOL) skips prose in
// comments ("short, rounded, inset") and JS object keys (`rounded: "..."`).
// Retired text-colour aliases (Tier 1): text-text-primary → text-text-heading,
// text-text-caption → text-text-secondary. Baseline 0 after the codemod, so any
// reintroduction of the class utility is a regression.
retiredTextAlias: /\btext-text-(?:primary|caption)\b/g,
bareRounded: /(?:^|[\s"'`])rounded(?=[\s"'`]|$)/gm,
// Retired radius tiers: the app exposes exactly two corner radii —
// rounded-default (controls) and rounded-surface (large surfaces) — plus
// rounded-full. The old sm/md/lg/xl scale (and its var(--radius-*) refs) was a
// set of same-value aliases that lied about being distinct; it was repointed
// (small→default, large→surface) and the tokens deleted. Baseline 0 after the
// codemod, so any reintroduction falls back to Tailwind's stock scale and is a
// regression. Matches the class utility (incl. per-corner variants) and the var.
retiredRadiusAlias:
/\brounded(?:-(?:t|b|l|r|s|e|tl|tr|bl|br|ss|se|es|ee))?-(?:sm|md|lg|xl)\b|var\(--radius-(?:sm|md|lg|xl)\)/g,
// Arbitrary radius VALUE — but not a CSS keyword (`rounded-[inherit]` etc.),
// which is not a hardcoded dimension and has no scale-token equivalent.
arbRadius: /rounded(?:-[a-z]+)*-\[(?!(?:inherit|initial|unset|revert|revert-layer)\])[^\]]+\]/g,
unscopedStyle: /<style(?![^>]*\bscoped\b)[^>]*>/g,
twPrefix: /\btw:/g,
helperUtil: /\btext-weight-[a-z]+\b/g,
arbZ: /\bz-\[[0-9]+\]/g,
// Literal font stacks. The app ships exactly two families, reached only via
// var(--font-sans) / var(--font-mono) (FONT_AUDIT.md). Anything else resolves
// to whatever the visitor's OS happens to have, which is how the app came to
// render in ~14 different stacks. Covers CSS declarations, Tailwind arbitrary
// values (`[font-family:…]`), and `font-[…]` when the value looks like a
// family rather than a weight (`font-[600]` stays legal).
literalFontFamily: new RegExp(
[
// font-family: <anything that isn't our token / inherit>.
// The lookahead absorbs the whitespace itself — if `\s*` were consumed
// first it could backtrack to zero width and let ` var(--font-mono)` slip
// through as a "literal" starting with a space.
String.raw`font-family:(?!\s*(?:var\(--font-(?:sans|mono)\)|inherit))\s*[^;"'\`\n}]+`,
// [font-family:<literal>]
String.raw`\[font-family:(?!(?:var\(--font-(?:sans|mono)\)|inherit)\])[^\]]+\]`,
// font-[<literal family>] — weights like font-[600] are not families
String.raw`\bfont-\[(?!\d+\]|var\(--font-(?:sans|mono)\)\]|inherit\])[^\]]*(?:mono|serif|Menlo|Monaco|Consolas|Courier|Geist|Inconsolata|Fira|Ubuntu|Segoe|Roboto|apple-system)[^\]]*\]`,
].join("|"),
"g",
),
// Dark-mode mechanism fragmentation (§3.R.2 mechanisms 1,2,5,6,7)
darkMechanism:
/theme\s*[=!]==?\s*['"]dark['"]|const\s+(?:isDark|isDarkMode|darkMode)\s*=|\.body--(?:dark|light)|classList\.contains\(['"]body--|\.(?:light|dark)-mode\b/g,
};
// Categories evaluated ONLY inside <style>…</style> blocks (scoped AND unscoped —
// styleBlocks() collects every block regardless of the `scoped` attribute).
const STYLE_ONLY = {
styleBlockHex: /#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(/g,
// NOTE: rawVarInComponent (F.6) is NOT a flat regex — it is context-aware and
// computed by countRawVarInComponent() below. See that function for why.
};
function walk(dir, files = []) {
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry === "dist") continue;
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) walk(full, files);
else if (extname(entry) === ".vue" || extname(entry) === ".ts") files.push(full);
}
return files;
}
function styleBlocks(text) {
const out = [];
const re = /<style[^>]*>([\s\S]*?)<\/style>/g;
let m;
while ((m = re.exec(text)) !== null) out.push(m[1]);
return out.join("\n");
}
// ── rawVarInComponent (F.6), CONTEXT-AWARE ─────────────────────────────────
// A raw var(--color-*) in a <style> block is debt ONLY where a utility could
// replace it. Skipped where one physically cannot: inside :deep(), inside
// color-mix()/gradient, a pseudo-element rule, inside @keyframes, or a custom-
// property definition. Judged PER-OCCURRENCE — keep() is mandatory on every
// surviving block, so a block-level exemption would zero the category.
// Blank comment BODIES, preserving byte offsets so every index stays valid.
// Must run BEFORE the scan: a comment sits between the previous `}` and the next
// `{`, so its punctuation is read as selector structure. A keep() comment naming
// ::after / :deep( / @keyframes exempted the rule after it, and a stray `;` or
// `{` in prose reset the stack mid-sentence. Ten occurrences across seven files
// were invisible before this.
function blankComments(css) {
let out = "";
for (let i = 0; i < css.length; ) {
if (css[i] === "/" && css[i + 1] === "*") {
const end = css.indexOf("*/", i + 2);
const stop = end === -1 ? css.length : end + 2;
out += css.slice(i, stop).replace(/[^\n]/g, " ");
i = stop;
} else if (css[i] === "/" && css[i + 1] === "/" && css[i - 1] !== ":") {
// SCSS line comment (`:` guard keeps `https://…` intact)
const end = css.indexOf("\n", i);
const stop = end === -1 ? css.length : end;
out += " ".repeat(stop - i);
i = stop;
} else {
out += css[i];
i++;
}
}
return out;
}
// selector-nesting stack (comment-free — see blankComments) at `target`
function selectorStackAt(css, target) {
const stack = [];
let pending = 0;
for (let i = 0; i < target && i < css.length; i++) {
const ch = css[i];
if (ch === "{") {
stack.push(css.slice(pending, i));
pending = i + 1;
} else if (ch === "}") {
stack.pop();
pending = i + 1;
} else if (ch === ";") pending = i + 1;
}
return stack.join(" ");
}
// is the occurrence inside an unclosed color-mix()/…gradient() in its declaration?
function insideColourFn(css, idx) {
let s = idx;
while (s > 0 && !";{}".includes(css[s - 1])) s--;
const chunk = css.slice(s, idx);
const k = chunk.search(/(?:color-mix|[a-z-]*gradient)\(/);
if (k === -1) return false;
let bal = 0;
for (let i = chunk.indexOf("(", k); i < chunk.length; i++) {
if (chunk[i] === "(") bal++;
else if (chunk[i] === ")") bal--;
}
return bal > 0;
}
// ── rawVarInTemplate ───────────────────────────────────────────────────────
// rawVarInComponent's twin on the template side. Only three shapes count,
// because only these are PROVABLY replaceable:
// bg-[var(--color-x)] -> bg-x
// bg-[color-mix(… var(--color-x) 12%, transparent)] -> bg-x/12 (same bytes)
// bg-[color-mix(… var(--color-x) 12%, var(--color-y))] -> a derived token
// Everything else is exempt — a gradient/mask stop or drop-shadow colour has no
// utility form. Comments are blanked first (a doc comment showing the bad
// pattern is not a violation — KpiCard.vue had one).
// The `(?:\s*,[^)\]]*)?` tails catch the FALLBACK form `bg-[var(--color-x,#fff)]`:
// the token is defined so the fallback never fires, it only hides the read.
// Shipped without that and two occurrences slipped past.
const BARE_READ =
/[a-zA-Z][a-zA-Z0-9-]*-(?:\[\s*(?:[a-z]+:)?\s*var\(\s*--color-[a-z0-9-]+\s*(?:,[^)\]]*)?\)\s*\]|\(\s*(?:[a-z]+:)?\s*--color-[a-z0-9-]+\s*(?:,[^)\]]*)?\))/g;
const ALPHA_MIX =
/[a-zA-Z][a-zA-Z0-9-]*-\[color-mix\(in[_ ]srgb,[_ ]?var\(\s*--color-[a-z0-9-]+\s*\)[_ ]+[0-9.]+%[_ ]?,[_ ]?transparent\)\]/g;
const BLEND_MIX =
/[a-zA-Z][a-zA-Z0-9-]*-\[color-mix\(in[_ ]srgb,[_ ]?var\(\s*--color-[a-z0-9-]+\s*\)[_ ]+[0-9.]+%[_ ]?,[_ ]?var\(\s*--color-[a-z0-9-]+\s*\)\)\]/g;
function countRawVarInTemplate(text) {
// template + script only; <style> is rawVarInComponent's job
const cut = text.indexOf("<style");
const head = blankComments(cut === -1 ? text : text.slice(0, cut)).replace(
/<!--[\s\S]*?-->/g,
(m) => m.replace(/[^\n]/g, " "),
);
return (
(head.match(BARE_READ) || []).length +
(head.match(ALPHA_MIX) || []).length +
(head.match(BLEND_MIX) || []).length
);
}
// ── arbShadow ──────────────────────────────────────────────────────────────
// A hand-written shadow in any of the three syntaxes it hides in. The app ships
// one scale (--shadow-xs/sm/md/lg + the sticky/rail/glow/scroll roles) and
// colour is a separate axis, so spelling out offsets/blur is never needed. A
// ring is ring-*. Mirrors arbRadius.
// Template-only scanning was not enough: a pass that fixed the template left 40
// literals in <style> blocks and JS boxShadow: objects, reporting zero throughout.
const ARB_SHADOW =
/\b(?:inset-)?(?:drop-)?shadow-\[(?!(?:inherit|initial|unset|none)\])[^\]]+\]|\[box-shadow:[^\]]+\]/g;
// Accepted forms: var(--shadow-*), none/inherit/initial/unset, and an
// interpolated ${…} (already a token by then). The (?<![-\w]) lookbehind stops
// a vendor-prefixed -webkit-box-shadow being counted alongside its twin.
const CSS_SHADOW = /(?<![-\w])box-shadow:\s*(?!\s*(?:var\(|none|inherit|initial|unset|\$\{))[^;]+;/g;
// Three JS spellings occur here: `boxShadow:` literal, `x.boxShadow =`, and
// `"box-shadow":` as a quoted key. The first version had only the literal form,
// and the other two sat in OTableBodyCell/useStickyColumns reporting clean.
const JS_SHADOW =
/(?:boxShadow\s*[:=]|["']box-shadow["']\s*:)\s*(["'`])(?!\s*(?:var\(|none|\$\{))[^"'`]+\1/g;
// Chart libraries and the email template serialise their own style strings —
// ECharts options and mail markup cannot resolve a CSS custom property, so a
// literal is the only option there. Same rationale as TS_HEX_ALLOWLIST.
const SHADOW_LITERAL_ALLOWLIST = [
"utils/prebuilt-templates/email.ts",
"utils/dashboard/",
"components/dashboards/PanelSchemaRenderer.vue",
"plugins/traces/ServiceGraph.vue",
];
function countArbShadow(text, rel = "") {
if (SHADOW_LITERAL_ALLOWLIST.some((p) => (p.endsWith("/") ? rel.includes(p) : rel.endsWith(p)))) {
return 0;
}
const blanked = blankComments(text).replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, " "));
return (
(blanked.match(ARB_SHADOW) || []).length +
(blanked.match(CSS_SHADOW) || []).length +
(blanked.match(JS_SHADOW) || []).length
);
}
function countRawVarInComponent(rawStyleText) {
const styleText = blankComments(rawStyleText);
let n = 0;
const reVar = /var\(\s*--color-[a-z0-9-]+/g;
let m;
while ((m = reVar.exec(styleText)) !== null) {
const idx = m.index;
// CSS custom-property definition: the declaration key starts with `--`
let s = idx;
while (s > 0 && !";{}".includes(styleText[s - 1])) s--;
if (/^\s*--[\w-]+\s*:/.test(styleText.slice(s, idx))) continue;
if (insideColourFn(styleText, idx)) continue;
const sel = selectorStackAt(styleText, idx);
if (/:deep\(/.test(sel)) continue; // child component internals
if (/::[a-z-]/.test(sel)) continue; // pseudo-element
if (/@keyframes/.test(sel)) continue; // keyframe step
n++;
}
return n;
}
// Every surviving <style> block must justify its own existence: it opens with
// /* keep(<tag>): <one line why this cannot be Tailwind> */
// Both `keep(<tag>)` and `keep: <tag>` are accepted — the punctuation is not the
// point, the stated reason is. Sanctioned tags: O2_STYLE_MIGRATION_PLAN.md §3.1
// step 5. `lib-override:<lib>` takes a free-form library suffix, matched by prefix.
const KEEP_TAG_NAMES =
"lib-override(?::[\\w.-]+)?|generated-content|keyframes|print|scrollbar|complex-state|brand|third-party";
const KEEP_TAGS = new RegExp(
`keep\\s*(?:\\(\\s*(?:${KEEP_TAG_NAMES})\\s*\\)|:\\s*(?:${KEEP_TAG_NAMES}))`,
);
// Counts style blocks that carry no keep-comment. A block with no justification
// is debt by definition — either it should have been migrated to utilities, or
// its author owes one line saying why it could not be.
function countUnjustifiedBlocks(text) {
let n = 0;
const re = /<style[^>]*>([\s\S]*?)<\/style>/g;
let m;
while ((m = re.exec(text)) !== null) {
if (!KEEP_TAGS.test(m[1])) n++;
}
return n;
}
function countFile(file, rel) {
const text = readFileSync(file, "utf8");
const counts = {};
const isVue = rel.endsWith(".vue");
const isSpec = rel.includes(".spec.");
if (isVue) {
// Per-category sanctioned exceptions (like the .ts allowlists): a file may carry
// ONE specific bypass the category can't express, while every OTHER category
// still applies in full.
// • darkMechanism → DARK_SEAM_ALLOWLIST (ThemeSwitcher, the toggle control).
// • unscopedStyle → UNSCOPED_STYLE_ALLOWLIST (selectors that must reach external
// ancestors — print / fullscreen — can't be scoped).
const skip = (k) =>
(k === "darkMechanism" && DARK_SEAM_ALLOWLIST.some((p) => rel.endsWith(p))) ||
(k === "unscopedStyle" && UNSCOPED_STYLE_ALLOWLIST.some((p) => rel.endsWith(p)));
// A component `shape="rounded"` prop (BadgeShape = "pill" | "rounded" |
// "square") is a first-class API value, NOT a bare Tailwind `rounded` radius
// class — strip these prop bindings before scanning so bareRounded doesn't
// false-match them. Real `class="… rounded …"` violations are untouched.
const scan = text.replace(/:?\bshape=(["'])[^"']*\1/g, "");
for (const [k, re] of Object.entries(WHOLE)) {
if (skip(k)) continue;
const n = (scan.match(re) || []).length;
if (n) counts[k] = n;
}
const sb = styleBlocks(text);
for (const [k, re] of Object.entries(STYLE_ONLY)) {
const n = (sb.match(re) || []).length;
if (n) counts[k] = n;
}
const rawVar = countRawVarInComponent(sb);
if (rawVar) counts.rawVarInComponent = rawVar;
const rawTpl = countRawVarInTemplate(text);
if (rawTpl) counts.rawVarInTemplate = rawTpl;
const arbShadow = countArbShadow(text, rel);
if (arbShadow) counts.arbShadow = arbShadow;
const unjustified = countUnjustifiedBlocks(text);
if (unjustified) counts.styleKeepComment = unjustified;
} else {
// .ts — only tsHex (non-spec, non-allowlisted) and darkMechanism apply.
// Allowlist entry ending in "/" matches by directory prefix, else by suffix.
const allowed = TS_HEX_ALLOWLIST.some((p) =>
p.endsWith("/") ? rel.includes(p) : rel.endsWith(p),
);
if (!isSpec && !allowed) {
// Strip comments first so a hex mentioned in prose ("e.g. #FF0000")
// doesn't count as a real colour literal.
const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/[^\n]*/g, "$1");
const n = (code.match(/['"]#[0-9a-fA-F]{3,8}['"]/g) || []).length;
if (n) counts.tsHex = n;
}
if (!isSpec) {
const n = countArbShadow(text, rel);
if (n) counts.arbShadow = n;
}
if (!isSpec && !DARK_SEAM_ALLOWLIST.some((p) => rel.endsWith(p))) {
const n = (text.match(WHOLE.darkMechanism) || []).length;
if (n) counts.darkMechanism = n;
}
// Literal font stacks also leak in via .ts that builds HTML/CSS strings
// (trace tooltips, chart tooltips) — that surface was ~20 of the original hits.
if (!isSpec && !FONT_ALLOWLIST.some((p) => rel.endsWith(p))) {
const n = (text.match(WHOLE.literalFontFamily) || []).length;
if (n) counts.literalFontFamily = n;
}
}
return counts;
}
const files = walk(SRC_DIR);
const current = {};
for (const f of files) {
const rel = relative(SRC_DIR, f).split("\\").join("/");
const c = countFile(f, rel);
if (Object.keys(c).length) current[rel] = c;
}
// Flatten to (file, category, count). Zero tolerance — any entry fails the build.
const violations = [];
for (const [rel, c] of Object.entries(current))
for (const [k, n] of Object.entries(c)) violations.push({ rel, k, n });
violations.sort((a, b) => b.n - a.n || a.rel.localeCompare(b.rel));
const total = violations.reduce((sum, v) => sum + v.n, 0);
const fileCount = Object.keys(current).length;
if (process.argv.includes("--list")) {
for (const { rel, k, n } of violations) console.log(` ${rel} [${k}] ${n}`);
console.log(`\n${total} violation(s) across ${fileCount} file(s).`);
process.exit(0);
}
if (violations.length) {
console.error(
`\ncheck-design-consistency: ${total} design-token bypass(es) in ${fileCount} file(s):\n`,
);
for (const { rel, k, n } of violations.slice(0, 60)) console.error(` ${rel} [${k}] ${n}`);
if (violations.length > 60) {
console.error(` …and ${violations.length - 60} more (--list to see all)`);
}
console.error(
"\nReach the token through its registered utility (bg-x / text-x / border-x), or" +
"\nmove the rule to src/styles/base-elements.css or src/styles/utilities.css." +
"\nDo not add an exemption. See DESIGN_TOKEN_STANDARD.md §4.\n",
);
process.exit(1);
}
console.log(`OK — no design-token bypasses (${files.length} files scanned).`);