diff --git a/frontend/docs/BACKLOG.md b/frontend/docs/BACKLOG.md
index 327be397..872cce1d 100644
--- a/frontend/docs/BACKLOG.md
+++ b/frontend/docs/BACKLOG.md
@@ -8,6 +8,9 @@
| Ф-2 | **React Compiler** — включить ОТДЕЛЬНЫМ шагом после заморозки интерфейса, с CI-проверкой, что вставки реально попали в бандл (в MVP выключен; совместимость держит линт eslint-plugin-react-hooks) | после заморозки UI | STACK_DECISIONS §1 |
| Ф-3 | **Пересмотр TS 6 → TS 7** после выхода TS 7.1 с программным API (~октябрь 2026); решение обратимо — тайпчек не участвует в сборке | триггер: релиз TS 7.1 | STACK_DECISIONS §1 |
| Ф-4 | **Токен-гейт на отступы** (`padding`/`margin`/`gap`/`border-radius`) — вторым шагом, когда шкала токенов зафиксирована (иначе гейт мешает подбору) | **триггер наступил**: витрина S1 снята, шкала `--space-1…6` заведена; включать, когда S2 обкатает её на настоящей оболочке | STACK_DECISIONS §3 |
+| Ф-8 | **Вопрос владельцу: закрывать ли `/* eslint-disable */` в TSX.** CSS-половина гейта цвета обхода комментарием больше не допускает (`reportUnscopedDisables`), TSX-половина — допускает. Закрывается одной строкой `linterOptions: { noInlineConfig: true }`, но той же строкой запрещаются легитимные точечные подавления (`react-hooks/exhaustive-deps`), которые рекомендует сам React. Выбор не технический, а владельческий | решение до S2 | адверсариальное ревью S1 |
+| Ф-9 | **Хвосты гейтов после адверсариального ревью S1** (все воспроизведены исполнением, ни один не в боевом коде): именованные цвета CSS в TSX (`fill="red"`, ``) не ловятся — в CSS запрещены, асимметрия; санкционированное исключение `style={{'--x': …}}` пропускает любое значение, включая литеральный цвет · JSX-спред `
` со `style` внутри · системные цветовые ключевые слова (`Highlight`, `ButtonText`) проходят в `box-shadow`/`outline`/`caret-color` и в сокращённых записях | до S2 или в ней | адверсариальное ревью S1 |
+| Ф-10 | **Хвосты структурных тестов**: `cssModules.test.ts` строит пары по имени файла (компонент, импортирующий соседний модуль, из проверки выпадает), не видит деструктуризацию `const { shell } = styles`, а `classesOf` тянет в «объявленные классы» расширения из `url(...)`. `tokens.test.ts` сверяет 23 токена из 38 и не имеет замка полноты — новый токен приезжает без гейта по построению | до S2 | адверсариальное ревью S1 |
| Ф-7 | **Хвосты сведения витрины с референсом** (`FRONTEND_PLAN.md` §5.2): `+` в конце рядов вкладок — вместе с действием, которое он запускает (S2) · трактовка колонки оригинала приглушённым цветом — решение, а не замер, пересмотреть на настоящей читалке (S6) · состояния наведения витриной не проверены, снимок статичен | S2 · S6 | S1, сверка с fleet.png |
| Ф-5 | **Tauri 2.x вторым шагом** — по явным триггерам (трей · глобальные горячие клавиши · распространяемый .exe · офлайн · хранилище учёток ОС); до того — установимое PWA, только браузерные API | триггеры названы | STACK_DECISIONS §6, D39.84 |
| Ф-6 | **`@tanstack/react-virtual`** — в резерве, подключать только по замеру (дефолт виртуализации — RAC Virtualizer) | по замеру | STACK_DECISIONS §2 |
diff --git a/frontend/docs/FRONTEND_PLAN.md b/frontend/docs/FRONTEND_PLAN.md
index e77c2627..03b521fe 100644
--- a/frontend/docs/FRONTEND_PLAN.md
+++ b/frontend/docs/FRONTEND_PLAN.md
@@ -126,7 +126,7 @@ CJK-шрифт не ставим: иероглифы отдаём системн
| # | Правило | Проверка |
|---|---|---|
| 1 | Цвет и размер только из `tokens.css` | **машинно**: stylelint `declaration-property-value-allowed-list` (`/^var\(--/` на `color`, `background-color`, `border-color`, `fill`, `stroke`, `font-size`, `z-index`) + ESLint `no-restricted-syntax` на `#hex`/`rgb(`/`hsl(`/`oklch(` в TSX. Исключение по пути — только `tokens/` |
-| 2 | Отключить гейт комментарием нельзя | **машинно**: `reportDisables: true` в конфиге stylelint — `/* stylelint-disable */` сам становится ошибкой |
+| 2 | Отключить CSS-гейт комментарием нельзя | **машинно**: `reportDisables: true` на правилах + `reportUnscopedDisables: true` в корне конфига. Одного `reportDisables` НЕ хватает: он ловит только именованное отключение, а голое `/* stylelint-disable */` в шапке снимало гейт молча — проверено. **В TSX дыра открыта:** `/* eslint-disable */` работает, `noInlineConfig` сознательно не включён (он же запретит легитимные точечные подавления `react-hooks/exhaustive-deps`). Решение владельца — `BACKLOG.md` Ф-8 |
| 3 | Данные только через `src/api/` | ревью-вопрос: есть ли `fetch`/`axios`/импорт из `mock/` вне `src/api/`? Должно быть «нет» |
| 4 | Файл = один компонент + свой `.module.css`, больше ~150 строк — делить | ревью глазами при лендинге пакета |
| 5 | Состояние ровно в двух местах: TanStack Query (серверное), Zustand (интерфейсное) | ревью-вопрос: есть ли `useState` с копией серверных данных? Должно быть «нет» |
@@ -136,6 +136,8 @@ CJK-шрифт не ставим: иероглифы отдаём системн
| 9 | Комментарии — одна-две строки «почему» | ревью глазами; проектная норма |
| 10 | Каждый экран открывается в изоляции: свой маршрут, своя фикстура | **машинно** косвенно: скриншот-скрипт снимает экран по URL. Не открывается по прямой ссылке — не снимется |
| 11 | `styles.имяКласса` ссылается на существующий класс | **машинно**: `src/cssModules.test.ts`. Vite типизирует модуль как `{ [key: string]: string }`, поэтому опечатка даёт `className="undefined"` тихо — тайпчек и линт её пропускают. Правило заведено не впрок: на витрине такая ссылка уже нашлась |
+| 12 | Имя токена в `var(--…)` и в строках кода объявлено в `tokens.css` | **машинно**: `src/tokens/tokens.test.ts`. Тот же класс тихой ошибки: stylelint проверяет только форму обёртки `var(--…)`, тайпчек видит обычную строку, браузер отдаёт пустое значение — элемент гаснет в фон, и на скриншоте это 124 пикселя из 3,9 млн. Локальные переменные, задаваемые через `style`, перечислены в тесте явным списком |
+| 13 | Инлайновый стиль — только литерал объекта прямо в атрибуте, и только с ключами-CSS-переменными | **машинно**: `no-restricted-syntax` запрещает сам атрибут `style` и разрешает единственную форму. Перечислять формы записи оказалось бесполезно: из одиннадцати способов записать то же самое ловилось три. Остаточная дыра — JSX-спред ``, где `style` приезжает внутри объекта; без типовой информации не ловится |
**Контрольный вопрос владельца** (`§5.1`, применять к каждому пакету работ):
добавление нового состояния главы или нового вида замечания правит **один** файл.
@@ -306,11 +308,29 @@ rm -f *.deb && cd ..
- **`import.meta.glob` по `*.module.css` брать с `?raw`, а не `?inline`:** `?inline` отдаёт уже
скомпилированный CSS с хешированными именами (`._shell_1abc_1`), и сверять с ним имена
из TSX бессмысленно.
-- **Гейты и оба структурных теста проверены живым нарушением, а не заявлением:** литерал цвета
- в модуле · литерал в сокращённой записи `border` · `font-size` числом · попытка отключить
- правило комментарием · hex в TSX · инлайновый стиль с обычным свойством · импорт глобального
- CSS мимо `main.tsx` · опечатка в имени класса. Каждый раз проверка падала, после отката —
- зелено. Разрешённое исключение `style={{ '--dot': … }}` проходит.
+### 5.4. Что именно проверено живым нарушением
+
+Формулировка «гейты проверены живым нарушением» без перечня форм — ловушка: она звучит как
+машинная гарантия, а покрывает ровно те входы, которые придумал автор. Ниже полный список
+проверенных форм; каждая роняла проверку, после отката дерево зелёное.
+
+**CSS:** hex-литерал в модуле · hex в сокращённой записи `border` · `rgb()` · `color()`
+в сокращённой записи · `font-size` числом · именованное отключение правила
+(`/* stylelint-disable color-no-hex */`) · **голое** `/* stylelint-disable */` в шапке файла.
+
+**TSX:** hex всех четырёх длин (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`) · цветовые функции
+включая `color()` и `light-dark()` · одиннадцать форм инлайнового стиля (литерал, спред,
+вынесенная переменная, приведение `as`, свойство объекта, вызов фабрики, тернарник,
+вычисляемый ключ, JSX-спред, смешанный литерал) · импорт глобального CSS мимо `main.tsx` ·
+предупреждение линта при `--max-warnings 0`.
+
+**Структурные тесты:** опечатка в имени класса CSS Module · то же при переименованном импорте
+модуля · опечатка в имени токена в CSS · опечатка в имени токена в данных TS.
+
+**Что НЕ ловится и остаётся известной дырой:** `/* eslint-disable */` в TSX (см. правило 2) ·
+JSX-спред `` со `style` внутри объекта · именованные цвета CSS в TSX
+(`fill="red"`) — в CSS они запрещены, в TSX ратифицированный список `STACK_DECISIONS.md` §3
+их не называет; строка в бэклоге.
---
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index c99b8d38..4a965f7b 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -13,25 +13,43 @@ const inlineStyle =
'Инлайновый стиль запрещён: размеры и цвета берутся из .module.css на токенах. ' +
"Единственное исключение — передача CSS-переменной: style={{ '--progress': value }}.";
+// Длины перечислены все четыре: #RGB, #RGBA, #RRGGBB, #RRGGBBAA. Ловить «3..8 подряд» одним
+// шаблоном нельзя — граница слова не сработает внутри более длинной последовательности.
+const hexLengths = [3, 4, 6, 8];
+
const tokenGate = [
- { selector: 'Literal[value=/#[0-9a-fA-F]{3}\\b/]', message: colorLiteral },
- { selector: 'Literal[value=/#[0-9a-fA-F]{6}\\b/]', message: colorLiteral },
- { selector: 'Literal[value=/#[0-9a-fA-F]{8}\\b/]', message: colorLiteral },
- { selector: 'Literal[value=/\\b(rgba?|hsla?|oklch|oklab|lab|lch)\\(/]', message: colorLiteral },
- { selector: 'TemplateElement[value.raw=/#[0-9a-fA-F]{3,8}\\b/]', message: colorLiteral },
- // Ключ-идентификатор — обычное свойство (color, width); CSS-переменная синтаксически
- // обязана быть строковым ключом, поэтому проходит только она.
+ ...hexLengths.map((n) => ({
+ selector: `Literal[value=/#[0-9a-fA-F]{${n}}\\b/]`,
+ message: colorLiteral,
+ })),
{
- selector: "JSXAttribute[name.name='style'] ObjectExpression > Property[key.type='Identifier']",
+ selector: 'Literal[value=/\\b(rgba?|hsla?|hwb|oklch|oklab|lab|lch|color|light-dark)\\(/]',
+ message: colorLiteral,
+ },
+ { selector: 'TemplateElement[value.raw=/#[0-9a-fA-F]{3,8}\\b/]', message: colorLiteral },
+ // Запрещаем сам атрибут, а не перечень форм записи: перечисление ловило три случая из
+ // одиннадцати — спред, вынесенная переменная, тернарник и фабрика проходили молча.
+ // Разрешён ровно один вид — литерал объекта прямо в атрибуте; его свойства проверяются ниже.
+ {
+ selector:
+ "JSXAttribute[name.name='style']:not(:has(JSXExpressionContainer > ObjectExpression))",
+ message: inlineStyle,
+ },
+ // Внутри разрешённого литерала: ключ-идентификатор — обычное свойство (color, width),
+ // CSS-переменная синтаксически обязана быть строковым ключом, поэтому проходит только она.
+ {
+ selector:
+ "JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > Property[key.type='Identifier']",
message: inlineStyle,
},
{
selector:
- "JSXAttribute[name.name='style'] ObjectExpression > Property[key.type='Literal'][key.value!=/^--/]",
+ "JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > Property[key.type='Literal'][key.value!=/^--/]",
message: inlineStyle,
},
{
- selector: "JSXAttribute[name.name='style'] > JSXExpressionContainer > Identifier",
+ selector:
+ "JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > SpreadElement",
message: inlineStyle,
},
];
diff --git a/frontend/package.json b/frontend/package.json
index 0cc1d3ab..c98adcd1 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,7 +10,7 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
- "check": "prettier --check . && eslint . && stylelint \"src/**/*.css\" && tsc --noEmit && vitest run",
+ "check": "prettier --check . && eslint . --max-warnings 0 && stylelint \"src/**/*.css\" && tsc --noEmit && vitest run",
"check:full": "npm run check && npm run build && npm run shot",
"shot": "node scripts/shot.mjs"
},
diff --git a/frontend/scripts/shot.mjs b/frontend/scripts/shot.mjs
index 3691ae58..67e49be6 100644
--- a/frontend/scripts/shot.mjs
+++ b/frontend/scripts/shot.mjs
@@ -1,12 +1,13 @@
// Скриншот-цикл: собрать, поднять preview, снять PNG, положить в .shots/.
// Смотреть на снимок — обязательная часть цикла: без неё код валиден, а вид случаен.
//
-// node scripts/shot.mjs все маршруты, 1440x900
-// node scripts/shot.mjs /showcase один маршрут
-// node scripts/shot.mjs --size 1280x764 вьюпорт референса — для прямого наложения
+// node scripts/shot.mjs все маршруты из KNOWN_ROUTES, 1440x900
+// node scripts/shot.mjs /showcase /reader только названные
+// node scripts/shot.mjs --size 1280x764 вьюпорт референса — для прямого наложения
//
-// Маршруты дублируют src/routes.tsx: два списка вместо загрузчика TS в Node — сознательный
+// KNOWN_ROUTES дублирует src/routes.tsx: два списка вместо загрузчика TS в Node — сознательный
// выбор в пользу простоты, пополнять оба.
+const KNOWN_ROUTES = ['/showcase'];
import { mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
@@ -27,15 +28,36 @@ if (existsSync(toolingRoot)) {
const { chromium } = await import('playwright');
const { build, preview } = await import('vite');
-const args = process.argv.slice(2);
-const sizeIndex = args.indexOf('--size');
-const [width, height] = (sizeIndex === -1 ? '1440x900' : (args[sizeIndex + 1] ?? ''))
- .split('x')
- .map(Number);
+// Разбираем последовательно, а не фильтром по индексу: фильтр без --size выбрасывал первый
+// позиционный маршрут (sizeIndex = -1, и условие index !== 0 съедало его молча).
+const requested = [];
+let size = '1440x900';
+let awaitingSize = false;
+for (const arg of process.argv.slice(2)) {
+ if (awaitingSize) {
+ size = arg;
+ awaitingSize = false;
+ } else if (arg === '--size') {
+ awaitingSize = true;
+ } else if (arg.startsWith('/')) {
+ requested.push(arg);
+ } else {
+ throw new Error(`Непонятный аргумент «${arg}». Ожидается маршрут /showcase или --size ШхВ`);
+ }
+}
+if (awaitingSize) throw new Error('У --size нет значения');
+
+const [width, height] = size.split('x').map(Number);
if (!width || !height) throw new Error('Ожидается --size ШИРИНАxВЫСОТА, например --size 1440x900');
-const requested = args.filter((arg, index) => arg.startsWith('/') && index !== sizeIndex + 1);
-const routes = requested.length > 0 ? requested : ['/showcase'];
+const routes = requested.length > 0 ? requested : KNOWN_ROUTES;
+const unknown = routes.filter((route) => !KNOWN_ROUTES.includes(route));
+if (unknown.length > 0) {
+ // Иначе в кадр молча попадёт страница ошибки роутера, а команда вернёт успех.
+ throw new Error(
+ `Нет такого маршрута: ${unknown.join(', ')}. Известные: ${KNOWN_ROUTES.join(', ')}`,
+ );
+}
await mkdir(shotsDir, { recursive: true });
await build({ logLevel: 'warn' });
diff --git a/frontend/src/cssModules.test.ts b/frontend/src/cssModules.test.ts
index 455cd32c..e1de3380 100644
--- a/frontend/src/cssModules.test.ts
+++ b/frontend/src/cssModules.test.ts
@@ -24,8 +24,15 @@ const classesOf = (css: string) =>
?.map((m) => m.slice(1)),
);
-const usedIn = (tsx: string) =>
- [...tsx.matchAll(/styles(?:\.(\w+)|\[['"]([\w-]+)['"]\])/g)].map((m) => m[1] ?? m[2] ?? '');
+// Имя берём из самого импорта, а не считаем, что оно всегда `styles`: привязка к одному имени
+// делала проверку слепой к `import s from './X.module.css'` — проверено, тест молча проходил.
+const bindingOf = (tsx: string, cssFile: string) =>
+ new RegExp(String.raw`import\s+(\w+)\s+from\s+['"][^'"]*${cssFile}['"]`).exec(tsx)?.[1];
+
+const usedIn = (tsx: string, binding: string) =>
+ [...tsx.matchAll(new RegExp(String.raw`\b${binding}(?:\.(\w+)|\[['"]([\w-]+)['"]\])`, 'g'))].map(
+ (m) => m[1] ?? m[2] ?? '',
+ );
describe('CSS Modules', () => {
const pairs = Object.keys(components)
@@ -33,8 +40,12 @@ describe('CSS Modules', () => {
.filter(([, css]) => css in modules);
it.each(pairs)('%s не ссылается на несуществующий класс', (tsx, css) => {
+ const source = components[tsx] as string;
+ const binding = bindingOf(source, basename(css));
+ expect(binding, `${basename(css)} лежит рядом, но не импортирован`).toBeDefined();
+
const defined = classesOf(modules[css] as string);
- const missing = usedIn(components[tsx] as string).filter((name) => !defined.has(name));
+ const missing = usedIn(source, binding as string).filter((name) => !defined.has(name));
expect(missing, `нет в ${basename(css)}`).toEqual([]);
});
diff --git a/frontend/src/react-css-variables.d.ts b/frontend/src/react-css-variables.d.ts
new file mode 100644
index 00000000..8b69c2d1
--- /dev/null
+++ b/frontend/src/react-css-variables.d.ts
@@ -0,0 +1,11 @@
+import 'react';
+
+// React не знает про CSS-переменные в style, из-за чего каждый такой случай требовал
+// `as CSSProperties`. Приведение мешает гейту: под ним атрибут перестаёт быть литералом
+// объекта, и строгая проверка инлайновых стилей начинает ругаться на разрешённый случай.
+// Одно объявление снимает приведения во всём коде.
+declare module 'react' {
+ interface CSSProperties {
+ [customProperty: `--${string}`]: string | number | undefined;
+ }
+}
diff --git a/frontend/src/showcase/Showcase.module.css b/frontend/src/showcase/Showcase.module.css
index 58797833..dda08117 100644
--- a/frontend/src/showcase/Showcase.module.css
+++ b/frontend/src/showcase/Showcase.module.css
@@ -33,7 +33,7 @@
.body {
display: grid;
- grid-template-columns: 320px 1fr 320px;
+ grid-template-columns: var(--panel-side-width) 1fr var(--panel-side-width);
gap: var(--gap);
min-height: 0;
}
@@ -116,8 +116,9 @@
outline: none;
}
-/* Прозрачная граница вместо отступа: подложка выделения красится по padding-box,
- поэтому при шаге строки 26px пилюля выходит ровно 24px, как в референсе. */
+/* Прозрачная граница вместо отступа плюс background-clip: фон по умолчанию красится
+ по border-box и прозрачную границу закрашивает, поэтому клип задан явно — иначе при шаге
+ строки 26px пилюля выходит те же 26px, а в референсе она 24px. */
.row {
display: flex;
gap: var(--space-2);
@@ -126,6 +127,7 @@
border-block: var(--row-fill-inset) solid transparent;
padding-inline: var(--space-2);
border-radius: var(--radius-control);
+ background-clip: padding-box;
}
.rowNested {
@@ -208,6 +210,7 @@
border-block: var(--row-fill-inset) solid transparent;
padding-inline: var(--space-2);
border-radius: var(--radius-control);
+ background-clip: padding-box;
}
.bankHead {
diff --git a/frontend/src/showcase/Showcase.tsx b/frontend/src/showcase/Showcase.tsx
index 5f19e4f9..ab15f43a 100644
--- a/frontend/src/showcase/Showcase.tsx
+++ b/frontend/src/showcase/Showcase.tsx
@@ -10,7 +10,6 @@ import {
Search,
Settings,
} from 'lucide-react';
-import type { CSSProperties } from 'react';
import styles from './Showcase.module.css';
import { bankRows, bookFacts, chapters, chapterStates, pairs } from './showcaseData';
@@ -64,9 +63,7 @@ export function Showcase() {
))}
diff --git a/frontend/src/tokens/tokens.css b/frontend/src/tokens/tokens.css
index 2bd14cc7..643b69c8 100644
--- a/frontend/src/tokens/tokens.css
+++ b/frontend/src/tokens/tokens.css
@@ -40,6 +40,7 @@
--statusbar-height: 20px;
--panel-header-height: 26px;
--panel-padding: 6px;
+ --panel-side-width: 320px; /* замер: боковые панели 8‥328 и 952‥1272 при вьюпорте 1280 */
--row-height: 26px; /* шаг строки дерева и таблицы */
--row-fill-inset: 1px; /* подложка выбранной строки 24px при шаге 26 */
diff --git a/frontend/src/tokens/tokens.test.ts b/frontend/src/tokens/tokens.test.ts
index 38862268..dc2dfd1c 100644
--- a/frontend/src/tokens/tokens.test.ts
+++ b/frontend/src/tokens/tokens.test.ts
@@ -3,6 +3,17 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import reset from './reset.css?inline';
import tokens from './tokens.css?inline';
+const styleSheets = import.meta.glob('../**/*.css', {
+ query: '?raw',
+ eager: true,
+ import: 'default',
+});
+const sources = import.meta.glob('../**/*.{ts,tsx}', {
+ query: '?raw',
+ eager: true,
+ import: 'default',
+});
+
// Контракт-тест токенов: заменяет визуальный гейт с эталонными скриншотами, который отложен
// как флейкующий между платформами (STACK_DECISIONS §3). Числа справа — замер
// references/fleet.png, способ и таблица в docs/FRONTEND_PLAN.md §5.1.
@@ -53,4 +64,27 @@ describe('tokens.css', () => {
expect(reset.indexOf('@layer reset, vendor;')).toBeGreaterThanOrEqual(0);
expect(reset.indexOf('@layer reset, vendor;')).toBeLessThan(reset.indexOf('@layer reset {'));
});
+
+ // Опечатка в ИМЕНИ токена не ловилась ничем: stylelint проверяет только форму обёртки
+ // var(--…), тайпчек видит обычную строку, а браузер молча отдаёт пустое значение —
+ // индикатор просто гаснет в фон. Тот же класс тихой ошибки, что и опечатка в имени класса.
+ it('каждый var(--…) и каждое имя токена в коде объявлены в tokens.css', () => {
+ const declared = new Set(
+ tokens.match(/^\s*(--[\w-]+)\s*:/gm)?.map((m) => m.trim().slice(0, -1)),
+ );
+ // Задаются на элементе через style, а не в tokens.css; список исчерпывающий и явный.
+ const local = new Set(['--dot']);
+
+ const used = new Set();
+ for (const source of Object.values(styleSheets)) {
+ for (const m of (source as string).matchAll(/var\(\s*(--[\w-]+)/g)) used.add(m[1] as string);
+ }
+ for (const source of Object.values(sources)) {
+ for (const m of (source as string).matchAll(/['"`](--[\w-]+)['"`]/g))
+ used.add(m[1] as string);
+ }
+
+ const dangling = [...used].filter((name) => !declared.has(name) && !local.has(name)).sort();
+ expect(dangling, 'имя токена нигде не объявлено').toEqual([]);
+ });
});
diff --git a/frontend/stylelint.config.js b/frontend/stylelint.config.js
index a75996cf..f4274353 100644
--- a/frontend/stylelint.config.js
+++ b/frontend/stylelint.config.js
@@ -11,11 +11,19 @@ const colorValues = [token, 'inherit', 'currentColor', 'transparent'];
export default {
extends: ['stylelint-config-standard'],
+ // reportDisables на правиле ловит только ИМЕНОВАННОЕ отключение
+ // (`/* stylelint-disable color-no-hex */`). Голое `/* stylelint-disable */` в шапке файла
+ // снимало гейт целиком и молча — проверено. Эта опция требует, чтобы отключение всегда
+ // называло правило, и тем самым закрывает обход.
+ reportUnscopedDisables: true,
rules: {
'color-no-hex': [true, gate],
'color-named': ['never', gate],
+ // color() и light-dark() тоже задают цвет литералом, а в сокращённой записи
+ // (`border: 1px solid color(...)`) их не ловит ничто другое. color-mix() оставлен:
+ // смешивать ему нечего, кроме токенов.
'function-disallowed-list': [
- ['rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch'],
+ ['rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color', 'light-dark'],
gate,
],
'declaration-property-value-allowed-list': [