From 242756708e00fb2b7cb3bca8f67253585662a266 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 10 Oct 2026 12:51:00 +0200 Subject: [PATCH] ci(client): run theme:lint as a per-file ratchet with the missing token rules theme:lint only reported a total and exited 0, skipped whole files by name and never ran in CI. It now counts per file under src/ against scripts/theme-baseline.json, which only shrinks, like lint:rtl: the colour literals, arbitrary colour classes and numeric font sizes it already saw, plus palette classes (bg-gray-100, text-indigo-600, bg-white), raw text sizes (text-sm, text-[13px]) instead of the type tiers, z-index literals above z-50 instead of a step of the layering scale, and reads of the dark_mode setting. The file exemptions are gone; the map, PDF and brand files carry their count in the baseline instead. theme-lint-disable markers stay valid in a comment and are counted against scripts/theme-disable-baseline.json, like rtl-lint-disable in lint:rtl; a new one raises its file's entry by hand, where review sees it. The comment handling lint:rtl already had moves into scripts/lib/source.mjs so both read source the same way. The check runs in the client-quality job. --- .github/workflows/test.yml | 8 + CLAUDE.md | 6 +- client/CLAUDE.md | 4 +- client/package.json | 1 - client/scripts/format-baseline.json | 1 - client/scripts/lib/rtl.mjs | 48 +-- client/scripts/lib/source.mjs | 53 +++ client/scripts/lib/theme.mjs | 202 ++++++++++++ client/scripts/theme-baseline.json | 323 +++++++++++++++++++ client/scripts/theme-disable-baseline.json | 75 +++++ client/scripts/theme-lint.mjs | 101 ++---- client/src/theme/README.md | 8 +- client/tests/unit/scripts/theme-lint.test.ts | 165 ++++++++++ wiki/Development-environment.md | 2 +- 14 files changed, 876 insertions(+), 121 deletions(-) create mode 100644 client/scripts/lib/source.mjs create mode 100644 client/scripts/lib/theme.mjs create mode 100644 client/scripts/theme-baseline.json create mode 100644 client/scripts/theme-disable-baseline.json create mode 100644 client/tests/unit/scripts/theme-lint.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0236439b1..8d7b8b164 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -438,6 +438,14 @@ jobs: # Every translation key src/ names must exist in shared en. run: cd client && npm run lint:i18n-keys + - name: Theme tokens check + # Colour literals, palette classes, raw text sizes, z-index literals + # and reads of the dark_mode setting may hold no more per file than + # scripts/theme-baseline.json allows, and theme-lint-disable markers + # no more than scripts/theme-disable-baseline.json, so new surfaces + # follow the user's scheme, accent and text size (scripts/theme-lint.mjs). + run: cd client && npm run theme:lint + plugin-sdk-quality: name: Plugin SDK Types needs: changes diff --git a/CLAUDE.md b/CLAUDE.md index 74b7bef18..7accd5e24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ cd server && npm run lint:query-api # MikroORM QueryBuilder calls cd server && npm run lint:mcp-zod # inline zod in the MCP tool files may only shrink; fields derive from @trek/shared (CI gate) cd server && npm run lint:service-http # { error, status } results and their caller-side branches may only shrink; services throw DomainError (CI gate) cd server && npm run lint:response-contracts # route handlers without @ResponseContract may only shrink (CI gate) -cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only) +cd client && npm run theme:lint # styling that bypasses appearance tokens may only shrink per file (CI gate) npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests) ``` @@ -100,7 +100,7 @@ Offline-first, with a layered data flow. A Page never owns state directly: - **Page pattern** (enforced by `lint:pages`, spec in `client/src/pages/PATTERN.md`): a `*Page.tsx` is a **wiring container** composing a co-located `use()` hook; it must not call React state/effect/memo hooks itself. - **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `client/src/sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `client/src/store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly. -- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` flags bypasses; suppress intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment. +- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` holds the bypasses per file to a baseline that only shrinks (CI gate); mark intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment, which is counted too. - **Reading direction**: Arabic runs the app right to left, so spacing, insets and alignment that follow the text use the logical forms (`ms-`/`me-`/`ps-`/`pe-`/`start-`/`end-`/`text-start`/`border-s`/`rounded-s`, `marginInlineStart`, `insetInlineEnd`, `textAlign: 'start'`). `lint:rtl` counts the physical ones per file against `client/scripts/rtl-baseline.json`, which may only shrink; geometry that is physical on purpose in new code (maps, measured positions, time axes) carries an `rtl-lint-disable` comment. - **PWA**: `vite-plugin-pwa` + Workbox cache tiles, API and uploads; `prebuild` generates icons. @@ -132,7 +132,7 @@ These principles come out of a full-repo audit and shape all new code: ## Quality gates in CI -`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips` and `lint:i18n-keys`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags. +`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags. - **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`. - **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix). diff --git a/client/CLAUDE.md b/client/CLAUDE.md index eb500422d..d6cc04e89 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -23,7 +23,7 @@ npm run lint:i18n-keys # every translation key src/ names exists in shared en npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage npm run e2e # Playwright (CI runs --project=public --project=app; e2e:report opens the last report) npm run shots # Playwright screenshot project (shots:promote to accept) -npm run theme:lint # theme conformance audit (theme:lint:strict exits 1; local only) +npm run theme:lint # colour literals, palette classes, raw text sizes, z-index literals above z-50 and dark_mode reads may only shrink per file, theme-lint-disable markers too (CI gate; --list shows them, --update lowers both baselines) npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (CI gate) ``` @@ -63,7 +63,7 @@ The offline core is flagship work surrounded by a periphery that ignores it. New - **Rendering security**: never interpolate user content into HTML strings (map popups included — build via DOM + `textContent`, or `escapeHtml` from `@trek/shared`); untrusted markdown gets `rehype-sanitize`; never `rehype-raw` near it. - **No `window` event buses or global mutable `window` state.** No `any` at boundaries: WS payloads and map renderer props get real types. - **Hygiene**: error boundaries around new shells/routes (`components/shared/ErrorBoundary.tsx`); every async `.then(setState)` needs a cancelled flag or `AbortController`; no routine `eslint-disable exhaustive-deps` (a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate. -- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `` styling (see `src/theme/README.md`). `theme:lint` catches literals and `bg-[#...]`-style classes but not named palette classes — review those by hand. +- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `` styling (see `src/theme/README.md`). `theme:lint` counts literals, `bg-[#...]`-style and palette classes, raw `text-sm`- and `text-[13px]`-style sizes, z-index literals and `dark_mode` reads per file against `scripts/theme-baseline.json`. ## Big-picture pieces diff --git a/client/package.json b/client/package.json index 63b5929db..18f3e2f69 100644 --- a/client/package.json +++ b/client/package.json @@ -33,7 +33,6 @@ "lint:i18n-keys": "node scripts/i18n-keys.mjs", "check:gl-split": "node scripts/check-gl-split.mjs", "theme:lint": "node scripts/theme-lint.mjs", - "theme:lint:strict": "node scripts/theme-lint.mjs --strict", "e2e": "playwright test", "shots": "playwright test --project=screenshots", "help:media": "node e2e/help/run.mjs", diff --git a/client/scripts/format-baseline.json b/client/scripts/format-baseline.json index 7212ad661..4738afed2 100644 --- a/client/scripts/format-baseline.json +++ b/client/scripts/format-baseline.json @@ -56,7 +56,6 @@ "scripts/check-page-pattern.mjs", "scripts/generate-icons.mjs", "scripts/patch-maplibre.mjs", - "scripts/theme-lint.mjs", "src/App.test.tsx", "src/App.tsx", "src/App.versionHandover.test.tsx", diff --git a/client/scripts/lib/rtl.mjs b/client/scripts/lib/rtl.mjs index a19d66cf8..617b617a1 100644 --- a/client/scripts/lib/rtl.mjs +++ b/client/scripts/lib/rtl.mjs @@ -2,7 +2,6 @@ * The matching and the comparison behind lint:rtl (scripts/rtl-lint.mjs). */ import { join } from 'node:path'; -import ts from 'typescript'; import { countMap, listFiles, @@ -15,6 +14,7 @@ import { toKey, writeBaseline, } from './ratchet.mjs'; +import { markedInComment, withoutComments } from './source.mjs'; // Tailwind utilities that name a physical side, with any variant prefix // (sm:, hover:, group-hover:) and the negative form (-ml-2). @@ -39,50 +39,6 @@ const CSS = export const DISABLE = 'rtl-lint-disable'; -/** - * The source with its comments blanked out, so prose like "top right" or - * "left out" is no side. TypeScript finds the comments in a .ts/.tsx file, - * which keeps `/*` and `//` inside strings and JSX text where they belong - * (accept="image/*", a URL); a stylesheet only has block comments. - */ -function withoutComments(source, file) { - const chars = source.split(''); - const blank = (pos, end) => { - for (let i = pos; i < end; i++) if (chars[i] !== '\n') chars[i] = ' '; - }; - if (file.endsWith('.css')) { - for (const m of source.matchAll(/\/\*[\s\S]*?\*\//g)) blank(m.index, m.index + m[0].length); - return chars.join(''); - } - const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; - const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind); - const seen = new Set(); - const take = (ranges) => { - for (const r of ranges ?? []) { - if (seen.has(r.pos)) continue; - seen.add(r.pos); - blank(r.pos, r.end); - } - }; - const visit = (node) => { - take(ts.getLeadingCommentRanges(source, node.pos)); - take(ts.getTrailingCommentRanges(source, node.end)); - // JSX text holds no comments, and asking for them there would read `//` in a URL as one. - if (!ts.isJsxText(node)) for (const child of node.getChildren(sf)) visit(child); - }; - visit(sf); - take(ts.getLeadingCommentRanges(source, sf.endOfFileToken.pos)); - return chars.join(''); -} - -/** Whether the line carries the marker inside a comment; in a string or in code it is no marker. */ -function markedInComment(line, code) { - for (let at = line.indexOf(DISABLE); at !== -1; at = line.indexOf(DISABLE, at + 1)) { - if (!code.slice(at, at + DISABLE.length).trim()) return true; - } - return false; -} - /** * One file read once: every physical use as `line: match`, skipping the lines * marked as physical on purpose, and how many lines carry that mark. @@ -95,7 +51,7 @@ export function inspect(source, file) { withoutComments(source, file) .split('\n') .forEach((code, i) => { - if (markedInComment(lines[i], code)) { + if (markedInComment(lines[i], code, DISABLE)) { markers++; return; } diff --git a/client/scripts/lib/source.mjs b/client/scripts/lib/source.mjs new file mode 100644 index 000000000..1c3b57bd4 --- /dev/null +++ b/client/scripts/lib/source.mjs @@ -0,0 +1,53 @@ +/* + * Reading source the way the line ratchets need it: with the comments blanked + * out, and telling a disable marker in a comment from the same text in code. + * Shared by lint:rtl (scripts/lib/rtl.mjs) and theme:lint (scripts/lib/theme.mjs). + */ +import ts from 'typescript'; + +/** + * The source with its comments blanked out, so prose like "top right" or + * "left out" is no side. TypeScript finds the comments in a .ts/.tsx file, + * which keeps `/*` and `//` inside strings and JSX text where they belong + * (accept="image/*", a URL); a stylesheet only has block comments. + */ +export function withoutComments(source, file) { + const chars = source.split(''); + const blank = (pos, end) => { + for (let i = pos; i < end; i++) if (chars[i] !== '\n') chars[i] = ' '; + }; + if (file.endsWith('.css')) { + for (const m of source.matchAll(/\/\*[\s\S]*?\*\//g)) blank(m.index, m.index + m[0].length); + return chars.join(''); + } + const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind); + const seen = new Set(); + const take = (ranges) => { + for (const r of ranges ?? []) { + if (seen.has(r.pos)) continue; + seen.add(r.pos); + blank(r.pos, r.end); + } + }; + const visit = (node) => { + take(ts.getLeadingCommentRanges(source, node.pos)); + take(ts.getTrailingCommentRanges(source, node.end)); + // JSX text holds no comments, and asking for them there would read `//` in a URL as one. + if (!ts.isJsxText(node)) for (const child of node.getChildren(sf)) visit(child); + }; + visit(sf); + take(ts.getLeadingCommentRanges(source, sf.endOfFileToken.pos)); + return chars.join(''); +} + +/** + * Whether line carries marker inside a comment; in a string or in code it is no + * marker. code is the same line from withoutComments. + */ +export function markedInComment(line, code, marker) { + for (let at = line.indexOf(marker); at !== -1; at = line.indexOf(marker, at + 1)) { + if (!code.slice(at, at + marker.length).trim()) return true; + } + return false; +} diff --git a/client/scripts/lib/theme.mjs b/client/scripts/lib/theme.mjs new file mode 100644 index 000000000..c7c2d771e --- /dev/null +++ b/client/scripts/lib/theme.mjs @@ -0,0 +1,202 @@ +/* + * The matching and the comparison behind theme:lint (scripts/theme-lint.mjs). + */ +import { join } from 'node:path'; +import { + countMap, + listFiles, + lowerCounts, + readBaseline, + readText, + reportStale, + staleCounts, + TEST_FILE, + toKey, + writeBaseline, +} from './ratchet.mjs'; +import { markedInComment, withoutComments } from './source.mjs'; + +export const DISABLE = 'theme-lint-disable'; + +// A Tailwind variant prefix (dark:, hover:, md:, group-hover:) and the important mark. +const VARIANT = String.raw`(? { + const found = []; + const add = (m, n) => { + if (Math.abs(Number(n)) > Z_LITERAL_ABOVE) found.push(m.trim()); + }; + for (const m of code.matchAll(new RegExp(`${VARIANT}-?z-(?:\\[(-?\\d+)\\]|(\\d+))(?![\\w-])`, 'g'))) + add(m[0], m[1] ?? m[2]); + for (const m of code.matchAll(/\b(?:zIndex\s*:|z-index\s*:)\s*['"`]?\s*(-?\d+)/g)) add(m[0], m[1]); + return found; + }, + }, + { + name: 'dark-mode-read', + fix: 'the .dark class (useIsDark, dark: variants); only applyAppearance reads the setting', + re: /\??\.dark_mode\b/g, + }, +]; + +const matchesOf = (rule, code) => (rule.match ? rule.match(code) : [...code.matchAll(rule.re)].map((m) => m[0].trim())); + +/** + * One file read once: every hit as `line: rule: match`, skipping the lines + * that carry the marker in a comment, and how many lines carry it. + */ +export function inspect(source, file) { + const hits = []; + let markers = 0; + const lines = source.split('\n'); + withoutComments(source, file) + .split('\n') + .forEach((code, i) => { + if (markedInComment(lines[i], code, DISABLE)) { + markers++; + return; + } + for (const rule of RULES) for (const m of matchesOf(rule, code)) hits.push(`${i + 1}: ${rule.name}: ${m}`); + }); + return { hits, markers }; +} + +/** The hits and markers per file under root/src (tests excluded), keyed by the path from src/. */ +export function scan(root) { + const counts = {}; + const listed = {}; + const markers = {}; + const accept = (key) => /\.tsx?$/.test(key) && !TEST_FILE.test(key); + for (const path of listFiles(root, ['src'], accept)) { + const found = inspect(readText(path), path); + const key = toKey(join(root, 'src'), path); + if (found.hits.length) { + counts[key] = found.hits.length; + listed[key] = found.hits; + } + if (found.markers) markers[key] = found.markers; + } + return { counts, listed, markers }; +} + +const sum = (counts) => Object.values(counts).reduce((a, b) => a + b, 0); + +/** + * Runs the check against theme-baseline.json (hits per file) and + * theme-disable-baseline.json (marked lines per file). Returns the exit code. + */ +export function check({ + root, + baselinePath = join(root, 'scripts/theme-baseline.json'), + disableBaselinePath = join(root, 'scripts/theme-disable-baseline.json'), + update = false, + list = false, + log = console.log, + error = console.error, +}) { + const { counts, listed, markers } = scan(root); + let baseline = readBaseline(baselinePath, countMap); + let allowedMarkers = readBaseline(disableBaselinePath, countMap); + + if (list) { + for (const [file, hits] of Object.entries(listed).sort()) { + log(`${file} (${hits.length}, baseline ${baseline[file] ?? 0})`); + for (const hit of hits) log(` ${hit}`); + } + } + + if (update) { + baseline = lowerCounts(baseline, counts); + allowedMarkers = lowerCounts(allowedMarkers, markers); + writeBaseline(baselinePath, baseline); + writeBaseline(disableBaselinePath, allowedMarkers); + } + + const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0)); + const marked = Object.entries(markers).filter(([file, n]) => n > (allowedMarkers[file] ?? 0)); + const stale = staleCounts(baseline, counts); + const staleMarkers = staleCounts(allowedMarkers, markers); + + for (const [file, n] of grown) { + const rules = [...new Set(listed[file].map((hit) => hit.split(': ')[1]))]; + error(`FAIL ${file}: ${n} styling bypass(es) of the theme tokens, baseline ${baseline[file] ?? 0}.`); + for (const name of rules) error(` ${name}: use ${RULES.find((rule) => rule.name === name).fix}.`); + } + if (grown.length) { + error( + `Run npm run theme:lint -- --list to see each hit. A colour CSS variables cannot reach (map paint, PDF, ` + + `brand) is marked with ${DISABLE} in a comment on its line. See src/theme/README.md.` + ); + } + for (const [file, n] of marked) { + error( + `FAIL ${file}: ${n} line(s) marked ${DISABLE}, scripts/theme-disable-baseline.json allows ` + + `${allowedMarkers[file] ?? 0}. Use a token where one reaches; a surface it cannot reach (map paint, PDF, ` + + 'brand colours) needs a reviewer to raise the entry by hand.' + ); + } + reportStale(stale, { file: 'theme-baseline.json', command: 'theme:lint', root: join(root, 'src'), error }); + reportStale(staleMarkers, { + file: 'theme-disable-baseline.json', + command: 'theme:lint', + root: join(root, 'src'), + error, + }); + log( + `theme: ${sum(counts)} styling bypass(es) in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}; ` + + `${sum(markers)} line(s) marked ${DISABLE}, baseline allows ${sum(allowedMarkers)}` + ); + return grown.length || marked.length || stale.length || staleMarkers.length ? 1 : 0; +} diff --git a/client/scripts/theme-baseline.json b/client/scripts/theme-baseline.json new file mode 100644 index 000000000..62a9b0d5e --- /dev/null +++ b/client/scripts/theme-baseline.json @@ -0,0 +1,323 @@ +{ + "App.tsx": 11, + "components/Admin/AdminPluginsPanel.tsx": 2, + "components/Admin/DefaultUserSettingsTab.tsx": 1, + "components/Atlas/AtlasCountryPlaces.tsx": 6, + "components/BackgroundTasks/BackgroundTasksWidget.tsx": 4, + "components/Budget/BudgetPanel.helpers.ts": 2, + "components/Budget/BudgetPanelMemberChips.tsx": 4, + "components/Budget/CostsPanel.tsx": 37, + "components/Budget/ReceiptPreviewModal.tsx": 6, + "components/Budget/ReceiptScanModal.tsx": 2, + "components/Budget/costsCategories.tsx": 14, + "components/Collab/CollabChat.tsx": 1, + "components/Collab/CollabChatAttachment.tsx": 4, + "components/Collab/CollabChatEmojiPicker.tsx": 1, + "components/Collab/CollabChatReactionBadge.tsx": 1, + "components/Collab/CollabChatReactionMenu.tsx": 1, + "components/Collab/CollabLinks.tsx": 1, + "components/Collab/CollabNotesFilePreviewPortal.tsx": 2, + "components/Collab/CollabPolls.tsx": 3, + "components/Collections/CollectionHero.tsx": 1, + "components/Collections/MSaveToCollectionSheet.tsx": 12, + "components/Collections/StatusBadge.tsx": 4, + "components/Dawarich/DawarichAtlasDialog.tsx": 3, + "components/Files/FileManagerAvatarChip.tsx": 1, + "components/Files/FileManagerImageLightbox.tsx": 8, + "components/Journey/ContributorInviteDialog.tsx": 76, + "components/Journey/JournalBody.tsx": 1, + "components/Journey/JourneyDayDawarich.tsx": 2, + "components/Journey/JourneyDayJump.tsx": 6, + "components/Journey/JourneyDayScrubber.tsx": 3, + "components/Journey/JourneyDetailPageChips.tsx": 2, + "components/Journey/JourneyDetailPageDatePicker.tsx": 43, + "components/Journey/JourneyDetailPageEntryCard.tsx": 73, + "components/Journey/JourneyDetailPageEntryEditor.tsx": 2, + "components/Journey/JourneyDetailPageExpandableStory.tsx": 17, + "components/Journey/JourneyDetailPageGalleryView.tsx": 15, + "components/Journey/JourneyDetailPageMapView.tsx": 50, + "components/Journey/JourneyDetailPagePhotoGrid.tsx": 6, + "components/Journey/JourneyDetailPageProviderPicker.tsx": 163, + "components/Journey/JourneyDetailPageScrollTrigger.tsx": 4, + "components/Journey/JourneyDetailPageVerdictSection.tsx": 51, + "components/Journey/JourneyEntryCover.tsx": 15, + "components/Journey/JourneyMap.tsx": 3, + "components/Journey/JourneyMapGL.tsx": 5, + "components/Journey/JourneyShareSection.tsx": 41, + "components/Journey/MobileEntryView.tsx": 94, + "components/Journey/MobileMapTimeline.tsx": 8, + "components/Journey/PhotoLightbox.tsx": 13, + "components/Journey/VideoPlayer.tsx": 2, + "components/Journey/VideoPlayerLazy.tsx": 1, + "components/Journey/journeyMapPopup.ts": 17, + "components/Journey/moodConfig.ts": 5, + "components/Layout/BottomNav.tsx": 3, + "components/Layout/DemoBanner.tsx": 25, + "components/Layout/InAppNotificationBell.tsx": 10, + "components/Layout/MobileTopBar.tsx": 51, + "components/Layout/Navbar.tsx": 23, + "components/Layout/NewVersionNotice.tsx": 3, + "components/Layout/OfflineBanner.tsx": 2, + "components/Layout/PageSidebar.tsx": 3, + "components/Map/LocationButton.tsx": 1, + "components/Map/MapPluginMarkers.tsx": 2, + "components/Map/MapTogglePill.tsx": 1, + "components/Map/MapView.tsx": 10, + "components/Map/MapViewGL.tsx": 11, + "components/Map/NightPauseTooltip.tsx": 1, + "components/Map/PlaceHoverCard.tsx": 1, + "components/Map/ReservationOverlay.tsx": 5, + "components/Map/RoadtripViaMarkers.tsx": 1, + "components/Map/locationMarkerMapbox.ts": 2, + "components/Map/placePopup.ts": 6, + "components/Map/poiCategories.ts": 7, + "components/Map/ratingBadge.ts": 2, + "components/Map/reservationsMapbox.ts": 1, + "components/Notifications/InAppNotificationItem.tsx": 8, + "components/PDF/TripPDF.tsx": 53, + "components/Packing/PackingListPanelBagModal.tsx": 2, + "components/Packing/PackingListPanelCategoryGroup.tsx": 5, + "components/Packing/PackingListPanelHeader.tsx": 5, + "components/Packing/PackingListPanelItemRow.tsx": 2, + "components/Packing/PackingShareControl.tsx": 3, + "components/Packing/packingPrintDocument.ts": 24, + "components/Planner/AirTrailImportModal.tsx": 4, + "components/Planner/BookingCostsSection.tsx": 1, + "components/Planner/BookingImportModal.tsx": 4, + "components/Planner/DayDetailPanel.tsx": 1, + "components/Planner/IcsSubscribeModal.tsx": 10, + "components/Planner/PlaceInspector.tsx": 1, + "components/Planner/PlacesSidebarMobileDayPicker.tsx": 3, + "components/Planner/SubscribeLinks.tsx": 5, + "components/Planner/SuggestionDropdown.tsx": 1, + "components/Planner/TransitJourneyModal.tsx": 4, + "components/Planner/TransitSearchPanel.tsx": 2, + "components/Planner/TravelerPicker.tsx": 3, + "components/Planner/bookings/BookingsTimeline.tsx": 1, + "components/Plugins/PluginContributions.tsx": 5, + "components/Plugins/PluginWidgets.tsx": 1, + "components/Roadtrip/AnchoredPopover.tsx": 1, + "components/Roadtrip/ServiceStopSection.tsx": 1, + "components/Roadtrip/serviceMarker.ts": 1, + "components/Settings/useAppearanceEditor.ts": 5, + "components/Studio/BookSheetsView.tsx": 2, + "components/Studio/SpreadView.tsx": 3, + "components/Studio/StudioElementsPanel.tsx": 8, + "components/Studio/StudioInspector.tsx": 2, + "components/Studio/StudioShell.tsx": 2, + "components/Studio/StudioSidebar.tsx": 2, + "components/Studio/StudioTravelPanel.tsx": 1, + "components/Studio/TravelElements.tsx": 5, + "components/Studio/printSheets.ts": 5, + "components/Studio/templates.ts": 3, + "components/SystemNotices/ReleaseNoticeVisuals.tsx": 4, + "components/SystemNotices/SystemNoticeBanner.tsx": 28, + "components/SystemNotices/SystemNoticeModal.tsx": 107, + "components/Todo/TodoListPanel.tsx": 6, + "components/Todo/todoListModel.ts": 3, + "components/Trips/TripFormModal.tsx": 9, + "components/Trips/TripMembersModal.tsx": 6, + "components/Vacay/VacayBadge.tsx": 3, + "components/Vacay/VacayCalendar.tsx": 20, + "components/Vacay/VacayMonthCard.tsx": 5, + "components/Vacay/VacayPersons.tsx": 4, + "components/Vacay/VacaySettings.tsx": 27, + "components/Vacay/VacaySharedCalendars.tsx": 6, + "components/Vacay/VacayStats.tsx": 14, + "components/Weather/WeatherWidget.tsx": 4, + "components/shared/ConfirmDialog.tsx": 8, + "components/shared/ContextMenu.tsx": 1, + "components/shared/CopyTripDialog.tsx": 12, + "components/shared/CustomDateTimePicker.tsx": 1, + "components/shared/CustomSelect.tsx": 1, + "components/shared/CustomTimePicker.tsx": 1, + "components/shared/EmptyState.tsx": 1, + "components/shared/ErrorBoundary.tsx": 2, + "components/shared/Modal.tsx": 3, + "components/shared/NavigationMenu.tsx": 1, + "components/shared/NoteColorPicker.tsx": 2, + "components/shared/PlaceAvatarUpload.tsx": 3, + "components/shared/PublicLanguagePicker.tsx": 3, + "components/shared/Skeleton.tsx": 2, + "components/shared/Spinner.tsx": 2, + "components/shared/StarRating.tsx": 1, + "components/shared/Toast.tsx": 8, + "components/shared/Tooltip.tsx": 1, + "components/shared/TrackColorPicker.tsx": 2, + "components/shared/TripLoadingSplash.tsx": 10, + "mobile/components/MBadge.tsx": 2, + "mobile/components/MBottomNav.tsx": 2, + "mobile/components/MChip.tsx": 2, + "mobile/components/MDayImpactList.tsx": 4, + "mobile/components/MListRow.tsx": 1, + "mobile/components/MSegmented.tsx": 2, + "mobile/components/MSheet.tsx": 1, + "mobile/components/MStorageTestResult.tsx": 3, + "mobile/components/MToastHost.tsx": 2, + "mobile/components/MUserPickerList.tsx": 1, + "mobile/screens/admin/MAdmin.tsx": 9, + "mobile/screens/admin/MAdminAddonManager.tsx": 26, + "mobile/screens/admin/MAdminAuditLogPanel.tsx": 15, + "mobile/screens/admin/MAdminBackupPanel.tsx": 8, + "mobile/screens/admin/MAdminCategoryManager.tsx": 8, + "mobile/screens/admin/MAdminDefaultUserSettings.tsx": 5, + "mobile/screens/admin/MAdminDevNotificationsPanel.tsx": 6, + "mobile/screens/admin/MAdminGitHubPanel.tsx": 30, + "mobile/screens/admin/MAdminMcpTokensPanel.tsx": 14, + "mobile/screens/admin/MAdminNotifyMatrix.tsx": 5, + "mobile/screens/admin/MAdminPackingTemplateManager.tsx": 8, + "mobile/screens/admin/MAdminPermissionsPanel.tsx": 7, + "mobile/screens/admin/MAdminPluginsPanel.tsx": 129, + "mobile/screens/admin/MAdminSettingsSection.tsx": 10, + "mobile/screens/admin/MAdminSheets.tsx": 11, + "mobile/screens/admin/MAdminStoragePanel.tsx": 42, + "mobile/screens/admin/MAdminUi.tsx": 10, + "mobile/screens/admin/MAdminUsersSection.tsx": 14, + "mobile/screens/admin/MApiProviderBlocks.tsx": 10, + "mobile/screens/atlas/MAtlas.tsx": 7, + "mobile/screens/atlas/MAtlasBucketSheet.tsx": 16, + "mobile/screens/atlas/MAtlasCountryPopup.tsx": 11, + "mobile/screens/atlas/MAtlasSearch.tsx": 5, + "mobile/screens/atlas/MAtlasStatsCard.tsx": 3, + "mobile/screens/collections/MCollAddSheet.tsx": 7, + "mobile/screens/collections/MCollCategoryPicker.tsx": 2, + "mobile/screens/collections/MCollEditSheet.tsx": 5, + "mobile/screens/collections/MCollLabelsSheet.tsx": 6, + "mobile/screens/collections/MCollLinksEditor.tsx": 2, + "mobile/screens/collections/MCollListPickerSheet.tsx": 3, + "mobile/screens/collections/MCollPlaceRow.tsx": 3, + "mobile/screens/collections/MCollPlaceSheet.tsx": 11, + "mobile/screens/collections/MCollShareSheet.tsx": 14, + "mobile/screens/collections/MCollSheetKit.tsx": 6, + "mobile/screens/collections/MCollTripPickerSheet.tsx": 4, + "mobile/screens/collections/MCollections.tsx": 21, + "mobile/screens/dashboard/MDashWidgets.tsx": 22, + "mobile/screens/dashboard/MDashboard.tsx": 36, + "mobile/screens/dashboard/MNewTripSheet.tsx": 21, + "mobile/screens/dashboard/MUserMenu.tsx": 7, + "mobile/screens/journey/MJourney.tsx": 10, + "mobile/screens/journey/MJourneyCreateSheet.tsx": 10, + "mobile/screens/journey/MJourneyDetail.tsx": 6, + "mobile/screens/journey/MJourneyEntrySheet.tsx": 52, + "mobile/screens/journey/MJourneySettingsSheet.tsx": 32, + "mobile/screens/journey/mobileJourneyMeta.ts": 4, + "mobile/screens/notifications/MNotificationRow.tsx": 4, + "mobile/screens/notifications/MNotifications.tsx": 3, + "mobile/screens/settings/MAirTrailConnectionSection.tsx": 2, + "mobile/screens/settings/MConfirmSheet.tsx": 2, + "mobile/screens/settings/MDawarichConnectionSection.tsx": 5, + "mobile/screens/settings/MMobileDashOrder.tsx": 2, + "mobile/screens/settings/MMobileNavCustomizer.tsx": 8, + "mobile/screens/settings/MPhotoProvidersSection.tsx": 2, + "mobile/screens/settings/MScopeGroupPicker.tsx": 5, + "mobile/screens/settings/MSetPickerSheet.tsx": 2, + "mobile/screens/settings/MSettings.tsx": 2, + "mobile/screens/settings/MSettingsAbout.tsx": 5, + "mobile/screens/settings/MSettingsAccount.tsx": 21, + "mobile/screens/settings/MSettingsAppearance.tsx": 12, + "mobile/screens/settings/MSettingsMap.tsx": 5, + "mobile/screens/settings/MSettingsMcp.tsx": 30, + "mobile/screens/settings/MSettingsNotifications.tsx": 5, + "mobile/screens/settings/MSettingsOffline.tsx": 18, + "mobile/screens/settings/MSettingsPlugins.tsx": 14, + "mobile/screens/settings/MSettingsUi.tsx": 11, + "mobile/screens/settings/MWebPushCard.tsx": 1, + "mobile/screens/trip/MTripLoadingSplash.tsx": 2, + "mobile/screens/trip/MTripShell.tsx": 9, + "mobile/screens/trip/places/MPlacesBrowser.tsx": 10, + "mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 4, + "mobile/screens/trip/places/MPlacesFilterControls.tsx": 1, + "mobile/screens/trip/places/MPlacesFilterSheet.tsx": 3, + "mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 7, + "mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": 1, + "mobile/screens/trip/places/MToursSelectionList.tsx": 9, + "mobile/screens/trip/plan/MPlanTimeline.tsx": 14, + "mobile/screens/trip/plan/MPlanTimelineRows.tsx": 26, + "mobile/screens/trip/roadtrip/MRoadtripRows.tsx": 31, + "mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 17, + "mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 8, + "mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 2, + "mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 7, + "mobile/screens/trip/roadtrip/MRtDraftSheet.tsx": 5, + "mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": 9, + "mobile/screens/trip/roadtrip/MRtKindSheet.tsx": 3, + "mobile/screens/trip/roadtrip/MRtStaySheet.tsx": 17, + "mobile/screens/trip/roadtrip/MRtStopSheet.tsx": 12, + "mobile/screens/trip/sheets/ImpFileStep.tsx": 12, + "mobile/screens/trip/sheets/ImpListStep.tsx": 3, + "mobile/screens/trip/sheets/MAccommodationSheet.tsx": 10, + "mobile/screens/trip/sheets/MBookingFields.tsx": 6, + "mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": 5, + "mobile/screens/trip/sheets/MCostSheet.tsx": 33, + "mobile/screens/trip/sheets/MDaySheet.tsx": 22, + "mobile/screens/trip/sheets/MDaysSheet.tsx": 4, + "mobile/screens/trip/sheets/MExportSheet.tsx": 2, + "mobile/screens/trip/sheets/MImportSheet.tsx": 2, + "mobile/screens/trip/sheets/MLinkedCosts.tsx": 9, + "mobile/screens/trip/sheets/MMehrSheet.tsx": 3, + "mobile/screens/trip/sheets/MNoteSheet.tsx": 1, + "mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2, + "mobile/screens/trip/sheets/MPlaceSheet.tsx": 27, + "mobile/screens/trip/sheets/MReservationSheet.tsx": 2, + "mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9, + "mobile/screens/trip/sheets/MTransportSheet.tsx": 12, + "mobile/screens/trip/sheets/MTripSheetUi.tsx": 5, + "mobile/screens/trip/sheets/MTripSheets.tsx": 2, + "mobile/screens/trip/sheets/PlCategoryPicker.tsx": 2, + "mobile/screens/trip/sheets/PlFileAttach.tsx": 7, + "mobile/screens/trip/sheets/PlPlaceSearch.tsx": 6, + "mobile/screens/trip/sheets/PlSheetChrome.tsx": 7, + "mobile/screens/trip/sheets/PlTimeFields.tsx": 3, + "mobile/screens/trip/tabs/MBagsSheet.tsx": 13, + "mobile/screens/trip/tabs/MBookingsTab.tsx": 3, + "mobile/screens/trip/tabs/MCollabChat.tsx": 22, + "mobile/screens/trip/tabs/MCollabNotes.tsx": 19, + "mobile/screens/trip/tabs/MCollabPolls.tsx": 18, + "mobile/screens/trip/tabs/MCollabTab.tsx": 1, + "mobile/screens/trip/tabs/MCostsTab.tsx": 71, + "mobile/screens/trip/tabs/MDocSyncSheet.tsx": 43, + "mobile/screens/trip/tabs/MFileLightbox.tsx": 12, + "mobile/screens/trip/tabs/MFileLinkSheet.tsx": 2, + "mobile/screens/trip/tabs/MFileMenuSheet.tsx": 1, + "mobile/screens/trip/tabs/MFileTrashSheet.tsx": 4, + "mobile/screens/trip/tabs/MFilesTab.tsx": 12, + "mobile/screens/trip/tabs/MPackItemSheet.tsx": 7, + "mobile/screens/trip/tabs/MPackingImportSheet.tsx": 3, + "mobile/screens/trip/tabs/MPackingListTab.tsx": 32, + "mobile/screens/trip/tabs/MReservationCardParts.tsx": 6, + "mobile/screens/trip/tabs/MTaskSheet.tsx": 6, + "mobile/screens/trip/tabs/MTodoListTab.tsx": 15, + "mobile/screens/trip/tabs/MTransportsTab.tsx": 2, + "mobile/screens/trip/tabs/filesModel.ts": 5, + "mobile/screens/trip/tabs/tabChrome.tsx": 12, + "mobile/screens/vacay/MVacay.tsx": 23, + "mobile/screens/vacay/MVacayInviteSheet.tsx": 9, + "mobile/screens/vacay/MVacayMonth.tsx": 3, + "mobile/screens/vacay/MVacaySettingsSheet.tsx": 36, + "mobile/screens/vacay/MVacayShareSheet.tsx": 11, + "mobile/screens/vacay/vacayDayModel.ts": 1, + "pages/AtlasPage.tsx": 32, + "pages/DashboardPage.tsx": 1, + "pages/FilesPage.tsx": 11, + "pages/ForgotPasswordPage.tsx": 18, + "pages/HelpPage.tsx": 21, + "pages/InAppNotificationsPage.tsx": 11, + "pages/JoinTripPage.tsx": 4, + "pages/JourneyDetailPage.tsx": 53, + "pages/JourneyPage.tsx": 64, + "pages/JourneyPublicPage.tsx": 113, + "pages/LoginPage.tsx": 58, + "pages/OAuthAuthorizePage.tsx": 33, + "pages/PluginPage.tsx": 1, + "pages/ResetPasswordPage.tsx": 23, + "pages/TripPlannerPage.tsx": 7, + "pages/VacayPage.tsx": 13, + "pages/atlas/useAtlas.ts": 2, + "pages/collections/collectionsModel.ts": 1, + "pages/collections/useCollections.ts": 1, + "pages/sharedTrip/SharedChrome.tsx": 2, + "pages/sharedTrip/SharedMap.tsx": 1, + "pages/tripPlanner/ListsContainer.tsx": 1, + "pages/tripPlanner/PlanMapStage.tsx": 1, + "utils/touchDragBridge.ts": 1 +} diff --git a/client/scripts/theme-disable-baseline.json b/client/scripts/theme-disable-baseline.json new file mode 100644 index 000000000..de8d283c1 --- /dev/null +++ b/client/scripts/theme-disable-baseline.json @@ -0,0 +1,75 @@ +{ + "components/Admin/GitHubPanel.tsx": 3, + "components/Admin/PluginPoiCategoryList.tsx": 1, + "components/Admin/useCategoryAdmin.ts": 1, + "components/Budget/CostsPanel.tsx": 1, + "components/Budget/CostsToolbar.tsx": 1, + "components/Collab/CollabNotesFilePreviewPortal.tsx": 3, + "components/Collections/AddPlaceToCollectionModal.tsx": 1, + "components/Collections/BulkAssignLabelModal.tsx": 1, + "components/Collections/CollectionPicker.tsx": 1, + "components/Collections/ImportCollectionModal.tsx": 1, + "components/Collections/MoveToListModal.tsx": 1, + "components/Collections/SaveTripPlacesToListModal.tsx": 1, + "components/Dawarich/DawarichAtlasSidePanel.tsx": 1, + "components/Files/FileManagerImageLightbox.tsx": 1, + "components/Files/FileManagerRow.tsx": 3, + "components/Files/FileManagerToolbar.tsx": 1, + "components/Help/HelpNav.tsx": 1, + "components/Journey/journeyPhotoLayer.ts": 2, + "components/Map/DawarichTrailLayer.tsx": 1, + "components/Map/dawarichTrail.ts": 10, + "components/Map/useDawarichTrailGL.ts": 1, + "components/Packing/PackingListPanelItemRow.tsx": 1, + "components/Planner/DayReorderPopup.tsx": 1, + "components/Planner/bookings/bookingsModel.ts": 1, + "components/Planner/bookings/transitParts.tsx": 1, + "components/Planner/usePlaceForm.ts": 1, + "components/Roadtrip/RangeStrip.tsx": 1, + "components/Roadtrip/RoadtripAlternativesBar.tsx": 1, + "components/Roadtrip/RoadtripCategoryPicker.tsx": 2, + "components/Roadtrip/RoadtripCorridorPanel.tsx": 3, + "components/Roadtrip/RoadtripSidebarStopRows.tsx": 4, + "components/Roadtrip/RoadtripStopPopup.tsx": 1, + "components/Roadtrip/RoadtripTrackModal.tsx": 1, + "components/Roadtrip/StopKindPicker.tsx": 2, + "components/Roadtrip/dayColors.ts": 8, + "components/Roadtrip/nightBookend.ts": 1, + "components/Roadtrip/stopKinds.ts": 1, + "components/Settings/AboutTab.tsx": 3, + "components/Settings/AccountTab.tsx": 2, + "components/Settings/AppearanceSettingsTab.tsx": 1, + "components/Settings/PasskeysSection.tsx": 1, + "components/Studio/BookSheetsView.tsx": 1, + "components/Studio/SpreadView.tsx": 2, + "components/Studio/StudioElementsPanel.tsx": 2, + "components/Studio/StudioSwatches.tsx": 1, + "components/Studio/TravelElements.tsx": 6, + "components/Studio/autoLayout.ts": 50, + "components/Studio/peerColour.ts": 1, + "components/Studio/printSheets.ts": 4, + "components/Trips/TripMemberAvatar.tsx": 2, + "components/Vacay/VacayPersons.tsx": 1, + "components/shared/DialogShell.tsx": 2, + "components/shared/PasswordChecklist.tsx": 2, + "mobile/components/MSheet.tsx": 1, + "mobile/screens/atlas/MAtlas.tsx": 1, + "mobile/screens/atlas/MAtlasCountryPopup.tsx": 2, + "mobile/screens/atlas/MAtlasSearch.tsx": 1, + "mobile/screens/collections/MCollEditSheet.tsx": 2, + "mobile/screens/collections/MCollPlaceSheet.tsx": 2, + "mobile/screens/collections/MCollShareSheet.tsx": 1, + "mobile/screens/collections/collectionsMobileModel.ts": 15, + "mobile/screens/dashboard/MDashboard.tsx": 3, + "mobile/screens/journey/MJourney.tsx": 2, + "mobile/screens/trip/roadtrip/MRoadtripRows.tsx": 2, + "mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 1, + "mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 1, + "mobile/screens/trip/roadtrip/MRtKindSheet.tsx": 1, + "mobile/screens/trip/roadtrip/MRtStopSheet.tsx": 1, + "mobile/screens/trip/sheets/MCostSheet.tsx": 1, + "pages/AtlasPage.tsx": 1, + "pages/admin/AdminUserModals.tsx": 1, + "pages/sharedTrip/SharedChrome.tsx": 4, + "pages/sharedTrip/SharedMap.tsx": 2 +} diff --git a/client/scripts/theme-lint.mjs b/client/scripts/theme-lint.mjs index fa980454e..1a7a493b5 100644 --- a/client/scripts/theme-lint.mjs +++ b/client/scripts/theme-lint.mjs @@ -1,73 +1,42 @@ #!/usr/bin/env node /* - * theme:lint — guards the appearance token system. + * theme:lint: styling that bypasses the appearance tokens may only go down. * - * Flags styling that bypasses the design tokens and therefore won't follow a - * user's chosen scheme / transparency / text-size: - * - inline color literals (color: '#111', background: 'rgba(...)', boxShadow: '...rgba...') - * - inline numeric fontSize (fontSize: 13) - * - arbitrary-value Tailwind color classes (bg-[#..], text-[rgba(..)]) + * A user picks a colour scheme, an accent, transparency and a text size, and + * applyAppearance() turns that into CSS variables. A colour literal, a palette + * class, a raw text size or a z-index of its own never hears about any of it, + * so the surface stays light in a dark scheme, keeps indigo under a red + * accent, or ignores the text size setting. The contract is in + * src/theme/README.md; this counts what of it source shows, per file under + * src/ (tests excluded): * - * ALLOWED (never flagged): var(--token) inline styles, bg-[var(--..)] classes, - * and genuinely dynamic values (data-driven colors, computed sizes/positions). + * arbitrary-color bg-[#..], text-[rgba(..)] and the like + * inline-color color: '#111', background: 'rgba(...)' in a style + * inline-font-size fontSize: 13 + * palette-class bg-gray-100, text-indigo-600, bg-white, text-black + * raw-text-size text-xs, text-sm ... text-9xl instead of the type tiers + * z-index-literal z-[9999], zIndex: 10000 above Tailwind's z-50, instead of + * a step of the layering scale (--z-bar ... --z-toast) + * dark-mode-read reading settings.dark_mode instead of the .dark class * - * Mirrors the i18n:parity gate. Default mode reports a baseline and exits 0; - * `--strict` exits non-zero when any violations remain (for once the backlog is - * burned down, or wired to changed files only). Add `theme-lint-disable` in a - * line comment to suppress an intentional exception (map/PDF/brand colors). + * Each file may hold no more hits than its entry in scripts/theme-baseline.json + * (a file without an entry holds none). A line that is literal on purpose (map + * paint, PDF, brand colours) carries `theme-lint-disable` in a comment; those + * lines are counted against scripts/theme-disable-baseline.json, so the marker + * cannot quietly take the place of the tokens. An entry of either baseline + * above what its file holds now, or for a file that is gone, fails as well + * until --update lowers it. + * + * npm run theme:lint check against the baselines (CI) + * npm run theme:lint -- --list print every hit, file by file + * npm run theme:lint -- --update lower both baselines to what the files hold now; + * it never raises an entry + * + * The matching lives in scripts/lib/theme.mjs. */ -import { readdirSync, readFileSync, statSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runCli } from './lib/ratchet.mjs'; +import { check } from './lib/theme.mjs'; -let SRC = new URL('../src', import.meta.url).pathname; -if (process.platform === 'win32' && SRC.startsWith('/')) SRC = SRC.slice(1); - -// Surfaces where CSS variables genuinely cannot reach (injected map HTML, WebGL -// paint, standalone PDF documents) — colors there must stay literal. -const EXEMPT = [ - /Mapbox/i, /placePopup/i, /marker/i, /popup/i, /TripPDF/, /JourneyBookPDF/, - /MapViewGL/, /MapView\./, /JourneyMapGL/, /reservationsMapbox/, /useAtlas/, - /ReservationOverlay/, /packingPrintDocument/, /\.test\./, /\.spec\./, -]; - -const ARB_CLASS = /\b(?:bg|text|border|ring|fill|stroke|from|via|to|shadow|outline|decoration|divide|caret)-\[\s*(?:#|rgba?\(|hsla?\(|oklch\()/; -const INLINE_COLOR = /(?:color|background|backgroundColor|borderColor|border|borderTop|borderBottom|borderLeft|borderRight|boxShadow|fill|stroke|outline|textDecorationColor)\s*:\s*['"`]?\s*(?:#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|oklch\()/; -const INLINE_FONTSIZE = /fontSize\s*:\s*['"`]?\d/; - -function walk(dir, files = []) { - for (const name of readdirSync(dir)) { - const p = join(dir, name); - if (statSync(p).isDirectory()) walk(p, files); - else if (/\.(ts|tsx)$/.test(name)) files.push(p); - } - return files; -} - -const strict = process.argv.includes('--strict'); -const offenders = []; -let total = 0; - -for (const f of walk(SRC)) { - if (EXEMPT.some((re) => re.test(f))) continue; - let count = 0; - for (const line of readFileSync(f, 'utf8').split('\n')) { - if (line.includes('theme-lint-disable')) continue; - if (ARB_CLASS.test(line) || INLINE_COLOR.test(line) || INLINE_FONTSIZE.test(line)) count++; - } - if (count) { - offenders.push([relative(SRC, f).replace(/\\/g, '/'), count]); - total += count; - } -} - -offenders.sort((a, b) => b[1] - a[1]); -console.log(`theme:lint — ${total} hardcoded-style hits across ${offenders.length} files (map/PDF excluded).`); -for (const [f, c] of offenders.slice(0, 20)) console.log(` ${String(c).padStart(4)} ${f}`); -if (offenders.length > 20) console.log(` … and ${offenders.length - 20} more files.`); -console.log('\nNew/changed code must use tokens (bg-surface / text-content / bg-accent / var(--..)) and the'); -console.log('text-title/subtitle/body/caption tiers — never inline #hex, never bg-[#..]. See src/theme/README.md.'); - -if (strict && total > 0) { - console.error(`\n✖ theme:lint:strict — ${total} violations remain.`); - process.exit(1); -} +const root = fileURLToPath(new URL('..', import.meta.url)); +await runCli('theme:lint', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') })); diff --git a/client/src/theme/README.md b/client/src/theme/README.md index dd3e17592..d68b1d52f 100644 --- a/client/src/theme/README.md +++ b/client/src/theme/README.md @@ -60,7 +60,7 @@ their current position until the file gets converted. (+ `ring-accent`) — never `bg-slate-900` / `bg-black` / `bg-indigo-*`. This is what lets a user's accent reach new surfaces automatically. 3. **Semantic text size** uses the tier utilities `text-title/subtitle/body/caption` - — never an inline `fontSize: `, never raw `text-sm`/`text-xs` to *mean* a tier. + — never an inline `fontSize: `, never raw `text-sm`/`text-xs` or arbitrary `text-[13px]` to *mean* a tier. 4. **Translucent/blurred** surfaces consume an alpha/glass token (`--bg-elevated`, `--tooltip-bg`, `--glass-*`) or a `backdrop-blur` utility, so transparency-off can neutralize them centrally — never inline `rgba()` @@ -78,3 +78,9 @@ Genuinely dynamic values (data-driven colors like `cat.color`, computed geometry/transforms/sizes), and the surfaces CSS variables can't reach: injected map popup/marker HTML, Mapbox/MapLibre paint, and the standalone `@react-pdf` documents. Mark intentional exceptions with a `theme-lint-disable` line comment. + +The markers are counted per file against `client/scripts/theme-disable-baseline.json`, +like `rtl-lint-disable` in `lint:rtl`, so an exception is a decision a reviewer +sees rather than a way around the check. `--update` only lowers that file: a new +marker for a surface the tokens really cannot reach is added by raising (or +adding) its file's entry by hand in the same change, where review can see it. diff --git a/client/tests/unit/scripts/theme-lint.test.ts b/client/tests/unit/scripts/theme-lint.test.ts new file mode 100644 index 000000000..b236853d5 --- /dev/null +++ b/client/tests/unit/scripts/theme-lint.test.ts @@ -0,0 +1,165 @@ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { RatchetError } from '../../../scripts/lib/ratchet.mjs'; +import { check, inspect } from '../../../scripts/lib/theme.mjs'; +import { ratchetTree, type RatchetTree } from '../../helpers/ratchetFixture'; + +let tree: RatchetTree; +afterEach(() => tree?.remove()); + +const HITS = 'scripts/theme-baseline.json'; +const MARKERS = 'scripts/theme-disable-baseline.json'; + +function run( + hits: Record, + markers: Record, + files: Record, + update = false +) { + tree = ratchetTree({ [HITS]: JSON.stringify(hits), [MARKERS]: JSON.stringify(markers), ...files }); + return check({ + root: tree.root, + baselinePath: tree.path(HITS), + disableBaselinePath: tree.path(MARKERS), + update, + ...tree.out, + }); +} + +const rulesOf = (source: string) => inspect(source, 'a.tsx').hits.map((hit: string) => hit.split(': ')[1]); + +describe('theme:lint', () => { + it('THEME-001: counts literals, arbitrary colour classes and numeric font sizes', () => { + const source = [ + `const a =
`, + `const s = { color: '#111', background: 'rgba(0,0,0,.1)', fontSize: 13 }`, + ].join('\n'); + expect(rulesOf(source)).toEqual([ + 'arbitrary-color', + 'arbitrary-color', + 'inline-color', + 'inline-color', + 'inline-font-size', + ]); + }); + + it('THEME-002: counts palette classes with variants, not the token utilities', () => { + const source = `const a =
`; + expect(inspect(source, 'a.tsx').hits).toEqual([ + '1: palette-class: bg-gray-100', + '1: palette-class: dark:text-indigo-400', + '1: palette-class: hover:bg-white/80', + ]); + }); + + it('THEME-003: counts raw text sizes, not the type tiers or text colours', () => { + const source = `const a =

