---
phase: 01-stabilize-video-pipeline
plan: 12
type: tdd
wave: 6
depends_on:
  - 01-09
  - 01-13
  - 01-14
files_modified:
  - src/shared/tokens.css
  - src/shared/fonts/Lora-VariableFont.woff2
  - src/shared/fonts/Lora-Italic-VariableFont.woff2
  - src/shared/fonts/IBMPlexSans-Regular.woff2
  - src/shared/fonts/IBMPlexSans-Medium.woff2
  - src/shared/fonts/IBMPlexSans-SemiBold.woff2
  - src/shared/fonts/IBMPlexSans-Bold.woff2
  - src/shared/fonts/IBMPlexMono-Regular.woff2
  - src/shared/fonts/IBMPlexMono-Medium.woff2
  - src/shared/fonts/LICENSE-Lora.txt
  - src/shared/fonts/LICENSE-IBM-Plex.txt
  - src/shared/fonts/README.md
  - src/shared/brand/mokosh-mark.svg
  - src/shared/brand/mokosh-lockup.svg
  - icons/icon16.png
  - icons/icon48.png
  - icons/icon128.png
  - scripts/rasterize-icons.sh
  - scripts/subset-fonts.sh
  - scripts/README.md
  - manifest.json
  - _locales/en/messages.json
  - _locales/ru/messages.json
  - src/popup/index.html
  - src/popup/index.ts
  - src/popup/style.css
  - src/welcome/welcome.css
  - src/welcome/welcome-tokens.css
  - src/welcome/welcome.ts
  - src/welcome/copy.ts
  - src/background/index.ts
  - vite.config.ts
  - vite.test.config.ts
  - tests/build/tokens-adopted.test.ts
  - tests/build/fonts-present.test.ts
  - tests/build/icons-present.test.ts
  - tests/build/no-remote-fonts.test.ts
  - tests/i18n/manifest-i18n.test.ts
  - tests/i18n/locale-parity.test.ts
  - tests/uat/extension-page-harness.ts
  - tests/uat/lib/harness-page-driver.ts
  - tests/uat/harness.test.ts
  - .planning/STATE.md
  - .planning/ROADMAP.md
autonomous: false
requirements:
  - REQ-video-ring-buffer
  - REQ-install-clean
  - REQ-manifest-permissions
tags:
  - design-integration
  - brand
  - tokens-css
  - woff2-selfhost
  - svg-rasterization
  - mv3-csp
  - i18n
  - locales
  - chrome-i18n
  - D-01-mark
  - D-02-welcome
  - D-03-voice-sober
  - D-04-palette
  - D-05-typography
  - D-06-icon-strategy
  - D-07-extname-override
  - D-08-tagline
  - D-09-smoke-dev-only
  - R2-lora-substitute
  - Approach-B-harness-extension
  - harness-A18-A22

