From 0c5bacb570c5fa2c32e5e51a79111fd5cf1f54df Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Mon, 26 Oct 2026 17:51:22 +0530 Subject: [PATCH 01/38] feat(frontend): v1 scaffold, strict TypeScript and import boundary (UN-4270) Adds frontend/src/v1/ as an isolated tree for the v1 frontend (P0-T01): - Folder taxonomy from the spec, a public entry (src/v1/index.ts, imported by v0 as `@v1`) and a cloud overlay placeholder. - src/v1/tsconfig.json: strict, no JS, unused locals/params are errors, `@v1` alias only. v0's tsconfig/jsconfig exclude src/v1. - Biome overrides for the two-way import boundary and v1 bans (no `../`, `@/`, prop-types, antd, component CSS; noExplicitAny). - scripts/check-v1.mjs for what Biome cannot express: TypeScript-only files, the folder taxonomy and import contract, PropTypes, `ant-`. - typecheck:v1, lint:v1 and test:v1 scripts; v0 scripts are unchanged. - test-fixtures/v1-boundary/ proves each rule fails and passes as specified, run with node --test so the v0 vitest suite never sees it. - .gitignore re-includes src/v1/lib/ (the Python `lib/` rule matched it). v0 build output, test counts, typecheck and Biome results are unchanged. --- .gitignore | 6 +- .pre-commit-config.yaml | 4 +- frontend/biome.json | 59 +++- frontend/jsconfig.json | 5 +- frontend/package.json | 5 +- frontend/scripts/check-v1.mjs | 253 ++++++++++++++++++ frontend/src/v1/README.md | 49 ++++ frontend/src/v1/app/.gitkeep | 0 frontend/src/v1/components/patterns/.gitkeep | 0 frontend/src/v1/components/ui/.gitkeep | 0 frontend/src/v1/hooks/.gitkeep | 0 frontend/src/v1/index.ts | 7 + frontend/src/v1/lib/.gitkeep | 0 frontend/src/v1/pages/.gitkeep | 0 frontend/src/v1/plugins/manifest.ts | 6 + frontend/src/v1/services/.gitkeep | 0 frontend/src/v1/shell/.gitkeep | 0 frontend/src/v1/stores/.gitkeep | 0 frontend/src/v1/styles/.gitkeep | 0 frontend/src/v1/test/.gitkeep | 0 frontend/src/v1/tsconfig.json | 39 +++ .../v1-boundary/boundary.fixtures.mjs | 220 +++++++++++++++ .../v0-imports-v1-entry/src/pages/Ok.jsx | 1 + .../v0-imports-v1-internal/src/pages/Bad.jsx | 3 + .../v0-imports-v1-relative/src/pages/Bad.jsx | 3 + .../src/v1/components/ui/Label.test.ts | 4 + .../src/v1/components/ui/Label.ts | 3 + .../v1-allowed-imports/src/v1/lib/format.ts | 1 + .../src/v1/pages/projects/title.ts | 1 + .../src/v1/pages/projects/view/heading.ts | 4 + .../v1-allowed-imports/src/v1/test/render.ts | 1 + .../src/v1/components/ui/Button.ts | 1 + .../src/v1/lib/bad.ts | 1 + .../src/v1/components/ui/Badge.css | 3 + .../src/v1/components/ui/Badge.tsx | 3 + .../cases/v1-explicit-any/src/v1/lib/bad.ts | 1 + .../cases/v1-implicit-any/src/v1/lib/bad.ts | 3 + .../v1-imports-v0-alias/src/v1/lib/bad.ts | 3 + .../v1-imports-v0-relative/src/v1/lib/bad.ts | 3 + .../cases/v1-js-file/src/v1/lib/legacy.js | 1 + .../src/v1/widgets/thing.ts | 1 + .../src/v1/pages/projects/index.ts | 3 + .../src/v1/pages/settings/index.ts | 1 + .../src/v1/components/ui/Box.ts | 4 + .../src/v1/components/ui/Badge.tsx | 3 + .../src/v1/components/ui/ProjectLink.tsx | 3 + .../src/v1/services/projects.ts | 1 + .../cases/v1-unused-local/src/v1/lib/bad.ts | 4 + frontend/tsconfig.json | 7 +- frontend/vite.config.js | 2 + frontend/vitest.config.mjs | 14 +- 51 files changed, 725 insertions(+), 11 deletions(-) create mode 100755 frontend/scripts/check-v1.mjs create mode 100644 frontend/src/v1/README.md create mode 100644 frontend/src/v1/app/.gitkeep create mode 100644 frontend/src/v1/components/patterns/.gitkeep create mode 100644 frontend/src/v1/components/ui/.gitkeep create mode 100644 frontend/src/v1/hooks/.gitkeep create mode 100644 frontend/src/v1/index.ts create mode 100644 frontend/src/v1/lib/.gitkeep create mode 100644 frontend/src/v1/pages/.gitkeep create mode 100644 frontend/src/v1/plugins/manifest.ts create mode 100644 frontend/src/v1/services/.gitkeep create mode 100644 frontend/src/v1/shell/.gitkeep create mode 100644 frontend/src/v1/stores/.gitkeep create mode 100644 frontend/src/v1/styles/.gitkeep create mode 100644 frontend/src/v1/test/.gitkeep create mode 100644 frontend/src/v1/tsconfig.json create mode 100644 frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs create mode 100644 frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-entry/src/pages/Ok.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/lib/format.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/view/heading.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/test/render.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-ant-class-prefix/src/v1/components/ui/Button.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-base-rules-still-apply/src/v1/lib/bad.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-component-css-file/src/v1/components/ui/Badge.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-component-css-import/src/v1/components/ui/Badge.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-explicit-any/src/v1/lib/bad.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-implicit-any/src/v1/lib/bad.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-js-file/src/v1/lib/legacy.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-outside-taxonomy/src/v1/widgets/thing.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/settings/index.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-prop-types-assignment/src/v1/components/ui/Box.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-prop-types/src/v1/components/ui/Badge.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/services/projects.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-unused-local/src/v1/lib/bad.ts diff --git a/.gitignore b/.gitignore index cfd13e388f..c7a5148e30 100644 --- a/.gitignore +++ b/.gitignore @@ -41,8 +41,12 @@ eggs/ lib/ lib64/ # The Python `lib/` rule above also matches the frontend's shadcn/ui helper -# directory (`@/lib/utils` per components.json). Keep that tracked. +# directory (`@/lib/utils` per components.json), the v1 `lib/` folder and the +# v1 guardrail fixtures. Keep those tracked, or their files are silently left +# out of commits. !frontend/src/lib/ +!frontend/src/v1/lib/ +!frontend/test-fixtures/**/lib/ parts/ sdist/ var/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e279326d6f..fe06ede0a0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,9 +22,9 @@ repos: - id: check-docstring-first - id: check-ast - id: check-json - # Both files are JSONC — their tooling (VS Code, tsc) allows comments, + # These files are JSONC — their tooling (VS Code, tsc) allows comments, # but this hook parses with strict `json.loads`, which does not. - exclude: "(\\.vscode/launch\\.json|frontend/tsconfig\\.json)$" + exclude: "(\\.vscode/launch\\.json|frontend/(src/v1/)?tsconfig\\.json)$" - id: check-executables-have-shebangs - id: check-shebang-scripts-are-executable - id: check-toml diff --git a/frontend/biome.json b/frontend/biome.json index eb98923c0c..c8ead404ef 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -129,5 +129,62 @@ "assist": { "enabled": true, "actions": { "source": { "organizeImports": "on" } } - } + }, + "overrides": [ + { + "includes": ["src/v1/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "paths": { + "prop-types": "v1 is typed with TypeScript; PropTypes are not used under src/v1.", + "antd": "antd is not used under src/v1." + }, + "patterns": [ + { + "group": ["../**", "@/**", "src/**"], + "message": "v1 imports nothing outside src/v1, and never with '../'. Use './' within a folder and '@v1/...' across folders." + }, + { + "group": ["@ant-design/**"], + "message": "antd is not used under src/v1." + }, + { + "group": ["**/*.css", "!@v1/styles/**"], + "message": "No component .css under src/v1; style with tokens and Tailwind. Global v1 styles live in @v1/styles/." + } + ] + } + } + }, + "suspicious": { + "noExplicitAny": "error" + } + } + } + }, + { + "includes": ["src/**", "!src/v1/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["@v1/**", "**/v1", "**/v1/**", "!@v1"], + "message": "v0 may import only the v1 public entry, as '@v1'." + } + ] + } + } + } + } + } + } + ] } diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json index 082b33982c..63ea90da3d 100644 --- a/frontend/jsconfig.json +++ b/frontend/jsconfig.json @@ -2,9 +2,10 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@v1": ["./src/v1/index.ts"] } }, "include": ["src/**/*"], - "exclude": ["node_modules", "build"] + "exclude": ["node_modules", "build", "src/v1"] } diff --git a/frontend/package.json b/frontend/package.json index b4ab1e1bd5..3d7f579cc9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -77,7 +77,10 @@ "check:fix": "biome check --write src/", "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" + "typecheck": "tsc --noEmit", + "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", + "test:v1": "vitest run --dir src/v1 --passWithNoTests && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs" }, "browserslist": { "production": [ diff --git a/frontend/scripts/check-v1.mjs b/frontend/scripts/check-v1.mjs new file mode 100755 index 0000000000..6a27cb1783 --- /dev/null +++ b/frontend/scripts/check-v1.mjs @@ -0,0 +1,253 @@ +#!/usr/bin/env node +/* + * Guard for the v1 frontend (src/v1/): the rules Biome cannot express. + * + * --files TypeScript only (a .js under v1 is skipped by tsc, not failed, + * because allowJs is off), style files only in styles/, and every + * file inside the v1 folder taxonomy. Run by `typecheck:v1`. + * --code The folder import contract (which v1 folder may import which), + * no PropTypes, no antd `ant-` class prefix. Run by `lint:v1`. + * + * With neither flag, both run. `--root ` points it at another tree + * (used by the fixtures in test-fixtures/v1-boundary/). + * + * The v0 <-> v1 boundary itself, `../` imports and banned packages are Biome + * rules (biome.json `overrides`), so they also run in CI and in editors. + * Biome overrides replace rule options rather than merge them, which is why + * the per-folder contract lives here and not as one override per folder. + * + * The `ant-` check exists only while antd markup survives in v0; drop it + * once the shadcn migration is complete. + */ +import fs from "node:fs"; +import path from "node:path"; + +const args = process.argv.slice(2); +const rootArg = args.indexOf("--root"); +const ROOT = path.resolve( + rootArg === -1 + ? path.join(import.meta.dirname, "..", "src", "v1") + : args[rootArg + 1], +); +const runFiles = args.includes("--files") || !args.includes("--code"); +const runCode = args.includes("--code") || !args.includes("--files"); + +const ALL = "*"; +/* + * Folder -> folders it may import from, besides itself. Mirrors the contract + * table in the P0-T01 spec and src/v1/README.md. `entry` is src/v1/index.ts. + * Pages may import everything except other pages. + */ +const CONTRACT = { + entry: ALL, + app: ALL, + plugins: ALL, + test: ALL, + pages: ALL, + "components/ui": ["lib", "styles"], + "components/patterns": [ + "components/ui", + "lib", + "services", + "stores", + "hooks", + ], + shell: [ + "components/ui", + "components/patterns", + "services", + "stores", + "hooks", + "lib", + ], + services: ["lib"], + stores: ["lib"], + hooks: ["lib"], + lib: [], + styles: ["lib"], +}; +const TOP_LEVEL = new Set([ + "app", + "shell", + "services", + "stores", + "hooks", + "lib", + "styles", + "plugins", + "test", +]); +const ROOT_FILES = new Set(["index.ts", "tsconfig.json", "README.md"]); + +const CODE_EXT = /\.(ts|tsx|mts|cts)$/; +const JS_EXT = /\.(js|jsx|mjs|cjs)$/; +const STYLE_EXT = /\.(css|scss|sass|less)$/; +const TEST_FILE = /\.(test|spec)\.tsx?$/; + +/* + * The v1 folder a path (relative to ROOT, `/`-separated) belongs to, e.g. + * "components/ui" or "pages/settings"; null when it is outside the taxonomy. + * `isDir` is for import targets like "@v1/lib", which name a folder. + */ +function folderOf(rel, isDir = false) { + const segs = rel.split("/").filter(Boolean); + const depth = isDir ? segs.length + 1 : segs.length; + if (segs.length === 0 || (depth === 1 && segs[0] === "index.ts")) { + return "entry"; + } + if (depth === 1) { + return null; + } + const [top, sub] = segs; + if (top === "components") { + return (sub === "ui" || sub === "patterns") && depth >= 3 + ? `components/${sub}` + : null; + } + if (top === "pages") { + return sub && depth >= 3 ? `pages/${sub}` : null; + } + return TOP_LEVEL.has(top) ? top : null; +} + +function contractKey(folder) { + return folder.startsWith("pages/") ? "pages" : folder; +} + +function mayImport(from, to, isTestFile) { + if (from === to) { + return true; + } + if (isTestFile && to === "test") { + return true; + } + if (from.startsWith("pages/") && to.startsWith("pages/")) { + return false; + } + const allowed = CONTRACT[contractKey(from)]; + return allowed === ALL || allowed.includes(to); +} + +/* Blanks comments while keeping line numbers, so matches can be reported. */ +function stripComments(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) + .replace(/(^|[^:"'`\\])\/\/.*$/gm, "$1"); +} + +const IMPORT_RES = [ + /\b(?:import|export)\s+(?:type\s+)?(?:[\w$*{}\s,]+?\s+from\s+)?["']([^"']+)["']/g, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, + /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g, +]; + +/* Resolves a specifier to a v1 folder, or undefined for packages and the + * forms Biome already rejects (`../`, `@/`). */ +function targetFolder(spec, fileRel) { + if (spec === "@v1" || /^@v1\/index(\.ts)?$/.test(spec)) { + return "entry"; + } + let rel; + if (spec.startsWith("@v1/")) { + rel = path.posix.normalize(spec.slice(4)); + } else if (spec.startsWith("./")) { + rel = path.posix.join(path.posix.dirname(fileRel), spec); + } else { + return undefined; + } + // No extension on the last segment -> it may name a folder ("@v1/lib"). + const isDir = !path.posix.extname(rel); + return folderOf(rel, isDir) ?? folderOf(rel) ?? null; +} + +function lineAt(text, index) { + return text.slice(0, index).split("\n").length; +} + +function walk(dir) { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((d) => d.isFile()) + .map((d) => + path + .relative(ROOT, path.join(d.parentPath, d.name)) + .replaceAll(path.sep, "/"), + ) + .sort(); +} + +const problems = []; +const report = (rel, line, msg) => + problems.push(`src/v1/${rel}${line ? `:${line}` : ""}: ${msg}`); + +if (!fs.existsSync(ROOT)) { + console.error(`check-v1: ${ROOT} does not exist`); + process.exit(2); +} + +for (const rel of walk(ROOT)) { + const name = path.posix.basename(rel); + const isRootFile = !rel.includes("/"); + const folder = folderOf(rel); + + if (runFiles) { + if (JS_EXT.test(name)) { + report(rel, 0, "v1 is TypeScript only; use .ts or .tsx."); + } + if (STYLE_EXT.test(name) && folder !== "styles") { + report(rel, 0, "No component style files; only styles/ holds CSS."); + } + if ( + isRootFile + ? !ROOT_FILES.has(name) + : folder === null && name !== ".gitkeep" + ) { + report(rel, 0, "Outside the v1 folder taxonomy (see src/v1/README.md)."); + } + } + + if (!runCode || !CODE_EXT.test(name) || folder === null) { + continue; + } + const text = stripComments(fs.readFileSync(path.join(ROOT, rel), "utf8")); + const isTestFile = TEST_FILE.test(name); + + for (const re of IMPORT_RES) { + for (const m of text.matchAll(re)) { + const to = targetFolder(m[1], rel); + if (to === undefined) { + continue; + } + if (to === null) { + report( + rel, + lineAt(text, m.index), + `'${m[1]}' is outside the v1 folder taxonomy.`, + ); + } else if (!mayImport(folder, to, isTestFile)) { + report( + rel, + lineAt(text, m.index), + `${folder}/ may not import from ${to}/ ('${m[1]}'); see the folder contract in src/v1/README.md.`, + ); + } + } + } + for (const m of text.matchAll(/\.propTypes\b|\bpropTypes\s*[:=]/g)) { + report( + rel, + lineAt(text, m.index), + "No PropTypes under v1; type props with TypeScript.", + ); + } + for (const m of text.matchAll(/(? 0) { + console.error(problems.join("\n")); + console.error(`\ncheck-v1: ${problems.length} problem(s).`); + process.exit(1); +} +console.log("check-v1: ok"); diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md new file mode 100644 index 0000000000..0e375909e4 --- /dev/null +++ b/frontend/src/v1/README.md @@ -0,0 +1,49 @@ +# Frontend v1 + +The v1 frontend, built alongside v0 (everything else under `src/`) until v0 is +retired. The full guide is the Frontend Developer Guide for v1 (P0-T11); this +file holds only the rules that tooling enforces. + +## Rules + +- **TypeScript only.** `.ts` / `.tsx`, `strict`, unused locals and parameters + are errors, no explicit `any`. +- **Nothing outside `src/v1`.** No v0 component, store, hook, helper, CSS or + shim. Import with `./` within a folder and `@v1/...` across folders; `../` is + never used. +- **v0 imports only the public entry**, `@v1` (`src/v1/index.ts`). +- **No PropTypes, no component `.css`, no antd** (package, shim or `ant-` + class). Global v1 styles live in `styles/`. + +## Folders and what each may import + +Every folder may import from itself. Test files (`*.test.ts(x)`) may also +import `test/`. + +| Folder | Holds | May import from | +| --- | --- | --- | +| `index.ts` | Public entry, used by the version switch | everything | +| `app/` | Entry, providers, router, route registry | everything | +| `components/ui/` | Core components | `lib/`, `styles/` | +| `components/patterns/` | Composites built from core components | `components/ui/`, `lib/`, `services/`, `stores/`, `hooks/` | +| `shell/` | Top bar, rail, context bar, tab bar | `components/ui/`, `components/patterns/`, `services/`, `stores/`, `hooks/`, `lib/` | +| `pages//` | One folder per target page | everything except other pages | +| `services/` | Typed service modules; the only place a URL appears | `lib/` | +| `stores/` | Zustand stores | `lib/` | +| `hooks/` | Shared hooks | `lib/` | +| `lib/` | Pure helpers | — | +| `styles/` | Tokens and global v1 styles | `lib/` | +| `plugins/` | Cloud overlay target; empty in the public repository | everything | +| `test/` | Render helpers, fixtures, network mocks | everything | + +## Scripts + +| Script | Checks | +| --- | --- | +| `bun run typecheck:v1` | TypeScript-only files and folder taxonomy, then `tsc` with `src/v1/tsconfig.json` | +| `bun run lint:v1` | Biome on `src/v1/`, then the folder contract, PropTypes and `ant-` checks | +| `bun run test:v1` | v1 unit tests, then the guardrail fixtures in `test-fixtures/v1-boundary/` | + +The v0 ↔ v1 boundary and the banned imports are Biome rules in `biome.json`, +so `biome ci src/` (CI) and editors report them too. The rest live in +`scripts/check-v1.mjs`. diff --git a/frontend/src/v1/app/.gitkeep b/frontend/src/v1/app/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/components/patterns/.gitkeep b/frontend/src/v1/components/patterns/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/components/ui/.gitkeep b/frontend/src/v1/components/ui/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/hooks/.gitkeep b/frontend/src/v1/hooks/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/index.ts b/frontend/src/v1/index.ts new file mode 100644 index 0000000000..8a6566ee67 --- /dev/null +++ b/frontend/src/v1/index.ts @@ -0,0 +1,7 @@ +/* + * Public entry of the v1 frontend — the only v1 module v0 may import, as + * `@v1`. Lint rejects any other v0 → v1 import (see src/v1/README.md). + * + * Empty until the version switch (P0-T03) mounts v1 through it. + */ +export {}; diff --git a/frontend/src/v1/lib/.gitkeep b/frontend/src/v1/lib/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/pages/.gitkeep b/frontend/src/v1/pages/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/plugins/manifest.ts b/frontend/src/v1/plugins/manifest.ts new file mode 100644 index 0000000000..dcfd546ab4 --- /dev/null +++ b/frontend/src/v1/plugins/manifest.ts @@ -0,0 +1,6 @@ +/* + * Cloud overlay target. Empty in the public repository: the cloud build + * copies its v1 plugins into this folder. The manifest's shape and contents + * belong to P0-T02 and P0-T07. + */ +export const pluginManifest = [] as const; diff --git a/frontend/src/v1/services/.gitkeep b/frontend/src/v1/services/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/shell/.gitkeep b/frontend/src/v1/shell/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/stores/.gitkeep b/frontend/src/v1/stores/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/styles/.gitkeep b/frontend/src/v1/styles/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/test/.gitkeep b/frontend/src/v1/test/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/tsconfig.json b/frontend/src/v1/tsconfig.json new file mode 100644 index 0000000000..f6b1c148ac --- /dev/null +++ b/frontend/src/v1/tsconfig.json @@ -0,0 +1,39 @@ +{ + /* + * TypeScript config for the v1 frontend, checked by `bun run typecheck:v1`. + * It lives here rather than beside tsconfig.json so editors pick it up for + * files under src/v1; tsconfig.json and jsconfig.json exclude this folder. + * + * `allowJs: false` makes tsc skip a .js file rather than fail on it, so the + * "TypeScript only" rule is enforced by scripts/check-v1.mjs, which + * typecheck:v1 runs first. + */ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + + "allowJs": false, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Only `@v1` — deliberately no `@/*`, so a v0 import fails typecheck as + * well as lint. Mirrored in vite.config.js and vitest.config.mjs. */ + "paths": { + "@v1": ["./index.ts"], + "@v1/*": ["./*"] + } + }, + "include": ["./**/*", "../../vite-env.d.ts"] +} diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs new file mode 100644 index 0000000000..a76001c182 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -0,0 +1,220 @@ +/* + * Proves the v1 guardrails fail when they should and pass when they should. + * Run by `bun run test:v1` (with `node --test`, not vitest, so the v0 suite + * never collects it). + * + * Each case builds a throwaway copy of the frontend: biome.json, + * package.json, scripts/, the real src/v1 scaffold and a node_modules + * symlink. It then overlays the files under cases// and runs the real + * package.json scripts (or a raw command) inside it. The known-bad files + * live here, outside src/, so they never reach the real lint or typecheck. + */ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +const FRONTEND = path.resolve(import.meta.dirname, "..", ".."); +const CASES_DIR = path.join(import.meta.dirname, "cases"); +const { scripts } = JSON.parse( + fs.readFileSync(path.join(FRONTEND, "package.json"), "utf8"), +); + +/* + * `run` holds package.json script names, or raw commands for the v0 side, + * which has no v1 script. `fails` is a substring the output must contain; + * cases without it must pass. + */ +const CASES = [ + // The empty scaffold passes everything. + { name: null, run: ["typecheck:v1", "lint:v1"] }, + + // Import boundary, v1 -> v0 (Biome). + { + name: "v1-imports-v0-alias", + run: ["lint:v1"], + fails: "v1 imports nothing outside src/v1", + }, + { + name: "v1-imports-v0-relative", + run: ["lint:v1"], + fails: "v1 imports nothing outside src/v1", + }, + + // Import boundary, v0 -> v1 (Biome). + { + name: "v0-imports-v1-internal", + run: ["biome lint src/"], + fails: "v0 may import only the v1 public entry", + }, + { + name: "v0-imports-v1-relative", + run: ["biome lint src/"], + fails: "v0 may import only the v1 public entry", + }, + { name: "v0-imports-v1-entry", run: ["biome lint src/"] }, + + // Banned under v1. + { name: "v1-prop-types", run: ["lint:v1"], fails: "PropTypes are not used" }, + { + name: "v1-prop-types-assignment", + run: ["lint:v1"], + fails: "No PropTypes under v1", + }, + { + name: "v1-component-css-import", + run: ["lint:v1"], + fails: "No component .css under src/v1", + }, + { + name: "v1-component-css-file", + run: ["typecheck:v1"], + fails: "No component style files", + }, + { + name: "v1-ant-class-prefix", + run: ["lint:v1"], + fails: "No antd 'ant-' class names", + }, + { name: "v1-explicit-any", run: ["lint:v1"], fails: "noExplicitAny" }, + // The v1 override must add to the shared rules, not replace them. + { + name: "v1-base-rules-still-apply", + run: ["lint:v1"], + fails: "noDoubleEquals", + }, + + // TypeScript only, strict. + { name: "v1-js-file", run: ["typecheck:v1"], fails: "TypeScript only" }, + { + name: "v1-implicit-any", + run: ["typecheck:v1"], + fails: "implicitly has an 'any' type", + }, + { + name: "v1-unused-local", + run: ["typecheck:v1"], + fails: "is declared but its value is never read", + }, + + // Folder taxonomy and contract. + { + name: "v1-outside-taxonomy", + run: ["typecheck:v1"], + fails: "Outside the v1 folder taxonomy", + }, + { + name: "v1-ui-imports-services", + run: ["lint:v1"], + fails: "components/ui/ may not import from services/", + }, + { + name: "v1-page-imports-other-page", + run: ["lint:v1"], + fails: "pages/projects/ may not import from pages/settings/", + }, + { name: "v1-allowed-imports", run: ["typecheck:v1", "lint:v1"] }, +]; + +function makeTree(caseName) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "v1-boundary-")); + for (const file of ["biome.json", "package.json", "vite-env.d.ts"]) { + fs.copyFileSync(path.join(FRONTEND, file), path.join(dir, file)); + } + // Copied, not linked: check-v1.mjs finds src/v1 relative to itself. + fs.cpSync(path.join(FRONTEND, "scripts"), path.join(dir, "scripts"), { + recursive: true, + }); + fs.cpSync(path.join(FRONTEND, "src", "v1"), path.join(dir, "src", "v1"), { + recursive: true, + }); + fs.symlinkSync( + path.join(FRONTEND, "node_modules"), + path.join(dir, "node_modules"), + "dir", + ); + if (caseName) { + fs.cpSync(path.join(CASES_DIR, caseName), dir, { recursive: true }); + } + // biome.json enables VCS integration with `useIgnoreFile`, which needs a + // repository root holding an ignore file. + execFileSync("git", ["init", "-q"], { cwd: dir }); + fs.writeFileSync(path.join(dir, ".gitignore"), "node_modules\n"); + return dir; +} + +function removeTree(dir) { + // Unlink first so the recursive delete can never walk into node_modules. + fs.unlinkSync(path.join(dir, "node_modules")); + fs.rmSync(dir, { recursive: true, force: true }); +} + +function runAll(dir, run) { + let output = ""; + for (const step of run) { + const command = scripts[step] ?? step; + const result = spawnSync("sh", ["-c", command], { + cwd: dir, + encoding: "utf8", + env: { + ...process.env, + NO_COLOR: "1", + PATH: `${path.join(dir, "node_modules", ".bin")}${path.delimiter}${process.env.PATH}`, + }, + }); + output += `$ ${command}\n${result.stdout}${result.stderr}`; + if (result.status !== 0) { + return { ok: false, output }; + } + } + return { ok: true, output }; +} + +/* + * The repository's Python `lib/` ignore rule also matches src/v1/lib/ and the + * fixtures' lib/ folders; .gitignore re-includes them. `--no-index` checks the + * rules even for files already tracked. + */ +test("no v1 or fixture file is git-ignored", () => { + const files = ["src/v1", "test-fixtures"].flatMap((top) => + fs + .readdirSync(path.join(FRONTEND, top), { + recursive: true, + withFileTypes: true, + }) + .filter((d) => d.isFile()) + .map((d) => path.relative(FRONTEND, path.join(d.parentPath, d.name))), + ); + const result = spawnSync("git", ["check-ignore", "--no-index", "--stdin"], { + cwd: FRONTEND, + encoding: "utf8", + input: files.join("\n"), + }); + // Exit 1 means "nothing matched"; 0 means some file is ignored. + assert.equal(result.status, 1, `git-ignored:\n${result.stdout}`); +}); + +test("every case folder is listed", () => { + const listed = new Set(CASES.map((c) => c.name).filter(Boolean)); + assert.deepEqual(fs.readdirSync(CASES_DIR).sort(), [...listed].sort()); +}); + +for (const { name, run, fails } of CASES) { + const label = `${name ?? "empty scaffold"}: ${fails ? "fails" : "passes"} ${run.join(", ")}`; + test(label, () => { + const dir = makeTree(name); + try { + const { ok, output } = runAll(dir, run); + if (fails) { + assert.equal(ok, false, `expected a failure, got:\n${output}`); + assert.ok(output.includes(fails), `expected "${fails}" in:\n${output}`); + } else { + assert.equal(ok, true, output); + } + } finally { + removeTree(dir); + } + }); +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-entry/src/pages/Ok.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-entry/src/pages/Ok.jsx new file mode 100644 index 0000000000..8e96182e95 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-entry/src/pages/Ok.jsx @@ -0,0 +1 @@ +import "@v1"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx new file mode 100644 index 0000000000..d822a2d078 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx @@ -0,0 +1,3 @@ +import { pluginManifest } from "@v1/plugins/manifest"; + +export const manifest = pluginManifest; diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx new file mode 100644 index 0000000000..d462e4d68a --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx @@ -0,0 +1,3 @@ +import { pluginManifest } from "../v1/plugins/manifest"; + +export const manifest = pluginManifest; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts new file mode 100644 index 0000000000..1cb7e9a430 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts @@ -0,0 +1,4 @@ +import { renderLabel } from "@v1/test/render"; +import { label } from "./Label"; + +export const rendered = renderLabel(label("ok")); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.ts new file mode 100644 index 0000000000..ef8a7487be --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.ts @@ -0,0 +1,3 @@ +import { upper } from "@v1/lib/format"; + +export const label = (text: string): string => upper(text); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/lib/format.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/lib/format.ts new file mode 100644 index 0000000000..ee479a8063 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/lib/format.ts @@ -0,0 +1 @@ +export const upper = (value: string): string => value.toUpperCase(); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.ts new file mode 100644 index 0000000000..b902577b52 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.ts @@ -0,0 +1 @@ +export const title = "Projects"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/view/heading.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/view/heading.ts new file mode 100644 index 0000000000..671f792f33 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/view/heading.ts @@ -0,0 +1,4 @@ +import { label } from "@v1/components/ui/Label"; +import { title } from "@v1/pages/projects/title"; + +export const heading = label(title); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/test/render.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/test/render.ts new file mode 100644 index 0000000000..858e72d057 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/test/render.ts @@ -0,0 +1 @@ +export const renderLabel = (text: string): string => text; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-ant-class-prefix/src/v1/components/ui/Button.ts b/frontend/test-fixtures/v1-boundary/cases/v1-ant-class-prefix/src/v1/components/ui/Button.ts new file mode 100644 index 0000000000..a87d0cde10 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-ant-class-prefix/src/v1/components/ui/Button.ts @@ -0,0 +1 @@ +export const buttonClass = "ant-btn primary"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-base-rules-still-apply/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-base-rules-still-apply/src/v1/lib/bad.ts new file mode 100644 index 0000000000..32f9a0a998 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-base-rules-still-apply/src/v1/lib/bad.ts @@ -0,0 +1 @@ +export const same = (a: number, b: number): boolean => a == b; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-component-css-file/src/v1/components/ui/Badge.css b/frontend/test-fixtures/v1-boundary/cases/v1-component-css-file/src/v1/components/ui/Badge.css new file mode 100644 index 0000000000..63bd92461e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-component-css-file/src/v1/components/ui/Badge.css @@ -0,0 +1,3 @@ +.badge { + color: red; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-component-css-import/src/v1/components/ui/Badge.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-component-css-import/src/v1/components/ui/Badge.tsx new file mode 100644 index 0000000000..be8a92895c --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-component-css-import/src/v1/components/ui/Badge.tsx @@ -0,0 +1,3 @@ +import "./Badge.css"; + +export const badge = "badge"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-explicit-any/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-explicit-any/src/v1/lib/bad.ts new file mode 100644 index 0000000000..6724498208 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-explicit-any/src/v1/lib/bad.ts @@ -0,0 +1 @@ +export const value: any = 1; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-implicit-any/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-implicit-any/src/v1/lib/bad.ts new file mode 100644 index 0000000000..cf9f27088d --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-implicit-any/src/v1/lib/bad.ts @@ -0,0 +1,3 @@ +export function identity(value) { + return value; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts new file mode 100644 index 0000000000..b6e5854b2b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts @@ -0,0 +1,3 @@ +import { getBaseUrl } from "@/helpers/GetStaticData"; + +export const base = getBaseUrl; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts new file mode 100644 index 0000000000..d2c250ae68 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts @@ -0,0 +1,3 @@ +import { getBaseUrl } from "../../helpers/GetStaticData"; + +export const base = getBaseUrl; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-js-file/src/v1/lib/legacy.js b/frontend/test-fixtures/v1-boundary/cases/v1-js-file/src/v1/lib/legacy.js new file mode 100644 index 0000000000..7e15a4a640 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-js-file/src/v1/lib/legacy.js @@ -0,0 +1 @@ +export const legacy = 1; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-outside-taxonomy/src/v1/widgets/thing.ts b/frontend/test-fixtures/v1-boundary/cases/v1-outside-taxonomy/src/v1/widgets/thing.ts new file mode 100644 index 0000000000..61483b3477 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-outside-taxonomy/src/v1/widgets/thing.ts @@ -0,0 +1 @@ +export const thing = 1; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts new file mode 100644 index 0000000000..1a075e1cc3 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts @@ -0,0 +1,3 @@ +import { settingsTitle } from "@v1/pages/settings"; + +export const title = settingsTitle; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/settings/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/settings/index.ts new file mode 100644 index 0000000000..317694fcb8 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/settings/index.ts @@ -0,0 +1 @@ +export const settingsTitle = "Settings"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-prop-types-assignment/src/v1/components/ui/Box.ts b/frontend/test-fixtures/v1-boundary/cases/v1-prop-types-assignment/src/v1/components/ui/Box.ts new file mode 100644 index 0000000000..e6e9ef769a --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-prop-types-assignment/src/v1/components/ui/Box.ts @@ -0,0 +1,4 @@ +export function Box(): null { + return null; +} +Box.propTypes = {}; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-prop-types/src/v1/components/ui/Badge.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-prop-types/src/v1/components/ui/Badge.tsx new file mode 100644 index 0000000000..64c5471dfb --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-prop-types/src/v1/components/ui/Badge.tsx @@ -0,0 +1,3 @@ +import PropTypes from "prop-types"; + +export const badgeShape = PropTypes.string; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx new file mode 100644 index 0000000000..3863836b20 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx @@ -0,0 +1,3 @@ +import { projectsUrl } from "@v1/services/projects"; + +export const href = projectsUrl; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/services/projects.ts b/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/services/projects.ts new file mode 100644 index 0000000000..934524c2f4 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/services/projects.ts @@ -0,0 +1 @@ +export const projectsUrl = "/api/v1/projects"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-unused-local/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-unused-local/src/v1/lib/bad.ts new file mode 100644 index 0000000000..ac320e04db --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-unused-local/src/v1/lib/bad.ts @@ -0,0 +1,4 @@ +export function double(value: number): number { + const unused = 1; + return value * 2; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index f7e7014f49..779791063e 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -40,9 +40,12 @@ * `baseUrl` was removed in TypeScript 6, so the paths are relative to * this file's directory. */ "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + /* v0 may import only the v1 public entry, so only that is mapped. */ + "@v1": ["./src/v1/index.ts"] } }, "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"], - "exclude": ["node_modules", "build", "dist"] + /* src/v1 is checked by src/v1/tsconfig.json under stricter settings. */ + "exclude": ["node_modules", "build", "dist", "src/v1"] } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index af124153fc..e9882c5d8f 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -184,6 +184,8 @@ export default defineConfig(({ mode }) => { resolve: { alias: { "@": path.resolve(import.meta.dirname, "./src"), + // v1 frontend; mirrored in src/v1/tsconfig.json and vitest.config.mjs. + "@v1": path.resolve(import.meta.dirname, "./src/v1"), }, }, diff --git a/frontend/vitest.config.mjs b/frontend/vitest.config.mjs index 128c7f042f..915c23d11d 100644 --- a/frontend/vitest.config.mjs +++ b/frontend/vitest.config.mjs @@ -94,6 +94,8 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(import.meta.dirname, "./src"), + // Mirror of the `@v1` alias in vite.config.js. + "@v1": path.resolve(import.meta.dirname, "./src/v1"), }, }, test: { @@ -107,8 +109,16 @@ export default defineConfig({ * The Playwright suite (the rig's `ui` group) lives in `tests/e2e/ui`, out * of this tree entirely, so vitest's default glob no longer reaches it. * `build/` is listed because a production build lands there and its - * bundled chunks would otherwise be walked. + * bundled chunks would otherwise be walked. `test-fixtures/` holds + * deliberately broken trees (some with `*.test.ts` files) that + * test-fixtures/v1-boundary/boundary.fixtures.mjs lints and type-checks; + * they are inputs, not tests. */ - exclude: ["**/node_modules/**", "**/dist/**", "**/build/**"], + exclude: [ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "test-fixtures/**", + ], }, }); From 5c55a7c12aca0e21c69094b2d39456e702f8f78f Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Mon, 26 Oct 2026 18:04:01 +0530 Subject: [PATCH 02/38] fix(frontend): close v1 guardrail bypasses found in self-review (UN-4270) check-v1.mjs: - Scan comments with a string/template-aware tokenizer; a `/*` or `//` inside a string or line comment no longer hides the imports after it. - Read template-literal specifiers, import.meta.glob and `import(..., opts)`. - Flag specifiers that leave src/v1 (`/`, `..`, `@/`, `src/`) itself, not only via Biome, which skips template literals. - Check `@import`/`url()` and `ant-` in styles/ CSS; report symlinks; catch built `ant-` names; treat .test/.spec .mts/.cts as test files; ignore .DS_Store; drop the unused --root option. biome.json: also ban `..`, `./../**`, `/**`, `antd/**`, `prop-types/**`. tsconfig: skipLibCheck off so v1's own .d.ts files are checked. .gitignore: re-include lib/ at any depth under src/v1. Fixtures: a generated matrix over every folder-contract pair, every import form, every outside spelling, a symlink case, and cases for each new rule; tsc cases assert error codes; output matching excludes the command text and spawn errors fail loudly. Comment and README corrections. --- .gitignore | 2 +- frontend/biome.json | 15 +- frontend/scripts/check-v1.mjs | 178 ++++++++--- frontend/src/v1/README.md | 23 +- frontend/src/v1/index.ts | 2 +- frontend/src/v1/plugins/manifest.ts | 2 +- frontend/src/v1/tsconfig.json | 9 +- .../v1-boundary/boundary.fixtures.mjs | 294 ++++++++++++++++-- .../src/v1/components/ui/Tag.tsx | 7 + .../v1-allowed-imports/src/v1/styles/index.ts | 3 + .../src/v1/styles/tokens.css | 3 + .../src/v1/components/ui/Tag.ts | 1 + .../src/v1/components/ui/Button.ts | 3 + .../src/v1/components/ui/Button.ts | 3 + .../src/v1/lib/logo.ts | 3 + .../src/v1/components/ui/Helper.ts | 3 + .../src/v1/test/render.ts | 1 + .../src/v1/components/ui/Badge.ts | 3 + .../cases/v1-root-file/src/v1/main.ts | 1 + .../cases/v1-strict-null/src/v1/lib/length.ts | 1 + .../src/v1/styles/global.css | 3 + .../src/v1/styles/global.css | 1 + .../src/v1/components/ui/Link.test.ts | 3 + .../src/v1/services/projects.ts | 1 + .../v1-unused-parameter/src/v1/lib/one.ts | 1 + frontend/vite.config.js | 3 +- frontend/vitest.config.mjs | 2 +- 27 files changed, 494 insertions(+), 77 deletions(-) create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/index.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/tokens.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-ant-built-class/src/v1/components/ui/Tag.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-antd-subpath/src/v1/components/ui/Button.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-imports-root-absolute/src/v1/lib/logo.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/components/ui/Helper.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/test/render.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-prop-types-subpath/src/v1/components/ui/Badge.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-root-file/src/v1/main.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-strict-null/src/v1/lib/length.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-ant-class/src/v1/styles/global.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-import-outside/src/v1/styles/global.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/components/ui/Link.test.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/services/projects.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-unused-parameter/src/v1/lib/one.ts diff --git a/.gitignore b/.gitignore index c7a5148e30..4817bafa05 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,7 @@ lib64/ # v1 guardrail fixtures. Keep those tracked, or their files are silently left # out of commits. !frontend/src/lib/ -!frontend/src/v1/lib/ +!frontend/src/v1/**/lib/ !frontend/test-fixtures/**/lib/ parts/ sdist/ diff --git a/frontend/biome.json b/frontend/biome.json index c8ead404ef..6799918af6 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -145,13 +145,24 @@ }, "patterns": [ { - "group": ["../**", "@/**", "src/**"], + "group": [ + "../**", + "..", + "./../**", + "/**", + "@/**", + "src/**" + ], "message": "v1 imports nothing outside src/v1, and never with '../'. Use './' within a folder and '@v1/...' across folders." }, { - "group": ["@ant-design/**"], + "group": ["@ant-design/**", "antd/**"], "message": "antd is not used under src/v1." }, + { + "group": ["prop-types/**"], + "message": "v1 is typed with TypeScript; PropTypes are not used under src/v1." + }, { "group": ["**/*.css", "!@v1/styles/**"], "message": "No component .css under src/v1; style with tokens and Tailwind. Global v1 styles live in @v1/styles/." diff --git a/frontend/scripts/check-v1.mjs b/frontend/scripts/check-v1.mjs index 6a27cb1783..e5d6612ae9 100755 --- a/frontend/scripts/check-v1.mjs +++ b/frontend/scripts/check-v1.mjs @@ -5,16 +5,19 @@ * --files TypeScript only (a .js under v1 is skipped by tsc, not failed, * because allowJs is off), style files only in styles/, and every * file inside the v1 folder taxonomy. Run by `typecheck:v1`. - * --code The folder import contract (which v1 folder may import which), - * no PropTypes, no antd `ant-` class prefix. Run by `lint:v1`. + * --code Imports: nothing outside src/v1 and the folder import contract + * (which v1 folder may import which). No PropTypes, no antd `ant-` + * class names. Run by `lint:v1`. * - * With neither flag, both run. `--root ` points it at another tree - * (used by the fixtures in test-fixtures/v1-boundary/). + * With neither flag, both run. It always checks the src/v1 beside this + * script; the fixtures in test-fixtures/v1-boundary/ copy it into each + * throwaway tree for that reason. * - * The v0 <-> v1 boundary itself, `../` imports and banned packages are Biome - * rules (biome.json `overrides`), so they also run in CI and in editors. - * Biome overrides replace rule options rather than merge them, which is why - * the per-folder contract lives here and not as one override per folder. + * The v0 <-> v1 boundary is also a Biome rule (biome.json `overrides`), so CI + * and editors report it. The script repeats the v1 -> outside half because it + * also reads template-literal imports, which Biome does not. Biome overrides + * replace rule options rather than merge them, which is why the per-folder + * contract lives here and not as one override per folder. * * The `ant-` check exists only while antd markup survives in v0; drop it * once the shadcn migration is complete. @@ -22,21 +25,16 @@ import fs from "node:fs"; import path from "node:path"; +const ROOT = path.join(import.meta.dirname, "..", "src", "v1"); const args = process.argv.slice(2); -const rootArg = args.indexOf("--root"); -const ROOT = path.resolve( - rootArg === -1 - ? path.join(import.meta.dirname, "..", "src", "v1") - : args[rootArg + 1], -); const runFiles = args.includes("--files") || !args.includes("--code"); const runCode = args.includes("--code") || !args.includes("--files"); const ALL = "*"; /* - * Folder -> folders it may import from, besides itself. Mirrors the contract - * table in the P0-T01 spec and src/v1/README.md. `entry` is src/v1/index.ts. - * Pages may import everything except other pages. + * Folder -> folders it may import from, besides itself. `entry` is + * src/v1/index.ts. Pages may import everything except other pages. + * Mirrors the table in src/v1/README.md; change both together. */ const CONTRACT = { entry: ALL, @@ -82,10 +80,12 @@ const ROOT_FILES = new Set(["index.ts", "tsconfig.json", "README.md"]); const CODE_EXT = /\.(ts|tsx|mts|cts)$/; const JS_EXT = /\.(js|jsx|mjs|cjs)$/; const STYLE_EXT = /\.(css|scss|sass|less)$/; -const TEST_FILE = /\.(test|spec)\.tsx?$/; +const TEST_FILE = /\.(test|spec)\.(ts|tsx|mts|cts)$/; +// Specifiers that leave src/v1: root-absolute, parent, the v0 alias, src/. +const OUTSIDE = /^(\/|\.\.(\/|$)|@\/|src\/)/; /* - * The v1 folder a path (relative to ROOT, `/`-separated) belongs to, e.g. + * The v1 folder a path (relative to src/v1, `/`-separated) belongs to, e.g. * "components/ui" or "pages/settings"; null when it is outside the taxonomy. * `isDir` is for import targets like "@v1/lib", which name a folder. */ @@ -128,22 +128,93 @@ function mayImport(from, to, isTestFile) { return allowed === ALL || allowed.includes(to); } -/* Blanks comments while keeping line numbers, so matches can be reported. */ -function stripComments(src) { - return src - .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) - .replace(/(^|[^:"'`\\])\/\/.*$/gm, "$1"); +/* + * Blanks comments, keeping line numbers so matches can be reported. Tracks + * strings and template literals (including `${}` expressions), so a `/*` or + * `//` inside a string does not swallow the code after it. Regex literals are + * not tracked; a quote inside one is read as a string to the end of the line + * at most, so it can hide that line only. + */ +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) { + const c = src[i]; + const next = src[i + 1]; + if (stack.at(-1) === "`") { + if (c === "\\") { + out += src.slice(i, i + 2); + i += 2; + } else if (c === "`") { + stack.pop(); + out += c; + i += 1; + } else if (c === "$" && next === "{") { + stack.push("{"); + out += "${"; + i += 2; + } else { + out += c; + i += 1; + } + continue; + } + if (c === "/" && (next === "/" || next === "*")) { + const close = + next === "/" ? src.indexOf("\n", i) : src.indexOf("*/", i + 2); + const end = close === -1 ? src.length : next === "/" ? close : close + 2; + out += src.slice(i, end).replace(/[^\n]/g, " "); + i = end; + } else if (c === '"' || c === "'") { + let j = i + 1; + while (j < src.length && src[j] !== c && src[j] !== "\n") { + j += src[j] === "\\" ? 2 : 1; + } + out += src.slice(i, j + 1); + i = j + 1; + } else { + if (c === "`") { + stack.push("`"); + } else if (c === "{" && stack.length > 0) { + stack.push("{"); + } else if (c === "}" && stack.length > 0) { + stack.pop(); + } + out += c; + i += 1; + } + } + return out; } +// A specifier: a quoted or backtick string with no interpolation. +const SPEC = "[\"'`]([^\"'`$]+)[\"'`]"; const IMPORT_RES = [ - /\b(?:import|export)\s+(?:type\s+)?(?:[\w$*{}\s,]+?\s+from\s+)?["']([^"']+)["']/g, - /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, - /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g, + new RegExp( + `\\b(?:import|export)\\s+(?:type\\s+)?(?:[\\w$*{}\\s,]+?\\s+from\\s+)?${SPEC}`, + "g", + ), + new RegExp(`\\bimport\\s*\\(\\s*${SPEC}\\s*[,)]`, "g"), + new RegExp(`\\brequire\\s*\\(\\s*${SPEC}\\s*\\)`, "g"), + // Vite globs; the first pattern of an array is checked. + new RegExp( + `\\bimport\\.meta\\.glob\\s*(?:<[^>]*>)?\\(\\s*\\[?\\s*${SPEC}`, + "g", + ), ]; +// CSS `@import` and `url()` targets, for the files in styles/. +const CSS_IMPORT_RE = /(?:@import\s+(?:url\(\s*)?|url\(\s*)["']?([^"')\s;]+)/g; -/* Resolves a specifier to a v1 folder, or undefined for packages and the - * forms Biome already rejects (`../`, `@/`). */ +/* + * Classifies a specifier: "outside" when it leaves src/v1; the v1 folder it + * names; null for a v1 path outside the taxonomy; undefined for a package. + */ function targetFolder(spec, fileRel) { + if (OUTSIDE.test(spec)) { + return "outside"; + } if (spec === "@v1" || /^@v1\/index(\.ts)?$/.test(spec)) { return "entry"; } @@ -155,6 +226,9 @@ function targetFolder(spec, fileRel) { } else { return undefined; } + if (rel.startsWith("..")) { + return "outside"; + } // No extension on the last segment -> it may name a folder ("@v1/lib"). const isDir = !path.posix.extname(rel); return folderOf(rel, isDir) ?? folderOf(rel) ?? null; @@ -164,10 +238,15 @@ function lineAt(text, index) { return text.slice(0, index).split("\n").length; } +// Finder litter; never committed (.gitignore), so not worth reporting. +const IGNORED = new Set([".DS_Store"]); + +/* Every file under dir, plus symlinks, which are reported rather than + * followed: a linked file would bring outside code in unchecked. */ function walk(dir) { return fs .readdirSync(dir, { recursive: true, withFileTypes: true }) - .filter((d) => d.isFile()) + .filter((d) => (d.isFile() || d.isSymbolicLink()) && !IGNORED.has(d.name)) .map((d) => path .relative(ROOT, path.join(d.parentPath, d.name)) @@ -190,6 +269,13 @@ for (const rel of walk(ROOT)) { const isRootFile = !rel.includes("/"); const folder = folderOf(rel); + if (fs.lstatSync(path.join(ROOT, rel)).isSymbolicLink()) { + if (runFiles) { + report(rel, 0, "No symlinks under v1; they bypass these checks."); + } + continue; + } + if (runFiles) { if (JS_EXT.test(name)) { report(rel, 0, "v1 is TypeScript only; use .ts or .tsx."); @@ -206,28 +292,47 @@ for (const rel of walk(ROOT)) { } } + if (runCode && STYLE_EXT.test(name) && folder === "styles") { + const css = blankComments(fs.readFileSync(path.join(ROOT, rel), "utf8")); + for (const m of css.matchAll(CSS_IMPORT_RE)) { + if (targetFolder(m[1], rel) === "outside") { + report( + rel, + lineAt(css, m.index), + `'${m[1]}' is outside src/v1; v1 styles import nothing outside it.`, + ); + } + } + for (const m of css.matchAll(/(?/ and runs the real - * package.json scripts (or a raw command) inside it. The known-bad files - * live here, outside src/, so they never reach the real lint or typecheck. + * Each case builds a minimal throwaway copy of the frontend (biome.json, + * package.json, vite-env.d.ts, scripts/, the real src/v1 scaffold, a + * node_modules symlink and a git root), overlays the files under + * cases// and runs the real package.json scripts, or a raw command, + * inside it. The known-bad files live here, outside src/, so CI lint, the + * pre-commit hook and typecheck never see them. */ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; @@ -24,8 +25,8 @@ const { scripts } = JSON.parse( /* * `run` holds package.json script names, or raw commands for the v0 side, - * which has no v1 script. `fails` is a substring the output must contain; - * cases without it must pass. + * which has no v1 script. `fails` is a substring the tool output must + * contain; cases without it must pass. */ const CASES = [ // The empty scaffold passes everything. @@ -42,6 +43,11 @@ const CASES = [ run: ["lint:v1"], fails: "v1 imports nothing outside src/v1", }, + { + name: "v1-imports-root-absolute", + run: ["lint:v1"], + fails: "v1 imports nothing outside src/v1", + }, // Import boundary, v0 -> v1 (Biome). { @@ -63,6 +69,31 @@ const CASES = [ run: ["lint:v1"], fails: "No PropTypes under v1", }, + { + name: "v1-antd-import", + run: ["lint:v1"], + fails: "antd is not used under src/v1", + }, + { + name: "v1-antd-subpath", + run: ["lint:v1"], + fails: "antd is not used under src/v1", + }, + { + name: "v1-prop-types-subpath", + run: ["lint:v1"], + fails: "PropTypes are not used", + }, + { + name: "v1-styles-import-outside", + run: ["lint:v1"], + fails: "v1 styles import nothing outside it", + }, + { + name: "v1-styles-ant-class", + run: ["lint:v1"], + fails: "No antd 'ant-' class names", + }, { name: "v1-component-css-import", run: ["lint:v1"], @@ -78,6 +109,11 @@ const CASES = [ run: ["lint:v1"], fails: "No antd 'ant-' class names", }, + { + name: "v1-ant-built-class", + run: ["lint:v1"], + fails: "No antd 'ant-' class names", + }, { name: "v1-explicit-any", run: ["lint:v1"], fails: "noExplicitAny" }, // The v1 override must add to the shared rules, not replace them. { @@ -91,20 +127,35 @@ const CASES = [ { name: "v1-implicit-any", run: ["typecheck:v1"], - fails: "implicitly has an 'any' type", + fails: "TS7006", + }, + { + name: "v1-strict-null", + run: ["typecheck:v1"], + fails: "TS18048", }, { name: "v1-unused-local", run: ["typecheck:v1"], - fails: "is declared but its value is never read", + fails: "TS6133", + }, + { + name: "v1-unused-parameter", + run: ["typecheck:v1"], + fails: "TS6133", }, - // Folder taxonomy and contract. + // Folder taxonomy and contract (the full contract is the matrix below). { name: "v1-outside-taxonomy", run: ["typecheck:v1"], fails: "Outside the v1 folder taxonomy", }, + { + name: "v1-root-file", + run: ["typecheck:v1"], + fails: "Outside the v1 folder taxonomy", + }, { name: "v1-ui-imports-services", run: ["lint:v1"], @@ -115,6 +166,17 @@ const CASES = [ run: ["lint:v1"], fails: "pages/projects/ may not import from pages/settings/", }, + // Only test files may use test/, and being a test file allows nothing else. + { + name: "v1-non-test-imports-test", + run: ["lint:v1"], + fails: "components/ui/ may not import from test/", + }, + { + name: "v1-test-file-imports-services", + run: ["lint:v1"], + fails: "components/ui/ may not import from services/", + }, { name: "v1-allowed-imports", run: ["typecheck:v1", "lint:v1"] }, ]; @@ -151,8 +213,20 @@ function removeTree(dir) { fs.rmSync(dir, { recursive: true, force: true }); } +function writeFile(dir, rel, content) { + const file = path.join(dir, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +/* + * Runs the steps in order, stopping at the first failure. `output` holds only + * what the tools printed, so an expected message can never match the command + * text. A command that could not start at all is an error, not a "failure". + */ function runAll(dir, run) { let output = ""; + let log = ""; for (const step of run) { const command = scripts[step] ?? step; const result = spawnSync("sh", ["-c", command], { @@ -164,12 +238,34 @@ function runAll(dir, run) { PATH: `${path.join(dir, "node_modules", ".bin")}${path.delimiter}${process.env.PATH}`, }, }); - output += `$ ${command}\n${result.stdout}${result.stderr}`; + if (result.error || result.status === null) { + throw new Error( + `could not run "${command}": ${result.error ?? result.signal}`, + ); + } + // 127: sh could not find the command, e.g. a tool missing from PATH. + assert.notEqual(result.status, 127, `command not found: ${command}`); + output += result.stdout + result.stderr; + log += `$ ${command}\n${result.stdout}${result.stderr}`; if (result.status !== 0) { - return { ok: false, output }; + return { ok: false, output, log }; + } + } + return { ok: true, output, log }; +} + +/* Runs `check-v1.mjs --code` over generated files; returns the reported lines. */ +function checkCode(files) { + const dir = makeTree(null); + try { + for (const [rel, content] of Object.entries(files)) { + writeFile(dir, rel, content); } + const { output } = runAll(dir, ["node scripts/check-v1.mjs --code"]); + return output.split("\n").filter((l) => l.startsWith("src/v1/")); + } finally { + removeTree(dir); } - return { ok: true, output }; } /* @@ -184,9 +280,11 @@ test("no v1 or fixture file is git-ignored", () => { recursive: true, withFileTypes: true, }) - .filter((d) => d.isFile()) + .filter((d) => d.isFile() && d.name !== ".DS_Store") .map((d) => path.relative(FRONTEND, path.join(d.parentPath, d.name))), ); + // A lib/ folder anywhere under v1, not only the top-level one. + files.push("src/v1/pages/projects/lib/x.ts", "src/v1/lib/sub/lib/x.ts"); const result = spawnSync("git", ["check-ignore", "--no-index", "--stdin"], { cwd: FRONTEND, encoding: "utf8", @@ -206,15 +304,171 @@ for (const { name, run, fails } of CASES) { test(label, () => { const dir = makeTree(name); try { - const { ok, output } = runAll(dir, run); + const { ok, output, log } = runAll(dir, run); if (fails) { - assert.equal(ok, false, `expected a failure, got:\n${output}`); - assert.ok(output.includes(fails), `expected "${fails}" in:\n${output}`); + assert.equal(ok, false, `expected a failure, got:\n${log}`); + assert.ok(output.includes(fails), `expected "${fails}" in:\n${log}`); } else { - assert.equal(ok, true, output); + assert.equal(ok, true, log); } } finally { removeTree(dir); } }); } + +/* + * The folder contract, written out independently of check-v1.mjs so a typo + * in either shows up here. Source: the table in src/v1/README.md. Folders + * may always import themselves; pages/a and pages/b stand for two pages. + */ +const FOLDERS = { + entry: "", + app: "app", + "components/ui": "components/ui", + "components/patterns": "components/patterns", + shell: "shell", + "pages/a": "pages/a", + "pages/b": "pages/b", + services: "services", + stores: "stores", + hooks: "hooks", + lib: "lib", + styles: "styles", + plugins: "plugins", + test: "test", +}; +const EVERYTHING = Object.keys(FOLDERS); +const MAY_IMPORT = { + app: EVERYTHING, + plugins: EVERYTHING, + test: EVERYTHING, + "pages/a": EVERYTHING.filter((f) => f !== "pages/b"), + "components/ui": ["lib", "styles"], + "components/patterns": [ + "components/ui", + "lib", + "services", + "stores", + "hooks", + ], + shell: [ + "components/ui", + "components/patterns", + "services", + "stores", + "hooks", + "lib", + ], + services: ["lib"], + stores: ["lib"], + hooks: ["lib"], + lib: [], + styles: ["lib"], +}; + +test("folder contract: every folder pair, by file and by folder", () => { + const files = {}; + const expected = []; + for (const [from, allowed] of Object.entries(MAY_IMPORT)) { + for (const to of EVERYTHING) { + const rel = `src/v1/${FOLDERS[from]}/m-${to.replace("/", "-")}.ts`; + // The entry is a file, so it has only the one form. + files[rel] = + to === "entry" + ? 'import "@v1";\n' + : `import "@v1/${FOLDERS[to]}/target";\nimport "@v1/${FOLDERS[to]}";\n`; + if (from !== to && !allowed.includes(to)) { + const lines = to === "entry" ? [1] : [1, 2]; + for (const line of lines) { + expected.push(`${rel}:${line}: ${from}/ may not import from ${to}/`); + } + } + } + } + const reported = checkCode(files).map((l) => l.replace(/ \(.*$/, "")); + assert.deepEqual(reported.sort(), expected.sort()); +}); + +test("folder contract: every import form is read", () => { + // Each file breaks the contract once (components/ui -> services). + const forms = { + static: 'import { a } from "@v1/services/a";', + multiline: 'import {\n a,\n b,\n} from "@v1/services/a";', + type: 'import type { A } from "@v1/services/a";', + inlineType: 'import { type A } from "@v1/services/a";', + sideEffect: 'import "@v1/services/a";', + namespace: 'import * as a from "@v1/services/a";', + exportFrom: 'export { a } from "@v1/services/a";', + exportStar: 'export * from "@v1/services/a";', + exportStarAs: 'export * as a from "@v1/services/a";', + dynamic: 'export const a = () => import("@v1/services/a");', + dynamicTemplate: "export const a = () => import(`@v1/services/a`);", + require: 'export const a = require("@v1/services/a");', + relative: 'import { a } from "./../../services/a";', + afterSlashStarInString: + 'export const route = "/projects/*";\nimport { a } from "@v1/services/a";\n/** doc */', + afterSlashStarInComment: + '// matches /api/*\nimport { a } from "@v1/services/a";\n/** doc */', + afterDoubleSlashInString: + 'const u = "a//b"; export { a } from "@v1/services/a";', + glob: 'export const a = import.meta.glob("@v1/services/*.ts");', + afterTemplateExpression: + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + 'const t = `${"}"}/*`;\nimport { a } from "@v1/services/a";\n/** doc */', + }; + const files = {}; + for (const [form, code] of Object.entries(forms)) { + files[`src/v1/components/ui/${form}.ts`] = `${code}\n`; + } + const reported = checkCode(files); + for (const form of Object.keys(forms)) { + const file = `src/v1/components/ui/${form}.ts:`; + assert.ok( + reported.some((l) => l.startsWith(file)), + `${form}: not reported\n${reported.join("\n")}`, + ); + } +}); + +test("a symlinked file under v1 fails typecheck:v1", () => { + const dir = makeTree(null); + try { + fs.symlinkSync( + path.join(dir, "vite-env.d.ts"), + path.join(dir, "src", "v1", "lib", "linked.d.ts"), + ); + const { ok, output, log } = runAll(dir, ["typecheck:v1"]); + assert.equal(ok, false, log); + assert.ok(output.includes("No symlinks under v1"), log); + } finally { + removeTree(dir); + } +}); + +test("v1 imports nothing outside src/v1, in any spelling", () => { + const specs = [ + "@/helpers/x", + "../helpers/x", + "..", + "./../../helpers/x", + "/src/assets/logo.svg", + "src/helpers/x", + ]; + const files = {}; + specs.forEach((spec, i) => { + files[`src/v1/lib/outside-${i}.ts`] = + `export const a = () => import(\`${spec}\`);\n`; + }); + const reported = checkCode(files); + specs.forEach((spec, i) => { + assert.ok( + reported.some( + (l) => + l.startsWith(`src/v1/lib/outside-${i}.ts:`) && + l.includes("is outside src/v1"), + ), + `'${spec}': not reported\n${reported.join("\n")}`, + ); + }); +}); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx new file mode 100644 index 0000000000..f00b602b5d --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx @@ -0,0 +1,7 @@ +import { upper } from "@v1/lib/format"; + +type TagProps = { label: string }; + +export function Tag({ label }: TagProps) { + return {upper(label)}; +} 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 new file mode 100644 index 0000000000..7ab5ff7d43 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/index.ts @@ -0,0 +1,3 @@ +import "@v1/styles/tokens.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 new file mode 100644 index 0000000000..ac38c30204 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/tokens.css @@ -0,0 +1,3 @@ +:root { + --v1-accent: #000; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-ant-built-class/src/v1/components/ui/Tag.ts b/frontend/test-fixtures/v1-boundary/cases/v1-ant-built-class/src/v1/components/ui/Tag.ts new file mode 100644 index 0000000000..758e2c7350 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-ant-built-class/src/v1/components/ui/Tag.ts @@ -0,0 +1 @@ +export const tagClass = (kind: string): string => `ant-${kind}`; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts b/frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts new file mode 100644 index 0000000000..fda637b2e3 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts @@ -0,0 +1,3 @@ +import { Button } from "antd"; + +export const AntButton = Button; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-antd-subpath/src/v1/components/ui/Button.ts b/frontend/test-fixtures/v1-boundary/cases/v1-antd-subpath/src/v1/components/ui/Button.ts new file mode 100644 index 0000000000..be5137ace6 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-antd-subpath/src/v1/components/ui/Button.ts @@ -0,0 +1,3 @@ +import Button from "antd/es/button"; + +export const AntButton = Button; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-imports-root-absolute/src/v1/lib/logo.ts b/frontend/test-fixtures/v1-boundary/cases/v1-imports-root-absolute/src/v1/lib/logo.ts new file mode 100644 index 0000000000..b306ec899d --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-imports-root-absolute/src/v1/lib/logo.ts @@ -0,0 +1,3 @@ +import logo from "/src/assets/logo.svg"; + +export const logoUrl = logo; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/components/ui/Helper.ts b/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/components/ui/Helper.ts new file mode 100644 index 0000000000..3ae7c334e1 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/components/ui/Helper.ts @@ -0,0 +1,3 @@ +import { render } from "@v1/test/render"; + +export const helper = render("x"); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/test/render.ts b/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/test/render.ts new file mode 100644 index 0000000000..a70ecac7f4 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/test/render.ts @@ -0,0 +1 @@ +export const render = (text: string): string => text; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-prop-types-subpath/src/v1/components/ui/Badge.ts b/frontend/test-fixtures/v1-boundary/cases/v1-prop-types-subpath/src/v1/components/ui/Badge.ts new file mode 100644 index 0000000000..cec0f63a16 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-prop-types-subpath/src/v1/components/ui/Badge.ts @@ -0,0 +1,3 @@ +import PropTypes from "prop-types/index"; + +export const badgeShape = PropTypes.string; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-root-file/src/v1/main.ts b/frontend/test-fixtures/v1-boundary/cases/v1-root-file/src/v1/main.ts new file mode 100644 index 0000000000..c8d21f3f94 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-root-file/src/v1/main.ts @@ -0,0 +1 @@ +export const main = 1; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-strict-null/src/v1/lib/length.ts b/frontend/test-fixtures/v1-boundary/cases/v1-strict-null/src/v1/lib/length.ts new file mode 100644 index 0000000000..b42e7ba01f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-strict-null/src/v1/lib/length.ts @@ -0,0 +1 @@ +export const length = (value: string | undefined): number => value.length; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-ant-class/src/v1/styles/global.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-ant-class/src/v1/styles/global.css new file mode 100644 index 0000000000..d077e5ea0f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-ant-class/src/v1/styles/global.css @@ -0,0 +1,3 @@ +.ant-btn { + color: red; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-import-outside/src/v1/styles/global.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-import-outside/src/v1/styles/global.css new file mode 100644 index 0000000000..e5971a61a2 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-import-outside/src/v1/styles/global.css @@ -0,0 +1 @@ +@import "../../index.css"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/components/ui/Link.test.ts b/frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/components/ui/Link.test.ts new file mode 100644 index 0000000000..a84d47e627 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/components/ui/Link.test.ts @@ -0,0 +1,3 @@ +import { projectsUrl } from "@v1/services/projects"; + +export const url = projectsUrl; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/services/projects.ts b/frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/services/projects.ts new file mode 100644 index 0000000000..934524c2f4 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/services/projects.ts @@ -0,0 +1 @@ +export const projectsUrl = "/api/v1/projects"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-unused-parameter/src/v1/lib/one.ts b/frontend/test-fixtures/v1-boundary/cases/v1-unused-parameter/src/v1/lib/one.ts new file mode 100644 index 0000000000..58bdb81c4e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-unused-parameter/src/v1/lib/one.ts @@ -0,0 +1 @@ +export const one = (value: number): number => 1; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index e9882c5d8f..fecc6b40f5 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -184,7 +184,8 @@ export default defineConfig(({ mode }) => { resolve: { alias: { "@": path.resolve(import.meta.dirname, "./src"), - // v1 frontend; mirrored in src/v1/tsconfig.json and vitest.config.mjs. + // v1 frontend. Also in src/v1/tsconfig.json and vitest.config.mjs; + // tsconfig.json/jsconfig.json map only the entry, for v0. "@v1": path.resolve(import.meta.dirname, "./src/v1"), }, }, diff --git a/frontend/vitest.config.mjs b/frontend/vitest.config.mjs index 915c23d11d..25cbe07f68 100644 --- a/frontend/vitest.config.mjs +++ b/frontend/vitest.config.mjs @@ -110,7 +110,7 @@ export default defineConfig({ * of this tree entirely, so vitest's default glob no longer reaches it. * `build/` is listed because a production build lands there and its * bundled chunks would otherwise be walked. `test-fixtures/` holds - * deliberately broken trees (some with `*.test.ts` files) that + * known-good and known-bad trees (some with `*.test.ts` files) that * test-fixtures/v1-boundary/boundary.fixtures.mjs lints and type-checks; * they are inputs, not tests. */ From 87c803c626230b6db9d5c23b710ef088e5898f6b Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Tue, 27 Oct 2026 10:15:00 +0530 Subject: [PATCH 03/38] feat(frontend): run v1 checks in CI and tighten what pages may import (UN-4270) - ci-frontend-lint: the typecheck job also runs `typecheck:v1` and `lint:v1`, so the strict v1 tsconfig and the scripts/check-v1.mjs checks (folder contract, TypeScript-only files, PropTypes, `ant-`) gate PRs now rather than from P0-T10. - Pages may no longer import app/, test/ or the v1 entry, only the shared folders (components, shell, services, stores, hooks, lib, styles, plugins). Test files in a page may still import test/. Contract table, README and the fixture matrix updated together. --- .github/workflows/ci-frontend-lint.yaml | 12 ++++++++++++ frontend/scripts/check-v1.mjs | 15 +++++++++++++-- frontend/src/v1/README.md | 2 +- .../v1-boundary/boundary.fixtures.mjs | 12 +++++++++++- .../src/v1/pages/projects/title.test.ts | 4 ++++ 5 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.test.ts diff --git a/.github/workflows/ci-frontend-lint.yaml b/.github/workflows/ci-frontend-lint.yaml index 1c87375a96..2b5fbac9c0 100644 --- a/.github/workflows/ci-frontend-lint.yaml +++ b/.github/workflows/ci-frontend-lint.yaml @@ -121,6 +121,18 @@ jobs: working-directory: frontend run: bun run typecheck + # src/v1 is excluded from the step above and checked under its own + # stricter tsconfig, after scripts/check-v1.mjs rejects non-TS files. + - name: Run v1 typecheck + working-directory: frontend + run: bun run typecheck:v1 + + # Biome on src/v1, then the folder import contract, PropTypes and `ant-` + # checks in scripts/check-v1.mjs, which `biome ci` cannot express. + - name: Run v1 lint + working-directory: frontend + run: bun run lint:v1 + # ci-test.yaml runs the same suite as the rig's `frontend` group, but its # tiers skip frontend-only PRs; this job is what gates those. vitest: diff --git a/frontend/scripts/check-v1.mjs b/frontend/scripts/check-v1.mjs index e5d6612ae9..1096abaf8e 100755 --- a/frontend/scripts/check-v1.mjs +++ b/frontend/scripts/check-v1.mjs @@ -33,7 +33,8 @@ const runCode = args.includes("--code") || !args.includes("--files"); const ALL = "*"; /* * Folder -> folders it may import from, besides itself. `entry` is - * src/v1/index.ts. Pages may import everything except other pages. + * src/v1/index.ts. Pages may not import other pages, app/, test/ (except + * from test files) or the entry. * Mirrors the table in src/v1/README.md; change both together. */ const CONTRACT = { @@ -41,7 +42,17 @@ const CONTRACT = { app: ALL, plugins: ALL, test: ALL, - pages: ALL, + pages: [ + "components/ui", + "components/patterns", + "shell", + "services", + "stores", + "hooks", + "lib", + "styles", + "plugins", + ], "components/ui": ["lib", "styles"], "components/patterns": [ "components/ui", diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 181b42bfaa..aeb7dfb32f 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -28,7 +28,7 @@ Every folder may import from itself. Test files (`*.test.ts(x)`, | `components/ui/` | Core components | `lib/`, `styles/` | | `components/patterns/` | Composites built from core components | `components/ui/`, `lib/`, `services/`, `stores/`, `hooks/` | | `shell/` | Top bar, rail, context bar, tab bar | `components/ui/`, `components/patterns/`, `services/`, `stores/`, `hooks/`, `lib/` | -| `pages//` | One folder per target page | everything except other pages | +| `pages//` | One folder per target page | `components/ui/`, `components/patterns/`, `shell/`, `services/`, `stores/`, `hooks/`, `lib/`, `styles/`, `plugins/` — not other pages, `app/`, `test/` or the entry | | `services/` | Typed service modules; the only place a URL appears | `lib/` | | `stores/` | Zustand stores | `lib/` | | `hooks/` | Shared hooks | `lib/` | diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index 26902335b9..1c7852a4b6 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -343,7 +343,17 @@ const MAY_IMPORT = { app: EVERYTHING, plugins: EVERYTHING, test: EVERYTHING, - "pages/a": EVERYTHING.filter((f) => f !== "pages/b"), + "pages/a": [ + "components/ui", + "components/patterns", + "shell", + "services", + "stores", + "hooks", + "lib", + "styles", + "plugins", + ], "components/ui": ["lib", "styles"], "components/patterns": [ "components/ui", diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.test.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.test.ts new file mode 100644 index 0000000000..73138391d0 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.test.ts @@ -0,0 +1,4 @@ +import { renderLabel } from "@v1/test/render"; +import { title } from "./title"; + +export const rendered = renderLabel(title); From f86de0b6e5d4b3cb14674d4ea3c188393e102fe8 Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Tue, 27 Oct 2026 10:21:09 +0530 Subject: [PATCH 04/38] refactor(frontend): address SonarCloud findings on the v1 guardrails (UN-4270) check-v1.mjs: split the comment scanner into small helpers (cognitive complexity 40 -> under 15), drop the nested ternary and nested template literal, use String.raw for the import regexes, keep CLI flags in a Set. Fixture runner: - Cases and the expected folder contract are parsed text tables instead of repeated object literals (the 10% duplication). - Sorts use an explicit localeCompare comparator. - The contract matrix is built by a helper. - Spawns /bin/sh by absolute path and no longer needs `git init` (Biome only needs the ignore file). The git check-ignore call keeps a PATH lookup, with the reason and a NOSONAR marker. Fixture cases: re-exports rewritten as small functions and read-only props, without changing what each case exercises. --- frontend/scripts/check-v1.mjs | 118 ++++--- .../v1-boundary/boundary.fixtures.mjs | 312 ++++++------------ .../v0-imports-v1-internal/src/pages/Bad.jsx | 2 +- .../v0-imports-v1-relative/src/pages/Bad.jsx | 2 +- .../src/v1/components/ui/Tag.tsx | 2 +- .../src/v1/components/ui/Button.ts | 2 +- .../v1-imports-v0-alias/src/v1/lib/bad.ts | 2 +- .../v1-imports-v0-relative/src/v1/lib/bad.ts | 2 +- .../src/v1/pages/projects/index.ts | 2 +- .../src/v1/components/ui/ProjectLink.tsx | 2 +- 10 files changed, 180 insertions(+), 266 deletions(-) diff --git a/frontend/scripts/check-v1.mjs b/frontend/scripts/check-v1.mjs index 1096abaf8e..0866dec496 100755 --- a/frontend/scripts/check-v1.mjs +++ b/frontend/scripts/check-v1.mjs @@ -26,9 +26,9 @@ import fs from "node:fs"; import path from "node:path"; const ROOT = path.join(import.meta.dirname, "..", "src", "v1"); -const args = process.argv.slice(2); -const runFiles = args.includes("--files") || !args.includes("--code"); -const runCode = args.includes("--code") || !args.includes("--files"); +const args = new Set(process.argv.slice(2)); +const runFiles = args.has("--files") || !args.has("--code"); +const runCode = args.has("--code") || !args.has("--files"); const ALL = "*"; /* @@ -152,66 +152,86 @@ function blankComments(src) { // "`" = inside a template literal; "{" = inside a `${}` or a brace in one. const stack = []; while (i < src.length) { - const c = src[i]; - const next = src[i + 1]; + let end; if (stack.at(-1) === "`") { - if (c === "\\") { - out += src.slice(i, i + 2); - i += 2; - } else if (c === "`") { - stack.pop(); - out += c; - i += 1; - } else if (c === "$" && next === "{") { - stack.push("{"); - out += "${"; - i += 2; - } else { - out += c; - i += 1; - } - continue; - } - if (c === "/" && (next === "/" || next === "*")) { - const close = - next === "/" ? src.indexOf("\n", i) : src.indexOf("*/", i + 2); - const end = close === -1 ? src.length : next === "/" ? close : close + 2; + 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; - } else if (c === '"' || c === "'") { - let j = i + 1; - while (j < src.length && src[j] !== c && src[j] !== "\n") { - j += src[j] === "\\" ? 2 : 1; - } - out += src.slice(i, j + 1); - i = j + 1; + continue; + } else if (src[i] === '"' || src[i] === "'") { + end = stringEnd(src, i); } else { - if (c === "`") { - stack.push("`"); - } else if (c === "{" && stack.length > 0) { - stack.push("{"); - } else if (c === "}" && stack.length > 0) { - stack.pop(); - } - out += c; - i += 1; + codeStep(src[i], stack); + end = i + 1; } + out += src.slice(i, end); + i = end; } return out; } +/* 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(); + } +} + // A specifier: a quoted or backtick string with no interpolation. const SPEC = "[\"'`]([^\"'`$]+)[\"'`]"; const IMPORT_RES = [ new RegExp( - `\\b(?:import|export)\\s+(?:type\\s+)?(?:[\\w$*{}\\s,]+?\\s+from\\s+)?${SPEC}`, + String.raw`\b(?:import|export)\s+(?:type\s+)?(?:[\w$*{}\s,]+?\s+from\s+)?${SPEC}`, "g", ), - new RegExp(`\\bimport\\s*\\(\\s*${SPEC}\\s*[,)]`, "g"), - new RegExp(`\\brequire\\s*\\(\\s*${SPEC}\\s*\\)`, "g"), + new RegExp(String.raw`\bimport\s*\(\s*${SPEC}\s*[,)]`, "g"), + new RegExp(String.raw`\brequire\s*\(\s*${SPEC}\s*\)`, "g"), // Vite globs; the first pattern of an array is checked. new RegExp( - `\\bimport\\.meta\\.glob\\s*(?:<[^>]*>)?\\(\\s*\\[?\\s*${SPEC}`, + String.raw`\bimport\.meta\.glob\s*(?:<[^>]*>)?\(\s*\[?\s*${SPEC}`, "g", ), ]; @@ -267,8 +287,10 @@ function walk(dir) { } const problems = []; -const report = (rel, line, msg) => - problems.push(`src/v1/${rel}${line ? `:${line}` : ""}: ${msg}`); +const report = (rel, line, msg) => { + const where = line ? `${rel}:${line}` : rel; + problems.push(`src/v1/${where}: ${msg}`); +}; if (!fs.existsSync(ROOT)) { console.error(`check-v1: ${ROOT} does not exist`); diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index 1c7852a4b6..fb84fc072c 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -11,7 +11,7 @@ * pre-commit hook and typecheck never see them. */ import assert from "node:assert/strict"; -import { execFileSync, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -23,162 +23,69 @@ const { scripts } = JSON.parse( fs.readFileSync(path.join(FRONTEND, "package.json"), "utf8"), ); -/* - * `run` holds package.json script names, or raw commands for the v0 side, - * which has no v1 script. `fails` is a substring the tool output must - * contain; cases without it must pass. - */ -const CASES = [ - // The empty scaffold passes everything. - { name: null, run: ["typecheck:v1", "lint:v1"] }, - - // Import boundary, v1 -> v0 (Biome). - { - name: "v1-imports-v0-alias", - run: ["lint:v1"], - fails: "v1 imports nothing outside src/v1", - }, - { - name: "v1-imports-v0-relative", - run: ["lint:v1"], - fails: "v1 imports nothing outside src/v1", - }, - { - name: "v1-imports-root-absolute", - run: ["lint:v1"], - fails: "v1 imports nothing outside src/v1", - }, - - // Import boundary, v0 -> v1 (Biome). - { - name: "v0-imports-v1-internal", - run: ["biome lint src/"], - fails: "v0 may import only the v1 public entry", - }, - { - name: "v0-imports-v1-relative", - run: ["biome lint src/"], - fails: "v0 may import only the v1 public entry", - }, - { name: "v0-imports-v1-entry", run: ["biome lint src/"] }, - - // Banned under v1. - { name: "v1-prop-types", run: ["lint:v1"], fails: "PropTypes are not used" }, - { - name: "v1-prop-types-assignment", - run: ["lint:v1"], - fails: "No PropTypes under v1", - }, - { - name: "v1-antd-import", - run: ["lint:v1"], - fails: "antd is not used under src/v1", - }, - { - name: "v1-antd-subpath", - run: ["lint:v1"], - fails: "antd is not used under src/v1", - }, - { - name: "v1-prop-types-subpath", - run: ["lint:v1"], - fails: "PropTypes are not used", - }, - { - name: "v1-styles-import-outside", - run: ["lint:v1"], - fails: "v1 styles import nothing outside it", - }, - { - name: "v1-styles-ant-class", - run: ["lint:v1"], - fails: "No antd 'ant-' class names", - }, - { - name: "v1-component-css-import", - run: ["lint:v1"], - fails: "No component .css under src/v1", - }, - { - name: "v1-component-css-file", - run: ["typecheck:v1"], - fails: "No component style files", - }, - { - name: "v1-ant-class-prefix", - run: ["lint:v1"], - fails: "No antd 'ant-' class names", - }, - { - name: "v1-ant-built-class", - run: ["lint:v1"], - fails: "No antd 'ant-' class names", - }, - { name: "v1-explicit-any", run: ["lint:v1"], fails: "noExplicitAny" }, - // The v1 override must add to the shared rules, not replace them. - { - name: "v1-base-rules-still-apply", - run: ["lint:v1"], - fails: "noDoubleEquals", - }, +/* Rows of "a | b | c" cells; blank lines and "#" comments are skipped. */ +function parseTable(text) { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")) + .map((line) => line.split("|").map((cell) => cell.trim())); +} - // TypeScript only, strict. - { name: "v1-js-file", run: ["typecheck:v1"], fails: "TypeScript only" }, - { - name: "v1-implicit-any", - run: ["typecheck:v1"], - fails: "TS7006", - }, - { - name: "v1-strict-null", - run: ["typecheck:v1"], - fails: "TS18048", - }, - { - name: "v1-unused-local", - run: ["typecheck:v1"], - fails: "TS6133", - }, - { - name: "v1-unused-parameter", - run: ["typecheck:v1"], - fails: "TS6133", - }, +const sorted = (items) => items.toSorted((a, b) => a.localeCompare(b)); - // Folder taxonomy and contract (the full contract is the matrix below). - { - name: "v1-outside-taxonomy", - run: ["typecheck:v1"], - fails: "Outside the v1 folder taxonomy", - }, - { - name: "v1-root-file", - run: ["typecheck:v1"], - fails: "Outside the v1 folder taxonomy", - }, - { - name: "v1-ui-imports-services", - run: ["lint:v1"], - fails: "components/ui/ may not import from services/", - }, - { - name: "v1-page-imports-other-page", - run: ["lint:v1"], - fails: "pages/projects/ may not import from pages/settings/", - }, - // Only test files may use test/, and being a test file allows nothing else. - { - name: "v1-non-test-imports-test", - run: ["lint:v1"], - fails: "components/ui/ may not import from test/", - }, - { - name: "v1-test-file-imports-services", - run: ["lint:v1"], - fails: "components/ui/ may not import from services/", - }, - { name: "v1-allowed-imports", run: ["typecheck:v1", "lint:v1"] }, -]; +/* + * One case per row: the folder under cases/ ("-" for the empty scaffold); + * the steps, joined by "+", each a package.json script name or a raw command + * (the v0 side has no v1 script); and a substring the tool output must + * contain, or "-" when the steps must pass. + */ +const CASES = parseTable(` + # The empty scaffold passes everything. + - | typecheck:v1 + lint:v1 | - + # Import boundary, v1 -> v0 (Biome). + v1-imports-v0-alias | lint:v1 | v1 imports nothing outside src/v1 + v1-imports-v0-relative | lint:v1 | v1 imports nothing outside src/v1 + v1-imports-root-absolute | lint:v1 | v1 imports nothing outside src/v1 + # Import boundary, v0 -> v1 (Biome). + v0-imports-v1-internal | biome lint src/ | v0 may import only the v1 public entry + v0-imports-v1-relative | biome lint src/ | v0 may import only the v1 public entry + v0-imports-v1-entry | biome lint src/ | - + # Banned under v1. + v1-prop-types | lint:v1 | PropTypes are not used + v1-prop-types-subpath | lint:v1 | PropTypes are not used + v1-prop-types-assignment | lint:v1 | No PropTypes under v1 + v1-antd-import | lint:v1 | antd is not used under src/v1 + v1-antd-subpath | lint:v1 | antd is not used under src/v1 + v1-styles-import-outside | lint:v1 | v1 styles import nothing outside it + v1-styles-ant-class | lint:v1 | No antd 'ant-' class names + v1-component-css-import | lint:v1 | No component .css under src/v1 + v1-component-css-file | typecheck:v1 | No component style files + v1-ant-class-prefix | lint:v1 | No antd 'ant-' class names + v1-ant-built-class | lint:v1 | No antd 'ant-' class names + v1-explicit-any | lint:v1 | noExplicitAny + # The v1 override must add to the shared rules, not replace them. + v1-base-rules-still-apply | lint:v1 | noDoubleEquals + # TypeScript only, strict. + v1-js-file | typecheck:v1 | TypeScript only + v1-implicit-any | typecheck:v1 | TS7006 + v1-strict-null | typecheck:v1 | TS18048 + v1-unused-local | typecheck:v1 | TS6133 + v1-unused-parameter | typecheck:v1 | TS6133 + # Folder taxonomy and contract (the full contract is the matrix below). + v1-outside-taxonomy | typecheck:v1 | Outside the v1 folder taxonomy + v1-root-file | typecheck:v1 | Outside the v1 folder taxonomy + v1-ui-imports-services | lint:v1 | components/ui/ may not import from services/ + v1-page-imports-other-page | lint:v1 | pages/projects/ may not import from pages/settings/ + # Only test files may use test/, and being a test file allows nothing else. + 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 | - +`).map(([name, run, fails]) => ({ + name: name === "-" ? null : name, + run: run.split(" + "), + fails: fails === "-" ? null : fails, +})); function makeTree(caseName) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "v1-boundary-")); @@ -200,9 +107,8 @@ function makeTree(caseName) { if (caseName) { fs.cpSync(path.join(CASES_DIR, caseName), dir, { recursive: true }); } - // biome.json enables VCS integration with `useIgnoreFile`, which needs a - // repository root holding an ignore file. - execFileSync("git", ["init", "-q"], { cwd: dir }); + // biome.json enables VCS integration with `useIgnoreFile`, which needs an + // ignore file at the root. fs.writeFileSync(path.join(dir, ".gitignore"), "node_modules\n"); return dir; } @@ -229,7 +135,7 @@ function runAll(dir, run) { let log = ""; for (const step of run) { const command = scripts[step] ?? step; - const result = spawnSync("sh", ["-c", command], { + const result = spawnSync("/bin/sh", ["-c", command], { cwd: dir, encoding: "utf8", env: { @@ -285,18 +191,18 @@ test("no v1 or fixture file is git-ignored", () => { ); // A lib/ folder anywhere under v1, not only the top-level one. files.push("src/v1/pages/projects/lib/x.ts", "src/v1/lib/sub/lib/x.ts"); - const result = spawnSync("git", ["check-ignore", "--no-index", "--stdin"], { - cwd: FRONTEND, - encoding: "utf8", - input: files.join("\n"), - }); + const gitArgs = ["check-ignore", "--no-index", "--stdin"]; + const options = { cwd: FRONTEND, encoding: "utf8", input: files.join("\n") }; + // git from PATH on purpose: this is a developer/CI test, and git has no + // fixed location across macOS and Linux. + const result = spawnSync("git", gitArgs, options); // NOSONAR // Exit 1 means "nothing matched"; 0 means some file is ignored. assert.equal(result.status, 1, `git-ignored:\n${result.stdout}`); }); test("every case folder is listed", () => { const listed = new Set(CASES.map((c) => c.name).filter(Boolean)); - assert.deepEqual(fs.readdirSync(CASES_DIR).sort(), [...listed].sort()); + assert.deepEqual(sorted(fs.readdirSync(CASES_DIR)), sorted([...listed])); }); for (const { name, run, fails } of CASES) { @@ -339,65 +245,51 @@ const FOLDERS = { test: "test", }; const EVERYTHING = Object.keys(FOLDERS); -const MAY_IMPORT = { - app: EVERYTHING, - plugins: EVERYTHING, - test: EVERYTHING, - "pages/a": [ - "components/ui", - "components/patterns", - "shell", - "services", - "stores", - "hooks", - "lib", - "styles", - "plugins", - ], - "components/ui": ["lib", "styles"], - "components/patterns": [ - "components/ui", - "lib", - "services", - "stores", - "hooks", - ], - shell: [ - "components/ui", - "components/patterns", - "services", - "stores", - "hooks", - "lib", - ], - services: ["lib"], - stores: ["lib"], - hooks: ["lib"], - lib: [], - styles: ["lib"], -}; +const MAY_IMPORT = Object.fromEntries( + parseTable(` + app | * + plugins | * + test | * + pages/a | components/ui components/patterns shell services stores hooks lib styles plugins + components/ui | lib styles + components/patterns | components/ui lib services stores hooks + shell | components/ui components/patterns services stores hooks lib + services | lib + stores | lib + hooks | lib + lib | + styles | lib + `).map(([from, to]) => [from, to === "*" ? EVERYTHING : to.split(/\s+/)]), +); -test("folder contract: every folder pair, by file and by folder", () => { +/* One file per folder pair, importing the target by file and by folder; and + * the problems check-v1 must report for them. */ +function contractMatrix() { const files = {}; const expected = []; for (const [from, allowed] of Object.entries(MAY_IMPORT)) { for (const to of EVERYTHING) { const rel = `src/v1/${FOLDERS[from]}/m-${to.replace("/", "-")}.ts`; // The entry is a file, so it has only the one form. + const target = FOLDERS[to]; files[rel] = to === "entry" ? 'import "@v1";\n' - : `import "@v1/${FOLDERS[to]}/target";\nimport "@v1/${FOLDERS[to]}";\n`; - if (from !== to && !allowed.includes(to)) { - const lines = to === "entry" ? [1] : [1, 2]; - for (const line of lines) { - expected.push(`${rel}:${line}: ${from}/ may not import from ${to}/`); - } + : `import "@v1/${target}/target";\nimport "@v1/${target}";\n`; + const denied = from !== to && !allowed.includes(to); + const lines = to === "entry" ? [1] : [1, 2]; + for (const line of denied ? lines : []) { + expected.push(`${rel}:${line}: ${from}/ may not import from ${to}/`); } } } + return { files, expected }; +} + +test("folder contract: every folder pair, by file and by folder", () => { + const { files, expected } = contractMatrix(); const reported = checkCode(files).map((l) => l.replace(/ \(.*$/, "")); - assert.deepEqual(reported.sort(), expected.sort()); + assert.deepEqual(sorted(reported), sorted(expected)); }); test("folder contract: every import form is read", () => { diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx index d822a2d078..ecddfc6910 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx @@ -1,3 +1,3 @@ import { pluginManifest } from "@v1/plugins/manifest"; -export const manifest = pluginManifest; +export const manifestSize = () => pluginManifest.length; diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx index d462e4d68a..953cb550c6 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx @@ -1,3 +1,3 @@ import { pluginManifest } from "../v1/plugins/manifest"; -export const manifest = pluginManifest; +export const manifestSize = () => pluginManifest.length; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx index f00b602b5d..07afb25230 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx @@ -1,6 +1,6 @@ import { upper } from "@v1/lib/format"; -type TagProps = { label: string }; +type TagProps = Readonly<{ label: string }>; export function Tag({ label }: TagProps) { return {upper(label)}; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts b/frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts index fda637b2e3..1973bc6c11 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts +++ b/frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts @@ -1,3 +1,3 @@ import { Button } from "antd"; -export const AntButton = Button; +export const buttonName = Button.name; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts index b6e5854b2b..4f64a2f1e2 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts +++ b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts @@ -1,3 +1,3 @@ import { getBaseUrl } from "@/helpers/GetStaticData"; -export const base = getBaseUrl; +export const base = () => getBaseUrl(); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts index d2c250ae68..acba160b3c 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts +++ b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts @@ -1,3 +1,3 @@ import { getBaseUrl } from "../../helpers/GetStaticData"; -export const base = getBaseUrl; +export const base = () => getBaseUrl(); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts index 1a075e1cc3..f6e2ea9482 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts +++ b/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts @@ -1,3 +1,3 @@ import { settingsTitle } from "@v1/pages/settings"; -export const title = settingsTitle; +export const title = `Projects · ${settingsTitle}`; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx index 3863836b20..8024a7c0bb 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx +++ b/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx @@ -1,3 +1,3 @@ import { projectsUrl } from "@v1/services/projects"; -export const href = projectsUrl; +export const href = `${projectsUrl}/`; From 67be34b11e113804f7b1e06ddcedc47e874c26c6 Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Tue, 27 Oct 2026 10:22:45 +0530 Subject: [PATCH 05/38] refactor(frontend): split the contract-matrix builder to cut its complexity (UN-4270) --- .../v1-boundary/boundary.fixtures.mjs | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index fb84fc072c..6b2aeaa56d 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -262,25 +262,30 @@ const MAY_IMPORT = Object.fromEntries( `).map(([from, to]) => [from, to === "*" ? EVERYTHING : to.split(/\s+/)]), ); -/* One file per folder pair, importing the target by file and by folder; and - * the problems check-v1 must report for them. */ +/* The file for one folder pair: it imports the target by file and by folder. + * The entry is a file, so it has only the one form. */ +function pairFile(to) { + const target = FOLDERS[to]; + return to === "entry" + ? ['import "@v1";'] + : [`import "@v1/${target}/target";`, `import "@v1/${target}";`]; +} + +/* One file per folder pair, and the problems check-v1 must report for them. */ function contractMatrix() { const files = {}; const expected = []; for (const [from, allowed] of Object.entries(MAY_IMPORT)) { for (const to of EVERYTHING) { const rel = `src/v1/${FOLDERS[from]}/m-${to.replace("/", "-")}.ts`; - // The entry is a file, so it has only the one form. - const target = FOLDERS[to]; - files[rel] = - to === "entry" - ? 'import "@v1";\n' - : `import "@v1/${target}/target";\nimport "@v1/${target}";\n`; + const imports = pairFile(to); + files[rel] = `${imports.join("\n")}\n`; const denied = from !== to && !allowed.includes(to); - const lines = to === "entry" ? [1] : [1, 2]; - for (const line of denied ? lines : []) { - expected.push(`${rel}:${line}: ${from}/ may not import from ${to}/`); - } + expected.push( + ...(denied ? imports : []).map( + (_, i) => `${rel}:${i + 1}: ${from}/ may not import from ${to}/`, + ), + ); } } return { files, expected }; From 81e15b1597413bac74ae4f4a4e476577326fc836 Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Tue, 27 Oct 2026 12:55:55 +0530 Subject: [PATCH 06/38] fix(frontend): check every glob pattern and v0 -> v1 imports Biome cannot see (UN-4270) Addresses Greptile review on #2380: - import.meta.glob([...]): every pattern in the array is checked, not only the first, so an allowed first pattern no longer hides a forbidden one. - check-v1 --code also scans v0 (src/ outside v1, including the cloud src/plugins overlay) for template-literal imports, globs, require(), @/v1, src/v1, relative paths and CSS @import that reach into v1 other than through `@v1`. Biome still covers plain imports. Fixtures: Greptile's example as a case, a glob array with a forbidden later pattern, and a generated test of every v0 -> v1 form (denied and allowed). The v0 scan reports nothing on the current tree. --- frontend/scripts/check-v1.mjs | 137 +++++++++++++----- frontend/src/v1/README.md | 2 +- .../v1-boundary/boundary.fixtures.mjs | 43 +++++- .../v0-imports-v1-template/src/pages/Bad.jsx | 1 + 4 files changed, 148 insertions(+), 35 deletions(-) create mode 100644 frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-template/src/pages/Bad.jsx diff --git a/frontend/scripts/check-v1.mjs b/frontend/scripts/check-v1.mjs index 0866dec496..3e842480c4 100755 --- a/frontend/scripts/check-v1.mjs +++ b/frontend/scripts/check-v1.mjs @@ -7,15 +7,17 @@ * file inside the v1 folder taxonomy. Run by `typecheck:v1`. * --code Imports: nothing outside src/v1 and the folder import contract * (which v1 folder may import which). No PropTypes, no antd `ant-` - * class names. Run by `lint:v1`. + * class names. Also scans v0 (the rest of src/) for imports that + * reach into v1 other than through `@v1`. Run by `lint:v1`. * * With neither flag, both run. It always checks the src/v1 beside this * script; the fixtures in test-fixtures/v1-boundary/ copy it into each * throwaway tree for that reason. * * The v0 <-> v1 boundary is also a Biome rule (biome.json `overrides`), so CI - * and editors report it. The script repeats the v1 -> outside half because it - * also reads template-literal imports, which Biome does not. Biome overrides + * and editors report it. The script repeats both halves because it also reads + * template-literal imports, Vite globs, require() and CSS @import, which + * Biome does not. Biome overrides * replace rule options rather than merge them, which is why the per-folder * contract lives here and not as one override per folder. * @@ -25,7 +27,8 @@ import fs from "node:fs"; import path from "node:path"; -const ROOT = path.join(import.meta.dirname, "..", "src", "v1"); +const SRC = path.join(import.meta.dirname, "..", "src"); +const ROOT = path.join(SRC, "v1"); const args = new Set(process.argv.slice(2)); const runFiles = args.has("--files") || !args.has("--code"); const runCode = args.has("--code") || !args.has("--files"); @@ -229,12 +232,28 @@ const IMPORT_RES = [ ), new RegExp(String.raw`\bimport\s*\(\s*${SPEC}\s*[,)]`, "g"), new RegExp(String.raw`\brequire\s*\(\s*${SPEC}\s*\)`, "g"), - // Vite globs; the first pattern of an array is checked. - new RegExp( - String.raw`\bimport\.meta\.glob\s*(?:<[^>]*>)?\(\s*\[?\s*${SPEC}`, - "g", - ), ]; +// Vite globs: one pattern or an array of them. Every pattern is checked. +const GLOB_RE = new RegExp( + String.raw`\bimport\.meta\.glob\s*(?:<[^>]*>)?\(\s*(\[[^\]]*\]|${SPEC})`, + "g", +); +const SPEC_RE = new RegExp(SPEC, "g"); + +/* Every import specifier in comment-blanked source, with its offset. */ +function* importSpecs(text) { + for (const re of IMPORT_RES) { + for (const m of text.matchAll(re)) { + yield { spec: m[1], index: m.index }; + } + } + for (const m of text.matchAll(GLOB_RE)) { + const start = m.index + m[0].indexOf(m[1]); + for (const s of m[1].matchAll(SPEC_RE)) { + yield { spec: s[1], index: start + s.index }; + } + } +} // CSS `@import` and `url()` targets, for the files in styles/. const CSS_IMPORT_RE = /(?:@import\s+(?:url\(\s*)?|url\(\s*)["']?([^"')\s;]+)/g; @@ -280,10 +299,10 @@ function walk(dir) { .filter((d) => (d.isFile() || d.isSymbolicLink()) && !IGNORED.has(d.name)) .map((d) => path - .relative(ROOT, path.join(d.parentPath, d.name)) + .relative(dir, path.join(d.parentPath, d.name)) .replaceAll(path.sep, "/"), ) - .sort(); + .toSorted((a, b) => a.localeCompare(b)); } const problems = []; @@ -347,28 +366,26 @@ for (const rel of walk(ROOT)) { const text = blankComments(fs.readFileSync(path.join(ROOT, rel), "utf8")); const isTestFile = TEST_FILE.test(name); - for (const re of IMPORT_RES) { - for (const m of text.matchAll(re)) { - const to = targetFolder(m[1], rel); - const line = lineAt(text, m.index); - if (to === undefined) { - continue; - } - if (to === "outside") { - report( - rel, - line, - `'${m[1]}' is outside src/v1; v1 imports nothing outside it.`, - ); - } else if (to === null) { - report(rel, line, `'${m[1]}' is outside the v1 folder taxonomy.`); - } else if (!mayImport(folder, to, isTestFile)) { - report( - rel, - line, - `${folder}/ may not import from ${to}/ ('${m[1]}'); see the folder contract in src/v1/README.md.`, - ); - } + for (const { spec, index } of importSpecs(text)) { + const to = targetFolder(spec, rel); + const line = lineAt(text, index); + if (to === undefined) { + continue; + } + if (to === "outside") { + report( + rel, + line, + `'${spec}' is outside src/v1; v1 imports nothing outside it.`, + ); + } else if (to === null) { + report(rel, line, `'${spec}' is outside the v1 folder taxonomy.`); + } else if (!mayImport(folder, to, isTestFile)) { + report( + rel, + line, + `${folder}/ may not import from ${to}/ ('${spec}'); see the folder contract in src/v1/README.md.`, + ); } } for (const m of text.matchAll(/\.propTypes\b|\bpropTypes\s*[:=]/g)) { @@ -384,6 +401,60 @@ for (const rel of walk(ROOT)) { } } +/* + * The v0 -> v1 half: v0 (everything in src/ outside v1, including the cloud + * src/plugins/ overlay) reaches v1 only through the `@v1` entry. + */ +const V0_CODE_EXT = /\.(js|jsx|mjs|cjs|ts|tsx|mts|cts)$/; + +/* Whether a v0 specifier (fileRel is relative to src/) reaches into v1. */ +function intoV1(spec, fileRel) { + if (spec === "@v1" || /^@v1\/index(\.ts)?$/.test(spec)) { + return false; + } + if (spec.startsWith("@v1/") || /^(@\/|src\/)v1(\/|$)/.test(spec)) { + return true; + } + if (!spec.startsWith(".")) { + return false; + } + const rel = path.posix.join(path.posix.dirname(fileRel), spec); + return rel === "v1" || rel.startsWith("v1/"); +} + +function v0Specs(rel, text) { + if (!STYLE_EXT.test(rel)) { + return importSpecs(text); + } + return [...text.matchAll(CSS_IMPORT_RE)].map((m) => ({ + spec: m[1], + index: m.index, + })); +} + +function checkV0() { + for (const rel of walk(SRC)) { + const full = path.join(SRC, rel); + const inV1 = rel.startsWith("v1/"); + const scanned = STYLE_EXT.test(rel) || V0_CODE_EXT.test(rel); + if (inV1 || !scanned || fs.lstatSync(full).isSymbolicLink()) { + continue; + } + const text = blankComments(fs.readFileSync(full, "utf8")); + for (const { spec, index } of v0Specs(rel, text)) { + if (intoV1(spec, rel)) { + problems.push( + `src/${rel}:${lineAt(text, index)}: '${spec}' reaches into src/v1; v0 may import only the v1 public entry, as '@v1'.`, + ); + } + } + } +} + +if (runCode) { + checkV0(); +} + if (problems.length > 0) { console.error(problems.join("\n")); console.error(`\ncheck-v1: ${problems.length} problem(s).`); diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index aeb7dfb32f..61fa459ac3 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -42,7 +42,7 @@ Every folder may import from itself. Test files (`*.test.ts(x)`, | Script | Checks | | --- | --- | | `bun run typecheck:v1` | TypeScript-only files, CSS only in `styles/`, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` | -| `bun run lint:v1` | Biome on `src/v1/`, then imports (nothing outside v1, folder contract), PropTypes and `ant-` checks | +| `bun run lint:v1` | Biome on `src/v1/`, then imports (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks | | `bun run test:v1` | v1 unit tests, then the guardrail fixtures in `test-fixtures/v1-boundary/` | The v0 ↔ v1 boundary and the banned imports are Biome rules in `biome.json`, diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index 6b2aeaa56d..c530319d89 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -50,6 +50,7 @@ const CASES = parseTable(` # Import boundary, v0 -> v1 (Biome). v0-imports-v1-internal | biome lint src/ | v0 may import only the v1 public entry v0-imports-v1-relative | biome lint src/ | v0 may import only the v1 public entry + v0-imports-v1-template | lint:v1 | v0 may import only the v1 public entry v0-imports-v1-entry | biome lint src/ | - # Banned under v1. v1-prop-types | lint:v1 | PropTypes are not used @@ -168,7 +169,7 @@ function checkCode(files) { writeFile(dir, rel, content); } const { output } = runAll(dir, ["node scripts/check-v1.mjs --code"]); - return output.split("\n").filter((l) => l.startsWith("src/v1/")); + return output.split("\n").filter((l) => l.startsWith("src/")); } finally { removeTree(dir); } @@ -320,6 +321,8 @@ test("folder contract: every import form is read", () => { afterDoubleSlashInString: 'const u = "a//b"; export { a } from "@v1/services/a";', glob: 'export const a = import.meta.glob("@v1/services/*.ts");', + globArrayLater: + 'export const a = import.meta.glob(["@v1/components/ui/*.tsx", "@v1/services/*.ts"]);', afterTemplateExpression: // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. 'const t = `${"}"}/*`;\nimport { a } from "@v1/services/a";\n/** doc */', @@ -379,3 +382,41 @@ test("v1 imports nothing outside src/v1, in any spelling", () => { ); }); }); + +test("v0 reaches v1 only through @v1, in any form", () => { + const denied = { + "static.jsx": 'import x from "@v1/lib/x";', + "template.jsx": "export const a = () => import(`@v1/plugins/manifest`);", + "glob.jsx": 'export const a = import.meta.glob("@v1/pages/*/index.ts");', + "globArrayLater.jsx": + 'export const a = import.meta.glob(["./*.jsx", "../v1/lib/*.ts"]);', + "require.jsx": 'export const a = require("../v1/lib/x");', + "aliasV0.jsx": 'export const a = () => import("@/v1/lib/x");', + "srcPath.jsx": 'export const a = () => import("src/v1/lib/x");', + "relativeEntry.jsx": 'import "../v1";', + "styles.css": '@import "../v1/styles/tokens.css";', + }; + const allowed = { + "entry.jsx": 'import "@v1";', + "entryTemplate.jsx": "export const a = () => import(`@v1`);", + "v0Only.jsx": 'import x from "../helpers/x";\nexport const y = x;', + }; + const files = {}; + for (const [name, code] of Object.entries({ ...denied, ...allowed })) { + files[`src/pages/${name}`] = `${code}\n`; + } + const reported = checkCode(files); + const v0Lines = reported.filter((l) => l.startsWith("src/pages/")); + for (const name of Object.keys(denied)) { + assert.ok( + v0Lines.some((l) => l.startsWith(`src/pages/${name}:`)), + `${name}: not reported\n${reported.join("\n")}`, + ); + } + for (const name of Object.keys(allowed)) { + assert.ok( + !v0Lines.some((l) => l.startsWith(`src/pages/${name}:`)), + `${name}: reported but allowed\n${reported.join("\n")}`, + ); + } +}); diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-template/src/pages/Bad.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-template/src/pages/Bad.jsx new file mode 100644 index 0000000000..278db55314 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-template/src/pages/Bad.jsx @@ -0,0 +1 @@ +export const loadManifest = () => import(`@v1/plugins/manifest`); From 7e541abd85665ece3d1b58b87e411e638f9f4e7e Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 11:47:36 +0530 Subject: [PATCH 07/38] UN-4299 [MISC] Turn on skipLibCheck for v1: vitest 3.2 and @types/chai 5.2 both declare containSubset Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/tsconfig.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/v1/tsconfig.json b/frontend/src/v1/tsconfig.json index 0a10f9dbdb..c72be92d4f 100644 --- a/frontend/src/v1/tsconfig.json +++ b/frontend/src/v1/tsconfig.json @@ -17,9 +17,11 @@ "allowJs": false, "noEmit": true, - /* Off so hand-written .d.ts under v1 are checked too; with it on, an - * unresolved name in one silently becomes `any`. */ - "skipLibCheck": false, + /* On: vitest 3.2 ships its own chai typings and also requires + * @types/chai ^5.2, and both declare containSubset, so library checking + * fails TS2300 for every v1 test file. This also skips hand-written .d.ts + * under v1. Turn this back off once vitest no longer clashes. */ + "skipLibCheck": true, "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, From 6f22f625b067bd30fc8d4b7377998c0d6482c7b6 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 11:47:36 +0530 Subject: [PATCH 08/38] UN-4299 [FEAT] Add the v1 theme model: three modes, follow-system and per-browser preference Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/theme.test.ts | 71 +++++++++++++++++++++++++++++++ frontend/src/v1/lib/theme.ts | 61 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 frontend/src/v1/lib/theme.test.ts create mode 100644 frontend/src/v1/lib/theme.ts 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..5c4409ec6e --- /dev/null +++ b/frontend/src/v1/lib/theme.ts @@ -0,0 +1,61 @@ +/** 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"]; + +/** 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. + } +} From 7aa5bf40ce166d8e957a62c089a50791e1b2ec78 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 11:50:50 +0530 Subject: [PATCH 09/38] UN-4299 [FEAT] Add the v1 portal container and the theme preference store Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/portal.test.ts | 35 +++++++++++ frontend/src/v1/lib/portal.ts | 41 +++++++++++++ frontend/src/v1/stores/theme.test.ts | 61 ++++++++++++++++++++ frontend/src/v1/stores/theme.ts | 86 ++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+) create mode 100644 frontend/src/v1/lib/portal.test.ts create mode 100644 frontend/src/v1/lib/portal.ts create mode 100644 frontend/src/v1/stores/theme.test.ts create mode 100644 frontend/src/v1/stores/theme.ts 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/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..15b0830fda --- /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). */ + hydrate: (storage: Pick | null | undefined) => 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) => { + 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; From 0c85cd6972ec124990336f2dcaa4c63668eb7415 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 11:55:35 +0530 Subject: [PATCH 10/38] UN-4299 [FEAT] Add the v1 token source, the generator and tokens.css in three modes Co-Authored-By: Claude Opus 5.5 --- frontend/package.json | 1 + frontend/scripts/build-v1-tokens.mjs | 40 ++ frontend/src/v1/lib/tokens.test.ts | 41 ++ frontend/src/v1/lib/tokens.ts | 59 ++ .../v1/styles/__snapshots__/token-table.md | 77 +++ frontend/src/v1/styles/tokens.css | 232 +++++++ frontend/src/v1/styles/tokens.test.ts | 119 ++++ frontend/src/v1/styles/tokens.ts | 590 ++++++++++++++++++ 8 files changed, 1159 insertions(+) create mode 100644 frontend/scripts/build-v1-tokens.mjs create mode 100644 frontend/src/v1/lib/tokens.test.ts create mode 100644 frontend/src/v1/lib/tokens.ts create mode 100644 frontend/src/v1/styles/__snapshots__/token-table.md create mode 100644 frontend/src/v1/styles/tokens.css create mode 100644 frontend/src/v1/styles/tokens.test.ts create mode 100644 frontend/src/v1/styles/tokens.ts diff --git a/frontend/package.json b/frontend/package.json index 3d7f579cc9..7007b8fc20 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -78,6 +78,7 @@ "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": "node 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", "test:v1": "vitest run --dir src/v1 --passWithNoTests && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs" diff --git a/frontend/scripts/build-v1-tokens.mjs b/frontend/scripts/build-v1-tokens.mjs new file mode 100644 index 0000000000..9983ae1844 --- /dev/null +++ b/frontend/scripts/build-v1-tokens.mjs @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import { registerHooks } from "node:module"; +import path from "node:path"; + +// Node's ESM resolver needs file extensions; v1 source imports siblings without +// them (`./theme`), as Vite and tsc expect. Retry such relative imports as `.ts`. +registerHooks({ + resolve(specifier, context, nextResolve) { + try { + return nextResolve(specifier, context); + } catch (error) { + if (specifier.startsWith(".") && !path.extname(specifier)) { + return nextResolve(`${specifier}.ts`, context); + } + throw error; + } + }, +}); + +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/src/v1/lib/tokens.test.ts b/frontend/src/v1/lib/tokens.test.ts new file mode 100644 index 0000000000..5ae7b64759 --- /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: "v1:rounded-sm", + values: sameInEveryMode("6px"), +}; +const bg: Token = { + family: "colour", + name: "background", + cssVar: "--background", + utility: "v1:bg-background", + values: { light: "#fafafa", dark: "#1a1a1a", unstract: "#0f0a1f" }, +}; + +it("renders one scoped block per mode with every token in each", () => { + expect(renderTokensCss([bg, radius])).toBe( + "/* Generated by scripts/build-v1-tokens.mjs from src/v1/styles/tokens.ts. Do not edit. */\n" + + '.v1[data-theme="light"] {\n --background: #fafafa;\n --radius-sm: 6px;\n}\n' + + '.v1[data-theme="dark"] {\n --background: #1a1a1a;\n --radius-sm: 6px;\n}\n' + + '.v1[data-theme="unstract"] {\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` | `v1: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..b29852466a --- /dev/null +++ b/frontend/src/v1/lib/tokens.ts @@ -0,0 +1,59 @@ +import { 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.ts. 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, each declaring 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`, + ); + return `.v1[data-theme="${mode}"] {\n${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/styles/__snapshots__/token-table.md b/frontend/src/v1/styles/__snapshots__/token-table.md new file mode 100644 index 0000000000..80d0c0782e --- /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` | `v1:bg-background` | #fafafa | #1a1a1a | #f0f0f0 | +| colour | foreground | `--foreground` | `v1:text-foreground` | #1a1a1a | #fafafa | #1a1a1a | +| colour | card | `--card` | `v1:bg-card` | #fcfcfc | #19191a | #ffffff | +| colour | card-foreground | `--card-foreground` | `v1:text-card-foreground` | #1a1a1a | #fafafa | #1a1a1a | +| colour | popover | `--popover` | `v1:bg-popover` | #ffffff | #171717 | #ffffff | +| colour | popover-foreground | `--popover-foreground` | `v1:text-popover-foreground` | #19191a | #fafafa | #19191a | +| colour | primary | `--primary` | `v1:bg-primary` | #6f5cef | #6f5cef | #072b4d | +| colour | primary-foreground | `--primary-foreground` | `v1:text-primary-foreground` | #fafafa | #fafafa | #ffffff | +| colour | primary-muted | `--primary-muted` | `v1:bg-primary-muted` | #f1effd | #2a2445 | #e8eef4 | +| colour | secondary | `--secondary` | `v1:bg-secondary` | #97caf5 | rgb(255 255 255 / 0.06) | #f0f4f8 | +| colour | secondary-foreground | `--secondary-foreground` | `v1:text-secondary-foreground` | #19191a | #fafafa | #072b4d | +| colour | muted | `--muted` | `v1:bg-muted` | #f5f5f5 | #262626 | #f0f0f0 | +| colour | muted-foreground | `--muted-foreground` | `v1:text-muted-foreground` | #737373 | #a1a1a1 | #6b7280 | +| colour | accent | `--accent` | `v1:bg-accent` | #f5f5f5 | #2a2a2d | #e8eef4 | +| colour | accent-foreground | `--accent-foreground` | `v1:text-accent-foreground` | #6161cc | #c1c5fb | #072b4d | +| colour | destructive | `--destructive` | `v1:bg-destructive` | #b41b30 | #e5484d | #b41b30 | +| colour | destructive-foreground | `--destructive-foreground` | `v1:text-destructive-foreground` | #ffffff | #fafafa | #ffffff | +| colour | success | `--success` | `v1:bg-success` | #15803d | #4ade80 | #15803d | +| colour | warning | `--warning` | `v1:bg-warning` | #b45309 | #fbbf24 | #b45309 | +| colour | border | `--border` | `v1:border-border` | #e5e5e5 | #2e2e2e | #e4e7eb | +| colour | input | `--input` | `v1:border-input` | #d3d3d3 | #343434 | #d3d8de | +| colour | ring | `--ring` | `v1:ring-ring` | #6f5cef | #6f5cef | #0e3963 | +| colour | sidebar | `--sidebar` | `v1:bg-sidebar` | #ffffff | #171717 | #0e3963 | +| colour | sidebar-foreground | `--sidebar-foreground` | `v1:text-sidebar-foreground` | #1a1a1a | #fafafa | #ffffff | +| colour | sidebar-accent | `--sidebar-accent` | `v1:bg-sidebar-accent` | #f5f5f5 | #262626 | #1b5183 | +| colour | sidebar-accent-foreground | `--sidebar-accent-foreground` | `v1:text-sidebar-accent-foreground` | #1a1a1a | #fafafa | #ffffff | +| colour | sidebar-border | `--sidebar-border` | `v1:border-sidebar-border` | #e5e5e5 | #2e2e2e | #17436e | +| colour | sidebar-muted-foreground | `--sidebar-muted-foreground` | `v1:text-sidebar-muted-foreground` | #737373 | #a1a1aa | #a9c3da | +| colour | header | `--header` | `v1:bg-header` | #fcfcfc | #0a0a0a | #072b4d | +| colour | header-foreground | `--header-foreground` | `v1:text-header-foreground` | #1a1a1a | #fafafa | #ffffff | +| spacing | space-1 | `--space-1` | `p-1` | 4px | 4px | 4px | +| spacing | space-2 | `--space-2` | `p-2` | 8px | 8px | 8px | +| spacing | space-3 | `--space-3` | `p-3` | 12px | 12px | 12px | +| spacing | space-4 | `--space-4` | `p-4` | 16px | 16px | 16px | +| spacing | space-5 | `--space-5` | `p-5` | 20px | 20px | 20px | +| spacing | space-6 | `--space-6` | `p-6` | 24px | 24px | 24px | +| spacing | space-8 | `--space-8` | `p-8` | 32px | 32px | 32px | +| radius | radius-sm | `--radius-sm` | `v1:rounded-sm` | 6px | 6px | 6px | +| radius | radius-md | `--radius-md` | `v1:rounded-md` | 8px | 8px | 8px | +| radius | radius-lg | `--radius-lg` | `v1:rounded-lg` | 10px | 10px | 10px | +| radius | radius-xl | `--radius-xl` | `v1:rounded-xl` | 14px | 14px | 14px | +| type | type-display-size | `--type-display-size` | `v1:text-display` | 34px | 34px | 34px | +| type | type-display-line-height | `--type-display-line-height` | `v1:text-display` | 46px | 46px | 46px | +| type | type-display-weight | `--type-display-weight` | `v1:text-display` | 600 | 600 | 600 | +| type | type-page-title-size | `--type-page-title-size` | `v1:text-page-title` | 20px | 20px | 20px | +| type | type-page-title-line-height | `--type-page-title-line-height` | `v1:text-page-title` | 28px | 28px | 28px | +| type | type-page-title-weight | `--type-page-title-weight` | `v1:text-page-title` | 600 | 600 | 600 | +| type | type-section-title-size | `--type-section-title-size` | `v1:text-section-title` | 16px | 16px | 16px | +| type | type-section-title-line-height | `--type-section-title-line-height` | `v1:text-section-title` | 24px | 24px | 24px | +| type | type-section-title-weight | `--type-section-title-weight` | `v1:text-section-title` | 600 | 600 | 600 | +| type | type-body-size | `--type-body-size` | `v1:text-body` | 14px | 14px | 14px | +| type | type-body-line-height | `--type-body-line-height` | `v1:text-body` | 20px | 20px | 20px | +| type | type-body-weight | `--type-body-weight` | `v1:text-body` | 400 | 400 | 400 | +| type | type-body-small-size | `--type-body-small-size` | `v1:text-body-small` | 13px | 13px | 13px | +| type | type-body-small-line-height | `--type-body-small-line-height` | `v1:text-body-small` | 18px | 18px | 18px | +| type | type-body-small-weight | `--type-body-small-weight` | `v1:text-body-small` | 400 | 400 | 400 | +| type | type-caption-size | `--type-caption-size` | `v1:text-caption` | 12px | 12px | 12px | +| type | type-caption-line-height | `--type-caption-line-height` | `v1:text-caption` | 16px | 16px | 16px | +| type | type-caption-weight | `--type-caption-weight` | `v1:text-caption` | 400 | 400 | 400 | +| type | type-overline-size | `--type-overline-size` | `v1:text-overline` | 11px | 11px | 11px | +| type | type-overline-line-height | `--type-overline-line-height` | `v1:text-overline` | 16px | 16px | 16px | +| type | type-overline-weight | `--type-overline-weight` | `v1:text-overline` | 600 | 600 | 600 | +| type | type-mono-size | `--type-mono-size` | `v1:text-mono` | 12px | 12px | 12px | +| type | type-mono-line-height | `--type-mono-line-height` | `v1:text-mono` | 22px | 22px | 22px | +| type | type-mono-weight | `--type-mono-weight` | `v1:text-mono` | 400 | 400 | 400 | +| type | type-overline-tracking | `--type-overline-tracking` | `v1:text-overline` | 0.05em | 0.05em | 0.05em | +| elevation | shadow-sm | `--shadow-sm` | `v1: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` | `v1: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` | `v1: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` | `v1: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` | `v1: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` | `v1:focus-ring` | 3px | 3px | 3px | +| focus | focus-ring-alpha | `--focus-ring-alpha` | `v1:focus-ring` | 50% | 50% | 50% | +| font | font-sans | `--font-sans` | `v1: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` | `v1: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/tokens.css b/frontend/src/v1/styles/tokens.css new file mode 100644 index 0000000000..9f2a0e97a4 --- /dev/null +++ b/frontend/src/v1/styles/tokens.css @@ -0,0 +1,232 @@ +/* Generated by scripts/build-v1-tokens.mjs from src/v1/styles/tokens.ts. Do not edit. */ +.v1[data-theme="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"] { + --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"] { + --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.test.ts b/frontend/src/v1/styles/tokens.test.ts new file mode 100644 index 0000000000..fe53e19d54 --- /dev/null +++ b/frontend/src/v1/styles/tokens.test.ts @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import path from "node:path"; +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 --v1- 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("--v1-"))).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("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..d559fd1a6c --- /dev/null +++ b/frontend/src/v1/styles/tokens.ts @@ -0,0 +1,590 @@ +import type { Token } from "@v1/lib/tokens"; + +// Local rather than lib's sameInEveryMode: a type-only import is erased when Node strips types for the generator; a value import through @v1 would not resolve. +const same = (v: string) => ({ light: v, dark: v, unstract: v }) as const; + +/** Every token on the dev-spec board, in board order. Edit here, then run `bun run tokens:v1`. */ +export const TOKENS: readonly Token[] = [ + // Colour: the Midnight Bloom collection, minus its palette entries (sky-300, sky-500, openai-green, white). + { + family: "colour", + name: "background", + cssVar: "--background", + utility: "v1:bg-background", + values: { light: "#fafafa", dark: "#1a1a1a", unstract: "#f0f0f0" }, + note: "page surface", + }, + { + family: "colour", + name: "foreground", + cssVar: "--foreground", + utility: "v1:text-foreground", + values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#1a1a1a" }, + note: "default text", + }, + { + family: "colour", + name: "card", + cssVar: "--card", + utility: "v1:bg-card", + values: { light: "#fcfcfc", dark: "#19191a", unstract: "#ffffff" }, + note: "every card and dialog surface; cards sit on background, not on muted", + }, + { + family: "colour", + name: "card-foreground", + cssVar: "--card-foreground", + utility: "v1:text-card-foreground", + values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#1a1a1a" }, + }, + { + family: "colour", + name: "popover", + cssVar: "--popover", + utility: "v1:bg-popover", + values: { light: "#ffffff", dark: "#171717", unstract: "#ffffff" }, + }, + { + family: "colour", + name: "popover-foreground", + cssVar: "--popover-foreground", + utility: "v1:text-popover-foreground", + values: { light: "#19191a", dark: "#fafafa", unstract: "#19191a" }, + }, + { + family: "colour", + name: "primary", + cssVar: "--primary", + utility: "v1:bg-primary", + values: { light: "#6f5cef", dark: "#6f5cef", unstract: "#072b4d" }, + note: "primary buttons, active tabs, selected icon buttons", + }, + { + family: "colour", + name: "primary-foreground", + cssVar: "--primary-foreground", + utility: "v1:text-primary-foreground", + values: { light: "#fafafa", dark: "#fafafa", unstract: "#ffffff" }, + }, + { + family: "colour", + name: "primary-muted", + cssVar: "--primary-muted", + utility: "v1:bg-primary-muted", + values: { light: "#f1effd", dark: "#2a2445", unstract: "#e8eef4" }, + note: "selected rows, active flow steps, selected model strip; added for Unstract", + }, + { + family: "colour", + name: "secondary", + cssVar: "--secondary", + utility: "v1:bg-secondary", + values: { + light: "#97caf5", + dark: "rgb(255 255 255 / 0.06)", + unstract: "#f0f4f8", + }, + }, + { + family: "colour", + name: "secondary-foreground", + cssVar: "--secondary-foreground", + utility: "v1:text-secondary-foreground", + values: { light: "#19191a", dark: "#fafafa", unstract: "#072b4d" }, + }, + { + family: "colour", + name: "muted", + cssVar: "--muted", + utility: "v1:bg-muted", + values: { light: "#f5f5f5", dark: "#262626", unstract: "#f0f0f0" }, + note: "inset surfaces: table headers, code blocks, dropzones, skeletons", + }, + { + family: "colour", + name: "muted-foreground", + cssVar: "--muted-foreground", + utility: "v1:text-muted-foreground", + values: { light: "#737373", dark: "#a1a1a1", unstract: "#6b7280" }, + note: "secondary text", + }, + { + family: "colour", + name: "accent", + cssVar: "--accent", + utility: "v1:bg-accent", + values: { light: "#f5f5f5", dark: "#2a2a2d", unstract: "#e8eef4" }, + note: "hover surface on rails, menu items and icon buttons", + }, + { + family: "colour", + name: "accent-foreground", + cssVar: "--accent-foreground", + utility: "v1:text-accent-foreground", + values: { light: "#6161cc", dark: "#c1c5fb", unstract: "#072b4d" }, + }, + { + family: "colour", + name: "destructive", + cssVar: "--destructive", + utility: "v1:bg-destructive", + values: { light: "#b41b30", dark: "#e5484d", unstract: "#b41b30" }, + note: "delete actions and failure states; never a warning", + }, + { + family: "colour", + name: "destructive-foreground", + cssVar: "--destructive-foreground", + utility: "v1:text-destructive-foreground", + values: { light: "#ffffff", dark: "#fafafa", unstract: "#ffffff" }, + }, + { + family: "colour", + name: "success", + cssVar: "--success", + utility: "v1:bg-success", + values: { light: "#15803d", dark: "#4ade80", unstract: "#15803d" }, + }, + { + family: "colour", + name: "warning", + cssVar: "--warning", + utility: "v1:bg-warning", + values: { light: "#b45309", dark: "#fbbf24", unstract: "#b45309" }, + }, + { + family: "colour", + name: "border", + cssVar: "--border", + utility: "v1:border-border", + values: { light: "#e5e5e5", dark: "#2e2e2e", unstract: "#e4e7eb" }, + note: "every 1px rule and control outline", + }, + { + family: "colour", + name: "input", + cssVar: "--input", + utility: "v1:border-input", + values: { light: "#d3d3d3", dark: "#343434", unstract: "#d3d8de" }, + }, + { + family: "colour", + name: "ring", + cssVar: "--ring", + utility: "v1:ring-ring", + values: { light: "#6f5cef", dark: "#6f5cef", unstract: "#0e3963" }, + note: "focus outline colour, paired with a 3px ring at 50% alpha", + }, + { + family: "colour", + name: "sidebar", + cssVar: "--sidebar", + utility: "v1:bg-sidebar", + values: { light: "#ffffff", dark: "#171717", unstract: "#0e3963" }, + note: "the rail and both nav bars", + }, + { + family: "colour", + name: "sidebar-foreground", + cssVar: "--sidebar-foreground", + utility: "v1:text-sidebar-foreground", + values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#ffffff" }, + }, + { + family: "colour", + name: "sidebar-accent", + cssVar: "--sidebar-accent", + utility: "v1:bg-sidebar-accent", + values: { light: "#f5f5f5", dark: "#262626", unstract: "#1b5183" }, + }, + { + family: "colour", + name: "sidebar-accent-foreground", + cssVar: "--sidebar-accent-foreground", + utility: "v1:text-sidebar-accent-foreground", + values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#ffffff" }, + }, + { + family: "colour", + name: "sidebar-border", + cssVar: "--sidebar-border", + utility: "v1:border-sidebar-border", + values: { light: "#e5e5e5", dark: "#2e2e2e", unstract: "#17436e" }, + }, + { + family: "colour", + name: "sidebar-muted-foreground", + cssVar: "--sidebar-muted-foreground", + utility: "v1:text-sidebar-muted-foreground", + values: { light: "#737373", dark: "#a1a1aa", unstract: "#a9c3da" }, + }, + { + family: "colour", + name: "header", + cssVar: "--header", + utility: "v1:bg-header", + values: { light: "#fcfcfc", dark: "#0a0a0a", unstract: "#072b4d" }, + }, + { + family: "colour", + name: "header-foreground", + cssVar: "--header-foreground", + utility: "v1:text-header-foreground", + values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#ffffff" }, + }, + // Spacing: one 4pt scale. A value not on this list is a bug. + { + family: "spacing", + name: "space-1", + cssVar: "--space-1", + utility: "p-1", + values: same("4px"), + note: "icon-to-label inside a dense chip", + }, + { + family: "spacing", + name: "space-2", + cssVar: "--space-2", + utility: "p-2", + values: same("8px"), + note: "label to control, stacked text inside a cell", + }, + { + family: "spacing", + name: "space-3", + cssVar: "--space-3", + utility: "p-3", + values: same("12px"), + note: "control to control inside a group", + }, + { + family: "spacing", + name: "space-4", + cssVar: "--space-4", + utility: "p-4", + values: same("16px"), + note: "card gutter, list row rhythm", + }, + { + family: "spacing", + name: "space-5", + cssVar: "--space-5", + utility: "p-5", + values: same("20px"), + note: "card inner padding, compact", + }, + { + family: "spacing", + name: "space-6", + cssVar: "--space-6", + utility: "p-6", + values: same("24px"), + note: "card inner padding, default \u00b7 page padding y", + }, + { + family: "spacing", + name: "space-8", + cssVar: "--space-8", + utility: "p-8", + values: same("32px"), + note: "page padding x, section break", + }, + // Radius + { + family: "radius", + name: "radius-sm", + cssVar: "--radius-sm", + utility: "v1:rounded-sm", + values: same("6px"), + note: "badge, pill, status chip, small icon button", + }, + { + family: "radius", + name: "radius-md", + cssVar: "--radius-md", + utility: "v1:rounded-md", + values: same("8px"), + note: "button, input, select, textarea, menu item", + }, + { + family: "radius", + name: "radius-lg", + cssVar: "--radius-lg", + utility: "v1:rounded-lg", + values: same("10px"), + note: "card, dialog, menu, popover, dropzone", + }, + { + family: "radius", + name: "radius-xl", + cssVar: "--radius-xl", + utility: "v1:rounded-xl", + values: same("14px"), + note: "empty-state icon badge", + }, + // Type: eight roles, each a size, a line height and a weight. + { + family: "type", + name: "type-display-size", + cssVar: "--type-display-size", + utility: "v1:text-display", + values: same("34px"), + }, + { + family: "type", + name: "type-display-line-height", + cssVar: "--type-display-line-height", + utility: "v1:text-display", + values: same("46px"), + }, + { + family: "type", + name: "type-display-weight", + cssVar: "--type-display-weight", + utility: "v1:text-display", + values: same("600"), + }, + { + family: "type", + name: "type-page-title-size", + cssVar: "--type-page-title-size", + utility: "v1:text-page-title", + values: same("20px"), + }, + { + family: "type", + name: "type-page-title-line-height", + cssVar: "--type-page-title-line-height", + utility: "v1:text-page-title", + values: same("28px"), + }, + { + family: "type", + name: "type-page-title-weight", + cssVar: "--type-page-title-weight", + utility: "v1:text-page-title", + values: same("600"), + }, + { + family: "type", + name: "type-section-title-size", + cssVar: "--type-section-title-size", + utility: "v1:text-section-title", + values: same("16px"), + }, + { + family: "type", + name: "type-section-title-line-height", + cssVar: "--type-section-title-line-height", + utility: "v1:text-section-title", + values: same("24px"), + }, + { + family: "type", + name: "type-section-title-weight", + cssVar: "--type-section-title-weight", + utility: "v1:text-section-title", + values: same("600"), + }, + { + family: "type", + name: "type-body-size", + cssVar: "--type-body-size", + utility: "v1:text-body", + values: same("14px"), + }, + { + family: "type", + name: "type-body-line-height", + cssVar: "--type-body-line-height", + utility: "v1:text-body", + values: same("20px"), + }, + { + family: "type", + name: "type-body-weight", + cssVar: "--type-body-weight", + utility: "v1:text-body", + values: same("400"), + }, + { + family: "type", + name: "type-body-small-size", + cssVar: "--type-body-small-size", + utility: "v1:text-body-small", + values: same("13px"), + }, + { + family: "type", + name: "type-body-small-line-height", + cssVar: "--type-body-small-line-height", + utility: "v1:text-body-small", + values: same("18px"), + }, + { + family: "type", + name: "type-body-small-weight", + cssVar: "--type-body-small-weight", + utility: "v1:text-body-small", + values: same("400"), + }, + { + family: "type", + name: "type-caption-size", + cssVar: "--type-caption-size", + utility: "v1:text-caption", + values: same("12px"), + }, + { + family: "type", + name: "type-caption-line-height", + cssVar: "--type-caption-line-height", + utility: "v1:text-caption", + values: same("16px"), + }, + { + family: "type", + name: "type-caption-weight", + cssVar: "--type-caption-weight", + utility: "v1:text-caption", + values: same("400"), + }, + { + family: "type", + name: "type-overline-size", + cssVar: "--type-overline-size", + utility: "v1:text-overline", + values: same("11px"), + note: "Pair with v1:uppercase.", + }, + { + family: "type", + name: "type-overline-line-height", + cssVar: "--type-overline-line-height", + utility: "v1:text-overline", + values: same("16px"), + note: "Pair with v1:uppercase.", + }, + { + family: "type", + name: "type-overline-weight", + cssVar: "--type-overline-weight", + utility: "v1:text-overline", + values: same("600"), + note: "Pair with v1:uppercase.", + }, + { + family: "type", + name: "type-mono-size", + cssVar: "--type-mono-size", + utility: "v1:text-mono", + values: same("12px"), + note: "Pair with v1:font-mono. Reserved for model output, JSON and token counts.", + }, + { + family: "type", + name: "type-mono-line-height", + cssVar: "--type-mono-line-height", + utility: "v1:text-mono", + values: same("22px"), + note: "Pair with v1:font-mono. Reserved for model output, JSON and token counts.", + }, + { + family: "type", + name: "type-mono-weight", + cssVar: "--type-mono-weight", + utility: "v1:text-mono", + values: same("400"), + note: "Pair with v1:font-mono. Reserved for model output, JSON and token counts.", + }, + { + family: "type", + name: "type-overline-tracking", + cssVar: "--type-overline-tracking", + utility: "v1:text-overline", + values: same("0.05em"), + note: "Pair with v1:uppercase.", + }, + // Elevation: one value per step, the same in every mode. + { + family: "elevation", + name: "shadow-sm", + cssVar: "--shadow-sm", + utility: "v1:shadow-sm", + values: same("0 1px 2px 0 rgb(0 0 0 / 0.05)"), + note: "every card at rest", + }, + { + family: "elevation", + name: "shadow-md", + cssVar: "--shadow-md", + utility: "v1:shadow-md", + values: same( + "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", + }, + { + family: "elevation", + name: "shadow-lg", + cssVar: "--shadow-lg", + utility: "v1:shadow-lg", + values: same( + "0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10)", + ), + note: "popovers", + }, + { + family: "elevation", + name: "shadow-xl", + cssVar: "--shadow-xl", + utility: "v1:shadow-xl", + values: same( + "0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10)", + ), + note: "dialogs", + }, + { + family: "elevation", + name: "scrim", + cssVar: "--scrim", + utility: "v1:bg-scrim", + values: same("rgb(0 0 0 / 0.5)"), + note: "behind every dialog, full viewport", + }, + // Focus: the ring colour is the `ring` token; no offset. + { + family: "focus", + name: "focus-ring-width", + cssVar: "--focus-ring-width", + utility: "v1:focus-ring", + values: same("3px"), + }, + { + family: "focus", + name: "focus-ring-alpha", + cssVar: "--focus-ring-alpha", + utility: "v1:focus-ring", + values: same("50%"), + }, + // Font + { + family: "font", + name: "font-sans", + cssVar: "--font-sans", + utility: "v1:font-sans", + values: same( + '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif', + ), + }, + { + family: "font", + name: "font-mono", + cssVar: "--font-mono", + utility: "v1:font-mono", + values: same( + '"Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + ), + note: "Reserved for model output, JSON and token counts.", + }, +]; From 6393d31fb54cbb4d6388f2a4036c42fba51b4864 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 11:58:23 +0530 Subject: [PATCH 11/38] UN-4299 [FIX] Prefix the spacing utilities in the token table so they match the v1 Tailwind root Co-Authored-By: Claude Opus 5.5 --- .../src/v1/styles/__snapshots__/token-table.md | 14 +++++++------- frontend/src/v1/styles/tokens.ts | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/src/v1/styles/__snapshots__/token-table.md b/frontend/src/v1/styles/__snapshots__/token-table.md index 80d0c0782e..11eb778532 100644 --- a/frontend/src/v1/styles/__snapshots__/token-table.md +++ b/frontend/src/v1/styles/__snapshots__/token-table.md @@ -30,13 +30,13 @@ | colour | sidebar-muted-foreground | `--sidebar-muted-foreground` | `v1:text-sidebar-muted-foreground` | #737373 | #a1a1aa | #a9c3da | | colour | header | `--header` | `v1:bg-header` | #fcfcfc | #0a0a0a | #072b4d | | colour | header-foreground | `--header-foreground` | `v1:text-header-foreground` | #1a1a1a | #fafafa | #ffffff | -| spacing | space-1 | `--space-1` | `p-1` | 4px | 4px | 4px | -| spacing | space-2 | `--space-2` | `p-2` | 8px | 8px | 8px | -| spacing | space-3 | `--space-3` | `p-3` | 12px | 12px | 12px | -| spacing | space-4 | `--space-4` | `p-4` | 16px | 16px | 16px | -| spacing | space-5 | `--space-5` | `p-5` | 20px | 20px | 20px | -| spacing | space-6 | `--space-6` | `p-6` | 24px | 24px | 24px | -| spacing | space-8 | `--space-8` | `p-8` | 32px | 32px | 32px | +| spacing | space-1 | `--space-1` | `v1:p-1` | 4px | 4px | 4px | +| spacing | space-2 | `--space-2` | `v1:p-2` | 8px | 8px | 8px | +| spacing | space-3 | `--space-3` | `v1:p-3` | 12px | 12px | 12px | +| spacing | space-4 | `--space-4` | `v1:p-4` | 16px | 16px | 16px | +| spacing | space-5 | `--space-5` | `v1:p-5` | 20px | 20px | 20px | +| spacing | space-6 | `--space-6` | `v1:p-6` | 24px | 24px | 24px | +| spacing | space-8 | `--space-8` | `v1:p-8` | 32px | 32px | 32px | | radius | radius-sm | `--radius-sm` | `v1:rounded-sm` | 6px | 6px | 6px | | radius | radius-md | `--radius-md` | `v1:rounded-md` | 8px | 8px | 8px | | radius | radius-lg | `--radius-lg` | `v1:rounded-lg` | 10px | 10px | 10px | diff --git a/frontend/src/v1/styles/tokens.ts b/frontend/src/v1/styles/tokens.ts index d559fd1a6c..b6cd8f0901 100644 --- a/frontend/src/v1/styles/tokens.ts +++ b/frontend/src/v1/styles/tokens.ts @@ -237,7 +237,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-1", cssVar: "--space-1", - utility: "p-1", + utility: "v1:p-1", values: same("4px"), note: "icon-to-label inside a dense chip", }, @@ -245,7 +245,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-2", cssVar: "--space-2", - utility: "p-2", + utility: "v1:p-2", values: same("8px"), note: "label to control, stacked text inside a cell", }, @@ -253,7 +253,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-3", cssVar: "--space-3", - utility: "p-3", + utility: "v1:p-3", values: same("12px"), note: "control to control inside a group", }, @@ -261,7 +261,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-4", cssVar: "--space-4", - utility: "p-4", + utility: "v1:p-4", values: same("16px"), note: "card gutter, list row rhythm", }, @@ -269,7 +269,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-5", cssVar: "--space-5", - utility: "p-5", + utility: "v1:p-5", values: same("20px"), note: "card inner padding, compact", }, @@ -277,7 +277,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-6", cssVar: "--space-6", - utility: "p-6", + utility: "v1:p-6", values: same("24px"), note: "card inner padding, default \u00b7 page padding y", }, @@ -285,7 +285,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-8", cssVar: "--space-8", - utility: "p-8", + utility: "v1:p-8", values: same("32px"), note: "page padding x, section break", }, From 4a05a268a5658f4a8073dc98ada740193cae4c8d Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 11:59:58 +0530 Subject: [PATCH 12/38] UN-4299 [FEAT] Add cn() configured for the v1 utility prefix Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/cn.test.ts | 13 +++++++++++++ frontend/src/v1/lib/cn.ts | 6 ++++++ 2 files changed, 19 insertions(+) create mode 100644 frontend/src/v1/lib/cn.test.ts create mode 100644 frontend/src/v1/lib/cn.ts diff --git a/frontend/src/v1/lib/cn.test.ts b/frontend/src/v1/lib/cn.test.ts new file mode 100644 index 0000000000..3f7bec2920 --- /dev/null +++ b/frontend/src/v1/lib/cn.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from "vitest"; +import { cn } from "./cn"; + +it("merges conflicting prefixed utilities, keeping the last", () => { + expect(cn("v1:p-2 v1:p-4")).toBe("v1:p-4"); + expect(cn("v1:bg-background", false, "v1:bg-card")).toBe("v1:bg-card"); +}); + +it("keeps unrelated and variant classes", () => { + expect(cn("v1:flex v1:dark:bg-card", ["v1:gap-2"])).toBe( + "v1:flex v1:dark:bg-card v1:gap-2", + ); +}); diff --git a/frontend/src/v1/lib/cn.ts b/frontend/src/v1/lib/cn.ts new file mode 100644 index 0000000000..cf37ce9c1c --- /dev/null +++ b/frontend/src/v1/lib/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { extendTailwindMerge } from "tailwind-merge"; +const twMerge = extendTailwindMerge({ prefix: "v1" }); +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} From a9307e7e8c91cd2aa60fa422551dee1eaa8e00f9 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:00:44 +0530 Subject: [PATCH 13/38] UN-4299 [FIX] Pin the v1 prefix in cn() with a test that fails without it Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/cn.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/v1/lib/cn.test.ts b/frontend/src/v1/lib/cn.test.ts index 3f7bec2920..fe48be0654 100644 --- a/frontend/src/v1/lib/cn.test.ts +++ b/frontend/src/v1/lib/cn.test.ts @@ -11,3 +11,7 @@ it("keeps unrelated and variant classes", () => { "v1:flex v1:dark:bg-card v1:gap-2", ); }); + +it("leaves unprefixed classes alone, so the merge is scoped to the v1 prefix", () => { + expect(cn("p-2 p-4")).toBe("p-2 p-4"); +}); From 5a361ae0d26a613cc516137d61f7f2639f80bd60 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:05:36 +0530 Subject: [PATCH 14/38] UN-4299 [FIX] Use "ui" as the v1 utility prefix: Tailwind accepts lowercase letters only Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/cn.test.ts | 10 +- frontend/src/v1/lib/cn.ts | 2 +- frontend/src/v1/lib/tokens.test.ts | 6 +- .../v1/styles/__snapshots__/token-table.md | 150 ++++++++-------- frontend/src/v1/styles/tokens.test.ts | 4 +- frontend/src/v1/styles/tokens.ts | 164 +++++++++--------- 6 files changed, 168 insertions(+), 168 deletions(-) diff --git a/frontend/src/v1/lib/cn.test.ts b/frontend/src/v1/lib/cn.test.ts index fe48be0654..5dd843f1ee 100644 --- a/frontend/src/v1/lib/cn.test.ts +++ b/frontend/src/v1/lib/cn.test.ts @@ -2,16 +2,16 @@ import { expect, it } from "vitest"; import { cn } from "./cn"; it("merges conflicting prefixed utilities, keeping the last", () => { - expect(cn("v1:p-2 v1:p-4")).toBe("v1:p-4"); - expect(cn("v1:bg-background", false, "v1:bg-card")).toBe("v1:bg-card"); + 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("v1:flex v1:dark:bg-card", ["v1:gap-2"])).toBe( - "v1:flex v1:dark:bg-card v1:gap-2", + 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 v1 prefix", () => { +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"); }); diff --git a/frontend/src/v1/lib/cn.ts b/frontend/src/v1/lib/cn.ts index cf37ce9c1c..8592caeb34 100644 --- a/frontend/src/v1/lib/cn.ts +++ b/frontend/src/v1/lib/cn.ts @@ -1,6 +1,6 @@ import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; -const twMerge = extendTailwindMerge({ prefix: "v1" }); +const twMerge = extendTailwindMerge({ prefix: "ui" }); export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } diff --git a/frontend/src/v1/lib/tokens.test.ts b/frontend/src/v1/lib/tokens.test.ts index 5ae7b64759..e082c50ab5 100644 --- a/frontend/src/v1/lib/tokens.test.ts +++ b/frontend/src/v1/lib/tokens.test.ts @@ -10,14 +10,14 @@ const radius: Token = { family: "radius", name: "radius-sm", cssVar: "--radius-sm", - utility: "v1:rounded-sm", + utility: "ui:rounded-sm", values: sameInEveryMode("6px"), }; const bg: Token = { family: "colour", name: "background", cssVar: "--background", - utility: "v1:bg-background", + utility: "ui:bg-background", values: { light: "#fafafa", dark: "#1a1a1a", unstract: "#0f0a1f" }, }; @@ -36,6 +36,6 @@ it("renders the gallery table with one row per token", () => { "| Family | Token | CSS variable | Utility | Light | Dark | Unstract |", ); expect(table).toContain( - "| colour | background | `--background` | `v1:bg-background` | #fafafa | #1a1a1a | #0f0a1f |", + "| colour | background | `--background` | `ui:bg-background` | #fafafa | #1a1a1a | #0f0a1f |", ); }); diff --git a/frontend/src/v1/styles/__snapshots__/token-table.md b/frontend/src/v1/styles/__snapshots__/token-table.md index 11eb778532..11bdfac7e6 100644 --- a/frontend/src/v1/styles/__snapshots__/token-table.md +++ b/frontend/src/v1/styles/__snapshots__/token-table.md @@ -1,77 +1,77 @@ | Family | Token | CSS variable | Utility | Light | Dark | Unstract | | --- | --- | --- | --- | --- | --- | --- | -| colour | background | `--background` | `v1:bg-background` | #fafafa | #1a1a1a | #f0f0f0 | -| colour | foreground | `--foreground` | `v1:text-foreground` | #1a1a1a | #fafafa | #1a1a1a | -| colour | card | `--card` | `v1:bg-card` | #fcfcfc | #19191a | #ffffff | -| colour | card-foreground | `--card-foreground` | `v1:text-card-foreground` | #1a1a1a | #fafafa | #1a1a1a | -| colour | popover | `--popover` | `v1:bg-popover` | #ffffff | #171717 | #ffffff | -| colour | popover-foreground | `--popover-foreground` | `v1:text-popover-foreground` | #19191a | #fafafa | #19191a | -| colour | primary | `--primary` | `v1:bg-primary` | #6f5cef | #6f5cef | #072b4d | -| colour | primary-foreground | `--primary-foreground` | `v1:text-primary-foreground` | #fafafa | #fafafa | #ffffff | -| colour | primary-muted | `--primary-muted` | `v1:bg-primary-muted` | #f1effd | #2a2445 | #e8eef4 | -| colour | secondary | `--secondary` | `v1:bg-secondary` | #97caf5 | rgb(255 255 255 / 0.06) | #f0f4f8 | -| colour | secondary-foreground | `--secondary-foreground` | `v1:text-secondary-foreground` | #19191a | #fafafa | #072b4d | -| colour | muted | `--muted` | `v1:bg-muted` | #f5f5f5 | #262626 | #f0f0f0 | -| colour | muted-foreground | `--muted-foreground` | `v1:text-muted-foreground` | #737373 | #a1a1a1 | #6b7280 | -| colour | accent | `--accent` | `v1:bg-accent` | #f5f5f5 | #2a2a2d | #e8eef4 | -| colour | accent-foreground | `--accent-foreground` | `v1:text-accent-foreground` | #6161cc | #c1c5fb | #072b4d | -| colour | destructive | `--destructive` | `v1:bg-destructive` | #b41b30 | #e5484d | #b41b30 | -| colour | destructive-foreground | `--destructive-foreground` | `v1:text-destructive-foreground` | #ffffff | #fafafa | #ffffff | -| colour | success | `--success` | `v1:bg-success` | #15803d | #4ade80 | #15803d | -| colour | warning | `--warning` | `v1:bg-warning` | #b45309 | #fbbf24 | #b45309 | -| colour | border | `--border` | `v1:border-border` | #e5e5e5 | #2e2e2e | #e4e7eb | -| colour | input | `--input` | `v1:border-input` | #d3d3d3 | #343434 | #d3d8de | -| colour | ring | `--ring` | `v1:ring-ring` | #6f5cef | #6f5cef | #0e3963 | -| colour | sidebar | `--sidebar` | `v1:bg-sidebar` | #ffffff | #171717 | #0e3963 | -| colour | sidebar-foreground | `--sidebar-foreground` | `v1:text-sidebar-foreground` | #1a1a1a | #fafafa | #ffffff | -| colour | sidebar-accent | `--sidebar-accent` | `v1:bg-sidebar-accent` | #f5f5f5 | #262626 | #1b5183 | -| colour | sidebar-accent-foreground | `--sidebar-accent-foreground` | `v1:text-sidebar-accent-foreground` | #1a1a1a | #fafafa | #ffffff | -| colour | sidebar-border | `--sidebar-border` | `v1:border-sidebar-border` | #e5e5e5 | #2e2e2e | #17436e | -| colour | sidebar-muted-foreground | `--sidebar-muted-foreground` | `v1:text-sidebar-muted-foreground` | #737373 | #a1a1aa | #a9c3da | -| colour | header | `--header` | `v1:bg-header` | #fcfcfc | #0a0a0a | #072b4d | -| colour | header-foreground | `--header-foreground` | `v1:text-header-foreground` | #1a1a1a | #fafafa | #ffffff | -| spacing | space-1 | `--space-1` | `v1:p-1` | 4px | 4px | 4px | -| spacing | space-2 | `--space-2` | `v1:p-2` | 8px | 8px | 8px | -| spacing | space-3 | `--space-3` | `v1:p-3` | 12px | 12px | 12px | -| spacing | space-4 | `--space-4` | `v1:p-4` | 16px | 16px | 16px | -| spacing | space-5 | `--space-5` | `v1:p-5` | 20px | 20px | 20px | -| spacing | space-6 | `--space-6` | `v1:p-6` | 24px | 24px | 24px | -| spacing | space-8 | `--space-8` | `v1:p-8` | 32px | 32px | 32px | -| radius | radius-sm | `--radius-sm` | `v1:rounded-sm` | 6px | 6px | 6px | -| radius | radius-md | `--radius-md` | `v1:rounded-md` | 8px | 8px | 8px | -| radius | radius-lg | `--radius-lg` | `v1:rounded-lg` | 10px | 10px | 10px | -| radius | radius-xl | `--radius-xl` | `v1:rounded-xl` | 14px | 14px | 14px | -| type | type-display-size | `--type-display-size` | `v1:text-display` | 34px | 34px | 34px | -| type | type-display-line-height | `--type-display-line-height` | `v1:text-display` | 46px | 46px | 46px | -| type | type-display-weight | `--type-display-weight` | `v1:text-display` | 600 | 600 | 600 | -| type | type-page-title-size | `--type-page-title-size` | `v1:text-page-title` | 20px | 20px | 20px | -| type | type-page-title-line-height | `--type-page-title-line-height` | `v1:text-page-title` | 28px | 28px | 28px | -| type | type-page-title-weight | `--type-page-title-weight` | `v1:text-page-title` | 600 | 600 | 600 | -| type | type-section-title-size | `--type-section-title-size` | `v1:text-section-title` | 16px | 16px | 16px | -| type | type-section-title-line-height | `--type-section-title-line-height` | `v1:text-section-title` | 24px | 24px | 24px | -| type | type-section-title-weight | `--type-section-title-weight` | `v1:text-section-title` | 600 | 600 | 600 | -| type | type-body-size | `--type-body-size` | `v1:text-body` | 14px | 14px | 14px | -| type | type-body-line-height | `--type-body-line-height` | `v1:text-body` | 20px | 20px | 20px | -| type | type-body-weight | `--type-body-weight` | `v1:text-body` | 400 | 400 | 400 | -| type | type-body-small-size | `--type-body-small-size` | `v1:text-body-small` | 13px | 13px | 13px | -| type | type-body-small-line-height | `--type-body-small-line-height` | `v1:text-body-small` | 18px | 18px | 18px | -| type | type-body-small-weight | `--type-body-small-weight` | `v1:text-body-small` | 400 | 400 | 400 | -| type | type-caption-size | `--type-caption-size` | `v1:text-caption` | 12px | 12px | 12px | -| type | type-caption-line-height | `--type-caption-line-height` | `v1:text-caption` | 16px | 16px | 16px | -| type | type-caption-weight | `--type-caption-weight` | `v1:text-caption` | 400 | 400 | 400 | -| type | type-overline-size | `--type-overline-size` | `v1:text-overline` | 11px | 11px | 11px | -| type | type-overline-line-height | `--type-overline-line-height` | `v1:text-overline` | 16px | 16px | 16px | -| type | type-overline-weight | `--type-overline-weight` | `v1:text-overline` | 600 | 600 | 600 | -| type | type-mono-size | `--type-mono-size` | `v1:text-mono` | 12px | 12px | 12px | -| type | type-mono-line-height | `--type-mono-line-height` | `v1:text-mono` | 22px | 22px | 22px | -| type | type-mono-weight | `--type-mono-weight` | `v1:text-mono` | 400 | 400 | 400 | -| type | type-overline-tracking | `--type-overline-tracking` | `v1:text-overline` | 0.05em | 0.05em | 0.05em | -| elevation | shadow-sm | `--shadow-sm` | `v1: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` | `v1: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` | `v1: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` | `v1: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` | `v1: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` | `v1:focus-ring` | 3px | 3px | 3px | -| focus | focus-ring-alpha | `--focus-ring-alpha` | `v1:focus-ring` | 50% | 50% | 50% | -| font | font-sans | `--font-sans` | `v1: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` | `v1: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 | +| 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/tokens.test.ts b/frontend/src/v1/styles/tokens.test.ts index fe53e19d54..ea9085ef31 100644 --- a/frontend/src/v1/styles/tokens.test.ts +++ b/frontend/src/v1/styles/tokens.test.ts @@ -13,10 +13,10 @@ it("every token is defined in every mode with a non-empty value", () => { } }); -it("token names and CSS variables are unique and runtime variables never use Tailwind's --v1- namespace", () => { +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("--v1-"))).toEqual([]); + expect(TOKENS.filter((t) => t.cssVar.startsWith("--ui-"))).toEqual([]); }); it("covers the families the board defines", () => { diff --git a/frontend/src/v1/styles/tokens.ts b/frontend/src/v1/styles/tokens.ts index b6cd8f0901..69f44bd195 100644 --- a/frontend/src/v1/styles/tokens.ts +++ b/frontend/src/v1/styles/tokens.ts @@ -10,7 +10,7 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "background", cssVar: "--background", - utility: "v1:bg-background", + utility: "ui:bg-background", values: { light: "#fafafa", dark: "#1a1a1a", unstract: "#f0f0f0" }, note: "page surface", }, @@ -18,7 +18,7 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "foreground", cssVar: "--foreground", - utility: "v1:text-foreground", + utility: "ui:text-foreground", values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#1a1a1a" }, note: "default text", }, @@ -26,7 +26,7 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "card", cssVar: "--card", - utility: "v1:bg-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", }, @@ -34,28 +34,28 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "card-foreground", cssVar: "--card-foreground", - utility: "v1:text-card-foreground", + utility: "ui:text-card-foreground", values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#1a1a1a" }, }, { family: "colour", name: "popover", cssVar: "--popover", - utility: "v1:bg-popover", + utility: "ui:bg-popover", values: { light: "#ffffff", dark: "#171717", unstract: "#ffffff" }, }, { family: "colour", name: "popover-foreground", cssVar: "--popover-foreground", - utility: "v1:text-popover-foreground", + utility: "ui:text-popover-foreground", values: { light: "#19191a", dark: "#fafafa", unstract: "#19191a" }, }, { family: "colour", name: "primary", cssVar: "--primary", - utility: "v1:bg-primary", + utility: "ui:bg-primary", values: { light: "#6f5cef", dark: "#6f5cef", unstract: "#072b4d" }, note: "primary buttons, active tabs, selected icon buttons", }, @@ -63,14 +63,14 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "primary-foreground", cssVar: "--primary-foreground", - utility: "v1:text-primary-foreground", + utility: "ui:text-primary-foreground", values: { light: "#fafafa", dark: "#fafafa", unstract: "#ffffff" }, }, { family: "colour", name: "primary-muted", cssVar: "--primary-muted", - utility: "v1:bg-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", }, @@ -78,7 +78,7 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "secondary", cssVar: "--secondary", - utility: "v1:bg-secondary", + utility: "ui:bg-secondary", values: { light: "#97caf5", dark: "rgb(255 255 255 / 0.06)", @@ -89,14 +89,14 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "secondary-foreground", cssVar: "--secondary-foreground", - utility: "v1:text-secondary-foreground", + utility: "ui:text-secondary-foreground", values: { light: "#19191a", dark: "#fafafa", unstract: "#072b4d" }, }, { family: "colour", name: "muted", cssVar: "--muted", - utility: "v1:bg-muted", + utility: "ui:bg-muted", values: { light: "#f5f5f5", dark: "#262626", unstract: "#f0f0f0" }, note: "inset surfaces: table headers, code blocks, dropzones, skeletons", }, @@ -104,7 +104,7 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "muted-foreground", cssVar: "--muted-foreground", - utility: "v1:text-muted-foreground", + utility: "ui:text-muted-foreground", values: { light: "#737373", dark: "#a1a1a1", unstract: "#6b7280" }, note: "secondary text", }, @@ -112,7 +112,7 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "accent", cssVar: "--accent", - utility: "v1:bg-accent", + utility: "ui:bg-accent", values: { light: "#f5f5f5", dark: "#2a2a2d", unstract: "#e8eef4" }, note: "hover surface on rails, menu items and icon buttons", }, @@ -120,14 +120,14 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "accent-foreground", cssVar: "--accent-foreground", - utility: "v1:text-accent-foreground", + utility: "ui:text-accent-foreground", values: { light: "#6161cc", dark: "#c1c5fb", unstract: "#072b4d" }, }, { family: "colour", name: "destructive", cssVar: "--destructive", - utility: "v1:bg-destructive", + utility: "ui:bg-destructive", values: { light: "#b41b30", dark: "#e5484d", unstract: "#b41b30" }, note: "delete actions and failure states; never a warning", }, @@ -135,28 +135,28 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "destructive-foreground", cssVar: "--destructive-foreground", - utility: "v1:text-destructive-foreground", + utility: "ui:text-destructive-foreground", values: { light: "#ffffff", dark: "#fafafa", unstract: "#ffffff" }, }, { family: "colour", name: "success", cssVar: "--success", - utility: "v1:bg-success", + utility: "ui:bg-success", values: { light: "#15803d", dark: "#4ade80", unstract: "#15803d" }, }, { family: "colour", name: "warning", cssVar: "--warning", - utility: "v1:bg-warning", + utility: "ui:bg-warning", values: { light: "#b45309", dark: "#fbbf24", unstract: "#b45309" }, }, { family: "colour", name: "border", cssVar: "--border", - utility: "v1:border-border", + utility: "ui:border-border", values: { light: "#e5e5e5", dark: "#2e2e2e", unstract: "#e4e7eb" }, note: "every 1px rule and control outline", }, @@ -164,14 +164,14 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "input", cssVar: "--input", - utility: "v1:border-input", + utility: "ui:border-input", values: { light: "#d3d3d3", dark: "#343434", unstract: "#d3d8de" }, }, { family: "colour", name: "ring", cssVar: "--ring", - utility: "v1:ring-ring", + utility: "ui:ring-ring", values: { light: "#6f5cef", dark: "#6f5cef", unstract: "#0e3963" }, note: "focus outline colour, paired with a 3px ring at 50% alpha", }, @@ -179,7 +179,7 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "sidebar", cssVar: "--sidebar", - utility: "v1:bg-sidebar", + utility: "ui:bg-sidebar", values: { light: "#ffffff", dark: "#171717", unstract: "#0e3963" }, note: "the rail and both nav bars", }, @@ -187,49 +187,49 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "sidebar-foreground", cssVar: "--sidebar-foreground", - utility: "v1:text-sidebar-foreground", + utility: "ui:text-sidebar-foreground", values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#ffffff" }, }, { family: "colour", name: "sidebar-accent", cssVar: "--sidebar-accent", - utility: "v1:bg-sidebar-accent", + utility: "ui:bg-sidebar-accent", values: { light: "#f5f5f5", dark: "#262626", unstract: "#1b5183" }, }, { family: "colour", name: "sidebar-accent-foreground", cssVar: "--sidebar-accent-foreground", - utility: "v1:text-sidebar-accent-foreground", + utility: "ui:text-sidebar-accent-foreground", values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#ffffff" }, }, { family: "colour", name: "sidebar-border", cssVar: "--sidebar-border", - utility: "v1:border-sidebar-border", + utility: "ui:border-sidebar-border", values: { light: "#e5e5e5", dark: "#2e2e2e", unstract: "#17436e" }, }, { family: "colour", name: "sidebar-muted-foreground", cssVar: "--sidebar-muted-foreground", - utility: "v1:text-sidebar-muted-foreground", + utility: "ui:text-sidebar-muted-foreground", values: { light: "#737373", dark: "#a1a1aa", unstract: "#a9c3da" }, }, { family: "colour", name: "header", cssVar: "--header", - utility: "v1:bg-header", + utility: "ui:bg-header", values: { light: "#fcfcfc", dark: "#0a0a0a", unstract: "#072b4d" }, }, { family: "colour", name: "header-foreground", cssVar: "--header-foreground", - utility: "v1:text-header-foreground", + utility: "ui:text-header-foreground", values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#ffffff" }, }, // Spacing: one 4pt scale. A value not on this list is a bug. @@ -237,7 +237,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-1", cssVar: "--space-1", - utility: "v1:p-1", + utility: "ui:p-1", values: same("4px"), note: "icon-to-label inside a dense chip", }, @@ -245,7 +245,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-2", cssVar: "--space-2", - utility: "v1:p-2", + utility: "ui:p-2", values: same("8px"), note: "label to control, stacked text inside a cell", }, @@ -253,7 +253,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-3", cssVar: "--space-3", - utility: "v1:p-3", + utility: "ui:p-3", values: same("12px"), note: "control to control inside a group", }, @@ -261,7 +261,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-4", cssVar: "--space-4", - utility: "v1:p-4", + utility: "ui:p-4", values: same("16px"), note: "card gutter, list row rhythm", }, @@ -269,7 +269,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-5", cssVar: "--space-5", - utility: "v1:p-5", + utility: "ui:p-5", values: same("20px"), note: "card inner padding, compact", }, @@ -277,7 +277,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-6", cssVar: "--space-6", - utility: "v1:p-6", + utility: "ui:p-6", values: same("24px"), note: "card inner padding, default \u00b7 page padding y", }, @@ -285,7 +285,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-8", cssVar: "--space-8", - utility: "v1:p-8", + utility: "ui:p-8", values: same("32px"), note: "page padding x, section break", }, @@ -294,7 +294,7 @@ export const TOKENS: readonly Token[] = [ family: "radius", name: "radius-sm", cssVar: "--radius-sm", - utility: "v1:rounded-sm", + utility: "ui:rounded-sm", values: same("6px"), note: "badge, pill, status chip, small icon button", }, @@ -302,7 +302,7 @@ export const TOKENS: readonly Token[] = [ family: "radius", name: "radius-md", cssVar: "--radius-md", - utility: "v1:rounded-md", + utility: "ui:rounded-md", values: same("8px"), note: "button, input, select, textarea, menu item", }, @@ -310,7 +310,7 @@ export const TOKENS: readonly Token[] = [ family: "radius", name: "radius-lg", cssVar: "--radius-lg", - utility: "v1:rounded-lg", + utility: "ui:rounded-lg", values: same("10px"), note: "card, dialog, menu, popover, dropzone", }, @@ -318,7 +318,7 @@ export const TOKENS: readonly Token[] = [ family: "radius", name: "radius-xl", cssVar: "--radius-xl", - utility: "v1:rounded-xl", + utility: "ui:rounded-xl", values: same("14px"), note: "empty-state icon badge", }, @@ -327,190 +327,190 @@ export const TOKENS: readonly Token[] = [ family: "type", name: "type-display-size", cssVar: "--type-display-size", - utility: "v1:text-display", + utility: "ui:text-display", values: same("34px"), }, { family: "type", name: "type-display-line-height", cssVar: "--type-display-line-height", - utility: "v1:text-display", + utility: "ui:text-display", values: same("46px"), }, { family: "type", name: "type-display-weight", cssVar: "--type-display-weight", - utility: "v1:text-display", + utility: "ui:text-display", values: same("600"), }, { family: "type", name: "type-page-title-size", cssVar: "--type-page-title-size", - utility: "v1:text-page-title", + utility: "ui:text-page-title", values: same("20px"), }, { family: "type", name: "type-page-title-line-height", cssVar: "--type-page-title-line-height", - utility: "v1:text-page-title", + utility: "ui:text-page-title", values: same("28px"), }, { family: "type", name: "type-page-title-weight", cssVar: "--type-page-title-weight", - utility: "v1:text-page-title", + utility: "ui:text-page-title", values: same("600"), }, { family: "type", name: "type-section-title-size", cssVar: "--type-section-title-size", - utility: "v1:text-section-title", + utility: "ui:text-section-title", values: same("16px"), }, { family: "type", name: "type-section-title-line-height", cssVar: "--type-section-title-line-height", - utility: "v1:text-section-title", + utility: "ui:text-section-title", values: same("24px"), }, { family: "type", name: "type-section-title-weight", cssVar: "--type-section-title-weight", - utility: "v1:text-section-title", + utility: "ui:text-section-title", values: same("600"), }, { family: "type", name: "type-body-size", cssVar: "--type-body-size", - utility: "v1:text-body", + utility: "ui:text-body", values: same("14px"), }, { family: "type", name: "type-body-line-height", cssVar: "--type-body-line-height", - utility: "v1:text-body", + utility: "ui:text-body", values: same("20px"), }, { family: "type", name: "type-body-weight", cssVar: "--type-body-weight", - utility: "v1:text-body", + utility: "ui:text-body", values: same("400"), }, { family: "type", name: "type-body-small-size", cssVar: "--type-body-small-size", - utility: "v1:text-body-small", + utility: "ui:text-body-small", values: same("13px"), }, { family: "type", name: "type-body-small-line-height", cssVar: "--type-body-small-line-height", - utility: "v1:text-body-small", + utility: "ui:text-body-small", values: same("18px"), }, { family: "type", name: "type-body-small-weight", cssVar: "--type-body-small-weight", - utility: "v1:text-body-small", + utility: "ui:text-body-small", values: same("400"), }, { family: "type", name: "type-caption-size", cssVar: "--type-caption-size", - utility: "v1:text-caption", + utility: "ui:text-caption", values: same("12px"), }, { family: "type", name: "type-caption-line-height", cssVar: "--type-caption-line-height", - utility: "v1:text-caption", + utility: "ui:text-caption", values: same("16px"), }, { family: "type", name: "type-caption-weight", cssVar: "--type-caption-weight", - utility: "v1:text-caption", + utility: "ui:text-caption", values: same("400"), }, { family: "type", name: "type-overline-size", cssVar: "--type-overline-size", - utility: "v1:text-overline", + utility: "ui:text-overline", values: same("11px"), - note: "Pair with v1:uppercase.", + note: "Pair with ui:uppercase.", }, { family: "type", name: "type-overline-line-height", cssVar: "--type-overline-line-height", - utility: "v1:text-overline", + utility: "ui:text-overline", values: same("16px"), - note: "Pair with v1:uppercase.", + note: "Pair with ui:uppercase.", }, { family: "type", name: "type-overline-weight", cssVar: "--type-overline-weight", - utility: "v1:text-overline", + utility: "ui:text-overline", values: same("600"), - note: "Pair with v1:uppercase.", + note: "Pair with ui:uppercase.", }, { family: "type", name: "type-mono-size", cssVar: "--type-mono-size", - utility: "v1:text-mono", + utility: "ui:text-mono", values: same("12px"), - note: "Pair with v1:font-mono. Reserved for model output, JSON and token counts.", + note: "Pair with ui:font-mono. Reserved for model output, JSON and token counts.", }, { family: "type", name: "type-mono-line-height", cssVar: "--type-mono-line-height", - utility: "v1:text-mono", + utility: "ui:text-mono", values: same("22px"), - note: "Pair with v1:font-mono. Reserved for model output, JSON and token counts.", + note: "Pair with ui:font-mono. Reserved for model output, JSON and token counts.", }, { family: "type", name: "type-mono-weight", cssVar: "--type-mono-weight", - utility: "v1:text-mono", + utility: "ui:text-mono", values: same("400"), - note: "Pair with v1:font-mono. Reserved for model output, JSON and token counts.", + note: "Pair with ui:font-mono. Reserved for model output, JSON and token counts.", }, { family: "type", name: "type-overline-tracking", cssVar: "--type-overline-tracking", - utility: "v1:text-overline", + utility: "ui:text-overline", values: same("0.05em"), - note: "Pair with v1:uppercase.", + note: "Pair with ui:uppercase.", }, // Elevation: one value per step, the same in every mode. { family: "elevation", name: "shadow-sm", cssVar: "--shadow-sm", - utility: "v1:shadow-sm", + utility: "ui:shadow-sm", values: same("0 1px 2px 0 rgb(0 0 0 / 0.05)"), note: "every card at rest", }, @@ -518,7 +518,7 @@ export const TOKENS: readonly Token[] = [ family: "elevation", name: "shadow-md", cssVar: "--shadow-md", - utility: "v1:shadow-md", + utility: "ui:shadow-md", values: same( "0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10)", ), @@ -528,7 +528,7 @@ export const TOKENS: readonly Token[] = [ family: "elevation", name: "shadow-lg", cssVar: "--shadow-lg", - utility: "v1:shadow-lg", + utility: "ui:shadow-lg", values: same( "0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10)", ), @@ -538,7 +538,7 @@ export const TOKENS: readonly Token[] = [ family: "elevation", name: "shadow-xl", cssVar: "--shadow-xl", - utility: "v1:shadow-xl", + utility: "ui:shadow-xl", values: same( "0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10)", ), @@ -548,7 +548,7 @@ export const TOKENS: readonly Token[] = [ family: "elevation", name: "scrim", cssVar: "--scrim", - utility: "v1:bg-scrim", + utility: "ui:bg-scrim", values: same("rgb(0 0 0 / 0.5)"), note: "behind every dialog, full viewport", }, @@ -557,14 +557,14 @@ export const TOKENS: readonly Token[] = [ family: "focus", name: "focus-ring-width", cssVar: "--focus-ring-width", - utility: "v1:focus-ring", + utility: "ui:focus-ring", values: same("3px"), }, { family: "focus", name: "focus-ring-alpha", cssVar: "--focus-ring-alpha", - utility: "v1:focus-ring", + utility: "ui:focus-ring", values: same("50%"), }, // Font @@ -572,7 +572,7 @@ export const TOKENS: readonly Token[] = [ family: "font", name: "font-sans", cssVar: "--font-sans", - utility: "v1:font-sans", + utility: "ui:font-sans", values: same( '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif', ), @@ -581,7 +581,7 @@ export const TOKENS: readonly Token[] = [ family: "font", name: "font-mono", cssVar: "--font-mono", - utility: "v1:font-mono", + utility: "ui:font-mono", values: same( '"Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', ), From 0e2b7a0f8085853a152514fffbeeac3d8765034f Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:07:13 +0530 Subject: [PATCH 15/38] UN-4299 [FEAT] Add the prefixed v1 Tailwind root with fonts, modes, theme mapping and the focus utility Co-Authored-By: Claude Opus 5.5 --- frontend/bun.lock | 7 +- frontend/package.json | 1 + frontend/src/v1/styles/base.css | 25 ++++++ frontend/src/v1/styles/index.css | 113 ++++++++++++++++++++++++++++ frontend/src/v1/styles/root.test.ts | 92 ++++++++++++++++++++++ 5 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 frontend/src/v1/styles/base.css create mode 100644 frontend/src/v1/styles/index.css create mode 100644 frontend/src/v1/styles/root.test.ts diff --git a/frontend/bun.lock b/frontend/bun.lock index 3616692ef3..bdb5fa847e 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -67,6 +67,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.13", + "@tailwindcss/node": "4.3.3", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4", @@ -1400,8 +1401,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=="], @@ -1630,6 +1629,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=="], @@ -1682,8 +1683,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 7007b8fc20..fc04c1348b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -97,6 +97,7 @@ }, "devDependencies": { "@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/src/v1/styles/base.css b/frontend/src/v1/styles/base.css new file mode 100644 index 0000000000..4ae40107cd --- /dev/null +++ b/frontend/src/v1/styles/base.css @@ -0,0 +1,25 @@ +/* What preflight would give the v1 tree, scoped to .v1 so v0 is untouched. */ +@layer base { + .v1 { + min-height: 100%; + background-color: var(--background); + 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..c412f0c426 --- /dev/null +++ b/frontend/src/v1/styles/index.css @@ -0,0 +1,113 @@ +/* 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 "../"; + +@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); +@custom-variant unstract (&:where( + [data-theme="unstract"], + [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); + + --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); + + --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". */ +@utility focus-ring { + outline: 0; + 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..29a58cb5a3 --- /dev/null +++ b/frontend/src/v1/styles/root.test.ts @@ -0,0 +1,92 @@ +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"; + +let css = ""; +beforeAll(async () => { + const base = path.resolve(import.meta.dirname); + const source = fs.readFileSync(path.join(base, "index.css"), "utf8"); + const compiler = await compile(source, { + base, + onDependency: () => undefined, + }); + css = compiler.build([ + "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", + "bg-background", + "flex", + "ui:bg-red-500", + "ui:text-sm", + "ui:shadow-2xl", + "ui:rounded-2xl", + ]); +}); + +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).toMatch(/\.ui\\:focus-ring\s*\{/); + expect(css).not.toMatch(/(^|[^\\:])\.bg-background\b/); + expect(css).not.toMatch(/(^|[^\\:])\.flex\b/); +}); + +it("has no stock palette, text scale, shadow scale 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"); +}); + +it("dark and unstract variants key on data-theme, never on prefers-color-scheme", () => { + expect(css).toMatch(/\[data-theme="?dark"?\]/); + expect(css).toMatch(/\[data-theme="?unstract"?\]/); + expect(css).not.toContain("prefers-color-scheme"); +}); + +it("every emitted rule is a ui: utility, scoped to .v1, or Tailwind's :root/property defaults", () => { + const selectors = [...css.matchAll(/^\s*([^@{}\n][^{}\n]*)\{/gm)].map((m) => + m[1].trim(), + ); + const unscoped = selectors.filter( + (s) => + !/^(\.v1\b|\.ui\\:|:root, :host|\*, ::before, ::after, ::backdrop)/.test( + s, + ) && !s.startsWith("@"), + ); + expect(unscoped).toEqual([]); +}); + +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([]); +}); From d737fa8fb6f92c844896a5716b5f4d4b59041875 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:09:48 +0530 Subject: [PATCH 16/38] UN-4299 [FIX] Exclude the generated tokens.css from Biome formatting so biome ci passes src/v1/styles/tokens.css is written by scripts/build-v1-tokens.mjs and checked byte-for-byte by styles/tokens.test.ts; the formatter would rewrap it and break the check. Co-Authored-By: Claude Opus 5.5 --- frontend/biome.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/biome.json b/frontend/biome.json index 6799918af6..df57f0f1a7 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -177,6 +177,10 @@ } } }, + { + "includes": ["src/v1/styles/tokens.css"], + "formatter": { "enabled": false } + }, { "includes": ["src/**", "!src/v1/**"], "linter": { From a9acf5e04dfa5f7f86f646b210b3830f376b18ea Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:10:29 +0530 Subject: [PATCH 17/38] UN-4299 [FIX] Sort the imports in cn.ts so biome ci passes Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/cn.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/v1/lib/cn.ts b/frontend/src/v1/lib/cn.ts index 8592caeb34..6693302d90 100644 --- a/frontend/src/v1/lib/cn.ts +++ b/frontend/src/v1/lib/cn.ts @@ -1,5 +1,6 @@ -import { clsx, type ClassValue } from "clsx"; +import { type ClassValue, clsx } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; + const twMerge = extendTailwindMerge({ prefix: "ui" }); export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); From 54469cba684fcba503a9ae644a80c69ec0871dd8 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:14:08 +0530 Subject: [PATCH 18/38] UN-4299 [FIX] Make the root compile test fail on unscoped selector lists and on missing variants Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/styles/root.test.ts | 118 +++++++++++++++++++--------- 1 file changed, 81 insertions(+), 37 deletions(-) diff --git a/frontend/src/v1/styles/root.test.ts b/frontend/src/v1/styles/root.test.ts index 29a58cb5a3..773252256e 100644 --- a/frontend/src/v1/styles/root.test.ts +++ b/frontend/src/v1/styles/root.test.ts @@ -4,34 +4,76 @@ import { compile } from "@tailwindcss/node"; import { beforeAll, expect, it } from "vitest"; import { TOKENS } from "./tokens"; -let css = ""; -beforeAll(async () => { - const base = path.resolve(import.meta.dirname); - const source = fs.readFileSync(path.join(base, "index.css"), "utf8"); - const compiler = await compile(source, { +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", + "bg-background", + "flex", + "ui:bg-red-500", + "ui:text-sm", + "ui:shadow-2xl", + "ui:rounded-2xl", +]; + +async function build(stylesheet: string): Promise { + const compiler = await compile(stylesheet, { base, onDependency: () => undefined, }); - css = compiler.build([ - "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", - "bg-background", - "flex", - "ui:bg-red-500", - "ui:text-sm", - "ui:shadow-2xl", - "ui:rounded-2xl", - ]); + 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", () => { @@ -62,22 +104,24 @@ it("has no stock palette, text scale, shadow scale or radius scale", () => { }); it("dark and unstract variants key on data-theme, never on prefers-color-scheme", () => { - expect(css).toMatch(/\[data-theme="?dark"?\]/); - expect(css).toMatch(/\[data-theme="?unstract"?\]/); + // 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\(\[data-theme="?dark"?\],\s*\[data-theme="?dark"?\]\s*\*\s*\)\s*\{[^}]*background-color:\s*var\(--card\)/, + ); + expect(css).toMatch( + /\.ui\\:unstract\\:bg-card:where\(\[data-theme="?unstract"?\],\s*\[data-theme="?unstract"?\]\s*\*\s*\)\s*\{[^}]*background-color:\s*var\(--card\)/, + ); expect(css).not.toContain("prefers-color-scheme"); }); -it("every emitted rule is a ui: utility, scoped to .v1, or Tailwind's :root/property defaults", () => { - const selectors = [...css.matchAll(/^\s*([^@{}\n][^{}\n]*)\{/gm)].map((m) => - m[1].trim(), - ); - const unscoped = selectors.filter( - (s) => - !/^(\.v1\b|\.ui\\:|:root, :host|\*, ::before, ::after, ::backdrop)/.test( - s, - ) && !s.startsWith("@"), +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(unscoped).toEqual([]); + expect(unscopedParts(leaky)).toEqual(["body"]); }); it("declares no !important, and every custom property is a Tailwind ui variable or a runtime token", () => { From 43c9ced657bb0f312a26d0925d6a7626e85f2088 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:18:16 +0530 Subject: [PATCH 19/38] UN-4299 [FEAT] Add ThemeProvider: the v1 root, follow-system binding and portal sync Co-Authored-By: Claude Opus 5.5 --- .../src/v1/app/theme/ThemeProvider.test.tsx | 88 +++++++++++++++++++ frontend/src/v1/app/theme/ThemeProvider.tsx | 57 ++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 frontend/src/v1/app/theme/ThemeProvider.test.tsx create mode 100644 frontend/src/v1/app/theme/ThemeProvider.tsx 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..1b3026fafe --- /dev/null +++ b/frontend/src/v1/app/theme/ThemeProvider.test.tsx @@ -0,0 +1,88 @@ +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; + act(() => { + for (const l of listeners) { + l({ matches: true }); + } + }); + expect(root.dataset.theme).toBe("dark"); + act(() => { + useThemeStore.getState().setPreference("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(getPortalContainer().dataset.theme).toBe("light"); +}); + +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..99990ec49e --- /dev/null +++ b/frontend/src/v1/app/theme/ThemeProvider.tsx @@ -0,0 +1,57 @@ +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, useEffect } from "react"; + +export type ThemeProviderProps = Readonly<{ + children: ReactNode; + className?: string; +}>; + +const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)"; + +/** Reading localStorage can throw in sandboxed documents; treat that as no storage. */ +function readLocalStorage(): Storage | null { + try { + return window.localStorage; + } catch { + return null; + } +} + +/** 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); + + useEffect(() => { + const { hydrate, setSystemPrefersDark } = useThemeStore.getState(); + hydrate(readLocalStorage()); + 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); + }; + }, []); + + useEffect(() => { + applyThemeMode(document, mode); + }, [mode]); + + return ( +
+ {children} +
+ ); +} From cc6126c013d6cdf1d6208e91d3bc74d5ecd43973 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:19:23 +0530 Subject: [PATCH 20/38] UN-4299 [FIX] Apply the stored theme before first paint with useLayoutEffect Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/app/theme/ThemeProvider.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/v1/app/theme/ThemeProvider.tsx b/frontend/src/v1/app/theme/ThemeProvider.tsx index 99990ec49e..09b2f83929 100644 --- a/frontend/src/v1/app/theme/ThemeProvider.tsx +++ b/frontend/src/v1/app/theme/ThemeProvider.tsx @@ -3,7 +3,7 @@ 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, useEffect } from "react"; +import { type JSX, type ReactNode, useLayoutEffect } from "react"; export type ThemeProviderProps = Readonly<{ children: ReactNode; @@ -28,7 +28,8 @@ export function ThemeProvider({ }: ThemeProviderProps): JSX.Element { const mode = useThemeStore(selectThemeMode); - useEffect(() => { + // Layout effect: a saved dark or unstract preference must not paint a light frame first. + useLayoutEffect(() => { const { hydrate, setSystemPrefersDark } = useThemeStore.getState(); hydrate(readLocalStorage()); if (typeof window.matchMedia !== "function") { @@ -45,7 +46,7 @@ export function ThemeProvider({ }; }, []); - useEffect(() => { + useLayoutEffect(() => { applyThemeMode(document, mode); }, [mode]); From 0e13d94dde42a79333b6094b8ed5f3e2a0cddf60 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:30:06 +0530 Subject: [PATCH 21/38] UN-4299 [FEAT] Enforce the v1 styling policy: raw colours, pixels, inline styles, CSS imports and !important fail lint Co-Authored-By: Claude Opus 5.5 --- frontend/package.json | 2 +- frontend/scripts/check-v1-styles.mjs | 451 ++++++++++++++++++ frontend/src/v1/README.md | 20 +- .../v1-boundary/boundary.fixtures.mjs | 13 + .../src/v1/styles/extra.css | 3 + .../v1-allowed-imports/src/v1/styles/index.ts | 2 +- .../src/v1/styles/tokens.css | 3 - .../src/v1/pages/demo/index.ts | 1 + .../v1-important-css/src/v1/styles/extra.css | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/styles/extra.css | 5 + .../src/v1/components/ui/Box.tsx | 11 + .../src/v1/styles/extra.css | 5 + .../src/v1/styles/extra.css | 3 + .../src/v1/styles/extra.css | 3 + .../src/v1/components/ui/Box.tsx | 3 + 20 files changed, 536 insertions(+), 7 deletions(-) create mode 100644 frontend/scripts/check-v1-styles.mjs create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/extra.css delete mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/tokens.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-css-import-from-page/src/v1/pages/demo/index.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-important-css/src/v1/styles/extra.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-important-utility/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-inline-style/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-arbitrary/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-palette/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-color-scheme-media/src/v1/styles/extra.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/styles/extra.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-colour/src/v1/styles/extra.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-unscoped-selector/src/v1/styles/extra.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-unprefixed-utility/src/v1/components/ui/Box.tsx diff --git a/frontend/package.json b/frontend/package.json index fc04c1348b..e401581158 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -80,7 +80,7 @@ "typecheck": "tsc --noEmit", "tokens:v1": "node 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": { diff --git a/frontend/scripts/check-v1-styles.mjs b/frontend/scripts/check-v1-styles.mjs new file mode 100644 index 0000000000..ae7518be11 --- /dev/null +++ b/frontend/scripts/check-v1-styles.mjs @@ -0,0 +1,451 @@ +#!/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 (JSX `style=`). + * S2 No raw colour: hex, rgb()/hsl()/oklch()/oklab()/color(), or a + * Tailwind palette utility (`-red-500`, `-white`). + * S3 No raw pixel value: `[13px]` arbitrary values, or px/rem/em/pt 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`, except 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. v0's stylesheet also + * scans src/v1, so an unprefixed class would render with v0's theme. + * + * In TypeScript, S2 to S4 and S8 read only the contents of string and template + * literals, so a non-null `!` or a hex-looking identifier never matches. Test + * files and styles/tokens.ts (the raw values, by design) are skipped for them. + * In CSS, comments are blanked first; the generated styles/tokens.css is + * skipped for S2 and S3 only. + * + * 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. + * + * 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 CSS_EXT = /\.css$/; +const TEST_FILE = /\.(test|spec)\.(ts|tsx|mts|cts)$/; +const RAW_VALUES_TS = "styles/tokens.ts"; +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.", + colour: "No raw colour under v1; use a token from styles/tokens.ts.", + 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.", + colourScheme: + "v1 never reads prefers-color-scheme in CSS; mode comes from data-theme.", + unprefixed: + "No unprefixed Tailwind utility under v1; write ui:. v0's stylesheet also scans src/v1, so an unprefixed class would render with v0's theme.", +}; + +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(" ") + .map((s) => s.replace("-", "\\-")) + .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 } with index the offset of the text's first character. + * 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 = []; + let piece = null; + let i = 0; + while (i < src.length) { + if (stack.at(-1) === "`") { + piece ??= { text: "", index: i }; + const step = templateStep(src, i, stack); + 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 }); + i = end; + } else { + 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 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, not inside an opaque at-rule and not nested in another style rule + * (a nested rule is already scoped by its parent). Grouping at-rules such as + * @layer and @media are transparent. Returns { prelude, index }. + */ +function topLevelSelectors(css) { + const found = []; + // Per open block: "opaque", "group" (transparent at-rule) or "rule". + 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 { + if (!stack.includes("rule")) { + found.push({ prelude, index }); + } + stack.push("rule"); + } + 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}`); +}; + +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)) { + report(rel, lineAt(text, m.index), MSG.inlineStyle); + } + if (rel === RAW_VALUES_TS) { + return; + } + for (const literal of literals(text)) { + 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_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 (isUnprefixedUtility(token)) { + report(rel, lineAt(text, index), `'${token}': ${MSG.unprefixed}`); + } + } + } +} + +function checkCss(rel) { + const css = blankCssComments(fs.readFileSync(path.join(ROOT, rel), "utf8")); + const at = (re, msg) => { + for (const m of css.matchAll(re)) { + report(rel, lineAt(css, m.index), msg); + } + }; + if (rel !== GENERATED_CSS) { + for (const re of RAW_COLOUR_RES) { + at(re, MSG.colour); + } + at(CSS_UNIT_RE, MSG.pixel); + } + at(CSS_IMPORTANT_RE, MSG.important); + at(COLOUR_SCHEME_RE, MSG.colourScheme); + for (const { prelude, index } of topLevelSelectors(css)) { + for (const selector of splitSelectors(prelude)) { + if (!/^\.v1(?![\w-])/.test(selector)) { + report(rel, lineAt(css, index), `'${selector}': ${MSG.unscoped}`); + } + } + } +} + +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 (CSS_EXT.test(rel)) { + 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/v1/README.md b/frontend/src/v1/README.md index 61fa459ac3..faf3b077c4 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -16,6 +16,21 @@ file holds only the rules that tooling enforces. class). Global v1 styles live in `styles/` and are imported as `@v1/styles/...`, including from inside `styles/`. +## Styling + +- Utilities carry the `ui:` prefix (`ui:bg-background`): v0's stylesheet also + scans `src/v1`, so an unprefixed class renders with v0's theme. Merge with + `cn` from `@v1/lib/cn`. Tokens live in `styles/tokens.ts`; `tokens.css` is + generated from them by `bun run tokens:v1`. +- `ThemeProvider` sets `data-theme` (`light`, `dark`, `unstract`; preference + key `unstract.v1.theme`). Portals render into `getPortalContainer()`. + `ui:focus-ring` is the one focus style; Geist Mono only for model output, + JSON and token counts. +- `lint:v1` rejects: S1 inline `style`; S2 raw colours; S3 raw pixel values; + S4 `!important`; S5 CSS imports outside `app/` and `styles/`; S6 selectors + not starting with `.v1`; S7 `prefers-color-scheme` in CSS; S8 unprefixed + utilities. + ## Folders and what each may import Every folder may import from itself. Test files (`*.test.ts(x)`, @@ -42,9 +57,10 @@ Every folder may import from itself. Test files (`*.test.ts(x)`, | Script | Checks | | --- | --- | | `bun run typecheck:v1` | TypeScript-only files, CSS only in `styles/`, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` | -| `bun run lint:v1` | Biome on `src/v1/`, then imports (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 (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.ts` | | `bun run test:v1` | v1 unit tests, then the guardrail fixtures in `test-fixtures/v1-boundary/` | The v0 ↔ v1 boundary and the banned imports are Biome rules in `biome.json`, so `biome ci src/` (CI) and editors report them too. The remaining checks are -`src/v1/tsconfig.json` and `scripts/check-v1.mjs`. +`src/v1/tsconfig.json`, `scripts/check-v1.mjs` and `scripts/check-v1-styles.mjs`. diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index c530319d89..e1194b9fbb 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -82,6 +82,19 @@ 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-raw-colour-palette | lint:v1 | No raw colour under v1 + v1-raw-colour-arbitrary | lint:v1 | No raw colour under v1 + v1-raw-pixel-arbitrary | lint:v1 | No raw pixel value under v1 + v1-important-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-unscoped-selector | lint:v1 | Every v1 selector is scoped to .v1 + v1-styles-color-scheme-media | lint:v1 | prefers-color-scheme + v1-unprefixed-utility | lint:v1 | No unprefixed Tailwind utility under v1 + v1-styles-policy-ok | lint:v1 | - `).map(([name, run, fails]) => ({ name: name === "-" ? null : name, run: run.split(" + "), 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-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-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-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-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-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-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..861e6a6d63 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/components/ui/Box.tsx @@ -0,0 +1,11 @@ +import { cn } from "@v1/lib/cn"; + +export function Box() { + 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..758e08ee11 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/styles/extra.css @@ -0,0 +1,5 @@ +.v1 .demo, +.v1 .demo::after { + color: var(--foreground); + margin: 0; +} 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-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-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
; +} From c3a703c20039c8934e0e296d7b97f8a42e0d1a8d Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:36:08 +0530 Subject: [PATCH 22/38] UN-4299 [FIX] Scope the styling lint to class strings and cover vh/vw arbitrary values Co-Authored-By: Claude Opus 5.5 --- frontend/scripts/check-v1-styles.mjs | 78 +++++++++++++++---- frontend/src/v1/README.md | 10 +-- .../v1-boundary/boundary.fixtures.mjs | 1 + .../src/v1/components/ui/Box.tsx | 10 +++ 4 files changed, 81 insertions(+), 18 deletions(-) create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-plain-strings-ok/src/v1/components/ui/Box.tsx diff --git a/frontend/scripts/check-v1-styles.mjs b/frontend/scripts/check-v1-styles.mjs index ae7518be11..b6de07ab4f 100644 --- a/frontend/scripts/check-v1-styles.mjs +++ b/frontend/scripts/check-v1-styles.mjs @@ -3,10 +3,11 @@ * 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 (JSX `style=`). + * S1 No inline style (a JSX `style` attribute). * S2 No raw colour: hex, rgb()/hsl()/oklch()/oklab()/color(), or a * Tailwind palette utility (`-red-500`, `-white`). - * S3 No raw pixel value: `[13px]` arbitrary values, or px/rem/em/pt in CSS. + * S3 No raw pixel value: `[13px]` or `[100vh]` arbitrary values, or + * px/rem/em/pt 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`, except inside @theme, @utility, @@ -15,9 +16,11 @@ * S8 Every Tailwind utility carries the `ui:` prefix. v0's stylesheet also * scans src/v1, so an unprefixed class would render with v0's theme. * - * In TypeScript, S2 to S4 and S8 read only the contents of string and template - * literals, so a non-null `!` or a hex-looking identifier never matches. Test - * files and styles/tokens.ts (the raw values, by design) are skipped for them. + * In TypeScript, S2 to S4 and S8 read only class strings: string and template + * literals that look 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. Test files and styles/tokens.ts (the raw values, by design) are + * skipped for them. 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. * @@ -26,7 +29,9 @@ * 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. + * 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. @@ -65,13 +70,14 @@ const RAW_COLOUR_RES = [ new RegExp(String.raw`-(?:${PALETTE})-\d{2,3}(?![\w-])`, "g"), /-(?:white|black)(?![\w-])/g, ]; -// A Tailwind arbitrary value in a unit: `w-[13px]`, `p-[1.5rem]`. -const ARBITRARY_UNIT_RE = /\[[^\]\s]*\d(?:px|rem|em|pt)\]/g; +// A Tailwind arbitrary value in a unit: `w-[13px]`, `p-[1.5rem]`, `h-[100vh]`. +const ARBITRARY_UNIT_RE = /\[[^\]\s]*\d(?:px|rem|em|vh|vw|pt)\]/g; // A CSS length in a unit: `3px`, `.5rem`, `-1em`. const CSS_UNIT_RE = /(? 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); @@ -386,7 +429,16 @@ function checkCode(rel) { if (rel === RAW_VALUES_TS) { return; } - for (const literal of literals(text)) { + 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}`); + } + for (const literal of pieces) { + if (!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); diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index faf3b077c4..2d6efbd873 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -22,14 +22,14 @@ file holds only the rules that tooling enforces. scans `src/v1`, so an unprefixed class renders with v0's theme. Merge with `cn` from `@v1/lib/cn`. Tokens live in `styles/tokens.ts`; `tokens.css` is generated from them by `bun run tokens:v1`. -- `ThemeProvider` sets `data-theme` (`light`, `dark`, `unstract`; preference - key `unstract.v1.theme`). Portals render into `getPortalContainer()`. - `ui:focus-ring` is the one focus style; Geist Mono only for model output, - JSON and token counts. +- `ThemeProvider` sets `data-theme` (`light`, `dark`, `unstract`; key + `unstract.v1.theme`); portals use `getPortalContainer()`. `ui:focus-ring` is + the one focus style; Geist Mono only for model output, JSON and token counts. - `lint:v1` rejects: S1 inline `style`; S2 raw colours; S3 raw pixel values; S4 `!important`; S5 CSS imports outside `app/` and `styles/`; S6 selectors not starting with `.v1`; S7 `prefers-color-scheme` in CSS; S8 unprefixed - utilities. + utilities. In code, S2 to S4 and S8 read class strings only (`className` + values, `cn()`/`cva()` arguments, constants holding classes), not every string. ## Folders and what each may import diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index e1194b9fbb..d847fb22da 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -95,6 +95,7 @@ const CASES = parseTable(` v1-styles-color-scheme-media | lint:v1 | prefers-color-scheme v1-unprefixed-utility | 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/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..658ea24b5a --- /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

+
+ ); +} From a35da5b1f0702b9b5585a6f504aaa89013b006ee Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 15:23:11 +0530 Subject: [PATCH 23/38] UN-4299 [FIX] Keep v0's Tailwind scan out of src/v1, the lint scripts and the fixtures v0's root auto-detected sources across the whole frontend, so the v1 tree, scripts/check-v1-styles.mjs and test-fixtures/ added 12 unprefixed utilities and 8 theme variables to v0's stylesheet. With these three @source not lines the v0 build is byte-identical to the base again (239/239 files). Co-Authored-By: Claude Opus 5.5 --- frontend/src/index.css | 6 ++++++ 1 file changed, 6 insertions(+) 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"; From d6047e949f6101c2f9427a383571f01e7dcac921 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 15:34:47 +0530 Subject: [PATCH 24/38] UN-4299 [FIX] Teach cn the type roles so a role and a text colour no longer cancel each other Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/cn.test.ts | 13 +++++++++++++ frontend/src/v1/lib/cn.ts | 20 +++++++++++++++++++- frontend/src/v1/styles/tokens.test.ts | 10 ++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/frontend/src/v1/lib/cn.test.ts b/frontend/src/v1/lib/cn.test.ts index 5dd843f1ee..d4c41dd3af 100644 --- a/frontend/src/v1/lib/cn.test.ts +++ b/frontend/src/v1/lib/cn.test.ts @@ -15,3 +15,16 @@ it("keeps unrelated and variant classes", () => { 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 index 6693302d90..0ba762972b 100644 --- a/frontend/src/v1/lib/cn.ts +++ b/frontend/src/v1/lib/cn.ts @@ -1,7 +1,25 @@ import { type ClassValue, clsx } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; -const twMerge = extendTailwindMerge({ prefix: "ui" }); +/** 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/styles/tokens.test.ts b/frontend/src/v1/styles/tokens.test.ts index ea9085ef31..fdffce554e 100644 --- a/frontend/src/v1/styles/tokens.test.ts +++ b/frontend/src/v1/styles/tokens.test.ts @@ -1,5 +1,6 @@ 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"; @@ -75,6 +76,15 @@ it("covers the families the board defines", () => { 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({ From b29503d6a518793971d9f776bfe136b0acd9c44d Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 15:36:02 +0530 Subject: [PATCH 25/38] UN-4299 [FIX] Reject colour literals outside class strings, named-colour arbitrary values and !important after a variant Co-Authored-By: Claude Opus 5.5 --- frontend/scripts/check-v1-styles.mjs | 77 +++++++++++++++++-- .../v1-boundary/boundary.fixtures.mjs | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 2 +- .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 2 +- 7 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-important-variant-utility/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-named/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-prop/src/v1/components/ui/Box.tsx diff --git a/frontend/scripts/check-v1-styles.mjs b/frontend/scripts/check-v1-styles.mjs index b6de07ab4f..6173a4173f 100644 --- a/frontend/scripts/check-v1-styles.mjs +++ b/frontend/scripts/check-v1-styles.mjs @@ -4,8 +4,9 @@ * check-v1.mjs; the rules and their reasons are in src/v1/README.md. * * S1 No inline style (a JSX `style` attribute). - * S2 No raw colour: hex, rgb()/hsl()/oklch()/oklab()/color(), or a - * Tailwind palette utility (`-red-500`, `-white`). + * 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]`). * S3 No raw pixel value: `[13px]` or `[100vh]` arbitrary values, or * px/rem/em/pt in CSS. * S4 No !important: a `!` class modifier, or `!important` in CSS. @@ -13,14 +14,17 @@ * S6 Every CSS selector starts with `.v1`, except 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. v0's stylesheet also - * scans src/v1, so an unprefixed class would render with v0's theme. + * S8 Every Tailwind utility carries the `ui:` prefix. 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: string and template * literals that look 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. Test files and styles/tokens.ts (the raw values, by design) are - * skipped for them. S1 matches only a JSX `style=` attribute. + * matches. One S2 check reads every literal: a string that is entirely a + * colour (`fill="#ff0000"`, "rgb(1 2 3)") fails wherever it appears, while + * "Run #1234" does not. Test files and styles/tokens.ts (the raw values, by + * design) are skipped for them. 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. * @@ -59,7 +63,7 @@ const MSG = { colourScheme: "v1 never reads prefers-color-scheme in CSS; mode comes from data-theme.", unprefixed: - "No unprefixed Tailwind utility under v1; write ui:. v0's stylesheet also scans src/v1, so an unprefixed class would render with v0's theme.", + "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 = @@ -70,6 +74,51 @@ const RAW_COLOUR_RES = [ new RegExp(String.raw`-(?:${PALETTE})-\d{2,3}(?![\w-])`, "g"), /-(?:white|black)(?![\w-])/g, ]; +// A literal that is nothing but a colour: `fill="#ff0000"`, "rgb(1 2 3)". +const WHOLE_COLOUR_RES = [ + /^#[0-9a-fA-F]{3,8}$/, + /^(?:rgba?|hsla?|oklch|oklab|color)\(/, +]; +// CSS named colours an arbitrary value might carry. Not all 148: the common +// ones. transparent and currentcolor are listed but allowed (see below). +const NAMED_COLOURS = new Set([ + "black", + "white", + "red", + "green", + "blue", + "yellow", + "orange", + "purple", + "pink", + "gray", + "grey", + "silver", + "tomato", + "navy", + "teal", + "maroon", + "olive", + "lime", + "aqua", + "cyan", + "magenta", + "fuchsia", + "gold", + "indigo", + "violet", + "brown", + "coral", + "crimson", + "salmon", + "transparent", + "currentcolor", +]); +// The focus utility and shadcn patterns use these, so they stay allowed. +const ALLOWED_NAMED_COLOURS = new Set(["transparent", "currentcolor"]); +// An arbitrary value holding one word, after an optional `property:` label: +// `text-[red]`, `[color:tomato]`, `bg-[color:navy]`. +const ARBITRARY_WORD_RE = /(?:-|(? re.test(trimmed))) { + report(rel, lineAt(text, literal.index), MSG.colour); + continue; + } if (!isClassString(textById.get(literal.id))) { continue; } @@ -444,6 +499,12 @@ function checkCode(rel) { 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); } diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index d847fb22da..6926d0f073 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -86,8 +86,11 @@ const CASES = parseTable(` v1-inline-style | lint:v1 | No inline style under 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-pixel-arbitrary | 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 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-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 index 658ea24b5a..1e286f3df0 100644 --- 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 @@ -4,7 +4,7 @@ export function Box({ variant }: BoxProps) { return (
-

