diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx
index b8279c3823..696746aff1 100644
--- a/packages/studio/src/player/components/TimelineClip.tsx
+++ b/packages/studio/src/player/components/TimelineClip.tsx
@@ -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)
diff --git a/packages/studio/src/styles/classCandidates.test.ts b/packages/studio/src/styles/classCandidates.test.ts
new file mode 100644
index 0000000000..9a7ae612e9
--- /dev/null
+++ b/packages/studio/src/styles/classCandidates.test.ts
@@ -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(`
`)).toEqual([
+ "border-dashed",
+ "text-center",
+ ]);
+ });
+
+ it("reads every string a class-building call is given", () => {
+ const source = ``;
+
+ 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 = {
+ 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("")).toEqual(["flex"]);
+ });
+
+ it("keeps a chunk that whitespace separates from the interpolation", () => {
+ expect(bases("")).toEqual(["flex"]);
+ });
+
+ it("strips variant prefixes, including bracketed ones", () => {
+ const source = ``;
+
+ expect(bases(source)).toEqual(["bg-surface/50", "text-accent"]);
+ });
+
+ it("flags bracketed arbitrary values so the sweep can count them", () => {
+ const found = extractClassCandidates(``);
+
+ 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 = ``;
+
+ expect(bases(source)).toEqual(["flex"]);
+ });
+
+ it("survives an unterminated region without hanging", () => {
+ expect(bases(` = { 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
= { "{": "}", "(": ")", "[": "]" };
+
+/**
+ * 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 = { ... } / 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, 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();
+ 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()];
+}
diff --git a/packages/studio/src/styles/hex-baseline.json b/packages/studio/src/styles/hex-baseline.json
new file mode 100644
index 0000000000..ff62d59b26
--- /dev/null
+++ b/packages/studio/src/styles/hex-baseline.json
@@ -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
+ }
+}
diff --git a/packages/studio/src/styles/hexRatchet.test.ts b/packages/studio/src/styles/hexRatchet.test.ts
new file mode 100644
index 0000000000..53d3c66986
--- /dev/null
+++ b/packages/studio/src/styles/hexRatchet.test.ts
@@ -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 {
+ const counts = new Map();
+ 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>;
+}
+
+function toBaseline(counts: ReadonlyMap): Baseline {
+ const files: Record = {};
+ 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, 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, 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(``)).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([]);
+ });
+});
diff --git a/packages/studio/src/styles/styleSources.ts b/packages/studio/src/styles/styleSources.ts
new file mode 100644
index 0000000000..7b898fb007
--- /dev/null
+++ b/packages/studio/src/styles/styleSources.ts
@@ -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 {
+ const files = new Map();
+ 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;
+}
diff --git a/packages/studio/src/styles/theme.css b/packages/studio/src/styles/theme.css
index f9d27ff393..952fac349d 100644
--- a/packages/studio/src/styles/theme.css
+++ b/packages/studio/src/styles/theme.css
@@ -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
diff --git a/packages/studio/src/styles/theme.test.ts b/packages/studio/src/styles/theme.test.ts
index c4c91617d6..cabc5339da 100644
--- a/packages/studio/src/styles/theme.test.ts
+++ b/packages/studio/src/styles/theme.test.ts
@@ -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 {
const compiled = await compile(readFileSync(path.join(STYLES_DIR, entry), "utf8"), {
base: STYLES_DIR,
diff --git a/packages/studio/src/styles/tokenGate.test.ts b/packages/studio/src/styles/tokenGate.test.ts
new file mode 100644
index 0000000000..c109239389
--- /dev/null
+++ b/packages/studio/src/styles/tokenGate.test.ts
@@ -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> {
+ 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();
+ 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): Promise {
+ const claims = new Map();
+ 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 {
+ 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 = ``;
+
+ 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 = ``;
+
+ 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 = ``;
+
+ 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", ""]]))).toEqual([]);
+ });
+
+ it("resolves every class Studio's own source claims", async () => {
+ expect(await unresolved(studioSources())).toEqual([]);
+ });
+});