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
This commit is contained in:
parent
8d6b3f731a
commit
804dbc97c6
16 changed files with 557 additions and 221 deletions
|
|
@ -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"`, `<Icon color="dodgerblue"/>`) не ловятся — в CSS запрещены, асимметрия; санкционированное исключение `style={{'--x': …}}` пропускает любое значение, включая литеральный цвет · JSX-спред `<div {...props}/>` со `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 |
|
||||
|
|
|
|||
|
|
@ -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-спред `<div {...props} />`, где `style` приезжает внутри объекта; без типовой информации не ловится |
|
||||
|
||||
**Контрольный вопрос владельца** (`§5.1`, применять к каждому пакету работ):
|
||||
|
|
|
|||
|
|
@ -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), не фронт.
|
||||
|
||||
## Открытые вопросы к владельцу
|
||||
|
||||
| # | Вопрос | Почему это его вопрос |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
24
frontend/package-lock.json
generated
24
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
28
frontend/src/api/index.ts
Normal file
28
frontend/src/api/index.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
64
frontend/src/api/types.ts
Normal file
64
frontend/src/api/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
102
frontend/src/mock/book.ts
Normal file
102
frontend/src/mock/book.ts
Normal file
|
|
@ -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: 'Блок не переведён: содержимое отклонено' },
|
||||
},
|
||||
];
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ChapterState, { shape?: string; tone?: string; label: string }> = {
|
||||
'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 (
|
||||
<div className={styles.shell}>
|
||||
<header className={styles.topbar}>
|
||||
{/* Кнопка есть ровно у той панели, которая существует: нижней панели в раскладке нет */}
|
||||
<div className={styles.topbarSide}>
|
||||
<PanelLeft {...icon} />
|
||||
<PanelRight {...icon} />
|
||||
<button type="button" className={styles.iconButton} aria-label="Свернуть левую панель">
|
||||
<PanelLeft {...icon} />
|
||||
</button>
|
||||
<button type="button" className={styles.iconButton} aria-label="Свернуть правую панель">
|
||||
<PanelRight {...icon} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.topbarTitle}>Гу Чжэньжэнь</div>
|
||||
<h1 className={styles.topbarTitle}>{book.title}</h1>
|
||||
<div className={styles.topbarSide}>
|
||||
<Search {...icon} />
|
||||
<Download {...icon} />
|
||||
<button type="button" className={styles.iconButton} aria-label="Поиск по книге">
|
||||
<Search {...icon} />
|
||||
</button>
|
||||
<button type="button" className={styles.iconButton} aria-label="Выгрузить книгу">
|
||||
<Download {...icon} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={styles.body}>
|
||||
<section className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={`${styles.tab} ${styles.tabActive}`}>Книги</span>
|
||||
<span className={styles.tab}>Поиск</span>
|
||||
<nav className={styles.panel} aria-label="Библиотека">
|
||||
<div className={styles.panelHeader} role="tablist" aria-label="Разделы левой панели">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected
|
||||
className={cx(styles.tab, styles.tabActive)}
|
||||
>
|
||||
Книги
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={false} className={styles.tab}>
|
||||
Поиск
|
||||
</button>
|
||||
</div>
|
||||
<ul className={styles.panelBody}>
|
||||
<li className={styles.row}>
|
||||
<ChevronDown {...icon} className={styles.chevron} />
|
||||
<BookText {...icon} className={styles.chevron} />
|
||||
<span className={styles.rowTitle}>Гу Чжэньжэнь</span>
|
||||
<span className={styles.rowTitle}>{book.title}</span>
|
||||
</li>
|
||||
{chapters.map((chapter, index) => (
|
||||
<li
|
||||
key={chapter.title}
|
||||
className={`${styles.row} ${styles.rowNested} ${index === 1 ? styles.rowSelected : ''}`}
|
||||
>
|
||||
<FileText {...icon} className={styles.chevron} />
|
||||
<span className={styles.rowTitle}>{chapter.title}</span>
|
||||
{/* Цвет состояния приходит токеном из данных: новое состояние правит один файл */}
|
||||
<span
|
||||
className={styles.dot}
|
||||
title={chapterStates[chapter.state].label}
|
||||
style={{ '--dot': `var(${chapterStates[chapter.state].colorToken})` }}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
<li className={`${styles.row} ${styles.rowNested} ${styles.rowMuted}`}>
|
||||
{chapters.map((chapter) => {
|
||||
const marker = chapterMarkers[chapter.state];
|
||||
return (
|
||||
<li
|
||||
key={chapter.id}
|
||||
className={cx(
|
||||
styles.row,
|
||||
styles.rowNested,
|
||||
chapter.id === openChapter?.id && styles.rowSelected,
|
||||
)}
|
||||
>
|
||||
<FileText {...icon} className={styles.chevron} />
|
||||
<span className={styles.rowTitle}>{chapter.title}</span>
|
||||
{chapter.noteCount > 0 && (
|
||||
<span className={styles.noteCount}>
|
||||
{chapter.noteCount}
|
||||
<span className={styles.visuallyHidden}> замечаний</span>
|
||||
</span>
|
||||
)}
|
||||
<span className={cx(styles.marker, marker.shape, marker.tone)}>
|
||||
<span className={styles.visuallyHidden}>{marker.label}</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<li className={cx(styles.row, styles.rowNested, styles.rowMuted)}>
|
||||
<Plus {...icon} className={styles.chevron} />
|
||||
<span className={styles.rowTitle}>Добавить главу</span>
|
||||
</li>
|
||||
</ul>
|
||||
{/* Единственный вход в настройки во всём интерфейсе (промт §3.12) */}
|
||||
<div className={styles.panelFooter}>
|
||||
<button type="button" className={styles.panelFooter}>
|
||||
<Settings {...icon} className={styles.chevron} />
|
||||
<span className={styles.rowTitle}>Настройки</span>
|
||||
</div>
|
||||
</section>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={`${styles.tab} ${styles.tabDocument} ${styles.tabActive}`}>
|
||||
Раздел 2. Прозрение пятисот лет
|
||||
</span>
|
||||
<span className={`${styles.tab} ${styles.tabDocument}`}>
|
||||
Раздел 3. Церемония открытия
|
||||
</span>
|
||||
<main className={styles.panel} aria-label="Текст главы">
|
||||
<div className={styles.panelHeader} role="tablist" aria-label="Открытые главы">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected
|
||||
className={cx(styles.tab, styles.tabDocument, styles.tabActive)}
|
||||
>
|
||||
{openChapter?.title}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={false}
|
||||
className={cx(styles.tab, styles.tabDocument)}
|
||||
>
|
||||
{chapters[2]?.title}
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.panelBody}>
|
||||
{pairs.map((pair) => (
|
||||
<article
|
||||
key={pair.source}
|
||||
className={`${styles.pair} ${pair.flagReason ? styles.pairNoted : ''}`}
|
||||
key={pair.id}
|
||||
className={cx(styles.pair, pair.note && styles[pair.note.severity])}
|
||||
>
|
||||
{pair.note && <p className={styles.note}>{pair.note.text}</p>}
|
||||
<div className={styles.source} lang="zh">
|
||||
{pair.source}
|
||||
</div>
|
||||
<div className={styles.target} lang="ru">
|
||||
{pair.target || <span className={styles.dim}>перевод не получен</span>}
|
||||
</div>
|
||||
{/* Показываем продуктовую фразу, а не вердикт движка: detail остаётся внутри */}
|
||||
{pair.flagReason && (
|
||||
<p className={styles.note}>{flagReasonLabels[pair.flagReason]}</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={styles.tab}>О книге</span>
|
||||
<span className={styles.tab}>Замечания</span>
|
||||
<span className={`${styles.tab} ${styles.tabActive}`}>Банк</span>
|
||||
<aside className={styles.panel} aria-label="О читаемой главе">
|
||||
<div className={styles.panelHeader} role="tablist" aria-label="Разделы правой панели">
|
||||
<button type="button" role="tab" aria-selected={false} className={styles.tab}>
|
||||
О книге
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={false} className={styles.tab}>
|
||||
Замечания
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected
|
||||
className={cx(styles.tab, styles.tabActive)}
|
||||
>
|
||||
Банк
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.panelBody}>
|
||||
<input className={styles.filter} placeholder="Фильтр терминов" autoFocus />
|
||||
{bankRows.map((row) => (
|
||||
<div key={row.src} className={styles.bankRow}>
|
||||
<span lang="zh">{row.src}</span>
|
||||
<input
|
||||
className={styles.filter}
|
||||
aria-label="Фильтр терминов"
|
||||
placeholder="Фильтр терминов"
|
||||
/>
|
||||
{terms.map((term) => (
|
||||
<div key={term.source} className={styles.bankRow}>
|
||||
<span lang="zh">{term.source}</span>
|
||||
{/* Неподписанный термин приглушён: состояние читается насыщенностью, не цветом */}
|
||||
<span className={row.status === 'approved' ? styles.rowTitle : styles.dim}>
|
||||
{row.dst}
|
||||
</span>
|
||||
<span className={term.signed ? styles.rowTitle : styles.dim}>{term.target}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Связка справочного вида с рабочим: таблица подписи открывается вкладкой в центре,
|
||||
{/* Справочный вид ведёт в рабочий: подписная таблица открывается вкладкой в центре,
|
||||
где есть ширина под колонки, клавиатуру и массовые действия (промт §3.5) */}
|
||||
<div className={`${styles.panelFooter} ${styles.panelFooterSplit}`}>
|
||||
<button type="button" className={cx(styles.panelFooter, styles.panelFooterSplit)}>
|
||||
<span className={styles.rowTitle}>Подписать банк</span>
|
||||
<span className={styles.dim}>подписано 9 из 11</span>
|
||||
</div>
|
||||
</section>
|
||||
<span className={styles.dim}>
|
||||
подписано {signed} из {terms.length}
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer className={styles.statusbar}>
|
||||
<span>Гу Чжэньжэнь / Раздел 2. Прозрение пятисот лет</span>
|
||||
<span className={styles.statusRight}>переведено 118 из 2284 · zh → ru</span>
|
||||
<span>
|
||||
{book.title} / {openChapter?.title}
|
||||
</span>
|
||||
<span className={styles.statusRight}>
|
||||
переведено {book.translatedChapters} из {book.chapterCount} · zh → ru
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<ChapterState, { label: string; colorToken: string }> = {
|
||||
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<string, string> = {
|
||||
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: 'переводится' },
|
||||
];
|
||||
|
|
@ -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) --- */
|
||||
|
|
|
|||
|
|
@ -77,11 +77,10 @@ describe('tokens.css', () => {
|
|||
|
||||
const used = new Set<string>();
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue