Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 196 additions & 3 deletions webviews/scripts/check-design-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,136 @@
* Design-token ratchet for first-party webview surfaces.
*
* New literal spacing and control sizes must use the adopted scale. Existing debt is recorded in
* design-tokens-baseline.tsv; the baseline can only shrink with --update-baseline. Glass fills use
* design-tokens-baseline.jsonl; the baseline can only shrink with --update-baseline. Glass fills use
* the same resolver as check-glass-never-opaque, with named floating surfaces included even while
* their markup is being migrated to `.ui-glass`.
*
* Token literals (design/grid.md, tokens.md, motion-scale.json), in CSS and in TSX inline `style={{}}`
* objects, outside the token files (pages/shared/desktop.css, generated CSS) and outside custom
* property definitions (`--x: ...` is a token definition):
* color a raw #hex, rgb()/rgba()/hsl()/hsla()/hwb()/lab()/lch()/oklab()/oklch()/color() literal
* that is not a var() fallback and takes no var() argument.
* spacing padding/gap (as before), margin and inset/top/right/bottom/left off 0/4/8/12/16/24/32/40/48/56.
* radius border-radius off 0/4/6/8/12/16 (menu 5 comes from --cmux-menu-*; >= 999px is a capsule).
* font-weight anything but 400/600 (`normal`); 700 for monograms is recorded in the baseline.
* duration a literal transition/animation duration other than 0, not var(--cmux-motion-*).
* easing cubic-bezier() or ease/ease-in/ease-out/ease-in-out, not var(--cmux-ease-out).
* Font sizes are the type-scale ratchet's job (scripts/cmux-next/check-type-scale.py), not this one's.
* A migration PR shrinks the baseline with `bun scripts/check-design-tokens.ts --update-baseline`,
* which refuses to grow any count.
*/
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import * as ts from "typescript";
import { fillKind, glassContextRules, targetsRequiredGlass, type Block } from "./check-glass-never-opaque";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const SRC = join(ROOT, "src");
const BASELINE = join(dirname(fileURLToPath(import.meta.url)), "design-tokens-baseline.jsonl");
const SPACING = new Set([0, 4, 8, 12, 16, 24, 32]);
/** Layout gaps (design/grid.md) are on the scale for margins and positioned insets. */
const LAYOUT_SPACING = new Set([...SPACING, 40, 48, 56]);
const RADIUS = new Set([0, 4, 6, 8, 12, 16]);
const CAPSULE_RADIUS = 999;
const WEIGHTS = new Set(["400", "600", "normal"]);
/** Token definitions and generated sheets: the literals the rest of the tree must reference. */
const TOKEN_FILES = new Set(["pages/shared/desktop.css"]);
const CONTROL_HEIGHT = new Set([0, 20, 24, 28, 32]);
const CSS_ROOTS = ["."];
const ROOT_FILES: string[] = [];
const NAMED_GLASS = /\.(?:nt-box|acpmux-env-panel|acpmux-composer-strip|acpmux-composer)\b/;
const SPACING_PROPERTY = /^(?:padding(?:-(?:block|inline|top|right|bottom|left))?|gap|row-gap|column-gap)$/;
const HEIGHT_PROPERTY = /^(?:height|min-height)$/;
const LAYOUT_PROPERTY =
/^(?:margin(?:-(?:block|inline|top|right|bottom|left)(?:-(?:start|end))?)?|inset(?:-(?:block|inline)(?:-(?:start|end))?)?|top|right|bottom|left)$/;
const RADIUS_PROPERTY = /^border(?:-(?:top|bottom|start|end)-(?:left|right|start|end))?-radius$/;
const DURATION_PROPERTY = /^(?:transition|animation)(?:-duration)?$/;
const EASING_PROPERTY = /^(?:transition|animation)(?:-timing-function)?$/;
const COLOR_FUNCTION = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(/gi;
/** Properties whose strings and idents are not colours (`content: "#1"`). */
const NOT_COLOR_PROPERTY =
/^(?:(?:-webkit-)?mask(?:-[a-z-]+)?|content|quotes|grid-template-areas|font-feature-settings|font-variation-settings|counter-\w+)$/;
const HEX_COLOR = /#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\b/i;
const REDUCE_MEDIA = /(?<!not\s*)\(\s*prefers-reduced-transparency\s*:\s*reduce\s*\)/;
const REDUCE_ATTR = /(?<!:not\(\s*)\[data-reduce-transparency\]/;

export interface Finding {
file: string;
kind: "spacing" | "height" | "opaque-glass";
kind: "spacing" | "height" | "opaque-glass" | "color" | "radius" | "font-weight" | "duration" | "easing";
selector: string;
property: string;
value: string;
line: number;
}

/** `value` with every var(...) call replaced by `\u00a7`, so a token and its fallback are not literals. */
function withoutVars(value: string): string {
let out = "";
for (let i = 0; i < value.length; i++) {
if (value.startsWith("var(", i)) {
let depth = 0;
for (; i < value.length; i++) {
if (value[i] === "(") depth++;
else if (value[i] === ")" && --depth === 0) break;
}
out += "\u00a7";
continue;
}
out += value[i];
}
return out;
}

/**
* A raw colour literal outside var(): a hex, or a colour function none of whose arguments is a
* token (`rgb(var(--accent-rgb) / 0.5)` and `color-mix(in srgb, var(--x) 40%, transparent)` compose tokens).
*/
export function hasRawColor(value: string): boolean {
const bare = withoutVars(value).replace(/url\([^)]*\)/gi, " ");
if (HEX_COLOR.test(bare)) return true;
for (const match of bare.matchAll(COLOR_FUNCTION)) {
let depth = 1;
let end = (match.index ?? 0) + match[0].length;
const start = end;
for (; end < bare.length && depth; end++) {
if (bare[end] === "(") depth++;
else if (bare[end] === ")") depth--;
}
if (!bare.slice(start, end).includes("\u00a7")) return true;
}
return false;
}

function outside(values: number[], allowed: Set<number>, capsule = false): boolean {
return values.some((number) => {
const size = Math.abs(number);
return !allowed.has(size) && !(capsule && size >= CAPSULE_RADIUS);
});
}

/** The token-literal kind a declaration breaks, if any (spacing/height keep their own path below). */
export function tokenLiteralKind(property: string, value: string): Finding["kind"] | undefined {
if (property.startsWith("--")) return undefined;
if (LAYOUT_PROPERTY.test(property) && outside(pxValues(value), LAYOUT_SPACING)) return "spacing";
if (RADIUS_PROPERTY.test(property) && outside(pxValues(value), RADIUS, true)) return "radius";
if (property === "font-weight") {
const weight = value.trim().toLowerCase();
if (!WEIGHTS.has(weight) && !/^(?:var\(|inherit|initial|unset|revert)/.test(weight)) return "font-weight";
}
if (DURATION_PROPERTY.test(property)) {
const times = [...withoutVars(value).matchAll(/(?<![\w.-])(\d*\.?\d+)(ms|s)\b/gi)].map((m) => Number(m[1]));
if (times.some((time) => time !== 0)) return "duration";
}
if (
EASING_PROPERTY.test(property) &&
/\bcubic-bezier\(|(?<![\w-])ease(?:-in|-out|-in-out)?(?![\w-])/i.test(withoutVars(value))
)
return "easing";
if (!NOT_COLOR_PROPERTY.test(property) && hasRawColor(value)) return "color";
return undefined;
}

function cssFiles(): string[] {
const walk = (dir: string): string[] =>
readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
Expand Down Expand Up @@ -141,6 +240,7 @@ function key(finding: Omit<Finding, "line">): string {
function findingsForFile(file: string): Finding[] {
const text = readFileSync(file, "utf8");
const rel = relative(SRC, file);
const tokenFile = TOKEN_FILES.has(rel.replaceAll("\\", "/"));
const blocks = parse(text);
const findings: Finding[] = [];
for (const block of blocks) {
Expand Down Expand Up @@ -168,6 +268,18 @@ function findingsForFile(file: string): Finding[] {
value: declaration.value,
line: declaration.line,
});
} else if (!tokenFile) {
const kind = tokenLiteralKind(declaration.property, declaration.value);
if (kind) {
findings.push({
file: rel,
kind,
selector: block.selector,
property: declaration.property,
value: declaration.value,
line: declaration.line,
});
}
}
}
}
Expand Down Expand Up @@ -209,8 +321,89 @@ function glassFindings(): Finding[] {
return findings;
}

function tsxFiles(): string[] {
const walk = (dir: string): string[] =>
readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
if (entry.isDirectory() && (entry.name === "node_modules" || entry.name === "generated")) return [];
const path = join(dir, entry.name);
if (entry.isDirectory()) return walk(path);
return entry.name.endsWith(".tsx") && !entry.name.includes(".test.") ? [path] : [];
});
return walk(SRC);
}

/** React appends px to a bare number on these properties. */
const PX_NUMBER_PROPERTY =
/^(?:padding|margin|gap|row-gap|column-gap|inset|top|right|bottom|left|border(?:-[a-z]+)*-radius|height|min-height)(?:-[a-z-]+)?$/;

function kebab(name: string): string {
return name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
}

/** The literal values an inline style property can take: a string, a number, or either branch of ?: / ??. */
function styleValues(node: ts.Expression, property: string): string[] {
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node)) return styleValues(node.expression, property);
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return [node.text];
if (ts.isTemplateExpression(node))
return [node.head.text + node.templateSpans.map((span) => `var(--expr)${span.literal.text}`).join("")];
if (ts.isNumericLiteral(node))
return [PX_NUMBER_PROPERTY.test(property) && Number(node.text) !== 0 ? `${node.text}px` : node.text];
if (
ts.isPrefixUnaryExpression(node) &&
node.operator === ts.SyntaxKind.MinusToken &&
ts.isNumericLiteral(node.operand)
)
return styleValues(node.operand, property).map((value) => `-${value}`);
if (ts.isConditionalExpression(node))
return [...styleValues(node.whenTrue, property), ...styleValues(node.whenFalse, property)];
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)
return styleValues(node.right, property);
return [];
}

