# Сайт-портфолио

`06 · portfolio-site · Open source`

Сайт, который ты сейчас читаешь. React · Vite · TypeScript, token-first CSS-модули, типизированный EN/RU/AR-контент, Markdown mirrors, privacy-first telemetry и route-aware навигация.

**Скоуп:** Соло · в работе  
**Роль:** Сайт инженерного портфолио · 2025–2026

---

## Контекст

> Шесть кейсов, в которые рекрутер не может кликнуть. Один сайт, в который может.

Из шести показанных проектов пять — за closed/NDA-скоупом: клиентская работа, внутренний тулинг, R&D. Рекрутер не может их открыть, не может склонировать репо, не может посмотреть git-историю. Сам сайт — единственный публичный артефакт, который senior-ревьюер может реально аудитировать прямо сейчас: открыть DevTools, посмотреть network, прочитать CSS, view-source dot-grid. Каждая видимая пользователю деталь — одновременно решение, видимое инженеру.

Ограничение двойное. Страница должна сканироваться рекрутером за пять секунд — фиксированной формы карточки, моно-spec-листы, ASCII-диаграммы, читающиеся как terminal-вывод. И она должна выдержать senior-глаз, который скроллит медленно — token-first дизайн, ручной i18n с compile-time парностью, view-transitions для morph-перехода с домашней карточки в hero кейса, и Hero-эмберы, которые не греют iPhone. Сайт обязан быть собственным кейсом — потому что больше здесь ничего открыть нельзя.

## Факты

| | |
|---|---|
| **Скоуп** | Соло · в работе |
| **Поверхности** | Главная + 6 кейсов · EN/RU/AR · dark/light · 3 палитры |
| **Source** | Публичный GitHub repo · CV PDF · copy-as-Markdown actions |
| **Stack** | React 18 · Vite · TypeScript · CSS-переменные |
| **Контент** | Типизированные EN/RU/AR-словари · public/private content trees · generated Markdown mirrors |
| **Telemetry** | Cookieless same-origin events · admin-only analytics |
| **Анимации** | View transitions (карточка → case hero FLIP-morph) · prefers-reduced-motion соблюдается |
| **Статус** | Open source · live |

## Архитектура

### Дерево провайдеров + роутинг

```text
<ThemeProvider>           // src/theme/ThemeContext.tsx
  <LangProvider>          // src/i18n/LangContext.tsx
    <BrowserRouter>
      <Loader />
      <ScrollToHash />
      <AnalyticsRouteTracker />
      <Routes>
        <Route "/"            → <HomePage />
        <Route "/cases/:slug" → <CaseRoute /> → <ProjectDetailPage />
        <Route "*"            → <HomePage />
      </Routes>
    </BrowserRouter>
  </LangProvider>
</ThemeProvider>
```

**Порядок провайдеров важен.** ThemeProvider снаружи — палитра ставится на `<html data-palette>` до того, как любой ребёнок прочитает color-переменные. LangProvider следующий — `useT()` доступен везде. Loader, scroll restoration и analytics живут внутри роутера, потому что им нужен routed content или `useLocation()`.

**Два функциональных роута + 404 fallback.** `/` рендерит главную; `/cases/:slug` — страницу кейса; `*` уводит на главную для неизвестных URL. Whitelist слагов (`CASE_SLUGS = [ai-crm, roblox-game, ai-video-editor, ai-warehouse, macos-vpn, portfolio-site]`) живёт в `src/config/cases.ts` — `Nav.tsx` и `ProjectDetailPage.tsx` импортируют его; server analytics держит свои отдельные allowlists.

**Code-splitting пока нет.** Оба роута уезжают одним бандлом. Lighthouse зелёный; пересмотрю, когда появится третий роут.

### Цепочка резолвинга токенов

```text
 1  ThemeContext        theme = 'dark'  →  palette = 'ochre'
        │
        ▼  пишет data-атрибуты в <html>
 2  <html data-theme="dark" data-palette="ochre">
        │
        ▼  tokens.css матчит через attribute-селекторы
 3  [data-theme="dark"]                       → --bg --fg --line
    [data-palette="ochre"][data-theme="dark"] → --accent --accent-soft
        │
        ▼  CSS-модули, импортированные styles.css, читают через var()
 4  .hero h1 { color: var(--fg) }
    .chip    { background: color-mix(in oklab, var(--bg) 10%, …) }
```

