# Heart Atlas. Этап 4: релиз 0.1

Дата: 11.10.2026
Статус: согласована (работаем автономно до MVP по решению владельца)

## Цель

Сделать из набора страниц продукт, которым можно пользоваться и делиться: поиск по статьям на любом языке, работа без сети, встраивание модулей в чужие сайты, проверенная доступность и производительность. Этап заканчивается публичным релизом v0.1.

Этапы 1–3 идут параллельно в других тредах. Этот этап опирается только на то, что уже есть в `main` после этапа 0 (реестр языков, макет, CI, деплой), и подключает страницы этапов 1–3 по мере их появления.

## Что входит

1. Поиск на Pagefind с синонимами.
2. PWA: манифест, сервис-воркер, офлайн-кэш статей и моделей.
3. Маршрут `/embed/` для встраивания модулей через `<iframe>`.
4. Аудит WCAG 2.1 AA и производительности, пороги в CI.
5. Документация для контрибьюторов, релиз v0.1.

## Что не входит

Аккаунты, комментарии, аналитика с cookies, мобильные приложения в сторах, своя поисковая служба.

## Ключевые детали

### Сборка после Astro

Одна интеграция `apps/web/integrations/post-build.ts` в хуке `astro:build:done` по порядку:

1. строит индекс Pagefind через Node API (`createIndex` → `addDirectory` → `writeFiles`), без запуска CLI;
2. генерирует манифесты по языкам;
3. пишет сервис-воркер `dist/sw.js`: рукописный `integrations/service-worker.js` плюс конфиг со списком предкэша, уже включающим файлы индекса.

`@vite-pwa/astro` не подходит: его peer-зависимость заканчивается на Astro 5.

### Поиск

- Индекс собирается по языкам автоматически: Pagefind читает `<html lang>`, который берётся из реестра. Для языков без стемминга Pagefind ищет по точному слову, это допустимо.
- `BaseLayout` получает проп `searchable`. Атрибут `data-pagefind-body` ставится на `<main>` только у страниц с содержанием: главная, статьи, анатомия. Страницы поиска, офлайна, встраивания, 404 и переадресации в индекс не попадают.
- Плашки «перевод ещё не готов» и «врачом не проверено» внутри `<main>` помечены `data-pagefind-ignore`, чтобы не попадать в выдержки. Текст статьи, показанный на языке `fallback`, тоже помечается `data-pagefind-ignore`: иначе английский текст попадёт в русский индекс. Он найдётся в индексе своего языка.
- Синонимы: в frontmatter статьи необязательное поле `aliases: string[]` (например «мерцательная аритмия», «ФП»). Макет статьи выводит их в `<div hidden data-pagefind-weight="3">`. Атрибут `hidden` скрывает блок и от экранных дикторов, а Pagefind разбирает HTML без CSS и индексирует его. Это проверяет e2e-тест «поиск по синониму находит статью».
- Поле поиска в шапке: обычная форма `<form role="search" action="${BASE_URL}<locale>/search/" method="get">` без JS, чтобы не нести скрипты поиска на каждую страницу.
- Страница `/<locale>/search/` читает `?q=`, загружает Pagefind UI из `${BASE_URL}pagefind/` с явным `baseUrl: BASE_URL` и передаёт строки интерфейса из `messages/<locale>.json` в опцию `translations`. Если Pagefind UI окажется тяжёлым или недоступным с клавиатуры, пишем свой остров на API `pagefind.js`. Число результатов объявляется через `aria-live`.
- В dev-режиме индекса нет. Страница поиска честно пишет, что поиск работает только в сборке (`pnpm build && pnpm preview`).

### PWA и офлайн

