feat(studio): fail the suite on a class that resolves to nothing

Tailwind silently drops a class it cannot compile, so `rounded-button`
renders as no radius at all and nothing goes red. Two tests close that:

- the token gate compiles Studio's entry stylesheet with every class the
  source claims and reports `file: class` for anything that produces no
  selector. Tailwind is the judge, so there is no allowlist.
- the hex ratchet counts colour literals per file against a committed
  baseline. It fails on a rise, and on a fall it prints the command that
  banks the lower number. The baseline is only written under a named
  flag, never as a side effect of a run.

The gate's first run found seven names the markup has always asked for
and no config has ever defined: rounded-button, shadow-btn-primary,
bg-surface-hover, bg-accent-red, ease-standard, text-2xs and two panel
backgrounds. Each is added to theme.css, the semantic ones by role and
the rest as deprecated aliases for the sweep to remove. It also found a
dead state class on the timeline clip, styled nowhere, which is deleted.
This commit is contained in:
Miguel Angel Simon Sierra
2026-09-18 12:11:39 -04:00
parent f77cfa602c
commit 5be39c233b
9 changed files with 890 additions and 17 deletions
@@ -71,7 +71,6 @@ export const TimelineClip = memo(function TimelineClip({
isSelected ? "is-selected" : "",
isHovered ? "is-hovered" : "",
isDragging ? "is-dragging" : "",
showDefaultText ? "" : "is-micro",
isAudioTimelineElement(el) ? "is-audio" : "",
]
.filter((className) => className.length > 0)
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { extractClassCandidates } from "./classCandidates";
function bases(source: string): string[] {
return extractClassCandidates(source)
.map((candidate) => candidate.base)
.sort();
}
describe("extractClassCandidates", () => {
it("reads a plain className attribute", () => {
expect(bases(`<div className="text-center border-dashed" />`)).toEqual([
"border-dashed",
"text-center",
]);
});
it("reads every string a class-building call is given", () => {
const source = `<div className={cn("flex", open && "bg-surface", { "text-accent": on })} />`;
expect(bases(source)).toEqual(["bg-surface", "flex", "text-accent"]);
});
it("reads a class map that never touches a className literal", () => {
// The shape Studio's primitives use: the utility only reaches markup
// through a lookup, so an attribute-only reader would miss it entirely.
const source = `const sizeStyles: Record<ButtonSize, string> = {
sm: "h-7 px-2.5 rounded-button",
};`;
expect(bases(source)).toEqual(["h-7", "px-2.5", "rounded-button"]);
});
it("drops the template chunk that touches an interpolation", () => {
expect(bases("<div className={`w-${i} flex`} />")).toEqual(["flex"]);
});
it("keeps a chunk that whitespace separates from the interpolation", () => {
expect(bases("<div className={`${base} flex `} />")).toEqual(["flex"]);
});
it("strips variant prefixes, including bracketed ones", () => {
const source = `<div className="hover:bg-surface/50 md:group-data-[open]:text-accent" />`;
expect(bases(source)).toEqual(["bg-surface/50", "text-accent"]);
});
it("flags bracketed arbitrary values so the sweep can count them", () => {
const found = extractClassCandidates(`<div className="text-[11px] text-center" />`);
expect(found.filter((candidate) => candidate.arbitrary).map((c) => c.raw)).toEqual([
"text-[11px]",
]);
});
it("ignores strings that are not class lists", () => {
const source = `const styles = getComputedStyle(el);
const label = "Export video";
log("failed to open the project file");`;
expect(bases(source)).toEqual([]);
});
it("does not read past the end of a class region", () => {
const source = `<div className="flex" title="Export video" />`;
expect(bases(source)).toEqual(["flex"]);
});
it("survives an unterminated region without hanging", () => {
expect(bases(`<div className={cn("flex",`)).toEqual(["flex"]);
});
});
@@ -0,0 +1,300 @@
/**
* Pulls the Tailwind class candidates out of a TypeScript or TSX source file.
*
* The token gate needs the set of classes Studio's markup *claims*, so that
* Tailwind can be asked whether each one resolves. Tailwind's own extractor is
* no help here: it scans everything and silently drops what it cannot compile,
* which is exactly the failure this gate exists to catch.
*
* So the reader is deliberately narrow. It only looks inside places where a
* string is unambiguously a class list:
*
* - a `className=` / `class=` JSX attribute,
* - an argument to a class-building call (`cn`, `clsx`, `twMerge`, ...),
* - an object property whose name ends in `class`/`className`,
* - a binding whose name ends in `Styles`/`Classes`/`ClassName(s)`
* (`const sizeStyles: Record<Size, string> = { sm: "h-7 rounded-button" }`,
* the shape Studio's primitives use, never reaches a `className` literal).
*
* Everything else in the file is ignored, because a heuristic that guesses at
* bare strings turns prose and file paths into "unresolved classes" and the
* gate stops being believed.
*
* Pure: text in, candidates out. No file system, no Tailwind.
*/
/** A class candidate and where it came from. */
export interface ClassCandidate {
/** The utility with its variant prefixes removed (`hover:bg-x` -> `bg-x`). */
readonly base: string;
/** The token exactly as it was written, for the failure message. */
readonly raw: string;
/** True when the utility carries a bracketed arbitrary value. */
readonly arbitrary: boolean;
}
const OPEN: Record<string, string> = { "{": "}", "(": ")", "[": "]" };
/**
* If a span that is not code starts at `i` (a string, a template literal, a
* line comment or a block comment), the index of its last character. Otherwise
* -1. Never runs past the end of the text.
*
* Known limit: a regular-expression literal containing a quote is read as a
* string. The cost is a spurious candidate, which the gate reports out loud
* rather than swallowing.
*/
function endOfSpan(text: string, i: number): number {
const ch = text[i];
if (ch === '"' || ch === "'" || ch === "`") {
return Math.min(endOfString(text, i), text.length - 1);
}
if (ch !== "/") return -1;
if (text[i + 1] === "/") {
const end = text.indexOf("\n", i);
return end === -1 ? text.length - 1 : end - 1;
}
if (text[i + 1] === "*") {
const end = text.indexOf("*/", i + 2);
return end === -1 ? text.length - 1 : end + 1;
}
return -1;
}
/**
* Index just past the region that starts at `start` (an opening bracket),
* skipping over strings, template literals and comments. Returns `text.length`
* when the region never closes.
*/
function endOfRegion(text: string, start: number): number {
const stack: string[] = [];
for (let i = start; i < text.length; i += 1) {
const span = endOfSpan(text, i);
if (span >= i) {
i = span;
continue;
}
const ch = text[i];
if (OPEN[ch]) {
stack.push(OPEN[ch]);
} else if (stack.length > 0 && ch === stack[stack.length - 1]) {
stack.pop();
if (stack.length === 0) return i + 1;
}
}
return text.length;
}
/** Index of the closing quote of the string that opens at `start`. */
function endOfString(text: string, start: number): number {
const quote = text[start];
for (let i = start + 1; i < text.length; i += 1) {
if (text[i] === "\\") {
i += 1;
continue;
}
if (text[i] === quote) return i;
if (quote === "`" && text[i] === "$" && text[i + 1] === "{") {
i = endOfRegion(text, i + 1) - 1;
}
}
return text.length;
}
/**
* Splits a template literal body into the chunks that are safe to read.
*
* A chunk that touches an interpolation is dropped at that edge, because
* `` `w-${size}` `` claims no class called `w-`. A chunk only survives at an
* interpolated edge when whitespace separates it from the hole.
*/
function templateChunks(body: string): string[] {
const chunks: string[] = [];
let cursor = 0;
let precededByHole = false;
while (cursor <= body.length) {
const hole = body.indexOf("${", cursor);
const end = hole === -1 ? body.length : hole;
let chunk = body.slice(cursor, end);
if (precededByHole && !/^\s/.test(chunk)) chunk = chunk.replace(/^\S+/, "");
if (hole !== -1 && !/\s$/.test(chunk)) chunk = chunk.replace(/\S+$/, "");
chunks.push(chunk);
if (hole === -1) break;
cursor = endOfRegion(body, hole + 1);
precededByHole = true;
}
return chunks;
}
/**
* A same-length copy of the file with every comment body and every string body
* blanked out. Quotes, brackets and code survive, so offsets still line up
* with the original text.
*
* The anchors are matched against this mask rather than the raw file, which is
* what keeps two whole classes of noise out of the results:
*
* - prose. One apostrophe in a doc comment ("don't") opens a string that
* swallows the paragraph after it, and every word arrives as a class.
* - generated code. `captions/generator.ts` writes `x.className = 'word'`
* into a string; that is markup for the composition it emits, not for
* Studio, and Studio's stylesheet is the wrong judge of it.
*/
function maskLiterals(text: string): string {
let out = "";
for (let i = 0; i < text.length; i += 1) {
const span = endOfSpan(text, i);
if (span < i) {
out += text[i];
continue;
}
// A comment goes entirely, newlines kept so line numbers survive. A string
// keeps its quotes, so the region scan still finds where it ends.
const body = " ".repeat(span - i - 1);
out +=
text[i] === "/"
? text.slice(i, span + 1).replace(/[^\n]/g, " ")
: text[i] + body + text[span];
i = span;
}
return out;
}
// `activeTool === "razor" ? a : b` inside a `className=` region: the operand
// of a comparison is a value being tested, never a class list.
const COMPARISON = /(?:[=!]==?|\bcase)\s*$/;
// A class list never carries a declaration, so `position: absolute; ...` in a
// constant called `visualStyles` is a stylesheet, not classes.
const CSS_TEXT = /[a-z-]+\s*:\s*[^;]+;/;
// The calls whose arguments are class lists.
const CLASS_BUILDER = /^(?:cn|clsx|classnames|classNames|twMerge|twJoin|cva|tv)$/;
// Written with escapes, not literal quotes: `maskLiterals` reads a quote in
// a regular-expression literal as the start of a string, and this file is
// one of the files the gate scans.
const CLOSE = /[)\]}]/;
const QUOTE = /[\u0022\u0027\u0060]/;
/**
* A string is a class list unless it is being compared against or is a sheet
* of CSS text.
*/
function isClassList(mask: string, start: number, i: number, body: string): boolean {
return !COMPARISON.test(mask.slice(Math.max(start, i - 8), i)) && !CSS_TEXT.test(body);
}
/**
* Does a string inside the bracket that opens at `i` hold classes?
*
* `className={TIER_CLASS[resolveValueTier(field.inlineStyles["font-weight"],
* "400")]}` sits inside a class region, but "font-weight" is a lookup key. A
* bracket opened by a name that is not a class builder closes the door.
*/
function opensClassContext(mask: string, from: number, i: number): boolean {
const name = /([A-Za-z_$][\w$]*)\s*$/.exec(mask.slice(Math.max(from, i - 40), i))?.[1];
return name === undefined || CLASS_BUILDER.test(name);
}
/**
* Every string and template body inside a source region. Where the strings are
* is decided on the mask; what they contain is read from the real text.
*/
function stringsIn(text: string, mask: string, start: number, end: number): string[] {
const found: string[] = [];
const classy: boolean[] = [];
for (let i = start; i < end; i += 1) {
const ch = mask[i];
if (OPEN[ch]) classy.push(opensClassContext(mask, start, i));
else if (CLOSE.test(ch)) classy.pop();
else if (QUOTE.test(ch)) {
const close = endOfString(mask, i);
const body = text.slice(i + 1, close);
if (classy.at(-1) !== false && isClassList(mask, start, i, body)) {
found.push(...(ch === "`" ? templateChunks(body) : [body]));
}
i = close;
}
}
return found;
}
/** Split on `:` that sits outside brackets, so `[&:hover]:x` yields `x`. */
function stripVariants(token: string): string {
let depth = 0;
let last = 0;
for (let i = 0; i < token.length; i += 1) {
const ch = token[i];
if (ch === "[" || ch === "(") depth += 1;
else if (ch === "]" || ch === ")") depth -= 1;
else if (ch === ":" && depth === 0) last = i + 1;
}
return token.slice(last);
}
// A utility starts with a letter, a digit, `-` (negative), `@` (container
// query) or `[` (fully arbitrary), and never contains a quote or a backslash.
// The quotes are written as escapes so this file can be read by its own
// comment stripper, which treats a quote in a regex literal as a string.
const TOKEN = /^[-@[a-z0-9][^\s\u0022\u0027\u0060\\]*$/;
/**
* The class contexts. Each anchor is matched, then the balanced region that
* follows it is read for strings. `lastIndex` advances past the region so a
* nested `cn()` inside a `className=` is not read twice.
*/
const ANCHORS = [
// className={...} / className="..." / class="..."
/\bclass(?:Name)?\s*=\s*/g,
// cn("..."), clsx(...), twMerge(...), cva(...)
/\b(?:cn|clsx|classnames|classNames|twMerge|twJoin|cva|tv)\s*\(/g,
// { className: "...", itemClass: "..." }
/\b\w*[Cc]lass(?:Names?)?\s*:\s*/g,
// const sizeStyles: Record<Size, string> = { ... } / baseClasses = "..."
// The prefix and the plural are both required: a bare `style=` is the JSX
// inline-style attribute and a singular `fxPresetStyle` is a sheet of CSS
// text; neither holds class names.
// The optional group is a type annotation. Without it the gap would also
// span `computedStyles["z-index"] !== "auto"`, whose `=` is a comparison.
/\b\w+(?:Styles|Classes|ClassNames?)\b(?:\s*:[^=;{}()[\]]*)?\s*=(?!=)\s*/g,
] as const;
/**
* Index just past the bracketed region or the string that starts at `at`, or
* -1 when neither does. Anything else after an anchor is not a class list:
* `const styles = getComputedStyle(el)` matches the binding-name anchor.
*/
function regionAfter(mask: string, at: number): number {
if (OPEN[mask[at]]) return endOfRegion(mask, at);
if (QUOTE.test(mask[at])) return endOfString(mask, at) + 1;
return -1;
}
/** Split one class list into candidates, keeping the first spelling of each. */
function collect(byRaw: Map<string, ClassCandidate>, list: string): void {
for (const raw of list.split(/\s+/)) {
if (raw === "" || !TOKEN.test(raw) || byRaw.has(raw)) continue;
const base = stripVariants(raw);
if (base === "") continue;
byRaw.set(raw, { base, raw, arbitrary: base.includes("[") });
}
}
/** Every class candidate claimed by one source file. */
export function extractClassCandidates(source: string): ClassCandidate[] {
// Anchors and region bounds come from the mask (code only); the strings
// themselves are read back out of the real text.
const mask = maskLiterals(source);
const byRaw = new Map<string, ClassCandidate>();
for (const anchor of ANCHORS) {
const pattern = new RegExp(anchor.source, "g");
let match: RegExpExecArray | null;
while ((match = pattern.exec(mask)) !== null) {
const start = match.index + match[0].length - (match[0].endsWith("(") ? 1 : 0);
const end = regionAfter(mask, start);
if (end <= start) continue;
for (const text of stringsIn(source, mask, start, end)) collect(byRaw, text);
pattern.lastIndex = Math.max(pattern.lastIndex, end);
}
}
return [...byRaw.values()];
}
@@ -0,0 +1,109 @@
{
"total": 475,
"files": {
"packages/studio/src/captions/components/CaptionOverlay.tsx": 5,
"packages/studio/src/captions/components/CaptionTimeline.tsx": 11,
"packages/studio/src/captions/generator.ts": 2,
"packages/studio/src/captions/parser.ts": 1,
"packages/studio/src/captions/types.ts": 4,
"packages/studio/src/components/StudioHeader.tsx": 5,
"packages/studio/src/components/StudioToast.tsx": 10,
"packages/studio/src/components/TimelineToolbar.tsx": 2,
"packages/studio/src/components/editor/ArcPathControls.tsx": 1,
"packages/studio/src/components/editor/BlockParamsPanel.tsx": 2,
"packages/studio/src/components/editor/BorderRadiusEditor.tsx": 10,
"packages/studio/src/components/editor/DomEditCropHandles.tsx": 1,
"packages/studio/src/components/editor/EaseCurveSection.tsx": 2,
"packages/studio/src/components/editor/FileTree.tsx": 2,
"packages/studio/src/components/editor/FileTreeIcons.tsx": 17,
"packages/studio/src/components/editor/FileTreeNodes.tsx": 4,
"packages/studio/src/components/editor/GestureTrailOverlay.tsx": 1,
"packages/studio/src/components/editor/GridOverlay.tsx": 4,
"packages/studio/src/components/editor/InlineTextToolbar.tsx": 2,
"packages/studio/src/components/editor/KeyframeDiamond.tsx": 2,
"packages/studio/src/components/editor/KeyframeNavigation.tsx": 2,
"packages/studio/src/components/editor/MotionPathNode.tsx": 5,
"packages/studio/src/components/editor/MotionPathOverlay.tsx": 1,
"packages/studio/src/components/editor/SnapGuideOverlay.tsx": 3,
"packages/studio/src/components/editor/TimelineFxPopover.tsx": 1,
"packages/studio/src/components/editor/Transform3DCube.tsx": 6,
"packages/studio/src/components/editor/canvasContextMenuZOrder.ts": 2,
"packages/studio/src/components/editor/colorValue.ts": 3,
"packages/studio/src/components/editor/domEditingElement.ts": 1,
"packages/studio/src/components/editor/easeCurveSvg.tsx": 2,
"packages/studio/src/components/editor/gradientValue.ts": 6,
"packages/studio/src/components/editor/keyframeDrag.ts": 1,
"packages/studio/src/components/editor/manualOffsetDrag.ts": 1,
"packages/studio/src/components/editor/panelTokens.ts": 4,
"packages/studio/src/components/editor/propertyPanelColor.tsx": 8,
"packages/studio/src/components/editor/propertyPanelColorCurveGraph.tsx": 17,
"packages/studio/src/components/editor/propertyPanelColorScopes.tsx": 8,
"packages/studio/src/components/editor/propertyPanelColorWheels.tsx": 8,
"packages/studio/src/components/editor/propertyPanelFlatEffectsSection.tsx": 5,
"packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx": 3,
"packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx": 2,
"packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx": 1,
"packages/studio/src/components/editor/propertyPanelFxFamily.ts": 1,
"packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts": 25,
"packages/studio/src/components/editor/propertyPanelHelpers.ts": 4,
"packages/studio/src/components/editor/propertyPanelPrimitives.tsx": 1,
"packages/studio/src/components/editor/propertyPanelSections.tsx": 1,
"packages/studio/src/components/editor/propertyPanelStyleSections.tsx": 3,
"packages/studio/src/components/editor/propertyPanelValueTier.ts": 1,
"packages/studio/src/components/editor/transform3dProjection.ts": 3,
"packages/studio/src/components/feedback/StudioFeedbackCard.tsx": 10,
"packages/studio/src/components/panels/VariablesBindElement.tsx": 6,
"packages/studio/src/components/panels/VariablesValueControls.tsx": 2,
"packages/studio/src/components/renders/useRenderQueue.ts": 1,
"packages/studio/src/components/sidebar/AudioRow.tsx": 3,
"packages/studio/src/components/storyboard/StoryboardFrameFocus.tsx": 2,
"packages/studio/src/components/ui/HyperframesMark.tsx": 4,
"packages/studio/src/contexts/variablePromoteHelpers.ts": 1,
"packages/studio/src/hooks/gsapResizeIntercept.ts": 1,
"packages/studio/src/hooks/gsapRuntimeBridge.ts": 2,
"packages/studio/src/hooks/gsapTweenSynth.ts": 1,
"packages/studio/src/hooks/useAnimatedPropertyCommit.ts": 1,
"packages/studio/src/hooks/useEditorSave.ts": 3,
"packages/studio/src/hooks/useInlineTextEdit.ts": 1,
"packages/studio/src/hooks/usePreviewInteraction.ts": 2,
"packages/studio/src/player/components/AudioWaveform.tsx": 6,
"packages/studio/src/player/components/BeatStrip.tsx": 6,
"packages/studio/src/player/components/CompositionThumbnail.tsx": 2,
"packages/studio/src/player/components/ImageThumbnail.tsx": 6,
"packages/studio/src/player/components/LayerDisclosureRow.tsx": 3,
"packages/studio/src/player/components/Player.tsx": 2,
"packages/studio/src/player/components/PlayerControls.tsx": 1,
"packages/studio/src/player/components/PlayheadIndicator.tsx": 3,
"packages/studio/src/player/components/ShortcutsPanel.tsx": 7,
"packages/studio/src/player/components/SpeedMenu.tsx": 2,
"packages/studio/src/player/components/TimelineAutomationLane.tsx": 4,
"packages/studio/src/player/components/TimelineCanvas.tsx": 15,
"packages/studio/src/player/components/TimelineClip.tsx": 2,
"packages/studio/src/player/components/TimelineClipDiamonds.tsx": 3,
"packages/studio/src/player/components/TimelineDiamondConnectors.tsx": 2,
"packages/studio/src/player/components/TimelineEmptyState.tsx": 1,
"packages/studio/src/player/components/TimelineFxButton.tsx": 8,
"packages/studio/src/player/components/TimelineGroupHeader.tsx": 6,
"packages/studio/src/player/components/TimelineGroupLaneLabels.tsx": 1,
"packages/studio/src/player/components/TimelineGroupRow.tsx": 1,
"packages/studio/src/player/components/TimelineRazorInteraction.tsx": 1,
"packages/studio/src/player/components/TimelineRuler.tsx": 2,
"packages/studio/src/player/components/TimelineShortcutHint.tsx": 2,
"packages/studio/src/player/components/TimelineTrackHeader.tsx": 2,
"packages/studio/src/player/components/TimelineTrackPlainHeader.tsx": 2,
"packages/studio/src/player/components/VideoThumbnail.tsx": 6,
"packages/studio/src/player/components/timelineCollision.ts": 2,
"packages/studio/src/player/components/timelineIcons.tsx": 1,
"packages/studio/src/player/components/timelineStackingSync.ts": 2,
"packages/studio/src/player/components/timelineTheme.ts": 31,
"packages/studio/src/player/components/trackHeaderLabelRows.tsx": 6,
"packages/studio/src/player/components/useAutomationEdgeStretch.ts": 1,
"packages/studio/src/player/components/useAutomationLaneGestures.ts": 1,
"packages/studio/src/player/store/editingModeSlice.ts": 1,
"packages/studio/src/styles/studio.css": 71,
"packages/studio/src/telemetry/client.ts": 1,
"packages/studio/src/utils/sdkResolverShadow.ts": 1,
"packages/studio/src/utils/studioFileVersion.ts": 3,
"packages/studio/src/webmcp/webmcpTestUtils.ts": 1
}
}
@@ -0,0 +1,193 @@
/**
* The hex ratchet (R12, AE2).
*
* Every colour Studio draws should come from `theme.css`. Getting there is a
* folder-by-folder sweep, so the rule cannot be "none" yet. It is "no more
* than yesterday": a per-file baseline that only ever goes down.
*
* Per file, not per repository, on purpose. Two sweep PRs that touch the same
* file conflict on that file's line, which is exactly when a recount is worth
* doing; a single total would merge cleanly and be wrong.
*
* The baseline is never regenerated as a side effect of a normal run. A
* missing one fails loudly, and rewriting it takes the named flag, so the
* number in git is always one a human chose to accept.
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { listSourceFiles, REPO_ROOT, STYLES_DIR } from "./styleSources";
const BASELINE_PATH = path.join(STYLES_DIR, "hex-baseline.json");
const WRITE_FLAG = "HEX_BASELINE_WRITE";
const LOWER_COMMAND = `${WRITE_FLAG}=1 bunx vitest run src/styles/hexRatchet.test.ts`;
/**
* A literal colour: `#abc`, `#aabbcc`, `#aabbccdd`, or a functional notation.
* The test owns this regex, so the baseline is whatever this rule sees and
* two different regexes cannot disagree about the count.
*/
const COLOR_LITERAL = /#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})\b|\b(?:rgba?|hsla?)\(/gi;
/** Files whose whole job is to hold colour values. */
function isTokenSource(relative: string): boolean {
return /styles\/(theme\.css|tailwind-preset\.shared\.js|tailwind-preset\.ts)$/.test(relative);
}
function isScanned(relative: string): boolean {
if (isTokenSource(relative) || /\.test\.tsx?$/.test(relative)) return false;
return /\.(tsx?|css)$/.test(relative);
}
function countColorLiterals(text: string): number {
return [...text.matchAll(COLOR_LITERAL)].length;
}
/** Repository-relative path to colour-literal count, for every scanned file. */
function scan(): Map<string, number> {
const counts = new Map<string, number>();
for (const [file, text] of listSourceFiles(isScanned, REPO_ROOT)) {
const count = countColorLiterals(text);
if (count > 0) counts.set(file, count);
}
return counts;
}
interface Baseline {
readonly total: number;
readonly files: Readonly<Record<string, number>>;
}
function toBaseline(counts: ReadonlyMap<string, number>): Baseline {
const files: Record<string, number> = {};
let total = 0;
for (const key of [...counts.keys()].sort()) {
files[key] = counts.get(key) ?? 0;
total += files[key];
}
return { total, files };
}
/** Every file whose count moved, split by direction. */
function compare(counts: ReadonlyMap<string, number>, baseline: Baseline) {
const risen: string[] = [];
const fallen: string[] = [];
// A file missing from the baseline has a baseline of zero, so a rename
// cannot smuggle its colours past the ratchet.
for (const [file, count] of counts) {
const allowed = baseline.files[file] ?? 0;
if (count > allowed) risen.push(`${file}: ${count} colour literals, baseline ${allowed}`);
if (count < allowed) fallen.push(`${file}: ${count}, baseline ${allowed}`);
}
for (const [file, allowed] of Object.entries(baseline.files)) {
if (!counts.has(file)) fallen.push(`${file}: now 0, baseline ${allowed}`);
}
return { risen, fallen };
}
/**
* What the author has to hear. A rise is a failure. A fall is fine, and is
* reported with the command that banks it, because a baseline left high is a
* budget the next change can spend.
*/
function verdict(counts: ReadonlyMap<string, number>, baseline: Baseline | undefined): string[] {
if (baseline === undefined) {
return [`src/styles/hex-baseline.json is missing. Write it with: ${LOWER_COMMAND}`];
}
const { risen, fallen } = compare(counts, baseline);
if (risen.length > 0) {
return [
"Colour literals belong in src/styles/theme.css.",
...risen,
`If these are unavoidable, lower is the only direction: ${LOWER_COMMAND}`,
];
}
if (fallen.length > 0) return [`Colour literals went down. Bank it: ${LOWER_COMMAND}`, ...fallen];
return [];
}
function readBaseline(): Baseline | undefined {
if (!existsSync(BASELINE_PATH)) return undefined;
return JSON.parse(readFileSync(BASELINE_PATH, "utf8")) as Baseline;
}
describe("colour literal counter", () => {
it("counts a hex in a style object, in canvas code and in a stylesheet", () => {
expect(countColorLiterals(`<div style={{ background: "#161618" }} />`)).toBe(1);
expect(countColorLiterals(`context.fillStyle = "#090b0e";`)).toBe(1);
expect(countColorLiterals(`.ring { box-shadow: 0 0 0 1px rgb(110 231 183); }`)).toBe(1);
expect(countColorLiterals(`a { color: rgba(0, 0, 0, 0.5); border-color: hsl(0 0% 4%); }`)).toBe(
2,
);
});
it("does not mistake a fragment or an issue number for a colour", () => {
// Known limit: an identifier that is exactly 3, 4, 6 or 8 hex characters
// reads as a colour. The baseline absorbs it; the count only has to be
// stable and monotone, not semantically perfect.
expect(countColorLiterals(`href="#section-two" // see #12345`)).toBe(0);
});
it("leaves the token sources and the tests out of the scan", () => {
expect(isScanned("packages/studio/src/styles/theme.css")).toBe(false);
expect(isScanned("packages/studio/src/styles/tailwind-preset.shared.js")).toBe(false);
expect(isScanned("packages/studio/src/styles/theme.test.ts")).toBe(false);
expect(isScanned("packages/studio/src/components/ui/Button.tsx")).toBe(true);
});
});
describe("hex ratchet", () => {
const baseline: Baseline = { total: 2, files: { "a.tsx": 2 } };
it("fails when a file's count rises, naming the file and both numbers", () => {
// Covers AE2.
const message = verdict(new Map([["a.tsx", 3]]), baseline).join("\n");
expect(message).toContain("a.tsx: 3 colour literals, baseline 2");
expect(message).toContain(LOWER_COMMAND);
});
it("passes when a count falls and says how to bank it", () => {
const message = verdict(new Map([["a.tsx", 1]]), baseline).join("\n");
expect(message).toContain("Colour literals went down");
expect(message).toContain(LOWER_COMMAND);
});
it("treats a file the baseline has never seen as a baseline of zero", () => {
const empty: Baseline = { total: 0, files: {} };
expect(verdict(new Map([["new.tsx", 0]]), empty)).toEqual([]);
expect(verdict(new Map([["new.tsx", 1]]), empty).join("\n")).toContain(
"new.tsx: 1 colour literals, baseline 0",
);
});
it("round-trips: a baseline written from a scan accepts that same scan", () => {
const counts = new Map([
["b.css", 4],
["a.tsx", 2],
]);
const written = toBaseline(counts);
expect(Object.keys(written.files)).toEqual(["a.tsx", "b.css"]);
expect(written.total).toBe(6);
expect(verdict(counts, written)).toEqual([]);
});
it("fails with the flag named when there is no baseline at all", () => {
expect(verdict(new Map([["a.tsx", 0]]), undefined).join("\n")).toContain(`${WRITE_FLAG}=1`);
});
it("holds Studio at or below its committed colour-literal baseline", () => {
const counts = scan();
if (process.env[WRITE_FLAG] === "1") {
writeFileSync(BASELINE_PATH, `${JSON.stringify(toBaseline(counts), null, 2)}\n`);
}
const committed = readBaseline();
expect(verdict(counts, committed)).toEqual([]);
});
});
@@ -0,0 +1,50 @@
/**
* Where the style tests read from.
*
* Three tests in this folder compile Studio's stylesheet or walk Studio's
* sources, and each needs the same two things: Tailwind's own entry file
* resolved from `node_modules`, and the file list that `@source` in
* `studio.css` describes. Kept here so the gate, the ratchet and the theme
* test cannot disagree about which files are Studio's.
*/
import { createRequire } from "node:module";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const STYLES_DIR = path.dirname(fileURLToPath(import.meta.url));
const SRC_DIR = path.dirname(STYLES_DIR);
export const REPO_ROOT = path.resolve(SRC_DIR, "../../..");
const require = createRequire(import.meta.url);
export const TAILWIND_DIR = path.dirname(require.resolve("tailwindcss/package.json"));
/** Resolves `@import` for Tailwind's compiler: bare `tailwindcss`, or a path. */
export function loadStylesheet(id: string, base: string) {
const file = id === "tailwindcss" ? path.join(TAILWIND_DIR, "index.css") : path.resolve(base, id);
return { path: file, base: path.dirname(file), content: readFileSync(file, "utf8") };
}
/**
* Studio's own sources, keyed by path relative to `from`, for every file the
* caller keeps. Build output and dependencies are never walked.
*/
export function listSourceFiles(
keep: (relativePath: string) => boolean,
from: string = SRC_DIR,
): Map<string, string> {
const files = new Map<string, string>();
const walk = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name !== "node_modules" && entry.name !== "dist") walk(full);
} else if (keep(path.relative(from, full))) {
files.set(path.relative(from, full), readFileSync(full, "utf8"));
}
}
};
walk(SRC_DIR);
return files;
}
+32 -5
View File
@@ -62,6 +62,10 @@
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
/* The one radius every push-button wears, named by its role so a button does
* not have to know which step of the scale it sits on. */
--radius-button: var(--radius-md);
/* Control heights: 24, 28 and 32 px. `--spacing-*` gives `h-ctl`, `min-h-ctl`
* and friends. */
--spacing-ctl-sm: 24px;
@@ -93,10 +97,14 @@
/* Floating chrome over a near-black canvas: the hairline ring does the edge
* definition that a light-mode drop shadow cannot. */
--shadow-menu:
0 8px 24px rgb(0 0 0 / 0.45), 0 0 0 1px rgb(0 0 0 / 0.4);
--shadow-popover:
0 16px 40px rgb(0 0 0 / 0.55), 0 0 0 1px rgb(0 0 0 / 0.45);
--shadow-menu: 0 8px 24px rgb(0 0 0 / 0.45), 0 0 0 1px rgb(0 0 0 / 0.4);
--shadow-popover: 0 16px 40px rgb(0 0 0 / 0.55), 0 0 0 1px rgb(0 0 0 / 0.45);
/* The primary button is the one light surface in a dark UI, so it reads as a
* raised key rather than a floating panel: a tight contact shadow, a soft
* ambient one, and an inner top highlight. */
--shadow-btn-primary:
0 1px 2px rgb(0 0 0 / 0.6), 0 2px 6px rgb(0 0 0 / 0.35), inset 0 1px 0 rgb(255 255 255 / 0.65);
/* ---------------------------------------------------------------- motion */
@@ -104,6 +112,11 @@
--ease-out-expo: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out-circ: cubic-bezier(0.77, 0, 0.175, 1);
/* The curve for a small state change (hover, press, focus), named by role so
* a control does not have to pick one. The other two stay for motion that is
* meant to be noticed. */
--ease-standard: var(--ease-out-quint);
/* Durations have no Tailwind namespace, so these are plain variables paired
* with the `@utility` rules at the bottom of this file. All are at or under
* 200 ms. */
@@ -124,7 +137,11 @@
/* The `studio.*` and `panel.*` colour scales, kept so existing markup keeps
* working while the sweep runs. They alias the semantic tokens above and
* carry no value of their own. Removed from Studio's source by U12; the
* published JS preset keeps them one more major for v3 consumers. */
* published JS preset keeps them one more major for v3 consumers.
*
* `panel-bg-soft` and `panel-bg-2` never existed in the v3 preset either:
* the token gate found the markup asking for backgrounds no config has ever
* supplied, so those rows were rendering with no background at all. */
--color-studio-bg: var(--color-bg-0);
--color-studio-surface: var(--color-surface-alt);
--color-studio-border: var(--color-border-strong);
@@ -145,11 +162,21 @@
--color-panel-text-3: var(--color-text-3);
--color-panel-text-4: var(--color-text-4);
--color-panel-text-5: var(--color-text-5);
--color-panel-bg-soft: var(--color-bg-2);
--color-panel-bg-2: var(--color-bg-2);
--color-panel-accent: var(--color-accent);
--color-panel-danger: var(--color-danger);
--color-panel-media: var(--color-media);
--color-panel-container: var(--color-container);
/* Names Studio's markup invented that no config ever defined, so the classes
* compiled to nothing. Aliased to the token that was meant, rather than
* given values of their own, so there is still one source per decision.
* U12 rewrites the call sites and deletes these. */
--color-surface-hover: var(--color-hover);
--color-accent-red: var(--color-danger);
--text-2xs: var(--text-step-10);
/* ------------------------------------------- deprecated: Tailwind palette */
/* Verbatim copies of the Tailwind 4.3.3 defaults for the stock colours
+1 -11
View File
@@ -16,10 +16,8 @@
* cover the `@import` wiring in `studio.css` as well as `theme.css` itself.
*/
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { Gear, IconContext } from "@phosphor-icons/react";
@@ -27,18 +25,10 @@ import { compile } from "tailwindcss";
import { afterEach, describe, expect, it } from "vitest";
import studioPreset from "./tailwind-preset.shared.js";
import { readIconTokens } from "./iconTokens";
import { loadStylesheet, STYLES_DIR, TAILWIND_DIR } from "./styleSources";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const STYLES_DIR = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const TAILWIND_DIR = path.dirname(require.resolve("tailwindcss/package.json"));
function loadStylesheet(id: string, base: string) {
const file = id === "tailwindcss" ? path.join(TAILWIND_DIR, "index.css") : path.resolve(base, id);
return { path: file, base: path.dirname(file), content: readFileSync(file, "utf8") };
}
async function build(entry: string, candidates: string[]): Promise<string> {
const compiled = await compile(readFileSync(path.join(STYLES_DIR, entry), "utf8"), {
base: STYLES_DIR,
@@ -0,0 +1,132 @@
/**
* The token gate (R11, AE1).
*
* Tailwind has no strict mode for the classes it finds in markup: a class it
* cannot compile is silently dropped, so `rounded-button` renders as no radius
* at all and nothing anywhere goes red. This test closes that hole by asking
* Tailwind itself. Studio's real entry stylesheet is compiled with every class
* the source claims as the candidate list, and any candidate that produces no
* selector is reported as `file: class`.
*
* Tailwind is the only judge, so there is no allowlist to maintain and nothing
* to keep in step: static utilities, arbitrary values, tokens from `theme.css`
* and Studio's own hand-written CSS rules all appear in the emitted sheet.
*
* A class that fails here is fixed by adding the token to `theme.css`, or by
* changing the markup. It is never excused.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { compile } from "tailwindcss";
import { describe, expect, it } from "vitest";
import { extractClassCandidates } from "./classCandidates";
import { listSourceFiles, loadStylesheet, STYLES_DIR } from "./styleSources";
/**
* A class selector in the emitted CSS, with Tailwind's escapes removed, so
* `.hover\:bg-surface\/50` reads back as the candidate that produced it.
*/
const CLASS_SELECTOR = /\.((?:\\.|[^\s.,{}()>+~:[\]#'"*/\\])+)/g;
/**
* Tailwind's two variant markers. They are written in markup and carry a
* variant name (`group/card`), but they produce no rule of their own, so no
* compiled selector can ever vouch for them. Nothing else is exempt.
*/
const MARKERS = new Set(["group", "peer"]);
/**
* `hf-` is HyperFrames' reserved prefix for its own semantic hooks. Those are
* not utilities and carry no design value, so Tailwind is the wrong judge of
* them: the gate covers Tailwind's namespace, and this one is Studio's.
*/
const HOOK_PREFIX = /^hf-/;
/** Compile Studio's stylesheet and return every class it can produce. */
async function resolvable(candidates: string[]): Promise<Set<string>> {
const compiled = await compile(readFileSync(path.join(STYLES_DIR, "studio.css"), "utf8"), {
base: STYLES_DIR,
loadStylesheet,
});
const css = compiled.build(candidates);
const selectors = new Set<string>();
for (const [, selector] of css.matchAll(CLASS_SELECTOR)) {
selectors.add(selector.replace(/\\(.)/g, "$1"));
}
return selectors;
}
/**
* `file: class` for every class the sources claim that Tailwind cannot make.
* Sources are passed in so the fixtures below exercise the same code path the
* tree does.
*/
async function unresolved(sources: ReadonlyMap<string, string>): Promise<string[]> {
const claims = new Map<string, string[]>();
for (const [file, source] of sources) {
for (const candidate of extractClassCandidates(source)) {
const seen = claims.get(candidate.base);
if (seen) seen.push(file);
else claims.set(candidate.base, [file]);
}
}
const produced = await resolvable([...claims.keys()]);
const failures: string[] = [];
for (const [candidate, files] of claims) {
if (produced.has(candidate) || MARKERS.has(candidate.split("/")[0])) continue;
if (HOOK_PREFIX.test(candidate)) continue;
for (const file of files) failures.push(`${file}: ${candidate}`);
}
return failures.sort();
}
/**
* Every file the gate reads. This mirrors the `@source` globs in `studio.css`,
* so a class the gate accepts is a class Tailwind was given the chance to see.
* Tests are excluded: a fixture in a test is not markup.
*/
function studioSources(): Map<string, string> {
return listSourceFiles((file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file));
}
describe("token gate", () => {
it("names the file and the class when a class resolves to nothing", async () => {
// Covers AE1. `rounded-hologram` is shaped exactly like a token name and
// is defined nowhere, which is the whole failure mode: it reads as real in
// review and renders as nothing at runtime.
const failures = await unresolved(
new Map([["ui/Button.tsx", `const sizeStyles = { md: "h-8 rounded-hologram text-mega" };`]]),
);
expect(failures).toEqual(["ui/Button.tsx: rounded-hologram", "ui/Button.tsx: text-mega"]);
});
it("accepts an arbitrary value and reports it as one", async () => {
const source = `<div className="text-[11px]" />`;
expect(await unresolved(new Map([["a.tsx", source]]))).toEqual([]);
expect(extractClassCandidates(source).filter((c) => c.arbitrary)).toHaveLength(1);
});
it("accepts variants, static utilities and Studio's own CSS classes", async () => {
// `timeline-clip` is a plain rule in `studio.css`, not a utility.
const source = `<div className="hover:bg-surface/50 text-center border-dashed timeline-clip" />`;
expect(await unresolved(new Map([["a.tsx", source]]))).toEqual([]);
});
it("leaves Studio's own hook prefix and Tailwind's variant markers alone", async () => {
const source = `<div className="group group/card peer hf-fx-node" />`;
expect(await unresolved(new Map([["a.tsx", source]]))).toEqual([]);
});
it("claims nothing for a template chunk that touches an interpolation", async () => {
expect(await unresolved(new Map([["a.tsx", "<div className={`w-${i}`} />"]]))).toEqual([]);
});
it("resolves every class Studio's own source claims", async () => {
expect(await unresolved(studioSources())).toEqual([]);
});
});