**Тема определяет палитру.** Выводится детерминистически — `dark → ochre` (тёплый золотой акцент), `light → electric` (холодный синий акцент). На `<html>` пишется и `data-theme`, и `data-palette`. CSS делает свап через attribute-селекторы; нулевое JS-ветвление per-element.

**Token-файл + CSS-модули, без Tailwind.** `tokens.css` (153 LOC) — единый источник правды для цветов / spacing / type / radii. `styles.css` теперь маленький entry-файл, который импортирует `base`, `home`, `media`, `case-study` и `runtime`. Token-система делает то, что делал бы Tailwind config; component-селекторы делают то, что делали бы utility-классы.

**oklch + color-mix для бэкдропов.** Активно используется `color-mix(in oklab, var(--bg) X%, transparent)` для тонированных оверлеев — frosted Hero-чипсы, case-study glow, бэкдропы диаграмм. Избегает opacity-трюков на неправильном цвете.

### Loader + фазы появления интерфейса

```text
T=0          T=400 ms                       T=900 ms
│            │                              │
▼            ▼                              ▼
pulse    →   rush                       →   gone
hairline     два беловатых blurred           loader размонтирован
opacity      pulses бегут с 15% / 85%        hero/about появляются
0.22-0.7     к центру (600 ms)               (с staggered)

в T+400 ms фазы rush:
  html.is-loading удаляется (был выставлен синхронно inline <script> в index.html)
    → hero-fx + about-body каскадируют через enter-fade / enter-up keyframes
      (stagger T=0..1550 ms по hero / row-top / about / contact)
    → opacity loader'а уходит 1→0 между T=400-900 ms
    → pulses встречаются в центре, когда loader уже в середине fade

prefers-reduced-motion: rush пропускается, эмберы выкл, staggered-появления выкл
```

**Синхронный is-loading.** `index.html` содержит inline `<script>`, который ставит `html.is-loading` *до* загрузки React-скрипта. Hero/about-элементы имеют `opacity: 0` пока этот класс на месте. Ноль FOUC — нет `useEffect`-кадра flash of unstyled content.

**Hairline выровнен по реальному divider.** Loader-hairline измеряет `#home.getBoundingClientRect().bottom` и ставит `--loader-line-y` так, чтобы его экранная позиция совпадала с реальным Hero↔About divider. Когда loader уходит в fade, белый pulsing-hairline визуально перетекает в существующий accent-tinted divider в той же Y. Continuity-приём — один кадр оптической магии.

**Свет включается во время rush, без вспышки.** Раньше была фаза flash (radial accent burst в центре). Заменено — `is-loading` удаляется в T+400 ms, hero-вход стартует, loader исчезает в fade 1→0 между T=400-900 ms. Pulses встречают уже подсвеченный интерфейс, без резкой вспышки. `prefers-reduced-motion` пропускает rush целиком.

## Ключевые инженерные решения

### 01 · Tokens, не Tailwind — token-файл + split CSS modules

**Решение.** `tokens.css` описывает цвета / spacing / type / radii через CSS-переменные. `styles.css` — только cascade entry point; реальные селекторы живут в `base.css`, `home.css`, `media.css`, `case-study.css` и `runtime.css`. Никакого utility-фреймворка, никакого CSS-in-JS, никакой UI-библиотеки.

**Почему.** Сайт со строгим дизайн-языком разжижается utility-class-шумом Tailwind — type scale, palette и spacing всё равно надо передеривить в config. Token-система делает то же, что Tailwind config; component-селекторы — то же, что utility-классы, но код остаётся читаемым и grep-friendly. Split после стабилизации дизайна сохраняет тот же cascade, но упрощает поиск багов.

**Цена.** Добавление новой высоты заголовка всё ещё означает добавить токен, а не inline `clamp()`. Split создаёт больше файлов и требует дисциплины import-order; Vite собирает imports обратно в один production stylesheet, поэтому runtime cost не меняется.

### 02 · Два параллельных контент-дерева, не key-based store

**Решение.** `useT()` возвращает полный `Content`-объект на язык — никаких плоских `t('hero.title')`-вызовов; consumer'ы читают `useT().hero.name`. Под капотом два дерева живут рядом: `src/content/public/` (committed sanitized demo) и `src/content/.private/` (gitignored real). Явные public/private scripts ставят `CONTENT_SOURCE`, запускают `select-content.mjs` и пишут generated `active.ts` barrel, реэкспортирующий выбранное дерево. `Content`-тип форсит парность EN/RU/AR на compile-time — TypeScript отвергает любое дерево, если в нём не хватает ключа.

**Почему.** Для трёх языков × ~250 строк наивная форма `{ "hero.title": { en, ru, ar } }` теряет читаемость — параграфы разбиваются по ключам, нет скана полного текста в одном месте. Кастомный `LangContext` (~70 LOC) + `useT()` даёт автокомплит (`t.hero.name`), TS-гарантию парности по всем трём деревьям и редактирование-параграфа-в-контексте. react-i18next весит ~25 KB minified ради plurals / namespaces / interpolation, которых на сайте нет.

**Цена.** Добавление поля форсит обновить все три языка или жить с transient TS-ошибкой. Plurals или interpolation пришлось бы делать руками. Для translation-team workflow с TMS трейд переворачивается обратно в сторону key-based store.

### 03 · Markdown twin для каждой страницы, не HTML scraping

**Решение.** У каждой публичной страницы есть generated Markdown sibling: `/index.md`, `/cases/<slug>.md`, плюс RU `.ru.md` и AR `.ar.md` варианты каждого, плюс `/llms.txt`, `/llms-ru.txt`, `/llms-ar.txt` и `/llms-full.txt`. Те же serializers питают in-page copy-as-Markdown кнопку.

**Почему.** Пять проектов в портфолио приватные, а AI/search agents чище цитируют маленький Markdown twin, чем скрейпят rendered React HTML. React UI остаётся оптимизированным под людей; Markdown layer даёт агентам и ревьюерам стабильный текстовый artifact из того же typed content — на каждом языке.

**Цена.** Generated files — build artifacts, не source of truth. Vite middleware должен отдавать `.md` / `.txt` напрямую с явным UTF-8, а любое изменение content-shape требует держать serializers в синхроне.

### 04 · Hero-эмберы — CSS-частицы, не SVG filter + SMIL

**Решение.** 26 абсолютно-позиционированных `<span>`, каждый анимируется CSS `@keyframes` только на `transform: translate3d(...)` + `opacity`. Glow через `box-shadow: 0 0 4px var(--accent)` (per-element GPU-кеш). Per-particle CSS-переменные (`--x`, `--start-y`, `--drift`, `--size`, `--dur`, `--delay`) задают вариативность из детерминированного seed-паттерна.

**Почему.** Первая версия использовала SVG `<filter>` с `feTurbulence + feDisplacementMap` поверх группы из 26 `<circle>` + SMIL. iOS Safari был непригоден — измеримый нагрев телефона в течение ~2 минут на iPhone 16 Pro Max; на cheap-Android не воспроизводилось. Корневая причина: WebKit растеризует SVG-фильтры на CPU, filter-cache инвалидируется каждый кадр на анимирующихся детях, SMIL работает через slow non-GPU path. Суммарно: 100% CPU pegged ради того, что выглядит как статичный ambient.

**Цена.** Потеряли sub-pixel warble — визуально субтильно, не стоит цены. Статичный `<radialGradient>` остаётся в SVG (viewBox-stable, не анимируется, фактически бесплатно). Общий урок — SVG-фильтры только на статичных элементах; CSS transform + opacity для всего, что движется — теперь живёт по всем ambient-анимациям в кодбейзе.

### 05 · ASCII-диаграммы в <pre>, не Mermaid

**Решение.** Архитектурные диаграммы живут как plain template-литералы в `en.ts` / `ru.ts`, рендерятся в `<pre>`. Box-drawing символы (`─│┌┐└┘├┤┬┴┼╔╗╚╝═║▼▲►◄→←↑↓`) — JetBrains Mono рендерит их чисто во всех темах. Image-диаграммы (airea n8n + roblox bestiary) — first-class через `images?: { src, alt, caption? }[]` рядом с `ascii?` в типе диаграммы.

