# Phase 1: Stabilize Video Pipeline — Context

**Gathered:** 2026-05-15
**Status:** Ready for planning

<domain>
## Phase Boundary

Stabilize the video-capture pipeline so the extension continuously records the
operator's session into a ring buffer and produces a **playable** WebM on
demand. The phase ends when:

1. The video buffer is alive end-to-end with **one** authoritative source of
   truth for the recorder code (no parallel offscreen implementations).
2. The MediaRecorder lifecycle is correct (no shadowed bindings, predictable
   start/stop).
3. The buffer holds at most 30 s of footage at any moment AND concatenating
   the retained chunks produces a file that `ffprobe -v error` accepts as
   playable.
4. State (the buffer itself) survives the MV3 service-worker idle unload
   cycle without losing video.

Scope anchor from ROADMAP.md — Phase 2 (DOM/event privacy), Phase 3 (export
UX), Phase 4 (smoke verification), and Phase 5 (P1/P2 hardening) are
explicitly out of scope here.

</domain>

<decisions>
## Implementation 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
  <last_30sec.webm>` 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).

</decisions>

<specifics>
## Specific Ideas

- "Fail loud" on missing `getDisplayMedia` support — log a clear error to
  the SW console with the failing user-agent string. No silent fallback.
- The ffprobe verification gate is a real CLI invocation in the plan's
  acceptance, not a hand-wave — Phase 1 success requires producing a
  sample WebM and running ffprobe against it.
- The "stop sharing" recovery path: when the offscreen detects
  `MediaStreamTrack.onended`, it should clear the buffer, send a
  RECORDING_STOPPED message to SW, and stop attempting to record. The
  popup will re-prompt next time the user clicks save (Phase 3
  territory).
- The `OFFSCREEN_READY` Message type is already declared in
  `src/shared/types.ts:18` but never used. Phase 1 wires up an actual
  handshake so SW doesn't fire `START_RECORDING` before the offscreen
  listener is registered (this fixes audit item P1 #12 in passing).

</specifics>

<canonical_refs>
## Canonical References

**Downstream agents MUST read these before planning or implementing.**

### Project baseline (current state, will be amended by D-A1..D-A6)

- `.planning/PROJECT.md` — overall project context. Note that DEC-003,
  DEC-010, and the Constraints section will be amended by this phase.
- `.planning/REQUIREMENTS.md` §"Video" — REQ-video-ring-buffer wording
  changes in this phase.
- `.planning/ROADMAP.md` §"Phase 1" — phase definition and success
  criteria (Success Criterion #2 changes).
- `.planning/intel/SYNTHESIS.md` — entry point for ingested intel.
- `.planning/intel/decisions.md` §DEC-003, §DEC-009, §DEC-010.
- `.planning/intel/constraints.md` §CON-video-window, §CON-video-codec,
  §CON-webm-header-retention, §CON-tab-capture-binding (RETIRED),
  §CON-service-worker-keepalive (RETIRED), §CON-buffer-storage.

### Authoritative SPEC

- `Тз расширение фаза1.md` §2 (stack), §4.1 (video buffer parameters),
  §7 (manifest permissions), §8 row 3 (tab-capture binding — being
  AMENDED), §10 acceptance criteria #2, #3, #7.

### Audit

- `/home/parf/.claude/plans/dear-claude-there-is-snazzy-fox.md` — full
  P0 analysis. Key spots: P0-1 (offscreen duality + mediaRecorder
  shadow, `vite.config.ts:113`/`vite.config.ts:208`), P0-2 (WebM ring
  buffer + 200 ms timeslice issue), P0-3 (always-on capture), P1 #8
  (alarms keepalive ineffective), P1 #12 (OFFSCREEN_READY handshake
  missing).

### Chrome APIs (read before researching)

- MDN: `MediaDevices.getDisplayMedia()` — capture semantics, picker UX,
  MediaStreamTrack.onended.
- Chrome docs: `chrome.offscreen` API, especially `Reason.DISPLAY_MEDIA`
  (Chrome 116+).
- Chrome docs: Service worker lifecycle in MV3 — confirm `chrome.alarms`
  vs `chrome.runtime.connect` port behavior with respect to idle timer.
- WebM/Matroska EBML format — cluster boundary semantics; needed only if
  the D-13 fallback (restart-segments) activates.

</canonical_refs>

<code_context>
## Existing Code Insights

### Reusable assets

- `src/background/index.ts:16-73` — `videoBuffer: VideoChunk[]` array +
  `cleanupVideoBuffer()`. Structural pattern (header retention + age
  trim) is **kept**; the implementation is **moved** to
  `src/offscreen/recorder.ts`.
- `src/shared/types.ts` — `Message`, `VideoChunk`, `VideoBufferResponse`,
  `OFFSCREEN_READY`, `RECORDING_ERROR`. Reuse as-is; wire up
  `OFFSCREEN_READY` and `RECORDING_ERROR` in Phase 1.
- `src/shared/logger.ts` — `Logger` (SW context) and `ContentLogger`
  (content-script context). Add an `OffscreenLogger` or reuse `Logger`
  with prefix `[Offscreen:...]`.

### Established patterns

- Strict TS with `noUnusedLocals`/`noUnusedParameters` (`tsconfig.json`).
  New code must comply — no `as any`, no `@ts-ignore` (audit item P1
  #13 informs this).
- Russian comments inline are acceptable per project provenance
  (see `Тз расширение фаза1.md`, the user-facing UI strings in REQ-popup-ui).

### Files to DELETE in this phase

- `offscreen/index.ts` — dead code (audit P2 #18).
- `offscreen/index.html` — REPLACED by a crxjs-managed equivalent
  pointing at the new `src/offscreen/recorder.ts`. (Path stays the same
  in dist; source moves.)
- `vite.config.ts:11-184` — the entire `copy-offscreen` inline plugin
  block including the embedded JS string + IndexedDB plumbing. Replaces
  with a small note + the standard crx() invocation.
- `src/background/index.ts:171-178` — `setupKeepalive()` and its
  `chrome.alarms` registration.
- `src/background/index.ts:445-475` — `loadChunkFromIndexedDB()` and
  `openIndexedDB()` (the SW-side IDB plumbing).
- `src/background/index.ts:432-442` — the `VIDEO_CHUNK` message handler
  that expected Blob over `chrome.runtime.sendMessage` (the
  fundamentally broken path from audit P0 #2 — never reachable today
  but documented to delete).

### Files to CREATE

- `src/offscreen/recorder.ts` — new module owning getDisplayMedia +
  MediaRecorder + ring buffer + keepalive port.
- `src/offscreen/index.html` (or amended `offscreen/index.html`) —
  minimal HTML that loads the new module via crxjs.

### Files to MODIFY heavily

- `src/background/index.ts` — owns offscreen lifecycle, port handling,
  export-time buffer fetch. Most of the file shrinks.
- `manifest.json` — permissions swap, `offscreen` already present.
- `vite.config.ts` — collapse to a clean crxjs invocation, no inline
  plugin.

</code_context>

<deferred>
## Deferred Ideas

- **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.

</deferred>

---

## Amendment (Phase 01-stabilize-video-pipeline, 2026-05-16) — D-17-port-lifecycle

- **AMENDED-BY:** debug session `empty-archive-port-race` (Option C, resolved 2026-05-16)
- **Replaces nothing.** D-17 above stands as the original port-keepalive
  contract. This amendment narrows the port LIFECYCLE shape that was
  Claude's-discretion at Phase 1 plan time.
- **Background.** UAT Test 3 surfaced two coupled defects: 3× Uncaught
  "Attempting to use a disconnected port object" in the offscreen console
  starting at the 290 s pre-emptive reconnect mark, AND a silent
  empty-video archive shipping to the operator. Bisect proved the H1
  port race was introduced by Plan 01-04 (commit `b064a21`) and the H2
  silent-skip in `createArchive` was an upstream defect from commit
  `555eb05` that the port race amplified from latent to fatal. Full
  lineage and strategy rationale: `.planning/debug/resolved/empty-archive-port-race.md`.
- **Architectural commitments retired:**
  - The legacy 290 000 ms pre-emptive `setTimeout` reconnect (Pitfall 4)
    is RETIRED. Its race window between the synchronous `.disconnect()`
    and the `onDisconnect` handler firing was the proximate cause of
    H1 — see the bisect notes.
- **Architectural commitments added:**
  - **Port health probe (PING ↔ PONG).** The offscreen `PORT_PING_MS`
    interval doubles as a liveness probe; each PING expects a PONG echo
    from the SW. The offscreen tracks `missedPongs` and reconnects
    cleanly when the count exceeds `MAX_MISSED_PONGS = 3` (~75 s of
    unresponsive SW — well past Chrome's ~30 s idle threshold, so a
    real disconnect would already have surfaced its own
    `onDisconnect`). The SW echoes PONG on every PING via the
    onConnect-level message sink.
  - **Request-id'd REQUEST_BUFFER / BUFFER.** Every `REQUEST_BUFFER`
    carries a SW-generated `requestId` (crypto.randomUUID with
    Math.random fallback). The offscreen echoes the same `requestId`
    on the BUFFER response. The SW routes BUFFER → pending request via
    a module-level `Map<requestId, PendingBufferRequest>` — port-
    agnostic, so port replacement does not lose the response.
  - **Retry on port replacement.** Every `onConnect` (post-bootstrap)
    scans `pendingBufferRequests` and re-issues REQUEST_BUFFER on the
    fresh port with the SAME requestId. The offscreen posts BUFFER on
    the CURRENT `keepalivePort`, the sink matches by id, and the
    request resolves. This retires the H2 silent-drop class
    architecturally.
  - **Outer hard-timeout bumped 2 s → 10 s.** The legacy per-port
    `BUFFER_FETCH_TIMEOUT_MS = 2000` was too tight to cover a retry
    across a reconnect. The new outer budget covers EVERY retry across
    port replacements; the inner per-port round-trip is still
    ~100-200 ms.
  - **Operator-visible failure surface.** `createArchive` throws
    `EmptyVideoBufferError` when the video buffer is empty. `saveArchive`
    catches and emits `{type:'RECORDING_ERROR', error:'empty-video-buffer'}`
    via `chrome.runtime.sendMessage` for the popup, AND returns
    `{success:false, error}` in the direct-response path. Replaces the
    upstream silent-skip branch in `createArchive` that shipped a
    video-less archive in silence.
- **Pinning contracts added:**
  - `tests/offscreen/port-health-probe.test.ts` — pins the PING/PONG +
    request-id'd encode contract on the offscreen side (4 tests).
  - `tests/background/request-id-protocol.test.ts` — pins the SW-side
    request-id routing + retry + error-surface contract (5 tests).
  - `tests/background/port-lifecycle-continuous.test.ts` — continuous
    600 s end-to-end simulation: 12 ping/pong cycles + 2 forced
    reconnects + 3 SAVE_ARCHIVE round-trips, asserts no Uncaught and
    every BUFFER carries segments.
  - `tests/offscreen/port-reconnect-race.test.ts` (refactored): H1.b
    no longer pins the retired 290 s setTimeout path — it now pins
    the externally-disconnected-port → ping try/catch → reconnect path
    that the H1 fix delivers.

---

## Amendment (Phase 01-stabilize-video-pipeline, 2026-05-16 second batch) — Plans 01-08 / 01-09 / 01-10 charter

**Context.** On 2026-05-16 Phase 1 was REOPENED after UAT Test 3 re-attempt
revealed two compounding gaps in the 2026-05-15 closure:

1. D-13's concat-of-self-contained-WebM-segments architecture produces a
   multi-EBML-header file that mpv, Chrome's HTMLMediaElement, and
   ffprobe's `format=duration` all play as ~9.94 s (the first segment's
   Info.Duration only) instead of ~30 s. The "operator-confirmed clean
   Chrome playback" check from 2026-05-15 verified playback ran without
   freezing but did not measure total duration. SPEC §10 #7
   (`last_30sec.webm plays back in a browser`) is not actually satisfied.
   See `.planning/debug/d13-multi-ebml-concat-unplayable.md` for the
   byte-level EBML probe + library-survey + decision rationale.

2. The operator UX is unsatisfactory at the picker stage (tab-share
   footgun: "Share this tab instead" affordance) and at the activation
   stage (3 clicks per session: toolbar → popup open → Start button →
   picker). These were flagged in UAT Test 3's advisory section and
   deferred to Phase 5 at closure time; the reopen reclassifies them
   as Phase 1 deliverables because the same fix-cycle window is
   available.

The orchestrator's "add-more-plans" routing on 2026-05-16 adds three
plans (01-08, 01-09, 01-10) to close these gaps without re-litigating
the seven plans (01-01..01-07) that already landed.

**D-14-remux: WebM remux via ts-ebml + webm-muxer (supersedes D-13's file-concat).**

- **AMENDED-BY:** debug session `d13-multi-ebml-concat-unplayable`
  (root-cause confirmed 2026-05-16). Remediation lands via Plan 01-08.
- **Replaces partial.** D-13's recorder-side restart-segments lifecycle
  (the part that fixed the orphan-P-frame freeze observed in debug
  session `webm-playback-freeze`) is PRESERVED. Only D-13's file-concat
  merge on the SW save path is retired. Original D-13 text above
  remains intact for provenance.
- **Architectural commitments retired:** D-13 file-concat byte-stream
  merge in `src/background/index.ts:mergeVideoSegments` is RETIRED.
  Concat of self-contained WebM segments does NOT produce a single
  playable 30 s WebM in any common consumer-grade player.
- **Architectural commitments added:**
  - `remuxSegments(segments: VideoSegment[]): Promise<Blob>` in
    `src/background/webm-remux.ts` replaces `mergeVideoSegments`.
    Parses each segment via `ts-ebml`'s Decoder, walks the EBML tree,
    extracts each Cluster's SimpleBlock children (VP9 frame bytes +
    keyframe flag + cluster Timecode + block offset), and re-mixes
    into a single output WebM via `webm-muxer`'s `Muxer<ArrayBufferTarget>.addVideoChunkRaw(data, type, timestampUs)`.
    Each frame's timestamp is adjusted to be monotonic against a
    single global timeline.
  - **Library choice (LOCKED):** `ts-ebml` ^3.0.2 (parse) +
    `webm-muxer` ^5.1.4 (write). Both MIT, both actively maintained
    (last releases 2025-09-28 and 2025-07-02 respectively), both
    SW-compatible (no DOM globals on the hot path). Combined gzipped
    weight ~100 KB. Verified by `tests/background/webm-remux-deps.test.ts`.
  - **`createArchive` becomes async on the merge path.**
    `videoBlob = await remuxSegments(...)` replaces synchronous
    `mergeVideoSegments(...)`. The existing `EmptyVideoBufferError`
    throw (Option C, D-17-port-lifecycle amendment) is preserved AND extended to
    fire on a zero-byte remux output.
- **D-13 recorder-side lifecycle UNCHANGED.** The offscreen-side
  `src/offscreen/recorder.ts` keeps the restart-segments rotation
  (`SEGMENT_DURATION_MS = 10_000` ms, `MAX_SEGMENTS = 3`), which
  remains the canonical fix for the orphan-P-frame freeze from
  debug session `webm-playback-freeze`. The remux happens only on
  export, only in the SW.
- **Pinning contracts added (Plan 01-08):**
  - `tests/background/webm-remux-deps.test.ts` (2 tests — library
    presence + SW-compat).
  - `tests/background/webm-remux.test.ts` (5 tests — single-EBML
    invariant, monotonic timestamps, size sanity, ffprobe duration
    >= 25 s, frame-count tolerance).
  - `tests/offscreen/webm-playback.test.ts` lines 231-316 (the 2 RED
    tests landed by the d13 debug session) flip GREEN against the
    regenerated `tests/fixtures/last_30sec.webm`.
- **D-13 status:** PARTIALLY RETIRED. Recorder lifecycle preserved;
  file-concat merge retired. CONTEXT.md D-13 text above stands for
  historical provenance; downstream readers reaching D-13 must ALSO
  read this D-14-remux amendment.

**D-15-display-surface: Whole-desktop constraint + post-grant validation (replaces
operator-discretion picker).**

- **AMENDED-BY:** UAT Test 3 advisory finding (2026-05-16): "Share
  this tab instead" Chrome affordance is a one-click footgun that
  redirects the recording target mid-session. Reclassified from
  Phase 5 advisory to Phase 1 deliverable via Plan 01-09.
- **Replaces nothing structurally.** D-01's `getDisplayMedia` choice
  stands; this amendment narrows the constraints object passed to it.
- **Architectural commitments added:**
  - `getDisplayMedia` is invoked with constraints
    `{video: { displaySurface: 'monitor', cursor: 'always' }, audio: false}`.
    The `displaySurface: 'monitor'` constraint hints Chrome's picker
    to default to entire-screen selection; the `cursor: 'always'`
    constraint includes the operator's screen cursor in captured
    frames (this lifts the Phase 5 cursor-visibility refinement
    opportunistically — STATE.md Decisions entry [Phase 01-07-deferred-to-5]).
  - Post-grant validation reads `track.getSettings().displaySurface`.
    If the operator overrode the hint and picked a tab or window,
    the recorder tears down the stream, nulls `mediaStream`, and
    throws `Error('wrong-display-surface: got "<observed>"')`. The
    throw routes through `classifyCaptureError` (extended with a
    `'wrong-display-surface'` branch added to the `CaptureErrorCode`
    union) into the existing `RECORDING_ERROR` channel.
  - The `CaptureErrorCode` union grows by one member:
    `'wrong-display-surface'` (joins the existing seven).
- **Pinning contract added (Plan 01-09):**
  - `tests/offscreen/display-surface-constraint.test.ts` (4 tests:
    constraints applied; wrong-displaySurface emits RECORDING_ERROR;
    monitor-displaySurface does NOT emit; classifyCaptureError
    branch).

**D-16-toolbar: Toolbar-onClicked + popup-SAVE-only + badge state machine +
onStartup/recovery notifications (replaces popup-Start-button activation).**

- **AMENDED-BY:** UAT Test 3 ergonomics observation; operator-experience
  goal "per-session click count from 3 to 2" surfaced by user on
  2026-05-16. Reclassified from soft-deferred UX-polish to Phase 1
  deliverable via Plan 01-09.
- **Replaces nothing structurally.** The existing popup → SW
  `REQUEST_PERMISSIONS` → `startVideoCapture` flow stands; this
  amendment changes WHICH UI surface triggers REQUEST_PERMISSIONS.
- **Architectural commitments added:**
  - `chrome.action.onClicked` listener registered at SW module load.
    When `isRecording === false`, the handler invokes
    `startVideoCapture()` directly (the toolbar click counts as the
    user gesture for `getDisplayMedia`).
  - Dynamic `chrome.action.setPopup` swap: empty string (`''`) in
    OFF mode (so toolbar click triggers `onClicked`); pointing to
    `src/popup/index.html` in REC mode (so toolbar click opens the
    popup for SAVE). The SAVE-only popup retires the auto-prompt
    behavior in `src/popup/index.ts:checkPermissions`.
  - Badge state machine with three states. REC: green background
    (`#00C853`), text `'REC'`, tooltip "Recording — last 30 s
    buffered. Click to save." OFF: red background (`#D32F2F`),
    empty text, tooltip "Not recording. Click to start." ERROR:
    yellow background (`#F9A825`), text `'ERR'`, tooltip
    "Recording error. Click to try again."
  - `chrome.runtime.onStartup` fires `chrome.notifications.create`
    once per browser startup with a `'mokosh-startup-'`-prefixed id,
    inviting the operator to click to start.
  - `chrome.notifications.onClicked` validates the id prefix
    (`'mokosh-'`), drains the notification via `chrome.notifications.clear`,
    and triggers `startVideoCapture()` (notification click is also
    a user gesture under Chrome's documented activation model).
  - On `RECORDING_ERROR` receipt, the SW transitions badge to ERROR
    and emits a `'mokosh-recovery-'`-prefixed recovery notification
    inviting a fresh start.
  - `manifest.json` adds `notifications` to permissions. `default_popup`
    retained for the SAVE flow.
- **Popup role change.** `src/popup/index.ts` no longer auto-requests
  permissions on init. `checkPermissions` / `requestPermissions`
  functions are removed from the init path. Empty-state copy updated
  to direct operator to the toolbar icon.
- **Smoke harness update.** `smoke.sh`'s auto-select target string
  changes from the tab title to an entire-screen string (e.g.
  `"Entire screen"` or `"Screen 1"` depending on Chrome locale). If
  the auto-select fails under a non-English Chrome, a one-time
  manual pick is documented as the fallback.
- **Pinning contracts added (Plan 01-09):**
  - `tests/background/toolbar-action.test.ts` (4 tests: onClicked
    routing; setPopup dance).
  - `tests/background/badge-state-machine.test.ts` (4 tests: three
    badge states + RECORDING_ERROR transition).
  - `tests/background/onstartup-notification.test.ts` (4 tests:
    onStartup → create notification; onClicked → start recording;
    RECORDING_ERROR → recovery notification).

**D-17 (NEW — distinct from the port-lifecycle D-17 amendment above):
Onboarding welcome tab on first install.**

> NB: this is a SEPARATE D-17 marker, scoped to Plan 01-10. The earlier
> D-17 amendment block (port lifecycle) is also labeled D-17 in its
> historical context (it amends the original D-17 from the 2026-05-15
> decisions block above). To disambiguate downstream readers, this
> Plan 01-10 marker is referenced as D-17-onboarding in cross-citations;
> the port-lifecycle marker is referenced as D-17-port-lifecycle.

- **Trigger:** Plan 01-09's UX is operator-facing at runtime; Plan 01-10
  adds the install-time activation path so the operator's very first
  interaction with the extension is also one-click.
- **Architectural commitments added:**
  - `chrome.runtime.onInstalled` handler extended: on
    `details.reason === 'install'` AND `chrome.storage.local`
    `'onboarding-completed'` flag absent, open
    `src/welcome/welcome.html` via
    `chrome.tabs.create({url: chrome.runtime.getURL(...)})`. After the
    tab opens, set the `'onboarding-completed'` flag so future installs
    or reloads do NOT re-open the welcome.
  - New welcome page bundle: `src/welcome/welcome.{html,ts,css}`.
    Vanilla DOM per project style; single `'Начать запись'` button
    that sends `REQUEST_PERMISSIONS` (the install-time click counts
    as user gesture for `getDisplayMedia`).
  - `manifest.json` adds `web_accessible_resources` array with entry
    for `src/welcome/welcome.html` so `chrome.runtime.getURL` resolves.
    `'storage'` permission already present (no change needed).
  - `vite.config.ts` `rollupOptions.input` gains a `welcome:
    'src/welcome/welcome.html'` entry so crxjs bundles the page.
- **Pinning contract added (Plan 01-10):**
  - `tests/background/onboarding.test.ts` (3 tests: first install
    creates welcome tab; subsequent install does NOT; already-completed
    flag suppresses).

---

*Phase: 01-stabilize-video-pipeline*
*Context gathered: 2026-05-15*
*Amended: 2026-05-16 (debug session empty-archive-port-race, Option C — D-17-port-lifecycle narrowing)*
*Amended: 2026-05-16 (Plans 01-08 / 01-09 / 01-10 charter — D-14-remux WebM remux supersedes D-13 file-concat; D-15-display-surface whole-desktop + cursor; D-16-toolbar toolbar + badge + notifications; D-17-onboarding welcome tab)*
