From 0c5bacb570c5fa2c32e5e51a79111fd5cf1f54df Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Mon, 26 Oct 2026 17:51:22 +0530 Subject: [PATCH 01/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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 aca2e0d2432a10af7295a0f2140d1ef2b912423a Mon Sep 17 00:00:00 2001 From: Athul Date: Thu, 29 Oct 2026 22:23:34 +0530 Subject: [PATCH 34/40] UN-4287 [FEAT] Add composeRefs to v1 lib A component that needs its own node and also forwards the caller's ref feeds both through one callback ref. Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/compose-refs.test.ts | 12 ++++++++++++ frontend/src/v1/lib/compose-refs.ts | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 frontend/src/v1/lib/compose-refs.test.ts create mode 100644 frontend/src/v1/lib/compose-refs.ts diff --git a/frontend/src/v1/lib/compose-refs.test.ts b/frontend/src/v1/lib/compose-refs.test.ts new file mode 100644 index 0000000000..3945730380 --- /dev/null +++ b/frontend/src/v1/lib/compose-refs.test.ts @@ -0,0 +1,12 @@ +import { createRef } from "react"; +import { expect, it, vi } from "vitest"; +import { composeRefs } from "./compose-refs"; + +it("sets object refs and calls callback refs, skipping missing ones", () => { + const objectRef = createRef(); + const callbackRef = vi.fn(); + const ref = composeRefs(objectRef, undefined, callbackRef, null); + ref("node"); + expect(objectRef.current).toBe("node"); + expect(callbackRef).toHaveBeenCalledWith("node"); +}); diff --git a/frontend/src/v1/lib/compose-refs.ts b/frontend/src/v1/lib/compose-refs.ts new file mode 100644 index 0000000000..01c6557f0f --- /dev/null +++ b/frontend/src/v1/lib/compose-refs.ts @@ -0,0 +1,16 @@ +import type { Ref, RefCallback } from "react"; + +/** One ref callback that feeds every given ref, for a component that needs its node and also forwards the caller's ref. */ +export function composeRefs( + ...refs: readonly (Ref | undefined)[] +): RefCallback { + return (node) => { + for (const ref of refs) { + if (typeof ref === "function") { + ref(node); + } else if (ref) { + ref.current = node; + } + } + }; +} From 9a254a0c549189818eb380007b3c8c724ee20420 Mon Sep 17 00:00:00 2001 From: Athul Date: Thu, 29 Oct 2026 22:23:46 +0530 Subject: [PATCH 35/40] UN-4287 [FEAT] Add v1 text inputs on one field frame FieldFrame gives every text control the same border, radius, padding and focus ring, with hover, invalid, disabled and read-only states as data attributes. On it: Input (32 tall, leading/trailing icons and prefix/suffix text), Textarea, PromptTextarea (104 minimum, grows to a maximum then scrolls; Enter submits, Shift+Enter breaks the line, an IME composition never submits), SearchField (Escape clears a value and stops; on an empty field it bubbles) and SecretField (masked, Reveal with aria-pressed, Copy writes the real value and reports the outcome to the caller for the toast). Field wires Label, HelperText and ErrorText to the control: the label names it, aria-describedby lists the rendered texts, and an error sets aria-invalid and renders as role=alert. Co-Authored-By: Claude Opus 5.5 --- .../src/v1/components/ui/form/ErrorText.tsx | 19 +++ .../src/v1/components/ui/form/Field.test.tsx | 95 +++++++++++++++ frontend/src/v1/components/ui/form/Field.tsx | 65 ++++++++++ .../src/v1/components/ui/form/FieldAction.tsx | 30 +++++ .../src/v1/components/ui/form/FieldFrame.tsx | 63 ++++++++++ .../src/v1/components/ui/form/HelperText.tsx | 18 +++ .../src/v1/components/ui/form/Input.test.tsx | 58 +++++++++ frontend/src/v1/components/ui/form/Input.tsx | 87 +++++++++++++ frontend/src/v1/components/ui/form/Label.tsx | 19 +++ .../ui/form/PromptTextarea.test.tsx | 77 ++++++++++++ .../v1/components/ui/form/PromptTextarea.tsx | 93 ++++++++++++++ .../components/ui/form/SearchField.test.tsx | 100 +++++++++++++++ .../src/v1/components/ui/form/SearchField.tsx | 115 ++++++++++++++++++ .../components/ui/form/SecretField.test.tsx | 81 ++++++++++++ .../src/v1/components/ui/form/SecretField.tsx | 83 +++++++++++++ .../src/v1/components/ui/form/Textarea.tsx | 55 +++++++++ .../form/__snapshots__/markup.test.tsx.snap | 21 ++++ .../v1/components/ui/form/field-context.ts | 53 ++++++++ frontend/src/v1/components/ui/form/index.ts | 25 ++++ .../src/v1/components/ui/form/keyboard.ts | 10 ++ .../src/v1/components/ui/form/markup.test.tsx | 46 +++++++ 21 files changed, 1213 insertions(+) create mode 100644 frontend/src/v1/components/ui/form/ErrorText.tsx create mode 100644 frontend/src/v1/components/ui/form/Field.test.tsx create mode 100644 frontend/src/v1/components/ui/form/Field.tsx create mode 100644 frontend/src/v1/components/ui/form/FieldAction.tsx create mode 100644 frontend/src/v1/components/ui/form/FieldFrame.tsx create mode 100644 frontend/src/v1/components/ui/form/HelperText.tsx create mode 100644 frontend/src/v1/components/ui/form/Input.test.tsx create mode 100644 frontend/src/v1/components/ui/form/Input.tsx create mode 100644 frontend/src/v1/components/ui/form/Label.tsx create mode 100644 frontend/src/v1/components/ui/form/PromptTextarea.test.tsx create mode 100644 frontend/src/v1/components/ui/form/PromptTextarea.tsx create mode 100644 frontend/src/v1/components/ui/form/SearchField.test.tsx create mode 100644 frontend/src/v1/components/ui/form/SearchField.tsx create mode 100644 frontend/src/v1/components/ui/form/SecretField.test.tsx create mode 100644 frontend/src/v1/components/ui/form/SecretField.tsx create mode 100644 frontend/src/v1/components/ui/form/Textarea.tsx create mode 100644 frontend/src/v1/components/ui/form/__snapshots__/markup.test.tsx.snap create mode 100644 frontend/src/v1/components/ui/form/field-context.ts create mode 100644 frontend/src/v1/components/ui/form/index.ts create mode 100644 frontend/src/v1/components/ui/form/keyboard.ts create mode 100644 frontend/src/v1/components/ui/form/markup.test.tsx diff --git a/frontend/src/v1/components/ui/form/ErrorText.tsx b/frontend/src/v1/components/ui/form/ErrorText.tsx new file mode 100644 index 0000000000..7d58f91e20 --- /dev/null +++ b/frontend/src/v1/components/ui/form/ErrorText.tsx @@ -0,0 +1,19 @@ +import { cn } from "@v1/lib/cn"; +import type { ComponentProps, JSX } from "react"; + +export type ErrorTextProps = ComponentProps<"p">; + +/** A validation message. role="alert" makes screen readers announce it when it is rendered, so render it only while there is an error. */ +export function ErrorText({ + className, + ...props +}: ErrorTextProps): JSX.Element { + return ( +

+ ); +} diff --git a/frontend/src/v1/components/ui/form/Field.test.tsx b/frontend/src/v1/components/ui/form/Field.test.tsx new file mode 100644 index 0000000000..4a916a9295 --- /dev/null +++ b/frontend/src/v1/components/ui/form/Field.test.tsx @@ -0,0 +1,95 @@ +import { render, screen } from "@testing-library/react"; +import { expect, it } from "vitest"; +import { ErrorText } from "./ErrorText"; +import { Field } from "./Field"; +import { Input } from "./Input"; +import { Label } from "./Label"; +import { PromptTextarea } from "./PromptTextarea"; +import { SearchField } from "./SearchField"; +import { SecretField } from "./SecretField"; +import { Textarea } from "./Textarea"; + +it.each([ + ["Input", ], + ["Textarea",

"`; + +exports[`renders the search field markup 1`] = `"
"`; + +exports[`renders the secret field markup 1`] = `"
"`; + +exports[`renders the textarea markup 1`] = `"
"`; diff --git a/frontend/src/v1/components/ui/form/field-context.ts b/frontend/src/v1/components/ui/form/field-context.ts new file mode 100644 index 0000000000..f0ac5ff369 --- /dev/null +++ b/frontend/src/v1/components/ui/form/field-context.ts @@ -0,0 +1,53 @@ +import { createContext, useContext } from "react"; + +export type FieldContextValue = Readonly<{ + controlId: string; + /** Ids of the helper and error text that are rendered, space-separated. */ + describedBy: string | undefined; + invalid: boolean; + disabled: boolean; + readOnly: boolean; +}>; + +export const FieldContext = createContext(null); + +/** The enclosing Field, or null for a control used on its own. */ +export function useFieldContext(): FieldContextValue | null { + return useContext(FieldContext); +} + +export type FieldControlInput = Readonly<{ + id?: string; + "aria-describedby"?: string; + invalid?: boolean; + disabled?: boolean; + readOnly?: boolean; +}>; + +export type FieldControlState = Readonly<{ + id: string | undefined; + "aria-describedby": string | undefined; + "aria-invalid": true | undefined; + invalid: boolean; + disabled: boolean; + readOnly: boolean; +}>; + +function joinIds(...ids: (string | undefined)[]): string | undefined { + const joined = ids.filter(Boolean).join(" "); + return joined === "" ? undefined : joined; +} + +/** Resolves a control's id, description, and state: its own props win, then the enclosing Field's. */ +export function useFieldControl(props: FieldControlInput): FieldControlState { + const field = useFieldContext(); + const invalid = props.invalid ?? field?.invalid ?? false; + return { + id: props.id ?? field?.controlId, + "aria-describedby": joinIds(field?.describedBy, props["aria-describedby"]), + "aria-invalid": invalid ? true : undefined, + invalid, + disabled: props.disabled ?? field?.disabled ?? false, + readOnly: props.readOnly ?? field?.readOnly ?? false, + }; +} diff --git a/frontend/src/v1/components/ui/form/index.ts b/frontend/src/v1/components/ui/form/index.ts new file mode 100644 index 0000000000..a74e9afec3 --- /dev/null +++ b/frontend/src/v1/components/ui/form/index.ts @@ -0,0 +1,25 @@ +export { ErrorText, type ErrorTextProps } from "./ErrorText"; +export { Field, type FieldProps } from "./Field"; +export { FieldAction, type FieldActionProps } from "./FieldAction"; +export { + FieldFrame, + type FieldFrameProps, + fieldControlClass, + fieldFrameVariants, +} from "./FieldFrame"; +export { + type FieldControlState, + useFieldContext, + useFieldControl, +} from "./field-context"; +export { HelperText, type HelperTextProps } from "./HelperText"; +export { Input, type InputProps } from "./Input"; +export { Label, type LabelProps } from "./Label"; +export { PromptTextarea, type PromptTextareaProps } from "./PromptTextarea"; +export { SearchField, type SearchFieldProps } from "./SearchField"; +export { + type CopyResult, + SecretField, + type SecretFieldProps, +} from "./SecretField"; +export { Textarea, type TextareaProps } from "./Textarea"; diff --git a/frontend/src/v1/components/ui/form/keyboard.ts b/frontend/src/v1/components/ui/form/keyboard.ts new file mode 100644 index 0000000000..4fdb8cbbb2 --- /dev/null +++ b/frontend/src/v1/components/ui/form/keyboard.ts @@ -0,0 +1,10 @@ +import type { KeyboardEvent } from "react"; + +/* + * True while an IME composition owns the key. Safari fires the keydown that + * ends a composition after compositionend, with isComposing already false; + * keyCode 229 is the only mark it leaves. + */ +export function isComposing(event: KeyboardEvent): boolean { + return event.nativeEvent.isComposing || event.keyCode === 229; +} diff --git a/frontend/src/v1/components/ui/form/markup.test.tsx b/frontend/src/v1/components/ui/form/markup.test.tsx new file mode 100644 index 0000000000..54f7cf9006 --- /dev/null +++ b/frontend/src/v1/components/ui/form/markup.test.tsx @@ -0,0 +1,46 @@ +import { render } from "@testing-library/react"; +import { Search } from "lucide-react"; +import type { ReactElement } from "react"; +import { expect, it } from "vitest"; +import { Field } from "./Field"; +import { Input } from "./Input"; +import { PromptTextarea } from "./PromptTextarea"; +import { SearchField } from "./SearchField"; +import { SecretField } from "./SecretField"; +import { Textarea } from "./Textarea"; + +// One snapshot per variant and state, so a markup or class change shows in review. +const VARIANTS: [string, ReactElement][] = [ + ["input", ], + [ + "input with adornments", + } + prefix="https://" + suffix=".com" + />, + ], + ["input invalid", ], + ["input disabled", ], + ["input read-only", ], + ["textarea",
"`; +exports[`renders the prompt textarea markup 1`] = `"
"`; -exports[`renders the search field markup 1`] = `"
"`; +exports[`renders the search field markup 1`] = `"
"`; -exports[`renders the secret field markup 1`] = `"
"`; +exports[`renders the secret field markup 1`] = `"
"`; -exports[`renders the textarea markup 1`] = `"
"`; +exports[`renders the textarea markup 1`] = `"
"`; From 9ae418bd343f8a8537edb8ee1bffc37b18075b51 Mon Sep 17 00:00:00 2001 From: Athul Date: Thu, 29 Oct 2026 22:51:23 +0530 Subject: [PATCH 38/40] UN-4287 [FIX] Run React 19 ref cleanups in composeRefs The composed callback now returns a cleanup, so React calls it instead of the callback with null: a ref that returned its own cleanup gets it run, and every other ref is set to null. Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/compose-refs.test.ts | 19 ++++++++++++ frontend/src/v1/lib/compose-refs.ts | 38 +++++++++++++++++++----- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/frontend/src/v1/lib/compose-refs.test.ts b/frontend/src/v1/lib/compose-refs.test.ts index 3945730380..6ee44d87c1 100644 --- a/frontend/src/v1/lib/compose-refs.test.ts +++ b/frontend/src/v1/lib/compose-refs.test.ts @@ -10,3 +10,22 @@ it("sets object refs and calls callback refs, skipping missing ones", () => { expect(objectRef.current).toBe("node"); expect(callbackRef).toHaveBeenCalledWith("node"); }); + +it("runs a ref's own cleanup on detach, and nulls the refs that have none", () => { + const objectRef = createRef(); + const plainCallback = vi.fn(); + const ownCleanup = vi.fn(); + const cleanupRef = vi.fn(() => ownCleanup); + const detach = composeRefs( + objectRef, + plainCallback, + cleanupRef, + )("node"); + expect(typeof detach).toBe("function"); + + (detach as () => void)(); + expect(ownCleanup).toHaveBeenCalledOnce(); + expect(cleanupRef).not.toHaveBeenCalledWith(null); + expect(plainCallback).toHaveBeenLastCalledWith(null); + expect(objectRef.current).toBeNull(); +}); diff --git a/frontend/src/v1/lib/compose-refs.ts b/frontend/src/v1/lib/compose-refs.ts index 01c6557f0f..c0ea17110d 100644 --- a/frontend/src/v1/lib/compose-refs.ts +++ b/frontend/src/v1/lib/compose-refs.ts @@ -1,16 +1,38 @@ import type { Ref, RefCallback } from "react"; -/** One ref callback that feeds every given ref, for a component that needs its node and also forwards the caller's ref. */ +type Cleanup = () => void; + +function setRef(ref: Ref | undefined, node: T | null): Cleanup | null { + if (typeof ref === "function") { + const cleanup = ref(node); + return typeof cleanup === "function" ? cleanup : null; + } + if (ref) { + ref.current = node; + } + return null; +} + +/* + * One ref callback that feeds every given ref, for a component that needs its + * node and also forwards the caller's ref. It returns a cleanup, so React 19 + * calls that instead of the callback with null: a ref that returned its own + * cleanup gets it run, every other ref is set to null. + */ export function composeRefs( ...refs: readonly (Ref | undefined)[] ): RefCallback { return (node) => { - for (const ref of refs) { - if (typeof ref === "function") { - ref(node); - } else if (ref) { - ref.current = node; - } - } + const cleanups = refs.map((ref) => setRef(ref, node)); + return () => { + refs.forEach((ref, index) => { + const cleanup = cleanups[index]; + if (cleanup) { + cleanup(); + } else { + setRef(ref, null); + } + }); + }; }; } From c320695151af2fb46f3dd9be4d37f58c79f909ac Mon Sep 17 00:00:00 2001 From: Athul Date: Thu, 29 Oct 2026 22:51:37 +0530 Subject: [PATCH 39/40] UN-4287 [FIX] Fix the review round 1 findings in the text inputs SearchField: a Radix dialog that skips dismissal for data-escape-clears calls preventDefault in the capture phase, so the clear now ignores a preventDefault made before it; only the caller's own onKeyDown cancels it. The lock reads disabled and read-only from props and Field alike, and a controlled field with no onValueChange lets Escape through. PromptTextarea: Enter does not submit when read-only. Where field-sizing is missing, it re-fits after its form resets and keeps the height automatic while scrollHeight is 0. SecretField: onCopyResult is required and is called once, after the clipboard write, so a throwing handler is not reported as a failed copy. An empty value is not written and reports status empty. Reveal and Copy follow a disabled Field and stay usable when read-only. new-password, the password-manager ignore hints and spellcheck off are set after the props spread. Tests cover each fix, a real Radix Dialog, and the wiring of all five controls to Field. Co-Authored-By: Claude Opus 5.5 --- .../src/v1/components/ui/form/Field.test.tsx | 67 +++++++- .../ui/form/PromptTextarea.test.tsx | 79 +++++++++ .../v1/components/ui/form/PromptTextarea.tsx | 29 ++++ .../components/ui/form/SearchField.test.tsx | 120 ++++++++++++- .../src/v1/components/ui/form/SearchField.tsx | 26 +-- .../components/ui/form/SecretField.test.tsx | 159 +++++++++++++++--- .../src/v1/components/ui/form/SecretField.tsx | 68 +++++--- .../form/__snapshots__/markup.test.tsx.snap | 2 +- .../src/v1/components/ui/form/markup.test.tsx | 10 +- 9 files changed, 484 insertions(+), 76 deletions(-) diff --git a/frontend/src/v1/components/ui/form/Field.test.tsx b/frontend/src/v1/components/ui/form/Field.test.tsx index 4a916a9295..12eea76690 100644 --- a/frontend/src/v1/components/ui/form/Field.test.tsx +++ b/frontend/src/v1/components/ui/form/Field.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from "@testing-library/react"; +import type { JSX } from "react"; import { expect, it } from "vitest"; import { ErrorText } from "./ErrorText"; import { Field } from "./Field"; @@ -9,18 +10,68 @@ import { SearchField } from "./SearchField"; import { SecretField } from "./SecretField"; import { Textarea } from "./Textarea"; -it.each([ - ["Input", ], - ["Textarea",
"`; diff --git a/frontend/src/v1/components/ui/form/markup.test.tsx b/frontend/src/v1/components/ui/form/markup.test.tsx index 54f7cf9006..6bd49e6e0e 100644 --- a/frontend/src/v1/components/ui/form/markup.test.tsx +++ b/frontend/src/v1/components/ui/form/markup.test.tsx @@ -31,7 +31,15 @@ const VARIANTS: [string, ReactElement][] = [ "search field", , ], - ["secret field", ], + [ + "secret field", + undefined} + />, + ], [ "field with helper and error", From e6115794db26be2ad9a3c9220305b5ea92f7ecf0 Mon Sep 17 00:00:00 2001 From: Athul Date: Thu, 29 Oct 2026 22:51:53 +0530 Subject: [PATCH 40/40] UN-4287 [FIX] Test ref forwarding and that a field action never submits Object and callback refs reach the control, and are released on unmount, for the three controls that compose refs. FieldAction inside a form clicks without submitting it. Co-Authored-By: Claude Opus 5.5 --- .../components/ui/form/FieldAction.test.tsx | 22 ++++++++++++ .../src/v1/components/ui/form/refs.test.tsx | 34 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 frontend/src/v1/components/ui/form/FieldAction.test.tsx create mode 100644 frontend/src/v1/components/ui/form/refs.test.tsx diff --git a/frontend/src/v1/components/ui/form/FieldAction.test.tsx b/frontend/src/v1/components/ui/form/FieldAction.test.tsx new file mode 100644 index 0000000000..8d6dc035bd --- /dev/null +++ b/frontend/src/v1/components/ui/form/FieldAction.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { expect, it, vi } from "vitest"; +import { FieldAction } from "./FieldAction"; + +it("is a named button that does not submit its form", async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn((event: { preventDefault: () => void }) => + event.preventDefault(), + ); + const onClick = vi.fn(); + render( +
+ + , + ); + const button = screen.getByRole("button", { name: "Copy" }); + expect(button.getAttribute("title")).toBe("Copy"); + await user.click(button); + expect(onClick).toHaveBeenCalledOnce(); + expect(onSubmit).not.toHaveBeenCalled(); +}); diff --git a/frontend/src/v1/components/ui/form/refs.test.tsx b/frontend/src/v1/components/ui/form/refs.test.tsx new file mode 100644 index 0000000000..a3f2ec6a80 --- /dev/null +++ b/frontend/src/v1/components/ui/form/refs.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { createRef, type JSX, type Ref } from "react"; +import { expect, it, vi } from "vitest"; +import { PromptTextarea } from "./PromptTextarea"; +import { SearchField } from "./SearchField"; +import { SecretField } from "./SecretField"; + +// These three keep their own ref to the node, so the caller's ref goes through composeRefs. +const COMPOSED: [string, (ref: Ref) => JSX.Element][] = [ + ["PromptTextarea", (ref) => ], + ["SearchField", (ref) => ], + [ + "SecretField", + (ref) => ( + undefined} /> + ), + ], +]; + +it.each(COMPOSED)("forwards an object ref to the %s control", (_, make) => { + const ref = createRef(); + const { unmount } = render(make(ref as Ref)); + expect(ref.current).toBe(screen.getByLabelText("C")); + unmount(); + expect(ref.current).toBeNull(); +}); + +it.each(COMPOSED)("forwards a callback ref to the %s control", (_, make) => { + const ref = vi.fn(); + const { unmount } = render(make(ref)); + expect(ref).toHaveBeenCalledWith(screen.getByLabelText("C")); + unmount(); + expect(ref).toHaveBeenLastCalledWith(null); +});