**Почему.** ~200 KB JS ради трёх диаграмм на портфолио, нацеленном на Lighthouse 95+, не оправдано. ASCII в моно на `--bg-sunk`-блоке читается как намеренный engineer-style артефакт — той же энергии, что `.hatch`-плейсхолдеры. Источник прямо в `en.ts` как template-литерал — никакого round-trip через external-тулинг, никакого compile-шага, никакого runtime-парсинга.

**Цена.** Диаграммы рисуются вручную; ошибки видны (один off-by-one hairline сдвинул колонку на два дня). Нет интерактивного зума или collapse. Image-вариант ratio (`fr = W/H` для ширин колонок, чтобы выравнять высоты) корректен только при отсутствии inner padding на `<img>` — поймал один alignment-баг постфактум. Guard tool / lint rule вытащил бы это раньше.

### 06 · Privacy-first telemetry, не public analytics theater

**Решение.** Client events уходят в same-origin `/api/track` через `sendBeacon` с fetch keepalive fallback. Event set намеренно маленький: pageview, dwell, outbound, interaction и video. Visitor identity daily-salted, dashboard admin-only.

**Почему.** Public counters — reverse signal для low-traffic portfolio. Полезный proof — дизайн системы: cookieless events, route-aware dwell tracking, retention boundaries и inspectable same-origin pipeline. Рекрутерам не нужны volatile traffic numbers; senior reviewers могут увидеть implementation path.

**Цена.** Система добавляет маленькую server surface и operational chores: SQLite backups, log rotation, salt rotation и admin auth. Держать приватной, пока цифры сами не станут значимым сигналом.

### 07 · Sticky video-провайдер + single-player rule + LiteYouTube facade

**Решение.** Каждая видео-карточка несёт toggle `Mirror on RuTube ↔ Mirror on YouTube`. Выбор global, sticky в `localStorage` — один клик где угодно свапает каждый смонтированный плеер на сайте. Отдельный single-slot store держит активный плеер; открытие второй карточки паузит первую через `src` recompute. Оба построены поверх `LiteYouTube`-facade — placeholder-картинка до первого клика, нулевой embed-cost на initial paint.

**Почему.** Per-card provider state означал бы клик RuTube на пять карточек подряд — ровно противоположное намерение. Session-sticky сбрасывался бы на reload. localStorage-sticky + global уважает «мне удобнее этот провайдер» один раз. Single-player rule предотвращает два одновременных аудио-стрима, когда юзер кликает play на новой карточке без паузы старой — включая autoplay-burst, когда срабатывает provider-toggle и каждый mounted-плеер ре-рендерится.

**Цена.** Cross-tab sync через `storage`-event не реализован (другие табы подхватывают на reload). YouTube сохраняет позицию через `start=lastTime` параметр (управляется `recordVideoTime` postMessage); RuTube перезагружается с нуля (postMessage API менее надёжный между версиями). Brief reload-flicker на запущенной карточке. Provider-swap autoplay-burst обрабатывается, но добавляет gating-логику в `LiteYouTube`.

### 08 · Public/private контент-деревья, переключаемые через generated barrel

**Решение.** Два параллельных контент-дерева: `src/content/public/` (committed sanitized demo) и `src/content/.private/` (gitignored real). Оба экспортируют ту же форму `{ en, ru }` против того же `Content`-типа. `scripts/select-content.mjs` требует явный `CONTENT_SOURCE=public|private`; `build:public`, `build:private`, `dev:public` и `dev:private` пишут `src/content/active.ts` перед Vite/TypeScript. Plain `build` и `dev` падают с подсказкой. GitHub Actions leakage-job блокирует любой коммит `.private/`, реальных media-имён или кириллицы вне `src/content/`.

**Почему.** Рекрутерам отдаётся полный inspectable-сайт; приватные тексты кейсов остаются вне публичного репо. Без swap-механизма выбор был бы — vendor-cover в feature flag (runtime-ветвление, dead-code в бандле) или два репо (drift между движком и контентом). Barrel — generated, а не commited, поэтому два дерева не путают друг друга и production-бандл содержит только выбранное.

**Цена.** Свежий клон не имеет `active.ts` пока public/private script не запустится — IDE может показать transient TS-ошибку до этого. `.private/` дерево обязано вручную mirror-ить форму `public/`; структурное изменение в `Content` форсит обновить оба. CI leakage-правила требуют поддержки, когда появляются новые media-паттерны.

## Стек

