diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 0000000000..bdaed4df3b --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,4 @@ +# SonarCloud automatic analysis settings (https://docs.sonarsource.com/sonarcloud/advanced-setup/automatic-analysis/). +# The v1 boundary fixtures exist to break the v1 rules on purpose (raw colours, +# !important, duplicate selectors, unused props), so they are not analysed. +sonar.exclusions=frontend/test-fixtures/** diff --git a/frontend/biome.json b/frontend/biome.json index 5a2b45318b..253b99397c 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -188,6 +188,10 @@ } } }, + { + "includes": ["src/v1/styles/tokens.css"], + "formatter": { "enabled": false } + }, { "includes": ["src/**", "!src/v1/**"], "linter": { diff --git a/frontend/bun.lock b/frontend/bun.lock index 94f3b30008..8c7974d908 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -68,6 +68,7 @@ "devDependencies": { "@babel/parser": "^7.28.6", "@biomejs/biome": "^2.3.13", + "@tailwindcss/node": "4.3.3", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4", @@ -1403,8 +1404,6 @@ "redux": ["redux@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="], - "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], - "refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="], "remark-gfm": ["remark-gfm@3.0.1", "", { "dependencies": { "@types/mdast": "^3.0.0", "mdast-util-gfm": "^2.0.0", "micromark-extension-gfm": "^2.0.0", "unified": "^10.0.0" } }, "sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig=="], @@ -1633,6 +1632,8 @@ "@reduxjs/toolkit/redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + "@reduxjs/toolkit/redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "@svgr/hast-util-to-babel-ast/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], @@ -1685,8 +1686,6 @@ "recharts/react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], - "redux-thunk/redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], - "refractor/@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], diff --git a/frontend/package.json b/frontend/package.json index d59e29aaec..f9e88ea682 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -78,8 +78,9 @@ "lint:all": "biome check --write src/", "lint:changed": "(git diff --name-only --diff-filter=ACMR; git ls-files --others --exclude-standard) | grep -E '\\.[jt]sx?$' | sed 's|^frontend/||' | sort -u | xargs -r biome check --write", "typecheck": "tsc --noEmit", + "tokens:v1": "bun scripts/build-v1-tokens.mjs", "typecheck:v1": "node scripts/check-v1.mjs --files && tsc -p src/v1/tsconfig.json", - "lint:v1": "biome lint src/v1/ && node scripts/check-v1.mjs --code", + "lint:v1": "biome lint src/v1/ && node scripts/check-v1.mjs --code && node scripts/check-v1-styles.mjs", "test:v1": "vitest run --dir src/v1 --passWithNoTests && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs" }, "browserslist": { @@ -97,6 +98,7 @@ "devDependencies": { "@babel/parser": "^7.28.6", "@biomejs/biome": "^2.3.13", + "@tailwindcss/node": "4.3.3", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4", diff --git a/frontend/scripts/build-v1-tokens.mjs b/frontend/scripts/build-v1-tokens.mjs new file mode 100644 index 0000000000..0c16fad947 --- /dev/null +++ b/frontend/scripts/build-v1-tokens.mjs @@ -0,0 +1,29 @@ +import fs from "node:fs"; +import path from "node:path"; + +// Runs under bun (`bun run tokens:v1`): it imports the TypeScript sources +// directly and resolves extensionless relative imports (`./theme`) as Vite and +// tsc do. Node would need a resolver hook and Node 22.15+ for it; the engines +// field allows 20.19, so the generator does not use node. + +const { renderTokensCss } = await import("../src/v1/lib/tokens.ts"); +const { TOKENS } = await import("../src/v1/styles/tokens.ts"); +const out = path.join( + import.meta.dirname, + "..", + "src", + "v1", + "styles", + "tokens.css", +); +const css = renderTokensCss(TOKENS); +if (process.argv.includes("--check")) { + if (fs.readFileSync(out, "utf8") !== css) { + console.error( + "src/v1/styles/tokens.css is stale; run `bun run tokens:v1`.", + ); + process.exit(1); + } +} else { + fs.writeFileSync(out, css); +} diff --git a/frontend/scripts/check-v1-styles.mjs b/frontend/scripts/check-v1-styles.mjs new file mode 100755 index 0000000000..85b9128328 --- /dev/null +++ b/frontend/scripts/check-v1-styles.mjs @@ -0,0 +1,898 @@ +#!/usr/bin/env node +/* + * Styling policy for the v1 frontend (src/v1/). Run by `lint:v1` after + * check-v1.mjs; the rules and their reasons are in src/v1/README.md. + * + * S1 No inline style: a JSX `style` attribute may hold only an object + * literal whose every key is a CSS custom property (`"--page-aspect"`), + * the channel for runtime geometry that `ui:aspect-(--page-aspect)` + * reads. A string, a variable, a spread or a plain key fails. + * S2 No raw colour: hex, rgb()/hsl()/oklch()/oklab()/color(), a + * Tailwind palette utility (`-red-500`, `-white`), or a named colour + * as an arbitrary value (`text-[red]`, `[color:tomato]`), as a whole + * value (`fill="red"`) or in a CSS declaration (`color: white`). + * S3 No raw pixel value: a px/rem/em/pt/vh/vw length anywhere in an + * arbitrary value (`[13px]`, `[200px_1fr]`, `[calc(100%-13px)]`) or + * in CSS. + * S4 No !important: a `!` class modifier, or `!important` in CSS. + * S5 CSS is imported only from app/ and styles/. + * S6 Every CSS selector starts with `.v1` and stays inside it: a sibling + * combinator right after the root (`.v1 + div`, or `& ~ div` nested + * directly in the root) selects outside v1. Not checked inside @theme, @utility, + * @custom-variant, @font-face and @keyframes. + * S7 CSS never reads prefers-color-scheme; data-theme sets the mode. + * S8 Every Tailwind utility carries the `ui:` prefix, and carries it + * first (`ui:hover:bg-primary`): Tailwind 4 generates nothing for a + * prefix after a variant (`hover:ui:bg-primary`). An unprefixed class + * in v1 renders with v0's theme whenever v0 already uses that class + * name, and silently does nothing otherwise. + * + * In TypeScript, S2 to S4 and S8 read only class strings: the value of a + * `className` attribute or an argument of cn()/cva()/clsx()/cx() (however + * short, so `className="flex"` counts; in cva() the base string, the variant + * values and a compound `class`, never a variant name or choice such as + * `defaultVariants: { variant: "outline" }`), and any other string or template + * literal that looks like a class list (see isClassString), so a non-null + * `!`, a hex-looking identifier or a value such as "outline" or "pt-BR" never + * matches. One S2 check reads every literal: a string that is entirely a + * colour (`fill="#ff0000"`, "rgb(1 2 3)", "red") fails wherever it appears, + * while "Run #1234" does not. Test files are skipped for them; the raw values + * live in styles/tokens.json, which is not code. S1 matches only a JSX + * `style=` attribute. + * In CSS, comments are blanked first; the generated styles/tokens.css is + * skipped for S2 and S3 only. One escape hatch, for S3 only: a CSS line that + * ends with the comment `/* v1-styles-allow: forced-colors outline *\/` may + * hold a pixel value. It exists for the focus utility's transparent outline, + * which forced-colors mode paints in place of the box-shadow ring. + * + * S8 checks a list of utility stems, not a Tailwind parse: a missing stem is a + * false negative. A bare stem counts only when it is a whole utility (`flex`), + * so a string such as "font" or "top" is not flagged. + * + * blankComments and its helpers are copied from check-v1.mjs, which runs on + * import and so cannot be imported; keep the two in step. As there, JSX text + * is parsed as code, so an apostrophe in it ("don't") opens a string to the + * end of the line; the class-string test keeps such text from being reported. + * + * It always checks the src/v1 beside this script, like + * check-v1.mjs; the fixtures in test-fixtures/v1-boundary/ rely on that. + */ +import fs from "node:fs"; +import path from "node:path"; + +const ROOT = path.join(import.meta.dirname, "..", "src", "v1"); + +const CODE_EXT = /\.(ts|tsx|mts|cts)$/; +const TEST_FILE = /\.(test|spec)\.(ts|tsx|mts|cts)$/; +const GENERATED_CSS = "styles/tokens.css"; +// Folders whose files may import a .css file. +const CSS_IMPORTERS = new Set(["app", "styles"]); + +const MSG = { + inlineStyle: + "No inline style under v1; style with tokens and Tailwind. A style attribute may set only CSS custom properties (--name) that ui: classes read.", + colour: "No raw colour under v1; use a token from styles/tokens.json.", + pixel: "No raw pixel value under v1; use a token or the spacing scale.", + important: "No !important under v1.", + cssImport: "CSS is imported only from app/ and styles/ under v1.", + unscoped: "Every v1 selector is scoped to .v1.", + sibling: + "Every v1 selector is scoped to .v1; a sibling combinator right after the root (.v1 + x, & ~ x) selects outside it.", + prefixOrder: + "The ui: prefix comes first (ui:hover:bg-primary, not hover:ui:bg-primary); Tailwind 4 generates nothing for a prefix after a variant.", + colourScheme: + "v1 never reads prefers-color-scheme in CSS; mode comes from data-theme.", + unprefixed: + "No unprefixed Tailwind utility under v1; write ui:. An unprefixed class renders with v0's theme whenever v0 already uses that class name, and silently does nothing otherwise.", +}; + +const PALETTE = + "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose"; +const RAW_COLOUR_RES = [ + /#[0-9a-fA-F]{3,8}(?![\w-])/g, + /(? lines.join(" ").split(" ").join("|"); +// A leading `-` is a negative value (`-mt-2`); a trailing `!` is important. +const UTILITY_RE = new RegExp( + `^-?(?:(?:${stems(WHOLE_STEMS)})(?:-|!?$)|(?:${stems(PREFIX_STEMS)})-)`, +); +// One leading variant: `hover:`, `md:`, `data-[state=open]:`, `[&>*]:`. +const VARIANT_RE = /^(?:[\w-]*\[[^\]]*\]|[\w-]+):/; + +/* + * Blanks comments, keeping line numbers so matches can be reported. Copied + * from check-v1.mjs (which runs on import, so it cannot be imported); keep + * the two in step. Strings and template literals are tracked, so a `/*` or + * `//` inside one does not swallow the code after it. + */ +function blankComments(src) { + let out = ""; + let i = 0; + // "`" = inside a template literal; "{" = inside a `${}` or a brace in one. + const stack = []; + while (i < src.length) { + let end; + if (stack.at(-1) === "`") { + end = i + templateStep(src, i, stack); + } else if (src[i] === "/" && (src[i + 1] === "/" || src[i + 1] === "*")) { + end = commentEnd(src, i); + out += src.slice(i, end).replace(/[^\n]/g, " "); + i = end; + continue; + } else if (src[i] === '"' || src[i] === "'") { + end = stringEnd(src, i); + } else { + codeStep(src[i], stack); + end = i + 1; + } + out += src.slice(i, end); + i = end; + } + return out; +} + +/* CSS has only block comments, and a `//` in a url() is not one. */ +function blankCssComments(src) { + return src.replace(/\/\*[\s\S]*?(?:\*\/|$)/g, (c) => + c.replace(/[^\n]/g, " "), + ); +} + +/* Index just past the line or block comment that starts at i. */ +function commentEnd(src, i) { + if (src[i + 1] === "/") { + const newline = src.indexOf("\n", i); + return newline === -1 ? src.length : newline; + } + const close = src.indexOf("*/", i + 2); + return close === -1 ? src.length : close + 2; +} + +/* Index just past the '...' or "..." string that starts at i. An unclosed + * string ends at the newline. */ +function stringEnd(src, i) { + let j = i + 1; + while (j < src.length && src[j] !== src[i] && src[j] !== "\n") { + j += src[j] === "\\" ? 2 : 1; + } + return j + 1; +} + +/* One step inside a template literal: how many characters to copy. Closing + * the literal or opening `${` updates the stack. */ +function templateStep(src, i, stack) { + if (src[i] === "\\") { + return 2; + } + if (src[i] === "`") { + stack.pop(); + return 1; + } + if (src[i] === "$" && src[i + 1] === "{") { + stack.push("{"); + return 2; + } + return 1; +} + +/* Tracks template literals and the braces inside their `${}` expressions. */ +function codeStep(c, stack) { + if (c === "`") { + stack.push("`"); + } else if (c === "{" && stack.length > 0) { + stack.push("{"); + } else if (c === "}" && stack.length > 0) { + stack.pop(); + } +} + +/* + * The text of every string and template literal in comment-blanked source, + * as { text, index, id } with index the offset of the text's first character + * and id shared by the pieces of one template literal. + * Code outside literals is replaced by spaces, and a `${}` expression ends one + * piece of template text and starts the next (its own literals are yielded + * too), so a match can never span code. + */ +function literals(src) { + const pieces = []; + const stack = []; + // The id of each open template literal, innermost last. + const templates = []; + let nextId = 0; + let piece = null; + let i = 0; + while (i < src.length) { + if (stack.at(-1) === "`") { + piece ??= { text: "", index: i, id: templates.at(-1) }; + const depth = stack.length; + const step = templateStep(src, i, stack); + if (stack.length < depth) { + templates.pop(); + } + if (stack.at(-1) !== "`") { + // The literal closed, or a `${` opened. + pieces.push(piece); + piece = null; + } else { + piece.text += src.slice(i, i + step); + } + i += step; + } else if (src[i] === '"' || src[i] === "'") { + const end = stringEnd(src, i); + const close = src[end - 1] === src[i] ? end - 1 : end; + pieces.push({ + text: src.slice(i + 1, close), + index: i + 1, + id: nextId++, + }); + i = end; + } else { + if (src[i] === "`") { + templates.push(nextId++); + } + codeStep(src[i], stack); + i += 1; + } + } + return pieces.filter((p) => p.text); +} + +// A specifier: a quoted or backtick string with no interpolation. +const SPEC = "[\"'`]([^\"'`$]+)[\"'`]"; +// The import forms check-v1.mjs reads, minus Vite globs. +const IMPORT_RES = [ + new RegExp( + String.raw`\b(?:import|export)\s+(?:type\s+)?(?:[\w$*{}\s,]+?\s+from\s+)?${SPEC}`, + "g", + ), + new RegExp(String.raw`\bimport\s*\(\s*${SPEC}\s*[,)]`, "g"), + new RegExp(String.raw`\brequire\s*\(\s*${SPEC}\s*\)`, "g"), +]; +const CSS_SPEC = /\.css(?:\?.*)?$/; + +function lineAt(text, index) { + return text.slice(0, index).split("\n").length; +} + +/* Every whitespace-separated token in a literal, with its offset in the file. */ +function* tokens({ text, index }) { + for (const m of text.matchAll(/\S+/g)) { + yield { token: m[0], index: index + m.index }; + } +} + +/* A token that looks like a Tailwind utility without the `ui:` prefix. */ +function isUnprefixedUtility(token) { + let rest = token.startsWith("!") ? token.slice(1) : token; + if (rest.startsWith("ui:")) { + return false; + } + for (let m = rest.match(VARIANT_RE); m; m = rest.match(VARIANT_RE)) { + rest = rest.slice(m[0].length); + if (rest.startsWith("ui:")) { + return false; + } + } + return UTILITY_RE.test(rest); +} + +/* A token that carries `ui:` after a variant (`hover:ui:bg-primary`), which + * Tailwind 4 compiles to nothing; the prefix must come first. */ +function prefixAfterVariant(token) { + let rest = token.startsWith("!") ? token.slice(1) : token; + let variants = 0; + for (let m = rest.match(VARIANT_RE); m; m = rest.match(VARIANT_RE)) { + rest = rest.slice(m[0].length); + variants += 1; + if (rest.startsWith("ui:")) { + return variants > 0; + } + } + return false; +} + +const DEFINITE_CLASS_RES = [ + // `ui:` first, or after a variant (wrong, and S8 reports it): `ui:p-2`, + // `!ui:p-2`, `md:ui:h-8!`. + /(?:^!?|:)ui:/, + /-\[[^\]]*\]/, + new RegExp(String.raw`-(?:${PALETTE})-\d{2,3}(?![\w-])`), +]; + +/* + * Ruling R7-3: S2, S3, S4 and S8 apply only to class strings. A literal is + * one when it holds at least one definite utility (`ui:` prefix, an arbitrary + * value `-[…]`, or a stock palette colour such as `-red-500`) or at least two + * utility-shaped tokens (isUnprefixedUtility). So "outline", "pt-BR", + * "Run #1234" and "Try re-running!" are values, while + * "flex items-center gap-2" is a class list. + */ +function isClassString(text) { + const words = text.split(/\s+/).filter(Boolean); + if (words.some((w) => DEFINITE_CLASS_RES.some((re) => re.test(w)))) { + return true; + } + return words.filter(isUnprefixedUtility).length >= 2; +} + +/* A class token with the `!` important modifier at either end. */ +function isImportantClass(token) { + return /^![\w-[]|[\w\])]!$/.test(token) && /[-:]/.test(token); +} + +// At-rules whose blocks hold theme keys or declarations, not v1 selectors. +const OPAQUE_AT_RULES = new Set([ + "theme", + "utility", + "custom-variant", + "font-face", + "keyframes", +]); + +/* Splits a selector list on the commas outside (), [] and strings. */ +function splitSelectors(prelude) { + const parts = []; + let depth = 0; + let quote = null; + let start = 0; + for (let i = 0; i < prelude.length; i += 1) { + const c = prelude[i]; + if (quote) { + quote = c === quote ? null : quote; + } else if (c === '"' || c === "'") { + quote = c; + } else if (c === "(" || c === "[") { + depth += 1; + } else if (c === ")" || c === "]") { + depth -= 1; + } else if (c === "," && depth === 0) { + parts.push(prelude.slice(start, i)); + start = i + 1; + } + } + parts.push(prelude.slice(start)); + return parts.map((p) => p.trim()); +} + +/* + * The selector lists that must be scoped: every block prelude that is not an + * at-rule and not inside an opaque at-rule. A prelude nested in another style + * rule is already scoped by its parent unless it starts with `&` and a + * sibling combinator (leavesRoot), so it is returned with nested: true. + * Grouping at-rules such as @layer and @media are transparent. + * Returns { prelude, index, nested }. + */ +function selectorPreludes(css) { + const found = []; + // Per open block: "opaque", "group" (transparent at-rule) or a style rule + // as { root }, true when the rule's prelude is one compound on the root. + const stack = []; + let start = 0; + let quote = null; + for (let i = 0; i < css.length; i += 1) { + const c = css[i]; + if (quote) { + quote = c === quote ? null : quote; + continue; + } + if (c === '"' || c === "'") { + quote = c; + } else if (c === ";" || c === "}") { + if (c === "}") { + stack.pop(); + } + start = i + 1; + } else if (c === "{") { + const raw = css.slice(start, i); + const prelude = raw.trim(); + const index = start + raw.length - raw.trimStart().length; + const at = prelude.match(/^@([\w-]+)/); + if (stack.includes("opaque") || (at && OPAQUE_AT_RULES.has(at[1]))) { + stack.push("opaque"); + } else if (at) { + stack.push("group"); + } else { + const rules = stack.filter((entry) => typeof entry === "object"); + const nested = rules.length > 0; + const parentIsRoot = rules.every((entry) => entry.root); + found.push({ prelude, index, nested, parentIsRoot }); + stack.push({ root: parentIsRoot && isRootCompound(prelude, nested) }); + } + start = i + 1; + } + } + return found; +} + +/* Every file under dir, relative and `/`-separated. Symlinks are reported by + * check-v1.mjs, so they are skipped here. */ +function walk(dir) { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((d) => d.isFile()) + .map((d) => + path + .relative(dir, path.join(d.parentPath, d.name)) + .replaceAll(path.sep, "/"), + ) + .toSorted((a, b) => a.localeCompare(b)); +} + +const problems = []; +const report = (rel, line, msg) => { + const where = line ? `${rel}:${line}` : rel; + problems.push(`src/v1/${where}: ${msg}`); +}; + +/* + * S1's one allowance. `open` indexes the `{` of a `style={…}` container (a + * `style="…"` string never qualifies). True only when the container holds an + * object literal and every entry is `"--name": value`: the attribute then + * carries runtime numbers into custom properties that `ui:` classes read + * (`ui:aspect-(--page-aspect)`), and the classes still own the visuals. A + * variable, a call, a spread or a plain key is rejected: its keys are not + * visible at the call site, or it styles the element directly. + */ +function isCustomPropertyStyle(text, open) { + if (text[open] !== "{") { + return false; + } + const close = matchingBrace(text, open); + if (close < 0) { + return false; + } + // The container must hold exactly one object literal: `{ {…} }`. + const innerOpen = open + 1 + text.slice(open + 1, close).search(/\S/); + const innerClose = matchingBrace(text, innerOpen); + if (text[innerOpen] !== "{" || innerClose < 0) { + return false; + } + if (text.slice(innerClose + 1, close).trim() !== "") { + return false; + } + const body = text.slice(innerOpen + 1, innerClose); + return topLevelSplit(body, ",").every((entry) => { + const e = entry.trim(); + if (e === "") { + return true; // a trailing comma + } + const [key] = topLevelSplit(e, ":"); + return key.length < e.length && CUSTOM_PROPERTY_KEY_RE.test(key.trim()); + }); +} + +/* Index of the `}` closing the `{` at `open`, skipping strings; -1 if none. */ +function matchingBrace(text, open) { + let depth = 0; + let i = open; + while (i < text.length) { + const c = text[i]; + if (c === '"' || c === "'" || c === "`") { + i = skipString(text, i); + continue; + } + if (c === "{" || c === "(" || c === "[") { + depth += 1; + } else if (c === "}" || c === ")" || c === "]") { + depth -= 1; + if (depth === 0) { + return c === "}" ? i : -1; + } + } + i += 1; + } + return -1; +} + +/* Splits `text` on `sep` at bracket depth 0, outside strings. */ +function topLevelSplit(text, sep) { + const parts = []; + let depth = 0; + let start = 0; + let i = 0; + while (i < text.length) { + const c = text[i]; + if (c === '"' || c === "'" || c === "`") { + i = skipString(text, i); + continue; + } + if (c === "{" || c === "(" || c === "[") { + depth += 1; + } else if (c === "}" || c === ")" || c === "]") { + depth -= 1; + } else if (c === sep && depth === 0) { + parts.push(text.slice(start, i)); + start = i + 1; + } + i += 1; + } + parts.push(text.slice(start)); + return parts; +} + +/* Index just past the string or template literal that starts at i. */ +function skipString(text, i) { + const quote = text[i]; + let j = i + 1; + while (j < text.length && text[j] !== quote) { + if (text[j] === "\\") { + j += 1; + } else if (quote === "`" && text[j] === "$" && text[j + 1] === "{") { + j = matchingBrace(text, j + 1); + if (j < 0) { + return text.length; + } + } + j += 1; + } + return j + 1; +} + +/* + * The start index (first character after the quote) of every string or + * template literal that sits in a class position: `className="…"`, anywhere + * inside `className={…}`, or anywhere inside cn(…)/cva(…)/clsx(…)/cx(…). + * Those are class lists by position, so S2 to S4 and S8 read them even when + * they hold a single word that isClassString would skip (`"flex"`). + */ +function classSiteLiterals(text) { + const starts = new Set(); + for (const m of text.matchAll(CLASS_SITE_RE)) { + const isCva = m[0].startsWith("cva("); + // A call: scan its parentheses. An attribute: skip spaces, then a quote + // starts one literal and a brace starts an expression to scan. + let i = m.index + m[0].length - 1; + if (!m[0].endsWith("(")) { + i += 1; + while (i < text.length && /\s/.test(text[i])) { + i += 1; + } + if (text[i] === '"' || text[i] === "'" || text[i] === "`") { + starts.add(i + 1); + continue; + } + if (text[i] !== "{") { + continue; + } + } + // keys[d] is the property name the value at bracket depth d sits under, + // read for cva() only, where variant names and choices are not classes. + const keys = []; + let depth = 0; + let lastWord = ""; + while (i < text.length) { + const c = text[i]; + if (c === '"' || c === "'" || c === "`") { + const end = skipString(text, i); + lastWord = text.slice(i + 1, end - 1); + if (!isCva || cvaClassPosition(text, end, keys, depth)) { + starts.add(i + 1); + } + i = end; + continue; + } + if (/[A-Za-z_$]/.test(c)) { + let j = i + 1; + while (j < text.length && /[\w$]/.test(text[j])) { + j += 1; + } + lastWord = text.slice(i, j); + i = j; + continue; + } + if (c === ":" && depth > 0) { + keys[depth] = lastWord; + } else if (c === "{" || c === "(" || c === "[") { + depth += 1; + keys[depth] = undefined; + } else if (c === "}" || c === ")" || c === "]") { + depth -= 1; + if (depth === 0) { + break; + } + } + i += 1; + } + } + return starts; +} + +/* + * In cva(), a literal that ends at `end` supplies classes unless it is a + * property name (`"sm": …`), sits under defaultVariants (a choice), or sits + * in compoundVariants under a key other than class/className (a choice to + * match). The base string and the values under variants are classes. + */ +function cvaClassPosition(text, end, keys, depth) { + let j = end; + while (j < text.length && /\s/.test(text[j])) { + j += 1; + } + if (text[j] === ":") { + return false; + } + const chain = keys.slice(1, depth + 1); + if (chain.includes("defaultVariants")) { + return false; + } + if (chain.includes("compoundVariants")) { + return keys[depth] === "class" || keys[depth] === "className"; + } + return true; +} + +function checkCode(rel) { + const text = blankComments(fs.readFileSync(path.join(ROOT, rel), "utf8")); + const top = rel.split("/")[0]; + + // S5 applies to test files too: they would load the CSS the same way. + for (const re of IMPORT_RES) { + for (const m of text.matchAll(re)) { + if (CSS_SPEC.test(m[1]) && !CSS_IMPORTERS.has(top)) { + report(rel, lineAt(text, m.index), MSG.cssImport); + } + } + } + if (TEST_FILE.test(rel)) { + return; + } + for (const m of text.matchAll(INLINE_STYLE_RE)) { + if (!isCustomPropertyStyle(text, m.index + m[0].length - 1)) { + report(rel, lineAt(text, m.index), MSG.inlineStyle); + } + } + const pieces = literals(text); + // A template literal is judged as a whole, across its `${}` expressions. + const textById = new Map(); + for (const { id, text: t } of pieces) { + textById.set(id, `${textById.get(id) ?? ""} ${t}`); + } + const classSites = classSiteLiterals(text); + for (const literal of pieces) { + const trimmed = literal.text.trim(); + const word = trimmed.toLowerCase(); + if ( + WHOLE_COLOUR_RES.some((re) => re.test(trimmed)) || + (NAMED_COLOURS.has(word) && !ALLOWED_NAMED_COLOURS.has(word)) + ) { + report(rel, lineAt(text, literal.index), MSG.colour); + continue; + } + if ( + !classSites.has(literal.index) && + !isClassString(textById.get(literal.id)) + ) { + continue; + } + for (const re of RAW_COLOUR_RES) { + for (const m of literal.text.matchAll(re)) { + report(rel, lineAt(text, literal.index + m.index), MSG.colour); + } + } + for (const m of literal.text.matchAll(ARBITRARY_WORD_RE)) { + const word = m[1].toLowerCase(); + if (NAMED_COLOURS.has(word) && !ALLOWED_NAMED_COLOURS.has(word)) { + report(rel, lineAt(text, literal.index + m.index), MSG.colour); + } + } + for (const m of literal.text.matchAll(ARBITRARY_UNIT_RE)) { + report(rel, lineAt(text, literal.index + m.index), MSG.pixel); + } + for (const { token, index } of tokens(literal)) { + if (isImportantClass(token)) { + report(rel, lineAt(text, index), MSG.important); + } + if (prefixAfterVariant(token)) { + report(rel, lineAt(text, index), `'${token}': ${MSG.prefixOrder}`); + } + if (isUnprefixedUtility(token)) { + report(rel, lineAt(text, index), `'${token}': ${MSG.unprefixed}`); + } + } + } +} + +function checkCss(rel) { + const source = fs.readFileSync(path.join(ROOT, rel), "utf8"); + const css = blankCssComments(source); + // 1-based numbers of the lines that carry the S3 escape-hatch marker. + const pixelAllowed = new Set( + source + .split("\n") + .flatMap((line, i) => (PIXEL_ALLOW_RE.test(line) ? [i + 1] : [])), + ); + const at = (re, msg, skip = new Set()) => { + for (const m of css.matchAll(re)) { + const line = lineAt(css, m.index); + if (!skip.has(line)) { + report(rel, line, msg); + } + } + }; + if (rel !== GENERATED_CSS) { + for (const re of RAW_COLOUR_RES) { + at(re, MSG.colour); + } + for (const m of css.matchAll(CSS_DECLARATION_RE)) { + // Words of the value, outside var(--…) and other function arguments. + for (const word of m[1] + .toLowerCase() + .matchAll(/(? a + b`), a nested selector without `&` is an implicit descendant, + * and `& + x` under `.v1 .menu` resolves to `.v1 .menu + x`, inside v1. + */ +function leavesRoot(selector, nested, parentIsRoot) { + if (nested && (!selector.startsWith("&") || !parentIsRoot)) { + return false; + } + const next = selector.slice(rootCompoundEnd(selector)).trimStart()[0]; + return next === "+" || next === "~"; +} + +/* + * True when some comma-separated part of the prelude is one compound on the + * root: `.v1` or `.v1[data-theme="dark"]` at top level, `&:hover` or `&.x` + * in a nested rule. A child rule of such a prelude that starts `& +` leaves + * v1; a child of `.v1 .menu`, or of a nested `.menu`, does not. + */ +function isRootCompound(prelude, nested) { + return splitSelectors(prelude).some( + (selector) => + (nested ? selector.startsWith("&") : /^\.v1(?![\w-])/.test(selector)) && + rootCompoundEnd(selector) === selector.length, + ); +} + +/* Index of the first combinator outside brackets and quotes, else length. */ +function rootCompoundEnd(selector) { + let depth = 0; + let quote = null; + for (let i = 0; i < selector.length; i += 1) { + const c = selector[i]; + if (quote) { + quote = c === quote ? null : quote; + } else if (c === '"' || c === "'") { + quote = c; + } else if (c === "(" || c === "[") { + depth += 1; + } else if (c === ")" || c === "]") { + depth -= 1; + } else if (depth === 0 && /[\s>+~]/.test(c)) { + return i; + } + } + return selector.length; +} + +if (!fs.existsSync(ROOT)) { + console.error(`check-v1-styles: ${ROOT} does not exist`); + process.exit(2); +} + +for (const rel of walk(ROOT)) { + if (CODE_EXT.test(rel)) { + checkCode(rel); + } else if (rel.endsWith(".css")) { + checkCss(rel); + } +} + +if (problems.length > 0) { + console.error(problems.join("\n")); + console.error(`\ncheck-v1-styles: ${problems.length} problem(s).`); + process.exit(1); +} +console.log("check-v1-styles: ok"); diff --git a/frontend/src/index.css b/frontend/src/index.css index f8a4a554b5..6bfdcd4123 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,6 +3,12 @@ * (UN_SHADCN_IMPL_PLAN P0-10) * ------------------------------------------------------------------ */ @import "tailwindcss"; +/* v1 compiles its own Tailwind root (src/v1/styles/index.css). Keep v0's scan + * out of the v1 tree, the lint scripts and the guardrail fixtures, or class-like + * strings there generate unprefixed utilities in v0's bundle (UN-4299). */ +@source not "./v1"; +@source not "../scripts"; +@source not "../test-fixtures"; @plugin "tailwindcss-animate"; @plugin "@tailwindcss/typography"; diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 4fa2aaa056..0bf54e99f7 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -32,6 +32,38 @@ is convention. `@v1/assets/logo.svg?react`). v1 never uses v0's `src/assets/` (enforced); where an asset file sits under v1 is convention, not checked. +## Styling + +- `ui:` on every utility: an unprefixed class renders with v0's theme whenever + v0 already uses that class name, and silently does nothing otherwise. Merge + with `cn` (`@v1/lib/cn`); `bun run tokens:v1` makes tokens.css from tokens.json. +- Modes `light`, `dark`, `unstract` (or `system`): the user chooses, stored per + browser under `unstract.v1.theme`, applied as `data-theme` by `ThemeProvider`. + Portals: `getPortalContainer()`; focus: `ui:focus-visible:focus-ring`; Geist + Mono only for model output, JSON and token counts. +- `lint:v1` S1–S8: no inline style, raw colour (hex, functions, palette or + named, in code and CSS) or pixel (any length inside `[…]` or in CSS, `vh` + and `vw` included), `!important`, CSS import outside `app/` or `styles/`, + selector outside `.v1` (a sibling combinator right after the root, + `.v1 + x` or `& ~ x` nested directly in the root, counts), + `prefers-color-scheme`, or a utility without `ui:` first (`hover:ui:flex` + compiles to nothing). Code: `className` values and `cn()`/`cva()` class + positions always (a cva variant name or choice is not a class), other + strings when they look like a class list. + Runtime geometry: `style` may set only custom properties (`"--page-aspect"`, + typed by `lib/css-custom-properties.d.ts`) that classes read + (`ui:aspect-(--page-aspect)`); a plain key or a variable fails. Pixel + escape, CSS only: `/* v1-styles-allow: forced-colors outline */`. +- Known v0 rules that reach v1 (unlayered, so v1 loses without `!important`): + - `scrollbar-color`, `::-webkit-scrollbar*`: the thumb stays light in dark. + - `[data-radix-popper-content-wrapper] { z-index: 1500 !important }`. + - `[data-sonner-toaster] { pointer-events: auto }`. + - `svg.lucide` under a `span`, `p`, `label`, `li`, `a`, `td` or `th` that + is not `.flex`/`.inline-flex`/`.grid` (v0 names, so `ui:inline-flex` + does not exempt) is forced to `1em` and `inline-block`, overriding the + icon's `size` prop. Put icons in a `ui:flex`/`ui:inline-flex` parent + that is not one of those tags, or size the parent's font. + ## Folders and what each may import Every folder may import from itself. Test files (`*.test.ts(x)`, @@ -59,7 +91,8 @@ Every folder may import from itself. Test files (`*.test.ts(x)`, | Script | Checks | | --- | --- | | `bun run typecheck:v1` | TypeScript-only files, styles only in `styles/`, allowed file types only, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` | -| `bun run lint:v1` | Biome on `src/v1/`, then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks | +| `bun run lint:v1` | Biome on `src/v1/`, then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, then the styling policy (S1 to S8 above) | +| `bun run tokens:v1` | Regenerates `styles/tokens.css` from `styles/tokens.json` (via `styles/tokens.ts`; runs under bun, which imports the TypeScript source directly) | | `bun run test:v1` | v1 unit tests, then the guardrail fixtures in `test-fixtures/v1-boundary/` | Biome (`biome.json`) also reports the banned imports and the plain spellings of @@ -68,4 +101,5 @@ the v0 ↔ v1 boundary, so `biome ci` and editors flag them too: `@/`, `../`, `./v1` / `../**/v1` in v0. The relative v0 patterns match any folder named `v1`, so no v0 folder may take that name. Everything else (the folder contract, templates, globs, `new URL`, CSS, symlinks) is checked only by -`scripts/check-v1.mjs`, and types by `src/v1/tsconfig.json`. +`scripts/check-v1.mjs`, the styling policy by `scripts/check-v1-styles.mjs`, +and types by `src/v1/tsconfig.json`. diff --git a/frontend/src/v1/app/theme/ThemeProvider.test.tsx b/frontend/src/v1/app/theme/ThemeProvider.test.tsx new file mode 100644 index 0000000000..30415c3cc9 --- /dev/null +++ b/frontend/src/v1/app/theme/ThemeProvider.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from "@testing-library/react"; +import { getPortalContainer } from "@v1/lib/portal"; +import { useThemeStore } from "@v1/stores/theme"; +import { act } from "react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { ThemeProvider } from "./ThemeProvider"; + +type Listener = (e: { matches: boolean }) => void; +let listeners: Listener[] = []; +let matches = false; + +beforeEach(() => { + listeners = []; + matches = false; + document.body.innerHTML = ""; + window.localStorage.clear(); + useThemeStore.setState({ + preference: "system", + systemPrefersDark: false, + mode: "light", + }); + vi.stubGlobal("matchMedia", (query: string) => ({ + media: query, + matches, + addEventListener: (_: string, l: Listener) => { + listeners.push(l); + }, + removeEventListener: (_: string, l: Listener) => { + listeners = listeners.filter((x) => x !== l); + }, + })); +}); +afterEach(() => vi.unstubAllGlobals()); + +it("renders the v1 root with the stored mode and syncs the portal container", () => { + window.localStorage.setItem("unstract.v1.theme", "unstract"); + render( + + hi + , + ); + const root = screen.getByText("hi").parentElement as HTMLElement; + expect(root.classList.contains("v1")).toBe(true); + expect(root.dataset.uiVersion).toBe("v1"); + expect(root.dataset.theme).toBe("unstract"); + expect(getPortalContainer().dataset.theme).toBe("unstract"); +}); + +it("follows the system preference while set to system and stops after an explicit choice", () => { + render( + + hi + , + ); + const root = screen.getByText("hi").parentElement as HTMLElement; + // Created before any change, so only the [mode] effect can update it. + const portal = getPortalContainer(); + expect(portal.dataset.theme).toBe("light"); + act(() => { + for (const l of listeners) { + l({ matches: true }); + } + }); + expect(root.dataset.theme).toBe("dark"); + expect(portal.dataset.theme).toBe("dark"); + act(() => { + useThemeStore.getState().setPreference("light"); + }); + expect(portal.dataset.theme).toBe("light"); + act(() => { + for (const l of listeners) { + l({ matches: false }); + } + }); + act(() => { + for (const l of listeners) { + l({ matches: true }); + } + }); + expect(root.dataset.theme).toBe("light"); + expect(portal.dataset.theme).toBe("light"); + expect(getPortalContainer()).toBe(portal); +}); + +it("removes the media listener on unmount", () => { + const { unmount } = render( + + hi + , + ); + expect(listeners).toHaveLength(1); + unmount(); + expect(listeners).toHaveLength(0); +}); diff --git a/frontend/src/v1/app/theme/ThemeProvider.tsx b/frontend/src/v1/app/theme/ThemeProvider.tsx new file mode 100644 index 0000000000..ad6186341e --- /dev/null +++ b/frontend/src/v1/app/theme/ThemeProvider.tsx @@ -0,0 +1,49 @@ +import "@v1/styles/index.css"; + +import { cn } from "@v1/lib/cn"; +import { applyThemeMode } from "@v1/lib/portal"; +import { selectThemeMode, useThemeStore } from "@v1/stores/theme"; +import { type JSX, type ReactNode, useLayoutEffect } from "react"; + +export type ThemeProviderProps = Readonly<{ + children: ReactNode; + className?: string; +}>; + +const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)"; + +/** The v1 root: hydrates the theme store, follows the OS scheme, and stamps the mode. */ +export function ThemeProvider({ + children, + className, +}: ThemeProviderProps): JSX.Element { + const mode = useThemeStore(selectThemeMode); + + // Layout effect: a saved dark or unstract preference must not paint a light frame first. + useLayoutEffect(() => { + const { hydrate, setSystemPrefersDark } = useThemeStore.getState(); + hydrate(); // from the storage the store writes to, so reads and writes agree + if (typeof window.matchMedia !== "function") { + return undefined; + } + const query = window.matchMedia(DARK_SCHEME_QUERY); + setSystemPrefersDark(query.matches); + const onChange = (event: { matches: boolean }) => { + setSystemPrefersDark(event.matches); + }; + query.addEventListener("change", onChange); + return () => { + query.removeEventListener("change", onChange); + }; + }, []); + + useLayoutEffect(() => { + applyThemeMode(document, mode); + }, [mode]); + + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/v1/lib/cn.test.ts b/frontend/src/v1/lib/cn.test.ts new file mode 100644 index 0000000000..d4c41dd3af --- /dev/null +++ b/frontend/src/v1/lib/cn.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from "vitest"; +import { cn } from "./cn"; + +it("merges conflicting prefixed utilities, keeping the last", () => { + expect(cn("ui:p-2 ui:p-4")).toBe("ui:p-4"); + expect(cn("ui:bg-background", false, "ui:bg-card")).toBe("ui:bg-card"); +}); + +it("keeps unrelated and variant classes", () => { + expect(cn("ui:flex ui:dark:bg-card", ["ui:gap-2"])).toBe( + "ui:flex ui:dark:bg-card ui:gap-2", + ); +}); + +it("leaves unprefixed classes alone, so the merge is scoped to the ui prefix", () => { + expect(cn("p-2 p-4")).toBe("p-2 p-4"); +}); + +it("keeps a type role beside a text colour, and lets the last of two roles win", () => { + expect(cn("ui:text-body ui:text-foreground")).toBe( + "ui:text-body ui:text-foreground", + ); + expect(cn("ui:text-body-small ui:text-muted-foreground")).toBe( + "ui:text-body-small ui:text-muted-foreground", + ); + expect(cn("ui:text-caption ui:text-primary")).toBe( + "ui:text-caption ui:text-primary", + ); + expect(cn("ui:text-body ui:text-caption")).toBe("ui:text-caption"); +}); diff --git a/frontend/src/v1/lib/cn.ts b/frontend/src/v1/lib/cn.ts new file mode 100644 index 0000000000..0ba762972b --- /dev/null +++ b/frontend/src/v1/lib/cn.ts @@ -0,0 +1,25 @@ +import { type ClassValue, clsx } from "clsx"; +import { extendTailwindMerge } from "tailwind-merge"; + +/** The type roles from the `type` token family, as `ui:text-` utilities. */ +export const TYPE_ROLES = [ + "display", + "page-title", + "section-title", + "body", + "body-small", + "caption", + "overline", + "mono", +] as const; + +// tailwind-merge knows only the stock text scale; without the roles it reads +// `ui:text-body` as a colour and drops it beside `ui:text-foreground`. +const twMerge = extendTailwindMerge({ + prefix: "ui", + extend: { theme: { text: [...TYPE_ROLES] } }, +}); + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/frontend/src/v1/lib/css-custom-properties.d.ts b/frontend/src/v1/lib/css-custom-properties.d.ts new file mode 100644 index 0000000000..0c3fdf2893 --- /dev/null +++ b/frontend/src/v1/lib/css-custom-properties.d.ts @@ -0,0 +1,14 @@ +/* + * Styling rule S1 lets a `style` attribute set CSS custom properties only + * (`style={{ "--page-aspect": width / height }}`), the channel for runtime + * geometry that `ui:` classes read. React's CSSProperties has no index + * signature for `--*` keys, so without this augmentation that object literal + * fails TS2353 under strict mode. + */ +import "react"; + +declare module "react" { + interface CSSProperties { + [property: `--${string}`]: string | number | undefined; + } +} diff --git a/frontend/src/v1/lib/portal.test.ts b/frontend/src/v1/lib/portal.test.ts new file mode 100644 index 0000000000..0c918f0bf9 --- /dev/null +++ b/frontend/src/v1/lib/portal.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, expect, it } from "vitest"; +import { + applyThemeMode, + getPortalContainer, + PORTAL_CONTAINER_ID, +} from "./portal"; + +beforeEach(() => { + document.body.innerHTML = ""; +}); + +it("creates one container under body with the v1 class and light by default", () => { + const a = getPortalContainer(); + const b = getPortalContainer(); + expect(a).toBe(b); + expect(a.id).toBe(PORTAL_CONTAINER_ID); + expect(a.parentElement).toBe(document.body); + expect(a.classList.contains("v1")).toBe(true); + expect(a.dataset.theme).toBe("light"); +}); + +it("copies the mode of an existing v1 root when created", () => { + document.body.innerHTML = '
'; + expect(getPortalContainer().dataset.theme).toBe("dark"); +}); + +it("applyThemeMode updates every v1 element including the container", () => { + document.body.innerHTML = '
'; + getPortalContainer(); + applyThemeMode(document, "unstract"); + const themes = [...document.querySelectorAll(".v1")].map( + (el) => (el as HTMLElement).dataset.theme, + ); + expect(themes).toEqual(["unstract", "unstract"]); +}); diff --git a/frontend/src/v1/lib/portal.ts b/frontend/src/v1/lib/portal.ts new file mode 100644 index 0000000000..c968f49a07 --- /dev/null +++ b/frontend/src/v1/lib/portal.ts @@ -0,0 +1,41 @@ +import { DEFAULT_THEME_MODE, THEME_MODES, type ThemeMode } from "./theme"; + +/** The single element under that v1 dialogs, menus and toasts render into. */ +export const PORTAL_CONTAINER_ID = "v1-portal"; + +/** The class that scopes v1 styles; every element carrying it gets a data-theme. */ +export const V1_ROOT_CLASS = "v1"; + +function isThemeMode(value: string | undefined): value is ThemeMode { + return (THEME_MODES as readonly (string | undefined)[]).includes(value); +} + +/** The mode already shown by a v1 root, so a late-created container matches it. */ +function currentThemeMode(doc: Document): ThemeMode { + const root = doc.querySelector(`.${V1_ROOT_CLASS}[data-theme]`); + const theme = root?.dataset.theme; + return isThemeMode(theme) ? theme : DEFAULT_THEME_MODE; +} + +/** Returns the portal container, creating it under on first use. */ +export function getPortalContainer(doc: Document = document): HTMLElement { + const existing = doc.getElementById(PORTAL_CONTAINER_ID); + if (existing) { + return existing; + } + const container = doc.createElement("div"); + container.id = PORTAL_CONTAINER_ID; + container.className = V1_ROOT_CLASS; + container.dataset.theme = currentThemeMode(doc); + doc.body.appendChild(container); + return container; +} + +/** Stamps the mode on every v1 element: the app roots and the portal container. */ +export function applyThemeMode(doc: Document, mode: ThemeMode): void { + for (const element of doc.querySelectorAll( + `.${V1_ROOT_CLASS}`, + )) { + element.dataset.theme = mode; + } +} diff --git a/frontend/src/v1/lib/theme.test.ts b/frontend/src/v1/lib/theme.test.ts new file mode 100644 index 0000000000..1096b0d860 --- /dev/null +++ b/frontend/src/v1/lib/theme.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + isThemePreference, + readThemePreference, + resolveThemeMode, + THEME_MODES, + THEME_STORAGE_KEY, + writeThemePreference, +} from "./theme"; + +describe("resolveThemeMode", () => { + it("returns the explicit mode unchanged", () => { + expect(resolveThemeMode("dark", false)).toBe("dark"); + expect(resolveThemeMode("unstract", true)).toBe("unstract"); + }); + it("maps system to dark or light from the media flag", () => { + expect(resolveThemeMode("system", true)).toBe("dark"); + expect(resolveThemeMode("system", false)).toBe("light"); + }); +}); + +describe("readThemePreference", () => { + it("returns a stored valid preference", () => { + const storage = { getItem: () => "unstract" }; + expect(readThemePreference(storage)).toBe("unstract"); + }); + it("returns system for a missing, unknown or unreadable value", () => { + expect(readThemePreference({ getItem: () => null })).toBe("system"); + expect(readThemePreference({ getItem: () => "sepia" })).toBe("system"); + expect( + readThemePreference({ + getItem: () => { + throw new Error("blocked"); + }, + }), + ).toBe("system"); + expect(readThemePreference(null)).toBe("system"); + }); +}); + +describe("writeThemePreference", () => { + it("writes under the v1 key and swallows storage errors", () => { + const calls: [string, string][] = []; + writeThemePreference( + { + setItem: (k, v) => { + calls.push([k, v]); + }, + }, + "dark", + ); + expect(calls).toEqual([[THEME_STORAGE_KEY, "dark"]]); + expect(() => + writeThemePreference( + { + setItem: () => { + throw new Error("quota"); + }, + }, + "dark", + ), + ).not.toThrow(); + expect(() => writeThemePreference(null, "dark")).not.toThrow(); + }); +}); + +it("THEME_MODES lists exactly the three modes and isThemePreference accepts system", () => { + expect(THEME_MODES).toEqual(["light", "dark", "unstract"]); + expect(isThemePreference("system")).toBe(true); + expect(isThemePreference("v0-dark")).toBe(false); +}); diff --git a/frontend/src/v1/lib/theme.ts b/frontend/src/v1/lib/theme.ts new file mode 100644 index 0000000000..24e005b5c4 --- /dev/null +++ b/frontend/src/v1/lib/theme.ts @@ -0,0 +1,70 @@ +/** A colour mode the v1 shell can render. */ +export type ThemeMode = "light" | "dark" | "unstract"; + +/** What the user chose: a fixed mode, or follow the operating system. */ +export type ThemePreference = ThemeMode | "system"; + +export const THEME_MODES: readonly ThemeMode[] = ["light", "dark", "unstract"]; + +/** The browser's palette for native controls, scrollbars and autofill in each mode. */ +export type ColorScheme = "light" | "dark"; + +export const THEME_COLOR_SCHEMES: Readonly> = { + light: "light", + dark: "dark", + unstract: "light", +}; + +/** v1's own storage key; v0 (next-themes) uses `theme`, which v1 never reads. */ +export const THEME_STORAGE_KEY = "unstract.v1.theme"; + +export const DEFAULT_THEME_MODE: ThemeMode = "light"; + +const SYSTEM_PREFERENCE: ThemePreference = "system"; + +export function isThemePreference(value: unknown): value is ThemePreference { + return ( + value === SYSTEM_PREFERENCE || + (THEME_MODES as readonly unknown[]).includes(value) + ); +} + +export function resolveThemeMode( + preference: ThemePreference, + systemPrefersDark: boolean, +): ThemeMode { + if (preference !== "system") { + return preference; + } + return systemPrefersDark ? "dark" : DEFAULT_THEME_MODE; +} + +/** Reads the stored preference; any missing, unknown or unreadable value means "system". */ +export function readThemePreference( + storage: Pick | null | undefined, +): ThemePreference { + if (!storage) { + return SYSTEM_PREFERENCE; + } + try { + const stored = storage.getItem(THEME_STORAGE_KEY); + return isThemePreference(stored) ? stored : SYSTEM_PREFERENCE; + } catch { + return SYSTEM_PREFERENCE; + } +} + +/** Stores the preference; storage that is absent or refuses the write is ignored. */ +export function writeThemePreference( + storage: Pick | null | undefined, + preference: ThemePreference, +): void { + if (!storage) { + return; + } + try { + storage.setItem(THEME_STORAGE_KEY, preference); + } catch { + // Private mode or a full quota: the preference simply does not persist. + } +} diff --git a/frontend/src/v1/lib/tokens.test.ts b/frontend/src/v1/lib/tokens.test.ts new file mode 100644 index 0000000000..d1fe000ac8 --- /dev/null +++ b/frontend/src/v1/lib/tokens.test.ts @@ -0,0 +1,41 @@ +import { expect, it } from "vitest"; +import { + renderTokensCss, + renderTokenTable, + sameInEveryMode, + type Token, +} from "./tokens"; + +const radius: Token = { + family: "radius", + name: "radius-sm", + cssVar: "--radius-sm", + utility: "ui:rounded-sm", + values: sameInEveryMode("6px"), +}; +const bg: Token = { + family: "colour", + name: "background", + cssVar: "--background", + utility: "ui:bg-background", + values: { light: "#fafafa", dark: "#1a1a1a", unstract: "#0f0a1f" }, +}; + +it("renders one scoped block per mode, each with its color-scheme and every token", () => { + expect(renderTokensCss([bg, radius])).toBe( + "/* Generated by scripts/build-v1-tokens.mjs from src/v1/styles/tokens.json. Do not edit. */\n" + + '.v1[data-theme="light"] {\n color-scheme: light;\n --background: #fafafa;\n --radius-sm: 6px;\n}\n' + + '.v1[data-theme="dark"] {\n color-scheme: dark;\n --background: #1a1a1a;\n --radius-sm: 6px;\n}\n' + + '.v1[data-theme="unstract"] {\n color-scheme: light;\n --background: #0f0a1f;\n --radius-sm: 6px;\n}\n', + ); +}); + +it("renders the gallery table with one row per token", () => { + const table = renderTokenTable([bg]); + expect(table.split("\n")[0]).toBe( + "| Family | Token | CSS variable | Utility | Light | Dark | Unstract |", + ); + expect(table).toContain( + "| colour | background | `--background` | `ui:bg-background` | #fafafa | #1a1a1a | #0f0a1f |", + ); +}); diff --git a/frontend/src/v1/lib/tokens.ts b/frontend/src/v1/lib/tokens.ts new file mode 100644 index 0000000000..6b0f010a35 --- /dev/null +++ b/frontend/src/v1/lib/tokens.ts @@ -0,0 +1,64 @@ +import { THEME_COLOR_SCHEMES, THEME_MODES, type ThemeMode } from "./theme"; + +export type TokenFamily = + | "colour" + | "spacing" + | "radius" + | "type" + | "elevation" + | "focus" + | "font"; + +/** One design token: a runtime CSS variable with a value in every theme mode. */ +export type Token = Readonly<{ + family: TokenFamily; + name: string; + cssVar: `--${string}`; + utility: string; + values: Readonly>; + note?: string; +}>; + +const CSS_HEADER = + "/* Generated by scripts/build-v1-tokens.mjs from src/v1/styles/tokens.json. Do not edit. */"; + +const TABLE_HEADER = + "| Family | Token | CSS variable | Utility | Light | Dark | Unstract |"; +const TABLE_SEPARATOR = "| --- | --- | --- | --- | --- | --- | --- |"; + +export function sameInEveryMode( + value: string, +): Readonly> { + return { light: value, dark: value, unstract: value }; +} + +/** + * One `.v1[data-theme=…]` block per mode: the mode's `color-scheme` (so native + * controls, scrollbars and autofill follow data-theme, not the OS), then every + * token in source order. + */ +export function renderTokensCss(tokens: readonly Token[]): string { + const blocks = THEME_MODES.map((mode) => { + const declarations = tokens.map( + (token) => ` ${token.cssVar}: ${token.values[mode]};\n`, + ); + const scheme = ` color-scheme: ${THEME_COLOR_SCHEMES[mode]};\n`; + return `.v1[data-theme="${mode}"] {\n${scheme}${declarations.join("")}}\n`; + }); + return `${CSS_HEADER}\n${blocks.join("")}`; +} + +/** Markdown table of every token, one row each, for the gallery and the dev-spec snapshot. */ +export function renderTokenTable(tokens: readonly Token[]): string { + const rows = tokens.map((token) => { + const cells = [ + token.family, + token.name, + `\`${token.cssVar}\``, + `\`${token.utility}\``, + ...THEME_MODES.map((mode) => token.values[mode]), + ]; + return `| ${cells.join(" | ")} |\n`; + }); + return `${TABLE_HEADER}\n${TABLE_SEPARATOR}\n${rows.join("")}`; +} diff --git a/frontend/src/v1/stores/theme.test.ts b/frontend/src/v1/stores/theme.test.ts new file mode 100644 index 0000000000..5a7c1eb0f8 --- /dev/null +++ b/frontend/src/v1/stores/theme.test.ts @@ -0,0 +1,61 @@ +import { expect, it } from "vitest"; +import { createThemeStore } from "./theme"; + +function memoryStorage(initial: Record = {}) { + const data = { ...initial }; + return { + getItem: (k: string) => data[k] ?? null, + setItem: (k: string, v: string) => { + data[k] = v; + }, + data, + }; +} + +it("starts as system and resolves from the system flag", () => { + const store = createThemeStore({ + storage: memoryStorage(), + systemPrefersDark: true, + }); + expect(store.getState().preference).toBe("system"); + expect(store.getState().mode).toBe("dark"); +}); + +it("hydrate reads the stored preference and setPreference persists it", () => { + const storage = memoryStorage({ "unstract.v1.theme": "unstract" }); + const store = createThemeStore({ storage, systemPrefersDark: false }); + store.getState().hydrate(storage); + expect(store.getState().mode).toBe("unstract"); + store.getState().setPreference("dark"); + expect(store.getState().mode).toBe("dark"); + expect(storage.data["unstract.v1.theme"]).toBe("dark"); +}); + +it("follows system flips only while the preference is system", () => { + const store = createThemeStore({ + storage: memoryStorage(), + systemPrefersDark: false, + }); + store.getState().setSystemPrefersDark(true); + expect(store.getState().mode).toBe("dark"); + store.getState().setPreference("light"); + store.getState().setSystemPrefersDark(false); + store.getState().setSystemPrefersDark(true); + expect(store.getState().mode).toBe("light"); +}); + +it("falls back when storage throws", () => { + const broken = { + getItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + }; + const store = createThemeStore({ storage: broken, systemPrefersDark: false }); + expect(() => store.getState().hydrate(broken)).not.toThrow(); + expect(store.getState().preference).toBe("system"); + expect(() => store.getState().setPreference("dark")).not.toThrow(); + expect(store.getState().mode).toBe("dark"); +}); diff --git a/frontend/src/v1/stores/theme.ts b/frontend/src/v1/stores/theme.ts new file mode 100644 index 0000000000..001c6ce6e6 --- /dev/null +++ b/frontend/src/v1/stores/theme.ts @@ -0,0 +1,86 @@ +import { + readThemePreference, + resolveThemeMode, + type ThemeMode, + type ThemePreference, + writeThemePreference, +} from "@v1/lib/theme"; +import { create, type UseBoundStore } from "zustand"; +import { createStore, type StateCreator, type StoreApi } from "zustand/vanilla"; + +export type ThemeState = { + preference: ThemePreference; + systemPrefersDark: boolean; + /** Always resolveThemeMode(preference, systemPrefersDark). */ + mode: ThemeMode; + /** Sets the preference and persists it; a refused write is ignored. */ + setPreference: (preference: ThemePreference) => void; + setSystemPrefersDark: (value: boolean) => void; + /** Replaces the preference with the one in storage ("system" if none); defaults to the store's own storage. */ + hydrate: (storage?: Pick | null) => void; +}; + +export type ThemeStoreOptions = { + storage?: Pick | null; + systemPrefersDark?: boolean; +}; + +/** Theme state only; the DOM is updated by whoever subscribes (ThemeProvider). */ +function themeState({ + storage = null, + systemPrefersDark = false, +}: ThemeStoreOptions): StateCreator { + return (set) => ({ + preference: "system", + systemPrefersDark, + mode: resolveThemeMode("system", systemPrefersDark), + setPreference: (preference) => { + writeThemePreference(storage, preference); + set((state) => ({ + preference, + mode: resolveThemeMode(preference, state.systemPrefersDark), + })); + }, + setSystemPrefersDark: (value) => { + set((state) => ({ + systemPrefersDark: value, + mode: resolveThemeMode(state.preference, value), + })); + }, + hydrate: (source = storage) => { + const preference = readThemePreference(source); + set((state) => ({ + preference, + mode: resolveThemeMode(preference, state.systemPrefersDark), + })); + }, + }); +} + +/** A standalone store, for tests and isolated trees. */ +export function createThemeStore( + options: ThemeStoreOptions = {}, +): StoreApi { + return createStore()(themeState(options)); +} + +/** Reading localStorage can throw in sandboxed documents; treat that as no storage. */ +function browserStorage(): Storage | null { + if (typeof window === "undefined") { + return null; + } + try { + return window.localStorage; + } catch { + return null; + } +} + +/** The app's theme store; the provider binds the real prefers-color-scheme query. */ +export const useThemeStore: UseBoundStore> = + create()( + themeState({ storage: browserStorage(), systemPrefersDark: false }), + ); + +export const selectThemeMode = (s: ThemeState) => s.mode; +export const selectThemePreference = (s: ThemeState) => s.preference; diff --git a/frontend/src/v1/styles/__snapshots__/token-table.md b/frontend/src/v1/styles/__snapshots__/token-table.md new file mode 100644 index 0000000000..11bdfac7e6 --- /dev/null +++ b/frontend/src/v1/styles/__snapshots__/token-table.md @@ -0,0 +1,77 @@ +| Family | Token | CSS variable | Utility | Light | Dark | Unstract | +| --- | --- | --- | --- | --- | --- | --- | +| colour | background | `--background` | `ui:bg-background` | #fafafa | #1a1a1a | #f0f0f0 | +| colour | foreground | `--foreground` | `ui:text-foreground` | #1a1a1a | #fafafa | #1a1a1a | +| colour | card | `--card` | `ui:bg-card` | #fcfcfc | #19191a | #ffffff | +| colour | card-foreground | `--card-foreground` | `ui:text-card-foreground` | #1a1a1a | #fafafa | #1a1a1a | +| colour | popover | `--popover` | `ui:bg-popover` | #ffffff | #171717 | #ffffff | +| colour | popover-foreground | `--popover-foreground` | `ui:text-popover-foreground` | #19191a | #fafafa | #19191a | +| colour | primary | `--primary` | `ui:bg-primary` | #6f5cef | #6f5cef | #072b4d | +| colour | primary-foreground | `--primary-foreground` | `ui:text-primary-foreground` | #fafafa | #fafafa | #ffffff | +| colour | primary-muted | `--primary-muted` | `ui:bg-primary-muted` | #f1effd | #2a2445 | #e8eef4 | +| colour | secondary | `--secondary` | `ui:bg-secondary` | #97caf5 | rgb(255 255 255 / 0.06) | #f0f4f8 | +| colour | secondary-foreground | `--secondary-foreground` | `ui:text-secondary-foreground` | #19191a | #fafafa | #072b4d | +| colour | muted | `--muted` | `ui:bg-muted` | #f5f5f5 | #262626 | #f0f0f0 | +| colour | muted-foreground | `--muted-foreground` | `ui:text-muted-foreground` | #737373 | #a1a1a1 | #6b7280 | +| colour | accent | `--accent` | `ui:bg-accent` | #f5f5f5 | #2a2a2d | #e8eef4 | +| colour | accent-foreground | `--accent-foreground` | `ui:text-accent-foreground` | #6161cc | #c1c5fb | #072b4d | +| colour | destructive | `--destructive` | `ui:bg-destructive` | #b41b30 | #e5484d | #b41b30 | +| colour | destructive-foreground | `--destructive-foreground` | `ui:text-destructive-foreground` | #ffffff | #fafafa | #ffffff | +| colour | success | `--success` | `ui:bg-success` | #15803d | #4ade80 | #15803d | +| colour | warning | `--warning` | `ui:bg-warning` | #b45309 | #fbbf24 | #b45309 | +| colour | border | `--border` | `ui:border-border` | #e5e5e5 | #2e2e2e | #e4e7eb | +| colour | input | `--input` | `ui:border-input` | #d3d3d3 | #343434 | #d3d8de | +| colour | ring | `--ring` | `ui:ring-ring` | #6f5cef | #6f5cef | #0e3963 | +| colour | sidebar | `--sidebar` | `ui:bg-sidebar` | #ffffff | #171717 | #0e3963 | +| colour | sidebar-foreground | `--sidebar-foreground` | `ui:text-sidebar-foreground` | #1a1a1a | #fafafa | #ffffff | +| colour | sidebar-accent | `--sidebar-accent` | `ui:bg-sidebar-accent` | #f5f5f5 | #262626 | #1b5183 | +| colour | sidebar-accent-foreground | `--sidebar-accent-foreground` | `ui:text-sidebar-accent-foreground` | #1a1a1a | #fafafa | #ffffff | +| colour | sidebar-border | `--sidebar-border` | `ui:border-sidebar-border` | #e5e5e5 | #2e2e2e | #17436e | +| colour | sidebar-muted-foreground | `--sidebar-muted-foreground` | `ui:text-sidebar-muted-foreground` | #737373 | #a1a1aa | #a9c3da | +| colour | header | `--header` | `ui:bg-header` | #fcfcfc | #0a0a0a | #072b4d | +| colour | header-foreground | `--header-foreground` | `ui:text-header-foreground` | #1a1a1a | #fafafa | #ffffff | +| spacing | space-1 | `--space-1` | `ui:p-1` | 4px | 4px | 4px | +| spacing | space-2 | `--space-2` | `ui:p-2` | 8px | 8px | 8px | +| spacing | space-3 | `--space-3` | `ui:p-3` | 12px | 12px | 12px | +| spacing | space-4 | `--space-4` | `ui:p-4` | 16px | 16px | 16px | +| spacing | space-5 | `--space-5` | `ui:p-5` | 20px | 20px | 20px | +| spacing | space-6 | `--space-6` | `ui:p-6` | 24px | 24px | 24px | +| spacing | space-8 | `--space-8` | `ui:p-8` | 32px | 32px | 32px | +| radius | radius-sm | `--radius-sm` | `ui:rounded-sm` | 6px | 6px | 6px | +| radius | radius-md | `--radius-md` | `ui:rounded-md` | 8px | 8px | 8px | +| radius | radius-lg | `--radius-lg` | `ui:rounded-lg` | 10px | 10px | 10px | +| radius | radius-xl | `--radius-xl` | `ui:rounded-xl` | 14px | 14px | 14px | +| type | type-display-size | `--type-display-size` | `ui:text-display` | 34px | 34px | 34px | +| type | type-display-line-height | `--type-display-line-height` | `ui:text-display` | 46px | 46px | 46px | +| type | type-display-weight | `--type-display-weight` | `ui:text-display` | 600 | 600 | 600 | +| type | type-page-title-size | `--type-page-title-size` | `ui:text-page-title` | 20px | 20px | 20px | +| type | type-page-title-line-height | `--type-page-title-line-height` | `ui:text-page-title` | 28px | 28px | 28px | +| type | type-page-title-weight | `--type-page-title-weight` | `ui:text-page-title` | 600 | 600 | 600 | +| type | type-section-title-size | `--type-section-title-size` | `ui:text-section-title` | 16px | 16px | 16px | +| type | type-section-title-line-height | `--type-section-title-line-height` | `ui:text-section-title` | 24px | 24px | 24px | +| type | type-section-title-weight | `--type-section-title-weight` | `ui:text-section-title` | 600 | 600 | 600 | +| type | type-body-size | `--type-body-size` | `ui:text-body` | 14px | 14px | 14px | +| type | type-body-line-height | `--type-body-line-height` | `ui:text-body` | 20px | 20px | 20px | +| type | type-body-weight | `--type-body-weight` | `ui:text-body` | 400 | 400 | 400 | +| type | type-body-small-size | `--type-body-small-size` | `ui:text-body-small` | 13px | 13px | 13px | +| type | type-body-small-line-height | `--type-body-small-line-height` | `ui:text-body-small` | 18px | 18px | 18px | +| type | type-body-small-weight | `--type-body-small-weight` | `ui:text-body-small` | 400 | 400 | 400 | +| type | type-caption-size | `--type-caption-size` | `ui:text-caption` | 12px | 12px | 12px | +| type | type-caption-line-height | `--type-caption-line-height` | `ui:text-caption` | 16px | 16px | 16px | +| type | type-caption-weight | `--type-caption-weight` | `ui:text-caption` | 400 | 400 | 400 | +| type | type-overline-size | `--type-overline-size` | `ui:text-overline` | 11px | 11px | 11px | +| type | type-overline-line-height | `--type-overline-line-height` | `ui:text-overline` | 16px | 16px | 16px | +| type | type-overline-weight | `--type-overline-weight` | `ui:text-overline` | 600 | 600 | 600 | +| type | type-mono-size | `--type-mono-size` | `ui:text-mono` | 12px | 12px | 12px | +| type | type-mono-line-height | `--type-mono-line-height` | `ui:text-mono` | 22px | 22px | 22px | +| type | type-mono-weight | `--type-mono-weight` | `ui:text-mono` | 400 | 400 | 400 | +| type | type-overline-tracking | `--type-overline-tracking` | `ui:text-overline` | 0.05em | 0.05em | 0.05em | +| elevation | shadow-sm | `--shadow-sm` | `ui:shadow-sm` | 0 1px 2px 0 rgb(0 0 0 / 0.05) | 0 1px 2px 0 rgb(0 0 0 / 0.05) | 0 1px 2px 0 rgb(0 0 0 / 0.05) | +| elevation | shadow-md | `--shadow-md` | `ui:shadow-md` | 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10) | 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10) | 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10) | +| elevation | shadow-lg | `--shadow-lg` | `ui:shadow-lg` | 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10) | 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10) | 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10) | +| elevation | shadow-xl | `--shadow-xl` | `ui:shadow-xl` | 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10) | 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10) | 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10) | +| elevation | scrim | `--scrim` | `ui:bg-scrim` | rgb(0 0 0 / 0.5) | rgb(0 0 0 / 0.5) | rgb(0 0 0 / 0.5) | +| focus | focus-ring-width | `--focus-ring-width` | `ui:focus-ring` | 3px | 3px | 3px | +| focus | focus-ring-alpha | `--focus-ring-alpha` | `ui:focus-ring` | 50% | 50% | 50% | +| font | font-sans | `--font-sans` | `ui:font-sans` | "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif | "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif | "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif | +| font | font-mono | `--font-mono` | `ui:font-mono` | "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace | "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace | "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace | diff --git a/frontend/src/v1/styles/base.css b/frontend/src/v1/styles/base.css new file mode 100644 index 0000000000..d4619d8328 --- /dev/null +++ b/frontend/src/v1/styles/base.css @@ -0,0 +1,29 @@ +/* What preflight would give the v1 tree, scoped to .v1 so v0 is untouched. + * The app root (data-ui-version) fills and paints its area; every .v1 element, + * the portal container included, only sets the inherited text defaults. */ +@layer base { + .v1[data-ui-version="v1"] { + min-height: 100%; + background-color: var(--background); + } + .v1 { + color: var(--foreground); + font-family: var(--font-sans); + font-size: var(--type-body-size); + line-height: var(--type-body-line-height); + font-weight: var(--type-body-weight); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + .v1 *, + .v1 ::before, + .v1 ::after { + border-color: var(--border); + } + .v1 code, + .v1 kbd, + .v1 pre, + .v1 samp { + font-family: var(--font-mono); + } +} diff --git a/frontend/src/v1/styles/index.css b/frontend/src/v1/styles/index.css new file mode 100644 index 0000000000..c019504115 --- /dev/null +++ b/frontend/src/v1/styles/index.css @@ -0,0 +1,135 @@ +/* The v1 Tailwind root. Prefixed `ui` (Tailwind accepts letters only, so not + * `v1`): utilities are `ui:…` and theme variables `--ui-…`, so they never + * collide with v0's, which compiles from src/index.css in the same document. + * Preflight is deliberately not imported: the document already has v0's, and + * a second copy would reset v0's border colours. P9 adds it when v0 goes. */ +@layer theme, base, components, utilities; +@import "tailwindcss/theme.css" layer(theme) prefix(ui); +@import "tailwindcss/utilities.css" layer(utilities) prefix(ui) source(none); + +@import "@fontsource/inter/400.css"; +@import "@fontsource/inter/500.css"; +@import "@fontsource/inter/600.css"; +@import "@fontsource/geist-mono/400.css"; + +@import "./tokens.css"; +@import "./base.css"; + +/* Scan only the v1 tree (source(none) above turns off automatic detection). */ +@source "../"; + +/* Keyed on the v1 root, so a data-theme elsewhere (v0's ) cannot flip them. */ +@custom-variant dark (&:where( + .v1[data-theme="dark"], + .v1[data-theme="dark"] * + )); +@custom-variant unstract (&:where( + .v1[data-theme="unstract"], + .v1[data-theme="unstract"] * + )); + +/* Every theme key reads a runtime token from tokens.css; the stock namespaces + * are reset so only the token-backed utilities exist. */ +@theme inline { + --color-*: initial; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-primary-muted: var(--primary-muted); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-warning: var(--warning); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-muted-foreground: var(--sidebar-muted-foreground); + --color-header: var(--header); + --color-header-foreground: var(--header-foreground); + --color-scrim: var(--scrim); + + /* --spacing is the base every bare step multiplies (ui:w-64 is 64 × 4px); + * each named step reads its own token, so ui:p-5 is var(--space-5). */ + --spacing: var(--space-1); + --spacing-1: var(--space-1); + --spacing-2: var(--space-2); + --spacing-3: var(--space-3); + --spacing-4: var(--space-4); + --spacing-5: var(--space-5); + --spacing-6: var(--space-6); + --spacing-8: var(--space-8); + + --radius-*: initial; + --radius-sm: var(--radius-sm); + --radius-md: var(--radius-md); + --radius-lg: var(--radius-lg); + --radius-xl: var(--radius-xl); + + --shadow-*: initial; + --shadow-sm: var(--shadow-sm); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --inset-shadow-*: initial; + --drop-shadow-*: initial; + --text-shadow-*: initial; + + --text-*: initial; + --text-display: var(--type-display-size); + --text-display--line-height: var(--type-display-line-height); + --text-display--font-weight: var(--type-display-weight); + --text-page-title: var(--type-page-title-size); + --text-page-title--line-height: var(--type-page-title-line-height); + --text-page-title--font-weight: var(--type-page-title-weight); + --text-section-title: var(--type-section-title-size); + --text-section-title--line-height: var(--type-section-title-line-height); + --text-section-title--font-weight: var(--type-section-title-weight); + --text-body: var(--type-body-size); + --text-body--line-height: var(--type-body-line-height); + --text-body--font-weight: var(--type-body-weight); + --text-body-small: var(--type-body-small-size); + --text-body-small--line-height: var(--type-body-small-line-height); + --text-body-small--font-weight: var(--type-body-small-weight); + --text-caption: var(--type-caption-size); + --text-caption--line-height: var(--type-caption-line-height); + --text-caption--font-weight: var(--type-caption-weight); + --text-overline: var(--type-overline-size); + --text-overline--line-height: var(--type-overline-line-height); + --text-overline--font-weight: var(--type-overline-weight); + --text-mono: var(--type-mono-size); + --text-mono--line-height: var(--type-mono-line-height); + --text-mono--font-weight: var(--type-mono-weight); + --text-overline--letter-spacing: var(--type-overline-tracking); + + --font-*: initial; + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); +} + +/* Board section 6: "border → ring, plus 3px ring at 50% outside". Use it as + * `ui:focus-visible:focus-ring`; a bare `ui:focus-ring` rings the element all + * the time. The transparent outline is what forced-colors mode paints (it + * drops box-shadow), so the ring stays visible in Windows High Contrast. */ +@utility focus-ring { + outline: 2px solid transparent; /* v1-styles-allow: forced-colors outline */ + outline-offset: 2px; /* v1-styles-allow: forced-colors outline */ + border-color: var(--ring); + box-shadow: 0 0 0 var(--focus-ring-width) + color-mix(in srgb, var(--ring) var(--focus-ring-alpha), transparent); +} diff --git a/frontend/src/v1/styles/root.test.ts b/frontend/src/v1/styles/root.test.ts new file mode 100644 index 0000000000..4cee79e0e3 --- /dev/null +++ b/frontend/src/v1/styles/root.test.ts @@ -0,0 +1,183 @@ +import fs from "node:fs"; +import path from "node:path"; +import { compile } from "@tailwindcss/node"; +import { beforeAll, expect, it } from "vitest"; +import { TOKENS } from "./tokens"; + +const base = path.resolve(import.meta.dirname); +const source = fs.readFileSync(path.join(base, "index.css"), "utf8"); +const CANDIDATES = [ + "ui:bg-background", + "ui:text-foreground", + "ui:rounded-md", + "ui:shadow-md", + "ui:bg-scrim", + "ui:text-body", + "ui:text-overline", + "ui:font-mono", + "ui:focus-ring", + "ui:dark:bg-card", + "ui:unstract:bg-card", + "ui:p-5", + "ui:gap-3", + "ui:w-64", + "ui:size-4", + "bg-background", + "flex", + "ui:bg-red-500", + "ui:text-sm", + "ui:shadow-2xl", + "ui:rounded-2xl", + "ui:drop-shadow-lg", + "ui:inset-shadow-sm", + "ui:text-shadow-lg", +]; + +async function build(stylesheet: string): Promise { + const compiler = await compile(stylesheet, { + base, + onDependency: () => undefined, + }); + return compiler.build(CANDIDATES); +} + +/** Split a selector list on its top-level commas (not those inside :where() or [..]). */ +function selectorParts(list: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const ch of list) { + if (ch === "(" || ch === "[") { + depth++; + } + if (ch === ")" || ch === "]") { + depth--; + } + if (ch === "," && depth === 0) { + parts.push(current.trim()); + current = ""; + } else { + current += ch; + } + } + parts.push(current.trim()); + return parts; +} + +// Each part must be a .v1-scoped selector, a ui: utility, or Tailwind's +// :root/:host theme layer and *, ::before, ::after, ::backdrop property defaults. +const ALLOWED_PART = + /^(\.v1(?=[\s.:[#>~+]|$)|\.ui\\:|:root$|:host$|\*$|::before$|::after$|::backdrop$)/; + +function unscopedParts(output: string): string[] { + return [...output.matchAll(/^\s*([^@{}\n][^{}\n]*)\{/gm)] + .map((m) => m[1].trim()) + .filter((list) => !list.startsWith("@")) + .flatMap(selectorParts) + .filter((part) => !ALLOWED_PART.test(part)); +} + +let css = ""; +beforeAll(async () => { + css = await build(source); +}); + +it("emits only prefixed utilities bound to runtime variables", () => { + expect(css).toMatch( + /\.ui\\:bg-background\s*\{[^}]*background-color:\s*var\(--background\)/, + ); + expect(css).toMatch( + /\.ui\\:rounded-md\s*\{[^}]*border-radius:\s*var\(--radius-md\)/, + ); + expect(css).toMatch(/\.ui\\:shadow-md\s*\{/); + expect(css).toMatch( + /\.ui\\:text-body\s*\{[^}]*font-size:\s*var\(--type-body-size\)/, + ); + expect(css).toMatch( + // Tailwind wraps the tracking in --tw-tracking so a tracking-* utility can override it. + /\.ui\\:text-overline\s*\{[^}]*letter-spacing:\s*var\((--tw-tracking,\s*var\()?--type-overline-tracking\)/, + ); + expect(css).not.toMatch(/(^|[^\\:])\.bg-background\b/); + expect(css).not.toMatch(/(^|[^\\:])\.flex\b/); +}); + +it("spacing utilities read the spacing tokens: named steps directly, bare steps through --space-1", () => { + expect(css).toMatch(/\.ui\\:p-5\s*\{[^}]*padding:\s*var\(--space-5\)/); + expect(css).toMatch(/\.ui\\:gap-3\s*\{[^}]*gap:\s*var\(--space-3\)/); + expect(css).toMatch( + /\.ui\\:w-64\s*\{[^}]*width:\s*calc\(var\(--space-1\)\s*\*\s*64\)/, + ); + expect(css).toMatch( + /\.ui\\:size-4\s*\{[^}]*width:\s*(?:calc\(var\(--space-1\)\s*\*\s*4\)|var\(--space-4\))/, + ); +}); + +it("each mode block sets the browser's color-scheme, so native controls follow data-theme", () => { + expect(css).toMatch( + /\.v1\[data-theme="?light"?\]\s*\{\s*color-scheme:\s*light;/, + ); + expect(css).toMatch( + /\.v1\[data-theme="?dark"?\]\s*\{\s*color-scheme:\s*dark;/, + ); + expect(css).toMatch( + /\.v1\[data-theme="?unstract"?\]\s*\{\s*color-scheme:\s*light;/, + ); +}); + +it("has no stock palette, text scale, shadow scales or radius scale", () => { + expect(css).not.toContain("bg-red-500"); + expect(css).not.toContain("text-sm"); + expect(css).not.toContain("shadow-2xl"); + expect(css).not.toContain("rounded-2xl"); + expect(css).not.toContain("drop-shadow-lg"); + expect(css).not.toContain("inset-shadow-sm"); + expect(css).not.toContain("text-shadow-lg"); +}); + +it("the focus ring keeps a transparent outline for forced-colors mode beside the box-shadow ring", () => { + const rule = css.match(/\.ui\\:focus-ring\s*\{([^}]*)\}/)?.[1] ?? ""; + expect(rule).toMatch(/outline(-style)?:\s*(solid|2px solid transparent)/); + expect(rule).toContain("border-color: var(--ring)"); + expect(rule).toContain( + "color-mix(in srgb, var(--ring) var(--focus-ring-alpha), transparent)", + ); +}); + +it("dark and unstract variants key on the v1 root's data-theme, never on prefers-color-scheme", () => { + // Name the variant rules and their binding: tokens.css alone also contains + // [data-theme=…] selectors, so matching those would pass with no variants. + expect(css).toMatch( + /\.ui\\:dark\\:bg-card:where\(\.v1\[data-theme="?dark"?\],\s*\.v1\[data-theme="?dark"?\]\s*\*\s*\)\s*\{[^}]*background-color:\s*var\(--card\)/, + ); + expect(css).toMatch( + /\.ui\\:unstract\\:bg-card:where\(\.v1\[data-theme="?unstract"?\],\s*\.v1\[data-theme="?unstract"?\]\s*\*\s*\)\s*\{[^}]*background-color:\s*var\(--card\)/, + ); + expect(css).not.toContain("prefers-color-scheme"); +}); + +it("every emitted selector is a ui: utility, scoped to .v1, or Tailwind's :root/property defaults", async () => { + expect(unscopedParts(css)).toEqual([]); + // Negative proof: a leak hidden after a scoped selector in a list is caught. + const leaky = await build( + `${source}\n.v1 p, body { color: var(--foreground); }\n`, + ); + expect(unscopedParts(leaky)).toEqual(["body"]); +}); + +it("declares no !important, and every custom property is a Tailwind ui variable or a runtime token", () => { + expect(css).not.toContain("!important"); + const tokenVars = new Set(TOKENS.map((t) => t.cssVar)); + const declared = [...css.matchAll(/^\s*(--[\w-]+):/gm)].map((m) => m[1]); + const strays = declared.filter( + (v) => + !v.startsWith("--ui-") && !v.startsWith("--tw-") && !tokenVars.has(v), + ); + expect(strays).toEqual([]); +}); + +it("only the app root fills and paints its area, so the portal container is not a painted block", () => { + expect(css).toMatch( + /\.v1\[data-ui-version="?v1"?\]\s*\{\s*min-height:\s*100%;\s*background-color:\s*var\(--background\);?\s*\}/, + ); + expect(css).not.toMatch(/\.v1\s*\{[^}]*(min-height|background-color)/); +}); diff --git a/frontend/src/v1/styles/tokens.css b/frontend/src/v1/styles/tokens.css new file mode 100644 index 0000000000..b48b2fb422 --- /dev/null +++ b/frontend/src/v1/styles/tokens.css @@ -0,0 +1,235 @@ +/* Generated by scripts/build-v1-tokens.mjs from src/v1/styles/tokens.json. Do not edit. */ +.v1[data-theme="light"] { + color-scheme: light; + --background: #fafafa; + --foreground: #1a1a1a; + --card: #fcfcfc; + --card-foreground: #1a1a1a; + --popover: #ffffff; + --popover-foreground: #19191a; + --primary: #6f5cef; + --primary-foreground: #fafafa; + --primary-muted: #f1effd; + --secondary: #97caf5; + --secondary-foreground: #19191a; + --muted: #f5f5f5; + --muted-foreground: #737373; + --accent: #f5f5f5; + --accent-foreground: #6161cc; + --destructive: #b41b30; + --destructive-foreground: #ffffff; + --success: #15803d; + --warning: #b45309; + --border: #e5e5e5; + --input: #d3d3d3; + --ring: #6f5cef; + --sidebar: #ffffff; + --sidebar-foreground: #1a1a1a; + --sidebar-accent: #f5f5f5; + --sidebar-accent-foreground: #1a1a1a; + --sidebar-border: #e5e5e5; + --sidebar-muted-foreground: #737373; + --header: #fcfcfc; + --header-foreground: #1a1a1a; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 10px; + --radius-xl: 14px; + --type-display-size: 34px; + --type-display-line-height: 46px; + --type-display-weight: 600; + --type-page-title-size: 20px; + --type-page-title-line-height: 28px; + --type-page-title-weight: 600; + --type-section-title-size: 16px; + --type-section-title-line-height: 24px; + --type-section-title-weight: 600; + --type-body-size: 14px; + --type-body-line-height: 20px; + --type-body-weight: 400; + --type-body-small-size: 13px; + --type-body-small-line-height: 18px; + --type-body-small-weight: 400; + --type-caption-size: 12px; + --type-caption-line-height: 16px; + --type-caption-weight: 400; + --type-overline-size: 11px; + --type-overline-line-height: 16px; + --type-overline-weight: 600; + --type-mono-size: 12px; + --type-mono-line-height: 22px; + --type-mono-weight: 400; + --type-overline-tracking: 0.05em; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10); + --scrim: rgb(0 0 0 / 0.5); + --focus-ring-width: 3px; + --focus-ring-alpha: 50%; + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; +} +.v1[data-theme="dark"] { + color-scheme: dark; + --background: #1a1a1a; + --foreground: #fafafa; + --card: #19191a; + --card-foreground: #fafafa; + --popover: #171717; + --popover-foreground: #fafafa; + --primary: #6f5cef; + --primary-foreground: #fafafa; + --primary-muted: #2a2445; + --secondary: rgb(255 255 255 / 0.06); + --secondary-foreground: #fafafa; + --muted: #262626; + --muted-foreground: #a1a1a1; + --accent: #2a2a2d; + --accent-foreground: #c1c5fb; + --destructive: #e5484d; + --destructive-foreground: #fafafa; + --success: #4ade80; + --warning: #fbbf24; + --border: #2e2e2e; + --input: #343434; + --ring: #6f5cef; + --sidebar: #171717; + --sidebar-foreground: #fafafa; + --sidebar-accent: #262626; + --sidebar-accent-foreground: #fafafa; + --sidebar-border: #2e2e2e; + --sidebar-muted-foreground: #a1a1aa; + --header: #0a0a0a; + --header-foreground: #fafafa; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 10px; + --radius-xl: 14px; + --type-display-size: 34px; + --type-display-line-height: 46px; + --type-display-weight: 600; + --type-page-title-size: 20px; + --type-page-title-line-height: 28px; + --type-page-title-weight: 600; + --type-section-title-size: 16px; + --type-section-title-line-height: 24px; + --type-section-title-weight: 600; + --type-body-size: 14px; + --type-body-line-height: 20px; + --type-body-weight: 400; + --type-body-small-size: 13px; + --type-body-small-line-height: 18px; + --type-body-small-weight: 400; + --type-caption-size: 12px; + --type-caption-line-height: 16px; + --type-caption-weight: 400; + --type-overline-size: 11px; + --type-overline-line-height: 16px; + --type-overline-weight: 600; + --type-mono-size: 12px; + --type-mono-line-height: 22px; + --type-mono-weight: 400; + --type-overline-tracking: 0.05em; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10); + --scrim: rgb(0 0 0 / 0.5); + --focus-ring-width: 3px; + --focus-ring-alpha: 50%; + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; +} +.v1[data-theme="unstract"] { + color-scheme: light; + --background: #f0f0f0; + --foreground: #1a1a1a; + --card: #ffffff; + --card-foreground: #1a1a1a; + --popover: #ffffff; + --popover-foreground: #19191a; + --primary: #072b4d; + --primary-foreground: #ffffff; + --primary-muted: #e8eef4; + --secondary: #f0f4f8; + --secondary-foreground: #072b4d; + --muted: #f0f0f0; + --muted-foreground: #6b7280; + --accent: #e8eef4; + --accent-foreground: #072b4d; + --destructive: #b41b30; + --destructive-foreground: #ffffff; + --success: #15803d; + --warning: #b45309; + --border: #e4e7eb; + --input: #d3d8de; + --ring: #0e3963; + --sidebar: #0e3963; + --sidebar-foreground: #ffffff; + --sidebar-accent: #1b5183; + --sidebar-accent-foreground: #ffffff; + --sidebar-border: #17436e; + --sidebar-muted-foreground: #a9c3da; + --header: #072b4d; + --header-foreground: #ffffff; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 10px; + --radius-xl: 14px; + --type-display-size: 34px; + --type-display-line-height: 46px; + --type-display-weight: 600; + --type-page-title-size: 20px; + --type-page-title-line-height: 28px; + --type-page-title-weight: 600; + --type-section-title-size: 16px; + --type-section-title-line-height: 24px; + --type-section-title-weight: 600; + --type-body-size: 14px; + --type-body-line-height: 20px; + --type-body-weight: 400; + --type-body-small-size: 13px; + --type-body-small-line-height: 18px; + --type-body-small-weight: 400; + --type-caption-size: 12px; + --type-caption-line-height: 16px; + --type-caption-weight: 400; + --type-overline-size: 11px; + --type-overline-line-height: 16px; + --type-overline-weight: 600; + --type-mono-size: 12px; + --type-mono-line-height: 22px; + --type-mono-weight: 400; + --type-overline-tracking: 0.05em; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10); + --scrim: rgb(0 0 0 / 0.5); + --focus-ring-width: 3px; + --focus-ring-alpha: 50%; + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; +} diff --git a/frontend/src/v1/styles/tokens.json b/frontend/src/v1/styles/tokens.json new file mode 100644 index 0000000000..720a553970 --- /dev/null +++ b/frontend/src/v1/styles/tokens.json @@ -0,0 +1,547 @@ +{ + "colour": [ + { + "name": "background", + "utility": "ui:bg-background", + "values": { + "light": "#fafafa", + "dark": "#1a1a1a", + "unstract": "#f0f0f0" + }, + "note": "page surface" + }, + { + "name": "foreground", + "utility": "ui:text-foreground", + "values": { + "light": "#1a1a1a", + "dark": "#fafafa", + "unstract": "#1a1a1a" + }, + "note": "default text" + }, + { + "name": "card", + "utility": "ui:bg-card", + "values": { + "light": "#fcfcfc", + "dark": "#19191a", + "unstract": "#ffffff" + }, + "note": "every card and dialog surface; cards sit on background, not on muted" + }, + { + "name": "card-foreground", + "utility": "ui:text-card-foreground", + "values": { + "light": "#1a1a1a", + "dark": "#fafafa", + "unstract": "#1a1a1a" + } + }, + { + "name": "popover", + "utility": "ui:bg-popover", + "values": { + "light": "#ffffff", + "dark": "#171717", + "unstract": "#ffffff" + } + }, + { + "name": "popover-foreground", + "utility": "ui:text-popover-foreground", + "values": { + "light": "#19191a", + "dark": "#fafafa", + "unstract": "#19191a" + } + }, + { + "name": "primary", + "utility": "ui:bg-primary", + "values": { + "light": "#6f5cef", + "dark": "#6f5cef", + "unstract": "#072b4d" + }, + "note": "primary buttons, active tabs, selected icon buttons" + }, + { + "name": "primary-foreground", + "utility": "ui:text-primary-foreground", + "values": { + "light": "#fafafa", + "dark": "#fafafa", + "unstract": "#ffffff" + } + }, + { + "name": "primary-muted", + "utility": "ui:bg-primary-muted", + "values": { + "light": "#f1effd", + "dark": "#2a2445", + "unstract": "#e8eef4" + }, + "note": "selected rows, active flow steps, selected model strip; added for Unstract" + }, + { + "name": "secondary", + "utility": "ui:bg-secondary", + "values": { + "light": "#97caf5", + "dark": "rgb(255 255 255 / 0.06)", + "unstract": "#f0f4f8" + } + }, + { + "name": "secondary-foreground", + "utility": "ui:text-secondary-foreground", + "values": { + "light": "#19191a", + "dark": "#fafafa", + "unstract": "#072b4d" + } + }, + { + "name": "muted", + "utility": "ui:bg-muted", + "values": { + "light": "#f5f5f5", + "dark": "#262626", + "unstract": "#f0f0f0" + }, + "note": "inset surfaces: table headers, code blocks, dropzones, skeletons" + }, + { + "name": "muted-foreground", + "utility": "ui:text-muted-foreground", + "values": { + "light": "#737373", + "dark": "#a1a1a1", + "unstract": "#6b7280" + }, + "note": "secondary text" + }, + { + "name": "accent", + "utility": "ui:bg-accent", + "values": { + "light": "#f5f5f5", + "dark": "#2a2a2d", + "unstract": "#e8eef4" + }, + "note": "hover surface on rails, menu items and icon buttons" + }, + { + "name": "accent-foreground", + "utility": "ui:text-accent-foreground", + "values": { + "light": "#6161cc", + "dark": "#c1c5fb", + "unstract": "#072b4d" + } + }, + { + "name": "destructive", + "utility": "ui:bg-destructive", + "values": { + "light": "#b41b30", + "dark": "#e5484d", + "unstract": "#b41b30" + }, + "note": "delete actions and failure states; never a warning" + }, + { + "name": "destructive-foreground", + "utility": "ui:text-destructive-foreground", + "values": { + "light": "#ffffff", + "dark": "#fafafa", + "unstract": "#ffffff" + } + }, + { + "name": "success", + "utility": "ui:bg-success", + "values": { + "light": "#15803d", + "dark": "#4ade80", + "unstract": "#15803d" + } + }, + { + "name": "warning", + "utility": "ui:bg-warning", + "values": { + "light": "#b45309", + "dark": "#fbbf24", + "unstract": "#b45309" + } + }, + { + "name": "border", + "utility": "ui:border-border", + "values": { + "light": "#e5e5e5", + "dark": "#2e2e2e", + "unstract": "#e4e7eb" + }, + "note": "every 1px rule and control outline" + }, + { + "name": "input", + "utility": "ui:border-input", + "values": { + "light": "#d3d3d3", + "dark": "#343434", + "unstract": "#d3d8de" + } + }, + { + "name": "ring", + "utility": "ui:ring-ring", + "values": { + "light": "#6f5cef", + "dark": "#6f5cef", + "unstract": "#0e3963" + }, + "note": "focus outline colour, paired with a 3px ring at 50% alpha" + }, + { + "name": "sidebar", + "utility": "ui:bg-sidebar", + "values": { + "light": "#ffffff", + "dark": "#171717", + "unstract": "#0e3963" + }, + "note": "the rail and both nav bars" + }, + { + "name": "sidebar-foreground", + "utility": "ui:text-sidebar-foreground", + "values": { + "light": "#1a1a1a", + "dark": "#fafafa", + "unstract": "#ffffff" + } + }, + { + "name": "sidebar-accent", + "utility": "ui:bg-sidebar-accent", + "values": { + "light": "#f5f5f5", + "dark": "#262626", + "unstract": "#1b5183" + } + }, + { + "name": "sidebar-accent-foreground", + "utility": "ui:text-sidebar-accent-foreground", + "values": { + "light": "#1a1a1a", + "dark": "#fafafa", + "unstract": "#ffffff" + } + }, + { + "name": "sidebar-border", + "utility": "ui:border-sidebar-border", + "values": { + "light": "#e5e5e5", + "dark": "#2e2e2e", + "unstract": "#17436e" + } + }, + { + "name": "sidebar-muted-foreground", + "utility": "ui:text-sidebar-muted-foreground", + "values": { + "light": "#737373", + "dark": "#a1a1aa", + "unstract": "#a9c3da" + } + }, + { + "name": "header", + "utility": "ui:bg-header", + "values": { + "light": "#fcfcfc", + "dark": "#0a0a0a", + "unstract": "#072b4d" + } + }, + { + "name": "header-foreground", + "utility": "ui:text-header-foreground", + "values": { + "light": "#1a1a1a", + "dark": "#fafafa", + "unstract": "#ffffff" + } + } + ], + "spacing": [ + { + "name": "space-1", + "utility": "ui:p-1", + "values": "4px", + "note": "icon-to-label inside a dense chip" + }, + { + "name": "space-2", + "utility": "ui:p-2", + "values": "8px", + "note": "label to control, stacked text inside a cell" + }, + { + "name": "space-3", + "utility": "ui:p-3", + "values": "12px", + "note": "control to control inside a group" + }, + { + "name": "space-4", + "utility": "ui:p-4", + "values": "16px", + "note": "card gutter, list row rhythm" + }, + { + "name": "space-5", + "utility": "ui:p-5", + "values": "20px", + "note": "card inner padding, compact" + }, + { + "name": "space-6", + "utility": "ui:p-6", + "values": "24px", + "note": "card inner padding, default · page padding y" + }, + { + "name": "space-8", + "utility": "ui:p-8", + "values": "32px", + "note": "page padding x, section break" + } + ], + "radius": [ + { + "name": "radius-sm", + "utility": "ui:rounded-sm", + "values": "6px", + "note": "badge, pill, status chip, small icon button" + }, + { + "name": "radius-md", + "utility": "ui:rounded-md", + "values": "8px", + "note": "button, input, select, textarea, menu item" + }, + { + "name": "radius-lg", + "utility": "ui:rounded-lg", + "values": "10px", + "note": "card, dialog, menu, popover, dropzone" + }, + { + "name": "radius-xl", + "utility": "ui:rounded-xl", + "values": "14px", + "note": "empty-state icon badge" + } + ], + "type": [ + { + "name": "type-display-size", + "utility": "ui:text-display", + "values": "34px" + }, + { + "name": "type-display-line-height", + "utility": "ui:text-display", + "values": "46px" + }, + { + "name": "type-display-weight", + "utility": "ui:text-display", + "values": "600" + }, + { + "name": "type-page-title-size", + "utility": "ui:text-page-title", + "values": "20px" + }, + { + "name": "type-page-title-line-height", + "utility": "ui:text-page-title", + "values": "28px" + }, + { + "name": "type-page-title-weight", + "utility": "ui:text-page-title", + "values": "600" + }, + { + "name": "type-section-title-size", + "utility": "ui:text-section-title", + "values": "16px" + }, + { + "name": "type-section-title-line-height", + "utility": "ui:text-section-title", + "values": "24px" + }, + { + "name": "type-section-title-weight", + "utility": "ui:text-section-title", + "values": "600" + }, + { + "name": "type-body-size", + "utility": "ui:text-body", + "values": "14px" + }, + { + "name": "type-body-line-height", + "utility": "ui:text-body", + "values": "20px" + }, + { + "name": "type-body-weight", + "utility": "ui:text-body", + "values": "400" + }, + { + "name": "type-body-small-size", + "utility": "ui:text-body-small", + "values": "13px" + }, + { + "name": "type-body-small-line-height", + "utility": "ui:text-body-small", + "values": "18px" + }, + { + "name": "type-body-small-weight", + "utility": "ui:text-body-small", + "values": "400" + }, + { + "name": "type-caption-size", + "utility": "ui:text-caption", + "values": "12px" + }, + { + "name": "type-caption-line-height", + "utility": "ui:text-caption", + "values": "16px" + }, + { + "name": "type-caption-weight", + "utility": "ui:text-caption", + "values": "400" + }, + { + "name": "type-overline-size", + "utility": "ui:text-overline", + "values": "11px", + "note": "Pair with ui:uppercase." + }, + { + "name": "type-overline-line-height", + "utility": "ui:text-overline", + "values": "16px", + "note": "Pair with ui:uppercase." + }, + { + "name": "type-overline-weight", + "utility": "ui:text-overline", + "values": "600", + "note": "Pair with ui:uppercase." + }, + { + "name": "type-mono-size", + "utility": "ui:text-mono", + "values": "12px", + "note": "Pair with ui:font-mono. Reserved for model output, JSON and token counts." + }, + { + "name": "type-mono-line-height", + "utility": "ui:text-mono", + "values": "22px", + "note": "Pair with ui:font-mono. Reserved for model output, JSON and token counts." + }, + { + "name": "type-mono-weight", + "utility": "ui:text-mono", + "values": "400", + "note": "Pair with ui:font-mono. Reserved for model output, JSON and token counts." + }, + { + "name": "type-overline-tracking", + "utility": "ui:text-overline", + "values": "0.05em", + "note": "Pair with ui:uppercase." + } + ], + "elevation": [ + { + "name": "shadow-sm", + "utility": "ui:shadow-sm", + "values": "0 1px 2px 0 rgb(0 0 0 / 0.05)", + "note": "every card at rest" + }, + { + "name": "shadow-md", + "utility": "ui:shadow-md", + "values": "0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10)", + "note": "dropdown menus, row action menus" + }, + { + "name": "shadow-lg", + "utility": "ui:shadow-lg", + "values": "0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10)", + "note": "popovers" + }, + { + "name": "shadow-xl", + "utility": "ui:shadow-xl", + "values": "0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10)", + "note": "dialogs" + }, + { + "name": "scrim", + "utility": "ui:bg-scrim", + "values": "rgb(0 0 0 / 0.5)", + "note": "behind every dialog, full viewport" + } + ], + "focus": [ + { + "name": "focus-ring-width", + "utility": "ui:focus-ring", + "values": "3px" + }, + { + "name": "focus-ring-alpha", + "utility": "ui:focus-ring", + "values": "50%" + } + ], + "font": [ + { + "name": "font-sans", + "utility": "ui:font-sans", + "values": "\"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", sans-serif" + }, + { + "name": "font-mono", + "utility": "ui:font-mono", + "values": "\"Geist Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace", + "note": "Reserved for model output, JSON and token counts." + } + ] +} diff --git a/frontend/src/v1/styles/tokens.test.ts b/frontend/src/v1/styles/tokens.test.ts new file mode 100644 index 0000000000..fdffce554e --- /dev/null +++ b/frontend/src/v1/styles/tokens.test.ts @@ -0,0 +1,129 @@ +import fs from "node:fs"; +import path from "node:path"; +import { TYPE_ROLES } from "@v1/lib/cn"; +import { THEME_MODES } from "@v1/lib/theme"; +import { renderTokensCss, renderTokenTable } from "@v1/lib/tokens"; +import { expect, it } from "vitest"; +import { TOKENS } from "./tokens"; + +it("every token is defined in every mode with a non-empty value", () => { + for (const token of TOKENS) { + for (const mode of THEME_MODES) { + expect(token.values[mode], `${token.name} in ${mode}`).toMatch(/\S/); + } + } +}); + +it("token names and CSS variables are unique and runtime variables never use Tailwind's --ui- namespace", () => { + expect(new Set(TOKENS.map((t) => t.name)).size).toBe(TOKENS.length); + expect(new Set(TOKENS.map((t) => t.cssVar)).size).toBe(TOKENS.length); + expect(TOKENS.filter((t) => t.cssVar.startsWith("--ui-"))).toEqual([]); +}); + +it("covers the families the board defines", () => { + const byFamily = (f: string) => + TOKENS.filter((t) => t.family === f).map((t) => t.name); + expect(byFamily("radius")).toEqual([ + "radius-sm", + "radius-md", + "radius-lg", + "radius-xl", + ]); + expect(byFamily("spacing")).toEqual([ + "space-1", + "space-2", + "space-3", + "space-4", + "space-5", + "space-6", + "space-8", + ]); + expect(byFamily("elevation")).toEqual([ + "shadow-sm", + "shadow-md", + "shadow-lg", + "shadow-xl", + "scrim", + ]); + expect(byFamily("type")).toHaveLength(25); // eight roles × (size, line-height, weight) + overline tracking + expect(byFamily("colour")).toHaveLength(30); // the Midnight Bloom collection minus its four palette entries + for (const name of [ + "background", + "foreground", + "card", + "popover", + "primary", + "primary-muted", + "secondary", + "muted", + "accent", + "destructive", + "success", + "warning", + "border", + "input", + "ring", + "sidebar", + "sidebar-muted-foreground", + "header", + ]) { + expect(byFamily("colour")).toContain(name); + } + for (const name of ["sky-300", "sky-500", "openai-green", "white"]) { + expect(byFamily("colour")).not.toContain(name); + } + expect(byFamily("font")).toEqual(["font-sans", "font-mono"]); + expect(byFamily("focus")).toEqual(["focus-ring-width", "focus-ring-alpha"]); +}); + +it("cn's type roles are exactly the roles of the type family, in order", () => { + const roles = TOKENS.filter((t) => t.family === "type").map((t) => + t.name + .replace(/^type-/, "") + .replace(/-(size|line-height|weight|tracking)$/, ""), + ); + expect([...TYPE_ROLES]).toEqual([...new Set(roles)]); +}); + +it("Unstract mode is the navy theme from the collection, not a copy of light", () => { + const value = (n: string) => TOKENS.find((t) => t.name === n)?.values; + expect(value("primary")).toEqual({ + light: "#6f5cef", + dark: "#6f5cef", + unstract: "#072b4d", + }); + expect(value("sidebar")).toEqual({ + light: "#ffffff", + dark: "#171717", + unstract: "#0e3963", + }); + expect(value("border")).toEqual({ + light: "#e5e5e5", + dark: "#2e2e2e", + unstract: "#e4e7eb", + }); +}); + +it("radii carry the four board values", () => { + const value = (n: string) => TOKENS.find((t) => t.name === n)?.values.light; + expect([ + value("radius-sm"), + value("radius-md"), + value("radius-lg"), + value("radius-xl"), + ]).toEqual(["6px", "8px", "10px", "14px"]); +}); + +it("generated tokens.css matches the source", () => { + const css = fs.readFileSync( + path.resolve(import.meta.dirname, "tokens.css"), + "utf8", + ); + expect(css).toBe(renderTokensCss(TOKENS)); +}); + +it("the token table matches the dev-spec snapshot", async () => { + await expect(renderTokenTable(TOKENS)).toMatchFileSnapshot( + "./__snapshots__/token-table.md", + ); +}); diff --git a/frontend/src/v1/styles/tokens.ts b/frontend/src/v1/styles/tokens.ts new file mode 100644 index 0000000000..26a8662eff --- /dev/null +++ b/frontend/src/v1/styles/tokens.ts @@ -0,0 +1,77 @@ +import type { ThemeMode } from "@v1/lib/theme"; +import type { Token, TokenFamily } from "@v1/lib/tokens"; +import source from "./tokens.json"; + +/** + * The token values live in tokens.json, one array per family in board order. + * Edit there, then run `bun run tokens:v1`. Each entry is `name`, `utility`, + * `values` (one string when the value is the same in every mode, else one + * per mode) and an optional `note`; the CSS variable is always `--` + name. + * + * Colour is the Midnight Bloom collection minus its palette entries (sky-300, + * sky-500, openai-green, white). Spacing is one 4pt scale: a value not on the + * list is a bug. Type is eight roles, each a size, a line height and a + * weight. Elevation is one value per step. Focus has no offset: its colour + * is the `ring` token. + */ +type TokenSource = Readonly<{ + name: string; + utility: string; + values: string | Readonly>; + note?: string; +}>; + +const MODES: readonly ThemeMode[] = ["light", "dark", "unstract"]; +// Board order; also the only keys tokens.json may have. +const FAMILIES: readonly TokenFamily[] = [ + "colour", + "spacing", + "radius", + "type", + "elevation", + "focus", + "font", +]; + +function values( + family: TokenFamily, + entry: TokenSource, +): Readonly> { + if (typeof entry.values === "string") { + const value = entry.values; + return { light: value, dark: value, unstract: value }; + } + for (const mode of MODES) { + if (typeof entry.values[mode] !== "string") { + throw new Error( + `tokens.json: ${family}/${entry.name} has no ${mode} value`, + ); + } + } + return entry.values; +} + +function fromSource( + entries: Readonly>, +): readonly Token[] { + const families = Object.keys(entries); + const unknown = families.filter( + (f) => !(FAMILIES as readonly string[]).includes(f), + ); + if (unknown.length > 0) { + throw new Error(`tokens.json: unknown family ${unknown.join(", ")}`); + } + return FAMILIES.flatMap((family) => + (entries[family] ?? []).map((entry) => ({ + family, + name: entry.name, + cssVar: `--${entry.name}` as const, + utility: entry.utility, + values: values(family, entry), + ...(entry.note === undefined ? {} : { note: entry.note }), + })), + ); +} + +/** Every token on the dev-spec board, in board order. */ +export const TOKENS: readonly Token[] = fromSource(source); diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index 705ecd2c9f..bb8342b994 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -98,6 +98,37 @@ const CASES = parseTable(` v1-non-test-imports-test | lint:v1 | components/ui/ may not import from test/ v1-test-file-imports-services | lint:v1 | components/ui/ may not import from services/ v1-allowed-imports | typecheck:v1 + lint:v1 | - + # Styling policy (check-v1-styles.mjs) + v1-inline-style | lint:v1 | No inline style under v1 + v1-inline-style-variable | lint:v1 | No inline style under v1 + v1-inline-style-mixed | lint:v1 | No inline style under v1 + v1-inline-style-spaced | lint:v1 | No inline style under v1 + v1-inline-style-custom-props-ok | typecheck:v1 + lint:v1 | - + v1-raw-colour-palette | lint:v1 | No raw colour under v1 + v1-raw-colour-arbitrary | lint:v1 | No raw colour under v1 + v1-raw-colour-prop | lint:v1 | No raw colour under v1 + v1-raw-colour-named | lint:v1 | No raw colour under v1 + v1-raw-colour-named-prop | lint:v1 | No raw colour under v1 + v1-raw-pixel-arbitrary | lint:v1 | No raw pixel value under v1 + v1-raw-pixel-arbitrary-multi | lint:v1 | No raw pixel value under v1 + v1-raw-pixel-arbitrary-separator | lint:v1 | No raw pixel value under v1 + v1-important-utility | lint:v1 | No !important under v1 + v1-important-variant-utility | lint:v1 | No !important under v1 + v1-important-css | lint:v1 | No !important under v1 + v1-css-import-from-page | lint:v1 | CSS is imported only from app/ and styles/ + v1-styles-raw-colour | lint:v1 | No raw colour under v1 + v1-styles-raw-colour-named | lint:v1 | No raw colour under v1 + v1-styles-raw-viewport-unit | lint:v1 | No raw pixel value under v1 + v1-styles-unscoped-selector | lint:v1 | Every v1 selector is scoped to .v1 + v1-styles-sibling-selector | lint:v1 | selects outside it + v1-styles-nested-sibling-selector | lint:v1 | selects outside it + v1-styles-color-scheme-media | lint:v1 | prefers-color-scheme + v1-unprefixed-utility | lint:v1 | No unprefixed Tailwind utility under v1 + v1-unprefixed-single-class | lint:v1 | No unprefixed Tailwind utility under v1 + v1-prefix-after-variant | lint:v1 | The ui: prefix comes first + v1-cva-unprefixed-variant | lint:v1 | No unprefixed Tailwind utility under v1 + v1-styles-policy-ok | lint:v1 | - + v1-plain-strings-ok | lint:v1 | - `).map(([name, run, fails]) => ({ name: name === "-" ? null : name, run: run.split(" + "), diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/styles/global.css b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/styles/global.css index ac38c30204..30feb914af 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/styles/global.css +++ b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/styles/global.css @@ -1,3 +1,3 @@ -:root { - --v1-accent: #000; +.v1 { + --ui-entry-demo: var(--foreground); } diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/extra.css new file mode 100644 index 0000000000..3a1eeb0120 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/extra.css @@ -0,0 +1,3 @@ +.v1 { + --ui-extra-demo: var(--foreground); +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/index.ts index 7ab5ff7d43..1e02711d7f 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/index.ts +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/index.ts @@ -1,3 +1,3 @@ -import "@v1/styles/tokens.css"; +import "@v1/styles/extra.css"; export const themed = true; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/tokens.css b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/tokens.css deleted file mode 100644 index ac38c30204..0000000000 --- a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/tokens.css +++ /dev/null @@ -1,3 +0,0 @@ -:root { - --v1-accent: #000; -} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-css-import-from-page/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-css-import-from-page/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..cf6a87372b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-css-import-from-page/src/v1/pages/demo/index.ts @@ -0,0 +1 @@ +import "@v1/styles/index.css"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-cva-unprefixed-variant/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-cva-unprefixed-variant/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..821710e287 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-cva-unprefixed-variant/src/v1/components/ui/Box.tsx @@ -0,0 +1,6 @@ +import { cva } from "class-variance-authority"; + +export const box = cva("ui:rounded-md", { + variants: { variant: { outline: "border", solid: "ui:bg-primary" } }, + defaultVariants: { variant: "outline" }, +}); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-important-css/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-important-css/src/v1/styles/extra.css new file mode 100644 index 0000000000..05a22973e1 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-important-css/src/v1/styles/extra.css @@ -0,0 +1,3 @@ +.v1 .x { + color: var(--foreground) !important; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-important-utility/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-important-utility/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..7e5475565d --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-important-utility/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-important-variant-utility/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-important-variant-utility/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..e939e1b501 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-important-variant-utility/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-inline-style-custom-props-ok/src/v1/components/ui/Page.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-inline-style-custom-props-ok/src/v1/components/ui/Page.tsx new file mode 100644 index 0000000000..e174a6c9b7 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-inline-style-custom-props-ok/src/v1/components/ui/Page.tsx @@ -0,0 +1,21 @@ +import { cn } from "@v1/lib/cn"; + +type Props = { width: number; height: number; x: number; y: number }; + +// Runtime geometry goes through custom properties; the classes read them. +export function Page({ width, height, x, y }: Props) { + return ( +
+ +
+ ); +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-inline-style-mixed/src/v1/components/ui/Page.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-inline-style-mixed/src/v1/components/ui/Page.tsx new file mode 100644 index 0000000000..b46e3b50ea --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-inline-style-mixed/src/v1/components/ui/Page.tsx @@ -0,0 +1,16 @@ +type Props = { width: number; height: number; extra: Record }; + +// One custom property and one plain key: the plain key styles the element. +export function Page({ width, height }: Props) { + return ( +
+ ); +} + +// A spread hides its keys. +export function Spread({ extra }: Props) { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-inline-style-spaced/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-inline-style-spaced/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..cd9043991e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-inline-style-spaced/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return