must_haves:
  truths:
    - "Self-hosted WOFF2 font bundle lands at src/shared/fonts/: Lora variable (Latin+Cyrillic via OFL Cyreal foundry release, replaces Newsreader per R2 designer reply 2026-05-19); IBM Plex Sans 400/500/600/700 latin+cyrillic; IBM Plex Mono 400/500 latin+cyrillic. Total bundle ~250-300KB. LICENSE-Lora.txt + LICENSE-IBM-Plex.txt + README.md ship alongside per OFL-1.1 attribution best practice."
    - "src/shared/tokens.css is the canonical token system: copy of .planning/intel/design-incoming/system/bundle/mokosh-handoff/tokens.css with two surgical edits — (a) line 12 Google Fonts @import REPLACED with 7 local @font-face rules (Lora variable normal + italic; Plex Sans 4 weights; Plex Mono 2 weights), and (b) --mks-font-display value substituted from 'Newsreader' to 'Lora' per R2. Adds .mks-word class definition (font-family: var(--mks-font-display); font-size: 32px; font-weight: var(--mks-weight-regular); fill: var(--mks-ink-900);) required by mokosh-lockup.svg line 21."
    - "src/shared/tokens.css contains ZERO references to fonts.googleapis.com or any https:// URL — MV3 CSP `style-src 'self'` + `font-src 'self'` enforced. Verified by tests/build/no-remote-fonts.test.ts grepping the built dist/ for the substring `googleapis` (occurrences === 0) and `https://fonts` (occurrences === 0)."
    - "icons/icon{16,48,128}.png are rasterized from src/shared/brand/mokosh-mark.svg via rsvg-convert (per RESEARCH §3 verified locally: 16=406B, 48=784B, 128=1952B). Each clears the assets-spec.md FLOOR: 16≥200B, 48≥500B, 128≥1024B. PNGs are committed as static artifacts (NOT regenerated at build time per RESEARCH §3 anti-pattern); scripts/rasterize-icons.sh is the documented re-run recipe. The Bug A placeholder icons (dark-square + green-dot per Plan 01-09 Path A; verified pre-existing as 574B/1153B/2615B 16-bit RGB at icons/icon{16,48,128}.png) are OVERWRITTEN."
    - "manifest.json:name = '__MSG_extName__' (was 'AI Call Recorder'); manifest.json:description = '__MSG_extDesc__' (was 'Запись сессий операторов для диагностики ошибок'); manifest.json:default_locale = 'en'; manifest.json:action.default_title = '__MSG_tooltipOff__'. _locales/en/messages.json carries extName='Mokosh — Session Capture' per D-07 user override and extDesc='Thirty seconds ago, always at hand.' per D-08 (using brand-decisions-v1.md final wording, not Brief §02's '...within reach.' variant — A4 from RESEARCH Open Questions resolved to brand-decisions-v1.md as the more recent doc)."
    - "8 i18n keys land in BOTH _locales/en/messages.json AND _locales/ru/messages.json (default_locale parity per RESEARCH Pitfall 4): extName, extDesc, tooltipOff, tooltipRecPrefix, tooltipErr, popupSavePrompt, popupSaveCta, popupSaveDone, notifStartup, notifRecovery, welcomeHeroRu, welcomeHeroEn (12 total keys; the 8 Brief §02 strings + 4 supporting keys including manifest fields and tooltipRec split). Every key in ru/messages.json is also present in en/messages.json (verified by tests/i18n/locale-parity.test.ts)."
    - "src/background/index.ts is migrated from hardcoded brand strings (line 82 BADGE_REC_TITLE 'Recording — last 30 s buffered. Click to save.'; line 83 BADGE_OFF_TITLE 'Not recording. Click to start.'; line 84 BADGE_ERROR_TITLE 'Recording error. Click to try again.'; line 862 'Mokosh stopped'; line 863 'Recording stopped. Click here to start a new session.'; line 964 'Mokosh ready'; line 965 startup-message) to chrome.i18n.getMessage('<key>') reads. BADGE_REC_COLOR ('#00C853' material-green at line 76) is updated to '#b2543d' (= --mks-madder-600 per D-04 loom palette) with a comment cross-referencing the token, per RESEARCH §10 Open Questions A7 default-action. BADGE_OFF_COLOR + BADGE_ERROR_COLOR remain as plan-discretion engineering choices unless the palette dictates otherwise; document as such in the source comment."
    - "src/popup/index.html title element becomes '__MSG_extName__' (or empty, populated by JS via chrome.i18n.getMessage('extName')); span.button-text remains empty (populated by JS per RESEARCH Pitfall 3); src/popup/index.ts updateUI() reads buttonText.textContent = chrome.i18n.getMessage('popupSaveCta') in idle case, 'popupSaveDone' in done case; the 'Архив успешно сохранён!' literal at saveArchive() success branch (line 91) becomes chrome.i18n.getMessage('popupSaveDone'); the info-text 'Последние 30 сек видео + 10 мин лога' at index.html line 15 becomes a data-mks-key='popupInfoText' attribute populated by JS. src/popup/style.css imports `../shared/tokens.css` and ALL hardcoded hex literals (#2563eb, #1d4ed8, #94a3b8, #f8f9fa, #0891b2, #059669, #dc2626, #64748b) are replaced with var(--mks-*) references (--mks-rec for save button, --mks-success for done state, --mks-error for error, --mks-fg-2 for info text, --mks-surface for body bg, --mks-fg-disabled for disabled, --mks-warning for warning info)."
    - "If Plan 01-10 (welcome tab) has landed before Plan 01-12 executes: src/welcome/welcome-tokens.css placeholder is REPLACED with a one-liner `@import '../shared/tokens.css';`, and any per-key copy literals in src/welcome/copy.ts are migrated to chrome.i18n.getMessage('<key>') reads (welcomeHeroRu, welcomeHeroEn) with the in-file COPY map removed or reduced to a fallback shim. If Plan 01-10 has NOT yet landed: src/welcome/* files are NOT created by Plan 01-12; the canonical tokens.css is import-ready from src/shared/tokens.css and Plan 01-10 executor can swap to it from day 1 (no placeholder welcome-tokens.css needed)."
    - "vite.config.ts gains a `define` block setting __VITE_DEV__ = JSON.stringify(process.env.VITE_DEV === '1') per RESEARCH §12 D-09 spirit-satisfaction (defensive flag for any future inline smoke-mode check; smoke.sh continues to live outside Vite's input set entirely — verified by `grep -rn 'smoke\\|SMOKE\\|data:text/html' src/` returning empty). vite.test.config.ts inherits via mergeConfig. scripts/README.md (NEW) documents the smoke isolation invariant in one paragraph."
    - "Tier-1 forbidden-strings inventory in tests/background/no-test-hooks-in-prod-bundle.test.ts AND tests/uat/harness.test.ts is UNCHANGED at 12 strings post-Plan-01-14 (no new test-mode symbols introduced by Plan 01-12; the i18n + tokens + font work uses production chrome.* APIs end-to-end). Plan 01-14 established the 12-string baseline by adding `lastGetDisplayMediaConstraints` + `get-last-getDisplayMedia-constraints` for its monitorTypeSurfaces harness. Verified by file-diff after plan close."
    - "UAT harness extended with 5 new assertions A18-A22: A18 = Lora WOFF2 loads from chrome.runtime.getURL('shared/fonts/Lora-VariableFont.woff2') OR equivalent emitted asset path (response.ok && content-length > 50000 — Lora variable Latin+Cyrillic is ~80-120KB after subsetting). A19 = icons are NOT the Bug A placeholders (icon128.png file size differs from the known 2615-byte 16-bit-RGB placeholder; OR icon128.png file content hash differs from the placeholder content hash). A20 = chrome.runtime.getManifest().name === 'Mokosh — Session Capture' (i18n resolution test; the en-locale fallback always returns this exact string per default_locale='en'). A21 = getComputedStyle on a test element with CSS class .mks-display-1 inside the harness page resolves to a font-family stack starting with 'Lora' (not 'Newsreader', not system fallback). A22 = welcome page tokens.css is loaded if 01-10 has landed (fetch chrome.runtime.getURL('src/welcome/welcome.html') returns 200 + welcome.css resolves --mks-rec to '#b2543d'); skip with a documented diagnostic if 01-10 has not landed."
    - "Every Plan 01-13 harness assertion (A0-A14), Plan 01-14 assertion (A23), and Plan 01-10 assertion (A15-A17, IF landed) remains GREEN after Plan 01-12 changes (no regression). vitest baseline grows by 6 tests (icons-present + tokens-adopted + fonts-present + no-remote-fonts + manifest-i18n + locale-parity) from 100 GREEN (post-01-14) to 106 GREEN; npm run test:uat grows from 16/16 (post-01-14, or 19/19 with 01-10) to 21/21 (or 24/24 with 01-10 + 01-12) — all GREEN."
    - "MV3 architectural constraints from 01-11-SUMMARY enforced: NO `await import(...)` anywhere in src/background/index.ts (chrome.i18n.getMessage is a synchronous API; no dynamic imports required); test-mode symbols stay in dist-test/ only via __MOKOSH_UAT__ define-token (Plan 01-12 introduces no new test-mode symbols)."
  artifacts:
    - path: "src/shared/tokens.css"
      provides: "Canonical token system (Mokosh design tokens; single source of truth). Copy of design-incoming tokens.css with: (1) line 12 Google Fonts @import REMOVED + replaced with 7 local @font-face rules pointing at ./fonts/*.woff2 (Lora variable normal + italic; Plex Sans 400/500/600/700; Plex Mono 400/500); (2) --mks-font-display value substituted from 'Newsreader' → 'Lora' per R2 designer reply 2026-05-19; (3) .mks-word class definition added (font-family: var(--mks-font-display); font-size: 32px; font-weight: var(--mks-weight-regular); fill: var(--mks-ink-900); letter-spacing: var(--mks-tracking-display);) at end-of-file per RESEARCH §8 + lockup SVG line 21 requirement. ZERO https:// URLs anywhere in this file."
      contains: "@font-face"
    - path: "src/shared/fonts/Lora-VariableFont.woff2"
      provides: "Lora variable font, normal style, weight axis 400-700, subset to Latin (U+0020-007E, U+00A0-00FF) + Cyrillic basic (U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116) via pyftsubset --flavor=woff2 from upstream OFL .ttf at github.com/cyrealtype/Lora-Cyrillic or Google Fonts metadata source. Expected size 80-120KB."
    - path: "src/shared/fonts/Lora-Italic-VariableFont.woff2"
      provides: "Lora variable italic, same subset as above. Expected size 80-120KB. NB: if upstream Lora ships italic in same file as normal via opsz/wght/ital axes (variable font with multi-axis support), this file may be omitted — A5 from RESEARCH Assumptions Log: verify upstream variable build structure at execute-plan time and adjust if italic+normal collapse into one file."
    - path: "src/shared/fonts/IBMPlexSans-Regular.woff2"
      provides: "IBM Plex Sans 400, Latin + Cyrillic basic. ~25KB per RESEARCH §1 sizing table."
    - path: "src/shared/fonts/IBMPlexSans-Medium.woff2"
      provides: "IBM Plex Sans 500, Latin + Cyrillic basic. ~25KB."
    - path: "src/shared/fonts/IBMPlexSans-SemiBold.woff2"
      provides: "IBM Plex Sans 600, Latin + Cyrillic basic. ~25KB."
    - path: "src/shared/fonts/IBMPlexSans-Bold.woff2"
      provides: "IBM Plex Sans 700, Latin + Cyrillic basic. ~25KB."
    - path: "src/shared/fonts/IBMPlexMono-Regular.woff2"
      provides: "IBM Plex Mono 400, Latin + Cyrillic basic. ~28KB."
    - path: "src/shared/fonts/IBMPlexMono-Medium.woff2"
      provides: "IBM Plex Mono 500, Latin + Cyrillic basic. ~28KB."
    - path: "src/shared/fonts/LICENSE-Lora.txt"
      provides: "Verbatim copy of Lora OFL-1.1 license + Cyreal foundry copyright lines, per OFL-FAQ best practice."
    - path: "src/shared/fonts/LICENSE-IBM-Plex.txt"
      provides: "Verbatim copy of IBM Plex OFL-1.1 license from github.com/IBM/plex/blob/master/LICENSE.txt."
    - path: "src/shared/fonts/README.md"
      provides: "One-pager attribution + regeneration recipe per RESEARCH §7 template. Lists all bundled faces + upstream URLs + subsetting recipe + license notes."
      min_lines: 15
    - path: "src/shared/brand/mokosh-mark.svg"
      provides: "Copy of .planning/intel/design-incoming/system/bundle/mokosh-handoff/assets/mokosh-mark.svg (Loom 2×2 weave intersection; D-01 brand mark). Source-of-truth for icon rasterization. Stays at hardcoded stroke='#181b2a' for PNG output; future Plan 01-10 may copy + adapt to currentColor for inline-SVG welcome usage (see RESEARCH §5)."
    - path: "src/shared/brand/mokosh-lockup.svg"
      provides: "Copy of mokosh-lockup.svg (mark + 'Mokosh' wordmark at 240×56 viewBox). The .mks-word class referenced at line 21 is defined in src/shared/tokens.css per artifact above."
    - path: "icons/icon16.png"
      provides: "Rasterized Loom mark at 16×16 via `rsvg-convert -w 16 -h 16 src/shared/brand/mokosh-mark.svg -o icons/icon16.png`. ≥200 bytes (~406B per RESEARCH §3 verified). REPLACES the prior 574B 16-bit-RGB placeholder."
    - path: "icons/icon48.png"
      provides: "Rasterized Loom mark at 48×48. ≥500 bytes (~784B). REPLACES the prior 1153B 16-bit-RGB placeholder."
    - path: "icons/icon128.png"
      provides: "Rasterized Loom mark at 128×128. ≥1024 bytes (~1952B). REPLACES the prior 2615B 16-bit-RGB placeholder."
    - path: "scripts/rasterize-icons.sh"
      provides: "One-off rasterization recipe per RESEARCH §3. Bash script reading src/shared/brand/mokosh-mark.svg and emitting icons/icon{16,48,128}.png via rsvg-convert. Documented for re-run when the mark SVG changes; NOT wired into prebuild (anti-pattern per RESEARCH §3)."
      min_lines: 15
    - path: "scripts/subset-fonts.sh"
      provides: "One-off font subsetting recipe per RESEARCH §1. Bash script for Lora (Latin+Cyrillic subset, variable normal + italic), Plex Sans 400/500/600/700 (Latin+Cyrillic), Plex Mono 400/500 (Latin+Cyrillic), via pyftsubset --flavor=woff2 from upstream .ttf sources downloaded one-off into a scratch dir. Documents the canonical --unicodes string."
      min_lines: 30
    - path: "scripts/README.md"
      provides: "One-paragraph note per RESEARCH §12: smoke.sh and other dev-only scripts live in this directory and are NOT bundled by `npm run build`. The production dist/ contains no smoke artifacts (verified by `grep -rn smoke dist/` returning empty)."
      min_lines: 8
    - path: "manifest.json"
      provides: "Updated manifest: `name` = '__MSG_extName__'; `description` = '__MSG_extDesc__'; `default_locale` = 'en' (NEW field); `action.default_title` = '__MSG_tooltipOff__' (NEW field per RESEARCH §11 + Brief §02 string #1). All other fields preserved unchanged (permissions, host_permissions, background, content_scripts, action.default_popup, action.default_icon, icons)."
      contains: "default_locale"
    - path: "_locales/en/messages.json"
      provides: "English locale messages (default_locale fallback). 12 keys: extName, extDesc, tooltipOff, tooltipRecPrefix, tooltipErr, popupSavePrompt, popupSaveCta, popupSaveDone, popupInfoText, notifStartup, notifRecovery, welcomeHeroRu, welcomeHeroEn. extName='Mokosh — Session Capture' per D-07; extDesc='Thirty seconds ago, always at hand.' per D-08 (brand-decisions-v1.md wording). Strings inherit D-03 Sober register. Each key has both message + description fields per Chrome i18n schema."
      min_lines: 70
    - path: "_locales/ru/messages.json"
      provides: "Russian locale messages (primary operator locale). Same 12 keys as en/messages.json with Russian text per RESEARCH §10 Brief §02 verbatim extraction: extName='Mokosh — Запись сессии'; extDesc='Тридцать секунд назад, всегда под рукой.'; tooltipOff='Mokosh — щёлкните, чтобы начать запись'; tooltipRecPrefix='Mokosh — идёт запись'; tooltipErr='Mokosh — ошибка записи, щёлкните для восстановления'; popupSavePrompt='Сохранить отчёт об ошибке?'; popupSaveCta='Сохранить отчёт'; popupSaveDone='Архив сохранён в Загрузки.'; popupInfoText='Последние 30 сек видео + 10 мин лога'; notifStartup='Запись запущена. Я слежу за последними 30 секундами.'; notifRecovery='Запись возобновлена. Буфер снова заполняется.'; welcomeHeroRu='Тридцать секунд назад, всегда под рукой.'; welcomeHeroEn='Thirty seconds ago, always at hand.'."
      min_lines: 70
    - path: "src/popup/index.html"
      provides: "Popup HTML migrated to i18n: <title>Mokosh — Session Capture</title> retained (chrome doesn't substitute __MSG__ in HTML body per RESEARCH Pitfall 3); button.button-text starts empty (populated by JS); info-text gains data-mks-key='popupInfoText' attribute; statusMessage retained for runtime status. Stylesheet link to style.css unchanged."
      contains: "data-mks-key"
    - path: "src/popup/index.ts"
      provides: "Popup TS migrated to chrome.i18n.getMessage: updateUI() idle case sets buttonText.textContent = chrome.i18n.getMessage('popupSaveCta'); done case sets 'Готово! ✓' replaced with chrome.i18n.getMessage('popupSaveDone') OR a dedicated 'popupSaveDoneShort' key; init() populates all [data-mks-key] elements via a small helper. Empty-state message line 62 'Откройте запись через иконку расширения' becomes chrome.i18n.getMessage('popupEmptyState') with a corresponding key added to messages.json. Logger import + log helper unchanged."
      contains: "chrome.i18n.getMessage"
    - path: "src/popup/style.css"
      provides: "Restyled per D-04 loom palette: @import '../shared/tokens.css' at top; body { background: var(--mks-surface); color: var(--mks-fg-1); font-family: var(--mks-font-ui); }; .save-button { background: var(--mks-rec); color: var(--mks-fg-inverse); border-radius: var(--mks-radius-md); box-shadow: var(--mks-shadow-1); }; .save-button:hover, .save-button.saving, .save-button.done all use --mks-madder-700, --mks-amber-600, --mks-moss-600 respectively; .info-text uses var(--mks-fg-2); .status-message.* use --mks-error / --mks-success / --mks-warning. ZERO hex literals."
      contains: "var(--mks-"
    - path: "src/welcome/welcome.css"
      provides: "(CONDITIONAL — only modified if Plan 01-10 has landed at execute-plan time): Replace placeholder color tokens with @import '../shared/tokens.css' at top; keep all var(--mks-*) references (welcome.css already uses var(--mks-*) per 01-10 design-swap-ready architecture). If 01-10 hasn't landed: this file is NOT created by Plan 01-12."
      contains: "../shared/tokens.css"
    - path: "src/welcome/welcome-tokens.css"
      provides: "(CONDITIONAL — only modified if Plan 01-10 has landed): file becomes a one-liner `@import '../shared/tokens.css';` OR is deleted entirely with welcome.css importing tokens.css directly. If 01-10 hasn't landed: file is NOT created."
    - path: "src/welcome/welcome.ts"
      provides: "(CONDITIONAL — only modified if Plan 01-10 has landed): copy population uses chrome.i18n.getMessage('welcomeHeroRu'), chrome.i18n.getMessage('welcomeHeroEn'), and other welcome keys instead of the in-file COPY map. The COPY import from './copy' is removed OR copy.ts is reduced to a fallback-only shim. If 01-10 hasn't landed: file is NOT created."
      contains: "chrome.i18n.getMessage"
    - path: "src/welcome/copy.ts"
      provides: "(CONDITIONAL — only modified if Plan 01-10 has landed): file becomes either deleted or a fallback shim (e.g., a function `getCopy(key)` that returns `chrome.i18n.getMessage(key) || COPY[key]` for grace under missing locales). If 01-10 hasn't landed: file is NOT created."
    - path: "src/background/index.ts"
      provides: "Migrated to i18n + token palette: lines 76 BADGE_REC_COLOR='#00C853' updated to '#b2543d' with comment `// --mks-madder-600 per D-04 loom palette`; lines 82-84 BADGE_*_TITLE constants are unused once setTitle reads chrome.i18n.getMessage at the call site (the 4 setTitle call sites in setBadgeState read chrome.i18n.getMessage('tooltipOff'|'tooltipRec'|'tooltipErr')); lines 862-865 notification title='Mokosh stopped' + message='Recording stopped...' migrate to chrome.i18n.getMessage('notifRecoveryTitle' or similar + 'notifRecovery'); line 964 notification 'Mokosh ready' + 'Click here to start recording your session.' migrate to chrome.i18n.getMessage('notifStartupTitle' + 'notifStartup'). For tooltipRec the runtime time-suffix `' (' + formatElapsed(seconds) + ')'` is concatenated after the chrome.i18n.getMessage('tooltipRecPrefix') call per RESEARCH §10 note. No dynamic imports added (MV3 SW constraint)."
      contains: "chrome.i18n.getMessage"
    - path: "vite.config.ts"
      provides: "Adds `define` field property __VITE_DEV__ = JSON.stringify(process.env.VITE_DEV === '1') alongside existing __MOKOSH_UAT__: 'false', per RESEARCH §12 D-09 defensive flag. NO other changes (build target, resolve.alias, rollupOptions.input, plugins all preserved)."
      contains: "__VITE_DEV__"
    - path: "vite.test.config.ts"
      provides: "Inherits __VITE_DEV__ via mergeConfig (base config gains the field; test config doesn't override). No structural change to existing __MOKOSH_UAT__: 'true' override or rollupOptions.input."
    - path: "tests/build/tokens-adopted.test.ts"
      provides: "Vitest unit test: src/shared/tokens.css exists + parses as CSS; popup/style.css contains `@import '../shared/tokens.css'`; src/popup/style.css contains ZERO `#[0-9a-fA-F]{3,8}` hex literals (regex match count === 0); src/welcome/welcome.css if exists contains ZERO hex literals."
      min_lines: 30
    - path: "tests/build/fonts-present.test.ts"
      provides: "Vitest unit test: every WOFF2 file declared in src/shared/tokens.css @font-face exists at the relative path; each is > 0 bytes; the file-name format ('Lora-VariableFont.woff2' or similar) matches the @font-face src URL. Validates the 7 expected files (Lora normal, Lora italic if separate, Plex Sans ×4, Plex Mono ×2) per Wave 0 inventory."
      min_lines: 25
    - path: "tests/build/icons-present.test.ts"
      provides: "Vitest unit test: icons/icon16.png ≥ 200 B; icons/icon48.png ≥ 500 B; icons/icon128.png ≥ 1024 B (Chrome imageUtil floors per assets-spec.md). Each is a PNG by signature (first 8 bytes match \\x89PNG\\r\\n\\x1a\\n). Each is at the expected dimensions (read via simple PNG header parse: width at bytes 16-19, height at bytes 20-23, network byte order)."
      min_lines: 35
    - path: "tests/build/no-remote-fonts.test.ts"
      provides: "Vitest unit test: `npm run build` runs (or SKIP_BUILD=1); reads dist/ recursively; asserts ZERO files contain 'googleapis' OR 'https://fonts'. Mirrors the existing no-test-hooks-in-prod-bundle.test.ts execFile pattern (RESEARCH §1 + §13 CSP migration verification)."
      min_lines: 40
    - path: "tests/i18n/manifest-i18n.test.ts"
      provides: "Vitest unit test: manifest.json parses; manifest.name === '__MSG_extName__'; manifest.description === '__MSG_extDesc__'; manifest.default_locale === 'en'; manifest.action.default_title === '__MSG_tooltipOff__'; _locales/en/messages.json parses with extName.message === 'Mokosh — Session Capture' + extDesc.message === 'Thirty seconds ago, always at hand.'; _locales/ru/messages.json parses with extName.message === 'Mokosh — Запись сессии' + extDesc.message === 'Тридцать секунд назад, всегда под рукой.'."
      min_lines: 35
    - path: "tests/i18n/locale-parity.test.ts"
      provides: "Vitest unit test: every key in _locales/ru/messages.json exists in _locales/en/messages.json (default_locale parity per RESEARCH Pitfall 4). Every key's .message field is a non-empty string."
      min_lines: 20
    - path: "tests/uat/extension-page-harness.ts"
      provides: "EXTENDED with 5 new page-side assertion methods on window.__mokoshHarness: assertA18 (font asset reachability + size floor), assertA19 (icon not Bug A placeholder — hash-distinct), assertA20 (manifest name resolved via i18n), assertA21 (--mks-font-display computed value === 'Lora'), assertA22 (welcome tokens loaded — gated on welcome.html being reachable). Each method returns the standard AssertionRecord { passed, name, checks, diagnostics } shape consistent with A1-A17. The harness page DOM gains a small test-element div with class='mks-display-1' for A21's getComputedStyle probe; this DOM element ships in the test bundle only (dist-test/) since it lives inside extension-page-harness.html."
      min_lines: 200
    - path: "tests/uat/lib/harness-page-driver.ts"
      provides: "Adds driveA18, driveA19, driveA20, driveA21, driveA22 host-side wrappers following the existing driveA4 pattern (page.evaluate → window.__mokoshHarness.assertXX). No new host-side filesystem or ffprobe primitives; all A18-A22 work runs page-side via fetch + getComputedStyle + chrome.runtime.getManifest."
      min_lines: 60
    - path: "tests/uat/harness.test.ts"
      provides: "Orchestrator extended with A18-A22 entries. FORBIDDEN_HOOK_STRINGS list UNCHANGED at 12 entries post-Plan-01-14 (no new test-mode symbols introduced by Plan 01-12). Assertion count grows from 16/16 (post-01-13+01-14) or 19/19 (post-01-10+01-13+01-14) to 21/21 or 24/24 (post-01-12). Bail-on-first-failure orchestration unchanged."
      contains: "A18"
    - path: ".planning/STATE.md"
      provides: "Wave 7 closure update: completed_plans increments by 1 (Plan 01-12 closes); decision-log appends '[Phase 01-12]: design integration landed — Lora self-hosted (R2 per designer 2026-05-19) + tokens.css canonical + 8 i18n strings + branded icons + manifest:name i18n; UAT harness 21/21 GREEN (or 24/24 if 01-10 also landed); operator brand-fit ack received'. percent recomputed."
    - path: ".planning/ROADMAP.md"
      provides: "Phase 1 Plans list gains `- [x] 01-12-PLAN.md — Design integration (R2 Lora, tokens.css, 8 i18n strings, branded icons, manifest i18n)` entry after 01-13. If Plan 01-10 not yet in list, also add its entry as `- [ ]` placeholder unless already present."
  key_links:
    - from: "src/shared/tokens.css @font-face src urls"
      to: "src/shared/fonts/*.woff2"
      via: "relative url('./fonts/<face>.woff2') per RESEARCH §2 Pattern A; Vite asset pipeline rebases at build to dist/assets/<hash>.woff2 with @crxjs auto-generated web_accessible_resources entry"
      pattern: "url\\(\\\"\\./fonts/[A-Z][A-Za-z]+-[A-Za-z]+\\.woff2\\\"\\)"
    - from: "src/popup/style.css"
      to: "src/shared/tokens.css"
      via: "@import '../shared/tokens.css'; at top of file"
      pattern: "@import.*shared/tokens\\.css"
    - from: "manifest.json:action.default_title"
      to: "_locales/{en,ru}/messages.json:tooltipOff.message"
      via: "Chrome i18n runtime substitution via __MSG_tooltipOff__ token + default_locale fallback chain"
      pattern: "__MSG_tooltipOff__"
    - from: "src/background/index.ts setBadgeState() setTitle call"
      to: "_locales/{en,ru}/messages.json"
      via: "chrome.i18n.getMessage('tooltipOff'|'tooltipRec'|'tooltipErr') synchronous read"
      pattern: "chrome\\.i18n\\.getMessage"
    - from: "src/popup/index.ts saveArchive success branch"
      to: "_locales/{en,ru}/messages.json:popupSaveDone.message"
      via: "chrome.i18n.getMessage('popupSaveDone') in updateUI/saveArchive"
      pattern: "popupSaveDone"
    - from: "icons/icon{16,48,128}.png"
      to: "src/shared/brand/mokosh-mark.svg"
      via: "scripts/rasterize-icons.sh runs rsvg-convert -w N -h N <svg> -o icons/iconN.png"
      pattern: "rsvg-convert.*mokosh-mark"
    - from: "tests/uat/extension-page-harness.ts assertA18"
      to: "src/shared/fonts/Lora-VariableFont.woff2"
      via: "fetch(chrome.runtime.getURL('shared/fonts/Lora-VariableFont.woff2')) OR fetch using emitted asset-rebased path (extracted via document.styleSheets[i].cssRules @font-face src.url at runtime — works regardless of Vite hashing)"
      pattern: "chrome\\.runtime\\.getURL.*\\.woff2"
    - from: "tests/uat/extension-page-harness.ts assertA21"
      to: "src/shared/tokens.css --mks-font-display value"
      via: "Creates a transient div, applies class='mks-display-1', reads getComputedStyle(div).fontFamily, asserts it starts with 'Lora'"
      pattern: "getComputedStyle.*fontFamily"
    - from: "tests/build/no-remote-fonts.test.ts"
      to: "dist/ artifact tree"
      via: "execFile npm run build; recursive readFileSync over dist/; grep for 'googleapis' + 'https://fonts'"
      pattern: "googleapis|https://fonts"