| | |
|---|---|
| **Frontend** | React 18 · Vite · TypeScript · react-router-dom |
| **Стили** | CSS custom properties · tokens.css + 5 domain CSS modules · oklch palette · без Tailwind / UI-библиотеки |
| **Контент** | Кастомный i18n context · 3 typed dictionaries · generated Markdown mirrors · без react-i18next |
| **Motion/video** | View transitions API · CSS @keyframes · LiteYouTube facade · sticky provider toggle · single-player rule |
| **Telemetry** | Same-origin `/api/track` · route-aware dwell · daily-salted visitor hash · admin-only dashboard |
| **Объём** | ~8.5K LOC TS/TSX incl. content · ~2.5K LOC CSS · ~100 TS modules · 7 CSS files · 6 кейсов · 3 языка |

## Уроки и статус

### Оставлю как есть

- Token-first дисциплина — добавление новой высоты заголовка означает новый токен, никогда не inline `clamp()`. Уже один раз попался, когда h2 у Stack ушёл со шкалы; фикс был — ввести `--fs-display-l`, шаренный между всеми section-headings. Токены — compile-time гарантия от visual drift.
- Два параллельных контент-дерева (`public/` + `.private/`), переключаемых через generated `active.ts` barrel — параграфы читаются естественно в контексте, RU и AR зеркалят EN. TS-форсит парность по всем трём деревьям, ловит missing-keys до того, как они уйдут в прод. Редактирование одного параграфа не воюет с плоским key-registry.
- Синхронный `is-loading`-класс через inline `<script>` в `index.html` — ноль FOUC на первый paint. React-Loader удаляет класс в фазу reveal. Никакого `useEffect`-кадра flash of unstyled content.
- Дефолт — CSS transform + opacity для любой ambient-анимации — iOS Safari ember-rewrite это история-урок. SVG `<filter>` на анимированном контенте CPU-bound на WebKit; тот же look из CSS-частиц + box-shadow работает холодно. Общее правило: SVG-фильтры только на статичных элементах, если нет explicit-бюджета.
- Разделил `styles.css` на per-domain модули (`base / home / media / case-study / runtime`), когда страница стабилизировалась — single-file фаза была правильна для быстрой итерации, per-domain split правилен post-stabilization. Кросс-секционные cascade-баги теперь bisect-ятся в файле на 200–650 строк; Vite собирает `@import`-ы обратно на build, production-бандл неизменен.
- Markdown mirrors как first-class agent layer — тот же typed content теперь обслуживает людей через React и агентов через `.md` / `llms.txt` без ручного дублирования текста.

### Поменял бы

- `react-router-dom` для двух роутов — честный overkill. Bundle cost ~12 KB gzipped — пустяк, — но кастомный роутер на 30 строк убрал бы зависимость. `ScrollToHash` уже нуждается в `useLocation`, втягивая router в любом случае; живём с ним до появления третьего роута.
- Image-вариант диаграмм (`images?: []` рядом с `ascii?` в `CaseStudyDiagram`) был добавлен после того, как ASCII-конвенция уже устоялась. Ratio `imageCols` (`fr = W/H`) корректен только при отсутствии inner padding на `<img>` — поймал один alignment-баг постфактум. Guard tool или lint rule вытащил бы это раньше.
- Frontend slug registry теперь живёт в `src/config/cases.ts` — `Nav.tsx` и `ProjectDetailPage.tsx` импортируют оттуда. Server-сторона всё ещё держит отдельные копии (`KNOWN_PATHS` в `ingest.js`, `SLUGS` в `admin.js`); следующий cleanup — shared JSON, читаемый обоими, чтобы добавление или rename кейса были одним edit, не checklist.

Open source · live · ongoing. Публичный GitHub source, generated Markdown mirrors, три typed dictionaries (EN/RU/AR), privacy-first telemetry и две темы × три палитры. Сайт — это и есть свой кейс.

---

Источник: https://ilyadev.xyz/cases/portfolio-site (HTML) · /cases/portfolio-site.ru.md (этот файл)
Назад: 05 — macOS VPN · per-app роутинг → https://ilyadev.xyz/cases/macos-vpn.ru.md
Индекс: https://ilyadev.xyz/llms-ru.txt — полный список кейсов
Автор: Илья Казанцев — https://ilyadev.xyz/index.ru.md
