# Portfolio Site

`06 · portfolio-site · Open source`

The site you're reading. React · Vite · TypeScript with token-first CSS modules, typed EN/RU/AR content, Markdown mirrors, privacy-first telemetry and route-aware navigation.

**Scope:** Solo · ongoing  
**Role:** Engineering portfolio · 2025–2026

---

## Context

> Six cases the recruiter cannot click into. One site they can.

Of the six showcased projects, five sit behind closed/NDA scope — client work, internal tooling, R&D. Recruiters cannot open them, cannot pull the repo, cannot diff the commit history. The site itself is the only public artifact a senior reviewer can audit on the spot: open DevTools, watch the network, read the CSS, view-source the dot-grid. Every detail visible to the user is also a decision visible to the engineer.

The constraint is dual. The page must scan for a recruiter in five seconds — fixed-shape cards, mono spec-sheets, ASCII diagrams that read like terminal output. It must also hold under a senior eye who scrolls slow — token-first design, hand-rolled i18n with compile-time parity, view transitions for the home → case morph, and a Hero ember field that does not cook iPhones. The site has to be its own case study, because nothing else here can be opened.

## Facts

| | |
|---|---|
| **Scope** | Solo · ongoing |
| **Surfaces** | Home + 6 cases · EN/RU/AR · dark/light · 3 palettes |
| **Source** | Public GitHub repository · CV PDF · copy-as-Markdown actions |
| **Stack** | React 18 · Vite · TypeScript · CSS variables |
| **Content** | Typed EN/RU/AR dictionaries · public/private content trees · generated Markdown mirrors |
| **Telemetry** | Cookieless same-origin events · admin-only analytics |
| **Motion** | View transitions (card → case hero FLIP-morph) · prefers-reduced-motion respected |
| **Status** | Open source · live |

## Architecture

### Provider tree + routing

```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>
```

**Provider order matters.** ThemeProvider outermost so the palette is set on `<html data-palette>` before any child reads color variables. LangProvider next so `useT()` is available everywhere. Loader, scroll restoration and analytics all sit inside the router because they need routed content or `useLocation()`.

**Two functional routes + 404 fallback.** `/` renders the home page; `/cases/:slug` renders the case-study page; `*` falls back to home for unknown URLs. Slug whitelist (`CASE_SLUGS = [ai-crm, roblox-game, ai-video-editor, ai-warehouse, macos-vpn, portfolio-site]`) lives in `src/config/cases.ts` — `Nav.tsx` and `ProjectDetailPage.tsx` consume it; server analytics keeps its own allowlists.

**No code-splitting yet.** Both routes ship in one bundle. Lighthouse stays green; revisit when a third route lands.

### Token resolution chain

```text
 1  ThemeContext        theme = 'dark'  →  palette = 'ochre'
        │
        ▼  writes data-attrs on <html>
 2  <html data-theme="dark" data-palette="ochre">
        │
        ▼  tokens.css matches via attribute selectors
 3  [data-theme="dark"]                       → --bg --fg --line
    [data-palette="ochre"][data-theme="dark"] → --accent --accent-soft
        │
        ▼  CSS modules imported by styles.css consume via var()
 4  .hero h1 { color: var(--fg) }
    .chip    { background: color-mix(in oklab, var(--bg) 10%, …) }
```

**Theme determines palette.** Derived deterministically — `dark → ochre` (warm gold accent), `light → electric` (cool blue accent). Both `data-theme` and `data-palette` written to `<html>`. CSS handles the swap via attribute selectors; zero JS branching per element.

**Token file + CSS modules, no Tailwind.** `tokens.css` (153 LOC) is the single source of truth for colors / spacing / type / radii. `styles.css` is now a tiny entry file importing `base`, `home`, `media`, `case-study` and `runtime` modules. The token system does what Tailwind config would; component selectors do what utility classes would.

**oklch + color-mix for backdrops.** Heavy use of `color-mix(in oklab, var(--bg) X%, transparent)` for tinted overlays — the frosted Hero chips, the case-study glows, the diagram backdrops. Avoids opacity tricks on the wrong color.

### Loader + interface entrance phases

