mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-13 20:58:33 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user