From 804dbc97c6a1a6b9452dcf9463ab24a245493e52 Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sun, 2 Aug 2026 17:22:53 +0300 Subject: [PATCH] Isolate mocks behind src/api seam enforced by lint, fix two inverted verdict phrasings against engine docs, add accessibility and type-aware gates, encode chapter state by shape and colour --- frontend/docs/BACKLOG.md | 3 + frontend/docs/FRONTEND_PLAN.md | 7 +- frontend/docs/PROGRESS.md | 64 ++++++- frontend/eslint.config.js | 28 ++- frontend/package-lock.json | 24 +++ frontend/package.json | 1 + frontend/scripts/shot.mjs | 18 +- frontend/src/api/index.ts | 28 +++ frontend/src/api/types.ts | 64 +++++++ frontend/src/mock/book.ts | 102 +++++++++++ frontend/src/showcase/Showcase.module.css | 84 ++++++++- frontend/src/showcase/Showcase.tsx | 213 +++++++++++++++------- frontend/src/showcase/showcaseData.ts | 134 -------------- frontend/src/tokens/tokens.css | 1 + frontend/src/tokens/tokens.test.ts | 5 +- frontend/stylelint.config.js | 2 +- 16 files changed, 557 insertions(+), 221 deletions(-) create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/mock/book.ts delete mode 100644 frontend/src/showcase/showcaseData.ts diff --git a/frontend/docs/BACKLOG.md b/frontend/docs/BACKLOG.md index ccf8c9d8..e8dd17f8 100644 --- a/frontend/docs/BACKLOG.md +++ b/frontend/docs/BACKLOG.md @@ -11,6 +11,9 @@ | ~~Ф-8~~ | ~~Вопрос владельцу: закрывать ли `/* eslint-disable */` в TSX~~ — **закрыт 02.08 без решения владельца.** Вопрос был поставлен как выбор из двух плохих вариантов, а у него есть отраслевой третий: `@eslint-community/eslint-plugin-eslint-comments` 4.7.2 (`no-unlimited-disable` + `require-description` + `disable-enable-pair`). Голое отключение падает тремя ошибками, точечное подавление с причиной проходит. Урок: не выносить владельцу технический вопрос, не поискав отраслевую практику | закрыт | адверсариальное ревью 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 | +| Ф-11 | **Контраст ниже порога WCAG на 5 узлах** (приглушённый текст `#707479` и `#8a8e91` на `#17191a`). Это прямое следствие копирования палитры Fleet, которую владелец принял; axe отчитывается о них отдельно и команду не валит. Решение владельца: оставить как есть ради верности референсу или поднять приглушённые тона | решение владельца | ревью S1 №2 | +| Ф-12 | **Масштаб списков.** Раскладка нарисована под 10 глав и 11 терминов, а статус-строка того же экрана говорит 2284 раздела и 7,78 млн знаков. Дерево — плоский список без группировки и виртуализации, банк — без разделения «подписано / ждёт решения», замечания — без сводного вида. Требование к S2: каждый список получает поведение при 10³ элементов ДО того, как рисуется | S2 | ревью S1 №2 | +| Ф-13 | **`eslint-plugin-jsx-a11y` не встаёт на ESLint 10** (peer `^3..^9`, проверено `--dry-run` → ERESOLVE). Статическую половину доступности держит axe в скриншот-цикле; плагин подключить, когда выйдет совместимый мажор | триггер: мажор плагина | ревью S1 №2 | | Ф-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 26ddcc5b..472e12de 100644 --- a/frontend/docs/FRONTEND_PLAN.md +++ b/frontend/docs/FRONTEND_PLAN.md @@ -88,8 +88,8 @@ CJK-шрифт не ставим: иероглифы отдаём системн | `ui/` | глупые примитивы на токенах: кнопка, поле, вкладки, строка дерева, таблица, выноска. Единственное место, где разрешён импорт `react-aria-components` | запросы, знание о доменных сущностях | | `shell/` | оболочка: три панели, верхняя полоса, статус-полоса, вкладки | доменная логика экранов | | `features/` | `books`, `chapters`, `bank`, `reader`, `settings` — по экрану на папку; запрос живёт здесь, на уровне экрана | цвета и размеры мимо токенов | -| `api/` | единственный вход к данным: типы контракта + функции. Сейчас внутри моки, потом HTTP | React-компоненты | -| `mock/` | фикстуры: книга, главы, пары оригинал/перевод, банк, состояния прогона | логика; фикстуры — данные | +| `api/` | единственный вход к данным: типы + функции. Сегодня внутри фикстура, завтра HTTP. Единственное место, где легальны импорт из `mock/` и `fetch` | React-компоненты | +| `mock/` | фикстуры. В день появления API папка удаляется целиком, экраны не трогаются | логика; фикстуры — данные | | `showcase/` | витрина примитивов: маршрут `/showcase`, живой каталог для сверки с `references/fleet.png` | продуктовые экраны | Правило зависимостей — сверху вниз, без обратных рёбер: @@ -137,6 +137,9 @@ CJK-шрифт не ставим: иероглифы отдаём системн | 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`, перечислены в тесте явным списком | +| 14 | Данные — только через `src/api/`; сеть — только там же | **машинно**: `no-restricted-imports` на `**/mock/**` и `no-restricted-globals` на `fetch`, с исключением для `src/api/**`. Раньше правило было записано в плане и не существовало в коде — компонент импортировал фикстуру напрямую | +| 15 | Доступность проверяется машиной, а не глазами владельца | **машинно**: axe-core прогоняется в `npm run shot` по каждому маршруту и валит команду. Контраст вынесен в отчёт: палитра снята с Fleet замером и принята владельцем, менять её под порог WCAG — отдельное решение (`BACKLOG.md` Ф-11) | +| 16 | Плавающий промис не проходит | **машинно**: `tseslint.configs.recommendedTypeChecked` с `projectService`. Проверено: `load()` без `await` роняет линт | | 13 | Инлайновый стиль — только литерал объекта прямо в атрибуте, и только с ключами-CSS-переменными | **машинно**: `no-restricted-syntax` запрещает сам атрибут `style` и разрешает единственную форму. Перечислять формы записи оказалось бесполезно: из одиннадцати способов записать то же самое ловилось три. Остаточная дыра — JSX-спред `
`, где `style` приезжает внутри объекта; без типовой информации не ловится | **Контрольный вопрос владельца** (`§5.1`, применять к каждому пакету работ): diff --git a/frontend/docs/PROGRESS.md b/frontend/docs/PROGRESS.md index eef4579a..b986b072 100644 --- a/frontend/docs/PROGRESS.md +++ b/frontend/docs/PROGRESS.md @@ -9,10 +9,13 @@ - **Пройдено:** S0 (план) и S1 (инструменты, скриншот-цикл, `tokens.css`, витрина). - **Дальше:** S2 — оболочка трёх панелей и слой `src/ui/`. -- **Ждёт владельца:** решений нет, все вопросы закрыты (см. хронику 02.08). -- **Ждёт движка:** ничего до S4. Затем — манифест глав и пофазный прогресс (строки 99–100 - единого бэклога), к S5 — машиночитаемая таблица подписи банка (строка 101). -- `npm run check` зелёный (5 шагов, 27 тестов), `npm run check:full` зелёный. +- **Ждёт владельца:** контраст палитры (`BACKLOG.md` Ф-11) и два вопроса ниже — В-1 диск, В-2 копирайт. +- **Ждёт движка:** до S2 — ничего. **Вход в S3 — контракт API v0 (единый бэклог, строка 95, + `docs/architecture/14-api-contract.md`): файла нет, а без него моки и API разойдутся — + ровно то, ради чего строка заведена как ранняя вставка.** Дальше — манифест глав + и пофазный прогресс (строки 99–100), к S5 — машиночитаемая таблица подписи банка (строка 101). +- `npm run check` зелёный (5 шагов, 27 тестов, тип-осведомлённый линт), `npm run check:full` + зелёный, гейт доступности в скриншот-цикле зелёный. ## Решения владельца по продукту @@ -64,6 +67,59 @@ в продуктовые понятия**, и он заведён одной картой в данных: причина → человеческая фраза; поле `detail` не показывается никогда. Это же ПТ-33 реестра требований. +### 02.08 — шов данных заведён: моки выкидываются одной папкой + +Владелец: **«все моки рано или поздно придётся выкинуть; нужна минимальность моков, +минимальная инвазивность в код и изолированность»**. На вопрос «соблюдаешь ли» честный ответ +был «нет»: правило «данные только через `src/api/`» стояло в плане, а в коде компонент +импортировал фикстуру напрямую — шва не было вовсе. Радиус поражения был мал (один экран), +но образец для S2–S7 задавался неправильный. + +Заведено: `src/api/` — типы и функции, единственный вход; `src/mock/` — фикстура, которую +видит только `api/`. Правило теперь машинное, а не на совести ревьюера: `no-restricted-imports` +на `**/mock/**` и `no-restricted-globals` на `fetch`, с исключением для `src/api/**`. +Доменных значений в разметке не осталось — их было восемь, и grep их не находил. +**В день появления HTTP правится тело функций в `api/` и удаляется одна папка; экраны +не трогаются.** + +### 02.08 — что дал разбор канона: фронт мокает не тот контракт + +`docs/README.md` прямо требует читать `research/23` перед любым кодом стыка. Не читал, +и это стоило двух вещей. + +1. **D39.85 (`research/23` §0): фронт читает ТОЛЬКО read-модель платформы в Postgres, + материализованную из NDJSON-потока событий; движок не опрашивается никогда, чтение живого + SQLite движка — запрещённый анти-паттерн.** Значит формы `glossary`/`ChunkExport` — словарь + предметной области, но НЕ форма API. Типы фронта переписаны так, чтобы это было видно: + `src/api/types.ts` объявлен рабочей гипотезой до появления контракта. +2. **Единый бэклог, строка 95:** «Контракт API v0 + продуктовый словарь статусов, ранняя + вставка, зафиксировать артефактом ДО большого кода обеих сторон — фронт пишется на моках, + без пришпиленного контракта моки и API разойдутся». Дом — `docs/architecture/14-api-contract.md`, + **файла нет**. То есть фронт делает ровно то, ради предотвращения чего строка заведена. + Для S2 (оболочка, `src/ui/`) это не помеха. **Для S3 (слой данных) — вход, которого нет.** + +### 02.08 — инверсия смысла двух вердиктов (дефект, внесённый фронтом) + +Продуктовые фразы к замечаниям были написаны по ИМЕНИ причины, а не по её доккомменту, +и две из пяти получились наоборот: + +- `glossary_miss` — «Термин не подписан в банке» ⟶ **неверно**. `disposition.go:78-85`: + «an **approved** term's src fired in the chunk but no accepted dst form appears in the + output — the model ignored the glossary». Термин подписан, его проигнорировал перевод. + Фраза посылала человека подписывать уже подписанное — по банку, который в каноне + центральная ценность. +- `sanitizer_stripped` — «Часть блока не переведена» ⟶ **неверно**. `disposition.go:96-104`: + «the chunk is NOT lost — the cleaned text is committed as the export»; `status.go:165-170`: + «the **least alarming** flag». Самый безобидный флаг из восьми подан как потеря текста. + +Заодно вскрылось, что у движка есть **ратифицированная лестница важности вердиктов** +(`status.go:150-175`, ранги 1..8), а интерфейс красил все замечания одинаково — то есть +терял единственный сигнал приоритета. Введены две ступени: `attention` и `glance`. + +**Правило на будущее:** фраза к вердикту пишется по доккомменту `disposition.go`, а не по имени +причины, и рядом кладётся цитата. Соответствие целиком — в шапке `src/mock/book.ts`; его +настоящий дом — контракт API (строка 95), не фронт. + ## Открытые вопросы к владельцу | # | Вопрос | Почему это его вопрос | diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 37865888..cd236c3e 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -58,7 +58,17 @@ const tokenGate = [ export default tseslint.config( { ignores: ['dist/**', '.shots/**', '.tooling/**'] }, js.configs.recommended, - tseslint.configs.recommended, + // С доступом к типам: ловит «промис бросили и не дождались» — самый частый настоящий баг + // React-приложения. Весь S3+ (загрузка, SSE-прогресс, запросы) асинхронный, и без этой сети + // накопится к третьей сессии. Прогон на нашем src — около 4 секунд. + tseslint.configs.recommendedTypeChecked, + { + languageOptions: { + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + }, + }, + // Конфиги вне tsconfig — им типы не нужны. + { files: ['**/*.js', '**/*.mjs'], extends: [tseslint.configs.disableTypeChecked] }, // .flat — именно flat-вариант: одноимённый ключ верхнего уровня остался в старом формате. reactHooks.configs.flat['recommended-latest'], reactRefresh.configs.vite, @@ -76,6 +86,7 @@ export default tseslint.config( '@eslint-community/eslint-comments/no-unlimited-disable': 'error', '@eslint-community/eslint-comments/require-description': ['error', { ignore: [] }], // Глобальных стилей ровно два файла; всё остальное — CSS Modules, они скоупятся сами. + // Плюс шов данных: моки видит только src/api/, и в день HTTP выбрасывается одна папка. 'no-restricted-imports': [ 'error', { @@ -85,9 +96,19 @@ export default tseslint.config( message: 'Глобальный CSS импортируется только в src/main.tsx. Экранам и примитивам — *.module.css.', }, + { + group: ['**/mock/**'], + message: + 'Фикстуры импортирует только src/api/. Экраны зовут функции из src/api/ и не знают, откуда данные.', + }, ], }, ], + // Сеть — тоже только за швом: иначе запрос расползётся по экранам и его не выкорчевать. + 'no-restricted-globals': [ + 'error', + { name: 'fetch', message: 'Запросы живут в src/api/ и больше нигде.' }, + ], }, }, { @@ -100,5 +121,10 @@ export default tseslint.config( files: ['src/tokens/*.test.ts'], rules: { 'no-restricted-syntax': 'off' }, }, + { + // Единственное место, где легальны и фикстуры, и сеть. + files: ['src/api/**'], + rules: { 'no-restricted-imports': 'off', 'no-restricted-globals': 'off' }, + }, prettier, ); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7699f0ad..4fe62175 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "react-router": "8.3.0" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", "@eslint/js": "10.0.1", "@testing-library/react": "16.3.2", @@ -43,6 +44,19 @@ "node": ">=22.22.0" } }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1838,6 +1852,16 @@ "node": ">=8" } }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 67259bd3..ed3d0a28 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "react-router": "8.3.0" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", "@eslint/js": "10.0.1", "@testing-library/react": "16.3.2", diff --git a/frontend/scripts/shot.mjs b/frontend/scripts/shot.mjs index 67e49be6..7e692d7f 100644 --- a/frontend/scripts/shot.mjs +++ b/frontend/scripts/shot.mjs @@ -26,6 +26,7 @@ if (existsSync(toolingRoot)) { } const { chromium } = await import('playwright'); +const { default: AxeBuilder } = await import('@axe-core/playwright'); const { build, preview } = await import('vite'); // Разбираем последовательно, а не фильтром по индексу: фильтр без --size выбрасывал первый @@ -67,7 +68,10 @@ if (!origin) throw new Error('vite preview не отдал локальный а const browser = await chromium.launch(); // deviceScaleFactor 2 — референс снят на macOS при 2x, иначе снимки несравнимы по детализации. -const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 2 }); +// Контекст создаётся явно: @axe-core/playwright отказывается работать со страницей из +// browser.newPage() («Please use browser.newContext()»). +const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 2 }); +const page = await context.newPage(); for (const route of routes) { await page.goto(`${origin}${route}`, { waitUntil: 'networkidle' }); @@ -76,6 +80,18 @@ for (const route of routes) { const file = resolve(shotsDir, `${route.replace(/^\//, '').replace(/\//g, '-') || 'index'}.png`); await page.screenshot({ path: file }); console.log(`${route} → ${file} (${width}x${height} @2x)`); + + // Доступность — из того же класса, что цвет и имена токенов: владелец её глазами не проверит, + // значит проверяет машина. Контраст вынесен в отчёт, а не в падение: палитра снята с Fleet + // замером и принята владельцем, менять её ради порога WCAG — отдельное решение (BACKLOG Ф-11). + const axe = await new AxeBuilder({ page }).analyze(); + const contrast = axe.violations.filter((v) => v.id === 'color-contrast'); + const blocking = axe.violations.filter((v) => v.id !== 'color-contrast'); + for (const v of contrast) console.log(` контраст (принято как есть): ${v.nodes.length} узл(ов)`); + if (blocking.length > 0) { + for (const v of blocking) console.error(` ✖ ${v.id}: ${v.help} — ${v.nodes.length} узл(ов)`); + throw new Error(`Доступность: ${blocking.length} нарушени(й) на ${route}`); + } } await browser.close(); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 00000000..f1fa85eb --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,28 @@ +// ЕДИНСТВЕННЫЙ вход к данным. Компоненты зовут отсюда и не знают, что внутри моки: +// сегодня фикстура, завтра HTTP к платформе. В день появления API правится тело этих +// функций и удаляется папка src/mock/ — экраны не трогаются вовсе. +// +// Правило машинное, а не на совести ревьюера: eslint.config.js запрещает импорт из +// src/mock/ и вызов fetch где-либо, кроме src/api/. + +import * as fixture from '../mock/book'; + +import type { BankTerm, Book, Chapter, Pair } from './types'; + +export type * from './types'; + +export function getBook(): Book { + return fixture.book; +} + +export function getChapters(): Chapter[] { + return fixture.chapters; +} + +export function getPairs(): Pair[] { + return fixture.pairs; +} + +export function getBankTerms(): BankTerm[] { + return fixture.bankTerms; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 00000000..a96b2f13 --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,64 @@ +// Форма данных, которую видит интерфейс. НЕ схема движка и не схема платформы: +// фронт читает только read-модель платформы (D39.85, research/23 §0 — «SSE и все чтения +// фронта ТОЛЬКО из Postgres, движок никогда не опрашивается»). Пока контракта нет +// (единый бэклог, строка 95 — `docs/architecture/14-api-contract.md` ещё не написан), +// эти типы — рабочая гипотеза фронта, и при появлении контракта правятся здесь и только здесь. + +export type ChapterState = + | 'not-started' + | 'parsing' + | 'translating' + | 'awaiting-bank' + | 'finalizing' + | 'ready' + | 'stopped' + | 'rejected' + | 'failed'; + +export interface Chapter { + id: string; + title: string; + state: ChapterState; + /** Есть замечания в главе — отдельная ось от состояния прогона. */ + noteCount: number; +} + +export interface Book { + id: string; + title: string; + sourceLanguage: string; + targetLanguage: string; + genre: string; + chapterCount: number; + characterCount: number; + addedAt: string; + translatedChapters: number; +} + +/** Термин банка памяти. Подпись — не правка строки: банк пересобирается на каждом прогоне. */ +export interface BankTerm { + source: string; + target: string; + kind: 'name' | 'nickname' | 'title' | 'place' | 'org' | 'term'; + signed: boolean; +} + +/** + * Замечание адресует блок целиком: байтовых смещений у проверок движка нет, только счётчики. + * `severity` — продуктовая проекция ратифицированной лестницы важности вердиктов + * (`backend/internal/pipeline/status.go:150-175`): чинить сейчас · посмотреть глазами. + */ +export type NoteSeverity = 'attention' | 'glance'; + +export interface Note { + severity: NoteSeverity; + text: string; +} + +/** Пара оригинал/перевод. Единица — edit-unit, местами вся глава окажется одним блоком. */ +export interface Pair { + id: string; + source: string; + target: string; + note?: Note; +} diff --git a/frontend/src/mock/book.ts b/frontend/src/mock/book.ts new file mode 100644 index 00000000..617c40f3 --- /dev/null +++ b/frontend/src/mock/book.ts @@ -0,0 +1,102 @@ +// Фикстура. Живёт отдельно от кода экранов и импортируется ТОЛЬКО из src/api/ — в день +// появления API эта папка удаляется целиком, экраны не трогаются. +// +// ⚠ Фактура снята с настоящего прогона на стенде (`/home/ubuntu/books/gu-zhenren/`, +// 蛊真人 zh→ru): в git художественного текста почти нет, оба `backend/example/sample-*.db` +// пустые. Взята короткая выдержка под проверку плотности колонок и не больше — исходник +// под авторским правом. Привязка фактуры к одной машине — вопрос В-1 в docs/PROGRESS.md. + +import type { BankTerm, Book, Chapter, Pair } from '../api/types'; + +export const book: Book = { + id: 'guzhenren', + title: 'Гу Чжэньжэнь', + sourceLanguage: 'китайский', + targetLanguage: 'русский', + genre: 'сянься', + chapterCount: 2284, + characterCount: 7_780_000, + addedAt: '28.07.2026', + translatedChapters: 118, +}; + +// Все девять состояний из §6 промта присутствуют: экран обязан выдерживать каждое, +// а не только те три, что удобно нарисовать. +export const chapters: Chapter[] = [ + { id: 'r1', title: 'Раздел 1. Нет раскаяния', state: 'ready', noteCount: 0 }, + { id: 'r2', title: 'Раздел 2. Прозрение пятисот лет', state: 'ready', noteCount: 2 }, + { id: 'r3', title: 'Раздел 3. Церемония открытия', state: 'translating', noteCount: 0 }, + { id: 'r4', title: 'Раздел 4. Класс А', state: 'awaiting-bank', noteCount: 0 }, + { id: 'r5', title: 'Раздел 5. Деревня Гуюэ', state: 'finalizing', noteCount: 1 }, + { id: 'r6', title: 'Раздел 6. Первый гу', state: 'not-started', noteCount: 0 }, + { id: 'r7', title: 'Раздел 7. Аптека', state: 'parsing', noteCount: 0 }, + { id: 'r8', title: 'Раздел 8. Кровь на снегу', state: 'stopped', noteCount: 0 }, + { id: 'r9', title: 'Раздел 9. Утраченный свиток', state: 'failed', noteCount: 0 }, + { id: 'r10', title: 'Раздел 10. Испорченный файл', state: 'rejected', noteCount: 0 }, +]; + +// Значения — из настоящего банка `minirun/guzhenren-minirun.db` (таблица `glossary`). +// В типе фронта от неё осталось только то, что нужно экрану. +export const bankTerms: BankTerm[] = [ + { source: '方源', target: 'Фан Юань', kind: 'name', signed: true }, + { source: '白凝冰', target: 'Бай Нинбин', kind: 'name', signed: true }, + { source: '花酒行者', target: 'Монах Цветочного Вина', kind: 'nickname', signed: true }, + { source: '沈嬷嬷', target: 'матушка Шэнь', kind: 'nickname', signed: false }, + { source: '古月山寨', target: 'деревня Гуюэ', kind: 'place', signed: true }, + { source: '蛊', target: 'гу', kind: 'term', signed: true }, + { source: '蛊师', target: 'гу-мастер', kind: 'title', signed: true }, + { source: '开窍', target: 'открытие апертуры', kind: 'term', signed: true }, + { source: '春秋蝉', target: 'Весенне-осенняя цикада', kind: 'term', signed: true }, + { source: '学堂', target: 'школа', kind: 'place', signed: true }, + { source: '一气金光虫', target: 'Золотосветный червь', kind: 'term', signed: false }, +]; + +// Замечания приходят уже переведёнными в продуктовые понятия: перевод вердиктов движка — +// не работа фронта, а обязательство контракта API (ПТ-33, единый бэклог строка 95). +// Соответствие, с которого сняты фразы ниже, — чтобы автору контракта было от чего оттолкнуться: +// +// движок | важность (status.go:150-175) | продуктовая фраза +// content_filter = 1 | attention | Блок не переведён: содержимое отклонено +// sanitizer_defect = 2 | attention | Блок не переведён: в ответе был мусор +// loop_degenerate = 3 | attention | Перевод зациклился, блок надо перевести заново +// glossary_miss = 5 | attention | Подписанный термин не применён в переводе +// sanitizer_stripped = 7 | glance | Служебная разметка вычищена автоматически +// +// ⚠ Смысл берётся из доккомментов `backend/internal/pipeline/disposition.go`, а не из имени +// причины: `glossary_miss` — это НЕ «термин не подписан» (термин как раз подписан, а модель +// его проигнорировала), а `sanitizer_stripped` — НЕ потеря текста (текст очищен и отгружен, +// это самый безобидный флаг из восьми). Обе фразы уже были однажды написаны наоборот. +export const pairs: Pair[] = [ + { + id: 'r2-0', + source: '第二节:逆光阴五百年觉悟', + target: 'Раздел 2: Прозрение пятисот лет против течения времени', + }, + { + id: 'r2-1', + source: + '传说中,这个世界存在着一条光阴之河,支撑着这个世界的流转。而利用春秋蝉的力量,就能逆流而上,回到从前。\n' + + '对这个传闻,世人众说纷纭。很多人并不相信,有些人则将信将疑。\n' + + '几乎没有人真的确信。', + target: + 'В преданиях говорилось, что в этом мире существует река времени, поддерживающая его течение, и с помощью силы Весенне-осенней цикады можно подняться против потока и вернуться в прошлое.\n' + + 'Об этом слухе люди судили по-разному. Многие не верили вовсе, иные верили наполовину.\n' + + 'Почти никто не был уверен по-настоящему.', + note: { severity: 'glance', text: 'Служебная разметка вычищена автоматически' }, + }, + { + id: 'r2-2', + source: '但是现在,方源信了,彻底地信了。铁一般的事实摆在他的面前,不容他反驳——他真的重生了!', + target: + 'Но теперь Фан Юань уверовал полностью и бесповоротно. Перед ним лежал железный факт, не допускающий возражений: он действительно переродился!', + note: { severity: 'attention', text: 'Подписанный термин не применён в переводе' }, + }, + { + id: 'r2-3', + source: + '因为每一次使用春秋蝉,都必须付出生命,将整个身躯和所有的修为统统献祭,作为驱动的力量。\n' + + '这个代价实在太昂贵了,更让人无法接受的是——往往付出了生命,也不知道结果怎样。', + target: '', + note: { severity: 'attention', text: 'Блок не переведён: содержимое отклонено' }, + }, +]; diff --git a/frontend/src/showcase/Showcase.module.css b/frontend/src/showcase/Showcase.module.css index fb69f464..af661abc 100644 --- a/frontend/src/showcase/Showcase.module.css +++ b/frontend/src/showcase/Showcase.module.css @@ -27,8 +27,11 @@ justify-content: flex-end; } +/* h1 ради структуры документа, а не ради вида: кегль и насыщенность — интерфейсные. */ .topbarTitle { color: var(--color-text); + font-size: var(--font-size-ui); + font-weight: inherit; } .body { @@ -62,6 +65,8 @@ .panelFooter { display: flex; + width: 100%; + text-align: left; gap: var(--space-2); align-items: center; height: var(--row-height); @@ -154,12 +159,69 @@ color: var(--color-text-muted); } -.dot { - width: 6px; - height: 6px; +.marker { margin-left: auto; + width: 7px; + height: 7px; + flex: none; +} + +/* Форма — первый канал: она различает состояния и без цвета. */ +.markerFilled { border-radius: 50%; - background-color: var(--dot); + background-color: currentcolor; +} + +.markerRing { + border: 1px solid currentcolor; + border-radius: 50%; +} + +.markerDash { + height: 1px; + margin-block: 3px; + background-color: currentcolor; +} + +.markerNone { + visibility: hidden; +} + +/* Цвет — второй канал, и означает только внимание и отказ (промт §4.2). */ +.toneMuted { + color: var(--color-text-muted); +} + +.toneNote { + color: var(--color-note); +} + +.toneDanger { + color: var(--color-danger); +} + +.noteCount { + margin-left: auto; + margin-right: var(--space-2); + color: var(--color-text-muted); + font-size: var(--font-size-small); +} + +.iconButton { + display: flex; + align-items: center; + padding: var(--space-1); + border-radius: var(--radius-control); + color: inherit; +} + +.visuallyHidden { + position: absolute; + overflow: hidden; + width: 1px; + height: 1px; + clip-path: inset(50%); + white-space: nowrap; } /* Читалка: один скролл-контейнер, колонки внутри строки-пары — разъехаться не могут. */ @@ -171,11 +233,17 @@ padding: var(--space-3) var(--space-2) var(--space-3) var(--space-4); } -/* Замечание: тонкая полоска у левого края блока и тусклая подпись — без заливки текста. */ -.pairNoted { +/* Замечание: тонкая полоска у левого края блока и тусклая подпись — без заливки текста. + Две ступени, а не одна: у движка есть ратифицированная лестница важности вердиктов + (status.go:150-175), и слепить её в один вид — потерять единственный сигнал приоритета. */ +.attention { border-left-color: var(--color-note); } +.glance { + border-left-color: var(--color-separator); +} + .source, .target { font-size: var(--font-size-content); @@ -192,9 +260,11 @@ color: var(--color-text); } +/* Подпись стоит НАД блоком, как выноска в antigravity_chat.png: под блоком она читается + как заголовок следующего. */ .note { grid-column: 1 / -1; - margin-top: var(--space-2); + margin-bottom: var(--space-2); color: var(--color-text-muted); font-size: var(--font-size-small); } diff --git a/frontend/src/showcase/Showcase.tsx b/frontend/src/showcase/Showcase.tsx index ed768d16..81aef021 100644 --- a/frontend/src/showcase/Showcase.tsx +++ b/frontend/src/showcase/Showcase.tsx @@ -10,132 +10,209 @@ import { Settings, } from 'lucide-react'; +import { getBankTerms, getBook, getChapters, getPairs, type ChapterState } from '../api'; import styles from './Showcase.module.css'; -import { bankRows, chapters, chapterStates, flagReasonLabels, pairs } from './showcaseData'; // Тонкие линейные монохромные иконки как во Fleet (STACK_DECISIONS §2). const icon = { size: 16, strokeWidth: 1.5 } as const; +// Vite типизирует ключи CSS-модуля как string | undefined, поэтому склейка классов — +// через отсев пустых, а не через шаблонную строку: иначе в class уезжает «undefined». +const cx = (...names: (string | undefined | false)[]) => names.filter(Boolean).join(' '); + +// Состояние кодируется ДВУМЯ каналами: формой метки и цветом. Одним цветом девять состояний +// в монохромном интерфейсе не развести, а цвет по §4.2 означает только внимание и отказ. +// Новое состояние — одна запись здесь. +const chapterMarkers: Record = { + 'not-started': { shape: styles.markerNone, label: 'не начата' }, + parsing: { shape: styles.markerRing, tone: styles.toneMuted, label: 'разбирается' }, + translating: { shape: styles.markerFilled, tone: styles.toneMuted, label: 'переводится' }, + 'awaiting-bank': { shape: styles.markerRing, tone: styles.toneNote, label: 'ждёт подписи банка' }, + finalizing: { shape: styles.markerFilled, tone: styles.toneMuted, label: 'финальный проход' }, + ready: { shape: styles.markerNone, label: 'готова' }, + stopped: { shape: styles.markerDash, tone: styles.toneMuted, label: 'остановлена' }, + rejected: { shape: styles.markerFilled, tone: styles.toneDanger, label: 'не удалось разобрать' }, + failed: { shape: styles.markerFilled, tone: styles.toneDanger, label: 'прервана ошибкой' }, +}; + /** - * Витрина — не экран продукта, а сверочная страница скриншот-цикла. Раскладка при этом - * несёт решения владельца 02.08 по структуре (docs/PROGRESS.md, запись «Раскладка»): - * навигация слева, документ в центре во всю высоту, справочное о читаемой главе справа. + * Витрина — не экран продукта, а сверочная страница скриншот-цикла. Данные берёт из src/api/ + * ровно так же, как будут брать настоящие экраны: доменных значений в разметке нет. */ export function Showcase() { + const book = getBook(); + const chapters = getChapters(); + const pairs = getPairs(); + const terms = getBankTerms(); + const openChapter = chapters[1]; + const signed = terms.filter((term) => term.signed).length; + return (
- {/* Кнопка есть ровно у той панели, которая существует: нижней панели в раскладке нет */}
- - + +
-
Гу Чжэньжэнь
+