`; + expect(inspect(source, 'a.tsx').hits).toEqual(['1: raw-text-size: text-sm', '1: raw-text-size: md:text-2xl']); + }); + + it('THEME-003b: counts arbitrary text sizes, not arbitrary colours or variables', () => { + const source = `const a =

`; + expect(inspect(source, 'a.tsx').hits).toEqual([ + '1: arbitrary-color: text-[#fff]', + '1: raw-text-size: text-[13px]', + '1: raw-text-size: sm:text-[0.8rem]', + '1: raw-text-size: text-[1.5em]', + ]); + }); + + it('THEME-004: counts a z-index literal above z-50, not local stacking or a scale step', () => { + const source = [ + `const a =

`, + `const b = { zIndex: 10000 }`, + 'const html = `
`', + ].join('\n'); + expect(inspect(source, 'a.tsx').hits).toEqual([ + '1: z-index-literal: z-[9999]', + '2: z-index-literal: zIndex: 10000', + '3: z-index-literal: z-index: 1000', + ]); + }); + + it('THEME-005: counts a read of the dark_mode setting, not a write of it', () => { + const source = [ + 'const dark = useSettingsStore((s) => s.settings.dark_mode)', + "save({ dark_mode: 'dark' })", + "updateSetting('dark_mode', 'auto')", + ].join('\n'); + expect(inspect(source, 'a.tsx').hits).toEqual(['1: dark-mode-read: .dark_mode']); + }); + + it('THEME-006: ignores comments and honours the marker in a comment only', () => { + const source = [ + '// never bg-white or text-sm here', + "const paint = { color: '#ff0000' } // theme-lint-disable: map paint", + "const label = { color: '#00ff00', note: 'theme-lint-disable' }", + ].join('\n'); + expect(inspect(source, 'a.tsx')).toEqual({ hits: ["3: inline-color: color: '#00ff00"], markers: 1 }); + }); + + it('THEME-007: fails a file that gains a hit and names the fix', () => { + expect(run({ 'a.tsx': 1 }, {}, { 'src/a.tsx': 'const a =
\n' })).toBe(1); + const out = tree.error.join('\n'); + expect(out).toMatch(/a\.tsx: 2 styling bypass\(es\) of the theme tokens, baseline 1/); + expect(out).toMatch(/raw-text-size: use a type tier/); + }); + + it('THEME-008: fails a file that gains a marker, and passes one within its entry', () => { + const marked = "const s = { color: '#fff' } // theme-lint-disable\n"; + expect(run({}, {}, { 'src/a.tsx': marked })).toBe(1); + expect(tree.error.join('\n')).toMatch(/a\.tsx: 1 line\(s\) marked theme-lint-disable/); + tree.remove(); + expect(run({}, { 'a.tsx': 1 }, { 'src/a.tsx': marked })).toBe(0); + }); + + it('THEME-009: leaves tests out of the count', () => { + expect(run({}, {}, { 'src/a.tsx': 'const a = 1\n', 'src/a.test.tsx': 'const a = "bg-gray-100"\n' })).toBe(0); + }); + + it('THEME-010: --update lowers both baselines and never raises them', () => { + run( + { 'a.tsx': 3, 'b.tsx': 1 }, + { 'a.tsx': 2 }, + { + 'src/a.tsx': "const a = 'bg-gray-100'\nconst s = { color: '#fff' } // theme-lint-disable\n", + 'src/b.tsx': "const b = 'bg-gray-100 text-xs'\n", + }, + true + ); + expect(JSON.parse(readFileSync(tree.path(HITS), 'utf8'))).toEqual({ 'a.tsx': 1, 'b.tsx': 1 }); + expect(JSON.parse(readFileSync(tree.path(MARKERS), 'utf8'))).toEqual({ 'a.tsx': 1 }); + }); + + it('THEME-011: fails an entry above its file, or for a file that is gone, until --update', () => { + const files = { 'src/a.tsx': "const a = 'bg-gray-100'\n" }; + expect(run({ 'a.tsx': 2 }, {}, files)).toBe(1); + expect(tree.error.join('\n')).toMatch(/a\.tsx is held at 2 in scripts\/theme-baseline\.json, but there are 1 now/); + tree.remove(); + expect(run({ 'a.tsx': 1, 'gone.tsx': 3 }, {}, files)).toBe(1); + expect(tree.error.join('\n')).toMatch( + /gone\.tsx is held at 3 in scripts\/theme-baseline\.json, but the file is gone/ + ); + }); + + it('THEME-012: a missing or broken baseline stops the check instead of reading as empty', () => { + tree = ratchetTree({ [HITS]: '{}', 'src/a.tsx': 'const a = 1\n' }); + expect(() => + check({ root: tree.root, baselinePath: tree.path(HITS), disableBaselinePath: tree.path(MARKERS), ...tree.out }) + ).toThrow(RatchetError); + }); + + it('THEME-013: the command runs its check whichever way it is started', { timeout: 60_000 }, () => { + const scripts = resolve('scripts'); + for (const [cwd, arg] of [ + [process.cwd(), join(scripts, 'theme-lint.mjs')], + [scripts, 'theme-lint.mjs'], + ]) { + const result = spawnSync(process.execPath, [arg], { cwd, encoding: 'utf8' }); + expect(result.stdout).toMatch(/^theme: \d+ styling bypass\(es\)/m); + expect(result.status).toBe(0); + } + }); +}); diff --git a/wiki/Development-environment.md b/wiki/Development-environment.md index 91a94166d..1f6af1d5e 100644 --- a/wiki/Development-environment.md +++ b/wiki/Development-environment.md @@ -217,7 +217,7 @@ The `@trek/shared` package is the single source of truth for code shared between | `npm run lint:pairs` | Desktop and phone views of a feature import its shared hook; every phone screen is listed (CI gate) | | `npm run lint:skips` | No focused test, and skipped tests may only go away (CI gate) | | `npm run lint:i18n-keys` | Every translation key the client names exists in `en` (CI gate) | -| `npm run theme:lint` | Flag styling that bypasses the appearance tokens (not run in CI) | +| `npm run theme:lint` | Styling that bypasses the appearance tokens may only shrink per file (CI gate) | | `npm run check:gl-split` | Fail when one built chunk carries both map engines (MapLibre and Mapbox); run after a build | | `npm run build:analyze` | Production build with the bundle analyzer | | `npm run e2e` | Playwright end-to-end tests; CI runs the public and app projects in Chromium and WebKit, the screenshot and help-media projects are local only |