tags_repeat:
  - design-integration
  - locales
  - mv3-csp

user_setup:
  - service: "Lora font (OFL-1.1)"
    why: "R2 designer reply 2026-05-19 substitutes Newsreader → Lora for Cyrillic coverage; Plan 01-12 self-hosts the WOFF2 per MV3 CSP"
    network_needed: true
    one_off_download:
      - url: "https://github.com/cyrealtype/Lora-Cyrillic OR Google Fonts Lora release page"
        why: "upstream Lora variable TTF — subsetted to Latin+Cyrillic by scripts/subset-fonts.sh"
        installs_to: "(transient scratch dir; only the subsetted woff2 committed to src/shared/fonts/)"
    license_attribution_required: true
    license_file: "src/shared/fonts/LICENSE-Lora.txt"
  - service: "IBM Plex Sans + Mono (OFL-1.1)"
    why: "D-05 type pairing; Plex Sans is the UI body face with full Cyrillic; Plex Mono is the diagnostic/timer face"
    network_needed: true
    one_off_download:
      - url: "https://github.com/IBM/plex/releases"
        why: "upstream Plex Sans + Mono v1.1.0 TTFs"
        installs_to: "(transient scratch dir)"
    license_attribution_required: true
    license_file: "src/shared/fonts/LICENSE-IBM-Plex.txt"
---

## Scope Sanity Note

**7 waves, 14 tasks total** (Wave 0 TDD scaffolds + Waves 1-7 implementation including 1 closing checkpoint). Above the "split signal" thresholds in `<scope_estimation>`, but the scope is dictated by the source artifact set (8 i18n strings + token system + 7 fonts + 3 icons + manifest + popup + welcome conditional + harness extension + UAT closure) and the planner has no authority to reduce coverage (see `planner_authority_limits`). Each wave is intended for a fresh-context executor spawn per the GSD execute-phase pattern.

**Why not split further:**

1. **Wave structure already maps to natural seams**. Wave 0 (TDD scaffolds — RED-by-design unit tests for tokens/fonts/icons/i18n/no-remote-fonts) gates every later wave's GREEN outcome. Wave 1 (fonts + tokens.css) is the foundation. Wave 2 (icons) is independent. Wave 3 (manifest + locales) depends only on tokens existing. Wave 4 (popup + background i18n + palette) depends on Waves 1-3. Wave 5 (welcome conditional + Vite define) is a single mostly-cosmetic wave. Wave 6 (harness A18-A22) extends the proven 01-13 architecture. Wave 7 closure + operator checkpoint.
2. **Per-executor context budget**: Wave 0 ~10%, Wave 1 ~35% (font subsetting + tokens.css migration; the heaviest wave), Wave 2 ~10%, Wave 3 ~15%, Wave 4 ~25%, Wave 5 ~10%, Wave 6 ~20%, Wave 7 ~5%. Each wave fits the ~50% target with margin.
3. **Cross-wave atomicity**: every wave produces a coherent commit-set with vitest GREEN at wave-close (Tier-1 gate, locale-parity, no-remote-fonts, etc. all run after every wave).

**If a future revision DOES force a split,** natural cut line: Plan 01-12A = Waves 0-3 (test scaffolds + fonts + tokens + icons + manifest + locales); Plan 01-12B = Waves 4-7 (source-code adoption + welcome + harness + closure). Wave numbering reflects this potential split cleanly.

<objective>
Land the full Mokosh design integration end-to-end across 7 waves. Three coordinated changes from the current Plan 01-13 baseline:

1. **Self-host the OFL font bundle + the canonical token system.** Subset Lora variable (per R2 designer reply 2026-05-19: Newsreader substituted with Lora for Cyrillic coverage), IBM Plex Sans 400/500/600/700, and IBM Plex Mono 400/500 to Latin + Cyrillic basic via pyftsubset. Place WOFF2 + LICENSE + README files under `src/shared/fonts/`. Land `src/shared/tokens.css` as the canonical token system — copy of designer's handoff with two surgical edits: (a) Google Fonts @import → local @font-face rules; (b) `--mks-font-display` from `"Newsreader"` to `"Lora"`. Add `.mks-word` class for the lockup wordmark. Verifies via `tests/build/no-remote-fonts.test.ts` (CSP-safe production bundle).

2. **Brand-mark PNG icons + manifest i18n.** Rasterize `src/shared/brand/mokosh-mark.svg` to `icons/icon{16,48,128}.png` via `rsvg-convert`, replacing the Plan 01-09 Path A dark-square + green-dot placeholders. Migrate `manifest.json` to `__MSG_extName__` + `__MSG_extDesc__` + `default_locale: "en"` + `action.default_title: "__MSG_tooltipOff__"`. Land `_locales/{en,ru}/messages.json` with the 8 Brief §02 operator-facing strings + 4 supporting keys (extName per D-07 override, extDesc per D-08 tagline, popupInfoText, popupEmptyState).

3. **Source-code adoption of tokens + i18n.** Restyle `src/popup/style.css` to import `src/shared/tokens.css` and replace all hex literals with `var(--mks-*)` references per D-04 loom palette. Migrate `src/popup/index.ts` + `src/background/index.ts` from hardcoded RU/EN strings to `chrome.i18n.getMessage` reads at every call site (badge titles, notification copy, popup CTA + status). Update `BADGE_REC_COLOR` from material-green `#00C853` to madder `#b2543d` (= --mks-madder-600). Wire the welcome page (Plan 01-10 conditional) to the canonical tokens.css.

**Wave 6 extends the 01-13 UAT harness** with 5 new assertions A18-A22 covering: WOFF2 reachability + size floor; icons-not-placeholders; manifest:name i18n resolution; `--mks-font-display` resolves to Lora; welcome tokens loaded (conditional on 01-10 landing). The harness pattern follows 01-13 Wave 3 exactly (page-side `assertA18..A22` methods + host-side `driveA18..A22` wrappers + orchestrator entries).

