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 e81966be80..989b80fee4 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 scripts/v1-plugins.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 f8a5e55189..62e1c766aa 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)`, @@ -72,7 +104,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 @@ -81,4 +114,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/shell/AccountMenu.test.tsx b/frontend/src/v1/shell/AccountMenu.test.tsx index 5058a56617..10e81b9a0c 100644 --- a/frontend/src/v1/shell/AccountMenu.test.tsx +++ b/frontend/src/v1/shell/AccountMenu.test.tsx @@ -40,6 +40,8 @@ describe("AccountMenu", () => { test("lists identity, profile, help and sign out, as frame NEW09 shows", async () => { await open(); + // The Unstract theme's sidebar is navy under dark text. + expect(screen.getByRole("menu")).toHaveClass("ui:bg-popover"); expect(screen.getByText("Tanushree Daga")).toBeInTheDocument(); expect(screen.getByText("tanushree@zipstack.com")).toBeInTheDocument(); expect( @@ -69,7 +71,7 @@ describe("AccountMenu", () => { const lines = screen.getAllByText("athul@zipstack.com"); expect(lines).toHaveLength(1); // The bold title line, not an empty title above the e-mail. - expect(lines[0]).toHaveClass("font-semibold"); + expect(lines[0]).toHaveClass("ui:font-semibold"); }); test("OSS has no Plan & billing item and no version switch (D14)", async () => { diff --git a/frontend/src/v1/shell/AccountMenu.tsx b/frontend/src/v1/shell/AccountMenu.tsx index 4e61fb99b3..637a05c2d9 100644 --- a/frontend/src/v1/shell/AccountMenu.tsx +++ b/frontend/src/v1/shell/AccountMenu.tsx @@ -1,5 +1,6 @@ import { initial, initials } from "@v1/lib/organizations"; import { Slot } from "@v1/lib/plugins"; +import { getPortalContainer } from "@v1/lib/portal"; import { signOutAndLeave } from "@v1/services/session"; import { ChevronDown, @@ -15,8 +16,8 @@ const HELP_URL = "https://docs.unstract.com"; // Frame NEW09 (1638:44719). Colours are the design's token names. const ITEM = - "flex h-[34px] cursor-pointer items-center gap-2.5 rounded-lg px-[9px] text-[13px] font-medium leading-5 text-[var(--foreground)] outline-none data-[highlighted]:bg-[var(--muted)]"; -const ICON = "size-[18px] shrink-0 text-[var(--muted-foreground)]"; + "ui:flex ui:h-8.5 ui:cursor-pointer ui:items-center ui:gap-2.5 ui:rounded-md ui:px-2.25 ui:text-body-small ui:font-medium ui:text-foreground ui:outline-none ui:data-highlighted:bg-muted"; +const ICON = "ui:size-4.5 ui:shrink-0 ui:text-muted-foreground"; export interface AccountMenuProps { user: { name: string; email: string }; @@ -49,38 +50,38 @@ export function AccountMenu({ user, profileHref }: AccountMenuProps) { - + {initial(label)} - + -
- +
+ {initials(label)} - - + + {user.name || user.email} {user.name && ( - + {user.email} )}
- + Profile & preferences @@ -88,15 +89,16 @@ export function AccountMenu({ user, profileHref }: AccountMenuProps) { - - Help & docs (opens in a new tab) + + Help & docs{" "} + (opens in a new tab) - + void signOutAndLeave()} diff --git a/frontend/src/v1/shell/OrganizationSwitcher.test.tsx b/frontend/src/v1/shell/OrganizationSwitcher.test.tsx index 8d15b09a2f..4a32af93e1 100644 --- a/frontend/src/v1/shell/OrganizationSwitcher.test.tsx +++ b/frontend/src/v1/shell/OrganizationSwitcher.test.tsx @@ -95,10 +95,10 @@ describe("OrganizationSwitcher", () => { await open(); const rows = await screen.findAllByRole("listitem"); const scroller = rows[0].parentElement as HTMLElement; - expect(scroller).toHaveClass("overflow-y-auto", "min-h-0"); + expect(scroller).toHaveClass("ui:overflow-y-auto", "ui:min-h-0"); expect( scroller.closest("[data-radix-popper-content-wrapper] > *"), - ).toHaveClass("max-h-[var(--radix-popover-content-available-height)]"); + ).toHaveClass("ui:max-h-(--radix-popover-content-available-height)"); }); test("a member reads Member (O04)", async () => { @@ -159,6 +159,8 @@ describe("OrganizationSwitcher", () => { }), ); const dialog = screen.getByRole("alertdialog"); + // The Unstract theme's sidebar is navy under dark text. + expect(dialog).toHaveClass("ui:bg-popover"); expect( within(dialog).getByText("Switch to Tanushree’s organisation?"), ).toBeInTheDocument(); @@ -361,6 +363,6 @@ describe("OrganizationSwitcher", () => { const button = await screen.findByRole("button", { name: "Switch to Tanushree’s organisation", }); - expect(button.querySelector("svg.lucide-arrow-left-right")).not.toBeNull(); + expect(button.querySelector("svg.lucide-arrow-right-left")).not.toBeNull(); }); }); diff --git a/frontend/src/v1/shell/OrganizationSwitcher.tsx b/frontend/src/v1/shell/OrganizationSwitcher.tsx index 3f0ead6372..de5e096884 100644 --- a/frontend/src/v1/shell/OrganizationSwitcher.tsx +++ b/frontend/src/v1/shell/OrganizationSwitcher.tsx @@ -4,6 +4,7 @@ import { landingPath, organizationInitials, } from "@v1/lib/organizations"; +import { getPortalContainer } from "@v1/lib/portal"; import { listOrganizations, type Organization, @@ -12,7 +13,6 @@ import { } from "@v1/services/organizations"; import axios from "axios"; import { - ArrowLeftRight, ArrowRightLeft, ChevronRight, ChevronsUpDown, @@ -22,16 +22,12 @@ import { AlertDialog, Popover } from "radix-ui"; import { useCallback, useRef, useState } from "react"; // Frames NEW10 (1638:44855) and page 19, O01 to O04 (2259:182 to 2259:656). -const AVATAR_COLOURS = [ - "bg-blue-100 text-blue-700", - "bg-teal-100 text-teal-700", - "bg-amber-100 text-amber-700", - "bg-rose-100 text-rose-700", - "bg-violet-100 text-violet-700", -]; +// ponytail: one colour for every organisation avatar; the frames rotate five, +// which have no tokens yet. Rotate again once the tokens include an avatar palette. +const AVATAR_COLOUR = "ui:bg-primary-muted ui:text-primary"; const SIGN_IN_PATH = "/landing"; const BUTTON = - "inline-flex h-8 items-center gap-1.5 rounded-[10px] border border-[var(--border)] bg-[var(--card)] px-3 text-[13px] font-medium text-[var(--foreground)] hover:bg-[var(--muted)] disabled:opacity-50"; + "ui:inline-flex ui:h-8 ui:items-center ui:gap-1.5 ui:rounded-lg ui:border ui:border-border ui:bg-card ui:text-body ui:font-medium ui:text-foreground ui:hover:bg-muted ui:disabled:opacity-50"; export interface CurrentOrganization { id: string; @@ -74,28 +70,15 @@ function useOrganizationList() { return { state, load }; } -function OrgAvatar({ - id, - name, - size, -}: { - id: string; - name: string; - size: "md" | "sm"; -}) { - const colour = - AVATAR_COLOURS[ - [...id].reduce((sum, c) => sum + c.charCodeAt(0), 0) % - AVATAR_COLOURS.length - ]; +function OrgAvatar({ name, size }: { name: string; size: "md" | "sm" }) { const box = size === "md" - ? "size-8 rounded-[10px] text-xs" - : "size-7 rounded-md text-[11px]"; + ? "ui:size-8 ui:rounded-lg ui:text-caption" + : "ui:size-7 ui:rounded-sm ui:text-overline"; return ( {organizationInitials(name)} @@ -104,11 +87,11 @@ function OrgAvatar({ function OrgLine({ org }: { org: Organization }) { return ( - - + + {org.display_name} - + {describeOrganization(org)} @@ -150,56 +133,59 @@ function ConfirmSwitch({ return ( !open && !busy && onClose()}> - - - -
- + + + +
+ -
- +
+ Switch to {to.display_name}? - + Every screen is replaced — projects, workflows, deployments, logs, members and the plan. Nothing is lost; you can switch back from here.
-
- +
+ - +
{failed && (

Could not switch. Try again.

)} -
- +
+ Cancel
@@ -244,41 +230,41 @@ export function OrganizationSwitcher({ > - + {initial(current.displayName)} - + {current.displayName} - + -