diff --git a/.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md b/.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md
new file mode 100644
index 0000000..539a49c
--- /dev/null
+++ b/.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md
@@ -0,0 +1,1305 @@
+# Phase 1: Stabilize Video Pipeline — Research
+
+**Researched:** 2026-05-15
+**Domain:** Chrome MV3 extension, offscreen documents, `getDisplayMedia`,
+`MediaRecorder` ring buffer, WebM container, SW lifecycle, Vite + crxjs.
+**Confidence:** HIGH on Chrome API contracts; HIGH on canonical patterns
+(verified against an in-the-wild production extension); MEDIUM on
+`MediaRecorder` cluster-boundary alignment with `timeslice=2000ms` (the
+spec is silent and Chromium docs are silent — published evidence is
+indirect; we have a mitigation in place via the D-13 fallback).
+
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+**Capture API — AMENDS DEC-003**
+
+This phase REPLACES the SPEC-locked `chrome.tabCapture` choice with
+`getDisplayMedia()` capture. Done eyes-open: the operator gains broader
+capture coverage at the cost of the SPEC §1 "silent operation" property.
+The doc cascade is enumerated in the **Doc Amendments (precede code)**
+subsection below.
+
+- **D-01:** Capture mechanism is `navigator.mediaDevices.getDisplayMedia()`
+ invoked **inside the offscreen document**. No more
+ `chrome.tabCapture.getMediaStreamId`, no more SW-side gesture juggling.
+- **D-02:** Offscreen document is created with
+ `chrome.offscreen.Reason.DISPLAY_MEDIA` (replaces `USER_MEDIA`).
+- **D-03:** One-time source picker on session start; the operator picks
+ "screen" or "window" once. If they later click the Chrome "Stop sharing"
+ banner or the captured source disappears, the offscreen surfaces an error
+ to the SW and the popup re-prompts on next interaction. (Exact error-UX
+ copy is deferred to Phase 3 — see Deferred Ideas.)
+- **D-04:** Operator UX is **NOT** silent. Chrome's permanent "Sharing your
+ screen" indicator is shown while recording. We accept this as the cost
+ of the API choice.
+- **D-05:** `manifest.json` permissions follow the new API: `desktopCapture`
+ replaces `tabCapture`; `activeTab` becomes unnecessary for the video
+ pipeline but stays for `chrome.tabs.captureVisibleTab` (screenshot path,
+ Phase 3 concern — kept).
+
+**Offscreen source-of-truth location**
+
+- **D-06:** Recorder code lives at **`src/offscreen/recorder.ts`** as a real
+ TypeScript module with strict type-check, source maps, and IDE support.
+- **D-07:** `offscreen/index.html` is rewritten to load the bundled module
+ via crxjs. The runtime path remains `offscreen/index.html` (referenced
+ from SW via `chrome.runtime.getURL('offscreen/index.html')`).
+- **D-08:** **DELETE** `offscreen/index.ts` (orphaned dead code) and the
+ entire `copy-offscreen` plugin block in `vite.config.ts:11-184`. crxjs
+ picks up the new TS entry through the HTML reference.
+
+**Ring-buffer mechanism**
+
+- **D-09:** **Single continuous MediaRecorder** for the whole session.
+ `mediaRecorder.start(2000)` so chunks land on cluster boundaries per the
+ spec timeslice (DEC-003, SPEC §4.1). No restart strategy at this point.
+- **D-10:** Retain the **first emitted chunk** (the chunk produced by the
+ first `dataavailable` event after `start()`) **indefinitely** — it carries
+ the EBML header plus the initial cluster. CON-webm-header-retention.
+- **D-11:** Drop later chunks once they are older than 30 s, by chunk
+ arrival timestamp. Keep header + every chunk newer than `now - 30000 ms`.
+- **D-12:** Acceptance gate for Phase 1: `ffprobe -v error -f matroska -i
+ ` must return exit 0 with no decoder warnings on a
+ fresh-export sample. Plan-checker enforces this as a phase success
+ criterion.
+- **D-13:** **Fallback if D-12 fails:** revise the plan mid-phase to use
+ *restart-segments* (stop + restart the MediaRecorder every 10 s, keep
+ the 3 most-recent self-contained segments, concat on save). Documented
+ as a known fallback so the planner can pre-stage the alternative
+ structure in PLAN.md.
+
+**Tab-switch behavior**
+
+- **D-14:** **Not applicable** under the new capture API. `getDisplayMedia()`
+ captures a screen or window, not a tab — there is nothing to re-attach
+ on `chrome.tabs.onActivated`. Phase 1 explicitly **removes** any
+ tab-switch handling from `src/background/index.ts`.
+- **D-15:** Operator switching tabs no longer interrupts the recording —
+ the buffer keeps filling regardless of active tab.
+
+**State survival across SW unload**
+
+- **D-16:** Video buffer **ownership moves to the offscreen document**. The
+ offscreen survives SW unloads because it holds the
+ `DISPLAY_MEDIA`-reason capture; chunks accumulate there.
+- **D-17:** A long-lived `chrome.runtime.connect` port from offscreen → SW
+ serves as the keepalive (this is the only mechanism that actually
+ resets the SW idle timer — `chrome.alarms` callbacks do not, contrary
+ to DEC-010).
+- **D-18:** **DELETE** the `chrome.alarms` keepalive
+ (`src/background/index.ts:171-178`). DEC-010 and CON-service-worker-keepalive
+ are amended in the doc cascade below.
+- **D-19:** On export, SW requests the buffer from offscreen over the port
+ (or one-shot `chrome.runtime.sendMessage`). SW does **NOT** cache
+ chunks. CON-buffer-storage is honored — buffer is plain JS variable in
+ offscreen memory, no `chrome.storage.session`, no IndexedDB. The
+ existing IndexedDB code path in `vite.config.ts:43-104` is **DELETED**
+ along with the inline plugin.
+
+**Doc Amendments (precede code)**
+
+These document edits **MUST** ship before any code-touching task in this
+phase, so downstream phases see a consistent baseline:
+
+- **D-A1:** Amend `.planning/intel/decisions.md` DEC-003 to record the
+ `getDisplayMedia` replacement, with rationale and the explicit silent-
+ operation trade-off. Amend DEC-010 to record port keepalive replacing
+ alarms keepalive.
+- **D-A2:** Amend `.planning/intel/constraints.md` to **RETIRE**
+ CON-tab-capture-binding and CON-service-worker-keepalive. Add new
+ CON-display-capture-binding (one-time picker, "Sharing" indicator).
+- **D-A3:** Amend `.planning/PROJECT.md` Key Decisions table (DEC-003,
+ DEC-010) and Constraints section accordingly.
+- **D-A4:** Amend `.planning/REQUIREMENTS.md` REQ-video-ring-buffer to
+ remove "active-tab" wording and update API binding.
+- **D-A5:** Amend `.planning/ROADMAP.md` Phase 1 description and Success
+ Criterion #2 (drop the "tab re-attach" clause).
+- **D-A6:** Amend `manifest.json`: swap `tabCapture` → `desktopCapture`
+ in `permissions`. Keep `activeTab` for the screenshot path.
+
+### Claude's Discretion
+
+- Exact protocol choice for offscreen↔SW messaging (port for keepalive +
+ sendMessage for one-shot vs port-only).
+- Codec strictness: enforce `video/webm; codecs=vp9` via
+ `MediaRecorder.isTypeSupported`; fail loud if unsupported (no fallback
+ chain — current code's vp9→vp8→h264→default fallback is removed).
+- Internal naming for the new buffer-owning module (offscreen-recorder vs
+ display-recorder etc.).
+- Code-style choices around TS strictness within `src/offscreen/`
+ (already on `"strict": true` per tsconfig).
+
+### Deferred Ideas (OUT OF SCOPE)
+
+- **Error UX for "user stopped sharing" mid-session.** The popup needs a
+ state for this — Phase 3 territory (REQ-popup-ui state machine
+ extension).
+- **Audio capture.** `getDisplayMedia()` makes audio capture trivial
+ (`audio: true`), but SPEC §9 explicitly excludes audio from Phase 1
+ (Phase 2 work — CAP-01). Capture this as an easier-now-than-before
+ follow-up.
+- **Per-tab silent capture mode** as an opt-in via `config.json`. Could
+ re-introduce tabCapture for installations that prioritize silent
+ operation over broad coverage. Future phase if there's demand.
+- **Cluster-aware EBML trim (ts-ebml).** Not needed for Phase 1 if
+ continuous + age-trim verifies via ffprobe. Keep on the shelf as a
+ third fallback under D-13.
+- **`chrome.storage.session` cold-start recovery.** Buffer pointer
+ rehydration after offscreen crash. Phase 5 (Harden + clean up)
+ territory.
+
+
+
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| REQ-video-ring-buffer | 30 s active-tab video ring buffer captured via `MediaRecorder` at `video/webm; codecs=vp9` @ 400 kbps with 2 s timeslice. AMENDED: capture API is `getDisplayMedia()` (D-01), not `chrome.tabCapture`. First chunk (WebM header) retained indefinitely (CON-webm-header-retention); subsequent chunks rotate out by 30 s TTL. **Capture is always-on**: starts on first popup invocation, runs continuously regardless of which tab the operator is on (no tab re-attach needed — display capture is screen/window-bound, not tab-bound). | (1) Canonical pattern for SW + offscreen + getDisplayMedia confirmed by Google sample + working production extension (Proscreen-S3). (2) WebM header / cluster trim semantics documented under "Pitfall 1" + "Validation Architecture". (3) Port-keepalive replaces alarm-keepalive per Chrome 110+ docs. (4) `MediaRecorder.start(2000)` semantics documented under Pitfall 1 with D-13 fallback if cluster alignment fails ffprobe gate. |
+
+
+
+## Project Constraints (from CLAUDE.md)
+
+> No project-level CLAUDE.md exists at `/home/parf/projects/work/repremium/CLAUDE.md`.
+> User's global `~/.claude/CLAUDE.md` applies — relevant excerpts:
+
+- **Iterative development:** Small, reviewable changes. Break large work
+ into phases. Plans should be concise (< 100 lines); detail goes into
+ context/research files.
+- **Extension over duplication:** Add functionality to existing code via
+ options/parameters rather than parallel implementations. *(Applies to
+ reusing `videoBuffer`/`cleanupVideoBuffer` patterns from the current
+ SW — preserve structure, relocate to offscreen.)*
+- **Defensive coding:** Validate external dependencies and environment
+ early; fail fast with clear error messages. *(Codec fail-loud via
+ `MediaRecorder.isTypeSupported`; track-ended detection.)*
+- **Naming:** Full words, `isFoo`/`hasFoo`/`shouldFoo` for booleans,
+ `SCREAMING_SNAKE` for true constants.
+- **Tools first:** Use automated tools before manual edits. *(crxjs
+ handles the offscreen build; do not hand-roll Vite plugins.)*
+- **Verify claims before presenting.** Cite authoritative sources.
+- **TypeScript:** Type arrow-function parameters explicitly.
+- **Don't ignore lint/type errors without research.** *(Maps to audit
+ P1 #13: no `as any`, no `@ts-ignore` in new code.)*
+- **Naming convention violation already in repo:** `mediaRecorder` (camel)
+ shadowing module-level `let mediaRecorder` is the exact P0 #2 defect we
+ are fixing — rename module-level to avoid recurrence.
+
+> **Note on the codebase's Russian inline comments:** The user's global
+> rule prefers Python/Google style guides, but this repo is a TypeScript
+> extension built to a Russian-authored SPEC. Inline Russian comments are
+> idiomatic and preserved per the SPEC's source-of-truth language (also
+> reaffirmed in CONTEXT.md "Established patterns"). User-facing strings
+> ("Сохранить отчёт об ошибке" etc.) are part of the contract.
+
+## Summary
+
+The audit's seven P0 defects boil down to two structural problems in this
+phase: **(a) the offscreen runtime lives as a string literal inside
+`vite.config.ts:11-184` and shadows the real `offscreen/index.ts`, with a
+shadow `let mediaRecorder` that makes `stopRecording` a no-op**; **(b) the
+ring-buffer math is right in `src/background/index.ts` but the lifecycle
+plumbing is wrong**: `mediaRecorder.start(200)` produces too-short chunks
+that mostly don't start on WebM cluster boundaries, capture only begins
+when the popup is opened, the SW's `chrome.alarms` keepalive does run but
+the SW still loses its `videoBuffer` array between idle unloads, and the
+SW's `VIDEO_CHUNK` message handler expects a Blob that `chrome.runtime.sendMessage`
+cannot transmit (forcing the buggy IndexedDB workaround in `vite.config.ts:43-104`).
+
+CONTEXT.md amends DEC-003 to `getDisplayMedia()` instead of `chrome.tabCapture`
+— eyes-open trade-off, broader capture coverage at the cost of the Chrome
+"Sharing your screen" banner. This is a canonical Chrome MV3 pattern:
+[CITED: developer.chrome.com/docs/extensions/how-to/web-platform/screen-capture]
+"To record in the background and across navigations, use an offscreen
+document with the DISPLAY_MEDIA reason." We have at least one in-the-wild
+production extension (Proscreen-S3) confirming the exact architecture
+works.
+
+**Primary recommendation:** Build `src/offscreen/recorder.ts` as a real
+TS module that owns: (1) a single continuous `MediaRecorder` started with
+`timeslice=2000`, (2) the in-memory ring buffer with WebM-header pinning
+and 30 s arrival-timestamp trim, (3) a long-lived `chrome.runtime.connect`
+port to the SW that doubles as the SW keepalive, and (4) a single
+on-demand `GET_BUFFER` handler that returns the chunks for ZIP packaging.
+The SW shrinks to: offscreen lifecycle management + port handling +
+manifest-time recording bootstrap. The verification gate is `ffprobe -v error`
+on a fresh export sample — if that fails because cluster boundaries don't
+align with the 2 s timeslice, fall back to D-13's restart-segments
+strategy (pre-staged in PLAN.md so we don't have to re-plan mid-phase).
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Display capture (`getDisplayMedia`) | Offscreen Document | — | SW has no DOM and cannot hold a `MediaStream`. Chrome 116+ requires `chrome.offscreen.Reason.DISPLAY_MEDIA`. [CITED: developer.chrome.com/docs/extensions/reference/api/offscreen] |
+| MediaRecorder lifecycle | Offscreen Document | — | `MediaRecorder` instances are tied to a `MediaStream` which lives in the offscreen DOM context. |
+| In-memory ring buffer | Offscreen Document | — | SW unloads after ~30 s idle (Chrome 110+ rules); offscreen survives because it owns the `DISPLAY_MEDIA` capture. |
+| Codec capability check (`isTypeSupported`) | Offscreen Document | — | API is on `MediaRecorder`, which is offscreen-bound. SW reports the result for telemetry. |
+| Offscreen lifecycle (create / close / hasDocument) | Service Worker | — | `chrome.offscreen.*` API is SW-bound. |
+| Long-lived port keepalive | Offscreen Document → SW | — | Offscreen initiates `chrome.runtime.connect()` because it is the long-living party with a real reason to stay alive. SW receives the port. |
+| Buffer export on user action | Service Worker | Offscreen Document | SW receives popup message, requests buffer from offscreen over the port, returns chunks to popup. |
+| Manifest permission boundary | Manifest | — | `desktopCapture` for the API name (CONTEXT.md D-A6); `offscreen` to gate `chrome.offscreen.*`. Note: `getDisplayMedia()` itself is a web standard API and does NOT require `desktopCapture` (which gates only `chrome.desktopCapture.chooseDesktopMedia`). Including `desktopCapture` is harmless and matches CONTEXT.md D-05. [VERIFIED: chrome.desktopCapture API docs] |
+| Stop-sharing recovery | Offscreen Document | Service Worker | `MediaStreamTrack.onended` fires inside offscreen; offscreen messages SW; SW updates state for popup (popup state machine is Phase 3 territory). |
+
+## Standard Stack
+
+### Core
+
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `@crxjs/vite-plugin` | `^2.4.0` (currently `^2.0.0-beta.25` in `package.json`) | Vite plugin that reads `manifest.json`, bundles each entry (SW, content scripts, popup, offscreen HTML), and produces a Chrome-loadable `dist/`. | Standard build for MV3 + TS + Vite per the project's existing setup (DEC-012). [VERIFIED: npm view @crxjs/vite-plugin version returned 2.4.0 on 2026-05-15] |
+| `@types/chrome` | `^0.1.42` (currently `^0.0.268` in `package.json`) | Type definitions for the `chrome.*` namespace including `chrome.offscreen.Reason.DISPLAY_MEDIA`. | Audit P1 #13 calls out that the current `0.0.268` is stale; the project needs to bump to drop the `as any` on `reasons: ['USER_MEDIA']`. [VERIFIED: npm view @types/chrome version returned 0.1.42 on 2026-05-15] |
+| `vite` | `^8.0.13` (currently `^5.4.2` in `package.json`) | Bundler. | Already a hard project decision (DEC-012). Phase 1 does NOT mandate a Vite bump — sticking with 5.4 is fine; the bump is a Phase 5 housekeeping task. [VERIFIED: npm view vite version returned 8.0.13 on 2026-05-15] |
+| `typescript` | `^6.0.3` (currently `^5.5.4` in `package.json`) | Type-check. Strict mode is already enabled in `tsconfig.json`. | Project decision. Phase 1 keeps 5.5; same Phase 5 housekeeping observation. [VERIFIED: npm view typescript version returned 6.0.3 on 2026-05-15] |
+
+> **No new dependencies are needed for Phase 1.** `JSZip` and `rrweb`
+> stay untouched (Phase 2 / 3 territory). All new code uses the standard
+> Web Platform APIs (`MediaRecorder`, `navigator.mediaDevices`,
+> `chrome.offscreen`, `chrome.runtime.connect`).
+
+### Supporting (Phase 1 specifically uses)
+
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| Web Platform: `MediaRecorder` | Built-in | Encode the captured `MediaStream` into a chunked WebM stream. | Inside the offscreen, after `getDisplayMedia()` returns a stream. |
+| Web Platform: `navigator.mediaDevices.getDisplayMedia` | Built-in | Acquire the operator's choice of screen/window/tab as a `MediaStream`. | Inside the offscreen, once on session start, in the message handler for `START_RECORDING`. |
+| Chrome API: `chrome.offscreen.{createDocument, closeDocument, hasDocument, Reason}` | Chrome 109+ for API; Chrome 116+ recommended baseline (matches the canonical Google sample's `minimum_chrome_version`). | Create + tear down the offscreen runtime. | SW only. |
+| Chrome API: `chrome.runtime.{connect, sendMessage, onConnect, onMessage}` | Built-in | Cross-context messaging. | Both SW and offscreen. |
+
+### Alternatives Considered (Honored CONTEXT.md, recorded for completeness)
+
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| `getDisplayMedia()` in offscreen | `chrome.tabCapture.getMediaStreamId` in SW + `getUserMedia({chromeMediaSource: 'tab'})` in offscreen (canonical Google sample pattern) | Tab-scoped only; silent (no Chrome banner); requires user-gesture juggling on first activation; loses capture on tab switch. **Rejected per CONTEXT.md D-01.** |
+| `getDisplayMedia()` in offscreen | `chrome.desktopCapture.chooseDesktopMedia` in SW + redeem ID in offscreen | Chrome-specific; doc explicitly says streamId not usable in offscreen MV3 [CITED: groups.google.com chromium-extensions/3RanHldyp9c]. **Not viable.** |
+| Single continuous recorder + age-trim | Restart-segments (10 s self-contained segments, keep 3 most-recent) | Each segment is its own valid WebM, concat-on-save is trivial, but burns ~3× more keyframes (bigger files). **Held in reserve as D-13 fallback** if `ffprobe -v error` fails on the simpler approach. |
+| Restart-segments | ts-ebml header injection on save | More plumbing, dependency, and runtime cost. **Held in reserve as third fallback per CONTEXT.md deferred.** |
+
+**Installation:** No `npm install` needed for Phase 1 (zero new deps).
+Type-bump for `@types/chrome` (`^0.0.268` → `^0.1.42`) is a one-line
+`package.json` edit, optional within this phase but recommended.
+
+**Version verification:** All package versions in the table above are
+verified via `npm view version` on 2026-05-15.
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+┌────────────────────────────────────────────────────────────────────────┐
+│ Operator interactions │
+└────────────────────────────────────────────────────────────────────────┘
+ │ click popup
+ ▼
+┌────────────┐ REQUEST_PERMISSIONS / GET_VIDEO_BUFFER ┌──────────────┐
+│ popup │ ──────────────────────────────────────────► │ Service │
+│ (Russian │ ◄────────────────────────────────────────── │ Worker │
+│ state-mc) │ responses │ (background) │
+└────────────┘ └──────┬───────┘
+ │
+ chrome.offscreen.createDocument
+ ({reasons:['DISPLAY_MEDIA']})
+ │
+ ▼
+ ┌──────────────────┐
+ long-lived │ Offscreen Doc │
+ port (keepalive + │ (DOM context) │
+ buffer fetch) │ │
+ SW ◄──────────────────────────────►│ recorder.ts │
+ │ - getDisplayMedia
+ │ - MediaRecorder │
+ │ - ring buffer │
+ │ - track.onended │
+ └─────┬────────────┘
+ │
+ navigator.mediaDevices
+ .getDisplayMedia()
+ │
+ ▼
+ [ Chrome native ]
+ [ source picker ]
+ [ + Sharing UI ]
+ │
+ ▼
+ ┌──────────────────┐
+ │ MediaStream │
+ │ (screen/window) │
+ └─────┬────────────┘
+ │
+ MediaRecorder.start(2000)
+ │
+ ▼
+ dataavailable chunks
+ (every ~2000 ms)
+ │
+ ▼
+ in-memory ring buffer
+ (offscreen JS array)
+
+Data flow on export (Phase 3 territory but the SW↔offscreen contract is
+locked here):
+ popup --SAVE_ARCHIVE--> SW --GET_BUFFER--> offscreen
+ offscreen --VIDEO_CHUNKS--> SW --(merge)--> popup --(jszip + download)
+```
+
+| Component | File | Responsibilities |
+|-----------|------|------------------|
+| Operator-facing popup | `src/popup/index.{ts,html,css}` | UI state machine, click handlers, archive trigger. Phase 3 owns most edits; Phase 1 touches it only minimally to unwire the dead `REQUEST_PERMISSIONS` path. |
+| Service Worker (background coordinator) | `src/background/index.ts` | Offscreen lifecycle (`createDocument` / `closeDocument` / `hasDocument`), port handling, buffer-fetch on export, message routing. **Shrinks substantially** in this phase. |
+| Offscreen recorder (NEW) | `src/offscreen/recorder.ts` | `getDisplayMedia` call, `MediaRecorder` instance, ring buffer, codec capability check, port to SW (keepalive + on-demand buffer push), `MediaStreamTrack.onended` handler. |
+| Offscreen page (NEW) | `src/offscreen/index.html` | Minimal HTML referencing `recorder.ts` via ``. crxjs picks it up. |
+| Manifest | `manifest.json` | Swap `tabCapture` → `desktopCapture`. Add nothing else; `offscreen` is already declared. |
+| Vite config | `vite.config.ts` | Collapse to a clean `crx({manifest, contentScripts: {injectCss: false}})` + `rollupOptions.input` entry for offscreen HTML. Delete the entire 174-line `copy-offscreen` plugin block. |
+
+### Recommended Project Structure
+
+```
+repremium/
+├── manifest.json # swap tabCapture→desktopCapture
+├── vite.config.ts # collapse to ~30 lines
+├── src/
+│ ├── background/
+│ │ └── index.ts # shrinks: lifecycle + port + export
+│ ├── content/
+│ │ └── index.ts # untouched in Phase 1
+│ ├── popup/
+│ │ ├── index.html # untouched in Phase 1
+│ │ ├── index.ts # minor: drop dead REQUEST_PERMISSIONS path
+│ │ └── style.css # untouched
+│ ├── offscreen/ # NEW directory (replaces top-level offscreen/)
+│ │ ├── index.html # NEW:
+