/** Literal design values in TSX inline `style={{ ... }}` objects, judged like the same CSS declaration. */
export function inlineStyleFindings(rel: string, source: string): Finding[] {
const ast = ts.createSourceFile(rel, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const findings: Finding[] = [];
const visit = (node: ts.Node) => {
if (
ts.isJsxAttribute(node) &&
ts.isIdentifier(node.name) &&
node.name.text === "style" &&
node.initializer &&
ts.isJsxExpression(node.initializer) &&
node.initializer.expression &&
ts.isObjectLiteralExpression(node.initializer.expression)
) {
for (const prop of node.initializer.expression.properties) {
if (!ts.isPropertyAssignment(prop)) continue;
const name = ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name) ? prop.name.text : undefined;
if (!name || name.startsWith("--")) continue;
const property = kebab(name);
const line = ast.getLineAndCharacterOfPosition(prop.getStart(ast)).line + 1;
for (const value of styleValues(prop.initializer, property)) {
const kind =
SPACING_PROPERTY.test(property) && isOutsideScale(property, value)
? "spacing"
: HEIGHT_PROPERTY.test(property)
? undefined
: tokenLiteralKind(property, value);
if (kind) findings.push({ file: rel, kind, selector: "style={{}}", property, value, line });
}
}
}
ts.forEachChild(node, visit);
};
visit(ast);
return findings;
}

function tsxFindings(): Finding[] {
return tsxFiles().flatMap((file) => inlineStyleFindings(relative(SRC, file), readFileSync(file, "utf8")));
}

export function findDesignTokenViolations(): Finding[] {
return [...cssFiles().flatMap(findingsForFile), ...glassFindings()];
return [...cssFiles().flatMap(findingsForFile), ...glassFindings(), ...tsxFindings()];
}

function loadBaseline(): Map<string, number> {
Expand Down
Loading
Loading