```text
T=0          T=400 ms                       T=900 ms
│            │                              │
▼            ▼                              ▼
pulse    →   rush                       →   gone
hairline     two whiteish blurred           loader unmounted
opacity      pulses run from 15% / 85%      hero/about reveal
0.22-0.7     toward center (600 ms)         (staggered)

at T+400 ms of rush:
  html.is-loading removed (was set synchronously by inline <script> in index.html)
    → hero-fx + about-body cascade through enter-fade / enter-up keyframes
      (stagger T=0..1550 ms across hero / row-top / about / contact)
    → loader opacity fades 1→0 between T=400-900 ms
    → pulses arrive at center while loader is already mid-fade

prefers-reduced-motion: rush skipped, embers off, staggered entrances off
```

**Synchronous is-loading class.** `index.html` contains an inline `<script>` that sets `html.is-loading` *before* the React script loads. Hero/about elements have `opacity: 0` while that class is on. Zero FOUC — no `useEffect`-frame flash of unstyled content.

**Hairline aligned to real divider.** Loader hairline measures `#home.getBoundingClientRect().bottom` and sets `--loader-line-y` so its on-screen position matches the real Hero↔About divider. When the loader fades, the white pulsing hairline visually transitions into the existing accent-tinted divider in the same Y. Continuity gimmick — one frame of optical magic.

**Light comes on during rush, no flash.** Earlier version had a flash phase (radial accent burst at center). Replaced — `is-loading` removes at T+400 ms, hero entrance starts, loader fades 1→0 between T=400-900 ms. Pulses meet the lit interface, no harsh flash. `prefers-reduced-motion` skips the rush entirely.

## Key engineering decisions

### 01 · Tokens, not Tailwind — token file + split CSS modules

**Decision.** `tokens.css` defines colors / spacing / type / radii via CSS custom properties. `styles.css` is only the cascade entry point; actual selectors live in `base.css`, `home.css`, `media.css`, `case-study.css` and `runtime.css`. No utility framework, no CSS-in-JS, no UI library.

**Why.** A site with a strict design language gets diluted by Tailwind's utility class noise — the type scale, palette, and spacing have to be re-derived in config either way. The token system does what Tailwind would; component selectors do what utility classes would, but the code stays readable and grep-friendly. Splitting after the design stabilized keeps the same cascade while making bugs easier to bisect.

**Cost.** Adding a new heading size still means adding a token, never an inline `clamp()`. The split creates more files and import-order discipline; Vite bundles the imports back into one production stylesheet, so runtime cost stays unchanged.

### 02 · Two parallel content trees, not a key-based store

**Decision.** `useT()` returns a complete `Content` object per language — no flat `t('hero.title')` calls; consumers read `useT().hero.name`. Under the hood, two trees live side by side: `src/content/public/` (committed sanitized demo) and `src/content/.private/` (gitignored real). Explicit public/private scripts set `CONTENT_SOURCE`, run `select-content.mjs`, and write a generated `active.ts` barrel pointing at the selected tree. The `Content` type enforces EN/RU/AR parity at compile time — TypeScript rejects any tree if a key is missing.

**Why.** For three languages × ~250 strings, the naïve `{ "hero.title": { en, ru, ar } }` shape loses readability — paragraphs split across keys, no scan of full copy in one place. The custom `LangContext` (~70 LOC) + `useT()` gives autocomplete (`t.hero.name`), TS parity guarantee across all three trees, and direct paragraph-in-context editing. react-i18next ships ~25 KB minified for plurals / namespaces / interpolation the site does not use.

**Cost.** Adding a field forces all three languages at once or a transient TS error. Plurals or interpolation would need to be hand-rolled. For a translation-team workflow with a TMS, the trade flips back toward a key-based store.

### 03 · Markdown twin per page, not HTML scraping

**Decision.** Every public page has a generated Markdown sibling: `/index.md`, `/cases/<slug>.md`, plus RU `.ru.md` and AR `.ar.md` variants of each, plus `/llms.txt`, `/llms-ru.txt`, `/llms-ar.txt` and `/llms-full.txt`. The same serializers power the in-page copy-as-Markdown button.

**Why.** Five portfolio projects are private, and AI/search agents do a cleaner job citing a small Markdown twin than scraping rendered React HTML. The React UI stays optimized for humans; the Markdown layer gives agents and reviewers a stable text artifact generated from the same typed content, in every language.

**Cost.** Generated files are build artifacts, not source of truth. The Vite middleware has to serve `.md` / `.txt` directly with explicit UTF-8, and any content-shape change must keep the serializers in sync.

### 04 · Hero embers — CSS particles, not SVG filter + SMIL