**Wave 7 closure:** operator empirical brand-fit checkpoint (autonomous: false) — fresh build, load unpacked, verify branded surfaces (toolbar icon, popup palette + Lora display heading, manifest name in chrome://extensions, Russian copy with Lora rendering, notification copy). Operator ack closes the plan + Phase 1 charter.

**Operator role at closure:** post-01-13, the operator's gates are brand/design only. Plan 01-12 satisfies the LAST remaining Phase 1 operator gate (brand-fit ack of branded surfaces).

Output:
- Wave 0: 6 RED unit tests scaffolded (tokens-adopted, fonts-present, icons-present, no-remote-fonts, manifest-i18n, locale-parity); each RED today, GREEN after later waves land their respective artifacts.
- Wave 1: src/shared/fonts/ + src/shared/tokens.css + scripts/subset-fonts.sh; fonts-present + no-remote-fonts flip GREEN.
- Wave 2: icons/icon{16,48,128}.png + scripts/rasterize-icons.sh + src/shared/brand/; icons-present flips GREEN.
- Wave 3: manifest.json + _locales/{en,ru}/messages.json; manifest-i18n + locale-parity flip GREEN; manifest:name resolves to "Mokosh — Session Capture" on extension reload.
- Wave 4: src/popup/style.css + src/popup/index.html + src/popup/index.ts + src/background/index.ts; tokens-adopted flips GREEN; existing 100+ vitest tests stay GREEN (i18n-call-site migration is behavior-equivalent for the chrome.* mock pattern used by existing tests).
- Wave 5: src/welcome/* conditional migration (if 01-10 landed) OR skip; vite.config.ts __VITE_DEV__ define-token + scripts/README.md.
- Wave 6: tests/uat/extension-page-harness.ts + tests/uat/lib/harness-page-driver.ts + tests/uat/harness.test.ts extended with A18-A22; npm run test:uat reports 21/21 (or 24/24 with 01-10) GREEN.
- Wave 7: operator checkpoint; STATE.md + ROADMAP.md sync; SUMMARY produced.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
@.planning/phases/01-stabilize-video-pipeline/01-09-PLAN.md
@.planning/phases/01-stabilize-video-pipeline/01-09-SUMMARY.md
@.planning/phases/01-stabilize-video-pipeline/01-10-PLAN.md
@.planning/phases/01-stabilize-video-pipeline/01-11-SUMMARY.md
@.planning/phases/01-stabilize-video-pipeline/01-13-PLAN.md
@.planning/phases/01-stabilize-video-pipeline/01-13-SUMMARY.md
@.planning/phases/01-stabilize-video-pipeline/01-12-RESEARCH.md
@.planning/intel/brand-decisions-v1.md
@.planning/intel/brand-decisions-v1-followup-display-font.md
@.planning/intel/assets-spec.md
@.planning/intel/design-incoming/system/bundle/mokosh-handoff/tokens.css
@.planning/intel/design-incoming/system/bundle/mokosh-handoff/README.txt
@.planning/intel/design-incoming/system/bundle/mokosh-handoff/assets/mokosh-mark.svg
@.planning/intel/design-incoming/system/bundle/mokosh-handoff/assets/mokosh-lockup.svg
@src/background/index.ts
@src/popup/index.html
@src/popup/index.ts
@src/popup/style.css
@manifest.json
@vite.config.ts
@vite.test.config.ts
@tests/uat/extension-page-harness.ts
@tests/uat/harness.test.ts
@tests/uat/lib/harness-page-driver.ts
@tests/background/no-test-hooks-in-prod-bundle.test.ts

<interfaces>
<!-- Key types, paths, and surfaces the executor needs. Embedded so no codebase exploration required. -->

### Designer reply ratifying R2 (per follow-up doc + the 2026-05-19 reply quoted in this plan's spawn brief)

```
--mks-font-display: "Lora", "Iowan Old Style", "Times New Roman", serif;
```

Use this EXACT string as the canonical token value in src/shared/tokens.css. Do NOT derive a different chain. Lora is OFL-1.1, variable-font (400-700 weight in one file), with FULL Cyrillic coverage (Cyreal foundry, designed by Olga Karpushina with Cyrillic-Latin parity).

### Per-face WOFF2 subsetting recipe (RESEARCH §1 + §6)

```bash
UNICODES='U+0020-007E,U+00A0-00FF,U+0131,U+0152-0153,U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116'

# Lora variable (Latin+Cyrillic; variable wght axis 400-700)
pyftsubset Lora-VariableFont_wght.ttf \
  --unicodes="$UNICODES" \
  --flavor=woff2 \
  --output-file=src/shared/fonts/Lora-VariableFont.woff2 \
  --layout-features='*' --no-hinting --desubroutinize

# Repeat for Lora-Italic-VariableFont_wght.ttf if upstream ships italic separately
# (A5 in RESEARCH Assumptions Log — verify at execute-plan time)

# Plex Sans 400/500/600/700 (each as a separate static-weight file)
for weight in Regular Medium SemiBold Bold; do
  pyftsubset "IBMPlexSans-${weight}.ttf" \
    --unicodes="$UNICODES" \
    --flavor=woff2 \
    --output-file="src/shared/fonts/IBMPlexSans-${weight}.woff2" \
    --layout-features='*' --no-hinting --desubroutinize
done

# Plex Mono 400/500
for weight in Regular Medium; do
  pyftsubset "IBMPlexMono-${weight}.ttf" \
    --unicodes="$UNICODES" \
    --flavor=woff2 \
    --output-file="src/shared/fonts/IBMPlexMono-${weight}.woff2" \
    --layout-features='*' --no-hinting --desubroutinize
done
```

### Local-faced tokens.css @font-face replacement block (replaces line 12 Google Fonts @import)

```css
/* ── Self-hosted fonts (MV3 CSP) ──
   Faces per D-05 with R2 Newsreader→Lora substitution (designer reply 2026-05-19).
   Subsetting: all faces are Latin (U+0020-007E + U+00A0-00FF) + Cyrillic basic
   (U+0400-045F + supplemental code points per pyftsubset recipe). All OFL-1.1
   (LICENSE-Lora.txt + LICENSE-IBM-Plex.txt next to this directory). */

/* Lora variable — display family with full Cyrillic (R2 substitute for Newsreader) */
@font-face {
  font-family: "Lora";
  src: url("./fonts/Lora-VariableFont.woff2") format("woff2-variations"),
       url("./fonts/Lora-VariableFont.woff2") format("woff2");
  font-weight: 400 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Lora";
  src: url("./fonts/Lora-Italic-VariableFont.woff2") format("woff2-variations"),
       url("./fonts/Lora-Italic-VariableFont.woff2") format("woff2");
  font-weight: 400 700;
  font-style: italic;
  font-display: swap;
}

/* IBM Plex Sans — UI body family with full Cyrillic */
@font-face {
  font-family: "IBM Plex Sans";
  src: url("./fonts/IBMPlexSans-Regular.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "IBM Plex Sans";
  src: url("./fonts/IBMPlexSans-Medium.woff2") format("woff2");
  font-weight: 500; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "IBM Plex Sans";
  src: url("./fonts/IBMPlexSans-SemiBold.woff2") format("woff2");
  font-weight: 600; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "IBM Plex Sans";
  src: url("./fonts/IBMPlexSans-Bold.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}

/* IBM Plex Mono — diagnostic / timer family with full Cyrillic */
@font-face {
  font-family: "IBM Plex Mono";
  src: url("./fonts/IBMPlexMono-Regular.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "IBM Plex Mono";
  src: url("./fonts/IBMPlexMono-Medium.woff2") format("woff2");
  font-weight: 500; font-style: normal; font-display: swap;
}
```

NOTE: if upstream Lora ships normal+italic in a single multi-axis variable file (italic via `ital` axis), the italic @font-face rule above collapses into the normal one with `font-style: oblique 0deg 14deg` per the variable-italic syntax. Verify upstream Lora release structure during execute-plan; adjust @font-face block accordingly. RESEARCH §A5 calls this out as a verify-at-execute checkpoint.

### .mks-word class (RESEARCH §8)

```css
/* ── Lockup wordmark — referenced by mokosh-lockup.svg line 21 ── */
.mks-word {
  font-family: var(--mks-font-display);
  font-size: 32px;
  font-weight: var(--mks-weight-regular);
  letter-spacing: var(--mks-tracking-display);
  fill: var(--mks-ink-900);
}
```

### Icon rasterization recipe (RESEARCH §3 verified locally)

```bash
#!/usr/bin/env bash
set -euo pipefail
SVG="src/shared/brand/mokosh-mark.svg"
for size in 16 48 128; do
  rsvg-convert -w "$size" -h "$size" "$SVG" -o "icons/icon${size}.png"
  bytes=$(stat -c%s "icons/icon${size}.png")
  echo "✓ icons/icon${size}.png (${bytes} bytes)"
  case $size in
    16)  [[ $bytes -ge 200  ]] || { echo "FAIL: icon16 < 200 B"; exit 1; } ;;
    48)  [[ $bytes -ge 500  ]] || { echo "FAIL: icon48 < 500 B"; exit 1; } ;;
    128) [[ $bytes -ge 1024 ]] || { echo "FAIL: icon128 < 1024 B"; exit 1; } ;;
  esac
done
```

### messages.json schema (Chrome i18n; RESEARCH §10)

```json
{
  "extName": {
    "message": "Mokosh — Session Capture",
    "description": "manifest.json:name; surfaces in chrome://extensions, Web Store, and right-click menus"
  },
  "tooltipRecPrefix": {
    "message": "Mokosh — recording",
    "description": "Prefix; the SW concatenates ' (MM:SS)' at runtime via formatElapsed(seconds)"
  }
}
```

JS-side read (synchronous; chrome.i18n.getMessage does NOT return a promise):

```ts
const cta = chrome.i18n.getMessage('popupSaveCta');  // returns '' on missing key
buttonText.textContent = cta;
```

### Background SW migration shape (src/background/index.ts)

```ts
// REPLACES line 76: const BADGE_REC_COLOR = '#00C853';     // material green
const BADGE_REC_COLOR = '#b2543d';     // --mks-madder-600 per D-04 loom palette (RESEARCH §10 Open Question A7)

// REPLACES the const BADGE_*_TITLE block (lines 82-84) — read at call site instead:
function setBadgeState(state: BadgeState): void {
  let text: string;
  let color: string;
  let titleKey: 'tooltipOff' | 'tooltipRec' | 'tooltipErr';
  if (state === 'REC') {
    text = BADGE_REC_TEXT;
    color = BADGE_REC_COLOR;
    titleKey = 'tooltipRec';
  } else if (state === 'OFF') {
    text = BADGE_OFF_TEXT;
    color = BADGE_OFF_COLOR;
    titleKey = 'tooltipOff';
  } else {
    text = BADGE_ERROR_TEXT;
    color = BADGE_ERROR_COLOR;
    titleKey = 'tooltipErr';
  }
  const title = chrome.i18n.getMessage(titleKey);
  try { chrome.action.setBadgeText({ text }); } catch (e) { logger.warn('setBadgeText failed:', e); }
  try { chrome.action.setBadgeBackgroundColor({ color }); } catch (e) { logger.warn('setBadgeBackgroundColor failed:', e); }
  try { chrome.action.setTitle({ title }); } catch (e) { logger.warn('setTitle failed:', e); }
}

// REPLACES line 862-865 (RECORDING_ERROR recovery notification body):
chrome.notifications.create(recoveryId, {
  type: 'basic',
  iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH),
  title: chrome.i18n.getMessage('extName'),
  message: chrome.i18n.getMessage('notifRecovery'),
  priority: 1,
});

// REPLACES line 961-965 (onStartup notification body):
chrome.notifications.create(notificationId, {
  type: 'basic',
  iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH),
  title: chrome.i18n.getMessage('extName'),
  message: chrome.i18n.getMessage('notifStartup'),
  priority: 1,
});
```

### Popup TS migration shape (src/popup/index.ts)

```ts
// in updateUI()
case 'idle':
  buttonText.textContent = chrome.i18n.getMessage('popupSaveCta');  // was 'Сохранить отчёт об ошибке'
  saveButton.className = 'save-button';
  saveButton.disabled = !popupState.hasPermissions;
  break;
case 'saving':
  buttonText.textContent = chrome.i18n.getMessage('popupSaving');  // new key OR keep 'Сохраняю...' as fallback
  break;
case 'done':
  buttonText.textContent = chrome.i18n.getMessage('popupSaveDoneShort');  // new key 'Готово! ✓'
  break;

// in saveArchive() success branch (was line 91):
statusMessage.textContent = chrome.i18n.getMessage('popupSaveDone');  // was 'Архив успешно сохранён!'