- Один манифест на язык: `${BASE_URL}manifest.<locale>.webmanifest`, генерируется из реестра (тестовая локаль только при `HA_TEST_LOCALES=1`). Общие `id` и `scope` равны `BASE_URL`, поэтому приложение одно; язык задаёт `start_url` = `${BASE_URL}<locale>/`. `name`, `short_name`, `lang`, `dir` берутся из строк и реестра. Макет ссылается на манифест своего языка.
- Иконки PNG 192, 512 и maskable (с полями под безопасную зону 80 %) генерируются один раз и лежат в `public/icons/`, чтобы не тащить sharp в сборку.
- Сервис-воркер пишется руками на чистом JS без Workbox (около сотни строк, без сборки и зависимостей); версия кэша — хэш всех предкэшируемых файлов:
  - предкэш: оболочка (CSS, JS, шрифты, иконки), главные страницы языков, офлайн-страницы, индекс Pagefind;
  - код 3D-сцены (`Scene.*.js`, three.js, около 1 МБ) в предкэш не входит (2026-10-14, предкэш дошёл до 5 МБ): без модели он не нужен, а модели кэшируются по запросу, поэтому и он кэшируется при первом открытии модели на странице под управлением воркера. Компромисс: после деплоя, который меняет хэш этого чанка, 3D без сети снова заработает только после открытия модели в сети, до того остаются постер или сообщение об ошибке;
  - статьи и модели кэшируются по запросу: страницы `NetworkFirst`, модели glTF и звуки `CacheFirst` с лимитом по числу и размеру;
  - навигация без сети и без кэша отдаёт `/<locale>/offline/` того языка, который стоит в пути (код языка берётся из реестра, вшитого при сборке);
  - имена кэшей начинаются с `heart-atlas-`, потому что origin `w3lld1.github.io` общий с другими репозиториями владельца;
  - `/embed/` не обслуживается и не перехватывается;
  - для файлов Pagefind параметры запроса игнорируются, потому что он запрашивает `pagefind-entry.json?ts=…`; без этого поиск без сети не работает. Совпадение ищется с `ignoreVary`, иначе модульный импорт `pagefind.js` (он шлёт `Origin`) не находится в кэше.
- Обновление: когда новый воркер ждёт, показывается уведомление «Доступна новая версия, обновить». Кнопка отправляет воркеру `SKIP_WAITING`, после `controllerchange` страница перезагружается один раз. `skipWaiting` и `clientsClaim` в конфиге не включаем, чтобы не менять версию посреди чтения. Если читатель согласился обновиться в одной вкладке, остальные открытые вкладки тоже перезагрузятся: версия у всех вкладок одна. Это осознанный компромисс.
- Воркер регистрируется только в продакшен-сборке и не регистрируется на страницах `/embed/`, чтобы не жить внутри чужих сайтов.
- Lighthouse 12 больше не проверяет PWA, поэтому установку и офлайн проверяет Playwright:
  - манифест каждого языка разбирается и содержит `name`, `start_url`, `scope`, `id`, иконки;
  - `navigator.serviceWorker.ready` разрешается, область регистрации равна `BASE_URL`;
  - с `context.setOffline(true)` открытая ранее страница перезагружается, незакэшированная показывает офлайн-страницу, поиск работает.
- Во всех остальных e2e и в Lighthouse сервис-воркер отключён (`serviceWorkers: 'block'`), чтобы старые кэши не делали тесты нестабильными.

### Встраивание

- Маршруты `/<locale>/embed/<module>/` отдают модуль без шапки и подвала. Внизу строка «Heart Atlas · открыть на сайте» (ссылка с `target="_blank" rel="noopener"`) и указание авторства: CC BY-SA 4.0 и авторы сторонних моделей из `assets/models/LICENSES.yaml`.
- Для статей о болезнях с `review: null` полная плашка «врачом не проверено» показывается и во встраивании, как требует CLAUDE.md.
- Страницы встраивания: `<meta name="robots" content="noindex">` и `canonical` на полную страницу.
- Модули появляются по мере готовности этапов: `heart-model` (этап 1), `ecg` (этап 2), `disease/<id>` (этап 3).
- Параметры через query string, только из белого списка с проверкой значения (сейчас `?rate=90` у ЭКГ, позже `?preset=atrial-fibrillation`). Неизвестные параметры и недопустимые значения игнорируются, это покрыто тестом. Остров ЭКГ во встраивании рендерится только на клиенте (`client:only`), потому что query string известен только в браузере.
- Ссылки внутри встраивания открываются в новой вкладке (`<base target="_blank">`): переходить по сайту внутри маленького фрейма на чужой странице неудобно.
- Страница `/<locale>/embed/` с конструктором кода `<iframe>`. В коде обязательны `title` (доступность) и `loading="lazy"`.
- GitHub Pages не отдаёт `X-Frame-Options`, поэтому встраивание работает без настроек. Запретить встраивание остальных страниц нельзя: своих заголовков на Pages нет, а `frame-ancestors` в `<meta>` не действует. Это принятый риск: на сайте нет форм и действий, которые можно украсть кликджекингом.

