# Plan 01-12 (Design Integration) — Research

**Researched:** 2026-05-17
**Domain:** Brand integration — font self-hosting, SVG rasterization, MV3 i18n, CSS token ingestion, Vite asset pipeline
**Confidence:** HIGH on stack/tooling (locally verified); MEDIUM on Cyrillic-extended-Plex sizing; **BLOCKER** on D-05 Newsreader Cyrillic coverage

## Summary

Designer-team handoff v1 (commit `5efc2a8`) lands a 280-line `tokens.css`, 32×32-viewBox `mokosh-mark.svg`, and 240×56 lockup. Engineering's Plan 01-12 ingests these into `src/`, removes the Google Fonts `@import` (MV3 CSP violation), self-hosts WOFF2 (Newsreader + IBM Plex Sans 4w + Plex Mono 2w, Latin + Cyrillic), rasterizes the mark to `icons/icon{16,48,128}.png`, updates `manifest.json:name`+`:description` per D-07/D-08 (via `__MSG_*__` + `_locales/`), wires the 8 operator strings into `_locales/ru/messages.json` + `_locales/en/messages.json`, restyles `src/popup/` per tokens, and replaces hardcoded Russian/placeholder colors with token references.

**Primary recommendation:** Execute in stable order — (1) write the canonical `tokens.css` to `src/shared/tokens.css` minus the Google Fonts `@import`, (2) rasterize PNG icons (rsvg-convert is installed + verified to produce icons that clear all Chrome floors with the existing mark at 16/48/128), (3) build out `fonts/` via `pyftsubset` (installed) + `woff2_compress` (installed) — BUT ONLY for the Plex faces and a Latin-only Newsreader subset; **substitute a Cyrillic-bearing display serif for Russian text** (see BLOCKER below), (4) create `_locales/` and migrate hardcoded Russian strings, (5) wire `manifest.json:name=__MSG_extName__` + `:description=__MSG_extDesc__` + `default_locale:"en"`, (6) restyle popup + smoke.

**BLOCKER (D-05):** Newsreader from Google Fonts / Production Type does NOT cover Cyrillic. The Decision Brief's own embedded `@font-face` declarations confirm this — Newsreader has only `latin / latin-ext / vietnamese` subsets; IBM Plex Sans and Plex Mono have `cyrillic / cyrillic-ext` subsets in addition. Russian-operator audience means display text in Newsreader will silently fall back to `Iowan Old Style → Times New Roman → serif`. See §1 BLOCKER for remediation options.

## User Constraints (from CONTEXT.md / brand-decisions-v1.md)

### Locked Decisions

- **D-01**: Mark concept = `A · Loom (2×2 weave intersection)`. Source SVG is `mokosh-mark.svg`. PNG rasterization is engineering's job.
- **D-02**: Welcome layout = `A · Hero + Loom dial`. Out of scope for Plan 01-12 (Plan 01-10's responsibility); Plan 01-12 must NOT touch `src/welcome/` content.
- **D-03**: Voice register = `A · Sober`. Carries to all 8 i18n strings.
- **D-04**: Palette = `A · Loom` (`tokens.css` `--mks-*` is canonical; replaces engineering placeholders).
- **D-05**: Type pairing = `A · Newsreader (display) + IBM Plex Sans (UI, 4 weights) + IBM Plex Mono (mono, 2 weights)`. All OFL. Self-host WOFF2; remove Google Fonts `@import`. — **subject to BLOCKER**.
- **D-06**: Icon strategy = `A · Neutral mark + dynamic badge`. NO per-state PNG sets. Matches existing arch (`chrome.action.setBadgeBackgroundColor` already in use per `src/background/index.ts:45`).
- **D-07 (USER OVERRIDE)**: `manifest.json:name = "Mokosh — Session Capture"` (not bare `"Mokosh"`). Surface capture purpose externally.
- **D-08**: Tagline = `«Тридцать секунд назад, всегда под рукой.»` / `"Thirty seconds ago, always at hand."`. Used in `manifest.json:description` + welcome hero.
- **D-09**: Smoke shipping = `A · Dev-only behind VITE_DEV flag`. — **subject to a no-op clarification**, see §12.

### Claude's Discretion

- The 8 i18n copy strings inherit D-03 Sober by default; per-string overrides surfaced as-encountered. Initial values for each string extracted from Decision Brief §02 — see §10 below for the verbatim text and recommended `__MSG_*__` keys.
- File layout: `src/shared/tokens.css` vs `src/styles/tokens.css` vs per-surface `tokens.css`. Recommended: **`src/shared/tokens.css`** to match the existing `src/shared/` convention (logger, types, binary).
- `default_locale` value: `"en"` recommended (Chrome Web Store convention; manifest is globally visible; Russian audience accesses the actual UI which is RU-primary).
- Font tooling specifics within the chosen WOFF2 self-host approach.

### Deferred Ideas (OUT OF SCOPE)

- Dark theme variant of mark SVG (`.dark` class already in `tokens.css` — but D-06 keeps mark neutral; badge does state work).
- Welcome page hero (Plan 01-10).
- Per-state icon variants (D-08 = neutral mark + badge; A-06 PNG set deferred indefinitely).
- Hi-DPI `icon192.png` (Brief P1, optional; defer unless Plan 01-12 budget allows).

## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| REQ-video-ring-buffer | Phase 1 anchor requirement; satisfied by Plans 01-01..01-07 | Not directly addressed by Plan 01-12 — but mentioned because Plan 01-12 must not regress any existing video-pipeline test. The 79 tests landed in 01-10 stay GREEN. |
| (implicit D-04..D-09) | Design integration requirements derived from `brand-decisions-v1.md` | This RESEARCH.md is the source-of-truth synthesis for each. |

---

## 1. WOFF2 self-hosting in MV3 (D-05) — **BLOCKER on Newsreader Cyrillic**

### BLOCKER finding

The Decision Brief's `@font-face` declarations (extracted from
`.planning/intel/design-incoming/mokosh/dist/Decision Brief (standalone).html`,
inside the bundler template after JSON-decoding) reveal the actual Google
Fonts subset structure:

| Family | Weights/styles shipped | Subsets shipped | Cyrillic? |
|---|---|---|---|
| Newsreader | normal 400/500/600, italic 400 | `latin`, `latin-ext`, `vietnamese` | **NO** |
| IBM Plex Sans | normal 400/500/600/700 | `latin`, `latin-ext`, `cyrillic`, `cyrillic-ext`, `greek`, `vietnamese` | YES |
| IBM Plex Mono | normal 400/500 | `latin`, `latin-ext`, `cyrillic`, `cyrillic-ext`, `vietnamese` | YES |

This is consistent with the Newsreader GitHub README (`productiontype/Newsreader`)
which states Newsreader covers "Google Fonts Latin Plus glyph set, ... English,
Western European languages as well as Vietnamese". Production Type's own page
makes no Cyrillic claim. Per the upstream charset files, Newsreader does not
include Cyrillic glyphs in any release shipped via Google Fonts.

**Impact on D-05:** Newsreader display text in Russian
(e.g. `«Тридцать секунд назад, всегда под рукой.»` if it lands on welcome hero
as `.mks-display-1`) will fall back to the next family in the stack:
`Newsreader, "Iowan Old Style", "Times New Roman", serif`. On Linux/Chrome OS
this means Times New Roman or DejaVu Serif. On macOS, Iowan Old Style. On
Windows, Times New Roman. Each render is a different visual personality and
none is the loom-warm character that D-05 picks.

**Remediation options for the planner:**

- **R1 (recommended): document the limitation + add Cyrillic-capable serif fallback.** Use Newsreader for Latin text only (welcome EN parallel-text subhead per D-08 — `"Thirty seconds ago, always at hand."`). For Russian display text, the existing fallback chain `Newsreader, "Iowan Old Style", "Times New Roman", serif` already does the work — just verify the Russian welcome hero renders in a visually-acceptable serif on the operator's actual Chrome. Engineering can prepend a Cyrillic-capable OFL serif (e.g. **PT Serif** by ParaType, **EB Garamond**, or **Lora** — all SIL OFL, all have Cyrillic) into the `--mks-font-display` stack so the Russian hero falls onto a curated face instead of a system default. Lowest engineering cost.
- **R2: switch display family.** Substitute a Cyrillic-bearing OFL serif (PT Serif, EB Garamond, Lora, Source Serif Pro) for Newsreader entirely. Loses the designer's specific aesthetic call. Requires re-coordinating with design team.
- **R3: extend Newsreader with a custom Cyrillic.** Out of scope; would require a font designer and license review.

**Recommended action for planner:** Surface this to user as a one-paragraph decision question. Default to R1 (smallest scope, preserves designer's call for Latin, adds a curated Cyrillic-capable serif as the next fallback). If user accepts R1, no font for Russian Newsreader needs to be subsetted — only Plex Sans + Plex Mono carry Cyrillic.

### Bundle plan (assumes R1; revise if R2)

For each subsetted WOFF2 file:

```bash
# Install: already installed on dev machine — pyftsubset (fontTools 4.63.0) + woff2_compress (Google's woff2)
# Source: download .ttf releases from https://github.com/IBM/plex (Plex Sans v1.1.0, Plex Mono v1.1.0)
#         and https://github.com/productiontype/Newsreader (variable font)

# Plex Sans 400 Cyrillic-only (smallest possible; covers Russian operator surfaces)
pyftsubset IBMPlexSans-Regular.ttf \
  --unicodes='U+0020-007E,U+00A0-00FF,U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116' \
  --flavor=woff2 \
  --output-file=fonts/IBMPlexSans-Regular-latin-cyrillic.woff2 \
  --layout-features='*' --no-hinting --desubroutinize
# Repeat for weights 500, 600, 700.

# Plex Mono 400 + 500 — same subset
pyftsubset IBMPlexMono-Regular.ttf \
  --unicodes='U+0020-007E,U+00A0-00FF,U+0400-045F,U+2116' \
  --flavor=woff2 --output-file=fonts/IBMPlexMono-Regular.woff2 ...

# Newsreader (variable, Latin only per the Brief subset)
pyftsubset Newsreader[opsz,wght].ttf \
  --unicodes='U+0020-007E,U+00A0-00FF,U+0152-0153,U+2000-206F,U+20AC,U+2122' \
  --flavor=woff2 --output-file=fonts/Newsreader-variable.woff2 ...
```

**Estimated bundle size (verified against Google Fonts subset sizes from public CDN):**

| File | Estimated size |
|---|---|
| Plex Sans 400 latin+cyrillic | ~25 KB |
| Plex Sans 500 latin+cyrillic | ~25 KB |
| Plex Sans 600 latin+cyrillic | ~25 KB |
| Plex Sans 700 latin+cyrillic | ~25 KB |
| Plex Mono 400 latin+cyrillic | ~28 KB |
| Plex Mono 500 latin+cyrillic | ~28 KB |
| Newsreader variable, Latin-only | ~60 KB (variable axes inflate) |
| **Total** | **~216 KB** |

Comfortably under the brief's implicit "≤300 KB" target.

**License files (D-05 OFL):**

- `fonts/LICENSE-IBM-Plex.txt` — copy from https://github.com/IBM/plex/blob/master/LICENSE.txt (SIL OFL 1.1 + IBM copyright lines)
- `fonts/LICENSE-Newsreader.txt` — copy from https://github.com/productiontype/Newsreader/blob/master/OFL.txt
- Both: OFL allows embedding without a separate NOTICE file (per OFL FAQ); a `fonts/README.md` linking to each upstream + listing copyright lines is sufficient and is best practice.

### Sources

- [Newsreader on GitHub (productiontype/Newsreader)](https://github.com/productiontype/Newsreader) — "Google Fonts Latin Plus glyph set" claim, no Cyrillic mentioned
- [IBM Plex on GitHub](https://github.com/IBM/plex) — Plex Sans supports "extended Latin, Arabic, Chinese (Traditional), Cyrillic, Devanagari, Greek, Hebrew, Japanese, Korean, and Thai"
- [SIL Open Font License 1.1](https://openfontlicense.org/) — embedded fonts have lighter attribution requirements
- [OFL-FAQ](https://openfontlicense.org/ofl-faq/) — bundled fonts do not require separate distribution copies
- [Decision Brief embedded `@font-face` declarations](file://.planning/intel/design-incoming/mokosh/dist/Decision%20Brief%20(standalone).html) — primary source for confirmed subset structure
- [fontTools pyftsubset docs](https://fonttools.readthedocs.io/en/stable/subset/) — `--unicodes` + `--flavor=woff2`
- [Markos Konstantopoulos on font subsetting](https://markoskon.com/creating-font-subsets/) — practical pyftsubset examples
- [Walter Ebert on subsetting web fonts](https://walterebert.com/blog/subsetting-web-fonts/) — IBM Plex bandwidth comparisons

---

## 2. Vite @crxjs font asset pipeline

### Findings

- `@crxjs/vite-plugin` (project on `2.0.0-beta.25`; latest stable is `2.4.0`) automatically generates `web_accessible_resources` entries for resources referenced from extension pages (popup, welcome, offscreen, etc.) per the official README: *"Automatic generation of `web_accessible_resources` manifest entries"*.
- Vite's general asset handling: CSS `url()` references inside imported `.css` files are auto-rebased and emitted with content-hashed names to `dist/assets/`. This works for `.woff2` files placed in `src/shared/fonts/` or similar.
- The known failure mode is when `chrome.runtime.getURL(<importedAsset>)` is called from a service worker (per [crxjs issue #891](https://github.com/crxjs/chrome-extension-tools/issues/891)) — but this is NOT the popup/welcome font-loading flow. Fonts referenced via `@font-face src: url('../shared/fonts/x.woff2')` inside an extension-page CSS work through Vite's normal rebase pipeline.

### Recommended approach

**Pattern A (recommended): regular `src/` assets.** Place WOFF2 files at `src/shared/fonts/<face>.woff2`. The canonical `src/shared/tokens.css` references them via relative paths:

```css
@font-face {
  font-family: "IBM Plex Sans";
  src: url("./fonts/IBMPlexSans-Regular-latin-cyrillic.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
```

When `src/popup/index.html` and `src/welcome/welcome.html` `@import` (or `<link>`) the tokens.css, Vite rebases the URLs, emits the WOFF2 files to `dist/assets/<hash>.woff2`, and rewrites the CSS to point at the hashed names. CRXJS then auto-adds them to `web_accessible_resources` because they're transitively reachable from extension HTML pages.

**Pattern B (fallback if Pattern A misfires): `/public/fonts/`.** Files placed under `public/` are copied verbatim to `dist/` without hashing. Reference as absolute paths `url("/fonts/x.woff2")`. The trade-off: no asset hashing (cache busting requires version bumps), but explicit and tool-independent. Manual `web_accessible_resources` entry needed in `manifest.json`:

```json
"web_accessible_resources": [
  { "resources": ["fonts/*.woff2"], "matches": ["<all_urls>"] }
]
```

**Validation step (planner: include in execute-plan):** After first build with WOFF2 files, run `grep -r "woff2" dist/manifest.json` and `ls dist/assets/*.woff2` — if WOFF2 files show up in `dist/assets/` AND `web_accessible_resources` mentions them, Pattern A is working. If not, fall back to Pattern B.

### Sources

- [@crxjs/vite-plugin README on GitHub](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/README.md) — "automatic generation of web_accessible_resources"
- [Vite Static Asset Handling](https://vite.dev/guide/assets) — CSS `url()` rebasing
- [crxjs issue #891 (SW asset loading failure)](https://github.com/crxjs/chrome-extension-tools/issues/891) — known pitfall, NOT applicable to popup/welcome font loading
- Codebase verification: current `vite.config.ts` line 10 sets `injectCss: false` for content scripts only (does not affect popup/welcome CSS); current `manifest.json` has no `web_accessible_resources` (Plan 01-10 adds one for welcome.html)

---

## 3. SVG → PNG rasterization toolchain (D-01)

### Verified locally

Tooling installed on dev machine:

- `rsvg-convert` 2.60.0 (libRSVG via librsvg) — installed at `/usr/bin/rsvg-convert`
- ImageMagick `convert` (deprecated in v7, use `magick`) — also available
- `inkscape` 1.4.4 — available

Empirically verified by rasterizing `mokosh-mark.svg`:

```bash
rsvg-convert -w 16  -h 16  mokosh-mark.svg -o icon16.png  # 406 bytes ✓ (>200)
rsvg-convert -w 48  -h 48  mokosh-mark.svg -o icon48.png  # 784 bytes ✓ (>500)
rsvg-convert -w 128 -h 128 mokosh-mark.svg -o icon128.png # 1952 bytes ✓ (>1024)
```

All three clear the Chrome `imageUtil` floors documented in `assets-spec.md` and the design handoff. PNG output is `8-bit/color RGBA, non-interlaced` per `file(1)`.

### Recommended approach

**One-off rasterization, commit the PNGs.** No build-time automation needed because the mark is stable (designer-handed-off; D-01 locked). A `scripts/rasterize-icons.sh` recipe is documented but only re-runs when the SVG changes:

```bash
#!/usr/bin/env bash
set -euo pipefail
SVG="src/shared/brand/mokosh-mark.svg"  # or wherever the planner lands the SVG
for size in 16 48 128; do
  rsvg-convert -w "$size" -h "$size" "$SVG" -o "icons/icon${size}.png"
  echo "✓ icons/icon${size}.png ($(stat -c%s "icons/icon${size}.png") bytes)"
done
```

Run once during execute-plan, commit the resulting `icons/icon{16,48,128}.png` files.

### Anti-pattern

Do NOT add this to `prebuild` — Chrome's `imageUtil` floors are bytes-of-PNG, not bytes-of-source-SVG. Re-rasterizing on every `npm run build` adds 30–80 ms with no value (the inputs are stable per phase). Plan 01-12 should commit static PNGs as deliverables, not generate them in CI.

### Sources

- `rsvg-convert(1)` man page (locally available)
- [Inkscape command-line export](https://wiki.inkscape.org/wiki/Using_the_Command_Line) — alternative tool
- Verified locally against `.planning/intel/design-incoming/system/bundle/mokosh-handoff/assets/mokosh-mark.svg`

---

## 4. Mark legibility at 16 px (D-01 risk) — **VERIFIED OK**

### Findings

Empirically rasterized via rsvg-convert at 16/48/128 px. The 2×2 weave mark holds:

- **16 px** (`/tmp/mokosh-rsearch/mark-16.png`, 406 B): Reads as a 4×4-grid silhouette. The two central weave gaps (the "hole" at x=12–14 and y=12.5–13.5) collapse into near-pixel-level dots, but the overall grid pattern is recognizable. Stroke weight at 16 px ≈ 1.1 px effective; corners round slightly via anti-aliasing.
- **48 px** (784 B): Clean weave rendering with all gaps visible.
- **128 px** (1952 B): Fully resolved, every gap distinct.

Visual inspection: at 16 px the mark reads as "small frame containing crosshatch" — distinct from generic placeholder shapes. The README claim that "[Newsreader concept] holds at 16 px" (designer handoff handoff.html line 458) is borne out by the rsvg-convert output.

### Recommended approach

**Use the canonical `mokosh-mark.svg` at all three sizes — no 16 px variant needed.** The mark survives at 16 px. The four candidate marks the README mentions ("Surface Kit shows 4 mark candidates") are alternative concepts, not 16 px-rescue variants. With D-01 locked on `A · Loom`, the candidate set doesn't apply.

**No remediation needed.** This risk was pre-mitigated by the designer's choice of a simple weave (4 vertical + 4 horizontal segments inside a rounded square — no fine detail that fails to resolve at low resolution).

### Sources

- Local empirical: rsvg-convert output inspected as image at 16/48/128 px
- Designer handoff `handoff.html` line 458: "[2×2 weave intersection — see assets/mokosh-mark.svg] Holds at 16 px."

---

## 5. Dark theme variant of mark SVG (D-06)

### Findings

The lockup + mark SVGs hardcode `stroke="#181b2a"` (matches `--mks-ink-900`). The `tokens.css` `.dark` block at line 165 swaps `--mks-surface-inverse` etc., but does NOT touch the mark stroke. D-06 picks "neutral mark + dynamic badge" — the badge does the state work, not an icon swap.

**Three architectural patterns evaluated:**

1. **`currentColor`** — change SVG to `stroke="currentColor"`. Works for *inline* SVG with `<svg style="color: var(--mks-fg-1)">`, but NOT for `<img src="mokosh-mark.svg">` (image elements don't inherit `currentColor`). The `default_icon` and `chrome.action.setIcon` paths use raster PNGs, not SVG, so this pattern doesn't help the toolbar at all.
2. **Two SVG variants** — `mokosh-mark-light.svg` (`#181b2a` stroke) + `mokosh-mark-dark.svg` (`#faf7f1` stroke for inverse surfaces). Swap based on `prefers-color-scheme`. Useful for *welcome page* hero rendering only.
3. **Badge-only differentiation (D-06)** — the toolbar PNG icon stays neutral (`#181b2a`) regardless of theme; the colored badge (`chrome.action.setBadgeBackgroundColor`) does the state communication. This is what D-06 picks.

### Recommended approach

**For toolbar PNGs (icon16/48/128):** Single neutral `#181b2a` stroke. The current rasterized PNGs from §3 satisfy D-06 directly. Chrome's toolbar will adapt the visual surrounding (light/dark theme of the browser chrome) but the icon itself stays a fixed ink-on-transparent rendering.

**For inline SVG usage on welcome page (Plan 01-10's territory, not 01-12's):** When Plan 01-10 inlines `mokosh-mark.svg` into the welcome HTML, modify the inline copy to use `stroke="currentColor"` and let CSS `color: var(--mks-fg-1)` control it. This is a Plan 01-10 concern; Plan 01-12 may pre-stage by copying the SVG to `src/shared/brand/mokosh-mark.svg` and leaving the canonical hardcoded-stroke version untouched (the rasterizer needs the stroke explicit for PNG output).

**No dark-variant SVG file needed for Plan 01-12.**

### Sources

- `tokens.css` lines 165–189 (`.dark` block) — confirms surface inversion but no mark color swap
- `brand-decisions-v1.md` D-06 — "Neutral mark + dynamic badge"
- MDN `currentColor`: only inherited by inline SVG, not `<img src=svg>` (well-known browser behavior; not vendor-disputed)

---

## 6. Cyrillic subsetting (D-05 corollary) — **superseded by §1 BLOCKER for Newsreader**

### Findings (Plex faces only — Newsreader covered in §1)

Per the canonical Google Fonts subset structure extracted from the Decision Brief:

| Subset | Unicode-range (canonical) | Bytes-per-face-weight (est.) |
|---|---|---|
| `cyrillic` (basic) | `U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116` | ~10 KB for Plex Sans 400 |
| `cyrillic-ext` | `U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F` | ~6 KB |
| `latin` (basic) | `U+0000-00FF, U+0131, U+0152-0153, ... (ASCII + Latin-1 + common punct)` | ~15 KB for Plex Sans 400 |

**Russian operators don't need `cyrillic-ext`** (that's Cyrillic Supplement — historical/extended scripts like Old Church Slavonic, Komi, Yakut). Mokosh's audience uses Modern Russian Cyrillic which lives entirely in `U+0400-04FF` (covered by basic `cyrillic`).

### Recommended subset per face

```bash
# Russian operator: latin (ASCII for English fallback text) + cyrillic (basic, U+0400-045F)
# Skip cyrillic-ext (Supplement), latin-ext (Eastern European), greek, vietnamese
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'
```

Per-face-weight WOFF2 should land at ~25 KB for Plex Sans, ~28 KB for Plex Mono. The size table in §1 is conservative.

### Sources

- Decision Brief embedded `@font-face` declarations (verified extraction; see §1)
- [`pyftsubset` docs](https://fonttools.readthedocs.io/en/stable/subset/) — `--unicodes` syntax
- Standard reference: [Markos Konstantopoulos on font subsetting](https://markoskon.com/creating-font-subsets/)

---

## 7. License compliance (D-05)

### Findings

- **IBM Plex Sans + IBM Plex Mono** — SIL OFL 1.1. Upstream license at https://github.com/IBM/plex/blob/master/LICENSE.txt. Copyright "© Copyright IBM Corp. with Reserved Font Names 'Plex' and 'IBM Plex'."
- **Newsreader** — SIL OFL 1.1. Upstream license at https://github.com/productiontype/Newsreader/blob/master/OFL.txt. Copyright "© 2019 Production Type, Paris."
- Both: embedding + bundling allowed. No royalty. Per [OFL-FAQ](https://openfontlicense.org/ofl-faq/), embedding a font in a software bundle "does not constitute distribution" for the purposes of mandatory NOTICE shipping. Best practice is still to include the OFL text + copyright lines.

### Recommended file layout

```
src/shared/fonts/
├── IBMPlexMono-Regular.woff2          # 400 latin+cyrillic
├── IBMPlexMono-Medium.woff2           # 500 latin+cyrillic
├── IBMPlexSans-Regular.woff2          # 400 latin+cyrillic
├── IBMPlexSans-Medium.woff2           # 500 latin+cyrillic
├── IBMPlexSans-SemiBold.woff2         # 600 latin+cyrillic
├── IBMPlexSans-Bold.woff2             # 700 latin+cyrillic
├── Newsreader-variable.woff2          # variable axes [opsz, wght], latin-only per §1 R1
├── LICENSE-IBM-Plex.txt               # copy of upstream LICENSE.txt
├── LICENSE-Newsreader.txt             # copy of upstream OFL.txt
└── README.md                          # one paragraph: what's bundled, with upstream URLs
```

`src/shared/fonts/README.md` content (one-pager):

```markdown
# Bundled fonts

Self-hosted per MV3 CSP (forbids remote @font-face at runtime).
All faces are SIL OFL 1.1 licensed (see LICENSE-* files in this directory).

## Sources

- IBM Plex Sans + Plex Mono: https://github.com/IBM/plex (Plex Sans v1.1.0, Plex Mono v1.1.0, 2024-11-13)
- Newsreader: https://github.com/productiontype/Newsreader (variable, 2020+, Production Type for Google Fonts)

## Subsetting

Subsetted via fontTools.pyftsubset to Latin (ASCII + Latin-1) + Cyrillic basic
(U+0400-045F) for Plex; Latin-only for Newsreader (no Cyrillic glyphs in upstream).
Russian operator surfaces use Plex Sans/Mono for text rendering; Newsreader is
display-only for Latin script (welcome EN parallel-text subhead).

## Regenerate

See scripts/subset-fonts.sh.
```

### Sources

- [SIL OFL 1.1 text](https://openfontlicense.org/)
- [OFL-FAQ](https://openfontlicense.org/ofl-faq/) — embedded fonts attribution
- [IBM Plex repo + LICENSE](https://github.com/IBM/plex)
- [Newsreader repo + OFL.txt](https://github.com/productiontype/Newsreader)
- [SPDX OFL-1.1](https://spdx.org/licenses/OFL-1.1.html)

---

## 8. `.mks-word` CSS class (lockup SVG dependency)

### Findings

The lockup SVG (`assets/mokosh-lockup.svg`) line 21:

```html
<text x="48" y="40" class="mks-word">Mokosh</text>
```

The `.mks-word` class is referenced but NOT defined in delivered `tokens.css`. Without it, the wordmark "Mokosh" inside the lockup SVG renders in the browser default serif at default size — completely off-brand.

### Recommended approach

**Add a one-line definition to `src/shared/tokens.css` (engineering-authored, designer-overridable).** The definition matches the visual specs implied by the lockup SVG geometry (text starts at x=48, baseline at y=40 inside a 240×56 viewBox; visual inspection shows the text occupies about 32 px cap-height):

```css
/* ── Lockup wordmark ──────────────────────────────────────────────
   References from mokosh-lockup.svg. Engineering's working definition;
   designer can override by sending a tokens.css patch. */
.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);
}
```

**Mitigations to surface to designer in commit message:**

```
docs(01-12): add .mks-word lockup wordmark CSS (engineering working defn)

The lockup SVG `assets/mokosh-lockup.svg` references `.mks-word`
which was not in delivered tokens.css. This commit adds a working
definition (32 px Newsreader at --mks-ink-900) so the lockup
renders. Designer may override by sending a tokens.css patch with
the canonical sizing.
```

**If the lockup is used inline in welcome page:** `fill` works via CSS for `<text>` inside inline SVG. If used as `<img src="lockup.svg">`, the CSS doesn't reach inside — the `.mks-word` would need to be applied via a `<style>` element inside the SVG itself (per [SVG2 CSS rules](https://www.w3.org/TR/SVG2/styling.html#StylingWithCSS)). For Plan 01-12, the simplest path: add the CSS to `tokens.css`, and any consumer of the lockup either (a) inlines the SVG into HTML (CSS reaches), or (b) inlines the same class definition into a `<style>` block within the SVG file (small duplication, acceptable).

### Sources

- `assets/mokosh-lockup.svg` line 21 (read verbatim)
- `tokens.css` lines 14–280 (verified `.mks-word` is absent)
- [SVG2 § 6: Styling with CSS](https://www.w3.org/TR/SVG2/styling.html#StylingWithCSS) — CSS-from-outside-SVG rules

---

## 9. tokens.css integration into existing src/ (D-04)

### Findings

Current `src/` layout:

```
src/
├── background/index.ts        # SW
├── content/index.ts           # content script
├── offscreen/{index.html, recorder.ts}
├── popup/{index.html, index.ts, style.css}   # current popup w/ placeholder palette
└── shared/{binary.ts, logger.ts, types.ts}   # shared module convention exists
```

Current popup style (`src/popup/style.css`) uses hardcoded hex (`#2563eb` blue button, `#f8f9fa` body bg, `#dc2626` error) — entirely engineering placeholder colors per the design handoff's diagnostic. Plan 01-12 rewires these to tokens.

**Future welcome page (Plan 01-10's territory) at `src/welcome/welcome.css`** uses hardcoded placeholders too (`#00C853` green button, `#1f1f1f` text) — per current Plan 01-10 contents. Plan 01-12 SHOULD NOT modify Plan 01-10's CSS *unless* the planner explicitly decides to rewire it as a hand-off to Plan 01-10 execution (which is a coordination question).

### Recommended file layout

```
src/shared/tokens.css       # canonical tokens (this is the single source of truth)
src/shared/fonts/           # WOFF2 files + LICENSE-* (per §7)
```

**Import strategy:** Each top-level CSS file (`src/popup/style.css`, `src/welcome/welcome.css`, smoke `data:` URL) prepends a relative `@import`:

```css
/* src/popup/style.css */
@import "../shared/tokens.css";

body {
  background: var(--mks-surface);
  color: var(--mks-fg-1);
  font-family: var(--mks-font-ui);
  ...
}
```

Vite resolves `@import` paths at build time and emits a single CSS chunk per HTML entry, deduplicating the token block. **No PostCSS pipeline needed** — CSS custom properties (`--mks-*`) are runtime browser features, resolved by the browser at paint time. Vite ships them as literal CSS without transformation.

### Anti-pattern

Do NOT use Vite's `?inline` CSS imports for tokens — that's for JS-side string imports of CSS source. Tokens go through normal `@import` to participate in the deduplication.

Do NOT split tokens into per-surface files. The handoff is one tokens.css; engineering's job is to keep that one file canonical so designer overrides are single-file patches per `handoff.html` line 27 ("the tokens are wired so a re-skin is a single-file edit").

### Smoke.sh consumption

`smoke.sh` builds a `data:text/html` URL containing the entire smoke page as a base64-encoded blob (verified at `smoke.sh:132–181`). The smoke HTML has inline `<style>` blocks. For Plan 01-12, D-04 wants the smoke page styled per tokens. Three options:

- **A**: Inline the relevant `--mks-*` definitions into the smoke `<style>` block (the smoke page only uses a handful of tokens — `#000`/`#0f0` mono timer overlay etc.). Replace hardcoded colors with the `--mks-*` values *literally* (because `data:` URLs can't `@import` from extension-relative paths). Verbosity cost: ~30 lines added to `smoke.sh`'s SMOKE_HTML heredoc.
- **B**: Convert smoke from `data:` URL to a Chrome-extension-relative URL (`chrome-extension://<id>/smoke.html`). Adds bundled smoke page artifacts to `dist/` — directly conflicts with D-09 ("dev-only behind VITE_DEV"). Increases bundle size for production.
- **C**: Leave smoke unstyled per tokens. The smoke page is dev-only; designer's restyling intent is implicit; engineering accepts the imperfection.

**Recommended: A** — copy a minimal subset of `--mks-*` literally into the smoke `<style>` block. Small verbosity cost, no architectural change, satisfies D-04's "rewire smoke.sh to use these tokens" directive in spirit.

### Sources

- Codebase verification: `src/popup/style.css` (read verbatim, lines 1–84)
- Codebase verification: `vite.config.ts` (no custom PostCSS plugin configured)
- [Vite CSS handling](https://vite.dev/guide/features.html#css) — `@import` deduplication
- [MDN CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) — runtime resolution

---

## 10. The 8 i18n copy strings (Brief §02)

### Verbatim extraction from Decision Brief §02

Extracted from `.planning/intel/design-incoming/mokosh/dist/Decision Brief (standalone).html` (decoded bundler-template script, §02 "Восемь операторских строк"). All strings inherit D-03 Sober register.

| # | Surface | RU (sober — recommended) | EN (welcome hero only) | Recommended `__MSG_*__` key | Source `src/*` file |
|---|---|---|---|---|---|
| 1 | Toolbar tooltip · OFF (action.default_title) | `Mokosh — щёлкните, чтобы начать запись` | — | `tooltipOff` | `src/background/index.ts` (chrome.action.setTitle for OFF state) |
| 2 | Toolbar tooltip · REC | `Mokosh — идёт запись (00:42)` (with timer interpolation; the `(00:42)` is a runtime substitution) | — | `tooltipRec` (with `$TIME$` placeholder) | `src/background/index.ts` (chrome.action.setTitle for REC state, requires a timer-update interval) |
| 3 | Toolbar tooltip · ERR | `Mokosh — ошибка записи, щёлкните для восстановления` | — | `tooltipErr` | `src/background/index.ts` (chrome.action.setTitle for ERR state, currently set in setErrorMode) |
| 4 | Popup idle body / CTA | `Сохранить отчёт об ошибке? / Сохранить отчёт` (the "?" is the body label; "Сохранить отчёт" is the button) | — | `popupSaveCta`, `popupSavePrompt` (two strings: prompt + button) | `src/popup/index.html` (button text "Сохранить отчёт об ошибке"), `src/popup/index.ts` line 38 (idle case) |
| 5 | Popup done | `Архив сохранён в Загрузки.` | — | `popupSaveDone` | `src/popup/index.ts` (replaces current line 91 `'Архив успешно сохранён!'`) |
| 6 | Notification startup | `Запись запущена. Я слежу за последними 30 секундами.` | — | `notifStartup` | `src/background/index.ts` (chrome.notifications.create startup message in setRecordingMode flow) |
| 7 | Notification recovery | `Запись возобновлена. Буфер снова заполняется.` | — | `notifRecovery` | `src/background/index.ts` lines 766–768 (recoveryId notification body) |
| 8 | Welcome hero · RU (+ EN parallel) | `Тридцать секунд назад, всегда под рукой.` | `Thirty seconds ago, always within reach.` | `welcomeHeroRu`, `welcomeHeroEn` (two keys: RU primary, EN parallel) | `src/welcome/welcome.html` (Plan 01-10's territory; Plan 01-12 produces the messages.json entries even if Plan 01-10 hasn't wired them yet) |

Note on string #4 vs the current popup (`src/popup/index.html` line 12 + `src/popup/index.ts` line 38): existing copy is `Сохранить отчёт об ошибке` (no "?", used as button text). The new sober register splits this into a label-with-question + a button — Plan 01-12 will need a small DOM rework in `src/popup/index.html` to render both. Alternatively, keep the existing single-button form and use string #4 ("Сохранить отчёт об ошибке?") as the combined tooltip + button label. **Recommend:** ask user during execute-plan whether to rework popup DOM. Simplest path: keep current single-button form, use `Сохранить отчёт` (the shorter button form) and put the `Сохранить отчёт об ошибке?` label above as `<p class="popup-prompt">` per the existing `<p class="info-text">` pattern.

Note on string #2 (REC tooltip with `(00:42)`): Chrome's `chrome.action.setTitle` accepts a string, no placeholder syntax. The `$TIME$` interpolation must happen in TS code before the API call. Suggested: define `__MSG_tooltipRecPrefix__` = `"Mokosh — идёт запись"` and concat `' (' + formatElapsed(seconds) + ')'` in JS. This keeps `messages.json` clean and lets the SW format the timestamp.

### Recommended `_locales/` layout

```
_locales/
├── en/messages.json    # default_locale fallback; non-Russian system locales see English
└── ru/messages.json    # primary operator locale
```

Example `_locales/ru/messages.json`:

```json
{
  "extName":         { "message": "Mokosh — Запись сессии",
                       "description": "manifest.json:name; surfaces in Chrome's extensions list, Web Store, and right-click menus" },
  "extDesc":         { "message": "Тридцать секунд назад, всегда под рукой.",
                       "description": "manifest.json:description" },
  "tooltipOff":      { "message": "Mokosh — щёлкните, чтобы начать запись" },
  "tooltipRecPrefix":{ "message": "Mokosh — идёт запись",
                       "description": "Suffix '(MM:SS)' appended at runtime by the SW" },
  "tooltipErr":      { "message": "Mokosh — ошибка записи, щёлкните для восстановления" },
  "popupSavePrompt": { "message": "Сохранить отчёт об ошибке?" },
  "popupSaveCta":    { "message": "Сохранить отчёт" },
  "popupSaveDone":   { "message": "Архив сохранён в Загрузки." },
  "notifStartup":    { "message": "Запись запущена. Я слежу за последними 30 секундами." },
  "notifRecovery":   { "message": "Запись возобновлена. Буфер снова заполняется." },
  "welcomeHeroRu":   { "message": "Тридцать секунд назад, всегда под рукой." },
  "welcomeHeroEn":   { "message": "Thirty seconds ago, always within reach.",
                       "description": "Parallel English subhead under the RU hero on the welcome page" }
}
```

And `_locales/en/messages.json` (fallback for non-RU Chrome locales) — same keys, English values, with `extDesc` = `"Thirty seconds ago, always within reach."` and `extName` = `"Mokosh — Session Capture"` (per D-07 user override).

### Sources

- Decision Brief §02 verbatim (extracted via Python JSON-decode of bundler template; see `tool-results/bdp4vyvas.txt`)
- `src/popup/index.html` + `index.ts` codebase grep
- `src/background/index.ts:54, 766–768, 868–870` for notification call sites
- [Chrome i18n message format](https://developer.chrome.com/docs/extensions/reference/api/i18n) — `messages.json` schema

---

## 11. manifest.json i18n via `__MSG_*__` (D-07 + D-08)

### Findings

Per [Chrome i18n docs](https://developer.chrome.com/docs/extensions/reference/api/i18n) and the [default_locale manifest reference](https://developer.chrome.com/docs/extensions/mv3/manifest/default_locale/):

- `manifest.json:name` and `manifest.json:description` accept `__MSG_<key>__` references — both fields support i18n.
- `default_locale` field is REQUIRED if `_locales/` directory exists. Without it, `_locales/` is ignored.
- Lookup order: user's preferred locale (e.g. `ru_RU`) → locale without region (`ru`) → `default_locale` → first-found in directory.
- "As long as the default locale's messages.json file has a value for every string, your extension will run no matter how sparse a translation is" (per Chrome docs).
- Chrome supports 54 store-listing locales; Russian (`ru`) is among them.

### Recommended approach

```json
{
  "manifest_version": 3,
  "name": "__MSG_extName__",
  "version": "1.0.0",
  "description": "__MSG_extDesc__",
  "default_locale": "en",
  "permissions": [...],
  ...
  "action": {
    "default_popup": "src/popup/index.html",
    "default_title": "__MSG_tooltipOff__",   // NEW per #1 above
    "default_icon": {...}
  },
  ...
}
```

**Why `default_locale: "en"`:**

- The Chrome Web Store fallback metadata (when displayed in a non-RU browser) uses `default_locale`. Setting it to `"en"` means the store listing for non-Russian users sees the English name + description, while RU-locale users (the operator audience) see the Russian.
- If `default_locale: "ru"`, then non-RU locales would see Russian (likely rendered correctly in the store but jarring for a Polish/German viewer).
- The Russian operator audience accesses the actual UI which is RU-localized regardless of `default_locale` — `default_locale` only controls fallback.

**If the user prefers `default_locale: "ru"`** (operator-only deployment, no Chrome Web Store distribution): set it to `"ru"`. The mechanism is the same; only the fallback differs.

### Anti-pattern

Do NOT use `__MSG_@@extension_id__` in manifest.json — the docs explicitly note "The special message `@@extension_id` ... doesn't work in manifest files."

Do NOT skip `default_locale` if `_locales/` exists — manifest validation fails at install time.

### Sources

- [Chrome i18n API](https://developer.chrome.com/docs/extensions/reference/api/i18n)
- [Manifest default_locale](https://developer.chrome.com/docs/extensions/mv3/manifest/default_locale/)
- [Localization message formats](https://developer.chrome.com/docs/extensions/mv2/i18n-messages/) — MV2 doc but format unchanged in MV3
- [How to Add i18n to Chrome Extension MV3 (Stefan Van Damme, 2023)](https://www.stefanvd.net/blog/2023/04/24/how-to-add-internationalization-i18n-chrome-extension/)

---

## 12. VITE_DEV smoke-page gating (D-09) — **NEAR-NO-OP**

### Findings

Verified by grepping the entire `src/` tree:

```bash
$ grep -rn "smoke\|SMOKE\|data:text/html" src/
# (empty)
$ find dist -name "smoke*" -o -name "*SMOKE*"
# (empty)
```

`smoke.sh` is a standalone bash script. The smoke page HTML is embedded as a heredoc inside `smoke.sh:132–181` and assembled into a `data:text/html,...` URL at line 181. The `data:` URL is passed to Chrome as a command-line argument:

```bash
SMOKE_DATA_URL="data:text/html,$(printf '%s' "${SMOKE_HTML}" | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read(), safe=""))')"
```

`SMOKE_HTML` exists only in shell process memory. Nothing in `src/` references it. Vite's build pipeline has zero awareness of `smoke.sh`. `dist/` carries no smoke artifacts.

### Why D-09 ("dev-only behind VITE_DEV") is a near-no-op

The decision was framed as if smoke might leak into the production bundle. **It can't** — smoke lives outside Vite's input set entirely. The production `dist/` does not ship smoke regardless of any flag.

### Recommended approach

**Document the existing state — minimal code change.** Plan 01-12 adds two artifacts to satisfy D-09's spirit:

1. **`scripts/README.md` (new file, 1 paragraph):** "Diagnostic / smoke scripts live in `smoke.sh` and `scripts/*`. These are dev-only tools and are not bundled by `npm run build`. The production `dist/` produced by `npm run build` does NOT contain smoke artifacts (verified by grep of `dist/` after every build)."
2. **`vite.config.ts` add a `define` block (defensive, for any future inline smoke flag):**
   ```ts
   define: {
     // D-09: any future inline-smoke-feature check should use this flag.
     // Currently smoke lives entirely in smoke.sh; this flag is reserved
     // for future code that wants conditional dev-only behavior.
     __VITE_DEV__: JSON.stringify(process.env.VITE_DEV === '1' || !!process.env.npm_lifecycle_event?.startsWith('dev')),
   },
   ```

**Optional (recommended but not required):** Add a vitest test that asserts `dist/` does NOT contain `smoke` strings after build:

```ts
// tests/build/no-smoke-in-dist.test.ts
it('dist/ contains no smoke artifacts', () => {
  // ...recursively read dist/, assert no file path or content contains 'smoke'
});
```

If the user wants stronger guarantees (D-09 audit-ready), this test is the right shape.

### Anti-pattern

Do NOT add `if (import.meta.env.VITE_DEV) { ... smoke code ... }` blocks to `src/` files — there are no such blocks today, and creating them would introduce the very leak D-09 is asking to prevent. The current architecture (smoke isolated to a shell script) is already the safest.

### Sources

- Codebase verification (grep results above)
- `smoke.sh` line 132–181 (read verbatim)
- [Vite env vars + modes](https://vite.dev/guide/env-and-mode) — `import.meta.env.VITE_*` pattern
- [Vite define option](https://vite.dev/config/shared-options.html#define) — for `__VITE_DEV__` constant injection

---

## 13. tokens.css line 12 Google Fonts @import (CSP migration)

### Findings

Verified — `tokens.css` line 12 is:

```css
@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');
```

This violates MV3 CSP. Per [Chrome MV3 CSP docs](https://developer.chrome.com/docs/extensions/reference/manifest/content-security-policy), the default extension-pages CSP is `script-src 'self'; object-src 'self';` plus `style-src 'self' 'unsafe-inline';` for stylesheets. The `style-src 'self'` directive prevents the Google Fonts stylesheet from loading at runtime. (The `font-src` directive defaults to `'self'` as well, so even if the CSS loaded, the `@font-face src: url(https://fonts.gstatic.com/...)` inside would also fail.)

In practice, this means popups built today using the unmodified tokens.css would silently fall back to system serif/sans because the `@font-face` declarations never get loaded.

### Recommended migration

Replace line 12 with local `@font-face` rules pointing at self-hosted WOFF2 files in `src/shared/fonts/`. Per the chosen weight matrix (D-05; subject to §1 BLOCKER R1 — Newsreader Latin-only):

```css
/* ── Self-hosted fonts (MV3 CSP) ──
   Faces and weights per D-05. Subsetting: Plex Sans + Plex Mono are
   Latin + Cyrillic (U+0400-045F); Newsreader is Latin-only (no
   Cyrillic glyphs in upstream — see RESEARCH.md §1 BLOCKER R1). */

/* IBM Plex Sans 400 / 500 / 600 / 700, normal */
@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 400 / 500, normal */
@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;
}

/* Newsreader variable — Latin only (no Cyrillic in upstream) */
@font-face {
  font-family: "Newsreader";
  src: url("./fonts/Newsreader-variable.woff2") format("woff2-variations"),
       url("./fonts/Newsreader-variable.woff2") format("woff2");
  font-weight: 400 700;     /* variable axis range */
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Newsreader";
  src: url("./fonts/Newsreader-italic-variable.woff2") format("woff2-variations"),
       url("./fonts/Newsreader-italic-variable.woff2") format("woff2");
  font-weight: 400 700;
  font-style: italic;
  font-display: swap;
}
```

**Rule count: 8** (4 Plex Sans + 2 Plex Mono + 2 Newsreader variable). Less than the 12–18 estimate in the prompt because using a variable font for Newsreader collapses 4 static-weight rules into 2 (one per style).

If §1 R1 is adopted with an additional Cyrillic-capable serif fallback (e.g. PT Serif), add 2–4 more `@font-face` rules for that face.

### Anti-pattern

Do NOT use the `format('woff2-variations')` hint with a fixed-weight WOFF2 file — the hint is for variable fonts only. Browsers without variable-font support fall back to the second `src` URL (`format('woff2')`).

Do NOT keep the Google Fonts `@import` "for dev fallback" — MV3 CSP refuses it in *all* modes (dev + prod alike); leaving it in would generate console errors during development.

### Sources

- `tokens.css` line 12 (verbatim)
- [Chrome MV3 CSP manifest docs](https://developer.chrome.com/docs/extensions/reference/manifest/content-security-policy) — default `style-src 'self'`, `font-src 'self'`
- [MDN @font-face](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face) — `font-weight: 400 700` range syntax for variable fonts
- [CSS Fonts Module 4 § variable fonts](https://www.w3.org/TR/css-fonts-4/#font-weight-prop) — `format('woff2-variations')` hint

---

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| Brand mark display (toolbar PNG) | Browser (Chrome action API) | — | `chrome.action.setIcon` consumes PNG; only the SW initially configures; rendering is browser-owned |
| Brand mark display (welcome inline SVG) | Browser (DOM) | Frontend Server (built HTML) | Plan 01-10 inlines the SVG into welcome.html; Vite builds the HTML; browser paints |
| CSS tokens | Browser (CSS custom properties) | — | All `--mks-*` resolve at paint time in the browser; no SSR; Vite ships them as literal CSS |
| Font loading (WOFF2) | Browser | CDN/Static (Vite-emitted `dist/assets/`) | Browser parses `@font-face`, fetches from extension origin; Vite asset pipeline emits + hashes |
| i18n string resolution (`__MSG_*__`) | Browser (Chrome i18n runtime) | CDN/Static (`_locales/*/messages.json`) | Chrome reads `_locales/` at install time, substitutes at runtime; no server involvement |
| Manifest:name + :description | Chrome runtime (manifest parser) | — | Chrome reads manifest, substitutes `__MSG_*__`, displays in Web Store / Extensions list |
| Popup HTML/CSS rendering | Browser | Frontend (built HTML) | Popup is a self-contained Chrome extension HTML page; browser-only rendering |
| Smoke page styling | Browser (data: URL DOM) | — | `data:text/html` URL renders client-side; no Vite bundling involved |

---

## Standard Stack

### Core

| Library / tool | Version (verified) | Purpose | Why standard |
|---|---|---|---|
| `@crxjs/vite-plugin` | 2.0.0-beta.25 (project); 2.4.0 (latest) | MV3 bundle + manifest rewriting + WAR auto-generation | Project already uses it; required for MV3 ergonomics on Vite |
| `vite` | 5.4.2 (project); 8.0.13 (latest) | Build tool; CSS @import dedup; asset hashing | Project already uses it; Vite native @import handling resolves tokens.css from anywhere in src/ |
| `fontTools` (`pyftsubset`) | 4.63.0 (verified locally) | WOFF2 subsetting | Industry standard; preinstalled on dev machine |
| `woff2_compress` (Google's `woff2`) | installed via `/usr/bin/woff2_compress` | WOFF2 encoding | Native compression; what pyftsubset uses under the hood when `--flavor=woff2` is specified |
| `rsvg-convert` (librsvg) | 2.60.0 (verified locally) | SVG → PNG rasterization | Reliable; produces valid 8-bit RGBA PNGs that clear all Chrome `imageUtil` floors |

### Supporting

| Library / tool | Version | Purpose | When to use |
|---|---|---|---|
| `vitest` | 4 (project) | Unit tests | Plan 01-12 adds 1–3 tests (manifest i18n shape, fonts present, no smoke in dist) |
| Newsreader (Production Type) | variable, current upstream | Display serif (Latin only) | Welcome page Latin headlines, lockup wordmark |
| IBM Plex Sans | v1.1.0 (2024-11-13) | UI body sans, Cyrillic + Latin | Popup, welcome RU body, all `--mks-font-ui` |
| IBM Plex Mono | v1.1.0 (2024-11-13) | Diagnostic mono, Cyrillic + Latin | Timer overlay (smoke), code-like UI labels |
| PT Serif or EB Garamond or Lora (optional, §1 R1) | OFL | Cyrillic-capable serif fallback for Russian display | Russian welcome hero, RU display text falling out of Newsreader stack |

### Alternatives considered

| Instead of | Could use | Tradeoff |
|---|---|---|
| `rsvg-convert` | `inkscape --export-png` | Inkscape is heavier (X server may be needed for headless run); rsvg-convert is purer + smaller |
| `pyftsubset` | `glyphhanger` (extracts unicode-range from HTML) | glyphhanger is for *discovering* needed glyphs from real content; not needed when subset ranges are known (we have the Google Fonts canonical ranges) |
| Newsreader (D-05) | PT Serif (Cyrillic-capable display serif) | Loses designer's specific D-05 choice; gains Russian rendering correctness — see §1 BLOCKER |
| `_locales/` for i18n | Hardcoded RU strings only | Locks out future EN store distribution; `_locales/` is one-time setup cost; manifest:name needs it anyway for D-07 EN listing |
| Asset path in `src/shared/fonts/` (Pattern A) | `/public/fonts/` (Pattern B) | Pattern A: hashed names, deduplication; Pattern B: stable names, no @crxjs WAR auto-gen |

### Installation

```bash
# All required tools are ALREADY installed on dev machine; verified:
which pyftsubset    # /usr/bin/pyftsubset (fontTools 4.63.0)
which woff2_compress # /usr/bin/woff2_compress
which rsvg-convert  # /usr/bin/rsvg-convert (2.60.0)

# No npm installs needed for Plan 01-12 — the tooling is all native binaries.
# IF the planner wants asset-pipeline tests, no new dev-deps either; vitest is already present.

# Font source downloads (one-off, will land via curl in execute-plan):
# - https://github.com/IBM/plex/releases (Plex Sans + Plex Mono v1.1.0)
# - https://github.com/productiontype/Newsreader/releases (variable WOFF2 + TTF)
```

### Version verification

Verified versions are current as of 2026-05-17 dev machine probe (`npm view` for npm packages; `which`/`--version` for system binaries). No package install needed for Plan 01-12 execution — Vite + crxjs versions stay where the project currently has them (upgrading Vite from 5 → 8 is out of scope for a brand-integration plan).

---

## Architecture Patterns

### Pattern 1: Tokens-first CSS

**What:** Every CSS rule consuming a color, font-family, spacing, or radius value references a `--mks-*` custom property defined in `src/shared/tokens.css`. No literal hex / px / font-family strings in `src/popup/style.css`, `src/welcome/welcome.css`, or smoke HTML.

**When to use:** All surfaces touched by Plan 01-12 (popup, smoke; welcome is Plan 01-10's).

**Example:**
```css
/* src/popup/style.css after Plan 01-12 */
@import "../shared/tokens.css";

body {
  background: var(--mks-surface);
  color: var(--mks-fg-1);
  font-family: var(--mks-font-ui);
  padding: var(--mks-space-4);
}
.save-button {
  background: var(--mks-rec);            /* madder for the SAVE button per D-04 */
  color: var(--mks-fg-inverse);
  border: var(--mks-border-width) solid var(--mks-border-strong);
  border-radius: var(--mks-radius-md);
  box-shadow: var(--mks-shadow-1);
  font-family: var(--mks-font-ui);
  font-weight: var(--mks-weight-semibold);
}
```

### Pattern 2: i18n `__MSG_*__` at every Chrome API call site

**What:** Every call to `chrome.action.setTitle`, `chrome.notifications.create`, `chrome.action.setBadgeText`, and any DOM-rendered Russian string uses `chrome.i18n.getMessage('key')` instead of a literal.

**When to use:** Every operator-facing string. Plan 01-12 audits `src/background/index.ts` + `src/popup/index.ts` for hardcoded RU strings and migrates each.

**Example:**
```ts
// src/background/index.ts (was: 'Запись возобновлена')
chrome.notifications.create(recoveryId, {
  type: 'basic',
  iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH),
  title: chrome.i18n.getMessage('notifRecoveryTitle'),
  message: chrome.i18n.getMessage('notifRecovery'),
});

// src/popup/index.ts (was: buttonText.textContent = 'Сохранить отчёт об ошибке')
buttonText.textContent = chrome.i18n.getMessage('popupSaveCta');
```

### Pattern 3: Single source of truth for tokens

**What:** `src/shared/tokens.css` is canonical. The handoff `tokens.css` is the input; engineering's `src/shared/tokens.css` is its production-ready descendant (Google Fonts `@import` replaced, `.mks-word` class added). The Decision Brief's `tokens.css` and the handoff's preview do NOT get bundled — only the engineering canonical.

### Anti-patterns to avoid

- **Per-surface mini-tokens.** Do NOT create `src/popup/popup-tokens.css` or similar. One tokens.css.
- **Inline color literals "for clarity".** Defeats the purpose. Use the token.
- **Skipping `_locales/en/` because audience is RU.** Manifest validation requires `default_locale` to have a messages.json. Plan 01-12 ships both.
- **Loading fonts with `chrome.runtime.getURL` from inside JS.** `@font-face url("./fonts/x.woff2")` in CSS is the only correct path (CSP-safe, Vite-rebased).

---

## Don't Hand-Roll

| Problem | Don't build | Use instead | Why |
|---|---|---|---|
| Font subsetting | Custom Python script reading .ttf bytes | `pyftsubset --flavor=woff2 --unicodes=...` | Glyph metric preservation + variable-axis-aware + handles ligatures correctly |
| SVG → PNG | Custom canvas rasterizer in Node | `rsvg-convert` or Inkscape | RGBA alpha, sub-pixel anti-aliasing, ImageMagick parity |
| i18n key resolution | Custom string-template engine | `chrome.i18n.getMessage` | Native Chrome integration; manifest substitution; locale fallback chain |
| WOFF2 compression | Custom Brotli encoder | `woff2_compress` (Google's reference impl) | The reference; what pyftsubset already calls internally |
| CSS custom properties → static colors | PostCSS plugin to inline | Use `var(--mks-*)` at runtime | Browser-native; runtime themable via `.dark` class addition |

**Key insight:** All five problems have battle-tested solutions in standard tooling. Engineering's role in Plan 01-12 is integration + wiring, not building. The single piece of "logic" Plan 01-12 writes is the `__MSG_*__` migration pass over the 8–12 hardcoded Russian strings + the `tokens.css` adoption — neither is library-grade work.

---

## Runtime State Inventory

Plan 01-12 includes brand-related runtime state changes; checking all five categories:

| Category | Items Found | Action Required |
|---|---|---|
| Stored data | `chrome.storage.local` key `onboarding-completed` (boolean, Plan 01-10). No PII; no brand text stored. | None — brand changes don't touch storage. |
| Live service config | `chrome.action.setTitle()` / `setBadgeText()` calls in `src/background/index.ts` currently pass literal Russian strings. After Plan 01-12, they pass `chrome.i18n.getMessage(...)`. Runtime state (the actual displayed tooltip / badge text per active locale) changes on next SW startup after the extension reload. | Code edit; no manual user action required. |
| OS-registered state | `manifest.json:name` changes from `"AI Call Recorder"` → `"Mokosh — Session Capture"` (per D-07). Chrome stores the manifest name in the extensions list. On `chrome://extensions` reload, the displayed name will update; old name remains briefly in any cached store listing. | None — Chrome handles on reload. |
| Secrets / env vars | None — no secrets in brand integration. | None. |
| Build artifacts | `dist/icons/icon{16,48,128}.png` currently hold the placeholder PNGs (dark square + green dot per `assets-spec.md` Path A description; verified file sizes 574 B / 1153 B / 2615 B in working tree at `/home/parf/projects/work/repremium/icons/`). After Plan 01-12 rasterizes the new mark, these are overwritten in source `icons/` and re-mirrored into `dist/icons/` on next `npm run build`. Also: `dist/manifest.json` will reflect new `name`/`description`/`default_locale`/`__MSG_*__` references; `dist/_locales/` will be a new directory. | `npm run build` after Plan 01-12 changes. Smoke test re-runs the operator UAT to confirm the new icons render correctly in toolbar + notification. |

**Verified by:** Codebase grep + file system probe. Nothing in `chrome.storage.session`, no IndexedDB brand caches, no remote services configured per brand identity.

---

## Common Pitfalls

### Pitfall 1: WOFF2 silent fallback to system fonts

**What goes wrong:** A misnamed font-family in `@font-face` (e.g. `font-family: "IBM Plex Sans Variable"` instead of `"IBM Plex Sans"`) doesn't error — the browser silently falls back to the next family in the stack (`"Segoe UI", -apple-system, ...`). Visual result: clean-looking sans-serif text, but NOT IBM Plex.

**Why it happens:** CSS `@font-face` matching is name-based; a typo creates a phantom face that no `var(--mks-font-ui)` reference resolves to.

**How to avoid:** Match the `font-family` name in `@font-face` EXACTLY to the name in `var(--mks-font-ui)` (`"IBM Plex Sans"` quoted, with the trailing space-and-Sans). The `tokens.css` already names them correctly; just verify each new `@font-face` rule matches.

**Warning signs:** Inspect popup with DevTools → Computed styles → font-family — should read `"IBM Plex Sans"` as the *applied* family, not `"Segoe UI"`. If `"Segoe UI"` appears, the `@font-face` didn't load.

### Pitfall 2: Chrome notification icon silently rejecting PNGs

**What goes wrong:** `chrome.notifications.create({iconUrl})` returns no error but the notification renders with a blank gray placeholder instead of the icon.

**Why it happens:** Chrome's `imageUtil` silently rejects PNGs below ~1 KB at 128×128 — a known artifact found during Plan 01-09 debugging (per `assets-spec.md` line 72). The rejection has no API surface; the only signal is "no icon."

**How to avoid:** Ensure `icons/icon128.png` is ≥ 1024 bytes. The rsvg-convert output at 128×128 produces ~1952 B (verified locally) — well above the floor.

**Warning signs:** Run smoke.sh, click SAVE, wait for the recovery notification. If the notification has no left-icon, the PNG was rejected. Plan 01-09's empirical UAT covers this; Plan 01-12's icon-replacement should re-run that UAT.

### Pitfall 3: `__MSG_*__` substitution failing in HTML

**What goes wrong:** Putting `__MSG_popupSaveCta__` literally in `src/popup/index.html` as text content does NOT get substituted. The string renders to the user verbatim.

**Why it happens:** `__MSG_*__` substitution only works in (a) `manifest.json` fields, (b) CSS via i18n predefined messages, and (c) JavaScript-side via `chrome.i18n.getMessage()`. HTML text content is NOT processed.

**How to avoid:** In HTML, leave the static text empty (or use a sensible default) and populate it from JS using `chrome.i18n.getMessage()` after `DOMContentLoaded`:

```html
<!-- src/popup/index.html -->
<button id="saveButton" class="save-button" disabled>
  <span class="button-text"></span>  <!-- populated by JS -->
</button>
```
```ts
// src/popup/index.ts in init()
const buttonText = saveButton.querySelector('.button-text') as HTMLSpanElement;
buttonText.textContent = chrome.i18n.getMessage('popupSaveCta');
```

**Warning signs:** Popup renders with `__MSG_*__` strings visible — substitution wasn't applied.

### Pitfall 4: Default locale missing a key

**What goes wrong:** A `chrome.i18n.getMessage('foo')` call returns an empty string when the user's locale messages.json has `foo` but the default_locale messages.json doesn't.

**Why it happens:** Chrome falls back to default_locale on any missing key in the user's locale — but if default_locale itself lacks the key, the chain ends. `chrome.i18n.getMessage` returns `""` (no warning).

**How to avoid:** Maintain the rule "every key present in any `_locales/*/messages.json` MUST be present in `_locales/<default_locale>/messages.json`." Write a vitest test if defensive coverage is wanted:

```ts
// tests/i18n/locale-parity.test.ts
it('every key in ru/messages.json exists in en/messages.json', () => {
  const ru = JSON.parse(readFileSync('_locales/ru/messages.json', 'utf8'));
  const en = JSON.parse(readFileSync('_locales/en/messages.json', 'utf8'));
  for (const k of Object.keys(ru)) expect(en).toHaveProperty(k);
});
```

**Warning signs:** Empty strings in popup / notification body.

### Pitfall 5: Vite hashing breaks @font-face url() in dev mode

**What goes wrong:** In dev mode (`npm run dev`), the WOFF2 file path `./fonts/IBMPlexSans-Regular.woff2` works, but on `npm run build` the output `@font-face src` becomes `url("/assets/IBMPlexSans-Regular-<hash>.woff2")` — same file, hashed name.

**Why it happens:** Vite's asset pipeline hashes assets for cache busting. The `@import "../shared/tokens.css"` in popup CSS gets re-emitted with hashed URLs.

**How to avoid:** This is correct behavior; the dev/prod discrepancy is intentional. The pitfall is only when the planner inspects `dist/manifest.json` for a specific filename — use a regex pattern instead. CRXJS handles `web_accessible_resources` registration based on the hashed names automatically.

**Warning signs:** Hardcoded asset paths in tests; tests fail in build mode but pass in dev mode.

---

## Code Examples

Verified patterns from official sources + designer handoff:

### Subsetting a WOFF2 with pyftsubset

```bash
# Source: fontTools.readthedocs.io/en/stable/subset/
# Verified locally: pyftsubset 4.63.0 installed at /usr/bin/pyftsubset

pyftsubset IBMPlexSans-Regular.ttf \
  --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' \
  --flavor=woff2 \
  --output-file=src/shared/fonts/IBMPlexSans-Regular.woff2 \
  --layout-features='*' \
  --no-hinting \
  --desubroutinize
```

### Rasterizing a PNG icon at a specific size

```bash
# Source: rsvg-convert(1) man page (locally available)
# Verified locally: produces RGBA 8-bit non-interlaced PNG that clears Chrome floors

rsvg-convert -w 128 -h 128 src/shared/brand/mokosh-mark.svg -o icons/icon128.png
stat -c%s icons/icon128.png  # confirm ≥1024 bytes (verified: ~1952)
```

### manifest.json with `__MSG_*__` + `default_locale`

```json
{
  "manifest_version": 3,
  "name": "__MSG_extName__",
  "version": "1.0.0",
  "description": "__MSG_extDesc__",
  "default_locale": "en",
  "action": {
    "default_popup": "src/popup/index.html",
    "default_title": "__MSG_tooltipOff__",
    "default_icon": {
      "16":  "icons/icon16.png",
      "48":  "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "icons": {
    "16":  "icons/icon16.png",
    "48":  "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}
```

### Reading an i18n string from JS

```ts
// Source: developer.chrome.com/docs/extensions/reference/api/i18n
// chrome.i18n.getMessage(messageName: string, substitutions?: string | string[]): string

const cta = chrome.i18n.getMessage('popupSaveCta');
buttonText.textContent = cta;

// With substitution placeholder ($1):
// messages.json: "tooltipRecWithTime": { "message": "Mokosh — идёт запись ($1)" }
const tooltip = chrome.i18n.getMessage('tooltipRecWithTime', formatElapsed(seconds));
chrome.action.setTitle({ title: tooltip });
```

---

## State of the Art

| Old approach | Current approach | When changed | Impact |
|---|---|---|---|
| Bundle TTF / OTF directly | WOFF2 only | ~2020 (universal browser support) | 30–50% smaller, native browser parsing |
| Static-weight WOFF2 files | Variable font WOFF2 | 2018 (Chrome 62+, FF 62+) | Single file replaces 5–9 weight files for Newsreader |
| Inline manifest:name | `__MSG_*__` + `_locales/` | Pre-MV3 | Required for Chrome Web Store store-listing localization |
| Google Fonts `@import` in MV2 popups | Self-hosted WOFF2 in `dist/` | MV3 mandate (2024 deprecation of MV2) | Forced by CSP `style-src 'self'` + `font-src 'self'` |
| Per-state PNG icon swaps via `chrome.action.setIcon` | Neutral mark + `setBadgeBackgroundColor` | 2015+ best practice | Lower asset count; faster state transitions; no PNG decoding per state change |
| `convert` (ImageMagick) for SVG → PNG | `rsvg-convert` (librsvg) | (preference shift, both still work) | rsvg-convert known-good for Inkscape-authored SVGs and produces predictable byte counts |

**Deprecated / outdated for this project:**

- **A-06 per-state PNG variants** (per D-06 = neutral mark + badge). Do not produce A-06 deliverables.
- **`@import url(...remote...)`** (per MV3 CSP). Removed from `tokens.css` in Plan 01-12.
- **Engineering placeholder palette** (`#2563eb`, `#dc2626`, etc. in `src/popup/style.css` and SW `BADGE_REC_COLOR = '#00C853'`). Replaced by `--mks-rec` etc. **Note:** `src/background/index.ts:45` has `BADGE_REC_COLOR = '#00C853'` as a TS literal — the badge color is set via `chrome.action.setBadgeBackgroundColor` which takes an `[r, g, b, a]` tuple OR a hex string. JS can't `var(--mks-*)` directly; Plan 01-12 needs to mirror the token value as a TS constant (e.g. `BADGE_REC_COLOR = '#b2543d' /* --mks-madder-600 */`). Document the dual ownership clearly.

---

## Environment Availability

| Dependency | Required by | Available | Version | Fallback |
|---|---|---|---|---|
| `rsvg-convert` | §3 PNG rasterization | ✓ | 2.60.0 | `inkscape --export-png` (also installed) or `convert` (ImageMagick) |
| `pyftsubset` (fontTools) | §1, §6 WOFF2 subsetting | ✓ | 4.63.0 | `glyphhanger` (npm; would need install) |
| `woff2_compress` | §1 WOFF2 encoding | ✓ | (system pkg) | bundled inside pyftsubset already; standalone tool is redundant |
| `inkscape` | §3 SVG rendering | ✓ | 1.4.4 | — |
| `convert` / `magick` | §3 SVG fallback | ✓ | (ImageMagick 7+, deprecated `convert` symlink) | — |
| `python3` | §1 subsetting + smoke.sh URL encoding | ✓ | 3.x (per pyftsubset / smoke.sh hashbang) | — |
| `node` | Vite build | ✓ | 24.14.0 | — |
| `npm` (or pnpm/yarn) | Package management | ✓ | bundled with node | — |
| `xmllint` / `brotli` | NOT required (pyftsubset bundles Brotli compression) | not checked | — | — |
| Internet access (one-off, to fetch upstream IBM Plex + Newsreader releases) | §1 source TTFs | required at execute-plan time | — | Can curl from CDN as alternate (`https://fonts.gstatic.com/...` paths leaked from the Brief's bundler — uuid'd file names extractable from same source) |

**Missing dependencies with no fallback:** None.

**Missing dependencies with fallback:** None applicable.

**Network requirement:** One-time download of TTF source files from GitHub Releases (IBM Plex ~30 MB; Newsreader ~10 MB). Within scope of any normal `curl`/`wget` step in execute-plan.

---

## Validation Architecture

### Test Framework

| Property | Value |
|---|---|
| Framework | `vitest` v4 |
| Config file | none in repo root; vitest uses defaults (verified via `package.json:24`) |
| Quick run command | `npx vitest run` |
| Full suite command | `npx vitest run` (no separate full / quick distinction in current setup) |

### Phase Requirements → Test Map

| Req ID | Behavior | Test type | Automated command | File exists? |
|---|---|---|---|---|
| D-01 / D-06 | `icons/icon{16,48,128}.png` exist, are PNGs, clear size floors | unit (build artifact assertion) | `npx vitest run tests/build/icons-present.test.ts` | ❌ Wave 0 |
| D-04 | `src/shared/tokens.css` exists; popup CSS @imports it; no `#2563eb`/`#dc2626` hex in popup CSS | unit (codebase grep) | `npx vitest run tests/build/tokens-adopted.test.ts` | ❌ Wave 0 |
| D-05 + §1 R1 | `src/shared/fonts/IBMPlexSans-{Regular,Medium,SemiBold,Bold}.woff2` exist; `Newsreader-variable.woff2` exists; tokens.css `@font-face` rules match expected count | unit (filesystem + grep) | `npx vitest run tests/build/fonts-present.test.ts` | ❌ Wave 0 |
| D-07 | `manifest.json:name === "__MSG_extName__"` AND `_locales/en/messages.json` has `extName.message === "Mokosh — Session Capture"` | unit (JSON parity) | `npx vitest run tests/i18n/manifest-i18n.test.ts` | ❌ Wave 0 |
| D-08 | `manifest.json:description === "__MSG_extDesc__"`; `_locales/{en,ru}/messages.json` has `extDesc` with the tagline text | unit (JSON parity) | `npx vitest run tests/i18n/manifest-i18n.test.ts` | (same as D-07) |
| D-09 | `dist/` after `npm run build` contains no file or content with `smoke`/`SMOKE` strings | integration (filesystem walk after build) | `npm run build && npx vitest run tests/build/no-smoke-in-dist.test.ts` | ❌ Wave 0 |
| (i18n parity) | Every key in `_locales/ru/messages.json` exists in `_locales/en/messages.json` (the default_locale) | unit | `npx vitest run tests/i18n/locale-parity.test.ts` | ❌ Wave 0 |
| (CSP) | Built `dist/src/shared/tokens.css` does not contain `fonts.googleapis.com` or any `https://` URL | unit | `npx vitest run tests/build/no-remote-fonts.test.ts` | ❌ Wave 0 |
| (Operator manual) | After load-unpacked: extension name in `chrome://extensions` reads `"Mokosh — Session Capture"`; tooltip on hover reads RU sober string; popup renders in Plex Sans; SAVE button is madder-rust color | manual-only | (operator UAT) | n/a |

### Sampling rate

- **Per task commit:** `npx vitest run tests/build/*.test.ts tests/i18n/*.test.ts` (Plan 01-12-specific tests only; ~1–2 seconds).
- **Per wave merge:** `npx vitest run && npm run build && npx tsc --noEmit` (full suite + build clean + types clean).
- **Phase gate:** Full suite green + operator empirical (load extension, verify name + popup + tooltip + notification rendering, capture screenshots) before `/gsd-verify-work`.

### Wave 0 gaps

- [ ] `tests/build/icons-present.test.ts` — covers D-01 / D-06 PNG floors
- [ ] `tests/build/tokens-adopted.test.ts` — covers D-04 token usage in popup CSS
- [ ] `tests/build/fonts-present.test.ts` — covers D-05 + §1 R1 WOFF2 presence + count
- [ ] `tests/i18n/manifest-i18n.test.ts` — covers D-07 + D-08 manifest:name/description shape + messages.json values
- [ ] `tests/i18n/locale-parity.test.ts` — covers i18n key parity across locales
- [ ] `tests/build/no-smoke-in-dist.test.ts` — covers D-09 (no-op-confirmation test)
- [ ] `tests/build/no-remote-fonts.test.ts` — covers CSP migration in tokens.css
- [ ] `tests/build/` directory may not exist yet — create it; convention matches existing `tests/{background,offscreen}/` structure.

---

## Security Domain

### Applicable ASVS categories

| ASVS category | Applies | Standard control |
|---|---|---|
| V2 Authentication | no | Plan 01-12 is brand integration; no auth surface |
| V3 Session Management | no | No sessions introduced |
| V4 Access Control | no | No new permission gates |
| V5 Input Validation | yes (loosely) | i18n `messages.json` files are JSON; ensure JSON validity in CI (vitest `JSON.parse`) |
| V6 Cryptography | no | No new crypto |
| V12 File / Resources | yes | Self-hosted WOFF2 files MUST live under `src/shared/fonts/`; no `https://` URLs in `tokens.css`; Chrome MV3 CSP enforces this at runtime |
| V14 Configuration | yes | `manifest.json:default_locale` set; `_locales/` directory complete; `web_accessible_resources` correctly auto-generated by @crxjs |

### Known threat patterns for this stack

| Pattern | STRIDE | Standard mitigation |
|---|---|---|
| Remote font load via `@import` (MV3 CSP violation) | Information Disclosure (font fingerprinting), Tampering (CDN MITM) | Self-host all fonts; explicitly assert no `https://` in built `tokens.css` via vitest |
| Malformed PNG triggering Chrome `imageUtil` rejection (Plan 01-09 known issue) | Denial of Service (notification flow blocked) | PNG byte-size floor assertions in tests (`tests/build/icons-present.test.ts`); empirical UAT |
| `_locales/` key tampering causing manifest validation failure | Denial of Service (extension fails to install) | JSON validity check in Wave 0 tests; manifest:name resolution test |
| `__MSG_*__` injection from user-controllable input | Cross-Site Scripting (low surface; manifest fields don't render arbitrary user input) | Not applicable — `__MSG_*__` is engineering-authored, no user input flow |
| WOFF2 with malicious OpenType tables | Code execution (historical browser CVEs) | Use upstream-distributed faces (IBM, Production Type); do not accept third-party fonts |

---

## Assumptions Log

Every claim tagged `[ASSUMED]` in this research. The planner and discuss-phase should confirm these with the user before locking decisions that depend on them.

| # | Claim | Section | Risk if wrong |
|---|---|---|---|
| A1 | `default_locale: "en"` is correct (Web Store convention) | §11 | If Mokosh is only distributed via Load Unpacked or operator-only channels, `"ru"` may be preferable. Low blast radius — flip the value, regenerate. |
| A2 | The Russian "Сохранить отчёт об ошибке?" (label) + "Сохранить отчёт" (button) split per Brief §02 means DOM rework in `src/popup/` | §10 string #4 | If user prefers keeping the single-button form, the rework is unnecessary. Verify before executing. |
| A3 | `src/shared/tokens.css` is the right path (vs `src/styles/tokens.css`) | "Claude's Discretion" + §9 | Low blast radius — rename, update 2–3 `@import` paths. |
| A4 | A Cyrillic-capable serif fallback should be added to `--mks-font-display` stack for Russian display text (§1 R1) | §1 BLOCKER | If user accepts the OS-serif fallback for Russian (Times New Roman on Windows, Iowan Old Style on macOS), no Cyrillic serif font needs subsetting + bundling. Lower bundle size; lower visual consistency. |
| A5 | `Newsreader-variable.woff2` covers the full italic + roman variable font range in a single file (Production Type's variable build may ship italic separately) | §1, §13 | If italic is a separate variable file, add a second `@font-face` rule with `font-style: italic;`. Verify at execute-plan time by downloading the actual release archive. |
| A6 | The smoke page tokenization (§9 Option A) is "verbose but acceptable" — adds ~30 lines to smoke.sh's heredoc | §9 | If user thinks it's too noisy, fall back to Option C (leave smoke unstyled per tokens, accept the imperfection — smoke is dev-only). |
| A7 | `BADGE_REC_COLOR` constant in `src/background/index.ts` should be updated to `#b2543d` (`--mks-madder-600`) with a comment cross-referencing the token | §10 / Anti-patterns | If user prefers keeping the existing material green, document the divergence between badge color and palette. The "dye reference" semantic of madder is the designer's intent per D-04. |

If this table is empty, all claims in this research were verified or cited. **This table has 7 entries** — the planner should surface A1, A2, A4 in particular (highest-impact assumptions) during plan creation, ideally via the discussion log or as plan-level questions.

---

## Open Questions

1. **D-05 Cyrillic for Newsreader** (BLOCKER per §1)
   - What we know: Newsreader from upstream has no Cyrillic. Documented in Production Type repo and verified empirically in Decision Brief's @font-face subsets.
   - What's unclear: User's preference among R1 (add Cyrillic-capable serif fallback), R2 (substitute display family), R3 (extend Newsreader — out of scope).
   - Recommendation: Default to R1, add **PT Serif** as the Cyrillic fallback (OFL, ParaType, ~30 KB subsetted for RU); surface as a plan-level question.

2. **D-07 wording for English manifest:name**
   - What we know: User picked `"Mokosh — Session Capture"`.
   - What's unclear: Should the EN messages.json `extName.message` exactly match the user's pick, or follow D-07 alt `"Mokosh / АИ Регистратор"` for transliteration support?
   - Recommendation: Exact user pick (no transliteration). Lock as-stated unless user clarifies.

3. **Plan 01-10 coordination**
   - What we know: Plan 01-10 created `src/welcome/welcome.{html,ts,css}` with placeholder palette (`#00C853` green button, `#1f1f1f` text). Plan 01-12 wants to ingest tokens.
   - What's unclear: Does Plan 01-12 rewire Plan 01-10's CSS now (single-pass token migration), or does Plan 01-12 leave Plan 01-10 alone and a future Plan 01-13 / 02-XX migrates welcome separately?
   - Recommendation: Rewire in Plan 01-12. Welcome page is in Phase 1 scope; one-pass migration is cheaper than two-pass.

4. **D-08 tagline wording mismatch**
   - What we know: Brief §02 wrote `"Thirty seconds ago, always within reach."`; brand-decisions-v1.md wrote `"Thirty seconds ago, always at hand."`. Both attribute to D-08 user-accepted.
   - What's unclear: Which exact wording for the EN parallel?
   - Recommendation: Default to brand-decisions-v1.md (`"always at hand."`) as it's the more recent doc and reflects the user's final pick. Surface as a single confirmation question.

5. **Plex Sans variable font availability**
   - What we know: IBM published `@ibm/plex-sans-variable@0.2.0` (June 2024). The static per-weight files are also distributed.
   - What's unclear: Whether the variable build is mature enough to replace the 4 static weights (which would shave ~80 KB).
   - Recommendation: Use static per-weight files (Pattern A from §1) for the first pass. Migrate to variable in a future polish phase if bundle size matters.

6. **Brief §02 string #4 popup-DOM rework**
   - What we know: Brief splits "Сохранить отчёт об ошибке?" (label) from "Сохранить отчёт" (button).
   - What's unclear: Single-button vs label+button.
   - Recommendation: A2 in Assumptions Log. Default to single-button form; ask user during execute-plan if rework is wanted.

---

## Sources

### Primary (HIGH confidence)

- `tokens.css` (verified verbatim, all 281 lines read)
- `mokosh-mark.svg`, `mokosh-lockup.svg` (verified verbatim)
- `manifest.json`, `vite.config.ts`, `package.json` (codebase-verified)
- `src/popup/index.html`, `src/popup/index.ts`, `src/popup/style.css`, `src/background/index.ts` lines 45, 54, 766–768 (codebase-verified)
- `smoke.sh` (codebase-verified)
- `.planning/intel/brand-decisions-v1.md` (verbatim read)
- `.planning/intel/assets-spec.md` (verbatim read)
- Decision Brief embedded `@font-face` declarations (extracted via Python JSON-decode, see tool-results)
- Decision Brief §02 8 copy strings (extracted verbatim)
- Local empirical: `rsvg-convert` rasterization verified at 16/48/128 px
- Local empirical: `pyftsubset` + `woff2_compress` installed + working
- [@crxjs/vite-plugin README on GitHub](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/README.md)
- [Chrome i18n API docs](https://developer.chrome.com/docs/extensions/reference/api/i18n)
- [Manifest default_locale docs](https://developer.chrome.com/docs/extensions/mv3/manifest/default_locale/)
- [Vite Static Asset Handling](https://vite.dev/guide/assets)
- [Vite env vars and modes](https://vite.dev/guide/env-and-mode)
- [SIL Open Font License 1.1](https://openfontlicense.org/) + [OFL-FAQ](https://openfontlicense.org/ofl-faq/)
- [IBM Plex repo](https://github.com/IBM/plex)
- [Newsreader repo (productiontype/Newsreader)](https://github.com/productiontype/Newsreader)
- [fontTools pyftsubset docs](https://fonttools.readthedocs.io/en/stable/subset/)

### Secondary (MEDIUM confidence)

- [Markos Konstantopoulos on font subsetting](https://markoskon.com/creating-font-subsets/)
- [Walter Ebert on subsetting web fonts](https://walterebert.com/blog/subsetting-web-fonts/)
- [Stefan Van Damme: i18n MV3 tutorial (2023)](https://www.stefanvd.net/blog/2023/04/24/how-to-add-internationalization-i18n-chrome-extension/)

### Tertiary (LOW confidence — flagged)

- WebSearch's initial claim that "Newsreader supports Cyrillic" — **contradicted by primary sources** (Decision Brief subsets, Production Type repo README). Resolved as BLOCKER §1.
- WebSearch claim about Chrome notification icon 3:2 ratio and `imageUtil` floors — official docs don't document these; empirical Plan 01-09 finding is the only confirmed reference (`assets-spec.md:72`). The 1024-byte floor is verified by Plan 01-09 ops; not in any docs page.

---

## Metadata

**Confidence breakdown:**

- Standard stack: **HIGH** — all tools verified locally; versions checked via `--version` and `npm view`.
- Architecture (tokens.css integration, @crxjs WAR auto-gen): **HIGH** — codebase + official docs.
- WOFF2 self-hosting for Plex faces: **HIGH** — canonical subset structure verified from Decision Brief.
- WOFF2 for Newsreader: **MEDIUM** — Latin-only subset is well-documented; Cyrillic handling required §1 BLOCKER resolution.
- i18n via `__MSG_*__`: **HIGH** — official docs + repo + 2023 tutorial concur.
- PNG rasterization: **HIGH** — locally empirically verified rsvg-convert at 16/48/128.
- Smoke gating (D-09): **HIGH** — codebase grep + smoke.sh inspection confirm no-op status.
- Notification icon floors (1024 B): **MEDIUM** — official docs don't document; relies on Plan 01-09 empirical finding.
- Operator string i18n migration: **HIGH** — strings verbatim from Brief §02; call sites grep-verified.

**Research date:** 2026-05-17

**Valid until:** ~30 days (2026-06-16) for stable claims (font upstreams, OFL, Chrome i18n API). 7 days for cutting-edge claims (`@crxjs/vite-plugin@2.4.0`, IBM Plex variable v0.2.0 maturity).
