From 739ffc35ce3a5a0830ea05fa2176420082f33334 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 4 Sep 2026 00:35:55 -0400 Subject: [PATCH] feat(studio): move every design value into one theme file Studio's design values lived in three places: a JS Tailwind preset, an inline @theme block, and raw hex and pixel literals in markup and CSS. theme.css is now the single owner. Tailwind v4 turns it into both the utility classes and the CSS custom properties, so markup, plain CSS and canvas code all read the same number. - semantic colour palette (background levels, surfaces, borders, text levels, accent, selection, playhead, danger, media, container), radii, control heights, the micro type scale, font stacks, shadows, easings and motion durations - the stock Tailwind palette is cleared with --color-*: initial, so a colour that is not a Studio token cannot be reached from markup; the entries Studio's markup still names are re-declared as deprecated aliases and removed once the sweep converts them - motion durations ship as @utility rules that zero themselves under prefers-reduced-motion, since Tailwind has no duration namespace - Phosphor icons get one default size and weight, read off the theme at the app root; icons that pass their own still win - published as @hyperframes/studio/theme.css; the JS preset export stays as a deprecated shim for v3 consumers, with a test that fails if the two drift apart --- packages/studio/package-subpaths.json | 6 + packages/studio/package.json | 2 + packages/studio/src/main.tsx | 12 +- packages/studio/src/styles/iconTokens.ts | 27 ++ packages/studio/src/styles/studio.css | 54 +--- .../src/styles/tailwind-preset.shared.js | 10 + packages/studio/src/styles/theme.css | 262 ++++++++++++++++++ packages/studio/src/styles/theme.test.ts | 195 +++++++++++++ 8 files changed, 520 insertions(+), 48 deletions(-) create mode 100644 packages/studio/src/styles/iconTokens.ts create mode 100644 packages/studio/src/styles/theme.css create mode 100644 packages/studio/src/styles/theme.test.ts diff --git a/packages/studio/package-subpaths.json b/packages/studio/package-subpaths.json index 50995872be..b31ed8a89b 100644 --- a/packages/studio/package-subpaths.json +++ b/packages/studio/package-subpaths.json @@ -13,6 +13,12 @@ "types": "./dist/styles/tailwind-preset.d.ts", "environments": ["bun", "node"] }, + "./theme.css": { + "source": "./src/styles/theme.css", + "runtime": "./src/styles/theme.css", + "types": null, + "environments": ["browser", "bun", "node"] + }, "./package.json": { "source": "./package.json", "runtime": "./package.json", diff --git a/packages/studio/package.json b/packages/studio/package.json index d37cf38ae4..7bd7ea4f79 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -26,6 +26,7 @@ "import": "./src/styles/tailwind-preset.ts", "types": "./src/styles/tailwind-preset.ts" }, + "./theme.css": "./src/styles/theme.css", "./package.json": "./package.json" }, "publishConfig": { @@ -39,6 +40,7 @@ "import": "./dist/styles/tailwind-preset.js", "types": "./dist/styles/tailwind-preset.d.ts" }, + "./theme.css": "./src/styles/theme.css", "./package.json": "./package.json" }, "main": "./dist/index.js", diff --git a/packages/studio/src/main.tsx b/packages/studio/src/main.tsx index 4bf0587430..9d69aacb66 100644 --- a/packages/studio/src/main.tsx +++ b/packages/studio/src/main.tsx @@ -1,7 +1,9 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { IconContext } from "@phosphor-icons/react"; import { StudioApp } from "./App"; import { StudioErrorBoundary } from "./components/StudioErrorBoundary"; +import { readIconTokens } from "./styles/iconTokens"; import { trackStudioEvent } from "./utils/studioTelemetry"; import "./styles/studio.css"; @@ -93,8 +95,12 @@ window.addEventListener("unhandledrejection", (event) => { createRoot(document.getElementById("root")!).render( - - - + {/* One icon size and weight for the whole app, taken from the theme file. + Icons that pass their own size or weight still win. */} + + + + + , ); diff --git a/packages/studio/src/styles/iconTokens.ts b/packages/studio/src/styles/iconTokens.ts new file mode 100644 index 0000000000..aaf29ae6fd --- /dev/null +++ b/packages/studio/src/styles/iconTokens.ts @@ -0,0 +1,27 @@ +import type { IconWeight } from "@phosphor-icons/react"; + +const WEIGHTS = ["thin", "light", "regular", "bold", "fill", "duotone"] as const; + +function asWeight(value: string): IconWeight | null { + return (WEIGHTS as readonly string[]).includes(value) ? (value as IconWeight) : null; +} + +/** + * Studio's Phosphor defaults, read off `theme.css` so the icon size and weight + * have one owner instead of a CSS variable and a JavaScript constant that drift + * apart. Both are SVG attributes, not styles, so `var()` cannot be handed to + * Phosphor directly and the values are resolved once at the app root. + * + * A missing or unrecognized value means the stylesheet has not loaded (or the + * token was renamed); Phosphor's own defaults are used rather than a second + * copy of the token values. + */ +export function readIconTokens(root: Element = document.documentElement): { + size: string; + weight: IconWeight; +} { + const styles = getComputedStyle(root); + const size = styles.getPropertyValue("--icon-size").trim(); + const weight = asWeight(styles.getPropertyValue("--icon-weight").trim()); + return { size: size || "1em", weight: weight ?? "regular" }; +} diff --git a/packages/studio/src/styles/studio.css b/packages/studio/src/styles/studio.css index 8244921774..0b24c2ce39 100644 --- a/packages/studio/src/styles/studio.css +++ b/packages/studio/src/styles/studio.css @@ -1,4 +1,5 @@ @import "tailwindcss" source(none); +@import "./theme.css"; /* * Explicit sources: Studio's own TypeScript and its shell HTML. Automatic @@ -8,33 +9,6 @@ @source "../**/*.{ts,tsx}"; @source "../../index.html"; -@theme { - --color-studio-bg: #0a0a0a; - --color-studio-surface: #141414; - --color-studio-border: #262626; - --color-studio-text: #e5e5e5; - --color-studio-muted: #737373; - --color-studio-accent: #3ce6ac; - - --color-panel-bg: #0c0c0e; - --color-panel-bg-inset: #121214; - --color-panel-input: #161618; - --color-panel-surface: #18181b; - --color-panel-hover: #27272a; - --color-panel-border: #1e1e1e; - --color-panel-border-input: #27272a; - --color-panel-hairline: #1a1a1c; - --color-panel-text-0: #fafafa; - --color-panel-text-1: #e4e4e7; - --color-panel-text-2: #a1a1aa; - --color-panel-text-3: #71717a; - --color-panel-text-4: #52525b; - --color-panel-text-5: #3f3f46; - --color-panel-accent: #3ce6ac; - --color-panel-danger: #ef4444; - --color-panel-media: #00e3ff; - --color-panel-container: #f5a623; -} /* * Studio is a dark-only UI — pin the user-agent color scheme to dark so that @@ -48,9 +22,9 @@ body { margin: 0; padding: 0; - background: #0a0a0a; - color: #e5e5e5; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--color-bg-0); + color: var(--color-text-body); + font-family: var(--font-sans); overflow: hidden; } @@ -324,7 +298,7 @@ body { left: 12px; overflow: hidden; color: rgba(255, 255, 255, 0.34); - font-family: "SF Mono", "Fira Code", monospace; + font-family: var(--font-mono); font-size: 9px; font-variant-numeric: tabular-nums; line-height: 1; @@ -362,7 +336,7 @@ body { } .cm-editor .cm-scroller { - font-family: "JetBrains Mono", "Fira Code", "SF Mono", monospace; + font-family: var(--font-mono); } .cm-editor.cm-focused { @@ -419,12 +393,7 @@ body { } .hf-loader-title { - font-family: - Inter, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - sans-serif; + font-family: var(--font-sans); font-size: 1rem; font-weight: 600; letter-spacing: 0; @@ -440,12 +409,7 @@ body { min-height: 2.5rem; overflow: hidden; color: var(--hf-text-secondary, rgba(244, 244, 245, 0.68)); - font-family: - Inter, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - sans-serif; + font-family: var(--font-sans); font-size: 0.82rem; line-height: 1.6; } @@ -457,7 +421,7 @@ body { text-overflow: ellipsis; white-space: nowrap; color: var(--hf-text-tertiary, rgba(244, 244, 245, 0.46)); - font-family: "IBM Plex Mono", "SF Mono", "Fira Code", monospace; + font-family: var(--font-mono); font-size: 0.75rem; letter-spacing: 0; font-variant-numeric: tabular-nums; diff --git a/packages/studio/src/styles/tailwind-preset.shared.js b/packages/studio/src/styles/tailwind-preset.shared.js index 53cde734e4..333b22bcf6 100644 --- a/packages/studio/src/styles/tailwind-preset.shared.js +++ b/packages/studio/src/styles/tailwind-preset.shared.js @@ -1,3 +1,13 @@ +/** + * @deprecated Studio's tokens now live in `src/styles/theme.css`, published as + * `@hyperframes/studio/theme.css`. This preset is kept one more major so a + * Tailwind v3 consumer of `@hyperframes/studio/tailwind-preset` keeps building; + * it carries only the old `studio.*` and `panel.*` colour names and no new + * token ever lands here. + * + * The values below are byte-identical to the ones the theme file holds, and + * `theme.css` is the source of truth: `theme.test.ts` fails if the two drift. + */ const studioPreset = { theme: { extend: { diff --git a/packages/studio/src/styles/theme.css b/packages/studio/src/styles/theme.css new file mode 100644 index 0000000000..f9d27ff393 --- /dev/null +++ b/packages/studio/src/styles/theme.css @@ -0,0 +1,262 @@ +/* + * Studio's design tokens. This file is the only place a design value is + * decided: Tailwind turns every entry below into both a utility class and a + * real CSS custom property, so markup (`bg-accent`), plain CSS and canvas code + * (`var(--color-accent)`) all read the same number. + * + * `@theme static` keeps every variable in the emitted `:root` block. The + * default (tree-shaken) behavior would drop the tokens that no utility class + * happens to reference, and Studio reads several of them from CSS and from + * JavaScript rather than through a class. + * + * Published as `@hyperframes/studio/theme.css`. + */ + +@theme static { + /* Clear Tailwind's stock palette so a colour that is not a Studio token + * cannot be reached from markup. The entries Studio still uses are + * re-declared below as deprecated aliases. */ + --color-*: initial; + + /* ---------------------------------------------------------------- colour */ + + /* Background levels, darkest (app shell) to lightest (inset panel body). */ + --color-bg-0: #0a0a0a; + --color-bg-1: #0c0c0e; + --color-bg-2: #121214; + + /* Raised surfaces. */ + --color-surface: #18181b; + --color-surface-alt: #141414; + --color-input: #161618; + --color-hover: #27272a; + + /* Separators, weakest to strongest. */ + --color-hairline: #1a1a1c; + --color-border: #1e1e1e; + --color-border-input: #27272a; + --color-border-strong: #262626; + + /* Text, brightest (0) to dimmest (5). */ + --color-text-0: #fafafa; + --color-text-1: #e4e4e7; + --color-text-2: #a1a1aa; + --color-text-3: #71717a; + --color-text-4: #52525b; + --color-text-5: #3f3f46; + --color-text-body: #e5e5e5; + --color-text-muted: #737373; + + /* Roles. Selection and playhead are reserved and differ from the accent so a + * selected clip, the playhead and an accented control stay tellable apart. */ + --color-accent: #3ce6ac; + --color-selection: #4ba3d2; + --color-playhead: #ffffff; + --color-danger: #ef4444; + --color-media: #00e3ff; + --color-container: #f5a623; + + /* -------------------------------------------------------------- geometry */ + + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + + /* Control heights: 24, 28 and 32 px. `--spacing-*` gives `h-ctl`, `min-h-ctl` + * and friends. */ + --spacing-ctl-sm: 24px; + --spacing-ctl: 28px; + --spacing-ctl-lg: 32px; + + /* ------------------------------------------------------------------ type */ + + /* Studio's dense UI type sizes, named by their pixel value. `step` is in the + * key because `--text-11` would collide with Tailwind's own size scale; + * `text-xs`, `text-sm` and `text-lg` keep their Tailwind values. */ + --text-step-7: 7px; + --text-step-8: 8px; + --text-step-9: 9px; + --text-step-10: 10px; + --text-step-11: 11px; + --text-step-12: 12px; + --text-step-13: 13px; + --text-step-14: 14px; + --text-step-15: 15px; + --text-step-16: 16px; + --text-step-17: 17px; + --text-step-18: 18px; + + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-mono: "JetBrains Mono", "IBM Plex Mono", "SF Mono", "Fira Code", monospace; + + /* ---------------------------------------------------------------- shadow */ + + /* 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); + + /* ---------------------------------------------------------------- motion */ + + --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); + --ease-out-expo: cubic-bezier(0.23, 1, 0.32, 1); + --ease-in-out-circ: cubic-bezier(0.77, 0, 0.175, 1); + + /* 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. */ + --duration-focus: 100ms; + --duration-press: 120ms; + --duration-hover: 150ms; + --duration-expand: 160ms; + --duration-open: 180ms; + + /* ------------------------------------------------------------------ icon */ + + /* Read by `readIconTokens` and handed to Phosphor's `IconContext`. */ + --icon-size: 16px; + --icon-weight: regular; + + /* ------------------------------------------------- deprecated: old names */ + + /* 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. */ + --color-studio-bg: var(--color-bg-0); + --color-studio-surface: var(--color-surface-alt); + --color-studio-border: var(--color-border-strong); + --color-studio-text: var(--color-text-body); + --color-studio-muted: var(--color-text-muted); + --color-studio-accent: var(--color-accent); + --color-panel-bg: var(--color-bg-1); + --color-panel-bg-inset: var(--color-bg-2); + --color-panel-input: var(--color-input); + --color-panel-surface: var(--color-surface); + --color-panel-hover: var(--color-hover); + --color-panel-border: var(--color-border); + --color-panel-border-input: var(--color-border-input); + --color-panel-hairline: var(--color-hairline); + --color-panel-text-0: var(--color-text-0); + --color-panel-text-1: var(--color-text-1); + --color-panel-text-2: var(--color-text-2); + --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-accent: var(--color-accent); + --color-panel-danger: var(--color-danger); + --color-panel-media: var(--color-media); + --color-panel-container: var(--color-container); + + /* ------------------------------------------- deprecated: Tailwind palette */ + + /* Verbatim copies of the Tailwind 4.3.3 defaults for the stock colours + * Studio's markup still names. Without them `--color-*: initial` above would + * blank roughly 1500 class usages in one commit. The sweep replaces each with + * a semantic token and U12 deletes this block; `theme.test.ts` fails if one + * of these values drifts away from the Tailwind default it was copied from. */ + --color-amber-50: oklch(98.7% 0.022 95.277); + --color-amber-100: oklch(96.2% 0.059 95.617); + --color-amber-200: oklch(92.4% 0.12 95.746); + --color-amber-300: oklch(87.9% 0.169 91.605); + --color-amber-400: oklch(82.8% 0.189 84.429); + --color-amber-500: oklch(76.9% 0.188 70.08); + --color-amber-900: oklch(41.4% 0.112 45.904); + --color-amber-950: oklch(27.9% 0.077 45.635); + --color-black: #000; + --color-blue-400: oklch(70.7% 0.165 254.624); + --color-blue-500: oklch(62.3% 0.214 259.815); + --color-cyan-400: oklch(78.9% 0.154 211.53); + --color-cyan-500: oklch(71.5% 0.143 215.221); + --color-emerald-300: oklch(84.5% 0.143 164.978); + --color-emerald-400: oklch(76.5% 0.177 163.223); + --color-emerald-500: oklch(69.6% 0.17 162.48); + --color-emerald-600: oklch(59.6% 0.145 163.225); + --color-emerald-700: oklch(50.8% 0.118 165.612); + --color-green-400: oklch(79.2% 0.209 151.711); + --color-green-500: oklch(72.3% 0.219 149.579); + --color-green-600: oklch(62.7% 0.194 149.214); + --color-neutral-50: oklch(98.5% 0 none); + --color-neutral-100: oklch(97% 0 none); + --color-neutral-200: oklch(92.2% 0 none); + --color-neutral-300: oklch(87% 0 none); + --color-neutral-400: oklch(70.8% 0 none); + --color-neutral-500: oklch(55.6% 0 none); + --color-neutral-600: oklch(43.9% 0 none); + --color-neutral-700: oklch(37.1% 0 none); + --color-neutral-800: oklch(26.9% 0 none); + --color-neutral-900: oklch(20.5% 0 none); + --color-neutral-950: oklch(14.5% 0 none); + --color-orange-300: oklch(83.7% 0.128 66.29); + --color-orange-400: oklch(75% 0.183 55.934); + --color-pink-400: oklch(71.8% 0.202 349.761); + --color-pink-500: oklch(65.6% 0.241 354.308); + --color-purple-200: oklch(90.2% 0.063 306.703); + --color-purple-300: oklch(82.7% 0.119 306.383); + --color-purple-400: oklch(71.4% 0.203 305.504); + --color-purple-500: oklch(62.7% 0.265 303.9); + --color-purple-900: oklch(38.1% 0.176 304.987); + --color-red-100: oklch(93.6% 0.032 17.717); + --color-red-200: oklch(88.5% 0.062 18.334); + --color-red-300: oklch(80.8% 0.114 19.571); + --color-red-400: oklch(70.4% 0.191 22.216); + --color-red-500: oklch(63.7% 0.237 25.331); + --color-red-600: oklch(57.7% 0.245 27.325); + --color-red-700: oklch(50.5% 0.213 27.518); + --color-red-800: oklch(44.4% 0.177 26.899); + --color-red-900: oklch(39.6% 0.141 25.723); + --color-red-950: oklch(25.8% 0.092 26.042); + --color-rose-400: oklch(71.2% 0.194 13.428); + --color-rose-500: oklch(64.5% 0.246 16.439); + --color-sky-200: oklch(90.1% 0.058 230.902); + --color-sky-300: oklch(82.8% 0.111 230.318); + --color-sky-400: oklch(74.6% 0.16 232.661); + --color-sky-500: oklch(68.5% 0.169 237.323); + --color-sky-700: oklch(50% 0.134 242.749); + --color-sky-900: oklch(39.1% 0.09 240.876); + --color-sky-950: oklch(29.3% 0.066 243.157); + --color-violet-400: oklch(70.2% 0.183 293.541); + --color-violet-500: oklch(60.6% 0.25 292.717); + --color-white: #fff; +} + +/* Motion durations as utilities, since Tailwind has no `--duration-*` + * namespace. Each one zeroes itself under `prefers-reduced-motion`, so a + * caller cannot forget the reduced-motion case. */ + +@utility duration-focus { + transition-duration: var(--duration-focus); + @media (prefers-reduced-motion: reduce) { + transition-duration: 0ms; + } +} + +@utility duration-press { + transition-duration: var(--duration-press); + @media (prefers-reduced-motion: reduce) { + transition-duration: 0ms; + } +} + +@utility duration-hover { + transition-duration: var(--duration-hover); + @media (prefers-reduced-motion: reduce) { + transition-duration: 0ms; + } +} + +@utility duration-expand { + transition-duration: var(--duration-expand); + @media (prefers-reduced-motion: reduce) { + transition-duration: 0ms; + } +} + +@utility duration-open { + transition-duration: var(--duration-open); + @media (prefers-reduced-motion: reduce) { + transition-duration: 0ms; + } +} diff --git a/packages/studio/src/styles/theme.test.ts b/packages/studio/src/styles/theme.test.ts new file mode 100644 index 0000000000..c4c91617d6 --- /dev/null +++ b/packages/studio/src/styles/theme.test.ts @@ -0,0 +1,195 @@ +// @vitest-environment happy-dom + +/** + * The theme file is the single source of every design value in Studio, so the + * things that can silently break it are checked here rather than by eye: + * + * - a token that stops being emitted as a CSS custom property (direct CSS and + * canvas code read `var(--color-accent)`, not a utility class), + * - a utility that stops compiling, + * - the default Tailwind palette leaking back in, + * - the published JS preset drifting away from the CSS it shadows, + * - the legacy palette entries Studio's markup still uses drifting away from + * the Tailwind defaults they were copied from. + * + * Studio's real entry stylesheet is compiled, not a fixture, so the assertions + * 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"; +import { compile } from "tailwindcss"; +import { afterEach, describe, expect, it } from "vitest"; +import studioPreset from "./tailwind-preset.shared.js"; +import { readIconTokens } from "./iconTokens"; + +(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, + loadStylesheet, + }); + return compiled.build(candidates); +} + +function rootVariables(css: string): Map { + const block = css.match(/:root, :host \{([\s\S]*?)\n {2}\}/); + const vars = new Map(); + for (const line of (block?.[1] ?? "").split(";")) { + const match = line.match(/(--[a-z0-9-]+):([\s\S]+)/i); + if (match) vars.set(match[1], match[2].trim().replace(/\s+/g, " ")); + } + return vars; +} + +const themeSource = readFileSync(path.join(STYLES_DIR, "theme.css"), "utf8"); + +function declaredValue(name: string): string { + const match = themeSource.match(new RegExp(`\\n\\s*${name}:\\s*([^;]+);`)); + if (!match) throw new Error(`theme.css does not declare ${name}`); + return match[1].trim(); +} + +afterEach(() => { + document.body.innerHTML = ""; + document.documentElement.removeAttribute("style"); +}); + +describe("studio theme", () => { + it("emits the semantic palette as custom properties and as utilities", async () => { + const css = await build("studio.css", ["bg-accent", "text-text-2", "border-border"]); + + expect(rootVariables(css).get("--color-accent")).toBe("#3ce6ac"); + expect(css).toContain(".bg-accent {"); + expect(css).toContain("background-color: var(--color-accent)"); + }); + + it("resolves an alpha modifier on a plain hex token through color-mix", async () => { + const css = await build("studio.css", ["bg-accent/30"]); + + expect(css).toMatch(/\.bg-accent\\\/30 \{[\s\S]*?color-mix\(/); + }); + + it("drops every Tailwind default color Studio does not use", async () => { + // `--color-*: initial` clears the stock palette. The entries Studio's + // markup still references are re-declared as deprecated aliases (U12 + // removes them); anything outside that set must no longer resolve. + const css = await build("studio.css", ["bg-neutral-750", "text-teal-500", "bg-neutral-800"]); + + expect(css).not.toContain("bg-neutral-750"); + expect(css).not.toContain("text-teal-500"); + expect(css).toContain(".bg-neutral-800 {"); + }); + + it("keeps selection, playhead and accent as three distinct colors", () => { + const accent = declaredValue("--color-accent"); + const selection = declaredValue("--color-selection"); + const playhead = declaredValue("--color-playhead"); + + expect(new Set([accent, selection, playhead]).size).toBe(3); + }); + + it("names every micro type step in use and leaves the Tailwind sizes alone", async () => { + const steps = [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]; + const css = await build( + "studio.css", + steps.map((step) => `text-step-${step}`).concat(["text-xs", "text-sm", "text-lg"]), + ); + const vars = rootVariables(css); + + for (const step of steps) { + expect(vars.get(`--text-step-${step}`)).toBe(`${step}px`); + expect(css).toContain(`.text-step-${step} {`); + } + expect(vars.get("--text-xs")).toBe("0.75rem"); + expect(vars.get("--text-sm")).toBe("0.875rem"); + expect(vars.get("--text-lg")).toBe("1.125rem"); + }); + + it("compiles a motion-duration utility with a reduced-motion variant", async () => { + const css = await build("studio.css", ["duration-press", "duration-open"]); + + expect(css).toMatch( + /\.duration-press \{[\s\S]*?transition-duration: var\(--duration-press\)[\s\S]*?prefers-reduced-motion: reduce[\s\S]*?transition-duration: 0ms/, + ); + expect(css).toContain(".duration-open {"); + }); + + it("keeps every legacy Tailwind palette entry at its upstream default value", async () => { + // These are copies of Tailwind's own values, kept only so existing markup + // renders unchanged. A Tailwind upgrade that moves one of them would + // otherwise silently change Studio's colors. + const upstream = new Map( + [ + ...readFileSync(path.join(TAILWIND_DIR, "theme.css"), "utf8").matchAll( + /\n\s*(--color-[a-z]+-(?:50|\d00|950)):\s*([^;]+);/g, + ), + ].map(([, name, value]) => [name, value.trim()]), + ); + const legacy = [...themeSource.matchAll(/\n\s*(--color-[a-z]+-(?:50|\d00|950)):\s*([^;]+);/g)]; + + expect(legacy.length).toBeGreaterThan(0); + for (const [, name, value] of legacy) { + if (!upstream.has(name)) continue; + expect(`${name}: ${value.trim()}`).toBe(`${name}: ${upstream.get(name)}`); + } + }); + + it("keeps the deprecated JS preset in step with the CSS it shadows", () => { + const colors = studioPreset.theme.extend.colors; + + expect(colors.studio.accent.toLowerCase()).toBe(declaredValue("--color-accent")); + expect(colors.panel.surface.toLowerCase()).toBe(declaredValue("--color-surface")); + }); + + it("reads the icon defaults off the theme rather than hard-coding them", () => { + document.documentElement.style.setProperty("--icon-size", "14px"); + document.documentElement.style.setProperty("--icon-weight", "bold"); + + expect(readIconTokens()).toEqual({ size: "14px", weight: "bold" }); + }); + + it("gives a child icon the token size and weight when it sets neither", () => { + document.documentElement.style.setProperty("--icon-size", "14px"); + document.documentElement.style.setProperty("--icon-weight", "bold"); + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + React.createElement( + IconContext.Provider, + { value: readIconTokens() }, + React.createElement(Gear, null), + ), + ); + }); + const inherited = host.querySelector("svg"); + const inheritedShape = inherited?.innerHTML; + act(() => { + root.render(React.createElement(Gear, { size: 14, weight: "bold" })); + }); + const explicitShape = host.querySelector("svg")?.innerHTML; + act(() => root.unmount()); + + expect(inherited?.getAttribute("width")).toBe("14px"); + expect(inherited?.getAttribute("height")).toBe("14px"); + expect(inheritedShape).toBe(explicitShape); + }); +});