// in init() — populate data-mks-key elements (info-text + status):
function populateMksKeys(): void {
  document.querySelectorAll<HTMLElement>('[data-mks-key]').forEach((el) => {
    const key = el.dataset.mksKey;
    if (key) el.textContent = chrome.i18n.getMessage(key);
  });
}
```

### Harness assertion A18-A22 shape (extends 01-13 pattern)

```ts
// tests/uat/extension-page-harness.ts (page-side)
async function assertA18(): Promise<AssertionRecord> {
  const checks: string[] = [];
  // Resolve the actual emitted asset URL from the loaded stylesheet (handles Vite hashing).
  const sheets = Array.from(document.styleSheets);
  let woff2Url: string | null = null;
  for (const sheet of sheets) {
    try {
      for (const rule of Array.from(sheet.cssRules)) {
        if (rule.constructor.name === 'CSSFontFaceRule') {
          const src = (rule as CSSFontFaceRule).style.getPropertyValue('src');
          const match = src.match(/url\(["']?([^"')]+Lora[^"')]+\.woff2)["']?\)/);
          if (match) { woff2Url = match[1]; break; }
        }
      }
    } catch { /* cross-origin sheets throw; skip */ }
    if (woff2Url) break;
  }
  if (!woff2Url) {
    return { passed: false, name: 'A18', checks, diagnostics: 'No Lora @font-face rule found in any stylesheet', error: '' };
  }
  checks.push(`A18.1: found Lora @font-face src=${woff2Url}`);
  const resp = await fetch(new URL(woff2Url, location.href).href);
  if (!resp.ok) return { passed: false, name: 'A18', checks, diagnostics: `fetch ${woff2Url} returned ${resp.status}`, error: '' };
  checks.push(`A18.2: fetch returned ${resp.status}`);
  const buf = await resp.arrayBuffer();
  if (buf.byteLength < 50000) return { passed: false, name: 'A18', checks, diagnostics: `Lora woff2 only ${buf.byteLength} B (expected >50000)`, error: '' };
  checks.push(`A18.3: byteLength=${buf.byteLength}`);
  return { passed: true, name: 'A18', checks, diagnostics: '' };
}

async function assertA20(): Promise<AssertionRecord> {
  const manifest = chrome.runtime.getManifest();
  if (manifest.name === 'Mokosh — Session Capture') {
    return { passed: true, name: 'A20', checks: [`A20.1: manifest.name='${manifest.name}'`], diagnostics: '' };
  }
  return { passed: false, name: 'A20', checks: [], diagnostics: `Expected 'Mokosh — Session Capture'; got '${manifest.name}'`, error: '' };
}

async function assertA21(): Promise<AssertionRecord> {
  // Create transient probe div with .mks-display-1 class
  const probe = document.createElement('div');
  probe.className = 'mks-display-1';
  probe.textContent = 'Probe';
  document.body.appendChild(probe);
  // Force layout so CSS resolves
  void probe.offsetHeight;
  const computed = window.getComputedStyle(probe).fontFamily;
  document.body.removeChild(probe);
  if (computed.startsWith('Lora') || computed.startsWith('"Lora"')) {
    return { passed: true, name: 'A21', checks: [`A21.1: fontFamily='${computed}'`], diagnostics: '' };
  }
  return { passed: false, name: 'A21', checks: [], diagnostics: `--mks-font-display did not resolve to Lora; computed='${computed}'`, error: '' };
}
```

### Tier-1 forbidden-strings inventory (UNCHANGED at 12 post-Plan-01-14)

```ts
const FORBIDDEN_HOOK_STRINGS: ReadonlyArray<string> = [
  '__mokoshTest',
  'setCurrentStream',
  'setSegmentCountGetter',
  'installFakeDisplayMedia',
  'uninstallFakeDisplayMedia',
  'dispatchEndedOnTrack',
  'getSegmentCount',
  '__mokoshOffscreenQuery',
  'get-display-surface',
  'get-segment-count',
  'lastGetDisplayMediaConstraints',         // added by Plan 01-14
  'get-last-getDisplayMedia-constraints',   // added by Plan 01-14
];
// Plan 01-12 introduces NO new test-mode symbols (A18-A22 use production chrome.* APIs
// + fetch + getComputedStyle exclusively). Do NOT extend this list beyond the 12 entries
// established by Plan 01-14.
```

### Locale-parity test shape

```ts
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';

describe('_locales/ key parity', () => {
  const ru = JSON.parse(readFileSync('_locales/ru/messages.json', 'utf8'));
  const en = JSON.parse(readFileSync('_locales/en/messages.json', 'utf8'));
  it('every key in ru/messages.json exists in en/messages.json', () => {
    for (const k of Object.keys(ru)) expect(en).toHaveProperty(k);
  });
  it('every key in en/messages.json exists in ru/messages.json (symmetric)', () => {
    for (const k of Object.keys(en)) expect(ru).toHaveProperty(k);
  });
  it('every key has a non-empty .message string', () => {
    for (const k of Object.keys(en)) expect(typeof en[k].message).toBe('string');
    for (const k of Object.keys(en)) expect(en[k].message.length).toBeGreaterThan(0);
    for (const k of Object.keys(ru)) expect(typeof ru[k].message).toBe('string');
    for (const k of Object.keys(ru)) expect(ru[k].message.length).toBeGreaterThan(0);
  });
});
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Wave 0 Task 1: Scaffold RED unit tests (TDD baseline)</name>
  <files>tests/build/tokens-adopted.test.ts, tests/build/fonts-present.test.ts, tests/build/icons-present.test.ts, tests/build/no-remote-fonts.test.ts, tests/i18n/manifest-i18n.test.ts, tests/i18n/locale-parity.test.ts</files>
  <behavior>
    - tokens-adopted: RED because src/shared/tokens.css does not yet exist + src/popup/style.css contains hex literals (#2563eb, #1d4ed8, ...). GREEN after Wave 1 + Wave 4.
    - fonts-present: RED because src/shared/fonts/*.woff2 do not exist. GREEN after Wave 1.
    - icons-present: RED-ish (existing placeholders DO clear floors: 574/1153/2615 > 200/500/1024 per `ls -la icons/`); the test should assert dimensions + signature + size FLOOR strictly. RED-FOR-DIMS today only if Wave 0 includes the dimension parse (the 16-bit-RGB placeholders ARE the right pixel dims). Test designed to be GREEN after Wave 2 (Loom mark rasterized via rsvg-convert produces 8-bit-RGBA per RESEARCH §3 — file content hash will change, but dim + floor checks pass before AND after Wave 2). Add explicit RED case: assert PNG color-type byte (offset 25) === 6 (RGBA) — placeholder is 2 (RGB) so this assertion RED today, GREEN after Wave 2 (rsvg-convert defaults to RGBA per RESEARCH §3 "PNG output is `8-bit/color RGBA, non-interlaced` per `file(1)`").
    - no-remote-fonts: RED-vacuous today (no dist/ tokens.css exists). After Wave 1 + Wave 4 + npm run build, dist/ exists + tokens.css inside it must have ZERO `googleapis` matches.
    - manifest-i18n: RED today (manifest.name === 'AI Call Recorder' + no default_locale). GREEN after Wave 3.
    - locale-parity: RED today (no _locales/). GREEN after Wave 3.
  </behavior>
  <action>
    Create the six test files per the `<interfaces>` block patterns and the artifact-table `provides` summaries. Each test file follows the existing tests/background/no-test-hooks-in-prod-bundle.test.ts and tests/offscreen/codec-check.test.ts patterns for module structure (vitest describe/it + readFileSync + JSON.parse + regex checks).

    Key implementation notes:

    1. tests/build/tokens-adopted.test.ts — three vitest cases: (a) `src/shared/tokens.css` exists + parses (`readFileSync`); (b) `src/popup/style.css` contains the import line `@import '../shared/tokens.css'` OR `@import "../shared/tokens.css"`; (c) `src/popup/style.css` matches `/#[0-9a-fA-F]{3,8}/g` ZERO times. NO build dependency — runs on source files only.

    2. tests/build/fonts-present.test.ts — single describe; iterate over the expected file list (Lora-VariableFont.woff2, Lora-Italic-VariableFont.woff2 [SKIP if upstream consolidates italic into normal — flag via a TODO comment per RESEARCH §A5], IBMPlexSans-Regular.woff2, IBMPlexSans-Medium.woff2, IBMPlexSans-SemiBold.woff2, IBMPlexSans-Bold.woff2, IBMPlexMono-Regular.woff2, IBMPlexMono-Medium.woff2). For each: assert existsSync + statSync.size > 0. Also assert src/shared/tokens.css contains each filename via substring match.

    3. tests/build/icons-present.test.ts — for each of {16: 200, 48: 500, 128: 1024} → assert existsSync(`icons/icon${size}.png`) + statSync.size >= floor + first 8 bytes match `\\x89PNG\\r\\n\\x1a\\n`. Read bytes 16-19 (width) + 20-23 (height) as big-endian uint32 and assert each === size. Read byte 25 (color-type) and assert === 6 (RGBA per Wave 2 rsvg-convert output) — this is the RED-today-GREEN-after-Wave-2 case.

    4. tests/build/no-remote-fonts.test.ts — mirrors tests/background/no-test-hooks-in-prod-bundle.test.ts execFile pattern: `execFile('npm', ['run', 'build'])` (gated by `process.env.SKIP_BUILD === '1'` escape hatch), then recursive readdirSync over `dist/`, then for each file read content + assert ZERO matches for `googleapis` AND `https://fonts`. Use a 60_000ms build timeout (PROD_BUILD_TIMEOUT_MS).

    5. tests/i18n/manifest-i18n.test.ts — read manifest.json + parse. Assert: `manifest.name === '__MSG_extName__'`; `manifest.description === '__MSG_extDesc__'`; `manifest.default_locale === 'en'`; `manifest.action.default_title === '__MSG_tooltipOff__'`. Read _locales/en/messages.json + parse + assert extName.message === 'Mokosh — Session Capture' + extDesc.message === 'Thirty seconds ago, always at hand.'. Read _locales/ru/messages.json + parse + assert extName.message === 'Mokosh — Запись сессии' + extDesc.message === 'Тридцать секунд назад, всегда под рукой.'.

    6. tests/i18n/locale-parity.test.ts — per the `<interfaces>` block; three describe blocks (ru→en parity; en→ru symmetric; every key has non-empty .message string).

    Create the directories `tests/build/` and `tests/i18n/` (they do not yet exist — verified via `ls tests/`). Commit all six files in a single atomic commit.
  </action>
  <verify>
    <automated>npx vitest run tests/build tests/i18n 2>&1 | tail -20 && echo "Expected: 6 test files, all RED (or RED on at least one case each — fonts-present, tokens-adopted, no-remote-fonts, manifest-i18n, locale-parity all RED today; icons-present RED on color-type-byte case only)"</automated>
  </verify>
  <done>
    - 6 test files exist at the documented paths
    - vitest discovers all 6 + reports them in `npx vitest run tests/build tests/i18n`
    - At least one case per file is RED today (per behavior above)
    - npm run build / build:test still pass (these tests do not change source code yet — the existing 100 vitest GREEN baseline (post-01-14) stays GREEN; only the 6 new tests are RED)
    - Atomic commit `test(01-12): wave-0 — scaffold RED unit tests for tokens / fonts / icons / no-remote-fonts / manifest-i18n / locale-parity`
  </done>
</task>

<task type="auto" tdd="true">
  <name>Wave 1 Task 1: Subset OFL fonts to src/shared/fonts/</name>
  <files>src/shared/fonts/Lora-VariableFont.woff2, src/shared/fonts/Lora-Italic-VariableFont.woff2, src/shared/fonts/IBMPlexSans-Regular.woff2, src/shared/fonts/IBMPlexSans-Medium.woff2, src/shared/fonts/IBMPlexSans-SemiBold.woff2, src/shared/fonts/IBMPlexSans-Bold.woff2, src/shared/fonts/IBMPlexMono-Regular.woff2, src/shared/fonts/IBMPlexMono-Medium.woff2, src/shared/fonts/LICENSE-Lora.txt, src/shared/fonts/LICENSE-IBM-Plex.txt, src/shared/fonts/README.md, scripts/subset-fonts.sh</files>
  <behavior>
    Per RESEARCH §1 + §6 + the `<interfaces>` block subsetting recipe. Each WOFF2 file:
    - Lora-VariableFont.woff2: variable-axis weight 400-700, Latin + Cyrillic basic subset, expected 80-120KB
    - Lora-Italic-VariableFont.woff2: if upstream ships italic separately (A5 verify); otherwise document the consolidation in README.md and omit this file (and the corresponding @font-face rule in tokens.css)
    - IBMPlexSans-{Regular,Medium,SemiBold,Bold}.woff2: ~25KB each
    - IBMPlexMono-{Regular,Medium}.woff2: ~28KB each
    Expected total ~250-300KB per the R2 substitution adjustment to RESEARCH §1's ~216KB estimate.
  </behavior>
  <action>
    1. Create scripts/subset-fonts.sh per the `<interfaces>` block recipe. Header comment documents the source URLs (github.com/IBM/plex/releases for Plex v1.1.0; for Lora: github.com/cyrealtype/Lora-Cyrillic OR the Google Fonts release page accessed via `curl`). Script downloads upstream TTFs into a scratch tmpdir (`mktemp -d`), runs pyftsubset per face with the UNICODES range from `<interfaces>`, then cleans up the tmpdir.

    2. Execute scripts/subset-fonts.sh. Verify each produced file:
       - exists at the documented path
       - size > 0 (typical: Lora ~100KB; Plex faces ~25-28KB each)
       - is WOFF2 (first 4 bytes === 'wOF2' = 0x77 0x4F 0x46 0x32)
       Document any execute-time surprises (italic consolidation per A5, etc.) in a brief note in src/shared/fonts/README.md.

    3. Create src/shared/fonts/LICENSE-Lora.txt by curling https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/master/OFL.txt (or equivalent canonical Lora OFL source — verify upstream layout during execute). Include copyright lines per the actual upstream content.

    4. Create src/shared/fonts/LICENSE-IBM-Plex.txt by curling https://raw.githubusercontent.com/IBM/plex/master/LICENSE.txt (verbatim copy).

    5. Create src/shared/fonts/README.md per RESEARCH §7 template + the R2 substitution note. Document: what's bundled; upstream URLs; subsetting unicodes; license notes; regeneration recipe (`scripts/subset-fonts.sh`). Mention the R2 substitution (Newsreader → Lora) + the designer 2026-05-19 reply.

    6. Verify tests/build/fonts-present.test.ts now passes (all 7 or 8 expected files exist).

    NOTE on Lora italic: if upstream variable file includes italic via the `ital` axis (single multi-axis file), update scripts/subset-fonts.sh to subset that single file + adjust tokens.css (next task) to use `font-style: oblique 0deg 14deg` per CSS Fonts Module 4 variable-italic syntax. Document the decision in src/shared/fonts/README.md.
  </action>
  <verify>
    <automated>ls -la src/shared/fonts/ && stat -c '%n: %s bytes' src/shared/fonts/*.woff2 && head -3 src/shared/fonts/LICENSE-*.txt && head -10 src/shared/fonts/README.md && (npx vitest run tests/build/fonts-present.test.ts 2>&1 | tail -10) && echo "Wave 1.1 done — fonts present; total bundle:" && du -ch src/shared/fonts/*.woff2 | tail -1</automated>
  </verify>
  <done>
    - 7 or 8 WOFF2 files exist (8 if Lora italic separate; 7 if collapsed)
    - Each WOFF2 file is > 0 bytes + WOFF2 signature verified
    - LICENSE-Lora.txt + LICENSE-IBM-Plex.txt exist + non-empty
    - src/shared/fonts/README.md exists + lists all bundled faces + upstream URLs + R2 note
    - scripts/subset-fonts.sh exists + executable
    - Total bundle size between 200KB and 350KB (relaxed envelope for R2 italic variance)
    - tests/build/fonts-present.test.ts GREEN
    - Atomic commit `feat(01-12): wave-1 task-1 — self-host OFL font bundle (Lora + Plex Sans + Plex Mono; R2 designer reply 2026-05-19)`
  </done>
</task>

<task type="auto" tdd="true">
  <name>Wave 1 Task 2: Land canonical src/shared/tokens.css (R2 Lora substitution + .mks-word + CSP-safe @font-face)</name>
  <files>src/shared/tokens.css, src/shared/brand/mokosh-mark.svg, src/shared/brand/mokosh-lockup.svg</files>
  <behavior>
    src/shared/tokens.css becomes the canonical token system. Contents:
    - Verbatim copy of .planning/intel/design-incoming/system/bundle/mokosh-handoff/tokens.css (281 lines) with three surgical edits:
      1. Line 12 Google Fonts @import REMOVED + replaced with the 7-rule (or 8-rule if Lora italic separate) @font-face block from `<interfaces>`
      2. Line 76 `--mks-font-display: "Newsreader", "Iowan Old Style", "Times New Roman", serif;` REPLACED with `--mks-font-display: "Lora", "Iowan Old Style", "Times New Roman", serif;` per R2 designer reply 2026-05-19
      3. .mks-word class added at end of file per the `<interfaces>` block definition
    - ZERO references to fonts.googleapis.com or any https:// URL
    - ZERO Newsreader font-family references (R2 substitution complete)

    src/shared/brand/mokosh-mark.svg + mokosh-lockup.svg are verbatim copies of the design-incoming files (so the engineering source tree has its own copy decoupled from the intel/ ingestion location).
  </behavior>
  <action>
    1. Copy .planning/intel/design-incoming/system/bundle/mokosh-handoff/tokens.css to src/shared/tokens.css (use `cp` or Read + Write). Apply the three edits:
       - Replace line 12 (the Google Fonts @import) with the local @font-face block from `<interfaces>` — wrap in a comment header noting MV3 CSP + R2 designer reply 2026-05-19
       - Replace line 76 `--mks-font-display: "Newsreader"...` with `--mks-font-display: "Lora", "Iowan Old Style", "Times New Roman", serif;` — also update the comment at line 72 to note "Lora for display (Cyreal foundry; R2 substitute for Newsreader per designer reply 2026-05-19; full Cyrillic coverage)"
       - Append the .mks-word class block from `<interfaces>` at end of file with a clear header `/* ── Lockup wordmark — referenced by mokosh-lockup.svg line 21 ── */`

    2. Create src/shared/brand/ directory; copy mokosh-mark.svg + mokosh-lockup.svg from .planning/intel/design-incoming/system/bundle/mokosh-handoff/assets/ to src/shared/brand/. The intel/ copies stay as the original handoff source-of-truth; src/shared/brand/ is the engineering working copy.

    3. Verify src/shared/tokens.css:
       - `grep -c 'googleapis\\|https://fonts' src/shared/tokens.css` returns 0
       - `grep -c 'Newsreader' src/shared/tokens.css` returns 0
       - `grep -c '@font-face' src/shared/tokens.css` returns 7 (or 8 with Lora italic)
       - `grep '\\.mks-word' src/shared/tokens.css` finds the new class

    4. Verify CSS-parses by spawning `npx vite build --mode test --config vite.test.config.ts` (dry-build) and confirming no warnings about tokens.css; OR by running a quick CSS-syntax check via `npx stylelint src/shared/tokens.css --config '{}'` if available; OR by inspection.

    5. Confirm tests/build/no-remote-fonts.test.ts can now run (after a `npm run build` it will read dist/ and verify zero remote-font URLs). Run it with SKIP_BUILD=1 to defer the actual build to a later wave verification.
  </action>
  <verify>
    <automated>grep -c "googleapis\|https://fonts" src/shared/tokens.css && grep -c "Newsreader" src/shared/tokens.css && grep -c "@font-face" src/shared/tokens.css && grep -c "\.mks-word" src/shared/tokens.css && grep -c "Lora" src/shared/tokens.css && ls -la src/shared/brand/ && npx vitest run tests/build/tokens-adopted.test.ts 2>&1 | tail -10</automated>
  </verify>
  <done>
    - src/shared/tokens.css exists + has 0 occurrences of `googleapis` / `https://fonts` / `Newsreader`
    - src/shared/tokens.css has 7 (or 8) `@font-face` rule occurrences
    - src/shared/tokens.css contains `.mks-word` class definition
    - src/shared/tokens.css contains `Lora` font-family reference (≥1)
    - src/shared/brand/mokosh-mark.svg + mokosh-lockup.svg exist + are byte-identical copies of intel/ originals
    - tests/build/tokens-adopted.test.ts case (a) GREEN (tokens.css exists); cases (b)+(c) still RED until Wave 4 (popup/style.css doesn't yet import tokens.css)
    - Atomic commit `feat(01-12): wave-1 task-2 — canonical src/shared/tokens.css (R2 Lora substitution + .mks-word + local @font-face block)`
  </done>
</task>

<task type="auto" tdd="true">
  <name>Wave 2 Task 1: Rasterize Loom brand mark to icons/icon{16,48,128}.png</name>
  <files>icons/icon16.png, icons/icon48.png, icons/icon128.png, scripts/rasterize-icons.sh</files>
  <behavior>
    Rasterize src/shared/brand/mokosh-mark.svg to icons/icon{16,48,128}.png via rsvg-convert (verified locally at RESEARCH §3: 16=406B, 48=784B, 128=1952B — all clear assets-spec.md FLOOR). The Plan 01-09 Path A placeholder icons (16-bit-RGB dark-square + green-dot) are OVERWRITTEN.
    
    Per D-06 (Neutral mark + dynamic badge): single neutral mark per size; NO per-state PNG sets (the dynamic chrome.action.setBadgeBackgroundColor in src/background/index.ts does state communication).
  </behavior>
  <action>
    1. Create scripts/rasterize-icons.sh per the `<interfaces>` block recipe (with the embedded size-floor sanity checks). Make it executable (`chmod +x`).

    2. Execute scripts/rasterize-icons.sh. Verify each produced PNG:
       - exists at icons/icon{16,48,128}.png
       - file(1) reports "PNG image data, NxN, 8-bit/color RGBA, non-interlaced" (RESEARCH §3 verified)
       - size meets the FLOOR: 16≥200B, 48≥500B, 128≥1024B
       - byte 25 (color-type) === 6 (RGBA) — this is the case Wave 0 Task 1's icons-present test asserts

    3. Verify the OVERWRITE happened: the prior placeholders were `16-bit/color RGB` (per `file icons/icon*.png` at plan-write time); the new files MUST be `8-bit/color RGBA`. This is a load-bearing behavioral change confirming the new rasterized mark is in place.

    4. Re-verify all Plan 01-13 harness assertions still pass — specifically A9 (icon file sizes meet imageUtil floors per RESEARCH §3 verification) and A8 (Bug A regression: chrome.notifications.create succeeds with the new icon).

    Run npx vitest run tests/build/icons-present.test.ts; expect GREEN.
  <verify>
    <automated>file icons/icon*.png && stat -c '%n: %s bytes' icons/icon*.png && npx vitest run tests/build/icons-present.test.ts 2>&1 | tail -10 && head -c 8 icons/icon16.png | xxd</automated>
  </verify>
  <done>
    - icons/icon16.png ≥ 200 B + 16×16 + 8-bit RGBA
    - icons/icon48.png ≥ 500 B + 48×48 + 8-bit RGBA
    - icons/icon128.png ≥ 1024 B + 128×128 + 8-bit RGBA
    - scripts/rasterize-icons.sh exists + executable
    - tests/build/icons-present.test.ts GREEN (all cases including the color-type RGBA case)
    - Plan 01-13 harness A8 + A9 still GREEN (re-run `npm run test:uat -- --only=A8 --only=A9` if standalone selector supported; otherwise run full suite and confirm 15+/15+ GREEN)
    - Atomic commit `feat(01-12): wave-2 task-1 — rasterize Loom mark to icons/{16,48,128}.png (overwrites Bug A placeholders)`
  </done>
</task>

<task type="auto" tdd="true">
  <name>Wave 3 Task 1: Land _locales/{en,ru}/messages.json + migrate manifest.json to __MSG_*__ + default_locale</name>
  <files>_locales/en/messages.json, _locales/ru/messages.json, manifest.json</files>
  <behavior>
    Two messages.json files at _locales/en/ + _locales/ru/ carry the 12-key matrix per RESEARCH §10 + Brief §02 verbatim + D-07 user override + D-08 tagline. manifest.json migrates to i18n with __MSG_extName__ + __MSG_extDesc__ + default_locale='en' + action.default_title='__MSG_tooltipOff__'. On extension reload after this wave, chrome://extensions displays "Mokosh — Session Capture" (D-07) — verifies the end-to-end i18n wiring.
    
    Test gates: tests/i18n/manifest-i18n.test.ts + tests/i18n/locale-parity.test.ts flip GREEN.
  </behavior>
  <action>
    1. Create _locales/ + _locales/en/ + _locales/ru/ directories (do not yet exist).

    2. Create _locales/en/messages.json with the 12 keys per RESEARCH §10 + D-07 override + D-08 tagline (using brand-decisions-v1.md "always at hand." wording per RESEARCH Open Questions #4 default-action). Each key is a `{ "message": "<text>", "description": "<context for translators>" }` shape per Chrome i18n schema:

       - extName: "Mokosh — Session Capture"
       - extDesc: "Thirty seconds ago, always at hand."
       - tooltipOff: "Mokosh — click to start recording"
       - tooltipRecPrefix: "Mokosh — recording"
       - tooltipErr: "Mokosh — recording error, click to recover"
       - popupSavePrompt: "Save error report?"
       - popupSaveCta: "Save report"
       - popupSaveDone: "Archive saved to Downloads."
       - popupInfoText: "Last 30 s video + 10 min log"
       - notifStartup: "Recording started. I'm watching the last 30 seconds."
       - notifRecovery: "Recording resumed. Buffer refilling."
       - welcomeHeroRu: (RU primary; same as RU file's entry — used as the Russian-quoted hero string on welcome.html under EN locale)
       - welcomeHeroEn: "Thirty seconds ago, always at hand."

    3. Create _locales/ru/messages.json with the same 12 keys + Russian text per RESEARCH §10 Brief §02 verbatim extraction:

       - extName: "Mokosh — Запись сессии"
       - extDesc: "Тридцать секунд назад, всегда под рукой."
       - tooltipOff: "Mokosh — щёлкните, чтобы начать запись"
       - tooltipRecPrefix: "Mokosh — идёт запись"
       - tooltipErr: "Mokosh — ошибка записи, щёлкните для восстановления"
       - popupSavePrompt: "Сохранить отчёт об ошибке?"
       - popupSaveCta: "Сохранить отчёт"
       - popupSaveDone: "Архив сохранён в Загрузки."
       - popupInfoText: "Последние 30 сек видео + 10 мин лога"
       - notifStartup: "Запись запущена. Я слежу за последними 30 секундами."
       - notifRecovery: "Запись возобновлена. Буфер снова заполняется."
       - welcomeHeroRu: "Тридцать секунд назад, всегда под рукой."
       - welcomeHeroEn: "Thirty seconds ago, always at hand."

    Each key gets a `description` field carrying the surface + production call site (e.g. for tooltipRecPrefix: "Suffix '(MM:SS)' appended at runtime by the SW via formatElapsed(seconds)").

    4. Update manifest.json (Read → Edit):
       - `name` 'AI Call Recorder' → `__MSG_extName__`
       - `description` 'Запись сессий операторов для диагностики ошибок' → `__MSG_extDesc__`
       - Add `default_locale` field with value `"en"` (between version and permissions, or wherever idiomatic per current shape)
       - Add `default_title` field to `action` (alongside default_popup + default_icon) with value `"__MSG_tooltipOff__"`
       - Verify the trailing JSON is valid (`JSON.parse(readFileSync('manifest.json', 'utf8'))` succeeds)

    5. Run `npm run build`. Verify:
       - `dist/manifest.json` carries the new shape (crxjs writes its own manifest copy)
       - `dist/_locales/en/messages.json` + `dist/_locales/ru/messages.json` exist (crxjs propagates the _locales/ directory verbatim)
       
       NB: if crxjs's manifest-rewriting clobbers the i18n placeholders, document the issue + use the public/ directory fallback per RESEARCH §2 Pattern B (place _locales/ in public/_locales/ instead).

    6. Verify tests/i18n/manifest-i18n.test.ts + tests/i18n/locale-parity.test.ts both GREEN.

    7. Manual sanity: load the extension unpacked + observe chrome://extensions name field reads "Mokosh — Session Capture" (in English Chrome) or "Mokosh — Запись сессии" (in Russian Chrome). Document the observed display name in the commit body.
  </action>
  <verify>
    <automated>cat manifest.json | python3 -c 'import sys,json;m=json.load(sys.stdin);assert m["name"]=="__MSG_extName__",m["name"];assert m["description"]=="__MSG_extDesc__",m["description"];assert m["default_locale"]=="en";assert m["action"]["default_title"]=="__MSG_tooltipOff__";print("manifest OK")' && cat _locales/en/messages.json | python3 -c 'import sys,json;m=json.load(sys.stdin);assert m["extName"]["message"]=="Mokosh — Session Capture";assert m["extDesc"]["message"]=="Thirty seconds ago, always at hand.";print("en/messages.json OK")' && cat _locales/ru/messages.json | python3 -c 'import sys,json;m=json.load(sys.stdin);assert m["extName"]["message"]=="Mokosh — Запись сессии";assert m["extDesc"]["message"]=="Тридцать секунд назад, всегда под рукой.";print("ru/messages.json OK")' && npx vitest run tests/i18n/manifest-i18n.test.ts tests/i18n/locale-parity.test.ts 2>&1 | tail -10</automated>
  </verify>
  <done>
    - _locales/en/messages.json + _locales/ru/messages.json exist + parse + each carry the 12 keys with the canonical values
    - manifest.json:name === '__MSG_extName__'; :description === '__MSG_extDesc__'; :default_locale === 'en'; :action.default_title === '__MSG_tooltipOff__'
    - npm run build succeeds + dist/_locales/{en,ru}/messages.json exist + dist/manifest.json carries the new i18n shape
    - tests/i18n/manifest-i18n.test.ts + tests/i18n/locale-parity.test.ts GREEN
    - Existing 100 vitest baseline (post-01-14) + 6 new tests = 106 vitest GREEN
    - Atomic commit `feat(01-12): wave-3 task-1 — manifest i18n (__MSG_*__ + default_locale='en') + _locales/{en,ru}/messages.json (12 keys; D-07 + D-08 baked in)`
  </done>
</task>

<task type="auto" tdd="true">
  <name>Wave 4 Task 1: Migrate src/popup/ + src/background/ to tokens.css palette + chrome.i18n.getMessage</name>
  <files>src/popup/index.html, src/popup/index.ts, src/popup/style.css, src/background/index.ts</files>
  <behavior>
    src/popup/style.css imports tokens.css + replaces every hex literal with var(--mks-*) reference. src/popup/index.html + src/popup/index.ts populate operator-facing strings via chrome.i18n.getMessage. src/background/index.ts migrates badge titles + notification copy to chrome.i18n.getMessage + updates BADGE_REC_COLOR to madder (--mks-madder-600 = #b2543d per D-04). All existing vitest tests stay GREEN (chrome.i18n is a synchronous API; existing chrome.* mocks need a getMessage stub returning the key as the value for round-trip identity).
  </behavior>
  <action>
    1. Update src/popup/style.css per the artifact-table `provides` summary:
       - Add `@import "../shared/tokens.css";` at top (above `* { margin: 0; ...}`)
       - body { background: var(--mks-surface); color: var(--mks-fg-1); font-family: var(--mks-font-ui); }
       - .save-button { background: var(--mks-rec); color: var(--mks-fg-inverse); border-radius: var(--mks-radius-md); box-shadow: var(--mks-shadow-1); transition: all var(--mks-dur-base) var(--mks-ease-out); }
       - .save-button:hover:not(:disabled) { background: var(--mks-madder-700); box-shadow: var(--mks-shadow-2); }
       - .save-button:disabled { background: var(--mks-ink-300); }
       - .save-button.saving { background: var(--mks-amber-600); }
       - .save-button.done { background: var(--mks-moss-600); }
       - .info-text { color: var(--mks-fg-2); font-family: var(--mks-font-ui); }
       - .status-message.error { color: var(--mks-error); }
       - .status-message.success { color: var(--mks-success); }
       - .status-message.info { color: var(--mks-warning); }
       - ZERO hex literals (regex /#[0-9a-fA-F]{3,8}/ match count === 0; verified by tests/build/tokens-adopted.test.ts case (c))

    2. Update src/popup/index.html per artifact-table:
       - <title>Mokosh — Session Capture</title> (literal English title — chrome doesn't substitute __MSG__ in HTML body text or <title>; popup's title is a sensible default for non-RU locales). Document the limitation in a comment.
       - <span class="button-text"></span> — empty (populated by JS); remove the hardcoded 'Сохранить отчёт об ошибке'
       - <p class="info-text" data-mks-key="popupInfoText"></p> — empty + data-mks-key attribute; remove the hardcoded 'Последние 30 сек видео + 10 мин лога'

    3. Update src/popup/index.ts per artifact-table:
       - In init(): call populateMksKeys() helper that loops over [data-mks-key] elements and sets each textContent = chrome.i18n.getMessage(el.dataset.mksKey || '')
       - In updateUI() idle case: buttonText.textContent = chrome.i18n.getMessage('popupSaveCta')
       - In updateUI() saving case: buttonText.textContent = chrome.i18n.getMessage('popupSaving') (new key — add to messages.json with 'Сохраняю...' RU / 'Saving...' EN; OR fallback to literal if missing — document choice)
       - In updateUI() done case: buttonText.textContent = chrome.i18n.getMessage('popupSaveDoneShort') (new key — 'Готово! ✓' RU / 'Done ✓' EN) OR retain literal with a TODO
       - In updateUI() empty-state branch: statusMessage.textContent = chrome.i18n.getMessage('popupEmptyState') (new key — 'Откройте запись через иконку расширения' RU / 'Open recording via the extension icon' EN)
       - In saveArchive() success branch: statusMessage.textContent = chrome.i18n.getMessage('popupSaveDone')
       
       Add the new keys (popupSaving, popupSaveDoneShort, popupEmptyState) to BOTH _locales/en/messages.json AND _locales/ru/messages.json. Re-verify locale-parity.test.ts.

    4. Update src/background/index.ts per the `<interfaces>` shape block:
       - Line 76 BADGE_REC_COLOR: '#00C853' → '#b2543d' with comment '// --mks-madder-600 per D-04 loom palette (Plan 01-12)'
       - BADGE_OFF_COLOR ('#D32F2F') stays (no loom-palette token for OFF) OR change to var(--mks-ink-700) hex literal '#2f3349'; document choice in commit
       - BADGE_ERROR_COLOR ('#F9A825') stays (no loom-palette token for warning amber) OR change to '#c98b3a' (= --mks-amber-600); document choice in commit
       - Lines 82-84 BADGE_*_TITLE constants: KEEP as fallback strings (for any chrome.* stub contexts) BUT replace the runtime read in setBadgeState with `chrome.i18n.getMessage(<key>) || <fallback_const>` so the existing vitest tests that don't mock chrome.i18n still pass via fallback
       - Line 862-865 RECOVERY notification body: title=chrome.i18n.getMessage('extName') || 'Mokosh', message=chrome.i18n.getMessage('notifRecovery') || 'Recording resumed.'
       - Line 961-965 STARTUP notification body: title=chrome.i18n.getMessage('extName') || 'Mokosh', message=chrome.i18n.getMessage('notifStartup') || 'Recording started.'

       Use the `|| fallback` pattern uniformly so chrome.i18n unavailability (in unit-test mocks lacking i18n) degrades to literal English-ish text rather than the empty string Chrome returns for missing keys.

    5. Update relevant existing vitest tests that may need a chrome.i18n.getMessage stub:
       - tests/background/badge-state-machine.test.ts: if setBadgeState now reads chrome.i18n.getMessage, the chromeStub needs `i18n: { getMessage: vi.fn((k: string) => k) }` so the test asserts title === <key-name> as an identity-passthrough. Adjust assertions accordingly.
       - tests/background/onstartup-notification.test.ts: same — add i18n.getMessage stub.
       - tests/background/save-archive-stops-recording.test.ts: same if it asserts notification copy.
       
       The fallback `|| <const>` pattern reduces the need for chrome.i18n stubs in tests that don't depend on the displayed text; tests that DO depend on text shape need the identity-passthrough stub.

    6. Run `npm test` — expect 106+ GREEN (100 existing post-01-14 + 6 new tests + any new copy in popup tests adjusted via chrome.i18n stub).

    7. Run `npm run build` then re-run `tests/build/no-remote-fonts.test.ts` — expect GREEN (dist/tokens.css contains zero `googleapis` / `https://fonts`).
  </action>
  <verify>
    <automated>grep -c "#[0-9a-fA-F]\{3,8\}" src/popup/style.css && grep -c "var(--mks-" src/popup/style.css && grep -c "chrome.i18n.getMessage" src/popup/index.ts && grep -c "chrome.i18n.getMessage" src/background/index.ts && grep -n "BADGE_REC_COLOR.*#b2543d\|--mks-madder-600" src/background/index.ts && npx vitest run 2>&1 | tail -10 && npm run build 2>&1 | tail -5 && npx vitest run tests/build/no-remote-fonts.test.ts 2>&1 | tail -10</automated>
  </verify>
  <done>
    - src/popup/style.css contains 0 hex literals + ≥ 8 var(--mks-*) references + @import of tokens.css
    - src/popup/index.html + src/popup/index.ts use chrome.i18n.getMessage at every operator-facing copy site (verified by grep ≥ 4 occurrences in popup/index.ts)
    - src/background/index.ts uses chrome.i18n.getMessage at every operator-facing copy site (badge titles + 2 notification call sites; verified by grep ≥ 4 occurrences); BADGE_REC_COLOR === '#b2543d'
    - tests/build/tokens-adopted.test.ts ALL cases GREEN (including (b)+(c) which were RED in Wave 0)
    - tests/build/no-remote-fonts.test.ts GREEN (after npm run build)
    - vitest baseline 106+ GREEN (existing 100 post-01-14 + 6 new); no regressions in tests/background/*
    - Atomic commit `feat(01-12): wave-4 task-1 — adopt tokens.css + chrome.i18n.getMessage in src/popup/ + src/background/ (loom palette + RU i18n + en fallback)`
  </done>
</task>

<task type="auto" tdd="true">
  <name>Wave 5 Task 1: Welcome page conditional migration + Vite __VITE_DEV__ define-token + scripts/README.md</name>
  <files>src/welcome/welcome.css, src/welcome/welcome-tokens.css, src/welcome/welcome.ts, src/welcome/copy.ts, vite.config.ts, scripts/README.md</files>
  <behavior>
    If Plan 01-10 has landed (src/welcome/ exists at execute-plan time): migrate welcome-tokens.css → @import '../shared/tokens.css' (one-liner) AND migrate copy.ts to chrome.i18n.getMessage reads for welcomeHeroRu + welcomeHeroEn + any other welcome keys.
    
    If Plan 01-10 has NOT landed: skip all src/welcome/* changes; Plan 01-10 executor uses src/shared/tokens.css directly when 01-10 lands later (no placeholder welcome-tokens.css needed in that path).
    
    Unconditionally: add __VITE_DEV__ define-token to vite.config.ts (RESEARCH §12 D-09 spirit-satisfaction) + create scripts/README.md documenting the smoke-isolation invariant.
  </behavior>
  <action>
    1. Detect Plan 01-10 state: `ls src/welcome/welcome.html 2>/dev/null && echo "01-10 landed" || echo "01-10 not yet"`. Branch on result.

    2A. IF Plan 01-10 has landed:
       - Read src/welcome/welcome-tokens.css. Replace contents with `@import '../shared/tokens.css';\n` (single line file). NB: per RESEARCH §9 anti-pattern, do NOT create per-surface mini-tokens — collapse into the canonical tokens.css.
       - Read src/welcome/welcome.css. Verify it uses var(--mks-*) references throughout (per 01-10 design-swap-ready architecture; no hex literals expected). Add a comment header noting "tokens.css now canonical via welcome-tokens.css → @import."
       - Read src/welcome/copy.ts. Reduce to a fallback shim: export `function getCopy(key: string): string { const i18n = chrome.i18n?.getMessage?.(key); return i18n && i18n.length > 0 ? i18n : COPY[key] || ''; }` where COPY is the existing static map (retained as the safety net). Update src/welcome/welcome.ts to call `getCopy()` instead of `COPY[key]` directly.
       - Add to _locales/en/messages.json + _locales/ru/messages.json the welcome keys that copy.ts exposes (welcomeHeroTitle, welcomeBodyExplainerLine1, welcomeBodyExplainerLine2, welcomeBodyCtaToolbar, welcomeFooterPrivacy, etc. — per 01-10-PLAN.md src/welcome/copy.ts artifact list). Re-verify locale-parity.test.ts GREEN.
       - Re-run 01-10's Plan 01-10 onboarding tests + harness A17 (welcome.css zero-hex regex + COPY[ substring check): assert A17's regex-based welcome.css check still GREEN (welcome.css continues to have zero hex literals).

    2B. IF Plan 01-10 has NOT landed:
       - Skip all src/welcome/* file modifications. Document this in the commit body: "Plan 01-10 not yet landed; welcome migration deferred. When 01-10 lands, its executor uses src/shared/tokens.css directly (skipping the placeholder welcome-tokens.css step entirely)." Add a paragraph to .planning/STATE.md noting the deferral so 01-10's planner can adjust.

    3. Unconditionally update vite.config.ts: in the existing `define` block, add `__VITE_DEV__: JSON.stringify(process.env.VITE_DEV === '1')` alongside the existing `__MOKOSH_UAT__: 'false'`. Add an inline comment per RESEARCH §12 explaining the defensive intent ("reserved for any future inline smoke-mode check; currently smoke lives entirely in smoke.sh which is outside Vite's input set — verified by `grep -rn 'smoke\\|SMOKE\\|data:text/html' src/` returning empty").

    4. Verify vite.test.config.ts mergeConfig propagates __VITE_DEV__ (no explicit override needed — mergeConfig spreads define fields).

    5. Create scripts/README.md (single paragraph per RESEARCH §12): document that smoke.sh + future dev-only scripts live in this directory + are NOT bundled by npm run build + the production dist/ contains no smoke artifacts. Cross-reference RESEARCH.md §12 for the no-op-confirmation rationale.

    6. Run npm test + npm run build + npm run build:test — expect all GREEN baseline (100 post-01-14) + 6 new tests = 106 GREEN.

    7. (Optional but recommended per RESEARCH §12 + the existing Tier-1 gate pattern): add a `tests/build/no-smoke-in-dist.test.ts` test asserting `grep -rn smoke dist/` returns 0 matches post-build. RED today if any smoke string leaks; GREEN per current architecture. Keep this OPTIONAL — if context budget tight, defer to a follow-up plan.
  </action>
  <verify>
    <automated>(ls src/welcome/welcome.html 2>/dev/null && echo "Plan 01-10 landed — checking welcome migration:" && grep -c "tokens.css" src/welcome/welcome-tokens.css && grep -c "chrome.i18n" src/welcome/welcome.ts) || echo "Plan 01-10 not landed — skipped welcome migration" && grep -c "__VITE_DEV__" vite.config.ts && ls -la scripts/README.md && npx vitest run 2>&1 | tail -10</automated>
  </verify>
  <done>
    - IF 01-10 landed: src/welcome/welcome-tokens.css === single @import line; welcome.ts uses chrome.i18n.getMessage via getCopy() shim
    - IF 01-10 NOT landed: src/welcome/* unchanged; STATE.md gains deferral note
    - vite.config.ts:define block contains __VITE_DEV__ entry
    - scripts/README.md exists + documents smoke-isolation invariant
    - vitest baseline 106+ GREEN (+ N welcome keys added to messages.json if 01-10 landed; locale-parity stays GREEN)
    - Atomic commit `feat(01-12): wave-5 task-1 — welcome i18n migration (conditional on 01-10) + __VITE_DEV__ define + scripts/README.md`
  </done>
</task>

<task type="auto" tdd="true">
  <name>Wave 6 Task 1: Extend UAT harness with A18-A22 (font / icon / manifest-i18n / Lora-resolved / welcome-tokens)</name>
  <files>tests/uat/extension-page-harness.ts, tests/uat/lib/harness-page-driver.ts, tests/uat/harness.test.ts</files>
  <behavior>
    The 01-13 + 01-14 harness gains 5 new assertions following the established Approach-B pattern (page-side assertA* + host-side driveA* wrapper + harness.test.ts orchestrator entry). Each assertion uses production chrome.* APIs + fetch + getComputedStyle (no new test-mode symbols). After this wave: `npm run test:uat` reports 21/21 GREEN (or 24/24 with 01-10's A15-A17 already in place). A0-A14 (Plan 01-13) and A23 (Plan 01-14) remain GREEN as no-regression guarantees.
    
    Tier-1 FORBIDDEN_HOOK_STRINGS list stays at 12 entries post-Plan-01-14 (no new test-mode surface introduced by Plan 01-12).
  </behavior>
  <action>
    1. Extend tests/uat/extension-page-harness.ts: add 5 page-side methods to window.__mokoshHarness per the `<interfaces>` block shapes:
       - assertA18(): walks document.styleSheets for the first @font-face rule whose src URL contains 'Lora'; resolves the URL relative to location.href; fetches it; asserts response.ok + arrayBuffer().byteLength >= 50000. Returns AssertionRecord with structured checks (A18.1 found rule; A18.2 fetch status; A18.3 byteLength).
       - assertA19(): fetches chrome.runtime.getURL('icons/icon128.png'); reads arrayBuffer; computes a simple identity check (first 32 bytes byte-by-byte comparison against a hardcoded snapshot of the Bug A placeholder file's first 32 bytes); returns A19.1: "icon differs from Bug A placeholder fingerprint". Stores the placeholder fingerprint as a const at top of the file with a comment referencing this plan + the verification command to refresh it.
       - assertA20(): chrome.runtime.getManifest().name === 'Mokosh — Session Capture'; structured AssertionRecord with A20.1 manifest.name === '...'.
       - assertA21(): creates a transient div, applies class='mks-display-1', reads getComputedStyle.fontFamily, asserts startsWith 'Lora' or '"Lora"'. Returns A21.1: fontFamily resolved.
       - assertA22(): fetches chrome.runtime.getURL('src/welcome/welcome.html'); if status === 404, returns PASS with diagnostic "Plan 01-10 not landed; A22 skipped"; if status === 200, also fetches the linked welcome.css (extract <link rel='stylesheet' href> from the HTML) and asserts welcome.css contains either `tokens.css` import OR substantive var(--mks-*) usage (regex /var\\(--mks-[a-z-]+\\)/ match count >= 3).

    2. Extend tests/uat/lib/harness-page-driver.ts: add driveA18 / driveA19 / driveA20 / driveA21 / driveA22 wrappers following the established driveA4 pattern (page.evaluate(() => window.__mokoshHarness.assertXX())). No new host-side fs / ffprobe / zip primitives.

    3. Extend tests/uat/harness.test.ts: import driveA18..A22; add entries to the orchestrator's assertion list in sequence (after A14, or A17 if 01-10 landed, with A23 from 01-14 elsewhere in the list). Keep FORBIDDEN_HOOK_STRINGS UNCHANGED at 12 entries (the 01-14-established baseline) — verify by `wc -l` diff before/after this task.

    4. Run npm run build:test (rebuilds dist-test/). Then run `npm run test:uat` (or just `tsx tests/uat/harness.test.ts`). Expected output: 21/21 (or 24/24) GREEN.

    5. Run `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` — confirm Tier-1 grep gate GREEN (no new hook strings leaked into dist/).
  </action>
  <verify>
    <automated>grep -c "assertA1[89]\|assertA2[012]" tests/uat/extension-page-harness.ts && grep -c "driveA1[89]\|driveA2[012]" tests/uat/lib/harness-page-driver.ts && grep -c "driveA1[89]\|driveA2[012]" tests/uat/harness.test.ts && (wc -l tests/uat/harness.test.ts && grep "FORBIDDEN_HOOK_STRINGS" tests/uat/harness.test.ts -A 15 | grep -v "^#" | grep -c "'") && npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts 2>&1 | tail -5 && (npm run test:uat 2>&1 | tail -25 || echo "test:uat invocation needs verification; check for 21/21 or 24/24 GREEN")</automated>
  </verify>
  <done>
    - 5 new assertA* methods on window.__mokoshHarness (verified by grep ≥ 5)
    - 5 new driveA* wrappers in harness-page-driver.ts
    - harness.test.ts orchestrator runs all 20 (or 23 with 01-10) assertions sequentially
    - npm run test:uat exits 0 with 21/21 (or 24/24) GREEN
    - Tier-1 forbidden-strings inventory in no-test-hooks-in-prod-bundle.test.ts UNCHANGED at 12 entries post-Plan-01-14 (no new test-mode symbols added by Plan 01-12)
    - Atomic commit `feat(01-12): wave-6 task-1 — harness A18-A22 (font reachability + icon-distinct + manifest-i18n + Lora-resolved + welcome-tokens)`
  </done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Wave 7 Checkpoint: Operator empirical brand-fit ack</name>
  <files>(operator-driven; no source files modified by this checkpoint — Wave 1-6 already landed the artifacts)</files>
  <action>See <how-to-verify> below — operator-driven empirical check against fresh build loaded in Chrome. The executor agent must NOT bypass this checkpoint by stubbing; brand fit is the LAST Phase 1 operator gate and must be confirmed against the real branded extension.</action>
  <verify>
    <automated>echo "checkpoint:human-verify — see how-to-verify section; resume signal is the gate"</automated>
  </verify>
  <done>Operator types 'approved' after running the how-to-verify steps. See <resume-signal>.</done>
  <what-built>
    Plan 01-12 lands the full design integration: Lora self-hosted (R2 designer reply); IBM Plex Sans + Mono self-hosted (Latin + Cyrillic); src/shared/tokens.css canonical (Newsreader → Lora substitution + .mks-word class + zero remote @import); icons rasterized from Loom mark (replaces Bug A placeholders); manifest:name + :description + :default_title migrated to chrome i18n (__MSG_*__ + _locales/{en,ru}/messages.json); 12 i18n keys land with D-07 + D-08 + Brief §02 verbatim Russian copy; src/popup/ + src/background/ use chrome.i18n.getMessage at every operator-facing site + tokens.css palette; BADGE_REC_COLOR madder-rust per D-04; welcome page (if 01-10 landed) inherits canonical tokens; vite __VITE_DEV__ defensive define; UAT harness 21/21 (or 24/24) GREEN with A18-A22 covering font reachability + icon-distinct + manifest-i18n + Lora-resolved + welcome-tokens (A0-A14 from 01-13 and A23 from 01-14 remain GREEN as no-regression guarantees).
  </what-built>
  <how-to-verify>
    1. Fresh build + load:
       - `rm -rf dist/ dist-test/`
       - `npm run build`
       - Open `chrome://extensions/`, toggle Developer mode, click "Load unpacked", select the `dist/` directory.
       - Verify: extension name shows "Mokosh — Session Capture" (en locale) or "Mokosh — Запись сессии" (ru locale). The extension's icon in the toolbar is the rasterized Loom mark (4×4-grid silhouette inside a rounded square), NOT the prior dark-square + green-dot placeholder.

    2. Popup brand fit:
       - Click the Mokosh toolbar icon to open the popup.
       - Verify: popup background is warm-linen (--mks-surface = #faf7f1), NOT cool-gray; SAVE button is madder-rust (--mks-rec = #b2543d), NOT material-blue; info text is warm gray (--mks-fg-2 = #5b5f76); copy reads "Сохранить отчёт" (RU) or "Save report" (EN) per chrome's resolved locale.

    3. Russian copy rendering in Lora:
       - Switch Chrome to Russian locale (chrome://settings/languages — add Russian + move to top + restart).
       - Re-open the Mokosh popup. Verify Russian strings render correctly in IBM Plex Sans (UI body) — text reads "Сохранить отчёт", "Архив сохранён в Загрузки.", etc.
       - The display-register text (if any visible in popup) renders in Lora — verify via DevTools → Inspect → Computed → font-family (should read 'Lora' as the matched family for any .mks-display-* class). For now, popup doesn't have visible display-register text; the load-bearing display-register check happens on the welcome page (if 01-10 landed) OR by spawning a temporary `chrome-extension://<id>/src/welcome/welcome.html` if available.

    4. Manifest name in chrome://extensions:
       - Switch back to English Chrome (or stay in RU for parallel verification).
       - chrome://extensions/ shows "Mokosh — Session Capture" (en) or "Mokosh — Запись сессии" (ru) in the extension card.

    5. Notification icon + copy:
       - Trigger a recording start (click toolbar — toolbar:onClicked → startVideoCapture → eventually notification).
       - Verify: startup notification icon is the rasterized Loom mark (not placeholder); copy reads "Запись запущена. Я слежу за последними 30 секундами." (ru) / "Recording started. I'm watching the last 30 seconds." (en).

    6. Welcome page (if 01-10 landed):
       - Navigate to chrome-extension://<id>/src/welcome/welcome.html in a tab.
       - Verify: welcome hero renders D-08 tagline in Lora; warm-linen background; mark slot shows the Loom mark; Russian + English parallel-text copy renders correctly.

    7. Harness gate:
       - `npm run test:uat` exits 0 with 21/21 GREEN (or 24/24 with 01-10).
       - Tier-1 grep gate GREEN: `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts`.

    8. Type "approved" if brand fit accepted, OR describe specific issues for follow-up.
  </how-to-verify>
  <resume-signal>Type "approved" or describe issues for follow-up</resume-signal>
</task>

<task type="auto">
  <name>Wave 7 Task 1: Plan closure (SUMMARY + STATE.md + ROADMAP.md sync)</name>
  <files>.planning/phases/01-stabilize-video-pipeline/01-12-SUMMARY.md, .planning/STATE.md, .planning/ROADMAP.md</files>
  <action>
    1. Create .planning/phases/01-stabilize-video-pipeline/01-12-SUMMARY.md following the template at $HOME/.claude/get-shit-done/templates/summary.md AND mirroring the structural choices of 01-13-SUMMARY.md (frontmatter with phase/plan/subsystem/tags/requires/provides/affects/tech-stack/key-files/decisions/metrics; body sections One-Liner / What Landed by Wave / Test Counts / Deviations from Plan / Architectural Notes Worth Carrying Forward / Self-Check / Known Limitations / Bridge to Phase 1 Closure).

       Key content:
       - One-Liner: "Design integration landed end-to-end: Lora self-hosted (R2 designer reply 2026-05-19); src/shared/tokens.css canonical; 12 i18n keys via _locales/{en,ru}; branded Loom icons replace placeholders; src/popup + src/background use chrome.i18n.getMessage + loom palette; harness 21/21 (or 24/24) GREEN with A18-A22; operator brand-fit ack received."
       - Wave-by-wave breakdown with commit refs.
       - Test Counts table: vitest before/after; harness before/after.
       - Architectural Notes: R2 substitution rationale; OFL attribution pattern; tokens.css as single source of truth; chrome.i18n.getMessage fallback pattern (|| <const>); .mks-word class as engineering working-defn (designer overridable); icons-as-static-artifacts (NOT regenerated in prebuild per RESEARCH §3 anti-pattern).
       - Self-Check: FOUND list including the 6 new vitest tests, 5 new harness assertions, font bundle, icons, manifest i18n, _locales/, source-code adoption.
       - Bridge: Phase 1 closure status (the LAST Phase 1 gate, brand-fit ack, is now closed); Phase 2 inherits tokens.css + chrome.i18n.getMessage as production conventions.

    2. Update .planning/STATE.md:
       - completed_plans + 1 (current 12 → 13)
       - percent re-computed (likely 100% Phase 1 now, since this was the last gate)
       - Add a "Plan 01-12 closure" section under "## Current Position" with the brand-fit ack date + harness count + R2 substitution note
       - Update "stopped_at" + "last_updated" + "last_activity" fields
       - Update "## Outstanding Phase 1 gates" section: remove Plan 01-12 entry (it's now closed); confirm Plan 01-10 status (closed if landed; still pending if not)

    3. Update .planning/ROADMAP.md:
       - In the Phase 1 Plans list (after `01-13-PLAN.md` entry per 01-13 SUMMARY ROADMAP.md known-gap), add `- [x] 01-12-PLAN.md — Design integration (R2 Lora, tokens.css, 8 i18n strings, branded icons, manifest i18n)`
       - If Plan 01-10 entry is missing per the 01-13 SUMMARY note about the ROADMAP.md gap, add it too as `- [x]` or `- [ ]` depending on its current status. (Per 01-13 SUMMARY Self-Check: Plan 01-10 was still pending at 01-13 close; if it landed between 01-13 close and this 01-12 close, mark `[x]`.)
       - Update the Phase 1 header line if it carries a count (e.g., "**Plans:** N plans" → updated to actual final count).

    4. Atomic commit: `docs(01-12): wave-7 task-1 — Plan 01-12 SUMMARY + STATE.md + ROADMAP.md sync (design integration closed; Phase 1 charter complete)`.
  </action>
  <verify>
    <automated>ls -la .planning/phases/01-stabilize-video-pipeline/01-12-SUMMARY.md && grep -n "01-12-PLAN.md\|R2 Lora\|design integration" .planning/ROADMAP.md && grep -n "01-12\|completed_plans" .planning/STATE.md | head -10 && head -20 .planning/phases/01-stabilize-video-pipeline/01-12-SUMMARY.md</automated>
  </verify>
  <done>
    - .planning/phases/01-stabilize-video-pipeline/01-12-SUMMARY.md exists + follows template + carries all 9 frontmatter sections
    - STATE.md completed_plans incremented + percent recomputed + Plan 01-12 closure section added
    - ROADMAP.md Phase 1 Plans list contains the 01-12 entry (and 01-10 entry if previously missing)
    - Atomic commit lands all three docs in one
    - Plan 01-12 functionally CLOSED; Phase 1 charter complete (subject to the Wave 7 checkpoint operator ack)
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Chrome extension boundary (MV3 CSP) | tokens.css @font-face MUST reference local URLs only; remote `@import` (Google Fonts) violates `style-src 'self'` + `font-src 'self'` per RESEARCH §13 |
| User-locale boundary (chrome.i18n) | messages.json values are operator-facing copy; manifest:name + :description surface in Chrome Web Store + chrome://extensions across all locales |
| Asset-pipeline boundary (Vite + @crxjs) | WOFF2 files are emitted to dist/assets/ with content-hashed names; web_accessible_resources auto-generated by @crxjs |
| Test-only-symbol boundary (__MOKOSH_UAT__) | Tier-1 grep gate enforces test-mode hook symbols are absent from production dist/ — Plan 01-12 does NOT extend the inventory (no new symbols) |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-12-01 | Information Disclosure | Remote font load via `@import url(https://fonts.googleapis.com/...)` in tokens.css | mitigate | Replace line 12 Google Fonts @import with 7-rule local @font-face block (Wave 1 Task 2). Verify zero `googleapis` matches in dist/ via tests/build/no-remote-fonts.test.ts (Wave 0). Fonts ship under src/shared/fonts/ with OFL LICENSE files (Wave 1 Task 1). |
| T-01-12-02 | Tampering | CDN MITM on remote font fetch | mitigate | Same as T-01-12-01: self-hosting eliminates the CDN trust dependency entirely. WOFF2 files ship as extension-internal assets; integrity is guaranteed by Chrome's extension-package signature. |
| T-01-12-03 | Denial of Service | PNG icon below Chrome imageUtil floor → chrome.notifications.create silent rejection | mitigate | tests/build/icons-present.test.ts asserts each PNG ≥ FLOOR (16≥200B, 48≥500B, 128≥1024B) per RESEARCH §3 + assets-spec.md (Wave 0 + Wave 2). Plan 01-13 harness A9 + A8 (regression rewind for Bug A) catch leaks at harness-time. |
| T-01-12-04 | Denial of Service | _locales/ key tampering causing manifest:name resolution failure → manifest validation error at install | mitigate | tests/i18n/manifest-i18n.test.ts asserts manifest fields + messages.json key shapes (Wave 0); tests/i18n/locale-parity.test.ts enforces default_locale parity per RESEARCH Pitfall 4. JSON validity guaranteed by JSON.parse in tests. |
| T-01-12-05 | Tampering | Vite asset hashing breaking @font-face url() resolution at runtime | mitigate | RESEARCH §2 Pattern A validated empirically (tokens.css → relative url(./fonts/x.woff2) → Vite rebases → @crxjs auto-WAR). UAT harness A18 walks document.styleSheets to resolve the actual emitted URL (handles hashing); fetch + size check verifies runtime reachability. Pattern B fallback (public/fonts/) documented in RESEARCH §2 if Pattern A misfires. |
| T-01-12-06 | Spoofing | `__MSG_@@extension_id__` substitution in manifest fields (not supported per Chrome docs) | accept | Plan 01-12 does NOT use @@extension_id; documented as RESEARCH §11 anti-pattern + per the explicit "Anti-pattern" subsection. Low blast radius (only affects if engineer ignores docs). |
| T-01-12-07 | Information Disclosure | chrome.i18n.getMessage returning empty string when default_locale lacks key (RESEARCH Pitfall 4) | mitigate | The `|| <const>` fallback pattern uniformly applied in src/popup/index.ts + src/background/index.ts (Wave 4) ensures missing-key cases degrade to literal English-ish text rather than blank UI. tests/i18n/locale-parity.test.ts asserts key-parity to catch missing keys at unit-test time. |
| T-01-12-08 | Elevation of Privilege | Test-mode hook strings leaking into production dist/ (T-1-11-01 + T-1-13-* + T-1-14-* inheritance) | accept | Plan 01-12 introduces ZERO new test-mode symbols (A18-A22 use production chrome.* + fetch + getComputedStyle exclusively). Tier-1 grep gate at tests/background/no-test-hooks-in-prod-bundle.test.ts remains at 12 entries post-Plan-01-14; no extension needed. Verified by file-diff at task end. |
| T-01-12-09 | Tampering | OFL license/attribution non-compliance | mitigate | src/shared/fonts/LICENSE-Lora.txt + LICENSE-IBM-Plex.txt ship verbatim per OFL-1.1 attribution best practice (RESEARCH §7); src/shared/fonts/README.md cross-references upstream URLs + R2 substitution rationale. |
</threat_model>

<verification>
Phase-level invariants to assert post-plan:

1. **Build cleanliness:** `npm run build` exits 0; `npm run build:test` exits 0; `npx tsc --noEmit` exits 0.
2. **MV3 CSP compliance:** zero `googleapis` / `https://fonts` matches in dist/ (tests/build/no-remote-fonts.test.ts).
3. **Vitest baseline grown:** 100 (post-01-14) → 106+ GREEN (+6 new unit tests; +N if welcome migration adds keys).
4. **UAT harness 21/21 (or 24/24 with 01-10) GREEN:** `npm run test:uat` exits 0; A0 Tier-1 grep gate GREEN; A1-A14 (or A1-A17 with 01-10) + A23 (Plan 01-14) GREEN; A18-A22 GREEN.
5. **Font bundle present:** all 7 or 8 WOFF2 files exist under src/shared/fonts/; total bundle ≤ 350KB; LICENSE files + README.md present.
6. **Icons rasterized:** icons/icon{16,48,128}.png are 8-bit RGBA PNGs at correct dims + clearing FLOOR; differ in content from the Bug A placeholders.
7. **Manifest i18n wired:** chrome://extensions name reads "Mokosh — Session Capture" (en) or "Mokosh — Запись сессии" (ru); operator-facing tooltips + notification copy + popup CTA all resolve via chrome.i18n.getMessage.
8. **Tokens adopted:** src/popup/style.css has 0 hex literals + imports tokens.css; src/welcome/welcome.css (if 01-10 landed) imports tokens.css; BADGE_REC_COLOR is madder-rust #b2543d.
9. **Tier-1 forbidden-strings inventory UNCHANGED at 12 entries post-Plan-01-14.**
10. **R2 substitution complete:** zero `Newsreader` references anywhere in src/shared/tokens.css; --mks-font-display === '"Lora", "Iowan Old Style", "Times New Roman", serif'.
11. **Operator brand-fit ack:** Wave 7 checkpoint received "approved".
</verification>

<success_criteria>
- [ ] All 6 vitest tests in tests/build/ + tests/i18n/ GREEN
- [ ] src/shared/fonts/ contains all required WOFF2 + LICENSE + README.md files
- [ ] src/shared/tokens.css canonical: zero remote-font URLs, R2 Lora substitution, .mks-word class present
- [ ] icons/icon{16,48,128}.png rasterized from Loom mark, clearing all FLOORs, 8-bit RGBA
- [ ] manifest.json carries `__MSG_extName__` + `__MSG_extDesc__` + `default_locale: "en"` + `action.default_title: "__MSG_tooltipOff__"`
- [ ] _locales/{en,ru}/messages.json each carry the 12-key matrix with D-07 + D-08 + Brief §02 values
- [ ] src/popup/ + src/background/ use chrome.i18n.getMessage at every operator-facing copy site (with `|| <const>` fallback)
- [ ] src/popup/style.css has zero hex literals + imports tokens.css
- [ ] BADGE_REC_COLOR === '#b2543d' (--mks-madder-600 per D-04)
- [ ] If Plan 01-10 landed: welcome-tokens.css = `@import '../shared/tokens.css';`; welcome.ts uses chrome.i18n.getMessage
- [ ] vite.config.ts has `__VITE_DEV__` defensive define
- [ ] scripts/{rasterize-icons.sh,subset-fonts.sh,README.md} all present
- [ ] UAT harness A18-A22 implemented + GREEN; npm run test:uat exits 0 with 21/21 (or 24/24 with 01-10) GREEN
- [ ] Tier-1 forbidden-strings inventory UNCHANGED at 12 entries post-Plan-01-14
- [ ] Operator empirical brand-fit ack ("approved") received at Wave 7 checkpoint
- [ ] 01-12-SUMMARY.md exists + STATE.md + ROADMAP.md synced
- [ ] All commits atomic + follow `feat(01-12)/test(01-12)/docs(01-12): wave-N task-M — <one-liner>` convention
</success_criteria>

<output>
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-12-SUMMARY.md` per the Wave 7 Task 1 spec — mirroring the structure of 01-13-SUMMARY.md (frontmatter with phase/plan/subsystem/tags/requires/provides/affects/tech-stack/key-files/decisions/metrics + body sections One-Liner / What Landed / Test Counts / Deviations / Architectural Notes Worth Carrying Forward / Self-Check / Known Limitations / Bridge to Phase 1 Closure).
</output>