**Decision.** 26 absolutely-positioned `<span>`s, each animated via CSS `@keyframes` on `transform: translate3d(...)` + `opacity` only. Glow via `box-shadow: 0 0 4px var(--accent)` (per-element GPU-cached). Per-particle CSS custom properties (`--x`, `--start-y`, `--drift`, `--size`, `--dur`, `--delay`) drive variety from a deterministic seed pattern.

**Why.** First version used an SVG `<filter>` with `feTurbulence + feDisplacementMap` on a group of 26 `<circle>`s + SMIL. iOS Safari was unusable — measurable phone heat within ~2 minutes on iPhone 16 Pro Max; cheap Android did not reproduce. Root cause: WebKit rasterizes SVG filters on CPU, the filter cache invalidates every frame on animated children, SMIL runs through a slow non-GPU path. Combined: 100% CPU pegged for what reads as a static ambient.

**Cost.** Lost the sub-pixel warble — visually subtle, not worth the cost. The static `<radialGradient>` stays in SVG (viewBox-stable, doesn't animate, effectively free). The general rule — SVG filters only on static elements; CSS transform + opacity for anything that moves — now lives across every ambient animation in the codebase.

### 05 · ASCII diagrams in <pre>, not Mermaid

**Decision.** Architecture diagrams live as plain template literals in `en.ts` / `ru.ts`, rendered into `<pre>`. Box-drawing chars (`─│┌┐└┘├┤┬┴┼╔╗╚╝═║▼▲►◄→←↑↓`) — JetBrains Mono renders them cleanly across themes. Image diagrams (airea n8n + roblox bestiary) are first-class through `images?: { src, alt, caption? }[]` alongside `ascii?` in the diagram type.

**Why.** ~200 KB of JS for three diagrams on a portfolio targeting Lighthouse 95+ is not justified. ASCII in mono on a `--bg-sunk` block reads as a deliberate engineer-style artifact — same energy as the `.hatch` placeholders. Source is right there in `en.ts` as a template literal — no round-trip through external tooling, no compile step, no runtime parse.

**Cost.** Diagrams must be hand-drafted; mistakes are visible (one off-by-one hairline shifted a column for two days). Cannot interactively zoom or collapse. Image variant ratio (`fr = W/H` for column widths to align heights) only stays correct with no inner padding on the `<img>` — caught one alignment bug after the fact. A guard tool / lint rule would have surfaced it earlier.

### 06 · Privacy-first telemetry, not public analytics theater

**Decision.** Client events go to a same-origin `/api/track` endpoint via `sendBeacon` with a fetch keepalive fallback. The event set is deliberately small: pageview, dwell, outbound, interaction and video. Visitor identity is daily-salted and the dashboard is admin-only.

**Why.** Public counters are a reverse signal on a low-traffic portfolio. The useful proof is the system design: cookieless events, route-aware dwell tracking, retention boundaries and an inspectable same-origin pipeline. Recruiters do not need volatile traffic numbers; senior reviewers can see the implementation path.

**Cost.** The system adds a tiny server surface and operational chores: SQLite backups, log rotation, salt rotation and admin auth. It should stay private unless the numbers become a meaningful signal.

### 07 · Sticky video provider + single-player rule + LiteYouTube facade

**Decision.** Each video card carries a `Mirror on RuTube ↔ Mirror on YouTube` toggle. The choice is global, sticky in `localStorage` — one click anywhere swaps every mounted player site-wide. A separate single-slot store holds the active player; opening a second card pauses the first via `src` recompute. Both ride a `LiteYouTube` facade — placeholder image until first click, no embed cost on initial paint.

**Why.** Per-card provider state would mean clicking RuTube on five cards in a row — the exact opposite intent. Session-sticky resets on reload. localStorage-sticky + global respects "I prefer this provider" once. Single-player rule prevents two simultaneous audio streams when the user clicks play on a new card without pausing the old one — including the autoplay-burst when the provider toggle fires and every mounted player re-renders.

**Cost.** Cross-tab sync via `storage` event is not implemented (other tabs pick up on reload). YouTube preserves position via `start=lastTime` param (driven by `recordVideoTime` postMessage); RuTube reloads from start (postMessage API less reliable across versions). Brief reload flicker on the paused card. Provider-swap autoplay-burst handled but adds gating logic to `LiteYouTube`.

### 08 · Public/private content trees, swapped via a generated barrel

**Decision.** Two parallel content trees: `src/content/public/` (committed sanitized demo) and `src/content/.private/` (gitignored real). Both export the same `{ en, ru }` shape against the same `Content` type. `scripts/select-content.mjs` requires explicit `CONTENT_SOURCE=public|private`; `build:public`, `build:private`, `dev:public`, and `dev:private` write `src/content/active.ts` before running Vite/TypeScript. Plain `build` and `dev` fail with guidance. A GitHub Actions leakage job blocks any commit of `.private/`, real media filenames, or Cyrillic outside `src/content/`.

**Why.** Recruiters get a complete inspectable site; private case copy stays out of the public repo. Without a swap mechanism the choice was vendor-coverage in a feature flag (runtime branching, dead code in the bundle) or two repos (drift between engine and content). The barrel is generated, not committed — so the two trees never confuse each other and the production bundle only contains the chosen one.

**Cost.** A fresh clone has no `active.ts` until a public/private script runs — IDE may show a transient TS error until then. The `.private/` tree must mirror the `public/` shape manually; a structural change in `Content` forces both updates. CI leakage rules need maintenance when new media patterns appear.

## Stack

| | |
|---|---|
| **Frontend** | React 18 · Vite · TypeScript · react-router-dom |
| **Styles** | CSS custom properties · tokens.css + 5 domain CSS modules · oklch palette · no Tailwind / UI library |
| **Content** | Custom i18n context · 3 typed dictionaries · generated Markdown mirrors · no 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 |
| **Scale** | ~8.5K LOC TS/TSX incl. content · ~2.5K LOC CSS · ~100 TS modules · 7 CSS files · 6 cases · 3 languages |

## Lessons & status

### Carry forward

- Token-first discipline — adding a new heading size means a new token, never an inline `clamp()`. Got bitten once when Stack's h2 went off-scale; the fix was introducing `--fs-display-l` shared across every section heading. Tokens are a compile-time guarantee against visual drift.
- Two parallel content trees (`public/` + `.private/`) swapped via a generated `active.ts` barrel — paragraphs read naturally in context, RU and AR mirror EN. TS-enforced parity across all three trees catches missing keys before they ship. Editing one paragraph doesn't fight a flat key registry.
- Synchronous `is-loading` class via inline `<script>` in `index.html` — zero FOUC on first paint. The React Loader removes the class during the reveal phase. No `useEffect`-frame flash of unstyled content.
- Default to CSS transform + opacity for any ambient animation — the iOS Safari ember rewrite is the war story. SVG `<filter>` on animated content is CPU-bound on WebKit; same look from CSS particles + box-shadow runs cool. General rule: SVG filters only on static elements unless there's explicit budget.
- Split `styles.css` into per-domain modules (`base / home / media / case-study / runtime`) once the page stabilized — the single-file phase was right for rapid iteration; per-domain split is right post-stabilization. Cross-section cascade bugs now bisect to a 200–650 line file; Vite bundles the @imports back at build time, so the production bundle is unchanged.
- Markdown mirrors as a first-class agent layer — the same typed content now serves humans in React and agents through `.md` / `llms.txt` without duplicating copy by hand.

### Would change

- `react-router-dom` for two routes is honest overkill. Bundle cost ~12 KB gzipped — trivial — but a thirty-line custom router would have removed a dependency. `ScrollToHash` already needs `useLocation`, dragging the router in anyway; live with it until a third route lands.
- Image-diagram variant (`images?: []` alongside `ascii?` in `CaseStudyDiagram`) was added after the ASCII convention was already cemented. The `imageCols` ratio (`fr = W/H`) only stays correct with no inner padding on the `<img>` — caught one alignment bug after the fact. A guard tool or lint rule would have surfaced it earlier.
- Frontend slug registry now lives in `src/config/cases.ts` — `Nav.tsx` and `ProjectDetailPage.tsx` consume it. The server side still keeps its own copies (`KNOWN_PATHS` in `ingest.js`, `SLUGS` in `admin.js`); next cleanup is a shared JSON read by both, so adding or renaming a case is one edit, not a checklist.

Open source · live · ongoing. Public GitHub source, generated Markdown mirrors, three typed dictionaries (EN/RU/AR), privacy-first telemetry and two themes × three palettes. The site is its own case study.

---

Source: https://ilyadev.xyz/cases/portfolio-site (HTML) · /cases/portfolio-site.md (this file)
Previous: 05 — macOS VPN · per-app routing → https://ilyadev.xyz/cases/macos-vpn.md
Index: https://ilyadev.xyz/llms.txt — full case-study list
Author: Ilya Kazantsev — https://ilyadev.xyz/index.md
