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/.gitignore b/.gitignore index cfd13e388f..4817bafa05 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..df57f0f1a7 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -129,5 +129,77 @@ "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/**", "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/." + } + ] + } + } + }, + "suspicious": { + "noExplicitAny": "error" + } + } + } + }, + { + "includes": ["src/v1/styles/tokens.css"], + "formatter": { "enabled": false } + }, + { + "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/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/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..e401581158 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -77,7 +77,11 @@ "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", + "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 && node scripts/check-v1-styles.mjs", + "test:v1": "vitest run --dir src/v1 --passWithNoTests && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs" }, "browserslist": { "production": [ @@ -93,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/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/scripts/check-v1-styles.mjs b/frontend/scripts/check-v1-styles.mjs new file mode 100755 index 0000000000..f58eae4131 --- /dev/null +++ b/frontend/scripts/check-v1-styles.mjs @@ -0,0 +1,692 @@ +#!/usr/bin/env node +/* + * Styling policy for the v1 frontend (src/v1/). Run by `lint:v1` after + * check-v1.mjs; the rules and their reasons are in src/v1/README.md. + * + * S1 No inline style: a JSX `style` attribute may hold only an object + * literal whose every key is a CSS custom property (`"--page-aspect"`), + * the channel for runtime geometry that `ui:aspect-(--page-aspect)` + * reads. A string, a variable, a spread or a plain key fails. + * S2 No raw colour: hex, rgb()/hsl()/oklch()/oklab()/color(), a + * Tailwind palette utility (`-red-500`, `-white`), or a named colour + * as an arbitrary value (`text-[red]`, `[color:tomato]`). + * 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, + * @custom-variant, @font-face and @keyframes. + * S7 CSS never reads prefers-color-scheme; data-theme sets the mode. + * S8 Every Tailwind utility carries the `ui:` prefix. An unprefixed class + * 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. 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. One escape hatch, for S3 only: a CSS line that + * ends with the comment `/* v1-styles-allow: forced-colors outline *\/` may + * hold a pixel value. It exists for the focus utility's transparent outline, + * which forced-colors mode paints in place of the box-shadow ring. + * + * S8 checks a list of utility stems, not a Tailwind parse: a missing stem is a + * false negative. A bare stem counts only when it is a whole utility (`flex`), + * so a string such as "font" or "top" is not flagged. + * + * blankComments and its helpers are copied from check-v1.mjs, which runs on + * import and so cannot be imported; keep the two in step. As there, JSX text + * is parsed as code, so an apostrophe in it ("don't") opens a string to the + * end of the line; the class-string test keeps such text from being reported. + * + * It always checks the src/v1 beside this script, like + * check-v1.mjs; the fixtures in test-fixtures/v1-boundary/ rely on that. + */ +import fs from "node:fs"; +import path from "node:path"; + +const ROOT = path.join(import.meta.dirname, "..", "src", "v1"); + +const CODE_EXT = /\.(ts|tsx|mts|cts)$/; +const 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. 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.", + 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:. An unprefixed class renders with v0's theme whenever v0 already uses that class name, and silently does nothing otherwise.", +}; + +const PALETTE = + "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose"; +const RAW_COLOUR_RES = [ + /#[0-9a-fA-F]{3,8}(?![\w-])/g, + /(? + lines + .join(" ") + .split(" ") + .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, id } with index the offset of the text's first character + * and id shared by the pieces of one template literal. + * Code outside literals is replaced by spaces, and a `${}` expression ends one + * piece of template text and starts the next (its own literals are yielded + * too), so a match can never span code. + */ +function literals(src) { + const pieces = []; + const stack = []; + // The id of each open template literal, innermost last. + const templates = []; + let nextId = 0; + let piece = null; + let i = 0; + while (i < src.length) { + if (stack.at(-1) === "`") { + piece ??= { text: "", index: i, id: templates.at(-1) }; + const depth = stack.length; + const step = templateStep(src, i, stack); + if (stack.length < depth) { + templates.pop(); + } + if (stack.at(-1) !== "`") { + // The literal closed, or a `${` opened. + pieces.push(piece); + piece = null; + } else { + piece.text += src.slice(i, i + step); + } + i += step; + } else if (src[i] === '"' || src[i] === "'") { + const end = stringEnd(src, i); + const close = src[end - 1] === src[i] ? end - 1 : end; + pieces.push({ + text: src.slice(i + 1, close), + index: i + 1, + id: nextId++, + }); + i = end; + } else { + if (src[i] === "`") { + templates.push(nextId++); + } + codeStep(src[i], stack); + i += 1; + } + } + return pieces.filter((p) => p.text); +} + +// A specifier: a quoted or backtick string with no interpolation. +const SPEC = "[\"'`]([^\"'`$]+)[\"'`]"; +// The import forms check-v1.mjs reads, minus Vite globs. +const IMPORT_RES = [ + new RegExp( + String.raw`\b(?:import|export)\s+(?:type\s+)?(?:[\w$*{}\s,]+?\s+from\s+)?${SPEC}`, + "g", + ), + new RegExp(String.raw`\bimport\s*\(\s*${SPEC}\s*[,)]`, "g"), + new RegExp(String.raw`\brequire\s*\(\s*${SPEC}\s*\)`, "g"), +]; +const CSS_SPEC = /\.css(?:\?.*)?$/; + +function lineAt(text, index) { + return text.slice(0, index).split("\n").length; +} + +/* Every whitespace-separated token in a literal, with its offset in the file. */ +function* tokens({ text, index }) { + for (const m of text.matchAll(/\S+/g)) { + yield { token: m[0], index: index + m.index }; + } +} + +/* A token that looks like a Tailwind utility without the `ui:` prefix. */ +function isUnprefixedUtility(token) { + let rest = token.startsWith("!") ? token.slice(1) : token; + if (rest.startsWith("ui:")) { + return false; + } + for (let m = rest.match(VARIANT_RE); m; m = rest.match(VARIANT_RE)) { + rest = rest.slice(m[0].length); + if (rest.startsWith("ui:")) { + return false; + } + } + return UTILITY_RE.test(rest); +} + +const DEFINITE_CLASS_RES = [ + // `ui:` first or after a variant: `ui:p-2`, `!ui:p-2`, `md:ui:h-8!`. + /(?:^!?|:)ui:/, + /-\[[^\]]*\]/, + new RegExp(String.raw`-(?:${PALETTE})-\d{2,3}(?![\w-])`), +]; + +/* + * Ruling R7-3: S2, S3, S4 and S8 apply only to class strings. A literal is + * one when it holds at least one definite utility (`ui:` prefix, an arbitrary + * value `-[…]`, or a stock palette colour such as `-red-500`) or at least two + * utility-shaped tokens (isUnprefixedUtility). So "outline", "pt-BR", + * "Run #1234" and "Try re-running!" are values, while + * "flex items-center gap-2" is a class list. + */ +function isClassString(text) { + const words = text.split(/\s+/).filter(Boolean); + if (words.some((w) => DEFINITE_CLASS_RES.some((re) => re.test(w)))) { + return true; + } + return words.filter(isUnprefixedUtility).length >= 2; +} + +/* A class token with the `!` important modifier at either end. */ +function isImportantClass(token) { + return /^![\w-[]|[\w\])]!$/.test(token) && /[-:]/.test(token); +} + +// At-rules whose blocks hold theme keys or declarations, not v1 selectors. +const OPAQUE_AT_RULES = new Set([ + "theme", + "utility", + "custom-variant", + "font-face", + "keyframes", +]); + +/* Splits a selector list on the commas outside (), [] and strings. */ +function splitSelectors(prelude) { + const parts = []; + let depth = 0; + let quote = null; + let start = 0; + for (let i = 0; i < prelude.length; i += 1) { + const c = prelude[i]; + if (quote) { + quote = c === quote ? null : quote; + } else if (c === '"' || c === "'") { + quote = c; + } else if (c === "(" || c === "[") { + depth += 1; + } else if (c === ")" || c === "]") { + depth -= 1; + } else if (c === "," && depth === 0) { + parts.push(prelude.slice(start, i)); + start = i + 1; + } + } + parts.push(prelude.slice(start)); + return parts.map((p) => p.trim()); +} + +/* + * The selector lists that must be scoped: every block prelude that is not an + * at-rule, 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}`); +}; + +/* + * 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]; + + // S5 applies to test files too: they would load the CSS the same way. + for (const re of IMPORT_RES) { + for (const m of text.matchAll(re)) { + if (CSS_SPEC.test(m[1]) && !CSS_IMPORTERS.has(top)) { + report(rel, lineAt(text, m.index), MSG.cssImport); + } + } + } + if (TEST_FILE.test(rel)) { + return; + } + for (const m of text.matchAll(INLINE_STYLE_RE)) { + if (!isCustomPropertyStyle(text, m.index + m[0].length - 1)) { + report(rel, lineAt(text, m.index), MSG.inlineStyle); + } + } + if (rel === RAW_VALUES_TS) { + return; + } + const pieces = literals(text); + // A template literal is judged as a whole, across its `${}` expressions. + const textById = new Map(); + for (const { id, text: t } of pieces) { + textById.set(id, `${textById.get(id) ?? ""} ${t}`); + } + for (const literal of pieces) { + const trimmed = literal.text.trim(); + if (WHOLE_COLOUR_RES.some((re) => re.test(trimmed))) { + report(rel, lineAt(text, literal.index), MSG.colour); + continue; + } + 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); + } + } + for (const m of literal.text.matchAll(ARBITRARY_WORD_RE)) { + const word = m[1].toLowerCase(); + if (NAMED_COLOURS.has(word) && !ALLOWED_NAMED_COLOURS.has(word)) { + report(rel, lineAt(text, literal.index + m.index), MSG.colour); + } + } + for (const m of literal.text.matchAll(ARBITRARY_UNIT_RE)) { + report(rel, lineAt(text, literal.index + m.index), MSG.pixel); + } + for (const { token, index } of tokens(literal)) { + if (isImportantClass(token)) { + report(rel, lineAt(text, index), MSG.important); + } + if (isUnprefixedUtility(token)) { + report(rel, lineAt(text, index), `'${token}': ${MSG.unprefixed}`); + } + } + } +} + +function checkCss(rel) { + const source = fs.readFileSync(path.join(ROOT, rel), "utf8"); + const css = blankCssComments(source); + // 1-based numbers of the lines that carry the S3 escape-hatch marker. + const pixelAllowed = new Set( + source + .split("\n") + .flatMap((line, i) => (PIXEL_ALLOW_RE.test(line) ? [i + 1] : [])), + ); + const at = (re, msg, skip = new Set()) => { + for (const m of css.matchAll(re)) { + const line = lineAt(css, m.index); + if (!skip.has(line)) { + report(rel, line, msg); + } + } + }; + if (rel !== GENERATED_CSS) { + for (const re of RAW_COLOUR_RES) { + at(re, MSG.colour); + } + at(CSS_UNIT_RE, MSG.pixel, pixelAllowed); + } + 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/scripts/check-v1.mjs b/frontend/scripts/check-v1.mjs new file mode 100755 index 0000000000..3e842480c4 --- /dev/null +++ b/frontend/scripts/check-v1.mjs @@ -0,0 +1,463 @@ +#!/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 Imports: nothing outside src/v1 and the folder import contract + * (which v1 folder may import which). No PropTypes, no antd `ant-` + * 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 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. + * + * 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 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"); + +const ALL = "*"; +/* + * Folder -> folders it may import from, besides itself. `entry` is + * 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 = { + entry: ALL, + app: ALL, + plugins: ALL, + test: ALL, + pages: [ + "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 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)\.(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 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. + */ +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, 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) { + 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; +} + +/* 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( + 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"), +]; +// 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; + +/* + * 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"; + } + 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; + } + 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; +} + +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() || d.isSymbolicLink()) && !IGNORED.has(d.name)) + .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}`); +}; + +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 (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."); + } + 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 && 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(/(? 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).`); + process.exit(1); +} +console.log("check-v1: ok"); diff --git a/frontend/src/index.css b/frontend/src/index.css index f8a4a554b5..6bfdcd4123 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,6 +3,12 @@ * (UN_SHADCN_IMPL_PLAN P0-10) * ------------------------------------------------------------------ */ @import "tailwindcss"; +/* v1 compiles its own Tailwind root (src/v1/styles/index.css). Keep v0's scan + * out of the v1 tree, the lint scripts and the guardrail fixtures, or class-like + * strings there generate unprefixed utilities in v0's bundle (UN-4299). */ +@source not "./v1"; +@source not "../scripts"; +@source not "../test-fixtures"; @plugin "tailwindcss-animate"; @plugin "@tailwindcss/typography"; diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md new file mode 100644 index 0000000000..2afdf06cca --- /dev/null +++ b/frontend/src/v1/README.md @@ -0,0 +1,72 @@ +# 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 (UN-4378); 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, and no symlinks. Import with `./` within a folder and `@v1/...` across + folders; `../`, `/src/...` and `src/...` are 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/` and are imported as + `@v1/styles/...`, including from inside `styles/`. + +## Styling + +- `ui:` on every utility: an unprefixed class renders with v0's theme whenever + v0 already uses that class name, and silently does nothing otherwise. Merge + with `cn` (`@v1/lib/cn`); `bun run tokens:v1` makes tokens.css from tokens.ts. +- 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). + Runtime geometry: `style` may set only custom properties (`"--page-aspect"`, + typed by `lib/css-custom-properties.d.ts`) that classes read + (`ui:aspect-(--page-aspect)`); a plain key or a variable fails. Pixel + escape, CSS only: `/* v1-styles-allow: forced-colors outline */`. +- Known v0 rules that reach v1 (unlayered, so v1 loses without `!important`): + - `scrollbar-color`, `::-webkit-scrollbar*`: the thumb stays light in dark. + - `[data-radix-popper-content-wrapper] { z-index: 1500 !important }`. + - `[data-sonner-toaster] { pointer-events: auto }`. + +## Folders and what each may import + +Every folder may import from itself. Test files (`*.test.ts(x)`, +`*.spec.ts(x)`) may also import `test/`. + +| Folder | Holds | May import from | +| --- | --- | --- | +| `index.ts` | Public entry; the version switch (UN-4271) will mount v1 through it | 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 | `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/` | +| `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, CSS only in `styles/`, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` | +| `bun run lint:v1` | Biome on `src/v1/`, then imports (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, then the styling policy (S1 to S8 above) | +| `bun run tokens:v1` | Regenerates `styles/tokens.css` from `styles/tokens.ts` | +| `bun run 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`, `scripts/check-v1.mjs` and `scripts/check-v1-styles.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/app/theme/ThemeProvider.test.tsx b/frontend/src/v1/app/theme/ThemeProvider.test.tsx new file mode 100644 index 0000000000..30415c3cc9 --- /dev/null +++ b/frontend/src/v1/app/theme/ThemeProvider.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from "@testing-library/react"; +import { getPortalContainer } from "@v1/lib/portal"; +import { useThemeStore } from "@v1/stores/theme"; +import { act } from "react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { ThemeProvider } from "./ThemeProvider"; + +type Listener = (e: { matches: boolean }) => void; +let listeners: Listener[] = []; +let matches = false; + +beforeEach(() => { + listeners = []; + matches = false; + document.body.innerHTML = ""; + window.localStorage.clear(); + useThemeStore.setState({ + preference: "system", + systemPrefersDark: false, + mode: "light", + }); + vi.stubGlobal("matchMedia", (query: string) => ({ + media: query, + matches, + addEventListener: (_: string, l: Listener) => { + listeners.push(l); + }, + removeEventListener: (_: string, l: Listener) => { + listeners = listeners.filter((x) => x !== l); + }, + })); +}); +afterEach(() => vi.unstubAllGlobals()); + +it("renders the v1 root with the stored mode and syncs the portal container", () => { + window.localStorage.setItem("unstract.v1.theme", "unstract"); + render( + + hi + , + ); + const root = screen.getByText("hi").parentElement as HTMLElement; + expect(root.classList.contains("v1")).toBe(true); + expect(root.dataset.uiVersion).toBe("v1"); + expect(root.dataset.theme).toBe("unstract"); + expect(getPortalContainer().dataset.theme).toBe("unstract"); +}); + +it("follows the system preference while set to system and stops after an explicit choice", () => { + render( + + hi + , + ); + const root = screen.getByText("hi").parentElement as HTMLElement; + // Created before any change, so only the [mode] effect can update it. + const portal = getPortalContainer(); + expect(portal.dataset.theme).toBe("light"); + act(() => { + for (const l of listeners) { + l({ matches: true }); + } + }); + expect(root.dataset.theme).toBe("dark"); + expect(portal.dataset.theme).toBe("dark"); + act(() => { + useThemeStore.getState().setPreference("light"); + }); + expect(portal.dataset.theme).toBe("light"); + act(() => { + for (const l of listeners) { + l({ matches: false }); + } + }); + act(() => { + for (const l of listeners) { + l({ matches: true }); + } + }); + expect(root.dataset.theme).toBe("light"); + expect(portal.dataset.theme).toBe("light"); + expect(getPortalContainer()).toBe(portal); +}); + +it("removes the media listener on unmount", () => { + const { unmount } = render( + + hi + , + ); + expect(listeners).toHaveLength(1); + unmount(); + expect(listeners).toHaveLength(0); +}); diff --git a/frontend/src/v1/app/theme/ThemeProvider.tsx b/frontend/src/v1/app/theme/ThemeProvider.tsx new file mode 100644 index 0000000000..09b2f83929 --- /dev/null +++ b/frontend/src/v1/app/theme/ThemeProvider.tsx @@ -0,0 +1,58 @@ +import "@v1/styles/index.css"; + +import { cn } from "@v1/lib/cn"; +import { applyThemeMode } from "@v1/lib/portal"; +import { selectThemeMode, useThemeStore } from "@v1/stores/theme"; +import { type JSX, type ReactNode, useLayoutEffect } from "react"; + +export type ThemeProviderProps = Readonly<{ + children: ReactNode; + className?: string; +}>; + +const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)"; + +/** 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); + + // 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") { + return undefined; + } + const query = window.matchMedia(DARK_SCHEME_QUERY); + setSystemPrefersDark(query.matches); + const onChange = (event: { matches: boolean }) => { + setSystemPrefersDark(event.matches); + }; + query.addEventListener("change", onChange); + return () => { + query.removeEventListener("change", onChange); + }; + }, []); + + useLayoutEffect(() => { + applyThemeMode(document, mode); + }, [mode]); + + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/v1/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/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..12eea76690 --- /dev/null +++ b/frontend/src/v1/components/ui/form/Field.test.tsx @@ -0,0 +1,146 @@ +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"; +import { Input } from "./Input"; +import { Label } from "./Label"; +import { PromptTextarea } from "./PromptTextarea"; +import { SearchField } from "./SearchField"; +import { SecretField } from "./SecretField"; +import { Textarea } from "./Textarea"; + +/** Every text control, rendered with the given props. */ +const CONTROLS: [string, (props: ControlProps) => JSX.Element][] = [ + ["Input", (p) => ], + ["Textarea", (p) => "`; + +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..6bd49e6e0e --- /dev/null +++ b/frontend/src/v1/components/ui/form/markup.test.tsx @@ -0,0 +1,54 @@ +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",