diff --git a/docs/superpowers/plans/2026-04-14-phase-0-preflight.md b/docs/superpowers/plans/2026-04-14-phase-0-preflight.md
new file mode 100644
index 00000000..d5ad1217
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-14-phase-0-preflight.md
@@ -0,0 +1,2246 @@
+# Phase 0 — Preflight Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Capture everything needed from the current Angular prod app (URL corpus, SEO baselines, VRT baselines, component inventories, translation keys) and confirm customer-side spec assumptions, so Phase 1 foundation work can proceed without blockers.
+
+**Architecture:** Discovery-only phase. No production change. Produces committed fixtures under `tests/fixtures/phase-0/` and inventory documents under `docs/superpowers/phase-0/` that Phase 1 and later phases depend on. Introduces a minimal top-level `package.json` + `scripts/phase-0/` at the repo root; no framework or runtime is installed beyond what the capture scripts need.
+
+**Tech Stack:** Node 20, pnpm, TypeScript + `tsx`, Playwright, `cheerio`, `zod`, `schema-dts`, Vitest (for the few pure-function tests in this phase).
+
+**Reference spec:** `docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md` — Phase 0 is described in §9.2 of that document.
+
+---
+
+## Prerequisites — block Phase 0 start until resolved
+
+These are not tasks; they are gates. Confirm each before running Task 1.
+
+- [ ] **Production URL access.** The capture scripts fetch pages from the live Angular app. Get the production URL (likely `https://flights.aeroflot.ru` or equivalent). If prod is rate-limited, get an unthrottled staging mirror URL that is byte-equivalent to prod.
+- [ ] **Access-log availability decision.** The URL-corpus task is ideally driven by anonymized prod access logs. If those are unavailable, fall back to enumerating Angular route definitions directly (Task 4 supports both modes).
+- [ ] **Customer point-of-contact.** Identify who will answer the spec assumptions A1–A5 listed in `docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md` §10. Task 16 produces the questionnaire; a human must deliver it.
+
+---
+
+## File structure
+
+Files created by Phase 0:
+
+**Repo root — minimal project skeleton (extended in Phase 1):**
+```
+package.json # minimal — only scripts/phase-0 deps
+pnpm-lock.yaml # frozen
+pnpm-workspace.yaml # excludes ClientApp/ from workspace
+.nvmrc # 20.11.0
+tsconfig.json # root TS config for scripts
+.gitignore # ignores dist/, node_modules/, playwright-report/, tests/fixtures/phase-0/vrt-baselines/raw/
+playwright.phase0.config.ts # Phase-0-only Playwright config
+```
+
+**Phase 0 scripts:**
+```
+scripts/phase-0/lib/angular-routes.ts # list of Angular route shapes (source of truth for URL enumeration)
+scripts/phase-0/lib/http.ts # small fetch wrapper with retry + throttle
+scripts/phase-0/lib/io.ts # read/write JSON + PNG helpers
+scripts/phase-0/anonymize-access-logs.ts # optional: raw log → anonymized URL list
+scripts/phase-0/extract-url-corpus.ts # writes url-corpus/*.json from routes + optional logs
+scripts/phase-0/capture-seo-baselines.ts # fetches pages, extracts
+ JSON-LD
+scripts/phase-0/capture-hreflang-parity.ts # verifies reciprocal hreflang on every language variant
+scripts/phase-0/capture-vrt-baselines.ts # Playwright driver for VRT baseline capture
+scripts/phase-0/inventory-primeng.ts # scans ClientApp/ for PrimeNG usages
+scripts/phase-0/inventory-scss-tokens.ts # extracts SCSS variable + theme override list
+scripts/phase-0/inventory-translation-keys.ts # finds @ngx-translate keys actually referenced in templates/TS
+```
+
+**Committed fixtures:**
+```
+tests/fixtures/phase-0/url-corpus/onlineboard.json # enumerated + observed URLs
+tests/fixtures/phase-0/url-corpus/schedule.json
+tests/fixtures/phase-0/url-corpus/flights-map.json
+tests/fixtures/phase-0/url-corpus/popular.json
+tests/fixtures/phase-0/seo-baselines/.json # ~20 files
+tests/fixtures/phase-0/hreflang-parity/.json
+tests/fixtures/phase-0/vrt-baselines/--.png # ~60 files
+```
+
+**Committed documents:**
+```
+docs/superpowers/phase-0/README.md # index of Phase 0 deliverables
+docs/superpowers/phase-0/primeng-backlog.md # PrimeNG component inventory
+docs/superpowers/phase-0/scss-theme-manifest.md # SCSS token/theme port list
+docs/superpowers/phase-0/translation-keys-used.md # which keys are live
+docs/superpowers/phase-0/customer-confirmation-checklist.md # A1–A5 + answers
+```
+
+---
+
+## Task 1: Attach spec commit to a working branch
+
+The spec commit `75dbec0` currently sits on detached HEAD. Before any other work, move it to a branch.
+
+**Files:**
+- None (git operations only)
+
+- [ ] **Step 1: Create working branch from current HEAD**
+
+```bash
+git checkout -b feat/react-migration-phase0
+```
+
+Expected: `Switched to a new branch 'feat/react-migration-phase0'`
+
+- [ ] **Step 2: Verify the spec commit is reachable from the new branch**
+
+```bash
+git log --oneline -3
+```
+
+Expected first line: `75dbec0 Add design spec for Angular-to-React MF remote rewrite`
+
+- [ ] **Step 3: Push the branch to origin**
+
+```bash
+git push -u origin feat/react-migration-phase0
+```
+
+Expected: new remote branch created; tracking set.
+
+---
+
+## Task 2: Root project scaffolding (package.json + TS config + gitignore)
+
+Create the minimal project skeleton at the repo root. This is additive — the existing `ClientApp/` Angular app is untouched and explicitly excluded from the workspace.
+
+**Files:**
+- Create: `package.json`
+- Create: `pnpm-workspace.yaml`
+- Create: `.nvmrc`
+- Create: `tsconfig.json`
+- Modify: `.gitignore` (create if missing)
+
+- [ ] **Step 1: Pin Node version**
+
+Create `.nvmrc` at repo root with exactly:
+```
+20.11.0
+```
+
+- [ ] **Step 2: Create minimal root `package.json`**
+
+Create `package.json` at repo root:
+
+```json
+{
+ "name": "aeroflot-flights-web",
+ "private": true,
+ "version": "0.0.0",
+ "packageManager": "pnpm@9.12.0",
+ "engines": {
+ "node": ">=20.11.0"
+ },
+ "scripts": {
+ "phase0:url-corpus": "tsx scripts/phase-0/extract-url-corpus.ts",
+ "phase0:seo": "tsx scripts/phase-0/capture-seo-baselines.ts",
+ "phase0:hreflang": "tsx scripts/phase-0/capture-hreflang-parity.ts",
+ "phase0:vrt": "playwright test --config=playwright.phase0.config.ts",
+ "phase0:inventory:primeng": "tsx scripts/phase-0/inventory-primeng.ts",
+ "phase0:inventory:scss": "tsx scripts/phase-0/inventory-scss-tokens.ts",
+ "phase0:inventory:i18n": "tsx scripts/phase-0/inventory-translation-keys.ts",
+ "test": "vitest run"
+ },
+ "devDependencies": {}
+}
+```
+
+- [ ] **Step 3: Exclude `ClientApp/` from the workspace**
+
+Create `pnpm-workspace.yaml`:
+
+```yaml
+packages:
+ - '.'
+ - '!ClientApp'
+```
+
+- [ ] **Step 4: Create root `tsconfig.json`**
+
+Create `tsconfig.json`:
+
+```json
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022", "DOM"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "allowSyntheticDefaultImports": true,
+ "types": ["node"],
+ "baseUrl": ".",
+ "paths": {
+ "@phase0/*": ["scripts/phase-0/*"]
+ }
+ },
+ "include": ["scripts/**/*.ts", "tests/**/*.ts"],
+ "exclude": ["ClientApp", "node_modules", "dist"]
+}
+```
+
+- [ ] **Step 5: Update `.gitignore`**
+
+Add these lines to `.gitignore` (create the file if it doesn't exist at the repo root):
+
+```
+# Phase 0 + later
+node_modules/
+dist/
+.DS_Store
+playwright-report/
+test-results/
+.playwright/
+
+# Ephemeral capture artifacts (final baselines are committed, raw intermediate files are not)
+tests/fixtures/phase-0/vrt-baselines/raw/
+```
+
+- [ ] **Step 6: Commit scaffolding**
+
+```bash
+git add package.json pnpm-workspace.yaml .nvmrc tsconfig.json .gitignore
+git commit -m "Add root project scaffolding for React rewrite
+
+Phase 0 introduces a minimal top-level package.json + TS config at the
+repo root. ClientApp/ is excluded from the workspace so the Angular app
+keeps its own dependency tree during the strangler-fig migration."
+```
+
+---
+
+## Task 3: Install Phase 0 toolchain
+
+Install the minimal dependency set needed for Phase 0 capture scripts. Do NOT install Modern.js, React, or anything else — that happens in Phase 1A.
+
+**Files:**
+- Modify: `package.json`
+- Create: `pnpm-lock.yaml`
+
+- [ ] **Step 1: Install dev dependencies**
+
+```bash
+pnpm add -D \
+ typescript@^5.5.0 \
+ tsx@^4.19.0 \
+ @types/node@^20.11.0 \
+ playwright@^1.47.0 \
+ @playwright/test@^1.47.0 \
+ cheerio@^1.0.0 \
+ zod@^3.23.0 \
+ schema-dts@^1.1.2 \
+ vitest@^2.1.0
+```
+
+Expected: `pnpm-lock.yaml` created; `node_modules/` populated; no warnings about peer dependency conflicts.
+
+- [ ] **Step 2: Install Playwright browsers**
+
+```bash
+pnpm exec playwright install chromium
+```
+
+Expected: Chromium downloaded to Playwright's cache.
+
+- [ ] **Step 3: Verify `tsx` runs a trivial script**
+
+Create a throwaway sanity check — do not commit:
+
+```bash
+echo 'console.log("tsx works", process.version)' > /tmp/sanity.ts
+pnpm exec tsx /tmp/sanity.ts
+rm /tmp/sanity.ts
+```
+
+Expected output: `tsx works v20.x.x`
+
+- [ ] **Step 4: Commit lockfile + dep additions**
+
+```bash
+git add package.json pnpm-lock.yaml
+git commit -m "Install Phase 0 capture toolchain
+
+Minimal dependency set for Phase 0 scripts: Playwright (VRT + SEO
+capture), cheerio (HTML parsing), zod (fixture schema validation),
+schema-dts (JSON-LD types), tsx + typescript. No framework yet."
+```
+
+---
+
+## Task 4: URL corpus — enumerate Angular route shapes
+
+The URL corpus is the source of truth for every URL the Angular app currently serves. It drives SEO capture, VRT capture, and the Phase 2 URL parity tests. Two sources: (a) the static route definitions in `ClientApp/src/`, which gives us every *shape* of URL; (b) optionally, anonymized access logs, which give us *real observed values* for the dynamic segments.
+
+This task covers (a). Task 5 covers (b).
+
+**Files:**
+- Create: `scripts/phase-0/lib/angular-routes.ts`
+- Create: `scripts/phase-0/lib/io.ts`
+- Create: `tests/phase-0/lib/angular-routes.test.ts`
+
+- [ ] **Step 1: Read the Angular route definitions to build the list**
+
+Before writing code, read these files in `ClientApp/src/app/` to confirm the route shapes:
+
+```bash
+cat ClientApp/src/app/app-routing.module.ts
+```
+
+```bash
+find ClientApp/src/app/features -name '*-routing.module.ts' -exec echo '===' {} ';' -exec cat {} ';'
+```
+
+Record the route shapes and their param-parsing rules. You'll hand-transcribe them into the next step — the Angular files are the authoritative source.
+
+- [ ] **Step 2: Write the route-shape catalog**
+
+Create `scripts/phase-0/lib/angular-routes.ts`:
+
+```ts
+/**
+ * Catalog of Angular route shapes, authored by hand from the current
+ * ClientApp/src/app routing modules. This is the source of truth for
+ * URL enumeration in Phase 0.
+ *
+ * Each entry is a template with :params placeholders. The sample values
+ * below are representative defaults used when access logs are unavailable;
+ * a real Phase 0 run should prefer observed values from access logs
+ * (see extract-url-corpus.ts).
+ */
+
+export type RouteFeature = "onlineboard" | "schedule" | "flights-map" | "popular";
+
+export interface RouteShape {
+ feature: RouteFeature;
+ /** Human-readable route slug used as a fixture filename. */
+ slug: string;
+ /** Template with {placeholders}, no language prefix (added per language at enumeration time). */
+ template: string;
+ /** Representative sample values used when no observed values are available. */
+ samples: Array>;
+}
+
+export const LANGUAGES = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"] as const;
+export type Language = (typeof LANGUAGES)[number];
+
+export const ROUTE_SHAPES: RouteShape[] = [
+ {
+ feature: "onlineboard",
+ slug: "onlineboard-start",
+ template: "/onlineboard",
+ samples: [{}],
+ },
+ {
+ feature: "onlineboard",
+ slug: "onlineboard-flight",
+ template: "/onlineboard/flight/{flightNumber}-{date}",
+ samples: [
+ { flightNumber: "SU100", date: "2025-01-15" },
+ { flightNumber: "SU0001", date: "2025-06-01" },
+ ],
+ },
+ {
+ feature: "onlineboard",
+ slug: "onlineboard-departure",
+ template: "/onlineboard/departure/{airport}-{date}",
+ samples: [
+ { airport: "SVO", date: "2025-01-15" },
+ { airport: "LED", date: "2025-06-01" },
+ ],
+ },
+ {
+ feature: "onlineboard",
+ slug: "onlineboard-arrival",
+ template: "/onlineboard/arrival/{airport}-{date}",
+ samples: [
+ { airport: "JFK", date: "2025-01-15" },
+ { airport: "DXB", date: "2025-06-01" },
+ ],
+ },
+ {
+ feature: "onlineboard",
+ slug: "onlineboard-route",
+ template: "/onlineboard/route/{departure}-{arrival}-{date}",
+ samples: [
+ { departure: "SVO", arrival: "JFK", date: "2025-01-15" },
+ { departure: "LED", arrival: "DXB", date: "2025-06-01" },
+ ],
+ },
+ {
+ feature: "onlineboard",
+ slug: "onlineboard-details",
+ template: "/onlineboard/{flightNumber}-{date}",
+ samples: [{ flightNumber: "SU100", date: "2025-01-15" }],
+ },
+ {
+ feature: "schedule",
+ slug: "schedule-start",
+ template: "/schedule",
+ samples: [{}],
+ },
+ {
+ feature: "schedule",
+ slug: "schedule-oneway",
+ template: "/schedule/route/{departure}-{arrival}-{date}",
+ samples: [{ departure: "SVO", arrival: "JFK", date: "2025-01-15" }],
+ },
+ {
+ feature: "schedule",
+ slug: "schedule-roundtrip",
+ template: "/schedule/route/{departure}-{arrival}-{date}/{returnDeparture}-{returnArrival}-{returnDate}",
+ samples: [
+ {
+ departure: "SVO",
+ arrival: "JFK",
+ date: "2025-01-15",
+ returnDeparture: "JFK",
+ returnArrival: "SVO",
+ returnDate: "2025-01-22",
+ },
+ ],
+ },
+ {
+ feature: "schedule",
+ slug: "schedule-multileg",
+ template: "/schedule/{legs}",
+ samples: [{ legs: "SU0001-2025-01-15/SU0002-2025-01-15" }],
+ },
+ {
+ feature: "flights-map",
+ slug: "flights-map-start",
+ template: "/flights-map",
+ samples: [{}],
+ },
+ {
+ feature: "flights-map",
+ slug: "flights-map-route",
+ template: "/flights-map/route/{departure}-{arrival}",
+ samples: [{ departure: "SVO", arrival: "JFK" }],
+ },
+ {
+ feature: "popular",
+ slug: "popular-start",
+ template: "/popular",
+ samples: [{}],
+ },
+];
+
+/** Substitute {placeholders} in a template with concrete values. */
+export function renderTemplate(template: string, values: Record): string {
+ return template.replace(/\{(\w+)\}/g, (_, key) => {
+ const value = values[key];
+ if (value === undefined) {
+ throw new Error(`renderTemplate: missing value for '${key}' in template '${template}'`);
+ }
+ return value;
+ });
+}
+
+/** Prepend a language prefix and the canonical origin to a rendered route. */
+export function buildUrl(origin: string, lang: Language, path: string): string {
+ const normalized = path.startsWith("/") ? path : `/${path}`;
+ return `${origin}/${lang}${normalized}`;
+}
+```
+
+- [ ] **Step 3: Write a unit test for `renderTemplate` + `buildUrl`**
+
+Create `tests/phase-0/lib/angular-routes.test.ts`:
+
+```ts
+import { describe, it, expect } from "vitest";
+import { renderTemplate, buildUrl } from "@phase0/lib/angular-routes";
+
+describe("renderTemplate", () => {
+ it("substitutes a single placeholder", () => {
+ expect(renderTemplate("/onlineboard/{flightNumber}-{date}", { flightNumber: "SU100", date: "2025-01-15" }))
+ .toBe("/onlineboard/SU100-2025-01-15");
+ });
+
+ it("substitutes multiple placeholders of the same name (first occurrence rule is fine because there are no duplicates in our catalog)", () => {
+ expect(renderTemplate("{a}/{b}", { a: "x", b: "y" })).toBe("x/y");
+ });
+
+ it("throws on a missing value", () => {
+ expect(() => renderTemplate("/{missing}", {})).toThrow(/missing value for 'missing'/);
+ });
+
+ it("leaves a literal path untouched", () => {
+ expect(renderTemplate("/onlineboard", {})).toBe("/onlineboard");
+ });
+});
+
+describe("buildUrl", () => {
+ it("joins origin + lang + path", () => {
+ expect(buildUrl("https://flights.aeroflot.ru", "ru", "/onlineboard"))
+ .toBe("https://flights.aeroflot.ru/ru/onlineboard");
+ });
+
+ it("inserts a leading slash if the path lacks one", () => {
+ expect(buildUrl("https://flights.aeroflot.ru", "en", "onlineboard"))
+ .toBe("https://flights.aeroflot.ru/en/onlineboard");
+ });
+});
+```
+
+- [ ] **Step 4: Run the test to confirm it passes**
+
+```bash
+pnpm test
+```
+
+Expected: 6 passing tests, 0 failing.
+
+- [ ] **Step 5: Create the small JSON I/O helper**
+
+Create `scripts/phase-0/lib/io.ts`:
+
+```ts
+import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
+import { dirname } from "node:path";
+
+export function writeJson(path: string, value: unknown): void {
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, JSON.stringify(value, null, 2) + "\n", "utf8");
+}
+
+export function readJson(path: string): T {
+ return JSON.parse(readFileSync(path, "utf8")) as T;
+}
+
+export function fileExists(path: string): boolean {
+ return existsSync(path);
+}
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add scripts/phase-0/lib/angular-routes.ts scripts/phase-0/lib/io.ts tests/phase-0/lib/angular-routes.test.ts
+git commit -m "Catalog Angular route shapes for Phase 0 URL enumeration
+
+Hand-transcribed from ClientApp/src/app routing modules. Source of truth
+for URL corpus, SEO capture, and VRT capture driver scripts that follow."
+```
+
+---
+
+## Task 5: Optional — anonymize prod access logs into an observed-URL list
+
+Only run this task if you have access to raw access logs. If not, skip to Task 6 — the URL corpus will be built from the route shapes alone.
+
+**Files:**
+- Create: `scripts/phase-0/anonymize-access-logs.ts`
+
+- [ ] **Step 1: Write the anonymizer**
+
+Create `scripts/phase-0/anonymize-access-logs.ts`:
+
+```ts
+#!/usr/bin/env tsx
+/**
+ * Reads raw access log lines from stdin, extracts URL paths that match
+ * the /{lang}/... pattern, strips query strings, deduplicates, and writes
+ * the result to tests/fixtures/phase-0/url-corpus/observed.json.
+ *
+ * Usage:
+ * cat raw-access.log | pnpm tsx scripts/phase-0/anonymize-access-logs.ts
+ *
+ * Accepts Common Log Format or any format where the path is the 7th
+ * whitespace-separated field inside a quoted request line like
+ * "GET /ru/onlineboard/flight/SU100-2025-01-15 HTTP/1.1"
+ */
+
+import { createInterface } from "node:readline";
+import { writeJson } from "./lib/io.js";
+import { LANGUAGES } from "./lib/angular-routes.js";
+
+const OUTPUT_PATH = "tests/fixtures/phase-0/url-corpus/observed.json";
+const LANG_PREFIX_RE = new RegExp(`^/(?:${LANGUAGES.join("|")})(/|$)`);
+
+async function main(): Promise {
+ const rl = createInterface({ input: process.stdin });
+ const seen = new Set();
+
+ for await (const line of rl) {
+ const requestMatch = line.match(/"\w+\s+(\S+)\s+HTTP/);
+ if (!requestMatch) continue;
+ const pathAndQuery = requestMatch[1];
+ if (!pathAndQuery) continue;
+ const [path] = pathAndQuery.split("?", 1);
+ if (!path) continue;
+ if (!LANG_PREFIX_RE.test(path)) continue;
+ seen.add(path);
+ }
+
+ const urls = [...seen].sort();
+ writeJson(OUTPUT_PATH, { capturedAt: new Date().toISOString(), count: urls.length, urls });
+ console.error(`Wrote ${urls.length} unique URLs to ${OUTPUT_PATH}`);
+}
+
+await main();
+```
+
+- [ ] **Step 2: Run it against a sample (skip if no logs)**
+
+```bash
+cat path/to/anonymized-access.log | pnpm tsx scripts/phase-0/anonymize-access-logs.ts
+```
+
+Expected: stderr prints a URL count; `tests/fixtures/phase-0/url-corpus/observed.json` exists and contains a sorted unique list.
+
+- [ ] **Step 3: Commit the script (not the observed.json output — that comes out of Task 6)**
+
+```bash
+git add scripts/phase-0/anonymize-access-logs.ts
+git commit -m "Add Phase 0 access-log anonymizer
+
+Reads raw access logs from stdin, extracts /{lang}/... paths, strips
+query strings, deduplicates. Output feeds into the URL corpus."
+```
+
+---
+
+## Task 6: URL corpus — write + run the corpus extractor
+
+Builds `tests/fixtures/phase-0/url-corpus/{feature}.json`, one file per feature, combining enumerated routes (from Task 4) + observed URLs (from Task 5 if available).
+
+**Files:**
+- Create: `scripts/phase-0/extract-url-corpus.ts`
+- Create: `tests/fixtures/phase-0/url-corpus/onlineboard.json` (generated)
+- Create: `tests/fixtures/phase-0/url-corpus/schedule.json` (generated)
+- Create: `tests/fixtures/phase-0/url-corpus/flights-map.json` (generated)
+- Create: `tests/fixtures/phase-0/url-corpus/popular.json` (generated)
+
+- [ ] **Step 1: Write the extractor**
+
+Create `scripts/phase-0/extract-url-corpus.ts`:
+
+```ts
+#!/usr/bin/env tsx
+/**
+ * Builds the URL corpus fixture files for each feature, combining:
+ * - Enumerated URLs from ROUTE_SHAPES × LANGUAGES × samples
+ * - Observed URLs from tests/fixtures/phase-0/url-corpus/observed.json (if present)
+ *
+ * Output: tests/fixtures/phase-0/url-corpus/{feature}.json
+ *
+ * Each output file has the shape:
+ * { feature, capturedAt, count, urls: [{ path, source, slug? }] }
+ */
+
+import { ROUTE_SHAPES, LANGUAGES, renderTemplate, type RouteFeature } from "./lib/angular-routes.js";
+import { writeJson, readJson, fileExists } from "./lib/io.js";
+
+interface CorpusEntry {
+ path: string;
+ source: "enumerated" | "observed";
+ slug?: string;
+}
+
+interface Corpus {
+ feature: RouteFeature;
+ capturedAt: string;
+ count: number;
+ urls: CorpusEntry[];
+}
+
+const OBSERVED_PATH = "tests/fixtures/phase-0/url-corpus/observed.json";
+
+function enumerate(): Map {
+ const byFeature = new Map();
+ for (const feature of ["onlineboard", "schedule", "flights-map", "popular"] as const) {
+ byFeature.set(feature, []);
+ }
+ for (const shape of ROUTE_SHAPES) {
+ for (const lang of LANGUAGES) {
+ for (const sample of shape.samples) {
+ const rendered = renderTemplate(shape.template, sample);
+ const path = `/${lang}${rendered}`;
+ byFeature.get(shape.feature)!.push({ path, source: "enumerated", slug: shape.slug });
+ }
+ }
+ }
+ return byFeature;
+}
+
+function assignObservedToFeatures(observed: string[], byFeature: Map): void {
+ for (const path of observed) {
+ const feature = classify(path);
+ if (!feature) continue;
+ byFeature.get(feature)!.push({ path, source: "observed" });
+ }
+}
+
+function classify(path: string): RouteFeature | null {
+ if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/onlineboard(\/|$)/.test(path)) return "onlineboard";
+ if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/schedule(\/|$)/.test(path)) return "schedule";
+ if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/flights-map(\/|$)/.test(path)) return "flights-map";
+ if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/popular(\/|$)/.test(path)) return "popular";
+ return null;
+}
+
+function main(): void {
+ const byFeature = enumerate();
+
+ if (fileExists(OBSERVED_PATH)) {
+ const observedDoc = readJson<{ urls: string[] }>(OBSERVED_PATH);
+ assignObservedToFeatures(observedDoc.urls, byFeature);
+ console.error(`Merged ${observedDoc.urls.length} observed URLs into the corpus`);
+ } else {
+ console.error(`No observed URLs at ${OBSERVED_PATH}; corpus is enumerated-only`);
+ }
+
+ const capturedAt = new Date().toISOString();
+ for (const [feature, urls] of byFeature.entries()) {
+ const dedup = dedupe(urls);
+ const corpus: Corpus = { feature, capturedAt, count: dedup.length, urls: dedup };
+ const out = `tests/fixtures/phase-0/url-corpus/${feature}.json`;
+ writeJson(out, corpus);
+ console.error(`Wrote ${feature} corpus (${dedup.length} URLs) → ${out}`);
+ }
+}
+
+function dedupe(entries: CorpusEntry[]): CorpusEntry[] {
+ const byPath = new Map();
+ for (const entry of entries) {
+ const existing = byPath.get(entry.path);
+ if (!existing) {
+ byPath.set(entry.path, entry);
+ continue;
+ }
+ // Prefer observed over enumerated (observed has real user evidence).
+ if (existing.source === "enumerated" && entry.source === "observed") {
+ byPath.set(entry.path, entry);
+ }
+ }
+ return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
+}
+
+main();
+```
+
+- [ ] **Step 2: Run the extractor**
+
+```bash
+pnpm phase0:url-corpus
+```
+
+Expected stderr output (with no observed logs):
+```
+No observed URLs at tests/fixtures/phase-0/url-corpus/observed.json; corpus is enumerated-only
+Wrote onlineboard corpus (N URLs) → tests/fixtures/phase-0/url-corpus/onlineboard.json
+Wrote schedule corpus (N URLs) → tests/fixtures/phase-0/url-corpus/schedule.json
+Wrote flights-map corpus (N URLs) → tests/fixtures/phase-0/url-corpus/flights-map.json
+Wrote popular corpus (N URLs) → tests/fixtures/phase-0/url-corpus/popular.json
+```
+
+- [ ] **Step 3: Inspect one corpus file**
+
+```bash
+head -30 tests/fixtures/phase-0/url-corpus/onlineboard.json
+```
+
+Expected: JSON starting with `{ "feature": "onlineboard", ... }` and a non-empty `urls` array.
+
+- [ ] **Step 4: Commit corpus fixtures**
+
+```bash
+git add scripts/phase-0/extract-url-corpus.ts tests/fixtures/phase-0/url-corpus/
+git commit -m "Generate Phase 0 URL corpus fixtures from Angular route shapes
+
+One fixture file per feature. Combines enumerated routes with observed
+log URLs where available. Consumed by SEO capture (Task 8) and by the
+Phase 2 URL parity test suite."
+```
+
+---
+
+## Task 7: HTTP fetch helper with retry + throttle
+
+Before the SEO/hreflang capture tasks can hit prod, we need a small shared HTTP helper that handles transient failures and doesn't hammer the origin.
+
+**Files:**
+- Create: `scripts/phase-0/lib/http.ts`
+- Create: `tests/phase-0/lib/http.test.ts`
+
+- [ ] **Step 1: Write the failing test first**
+
+Create `tests/phase-0/lib/http.test.ts`:
+
+```ts
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { fetchWithRetry } from "@phase0/lib/http";
+
+describe("fetchWithRetry", () => {
+ const realFetch = globalThis.fetch;
+ beforeEach(() => { vi.useFakeTimers(); });
+ afterEach(() => {
+ globalThis.fetch = realFetch;
+ vi.useRealTimers();
+ });
+
+ it("returns the response on first-try success", async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
+ const res = await fetchWithRetry("https://example.test/a");
+ expect(res.status).toBe(200);
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("retries on 5xx and returns the eventual success", async () => {
+ globalThis.fetch = vi.fn()
+ .mockResolvedValueOnce(new Response("oops", { status: 503 }))
+ .mockResolvedValueOnce(new Response("ok", { status: 200 }));
+ const promise = fetchWithRetry("https://example.test/b", { maxRetries: 2, retryDelayMs: 10 });
+ await vi.advanceTimersByTimeAsync(10);
+ const res = await promise;
+ expect(res.status).toBe(200);
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
+ });
+
+ it("gives up after maxRetries and throws with the last status", async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response("nope", { status: 500 }));
+ const promise = fetchWithRetry("https://example.test/c", { maxRetries: 2, retryDelayMs: 1 });
+ const pending = expect(promise).rejects.toThrow(/HTTP 500/);
+ await vi.advanceTimersByTimeAsync(10);
+ await pending;
+ expect(globalThis.fetch).toHaveBeenCalledTimes(3);
+ });
+
+ it("does not retry on 4xx", async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response("no", { status: 404 }));
+ await expect(fetchWithRetry("https://example.test/d", { maxRetries: 3, retryDelayMs: 1 }))
+ .rejects.toThrow(/HTTP 404/);
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
+ });
+});
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+```bash
+pnpm test tests/phase-0/lib/http.test.ts
+```
+
+Expected: all 4 tests fail with `Cannot find module '@phase0/lib/http'` or equivalent.
+
+- [ ] **Step 3: Implement the helper**
+
+Create `scripts/phase-0/lib/http.ts`:
+
+```ts
+export interface FetchWithRetryOptions {
+ maxRetries?: number;
+ retryDelayMs?: number;
+ throttleMs?: number;
+ headers?: Record;
+}
+
+let lastCallAt = 0;
+
+export async function fetchWithRetry(
+ url: string,
+ opts: FetchWithRetryOptions = {},
+): Promise {
+ const maxRetries = opts.maxRetries ?? 2;
+ const retryDelayMs = opts.retryDelayMs ?? 500;
+ const throttleMs = opts.throttleMs ?? 0;
+ const headers = opts.headers ?? { "User-Agent": "aeroflot-flights-phase0/1.0" };
+
+ if (throttleMs > 0) {
+ const sinceLast = Date.now() - lastCallAt;
+ if (sinceLast < throttleMs) {
+ await sleep(throttleMs - sinceLast);
+ }
+ lastCallAt = Date.now();
+ }
+
+ let attempt = 0;
+ let lastStatus = 0;
+ while (attempt <= maxRetries) {
+ const res = await fetch(url, { headers });
+ if (res.status >= 200 && res.status < 400) return res;
+ if (res.status >= 400 && res.status < 500) {
+ throw new Error(`HTTP ${res.status} ${url}`);
+ }
+ lastStatus = res.status;
+ attempt += 1;
+ if (attempt > maxRetries) break;
+ await sleep(retryDelayMs * attempt);
+ }
+ throw new Error(`HTTP ${lastStatus} ${url} (after ${maxRetries} retries)`);
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+```
+
+- [ ] **Step 4: Re-run the test**
+
+```bash
+pnpm test tests/phase-0/lib/http.test.ts
+```
+
+Expected: 4 passing tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/phase-0/lib/http.ts tests/phase-0/lib/http.test.ts
+git commit -m "Add fetch-with-retry helper for Phase 0 capture scripts
+
+Retries on 5xx, fails fast on 4xx, optional per-call throttle window.
+Protects the Angular prod origin from being hammered by capture runs."
+```
+
+---
+
+## Task 8: SEO baseline capture script
+
+Fetches a curated set of representative pages from Angular prod, extracts ``, `` tags, ``, `` (hreflang), OpenGraph, Twitter Card, and `
+
+
+