### Доступность и производительность

- e2e-тесты с axe проходят по всем шаблонам страниц на всех языках, включая тестовую RTL-локаль.
- Lighthouse CI проверяет главную, статью, страницу анатомии, монитор и поиск: performance, accessibility, best-practices и SEO не ниже 90, мобильная эмуляция, три прогона и медиана.
- Пороги задаются через `assertions` LHCI: `largest-contentful-paint` до 2500 мс, `cumulative-layout-shift` до 0,1.
- Объём JS на странице статьи без 3D (до 100 КБ gzip) проверяет отдельный скрипт по `dist/`: он сжимает чанки, на которые ссылается страница. Так результат не зависит от того, сжимает ли `astro preview`.
- Страницы с 3D достигают 90 только если three.js грузится по взаимодействию или по появлению в зоне видимости, а до этого стоит постер. Это требование передаём этапу 1. Если не получится, для этих URL ставим отдельный порог производительности и записываем это в риски.
- Ручная проверка с клавиатурой и экранным диктором (VoiceOver или NVDA) по чек-листу в `docs/accessibility.md`: 3D-вьюер управляется с клавиатуры, у ЭКГ есть текстовое описание, у звука есть кнопка и нет автозапуска.
- `prefers-reduced-motion` (анимации сердца и ЭКГ по умолчанию на паузе) реализуют этапы 1 и 2, требование передаём их тредам. Здесь только проверка.

### Релиз

- `docs/` для контрибьюторов: как устроен проект, как добавить статью, язык, модель, пресет болезни. `CONTRIBUTING.md` остаётся точкой входа и ссылается на эти страницы, а не дублирует их. Отдельный сайт документации в 0.1 не нужен.
- `CHANGELOG.md`, `version: 0.1.0` в `package.json`, тег `v0.1.0` и GitHub Release создаёт Claude после мерджа последнего PR этапа.
- Анонс (тексты для каналов) готовим как черновик, публикует владелец.

## Разбиение на PR

| PR | Содержание | Готово, когда |
|---|---|---|
| PR 1 | Интеграция post-build с Pagefind, проп `searchable`, форма поиска в шапке, страница поиска, строки, e2e | Поиск по главным страницам находит текст на `ru` и `en`, ссылки результатов начинаются с `/heart-atlas/`, axe чистый |
| PR 2 | Манифесты по языкам, иконки, сервис-воркер, офлайн-страницы, уведомление об обновлении, e2e офлайна | Playwright проверяет манифест, регистрацию и офлайн, включая поиск |
| PR 3 | Синонимы `aliases` в макете статьи | Поиск по синониму находит статью (после схемы этапа 3) |
| PR 4 | `/embed/` и конструктор кода | Модули, готовые к этому моменту, открываются во `<iframe>` с плашкой и авторством |
| PR 5 | Пороги Lighthouse на всех шаблонах, скрипт объёма JS, `docs/accessibility.md`, правки по аудиту | Все шаблоны проходят пороги |
| PR 6 | Документация для контрибьюторов, CHANGELOG, релиз v0.1.0 | Релиз опубликован, сайт работает на `w3lld1.github.io/heart-atlas` |

PR 1 и PR 2 можно делать сразу. PR 3, 4 и 5 ждут страниц этапов 1–3, PR 6 ждёт всех.

## Риски

- Pagefind UI может оказаться тяжёлым или неудобным с клавиатуры. Тогда пишем свой остров на `pagefind.js`; страница поиска грузит его одна, на бюджет статей это не влияет.
- Предкэш растёт вместе с числом языков (индекс Pagefind на каждый). Сборка падает, если он больше 5 МБ; тогда индекс кэшируем по запросу, а предкэшем оставляем оболочку.
- Ошибки в `scope` воркера под `/heart-atlas/` ломают офлайн незаметно, поэтому офлайн проверяется в e2e.
- Страницы с 3D могут не достичь 90 по производительности на мобильных. Мера описана выше.