You don't have hidden files

+

You don't have hidden files

); } 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-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-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 index 861e6a6d63..11f9727a7a 100644 --- 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 @@ -4,7 +4,7 @@ export function Box() { return (
); From e064614ec910bb70ca94396027d27e26f7d9e54d Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 15:37:30 +0530 Subject: [PATCH 26/38] UN-4299 [FIX] Keep the focus ring visible in forced-colors mode and scope variants, shadows and the root fill to v1 Co-Authored-By: Claude Opus 5.5 --- frontend/scripts/check-v1-styles.mjs | 26 ++++++++++++++++++----- frontend/src/v1/styles/base.css | 8 +++++-- frontend/src/v1/styles/index.css | 21 ++++++++++++++----- frontend/src/v1/styles/root.test.ts | 31 +++++++++++++++++++++++----- 4 files changed, 69 insertions(+), 17 deletions(-) diff --git a/frontend/scripts/check-v1-styles.mjs b/frontend/scripts/check-v1-styles.mjs index 6173a4173f..484950afcf 100644 --- a/frontend/scripts/check-v1-styles.mjs +++ b/frontend/scripts/check-v1-styles.mjs @@ -26,7 +26,10 @@ * "Run #1234" does not. Test files and styles/tokens.ts (the raw values, by * design) are skipped for them. 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. + * 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`), @@ -123,6 +126,9 @@ const ARBITRARY_WORD_RE = /(?:-|(? { + 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)) { - report(rel, lineAt(css, m.index), msg); + 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); } - at(CSS_UNIT_RE, MSG.pixel); + at(CSS_UNIT_RE, MSG.pixel, pixelAllowed); } at(CSS_IMPORTANT_RE, MSG.important); at(COLOUR_SCHEME_RE, MSG.colourScheme); diff --git a/frontend/src/v1/styles/base.css b/frontend/src/v1/styles/base.css index 4ae40107cd..d4619d8328 100644 --- a/frontend/src/v1/styles/base.css +++ b/frontend/src/v1/styles/base.css @@ -1,8 +1,12 @@ -/* What preflight would give the v1 tree, scoped to .v1 so v0 is untouched. */ +/* 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 { + .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); diff --git a/frontend/src/v1/styles/index.css b/frontend/src/v1/styles/index.css index c412f0c426..9474272455 100644 --- a/frontend/src/v1/styles/index.css +++ b/frontend/src/v1/styles/index.css @@ -18,10 +18,14 @@ /* Scan only the v1 tree (source(none) above turns off automatic detection). */ @source "../"; -@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); +/* 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( - [data-theme="unstract"], - [data-theme="unstract"] * + .v1[data-theme="unstract"], + .v1[data-theme="unstract"] * )); /* Every theme key reads a runtime token from tokens.css; the stock namespaces @@ -71,6 +75,9 @@ --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); @@ -104,9 +111,13 @@ --font-mono: var(--font-mono); } -/* Board section 6: "border → ring, plus 3px ring at 50% outside". */ +/* 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: 0; + 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 index 773252256e..c1db537f6a 100644 --- a/frontend/src/v1/styles/root.test.ts +++ b/frontend/src/v1/styles/root.test.ts @@ -25,6 +25,9 @@ const CANDIDATES = [ "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 { @@ -91,26 +94,37 @@ it("emits only prefixed utilities bound to runtime variables", () => { // 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).toMatch(/\.ui\\:focus-ring\s*\{/); expect(css).not.toMatch(/(^|[^\\:])\.bg-background\b/); expect(css).not.toMatch(/(^|[^\\:])\.flex\b/); }); -it("has no stock palette, text scale, shadow scale or radius scale", () => { +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("dark and unstract variants key on data-theme, never on prefers-color-scheme", () => { +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\(\[data-theme="?dark"?\],\s*\[data-theme="?dark"?\]\s*\*\s*\)\s*\{[^}]*background-color:\s*var\(--card\)/, + /\.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\(\[data-theme="?unstract"?\],\s*\[data-theme="?unstract"?\]\s*\*\s*\)\s*\{[^}]*background-color:\s*var\(--card\)/, + /\.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"); }); @@ -134,3 +148,10 @@ it("declares no !important, and every custom property is a Tailwind ui variable ); 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)/); +}); From 88354e13d1440f44facc007bf6ed5e3f8c545a83 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 15:37:52 +0530 Subject: [PATCH 27/38] UN-4299 [FIX] Create the portal container before the mode changes so the theme test exercises the mode effect Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/app/theme/ThemeProvider.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/v1/app/theme/ThemeProvider.test.tsx b/frontend/src/v1/app/theme/ThemeProvider.test.tsx index 1b3026fafe..30415c3cc9 100644 --- a/frontend/src/v1/app/theme/ThemeProvider.test.tsx +++ b/frontend/src/v1/app/theme/ThemeProvider.test.tsx @@ -53,15 +53,20 @@ it("follows the system preference while set to system and stops after an explici , ); 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 }); @@ -73,7 +78,8 @@ it("follows the system preference while set to system and stops after an explici } }); expect(root.dataset.theme).toBe("light"); - expect(getPortalContainer().dataset.theme).toBe("light"); + expect(portal.dataset.theme).toBe("light"); + expect(getPortalContainer()).toBe(portal); }); it("removes the media listener on unmount", () => { From a41e1baa9b41b95f3fb266b04e8a0ee11b8b04b2 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 15:38:53 +0530 Subject: [PATCH 28/38] UN-4299 [FIX] Document the v0 rules that reach v1, the focus-ring usage, the pixel escape and the current unprefixed-class rationale Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/README.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 2d6efbd873..185302f2e6 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -18,18 +18,19 @@ file holds only the rules that tooling enforces. ## Styling -- Utilities carry the `ui:` prefix (`ui:bg-background`): v0's stylesheet also - scans `src/v1`, so an unprefixed class renders with v0's theme. Merge with - `cn` from `@v1/lib/cn`. Tokens live in `styles/tokens.ts`; `tokens.css` is - generated from them by `bun run tokens:v1`. -- `ThemeProvider` sets `data-theme` (`light`, `dark`, `unstract`; key - `unstract.v1.theme`); portals use `getPortalContainer()`. `ui:focus-ring` is - the one focus style; Geist Mono only for model output, JSON and token counts. -- `lint:v1` rejects: S1 inline `style`; S2 raw colours; S3 raw pixel values; - S4 `!important`; S5 CSS imports outside `app/` and `styles/`; S6 selectors - not starting with `.v1`; S7 `prefers-color-scheme` in CSS; S8 unprefixed - utilities. In code, S2 to S4 and S8 read class strings only (`className` - values, `cn()`/`cva()` arguments, constants holding classes), not every string. +- `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`); tokens live in `styles/tokens.ts`. +- `ThemeProvider` sets `data-theme`; portals use `getPortalContainer()`; focus + is `ui:focus-visible:focus-ring`; Geist Mono only for model output and JSON. +- `lint:v1` S1–S8: no inline style, raw colour or pixel, `!important`, CSS + import outside `app/` or `styles/`, selector outside `.v1`, + `prefers-color-scheme` or unprefixed utility (code: class and colour strings). + 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 }`. ## Folders and what each may import From 8231919d016e00945d71dcca6202e218378b107f Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 15:41:16 +0530 Subject: [PATCH 29/38] UN-4299 [FIX] Name the theme storage key in the v1 README Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 185302f2e6..b1fd6afdf6 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -20,9 +20,10 @@ file holds only the rules that tooling enforces. - `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`); tokens live in `styles/tokens.ts`. -- `ThemeProvider` sets `data-theme`; portals use `getPortalContainer()`; focus - is `ui:focus-visible:focus-ring`; Geist Mono only for model output and JSON. + with `cn` (`@v1/lib/cn`); `bun run tokens:v1` makes tokens.css from tokens.ts. +- The user chooses the mode, stored per browser under `unstract.v1.theme` and + applied as `data-theme` by `ThemeProvider`. Portals: `getPortalContainer()`; + focus: `ui:focus-visible:focus-ring`; Geist Mono only for model output, JSON. - `lint:v1` S1–S8: no inline style, raw colour or pixel, `!important`, CSS import outside `app/` or `styles/`, selector outside `.v1`, `prefers-color-scheme` or unprefixed utility (code: class and colour strings). From d0c1be0fa8df2692b02c2a90af028fd53c0221de Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 18:01:04 +0530 Subject: [PATCH 30/38] UN-4299 [MISC] Name the three theme modes in the v1 README Co-Authored-By: Claude Fable 5.1 --- frontend/src/v1/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index b1fd6afdf6..c8fbafcf26 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -21,9 +21,10 @@ file holds only the rules that tooling enforces. - `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.ts. -- The user chooses the mode, stored per browser under `unstract.v1.theme` and - applied as `data-theme` by `ThemeProvider`. Portals: `getPortalContainer()`; - focus: `ui:focus-visible:focus-ring`; Geist Mono only for model output, 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 or pixel, `!important`, CSS import outside `app/` or `styles/`, selector outside `.v1`, `prefers-color-scheme` or unprefixed utility (code: class and colour strings). From bef9515660be91ec638428c0f233622ea36a163a Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Thu, 29 Oct 2026 13:48:04 +0530 Subject: [PATCH 31/38] UN-4299 [FEAT] Let a v1 style attribute set CSS custom properties only Runtime geometry (a page's aspect ratio, a highlight's position) cannot be a token or a class. Rule S1 now accepts a style attribute whose value is an object literal with only "--name" keys, so the numbers travel through custom properties that ui: classes read (ui:aspect-(--page-aspect)); a string, a variable, a spread or a plain key still fails. Three fixtures pin it. Co-Authored-By: Claude Fable 5.1 --- frontend/scripts/check-v1-styles.mjs | 118 +++++++++++++++++- frontend/src/v1/README.md | 4 +- .../v1-boundary/boundary.fixtures.mjs | 3 + .../src/v1/components/ui/Page.tsx | 21 ++++ .../src/v1/components/ui/Page.tsx | 16 +++ .../src/v1/components/ui/Page.tsx | 6 + 6 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-inline-style-custom-props-ok/src/v1/components/ui/Page.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-inline-style-mixed/src/v1/components/ui/Page.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-inline-style-variable/src/v1/components/ui/Page.tsx diff --git a/frontend/scripts/check-v1-styles.mjs b/frontend/scripts/check-v1-styles.mjs index 484950afcf..f58eae4131 100644 --- a/frontend/scripts/check-v1-styles.mjs +++ b/frontend/scripts/check-v1-styles.mjs @@ -3,7 +3,10 @@ * 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). + * 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]`). @@ -57,7 +60,8 @@ const GENERATED_CSS = "styles/tokens.css"; const CSS_IMPORTERS = new Set(["app", "styles"]); const MSG = { - inlineStyle: "No inline style under v1; style with tokens and Tailwind.", + 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.ts.", pixel: "No raw pixel value under v1; use a token or the spacing scale.", important: "No !important under v1.", @@ -133,6 +137,8 @@ const CSS_IMPORTANT_RE = /!\s*important\b/g; const COLOUR_SCHEME_RE = /prefers-color-scheme/g; // A JSX attribute: `
{ 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; +} + function checkCode(rel) { const text = blankComments(fs.readFileSync(path.join(ROOT, rel), "utf8")); const top = rel.split("/")[0]; @@ -480,7 +590,9 @@ function checkCode(rel) { return; } for (const m of text.matchAll(INLINE_STYLE_RE)) { - report(rel, lineAt(text, m.index), MSG.inlineStyle); + if (!isCustomPropertyStyle(text, m.index + m[0].length - 1)) { + report(rel, lineAt(text, m.index), MSG.inlineStyle); + } } if (rel === RAW_VALUES_TS) { return; diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index c8fbafcf26..23a8a7915f 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -28,7 +28,9 @@ file holds only the rules that tooling enforces. - `lint:v1` S1–S8: no inline style, raw colour or pixel, `!important`, CSS import outside `app/` or `styles/`, selector outside `.v1`, `prefers-color-scheme` or unprefixed utility (code: class and colour strings). - Pixel escape, CSS only: `/* v1-styles-allow: forced-colors outline */`. + Runtime geometry: `style` may set only custom properties (`"--page-aspect"`) + 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 }`. diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index 6926d0f073..ec5040964c 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -84,6 +84,9 @@ const CASES = parseTable(` 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-custom-props-ok | 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 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-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
; +} From 678efbb9be1c1d66cd2ffc5519c374dcba3f3903 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Thu, 29 Oct 2026 13:54:48 +0530 Subject: [PATCH 32/38] UN-4299 [FIX] Make check-v1-styles.mjs executable to match its shebang pre-commit's check-shebang-scripts-are-executable fails on branches stacked on this one; check-v1.mjs already carries the bit. Co-Authored-By: Claude Fable 5.1 --- frontend/scripts/check-v1-styles.mjs | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 frontend/scripts/check-v1-styles.mjs diff --git a/frontend/scripts/check-v1-styles.mjs b/frontend/scripts/check-v1-styles.mjs old mode 100644 new mode 100755 From 8028a73205ca27f8d8d414a9fead22a409eef3d9 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Thu, 29 Oct 2026 14:37:16 +0530 Subject: [PATCH 33/38] UN-4299 [FIX] Type the custom-property keys a v1 style attribute may set React's CSSProperties has no index signature for "--*" keys, so the object literal rule S1 allows fails TS2353 under strict mode. Augment it under lib/, and typecheck the passing fixture so the augmentation is exercised. Raised by Ali on the viewer stack. Co-Authored-By: Claude Fable 5.1 --- frontend/src/v1/README.md | 7 ++++--- frontend/src/v1/lib/css-custom-properties.d.ts | 14 ++++++++++++++ .../v1-boundary/boundary.fixtures.mjs | 2 +- 3 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 frontend/src/v1/lib/css-custom-properties.d.ts diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 23a8a7915f..2afdf06cca 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -28,9 +28,10 @@ file holds only the rules that tooling enforces. - `lint:v1` S1–S8: no inline style, raw colour or pixel, `!important`, CSS import outside `app/` or `styles/`, selector outside `.v1`, `prefers-color-scheme` or unprefixed utility (code: class and colour strings). - Runtime geometry: `style` may set only custom properties (`"--page-aspect"`) - that classes read (`ui:aspect-(--page-aspect)`); a plain key or a variable - fails. Pixel escape, CSS only: `/* v1-styles-allow: forced-colors outline */`. + 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 }`. 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/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index ec5040964c..77cf293df2 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -86,7 +86,7 @@ const CASES = parseTable(` 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-custom-props-ok | lint: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 From 508e370a57c1a3b2a880ae50d3ae4c6683d24336 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Mon, 2 Nov 2026 12:51:15 +0530 Subject: [PATCH 34/38] UN-4299 [FIX] Close the styling-lint gaps found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-v1-styles.mjs let banned styling through in six ways, each reproduced on this branch before the fix: - S8 accepted `hover:ui:bg-primary` and `md:ui:flex`; Tailwind 4 needs the prefix first and compiles those to nothing. A prefix after a variant is now reported with its own message. - S2 missed named colours as whole JSX values (`fill="red"`) and in CSS declarations (`color: white`); both now reuse the named-colour list, with transparent and currentcolor still allowed. - S3 matched a unit only right before `]`, so `grid-cols-[200px_1fr]` and `w-[calc(100%-13px)]` passed; it now matches anywhere inside the brackets. The CSS pattern gains vh and vw, which the TSX side already rejected. - S8 skipped a single-word class (`className="flex"`) because the class-string heuristic needed two utilities; the value of className and the arguments of cn()/cva()/clsx()/cx() are now always checked. - S6 accepted `.v1 + div` and `.v1 ~ div`, and never looked at nested rules, so `& + div` escaped the root too. A sibling combinator right after the root now fails, at top level and nested. - S1 missed `style = {{ … }}` with spaces around the `=`. Nine fixtures pin the gaps; the passing fixture gains the forms the tightened rules must still allow (prefix-first variants, `.v1 > a + b`, nested `& > a`, a non-utility single word, transparent in CSS). Co-Authored-By: Claude Fable 5.1 --- frontend/scripts/check-v1-styles.mjs | 197 +++++++++++++++--- frontend/src/v1/README.md | 18 +- .../v1-boundary/boundary.fixtures.mjs | 9 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 5 + .../src/v1/styles/extra.css | 10 + .../src/v1/components/ui/Box.tsx | 9 +- .../src/v1/styles/extra.css | 21 +- .../src/v1/styles/extra.css | 4 + .../src/v1/styles/extra.css | 4 + .../src/v1/styles/extra.css | 4 + .../src/v1/components/ui/Box.tsx | 10 + 14 files changed, 265 insertions(+), 35 deletions(-) create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-inline-style-spaced/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-prefix-after-variant/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-named-prop/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary-multi/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-nested-sibling-selector/src/v1/styles/extra.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-colour-named/src/v1/styles/extra.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-viewport-unit/src/v1/styles/extra.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-sibling-selector/src/v1/styles/extra.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-unprefixed-single-class/src/v1/components/ui/Box.tsx diff --git a/frontend/scripts/check-v1-styles.mjs b/frontend/scripts/check-v1-styles.mjs index f58eae4131..3ecfce7f70 100755 --- a/frontend/scripts/check-v1-styles.mjs +++ b/frontend/scripts/check-v1-styles.mjs @@ -9,24 +9,32 @@ * 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]`). - * S3 No raw pixel value: `[13px]` or `[100vh]` arbitrary values, or - * px/rem/em/pt in CSS. + * 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`, except inside @theme, @utility, + * S6 Every CSS selector starts with `.v1` and stays inside it: a sibling + * combinator right after the root (`.v1 + div`, nested `& ~ div`) + * 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. An unprefixed class + * 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: string and template - * literals that look like a class list (see isClassString), so a non-null `!`, - * a hex-looking identifier or a value such as "outline" or "pt-BR" never + * 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), 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)") fails wherever it appears, while - * "Run #1234" does not. Test files and styles/tokens.ts (the raw values, by + * colour (`fill="#ff0000"`, "rgb(1 2 3)", "red") fails wherever it appears, + * while "Run #1234" does not. Test files and styles/tokens.ts (the raw values, by * design) are skipped for them. 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 @@ -67,6 +75,10 @@ const MSG = { 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: @@ -126,17 +138,27 @@ const ALLOWED_NAMED_COLOURS = new Set(["transparent", "currentcolor"]); // An arbitrary value holding one word, after an optional `property:` label: // `text-[red]`, `[color:tomato]`, `bg-[color:navy]`. const ARBITRARY_WORD_RE = /(?:-|(? 0; + } + } + return false; +} + const DEFINITE_CLASS_RES = [ - // `ui:` first or after a variant: `ui:p-2`, `!ui:p-2`, `md:ui:h-8!`. + // `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-])`), @@ -406,11 +444,13 @@ function splitSelectors(prelude) { /* * The selector lists that must be scoped: every block prelude that is not an - * at-rule, not inside an opaque at-rule and not nested in another style rule - * (a nested rule is already scoped by its parent). Grouping at-rules such as - * @layer and @media are transparent. Returns { prelude, index }. + * 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 topLevelSelectors(css) { +function selectorPreludes(css) { const found = []; // Per open block: "opaque", "group" (transparent at-rule) or "rule". const stack = []; @@ -439,9 +479,7 @@ function topLevelSelectors(css) { } else if (at) { stack.push("group"); } else { - if (!stack.includes("rule")) { - found.push({ prelude, index }); - } + found.push({ prelude, index, nested: stack.includes("rule") }); stack.push("rule"); } start = i + 1; @@ -574,6 +612,54 @@ function skipString(text, i) { 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)) { + // 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; + } + } + let depth = 0; + while (i < text.length) { + const c = text[i]; + if (c === '"' || c === "'" || c === "`") { + starts.add(i + 1); + i = skipString(text, i); + continue; + } + if (c === "{" || c === "(" || c === "[") { + depth += 1; + } else if (c === "}" || c === ")" || c === "]") { + depth -= 1; + if (depth === 0) { + break; + } + } + i += 1; + } + } + return starts; +} + function checkCode(rel) { const text = blankComments(fs.readFileSync(path.join(ROOT, rel), "utf8")); const top = rel.split("/")[0]; @@ -603,13 +689,21 @@ function checkCode(rel) { 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(); - if (WHOLE_COLOUR_RES.some((re) => re.test(trimmed))) { + 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 (!isClassString(textById.get(literal.id))) { + if ( + !classSites.has(literal.index) && + !isClassString(textById.get(literal.id)) + ) { continue; } for (const re of RAW_COLOUR_RES) { @@ -630,6 +724,9 @@ function checkCode(rel) { 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}`); } @@ -658,19 +755,63 @@ function checkCss(rel) { 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`), and a nested selector without `&` is an + * implicit descendant. + */ +function leavesRoot(selector, nested) { + if (nested && !selector.startsWith("&")) { + return false; + } + let depth = 0; + let quote = null; + let i = 0; + for (; 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)) { + break; + } + } + const next = selector.slice(i).trimStart()[0]; + return next === "+" || next === "~"; +} + if (!fs.existsSync(ROOT)) { console.error(`check-v1-styles: ${ROOT} does not exist`); process.exit(2); diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 2afdf06cca..8399d50ac3 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -25,9 +25,14 @@ file holds only the rules that tooling enforces. 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 or pixel, `!important`, CSS - import outside `app/` or `styles/`, selector outside `.v1`, - `prefers-color-scheme` or unprefixed utility (code: class and colour strings). +- `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 nested `& ~ x`, counts), `prefers-color-scheme`, or a + utility without `ui:` first (`hover:ui:flex` compiles to nothing). Code: + `className` values and `cn()` arguments always, 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 @@ -36,6 +41,11 @@ file holds only the rules that tooling enforces. - `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 @@ -64,7 +74,7 @@ Every folder may import from itself. Test files (`*.test.ts(x)`, | --- | --- | | `bun run typecheck:v1` | TypeScript-only files, CSS only in `styles/`, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` | | `bun run lint:v1` | Biome on `src/v1/`, then imports (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.ts` | +| `bun run tokens:v1` | Regenerates `styles/tokens.css` from `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/` | The v0 ↔ v1 boundary and the banned imports are Biome rules in `biome.json`, diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index 77cf293df2..5f03afbd31 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -86,20 +86,29 @@ const CASES = parseTable(` 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-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-styles-policy-ok | lint:v1 | - v1-plain-strings-ok | lint:v1 | - `).map(([name, run, fails]) => ({ 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-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-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-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-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..fffef53334 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-nested-sibling-selector/src/v1/styles/extra.css @@ -0,0 +1,10 @@ +.v1 .menu { + & + .demo { + color: var(--foreground); + } + @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 index 11f9727a7a..3efec09ca6 100644 --- 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 @@ -1,11 +1,16 @@ import { cn } from "@v1/lib/cn"; -export function Box() { +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 index 758e08ee11..b4e3202ae8 100644 --- 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 @@ -1,5 +1,24 @@ .v1 .demo, -.v1 .demo::after { +.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); + } + } +} 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-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-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 ( +
+ + +
+ ); +} From 6e7588294ddfdb245d1b95f7711f0918de1a32f5 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Mon, 2 Nov 2026 12:51:15 +0530 Subject: [PATCH 35/38] UN-4299 [FIX] Set color-scheme per mode, bind spacing utilities to the tokens, share the store's storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Each generated `.v1[data-theme=…]` block now starts with the mode's `color-scheme` (dark for dark, light otherwise), declared beside the modes in lib/theme.ts. Without it native controls, select popups, autofill and scrollbars kept the browser's light palette on a dark v1 page, and S7 rules out the prefers-color-scheme fallback. - The spacing utilities read the spacing tokens: `--spacing` is bound to `--space-1` so every bare step (`ui:w-64`, `ui:size-4`) multiplies the token, and the named steps 1 to 8 read their own token, so `ui:p-5` is `var(--space-5)` as the token table says. The compile test asserts both forms. - `hydrate()` defaults to the storage the store writes through, and the provider's duplicate localStorage lookup is gone, so the preference is read from where it was written. Co-Authored-By: Claude Fable 5.1 --- frontend/src/v1/app/theme/ThemeProvider.tsx | 11 +-------- frontend/src/v1/lib/theme.ts | 9 +++++++ frontend/src/v1/lib/tokens.test.ts | 8 +++---- frontend/src/v1/lib/tokens.ts | 11 ++++++--- frontend/src/v1/stores/theme.ts | 6 ++--- frontend/src/v1/styles/index.css | 11 +++++++++ frontend/src/v1/styles/root.test.ts | 26 +++++++++++++++++++++ frontend/src/v1/styles/tokens.css | 3 +++ 8 files changed, 65 insertions(+), 20 deletions(-) diff --git a/frontend/src/v1/app/theme/ThemeProvider.tsx b/frontend/src/v1/app/theme/ThemeProvider.tsx index 09b2f83929..ad6186341e 100644 --- a/frontend/src/v1/app/theme/ThemeProvider.tsx +++ b/frontend/src/v1/app/theme/ThemeProvider.tsx @@ -12,15 +12,6 @@ export type ThemeProviderProps = Readonly<{ const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)"; -/** Reading localStorage can throw in sandboxed documents; treat that as no storage. */ -function readLocalStorage(): Storage | null { - try { - return window.localStorage; - } catch { - return null; - } -} - /** The v1 root: hydrates the theme store, follows the OS scheme, and stamps the mode. */ export function ThemeProvider({ children, @@ -31,7 +22,7 @@ export function ThemeProvider({ // Layout effect: a saved dark or unstract preference must not paint a light frame first. useLayoutEffect(() => { const { hydrate, setSystemPrefersDark } = useThemeStore.getState(); - hydrate(readLocalStorage()); + hydrate(); // from the storage the store writes to, so reads and writes agree if (typeof window.matchMedia !== "function") { return undefined; } diff --git a/frontend/src/v1/lib/theme.ts b/frontend/src/v1/lib/theme.ts index 5c4409ec6e..24e005b5c4 100644 --- a/frontend/src/v1/lib/theme.ts +++ b/frontend/src/v1/lib/theme.ts @@ -6,6 +6,15 @@ 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"; diff --git a/frontend/src/v1/lib/tokens.test.ts b/frontend/src/v1/lib/tokens.test.ts index e082c50ab5..3e4cfe9513 100644 --- a/frontend/src/v1/lib/tokens.test.ts +++ b/frontend/src/v1/lib/tokens.test.ts @@ -21,12 +21,12 @@ const bg: Token = { values: { light: "#fafafa", dark: "#1a1a1a", unstract: "#0f0a1f" }, }; -it("renders one scoped block per mode with every token in each", () => { +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.ts. Do not edit. */\n" + - '.v1[data-theme="light"] {\n --background: #fafafa;\n --radius-sm: 6px;\n}\n' + - '.v1[data-theme="dark"] {\n --background: #1a1a1a;\n --radius-sm: 6px;\n}\n' + - '.v1[data-theme="unstract"] {\n --background: #0f0a1f;\n --radius-sm: 6px;\n}\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', ); }); diff --git a/frontend/src/v1/lib/tokens.ts b/frontend/src/v1/lib/tokens.ts index b29852466a..e5ace90cf1 100644 --- a/frontend/src/v1/lib/tokens.ts +++ b/frontend/src/v1/lib/tokens.ts @@ -1,4 +1,4 @@ -import { THEME_MODES, type ThemeMode } from "./theme"; +import { THEME_COLOR_SCHEMES, THEME_MODES, type ThemeMode } from "./theme"; export type TokenFamily = | "colour" @@ -32,13 +32,18 @@ export function sameInEveryMode( return { light: value, dark: value, unstract: value }; } -/** One `.v1[data-theme=…]` block per mode, each declaring every token in source order. */ +/** + * 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`, ); - return `.v1[data-theme="${mode}"] {\n${declarations.join("")}}\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("")}`; } diff --git a/frontend/src/v1/stores/theme.ts b/frontend/src/v1/stores/theme.ts index 15b0830fda..001c6ce6e6 100644 --- a/frontend/src/v1/stores/theme.ts +++ b/frontend/src/v1/stores/theme.ts @@ -16,8 +16,8 @@ export type ThemeState = { /** 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). */ - hydrate: (storage: Pick | null | undefined) => 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 = { @@ -47,7 +47,7 @@ function themeState({ mode: resolveThemeMode(state.preference, value), })); }, - hydrate: (source) => { + hydrate: (source = storage) => { const preference = readThemePreference(source); set((state) => ({ preference, diff --git a/frontend/src/v1/styles/index.css b/frontend/src/v1/styles/index.css index 9474272455..c019504115 100644 --- a/frontend/src/v1/styles/index.css +++ b/frontend/src/v1/styles/index.css @@ -64,6 +64,17 @@ --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); diff --git a/frontend/src/v1/styles/root.test.ts b/frontend/src/v1/styles/root.test.ts index c1db537f6a..4cee79e0e3 100644 --- a/frontend/src/v1/styles/root.test.ts +++ b/frontend/src/v1/styles/root.test.ts @@ -19,6 +19,9 @@ const CANDIDATES = [ "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", @@ -98,6 +101,29 @@ it("emits only prefixed utilities bound to runtime variables", () => { 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"); diff --git a/frontend/src/v1/styles/tokens.css b/frontend/src/v1/styles/tokens.css index 9f2a0e97a4..43cbae362d 100644 --- a/frontend/src/v1/styles/tokens.css +++ b/frontend/src/v1/styles/tokens.css @@ -1,5 +1,6 @@ /* Generated by scripts/build-v1-tokens.mjs from src/v1/styles/tokens.ts. Do not edit. */ .v1[data-theme="light"] { + color-scheme: light; --background: #fafafa; --foreground: #1a1a1a; --card: #fcfcfc; @@ -77,6 +78,7 @@ --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; @@ -154,6 +156,7 @@ --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; From 622695faa1e48689da94b8f6fe1fa0c676c9e6bf Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Mon, 2 Nov 2026 12:51:15 +0530 Subject: [PATCH 36/38] UN-4299 [FIX] Run the token generator under bun `tokens:v1` used node with a `registerHooks` resolver so the generator could import the TypeScript sources; that API needs Node 22.15 while the engines field allows 20.19, so the script failed on the oldest supported Node. bun, which the frontend toolchain already uses, imports the sources and resolves extensionless imports directly, so the hook is removed. The generated tokens.css is unchanged. Co-Authored-By: Claude Fable 5.1 --- frontend/package.json | 2 +- frontend/scripts/build-v1-tokens.mjs | 19 ++++--------------- frontend/src/v1/styles/tokens.ts | 2 +- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index e401581158..b94558381e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -78,7 +78,7 @@ "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": "node scripts/build-v1-tokens.mjs", + "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 && node scripts/check-v1-styles.mjs", "test:v1": "vitest run --dir src/v1 --passWithNoTests && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs" diff --git a/frontend/scripts/build-v1-tokens.mjs b/frontend/scripts/build-v1-tokens.mjs index 9983ae1844..0c16fad947 100644 --- a/frontend/scripts/build-v1-tokens.mjs +++ b/frontend/scripts/build-v1-tokens.mjs @@ -1,21 +1,10 @@ import fs from "node:fs"; -import { registerHooks } from "node:module"; import path from "node:path"; -// Node's ESM resolver needs file extensions; v1 source imports siblings without -// them (`./theme`), as Vite and tsc expect. Retry such relative imports as `.ts`. -registerHooks({ - resolve(specifier, context, nextResolve) { - try { - return nextResolve(specifier, context); - } catch (error) { - if (specifier.startsWith(".") && !path.extname(specifier)) { - return nextResolve(`${specifier}.ts`, context); - } - throw error; - } - }, -}); +// 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"); diff --git a/frontend/src/v1/styles/tokens.ts b/frontend/src/v1/styles/tokens.ts index 69f44bd195..02d8166e79 100644 --- a/frontend/src/v1/styles/tokens.ts +++ b/frontend/src/v1/styles/tokens.ts @@ -1,6 +1,6 @@ import type { Token } from "@v1/lib/tokens"; -// Local rather than lib's sameInEveryMode: a type-only import is erased when Node strips types for the generator; a value import through @v1 would not resolve. +// Local rather than lib's sameInEveryMode: the generator runs this file under bun, where the type-only import above is erased and a value import through the @v1 alias would not resolve. const same = (v: string) => ({ light: v, dark: v, unstract: v }) as const; /** Every token on the dev-spec board, in board order. Edit here, then run `bun run tokens:v1`. */ From c6c0b21c795645dd068885867d3b2b9fae555518 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Mon, 2 Nov 2026 16:11:39 +0530 Subject: [PATCH 37/38] UN-4299 [FIX] Close the S3 separator gap and two lint false positives from review S3 let a unit followed by `_` through (`ui:grid-cols-[200px_1fr]`, `ui:m-[2rem_auto]`): the lookahead after the unit rejected `\w`, which includes Tailwind's in-bracket separator. It now rejects letters and `-` only, and fixture v1-raw-pixel-arbitrary-separator pins both forms. S8 read every literal inside cva() as a class list, so a normal `defaultVariants: { variant: "outline" }` failed because outline is also a utility name. classSiteLiterals now tracks the property chain inside cva() and treats as classes only the base string, the values under variants and a compound `class`/`className`; a property name, a defaultVariants value or a compoundVariants matcher is a choice, not a class. The passing fixture carries a full cva config; v1-cva-unprefixed-variant proves a variant value is still checked. S6 reported every nested `& + x` as leaving the root, but under `.v1 .menu` it resolves to `.v1 .menu + x`, inside v1. selectorPreludes now records whether every enclosing rule is one compound on the root, and only then does a `&` sibling leave. The nested fixture is rewritten to the leaving forms (directly under `.v1`, and under `&[data-theme="dark"]`), and the passing fixture gains the safe ones. Co-Authored-By: Claude Fable 5.1 --- frontend/scripts/check-v1-styles.mjs | 119 ++++++++++++++---- frontend/src/v1/README.md | 9 +- .../v1-boundary/boundary.fixtures.mjs | 2 + .../src/v1/components/ui/Box.tsx | 6 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/styles/extra.css | 10 +- .../src/v1/components/ui/Box.tsx | 12 +- .../src/v1/styles/extra.css | 12 ++ 8 files changed, 141 insertions(+), 32 deletions(-) create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-cva-unprefixed-variant/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary-separator/src/v1/components/ui/Box.tsx diff --git a/frontend/scripts/check-v1-styles.mjs b/frontend/scripts/check-v1-styles.mjs index 3ecfce7f70..30177cc622 100755 --- a/frontend/scripts/check-v1-styles.mjs +++ b/frontend/scripts/check-v1-styles.mjs @@ -17,8 +17,8 @@ * 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`, nested `& ~ div`) - * selects outside v1. Not checked inside @theme, @utility, + * 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 @@ -29,7 +29,9 @@ * * 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), and any other string or template + * 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 @@ -140,7 +142,8 @@ const ALLOWED_NAMED_COLOURS = new Set(["transparent", "currentcolor"]); const ARBITRARY_WORD_RE = /(?:-|(? 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; } @@ -622,6 +629,7 @@ function skipString(text, i) { 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; @@ -638,16 +646,36 @@ function classSiteLiterals(text) { 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 === "`") { - starts.add(i + 1); - i = skipString(text, i); + 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 (c === "{" || c === "(" || c === "[") { + 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) { @@ -660,6 +688,30 @@ function classSiteLiterals(text) { 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]; @@ -769,11 +821,13 @@ function checkCss(rel) { } at(CSS_IMPORTANT_RE, MSG.important); at(COLOUR_SCHEME_RE, MSG.colourScheme); - for (const { prelude, index, nested } of selectorPreludes(css)) { + for (const { prelude, index, nested, parentIsRoot } of selectorPreludes( + css, + )) { for (const selector of splitSelectors(prelude)) { if (!nested && !/^\.v1(?![\w-])/.test(selector)) { report(rel, lineAt(css, index), `'${selector}': ${MSG.unscoped}`); - } else if (leavesRoot(selector, nested)) { + } else if (leavesRoot(selector, nested, parentIsRoot)) { report(rel, lineAt(css, index), `'${selector}': ${MSG.sibling}`); } } @@ -782,19 +836,39 @@ function checkCss(rel) { /* * True when the selector's subject can sit outside the v1 root: its first - * compound is the root (`.v1…` at top level, `&…` in a nested rule) and the - * combinator right after it is `+` or `~`. Any later combinator keeps the - * subject inside (`.v1 > a + b`), and a nested selector without `&` is an - * implicit descendant. + * compound is the root (`.v1…` at top level; `&…` in a nested rule whose + * enclosing rules are all the root itself) and the combinator right after + * it is `+` or `~`. Any later combinator keeps the subject inside + * (`.v1 > 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) { - if (nested && !selector.startsWith("&")) { +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; - let i = 0; - for (; i < selector.length; i += 1) { + for (let i = 0; i < selector.length; i += 1) { const c = selector[i]; if (quote) { quote = c === quote ? null : quote; @@ -805,11 +879,10 @@ function leavesRoot(selector, nested) { } else if (c === ")" || c === "]") { depth -= 1; } else if (depth === 0 && /[\s>+~]/.test(c)) { - break; + return i; } } - const next = selector.slice(i).trimStart()[0]; - return next === "+" || next === "~"; + return selector.length; } if (!fs.existsSync(ROOT)) { diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index d0563a2500..4aa89a89e6 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -45,10 +45,11 @@ is convention. 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 nested `& ~ x`, counts), `prefers-color-scheme`, or a - utility without `ui:` first (`hover:ui:flex` compiles to nothing). Code: - `className` values and `cn()` arguments always, other strings when they - look like a class list. + `.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 diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index 3d29751411..bb8342b994 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -111,6 +111,7 @@ const CASES = parseTable(` 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 @@ -125,6 +126,7 @@ const CASES = parseTable(` 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]) => ({ 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-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-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 index fffef53334..e432df676c 100644 --- 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 @@ -1,10 +1,12 @@ -.v1 .menu { +.v1 { & + .demo { color: var(--foreground); } - @media (prefers-reduced-motion: reduce) { - & ~ .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 index 3efec09ca6..ba151f7ee5 100644 --- 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 @@ -1,11 +1,21 @@ 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 index b4e3202ae8..6ebb22acc9 100644 --- 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 @@ -22,3 +22,15 @@ } } } +.v1 .menu { + & + .demo { + color: var(--foreground); + } +} +.v1 { + .menu { + & ~ .demo { + color: var(--foreground); + } + } +} From 4667e5d3ceb429aa0ce0c0325d70ade4108cafc9 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Tue, 3 Nov 2026 10:34:12 +0530 Subject: [PATCH 38/38] UN-4299 [MISC] Move the token values to tokens.json and settle the SonarCloud findings SonarCloud's duplication check ignores literal values, so the 75 same-shaped entries in styles/tokens.ts read as copies of each other (91% of the file, 16.8% of the PR's new lines, gate threshold 3%). The values now live in styles/tokens.json, one array per family in board order; tokens.ts keeps the Token type and builds TOKENS from it, deriving cssVar from name (true for every entry) and expanding a single string to all three modes. tokens.css is regenerated (header only) and the lint no longer needs a raw-values exemption for tokens.ts. Also, from Ritwik's pass over the remaining findings: - CSS_UNIT_RE: `\d+(?:\.\d*)?|\.\d+` matches the same strings without the overlapping alternation (javascript:S8786). - The stem escape `s.replace("-", "\\-")` was a no-op: `-` is literal outside a character class. - The file-type dispatch uses `endsWith(".css")` (javascript:S6557). - `.sonarcloud.properties` excludes frontend/test-fixtures/**: the boundary fixtures break the v1 rules on purpose. Co-Authored-By: Claude Fable 5.1 --- .sonarcloud.properties | 4 + frontend/scripts/check-v1-styles.mjs | 24 +- frontend/src/v1/README.md | 4 +- frontend/src/v1/lib/tokens.test.ts | 2 +- frontend/src/v1/lib/tokens.ts | 2 +- frontend/src/v1/styles/tokens.css | 2 +- frontend/src/v1/styles/tokens.json | 547 ++++++++++++++++++++++ frontend/src/v1/styles/tokens.ts | 661 +++------------------------ 8 files changed, 638 insertions(+), 608 deletions(-) create mode 100644 .sonarcloud.properties create mode 100644 frontend/src/v1/styles/tokens.json 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/scripts/check-v1-styles.mjs b/frontend/scripts/check-v1-styles.mjs index 30177cc622..85b9128328 100755 --- a/frontend/scripts/check-v1-styles.mjs +++ b/frontend/scripts/check-v1-styles.mjs @@ -36,8 +36,9 @@ * `!`, 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 and styles/tokens.ts (the raw values, by - * design) are skipped for them. S1 matches only a JSX `style=` attribute. + * 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 @@ -62,9 +63,7 @@ import path from "node:path"; const ROOT = path.join(import.meta.dirname, "..", "src", "v1"); const CODE_EXT = /\.(ts|tsx|mts|cts)$/; -const CSS_EXT = /\.css$/; const TEST_FILE = /\.(test|spec)\.(ts|tsx|mts|cts)$/; -const RAW_VALUES_TS = "styles/tokens.ts"; const GENERATED_CSS = "styles/tokens.css"; // Folders whose files may import a .css file. const CSS_IMPORTERS = new Set(["app", "styles"]); @@ -72,7 +71,7 @@ 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.ts.", + 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.", @@ -146,7 +145,7 @@ const ARBITRARY_WORD_RE = /(?:-|(? - lines - .join(" ") - .split(" ") - .map((s) => s.replace("-", "\\-")) - .join("|"); +// `-` needs no escape outside a character class. +const stems = (lines) => 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)})-)`, @@ -732,9 +727,6 @@ function checkCode(rel) { report(rel, lineAt(text, m.index), MSG.inlineStyle); } } - if (rel === RAW_VALUES_TS) { - return; - } const pieces = literals(text); // A template literal is judged as a whole, across its `${}` expressions. const textById = new Map(); @@ -893,7 +885,7 @@ if (!fs.existsSync(ROOT)) { for (const rel of walk(ROOT)) { if (CODE_EXT.test(rel)) { checkCode(rel); - } else if (CSS_EXT.test(rel)) { + } else if (rel.endsWith(".css")) { checkCss(rel); } } diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 4aa89a89e6..0bf54e99f7 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -36,7 +36,7 @@ is convention. - `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.ts. + 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 @@ -92,7 +92,7 @@ Every folder may import from itself. Test files (`*.test.ts(x)`, | --- | --- | | `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, then the styling policy (S1 to S8 above) | -| `bun run tokens:v1` | Regenerates `styles/tokens.css` from `styles/tokens.ts` (runs under bun, which imports the TypeScript source directly) | +| `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 diff --git a/frontend/src/v1/lib/tokens.test.ts b/frontend/src/v1/lib/tokens.test.ts index 3e4cfe9513..d1fe000ac8 100644 --- a/frontend/src/v1/lib/tokens.test.ts +++ b/frontend/src/v1/lib/tokens.test.ts @@ -23,7 +23,7 @@ const bg: Token = { 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.ts. Do not edit. */\n" + + "/* 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', diff --git a/frontend/src/v1/lib/tokens.ts b/frontend/src/v1/lib/tokens.ts index e5ace90cf1..6b0f010a35 100644 --- a/frontend/src/v1/lib/tokens.ts +++ b/frontend/src/v1/lib/tokens.ts @@ -20,7 +20,7 @@ export type Token = Readonly<{ }>; const CSS_HEADER = - "/* Generated by scripts/build-v1-tokens.mjs from src/v1/styles/tokens.ts. Do not edit. */"; + "/* 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 |"; diff --git a/frontend/src/v1/styles/tokens.css b/frontend/src/v1/styles/tokens.css index 43cbae362d..b48b2fb422 100644 --- a/frontend/src/v1/styles/tokens.css +++ b/frontend/src/v1/styles/tokens.css @@ -1,4 +1,4 @@ -/* Generated by scripts/build-v1-tokens.mjs from src/v1/styles/tokens.ts. Do not edit. */ +/* 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; 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.ts b/frontend/src/v1/styles/tokens.ts index 02d8166e79..26a8662eff 100644 --- a/frontend/src/v1/styles/tokens.ts +++ b/frontend/src/v1/styles/tokens.ts @@ -1,590 +1,77 @@ -import type { Token } from "@v1/lib/tokens"; +import type { ThemeMode } from "@v1/lib/theme"; +import type { Token, TokenFamily } from "@v1/lib/tokens"; +import source from "./tokens.json"; -// Local rather than lib's sameInEveryMode: the generator runs this file under bun, where the type-only import above is erased and a value import through the @v1 alias would not resolve. -const same = (v: string) => ({ light: v, dark: v, unstract: v }) as const; +/** + * 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; +}>; -/** Every token on the dev-spec board, in board order. Edit here, then run `bun run tokens:v1`. */ -export const TOKENS: readonly Token[] = [ - // Colour: the Midnight Bloom collection, minus its palette entries (sky-300, sky-500, openai-green, white). - { - family: "colour", - name: "background", - cssVar: "--background", - utility: "ui:bg-background", - values: { light: "#fafafa", dark: "#1a1a1a", unstract: "#f0f0f0" }, - note: "page surface", - }, - { - family: "colour", - name: "foreground", - cssVar: "--foreground", - utility: "ui:text-foreground", - values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#1a1a1a" }, - note: "default text", - }, - { - family: "colour", - name: "card", - cssVar: "--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", - }, - { - family: "colour", - name: "card-foreground", - cssVar: "--card-foreground", - utility: "ui:text-card-foreground", - values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#1a1a1a" }, - }, - { - family: "colour", - name: "popover", - cssVar: "--popover", - utility: "ui:bg-popover", - values: { light: "#ffffff", dark: "#171717", unstract: "#ffffff" }, - }, - { - family: "colour", - name: "popover-foreground", - cssVar: "--popover-foreground", - utility: "ui:text-popover-foreground", - values: { light: "#19191a", dark: "#fafafa", unstract: "#19191a" }, - }, - { - family: "colour", - name: "primary", - cssVar: "--primary", - utility: "ui:bg-primary", - values: { light: "#6f5cef", dark: "#6f5cef", unstract: "#072b4d" }, - note: "primary buttons, active tabs, selected icon buttons", - }, - { - family: "colour", - name: "primary-foreground", - cssVar: "--primary-foreground", - utility: "ui:text-primary-foreground", - values: { light: "#fafafa", dark: "#fafafa", unstract: "#ffffff" }, - }, - { - family: "colour", - name: "primary-muted", - cssVar: "--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", - }, - { - family: "colour", - name: "secondary", - cssVar: "--secondary", - utility: "ui:bg-secondary", - values: { - light: "#97caf5", - dark: "rgb(255 255 255 / 0.06)", - unstract: "#f0f4f8", - }, - }, - { - family: "colour", - name: "secondary-foreground", - cssVar: "--secondary-foreground", - utility: "ui:text-secondary-foreground", - values: { light: "#19191a", dark: "#fafafa", unstract: "#072b4d" }, - }, - { - family: "colour", - name: "muted", - cssVar: "--muted", - utility: "ui:bg-muted", - values: { light: "#f5f5f5", dark: "#262626", unstract: "#f0f0f0" }, - note: "inset surfaces: table headers, code blocks, dropzones, skeletons", - }, - { - family: "colour", - name: "muted-foreground", - cssVar: "--muted-foreground", - utility: "ui:text-muted-foreground", - values: { light: "#737373", dark: "#a1a1a1", unstract: "#6b7280" }, - note: "secondary text", - }, - { - family: "colour", - name: "accent", - cssVar: "--accent", - utility: "ui:bg-accent", - values: { light: "#f5f5f5", dark: "#2a2a2d", unstract: "#e8eef4" }, - note: "hover surface on rails, menu items and icon buttons", - }, - { - family: "colour", - name: "accent-foreground", - cssVar: "--accent-foreground", - utility: "ui:text-accent-foreground", - values: { light: "#6161cc", dark: "#c1c5fb", unstract: "#072b4d" }, - }, - { - family: "colour", - name: "destructive", - cssVar: "--destructive", - utility: "ui:bg-destructive", - values: { light: "#b41b30", dark: "#e5484d", unstract: "#b41b30" }, - note: "delete actions and failure states; never a warning", - }, - { - family: "colour", - name: "destructive-foreground", - cssVar: "--destructive-foreground", - utility: "ui:text-destructive-foreground", - values: { light: "#ffffff", dark: "#fafafa", unstract: "#ffffff" }, - }, - { - family: "colour", - name: "success", - cssVar: "--success", - utility: "ui:bg-success", - values: { light: "#15803d", dark: "#4ade80", unstract: "#15803d" }, - }, - { - family: "colour", - name: "warning", - cssVar: "--warning", - utility: "ui:bg-warning", - values: { light: "#b45309", dark: "#fbbf24", unstract: "#b45309" }, - }, - { - family: "colour", - name: "border", - cssVar: "--border", - utility: "ui:border-border", - values: { light: "#e5e5e5", dark: "#2e2e2e", unstract: "#e4e7eb" }, - note: "every 1px rule and control outline", - }, - { - family: "colour", - name: "input", - cssVar: "--input", - utility: "ui:border-input", - values: { light: "#d3d3d3", dark: "#343434", unstract: "#d3d8de" }, - }, - { - family: "colour", - name: "ring", - cssVar: "--ring", - utility: "ui:ring-ring", - values: { light: "#6f5cef", dark: "#6f5cef", unstract: "#0e3963" }, - note: "focus outline colour, paired with a 3px ring at 50% alpha", - }, - { - family: "colour", - name: "sidebar", - cssVar: "--sidebar", - utility: "ui:bg-sidebar", - values: { light: "#ffffff", dark: "#171717", unstract: "#0e3963" }, - note: "the rail and both nav bars", - }, - { - family: "colour", - name: "sidebar-foreground", - cssVar: "--sidebar-foreground", - utility: "ui:text-sidebar-foreground", - values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#ffffff" }, - }, - { - family: "colour", - name: "sidebar-accent", - cssVar: "--sidebar-accent", - utility: "ui:bg-sidebar-accent", - values: { light: "#f5f5f5", dark: "#262626", unstract: "#1b5183" }, - }, - { - family: "colour", - name: "sidebar-accent-foreground", - cssVar: "--sidebar-accent-foreground", - utility: "ui:text-sidebar-accent-foreground", - values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#ffffff" }, - }, - { - family: "colour", - name: "sidebar-border", - cssVar: "--sidebar-border", - utility: "ui:border-sidebar-border", - values: { light: "#e5e5e5", dark: "#2e2e2e", unstract: "#17436e" }, - }, - { - family: "colour", - name: "sidebar-muted-foreground", - cssVar: "--sidebar-muted-foreground", - utility: "ui:text-sidebar-muted-foreground", - values: { light: "#737373", dark: "#a1a1aa", unstract: "#a9c3da" }, - }, - { - family: "colour", - name: "header", - cssVar: "--header", - utility: "ui:bg-header", - values: { light: "#fcfcfc", dark: "#0a0a0a", unstract: "#072b4d" }, - }, - { - family: "colour", - name: "header-foreground", - cssVar: "--header-foreground", - utility: "ui:text-header-foreground", - values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#ffffff" }, - }, - // Spacing: one 4pt scale. A value not on this list is a bug. - { - family: "spacing", - name: "space-1", - cssVar: "--space-1", - utility: "ui:p-1", - values: same("4px"), - note: "icon-to-label inside a dense chip", - }, - { - family: "spacing", - name: "space-2", - cssVar: "--space-2", - utility: "ui:p-2", - values: same("8px"), - note: "label to control, stacked text inside a cell", - }, - { - family: "spacing", - name: "space-3", - cssVar: "--space-3", - utility: "ui:p-3", - values: same("12px"), - note: "control to control inside a group", - }, - { - family: "spacing", - name: "space-4", - cssVar: "--space-4", - utility: "ui:p-4", - values: same("16px"), - note: "card gutter, list row rhythm", - }, - { - family: "spacing", - name: "space-5", - cssVar: "--space-5", - utility: "ui:p-5", - values: same("20px"), - note: "card inner padding, compact", - }, - { - family: "spacing", - name: "space-6", - cssVar: "--space-6", - utility: "ui:p-6", - values: same("24px"), - note: "card inner padding, default \u00b7 page padding y", - }, - { - family: "spacing", - name: "space-8", - cssVar: "--space-8", - utility: "ui:p-8", - values: same("32px"), - note: "page padding x, section break", - }, - // Radius - { - family: "radius", - name: "radius-sm", - cssVar: "--radius-sm", - utility: "ui:rounded-sm", - values: same("6px"), - note: "badge, pill, status chip, small icon button", - }, - { - family: "radius", - name: "radius-md", - cssVar: "--radius-md", - utility: "ui:rounded-md", - values: same("8px"), - note: "button, input, select, textarea, menu item", - }, - { - family: "radius", - name: "radius-lg", - cssVar: "--radius-lg", - utility: "ui:rounded-lg", - values: same("10px"), - note: "card, dialog, menu, popover, dropzone", - }, - { - family: "radius", - name: "radius-xl", - cssVar: "--radius-xl", - utility: "ui:rounded-xl", - values: same("14px"), - note: "empty-state icon badge", - }, - // Type: eight roles, each a size, a line height and a weight. - { - family: "type", - name: "type-display-size", - cssVar: "--type-display-size", - utility: "ui:text-display", - values: same("34px"), - }, - { - family: "type", - name: "type-display-line-height", - cssVar: "--type-display-line-height", - utility: "ui:text-display", - values: same("46px"), - }, - { - family: "type", - name: "type-display-weight", - cssVar: "--type-display-weight", - utility: "ui:text-display", - values: same("600"), - }, - { - family: "type", - name: "type-page-title-size", - cssVar: "--type-page-title-size", - utility: "ui:text-page-title", - values: same("20px"), - }, - { - family: "type", - name: "type-page-title-line-height", - cssVar: "--type-page-title-line-height", - utility: "ui:text-page-title", - values: same("28px"), - }, - { - family: "type", - name: "type-page-title-weight", - cssVar: "--type-page-title-weight", - utility: "ui:text-page-title", - values: same("600"), - }, - { - family: "type", - name: "type-section-title-size", - cssVar: "--type-section-title-size", - utility: "ui:text-section-title", - values: same("16px"), - }, - { - family: "type", - name: "type-section-title-line-height", - cssVar: "--type-section-title-line-height", - utility: "ui:text-section-title", - values: same("24px"), - }, - { - family: "type", - name: "type-section-title-weight", - cssVar: "--type-section-title-weight", - utility: "ui:text-section-title", - values: same("600"), - }, - { - family: "type", - name: "type-body-size", - cssVar: "--type-body-size", - utility: "ui:text-body", - values: same("14px"), - }, - { - family: "type", - name: "type-body-line-height", - cssVar: "--type-body-line-height", - utility: "ui:text-body", - values: same("20px"), - }, - { - family: "type", - name: "type-body-weight", - cssVar: "--type-body-weight", - utility: "ui:text-body", - values: same("400"), - }, - { - family: "type", - name: "type-body-small-size", - cssVar: "--type-body-small-size", - utility: "ui:text-body-small", - values: same("13px"), - }, - { - family: "type", - name: "type-body-small-line-height", - cssVar: "--type-body-small-line-height", - utility: "ui:text-body-small", - values: same("18px"), - }, - { - family: "type", - name: "type-body-small-weight", - cssVar: "--type-body-small-weight", - utility: "ui:text-body-small", - values: same("400"), - }, - { - family: "type", - name: "type-caption-size", - cssVar: "--type-caption-size", - utility: "ui:text-caption", - values: same("12px"), - }, - { - family: "type", - name: "type-caption-line-height", - cssVar: "--type-caption-line-height", - utility: "ui:text-caption", - values: same("16px"), - }, - { - family: "type", - name: "type-caption-weight", - cssVar: "--type-caption-weight", - utility: "ui:text-caption", - values: same("400"), - }, - { - family: "type", - name: "type-overline-size", - cssVar: "--type-overline-size", - utility: "ui:text-overline", - values: same("11px"), - note: "Pair with ui:uppercase.", - }, - { - family: "type", - name: "type-overline-line-height", - cssVar: "--type-overline-line-height", - utility: "ui:text-overline", - values: same("16px"), - note: "Pair with ui:uppercase.", - }, - { - family: "type", - name: "type-overline-weight", - cssVar: "--type-overline-weight", - utility: "ui:text-overline", - values: same("600"), - note: "Pair with ui:uppercase.", - }, - { - family: "type", - name: "type-mono-size", - cssVar: "--type-mono-size", - utility: "ui:text-mono", - values: same("12px"), - note: "Pair with ui:font-mono. Reserved for model output, JSON and token counts.", - }, - { - family: "type", - name: "type-mono-line-height", - cssVar: "--type-mono-line-height", - utility: "ui:text-mono", - values: same("22px"), - note: "Pair with ui:font-mono. Reserved for model output, JSON and token counts.", - }, - { - family: "type", - name: "type-mono-weight", - cssVar: "--type-mono-weight", - utility: "ui:text-mono", - values: same("400"), - note: "Pair with ui:font-mono. Reserved for model output, JSON and token counts.", - }, - { - family: "type", - name: "type-overline-tracking", - cssVar: "--type-overline-tracking", - utility: "ui:text-overline", - values: same("0.05em"), - note: "Pair with ui:uppercase.", - }, - // Elevation: one value per step, the same in every mode. - { - family: "elevation", - name: "shadow-sm", - cssVar: "--shadow-sm", - utility: "ui:shadow-sm", - values: same("0 1px 2px 0 rgb(0 0 0 / 0.05)"), - note: "every card at rest", - }, - { - family: "elevation", - name: "shadow-md", - cssVar: "--shadow-md", - utility: "ui:shadow-md", - values: same( - "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", - }, - { - family: "elevation", - name: "shadow-lg", - cssVar: "--shadow-lg", - utility: "ui:shadow-lg", - values: same( - "0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10)", - ), - note: "popovers", - }, - { - family: "elevation", - name: "shadow-xl", - cssVar: "--shadow-xl", - utility: "ui:shadow-xl", - values: same( - "0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10)", - ), - note: "dialogs", - }, - { - family: "elevation", - name: "scrim", - cssVar: "--scrim", - utility: "ui:bg-scrim", - values: same("rgb(0 0 0 / 0.5)"), - note: "behind every dialog, full viewport", - }, - // Focus: the ring colour is the `ring` token; no offset. - { - family: "focus", - name: "focus-ring-width", - cssVar: "--focus-ring-width", - utility: "ui:focus-ring", - values: same("3px"), - }, - { - family: "focus", - name: "focus-ring-alpha", - cssVar: "--focus-ring-alpha", - utility: "ui:focus-ring", - values: same("50%"), - }, - // Font - { - family: "font", - name: "font-sans", - cssVar: "--font-sans", - utility: "ui:font-sans", - values: same( - '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif', - ), - }, - { - family: "font", - name: "font-mono", - cssVar: "--font-mono", - utility: "ui:font-mono", - values: same( - '"Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', - ), - note: "Reserved for model output, JSON and token counts.", - }, +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);