mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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') }));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user