spaced

; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-inline-style-variable/src/v1/components/ui/Page.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-inline-style-variable/src/v1/components/ui/Page.tsx new file mode 100644 index 0000000000..d07a979028 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-inline-style-variable/src/v1/components/ui/Page.tsx @@ -0,0 +1,6 @@ +type Props = { pageStyle: React.CSSProperties }; + +// The keys are not visible at the call site, so the attribute is rejected. +export function Page({ pageStyle }: Props) { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-inline-style/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-inline-style/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..684e0311af --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-inline-style/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-plain-strings-ok/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-plain-strings-ok/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..1e286f3df0 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-plain-strings-ok/src/v1/components/ui/Box.tsx @@ -0,0 +1,10 @@ +type BoxProps = Readonly<{ variant: "outline" | "ghost" }>; + +export function Box({ variant }: BoxProps) { + return ( +
+ +

You don't have hidden files

+
+ ); +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-prefix-after-variant/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-prefix-after-variant/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..19b2384c1d --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-prefix-after-variant/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-arbitrary/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-arbitrary/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..03929ff06b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-arbitrary/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-named-prop/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-named-prop/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..1813477258 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-named-prop/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return ; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-named/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-named/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..dbf62949bc --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-named/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-palette/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-palette/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..9708443055 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-palette/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-prop/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-prop/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..6e1319c1ed --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-prop/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return ; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary-multi/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary-multi/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..df14f9e42e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary-multi/src/v1/components/ui/Box.tsx @@ -0,0 +1,5 @@ +export function Box() { + return ( +
+ ); +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary-separator/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary-separator/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..1d18f7130b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary-separator/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..10ce222d2c --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-color-scheme-media/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-color-scheme-media/src/v1/styles/extra.css new file mode 100644 index 0000000000..d2422fe70e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-color-scheme-media/src/v1/styles/extra.css @@ -0,0 +1,5 @@ +@media (prefers-color-scheme: dark) { + .v1 { + color: var(--foreground); + } +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-nested-sibling-selector/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-nested-sibling-selector/src/v1/styles/extra.css new file mode 100644 index 0000000000..e432df676c --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-nested-sibling-selector/src/v1/styles/extra.css @@ -0,0 +1,12 @@ +.v1 { + & + .demo { + color: var(--foreground); + } + &[data-theme="dark"] { + @media (prefers-reduced-motion: reduce) { + & ~ .demo { + color: var(--foreground); + } + } + } +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..ba151f7ee5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/components/ui/Box.tsx @@ -0,0 +1,26 @@ +import { cn } from "@v1/lib/cn"; +import { cva } from "class-variance-authority"; + +export const box = cva("ui:rounded-md ui:p-2", { + variants: { + variant: { outline: "ui:border", solid: "ui:bg-primary" }, + size: { "sm": "ui:text-caption", lg: "ui:text-body" }, + }, + compoundVariants: [{ variant: "outline", size: "sm", class: "ui:border-2" }], + defaultVariants: { variant: "outline", size: "sm" }, +}); + +export function Box({ open }: { open: boolean }) { + return ( +
+ + +
+ ); +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/styles/extra.css new file mode 100644 index 0000000000..6ebb22acc9 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/styles/extra.css @@ -0,0 +1,36 @@ +.v1 .demo, +.v1 .demo::after, +.v1 > .demo + .demo { + color: var(--foreground); + border-color: transparent; + fill: currentcolor; + margin: 0; +} +.v1 .menu { + &:hover { + color: var(--foreground); + } + & > a { + color: var(--foreground); + } + a + a { + color: var(--foreground); + } + @media (prefers-reduced-motion: reduce) { + & a { + color: var(--foreground); + } + } +} +.v1 .menu { + & + .demo { + color: var(--foreground); + } +} +.v1 { + .menu { + & ~ .demo { + color: var(--foreground); + } + } +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-colour-named/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-colour-named/src/v1/styles/extra.css new file mode 100644 index 0000000000..b6e6d80153 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-colour-named/src/v1/styles/extra.css @@ -0,0 +1,4 @@ +.v1 .demo { + color: red; + background: White; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-colour/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-colour/src/v1/styles/extra.css new file mode 100644 index 0000000000..a54d4a6cd2 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-colour/src/v1/styles/extra.css @@ -0,0 +1,3 @@ +.v1 .x { + color: #fff; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-viewport-unit/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-viewport-unit/src/v1/styles/extra.css new file mode 100644 index 0000000000..53dad9e8e5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-viewport-unit/src/v1/styles/extra.css @@ -0,0 +1,4 @@ +.v1 .demo { + min-height: 100vh; + width: 50vw; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-sibling-selector/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-sibling-selector/src/v1/styles/extra.css new file mode 100644 index 0000000000..eb992717b5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-sibling-selector/src/v1/styles/extra.css @@ -0,0 +1,4 @@ +.v1 + .demo, +.v1 ~ .demo { + color: var(--foreground); +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-unscoped-selector/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-unscoped-selector/src/v1/styles/extra.css new file mode 100644 index 0000000000..ded6b3af88 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-unscoped-selector/src/v1/styles/extra.css @@ -0,0 +1,3 @@ +button { + color: var(--foreground); +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-unprefixed-single-class/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-unprefixed-single-class/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..c1c2506274 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-unprefixed-single-class/src/v1/components/ui/Box.tsx @@ -0,0 +1,10 @@ +import { cn } from "@v1/lib/cn"; + +export function Box({ open }: { open: boolean }) { + return ( +
+ + +
+ ); +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-unprefixed-utility/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-unprefixed-utility/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..e5f587adb1 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-unprefixed-utility/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +}