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.
This commit is contained in:
Maurice
2026-10-10 12:51:00 +02:00
parent f51a64017d
commit 242756708e
14 changed files with 876 additions and 121 deletions
+8
View File
@@ -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
+3 -3
View File
@@ -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<Page>()` 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).
+2 -2
View File
@@ -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 `<html>` 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 `<html>` 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
-1
View File
@@ -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",
-1
View File
@@ -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",
+2 -46
View File
@@ -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;
}
+53
View File
@@ -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;
}
+202
View File
@@ -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`(?<![\w-])(?:[\w-]+:)*!?`;
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 COLOR_UTILITY = String.raw`bg|text|border(?:-[trblxyse])?|ring(?:-offset)?|fill|stroke|from|via|to|divide|outline|decoration|placeholder|caret|accent|shadow`;
/**
* The z-index steps in src/index.css start at 60 (--z-bar). A literal above
* Tailwind's own z-50 is a fixed surface placing itself by a number of its own
* instead of docking onto a step; z-0 to z-50 stay free for stacking inside a
* component.
*/
export const Z_LITERAL_ABOVE = 50;
/**
* Each rule of the contract in src/theme/README.md the check can see in source,
* with the fix its failure message names. A rule returns the offending matches
* in one line of code (comments already blanked out).
*/
export const RULES = [
{
name: 'arbitrary-color',
fix: 'a token utility (bg-surface*, text-content*, border-edge*, bg-accent) or bg-[var(--token)]',
re: new RegExp(
String.raw`\b(?:bg|text|border|ring|fill|stroke|from|via|to|shadow|outline|decoration|divide|caret)-\[\s*(?:#|rgba?\(|hsla?\(|oklch\()[^\]]*\]`,
'g'
),
},
{
name: 'inline-color',
fix: 'var(--token) in the style, or the token utility as a class',
re: /(?: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\()/g,
},
{
name: 'inline-font-size',
fix: 'a type tier (text-title, text-subtitle, text-body, text-caption)',
re: /fontSize\s*:\s*['"`]?\d/g,
},
{
name: 'palette-class',
fix: 'a token utility: bg-surface*, text-content*, border-edge*, bg-accent with text-accent-text, or a status color',
re: new RegExp(
`${VARIANT}(?:${COLOR_UTILITY})-(?:(?:${PALETTE})-\\d{2,3}|white|black)(?:\\/\\d{1,3})?(?![\\w-])`,
'g'
),
},
{
name: 'raw-text-size',
fix: 'a type tier (text-title, text-subtitle, text-body, text-caption), which follows the text size setting',
// The named sizes and the arbitrary ones (text-[13px], text-[0.8rem]) alike.
re: new RegExp(`${VARIANT}text-(?:xs|sm|base|lg|xl|[2-9]xl|\\[\\d*\\.?\\d+(?:px|rem|em)\\])(?![\\w-])`, 'g'),
},
{
name: 'z-index-literal',
fix: 'a step of the layering scale: z-[var(--z-modal)] as a class, zIndex: "var(--z-modal)" inline',
match: (code) => {
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;
}
+323
View File
@@ -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
}
@@ -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
}
+35 -66
View File
@@ -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') }));
+7 -1
View File
@@ -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: <px>`, never raw `text-sm`/`text-xs` to *mean* a tier.
— never an inline `fontSize: <px>`, 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.
@@ -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<string, number>,
markers: Record<string, number>,
files: Record<string, string>,
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 = <div className="bg-[#fff] text-[rgba(0,0,0,.5)]" />`,
`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 = <div className="bg-gray-100 dark:text-indigo-400 hover:bg-white/80 bg-surface text-content-muted border-edge bg-accent" />`;
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 = <p className="text-sm md:text-2xl text-caption text-body text-content" />`;
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 = <p className="text-[13px] sm:text-[0.8rem] text-[1.5em] text-[var(--x)] text-[#fff]" />`;
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 = <div className="z-10 z-50 z-[9999] z-[var(--z-modal)]" style={{ zIndex: 2 }} />`,
`const b = { zIndex: 10000 }`,
'const html = `<div style="z-index: 1000">`',
].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 = <div className="bg-gray-100 text-sm" />\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);
}
});
});
+1 -1
View File
@@ -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 |