{book.title}

- - + +
-
-
- Книги - Поиск +
+ + -
-
- - Раздел 2. Прозрение пятисот лет - - - Раздел 3. Церемония открытия - +
+
+ +
{pairs.map((pair) => (
+ {pair.note &&

{pair.note.text}

}
{pair.source}
{pair.target || перевод не получен}
- {/* Показываем продуктовую фразу, а не вердикт движка: detail остаётся внутри */} - {pair.flagReason && ( -

{flagReasonLabels[pair.flagReason]}

- )}
))}
-
+ -
-
- О книге - Замечания - Банк +
+ + подписано {signed} из {terms.length} + + +
- Гу Чжэньжэнь / Раздел 2. Прозрение пятисот лет - переведено 118 из 2284 · zh → ru + + {book.title} / {openChapter?.title} + + + переведено {book.translatedChapters} из {book.chapterCount} · zh → ru +
); diff --git a/frontend/src/showcase/showcaseData.ts b/frontend/src/showcase/showcaseData.ts deleted file mode 100644 index 6a807748..00000000 --- a/frontend/src/showcase/showcaseData.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Материал витрины. Формы данных сняты с движка, а не выдуманы — иначе моки разойдутся -// с настоящим контрактом ещё до того, как появится HTTP-слой. -// -// ⚠ Источник фактуры лежит ВНЕ репозитория, на диске стенда (`/home/ubuntu/books/gu-zhenren/`): -// прогоны, экспорты пар, банки. В git художественного текста почти нет, а оба -// `backend/example/sample-*.db` пустые — там только схема. Привязка данных к конкретной -// машине — открытый вопрос владельца, см. docs/PROGRESS.md. -// -// Фрагмент ниже — короткая выдержка из настоящего прогона `rerun2/export-dspro.json` -// (蛊真人, zh→ru). Взят объём, которого хватает на проверку плотности колонок, и не больше: -// исходник защищён авторским правом, и в репозиторий его целиком не кладут. -// -// Это ТОЛЬКО данные витрины. Продуктовые фикстуры живут в src/mock/ и заводятся на S3. - -export type ChapterState = 'ready' | 'running' | 'queued' | 'attention'; - -// Новое состояние главы добавляется здесь и больше нигде: подпись и токен цвета — в одной записи. -export const chapterStates: Record = { - ready: { label: 'готово', colorToken: '--color-text-muted' }, - running: { label: 'переводится', colorToken: '--color-accent' }, - queued: { label: 'в очереди', colorToken: '--color-text-muted' }, - attention: { label: 'есть замечания', colorToken: '--color-note' }, -}; - -export const chapters: { title: string; state: ChapterState }[] = [ - { title: 'Раздел 1. Нет раскаяния', state: 'ready' }, - { title: 'Раздел 2. Прозрение пятисот лет', state: 'attention' }, - { title: 'Раздел 3. Церемония открытия', state: 'running' }, - { title: 'Раздел 4. Класс А', state: 'running' }, - { title: 'Раздел 5. Деревня Гуюэ', state: 'queued' }, - { title: 'Раздел 6. Первый гу', state: 'queued' }, -]; - -// Поля — как в таблице `glossary` движка (store/migrate.go:176): src · dst · type · status · -// source. Значения тоже настоящие, из `minirun/guzhenren-minirun.db`. -// `status` подписи ровно три: auto | draft | approved — и только approved инжектится -// редактору как подтверждённый. -export type BankType = 'name' | 'nickname' | 'title' | 'place' | 'org' | 'term'; -export type BankStatus = 'auto' | 'draft' | 'approved'; - -export const bankRows: { - src: string; - dst: string; - type: BankType; - status: BankStatus; - source: 'seed' | 'ruby' | 'auto' | 'mined'; -}[] = [ - { src: '方源', dst: 'Фан Юань', type: 'name', status: 'approved', source: 'seed' }, - { src: '白凝冰', dst: 'Бай Нинбин', type: 'name', status: 'approved', source: 'seed' }, - { - src: '花酒行者', - dst: 'Монах Цветочного Вина', - type: 'nickname', - status: 'approved', - source: 'seed', - }, - { src: '沈嬷嬷', dst: 'матушка Шэнь', type: 'nickname', status: 'draft', source: 'seed' }, - { src: '古月山寨', dst: 'деревня Гуюэ', type: 'place', status: 'approved', source: 'seed' }, - { src: '蛊', dst: 'гу', type: 'term', status: 'approved', source: 'seed' }, - { src: '蛊师', dst: 'гу-мастер', type: 'title', status: 'approved', source: 'seed' }, - { src: '开窍', dst: 'открытие апертуры', type: 'term', status: 'approved', source: 'seed' }, - { - src: '春秋蝉', - dst: 'Весенне-осенняя цикада', - type: 'term', - status: 'approved', - source: 'seed', - }, - { src: '学堂', dst: 'школа', type: 'place', status: 'approved', source: 'mined' }, - { src: '一气金光虫', dst: 'Золотосветный червь', type: 'term', status: 'draft', source: 'mined' }, -]; - -// Интерфейс не раскрывает конвейер (промт §4.1), а движок отдаёт вердикты своим языком: -// `sanitizer_stripped`, «CJK leak in the ru output: 第一节». Поэтому между ними стоит слой -// перевода: причина движка → продуктовая фраза. Новый вид замечания правит одну запись здесь, -// компонент выноски не трогается. Поле `detail` пользователю не показывается никогда. -export const flagReasonLabels: Record = { - glossary_miss: 'Термин из этого места не подписан в банке памяти', - sanitizer_stripped: 'Часть блока не переведена, стоит перечитать', - excision_suspect: 'Возможен пропуск: перевод заметно короче оригинала', - loop_degenerate: 'Перевод зациклился, блок стоит перевести заново', - content_filter: 'Блок не переведён: содержимое отклонено', -}; - -// Единица пары — edit-unit из `tmctl export --pairs` (pipeline/export.go:25). Замечание -// адресует блок целиком: байтовых смещений у проверок движка нет, только счётчики, -// поэтому `flagReason` и `detail` — ровно то, что отдаёт движок. -export const pairs: { - source: string; - target: string; - flagReason?: string; - detail?: string; -}[] = [ - { - source: '第二节:逆光阴五百年觉悟', - target: 'Раздел 2: Прозрение пятисот лет против течения времени', - }, - { - source: - '传说中,这个世界存在着一条光阴之河,支撑着这个世界的流转。而利用春秋蝉的力量,就能逆流而上,回到从前。\n' + - '对这个传闻,世人众说纷纭。很多人并不相信,有些人则将信将疑。\n' + - '几乎没有人真的确信。', - target: - 'В преданиях говорилось, что в этом мире существует река времени, поддерживающая его течение, и с помощью силы Весенне-осенней цикады можно подняться против потока и вернуться в прошлое.\n' + - 'Об этом слухе люди судили по-разному. Многие не верили вовсе, иные верили наполовину.\n' + - 'Почти никто не был уверен по-настоящему.', - }, - { - source: '但是现在,方源信了,彻底地信了。铁一般的事实摆在他的面前,不容他反驳——他真的重生了!', - target: - 'Но теперь Фан Юань уверовал полностью и бесповоротно. Перед ним лежал железный факт, не допускающий возражений: он действительно переродился!', - flagReason: 'glossary_miss', - detail: 'В блоке встречается термин без подписи в банке памяти', - }, - { - source: - '因为每一次使用春秋蝉,都必须付出生命,将整个身躯和所有的修为统统献祭,作为驱动的力量。\n' + - '这个代价实在太昂贵了,更让人无法接受的是——往往付出了生命,也不知道结果怎样。', - target: '', - flagReason: 'content_filter', - detail: 'upstream refusal: content policy', - }, -]; - -export const bookFacts: { label: string; value: string }[] = [ - { label: 'Название', value: 'Гу Чжэньжэнь' }, - { label: 'Оригинал', value: 'китайский' }, - { label: 'Перевод', value: 'русский' }, - { label: 'Жанр', value: 'сянься' }, - { label: 'Разделов', value: '2284' }, - { label: 'Объём', value: '7,78 млн знаков' }, - { label: 'Добавлена', value: '28.07.2026' }, - { label: 'Состояние', value: 'переводится' }, -]; diff --git a/frontend/src/tokens/tokens.css b/frontend/src/tokens/tokens.css index 643b69c8..5f4e7edf 100644 --- a/frontend/src/tokens/tokens.css +++ b/frontend/src/tokens/tokens.css @@ -30,6 +30,7 @@ --color-danger: #b82e45; --color-note: #2964ad; /* полоска замечания-заметки (antigravity_chat.png) */ --color-hint: #3c7d4f; /* полоска замечания-подсказки */ + --color-separator: #27292b; /* разделители и вторая, спокойная ступень замечания */ --color-scrollbar-thumb: #353739; /* --- Геометрия (CSS-пиксели; снимок референса сделан при 2x) --- */ diff --git a/frontend/src/tokens/tokens.test.ts b/frontend/src/tokens/tokens.test.ts index dc2dfd1c..78e637d1 100644 --- a/frontend/src/tokens/tokens.test.ts +++ b/frontend/src/tokens/tokens.test.ts @@ -77,11 +77,10 @@ describe('tokens.css', () => { 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 m of source.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); + for (const m of source.matchAll(/['"`](--[\w-]+)['"`]/g)) used.add(m[1] as string); } const dangling = [...used].filter((name) => !declared.has(name) && !local.has(name)).sort(); diff --git a/frontend/stylelint.config.js b/frontend/stylelint.config.js index f4274353..2265fca9 100644 --- a/frontend/stylelint.config.js +++ b/frontend/stylelint.config.js @@ -7,7 +7,7 @@ // правило комментарием. const gate = { reportDisables: true }; const token = '/^var\\(--/'; -const colorValues = [token, 'inherit', 'currentColor', 'transparent']; +const colorValues = [token, 'inherit', 'currentColor', 'currentcolor', 'transparent']; export default { extends: ['stylelint-config-standard'],