Files
mokosh/.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
Mark 2e499d7387 docs(01): add Plans 01-08 / 01-09 / 01-10 (amended Phase 1 charter)
Plans cover the post-D-13 architecture and the auto-start UX charter
expansion that landed during 2026-05-16 UAT:

- Plan 01-08 — WebM remux via ts-ebml@3.0.2 + webm-muxer@5.1.4. Replaces
  the broken file-concat in mergeVideoSegments with a real single-EBML
  remux. Drives the 2 RED tests in tests/offscreen/webm-playback.test.ts
  to GREEN. Regenerates the canonical fixture against the remuxer.
  5 tasks (4 TDD + 1 operator empirical checkpoint), wave 1.

- Plan 01-09 — Whole-desktop constraint (displaySurface:'monitor',
  cursor:'always') + post-grant validation, chrome.action.onClicked
  direct toolbar invocation, chrome.action badge state machine
  (REC/OFF/ERROR), chrome.runtime.onStartup notification + recovery
  notification on onUserStoppedSharing, popup scoped to SAVE-only.
  17 new test assertions across 4 test files. smoke.sh updated to
  auto-select an entire screen. 5 tasks (4 TDD + 1 operator empirical
  checkpoint), wave 2 (depends on 01-08).

- Plan 01-10 — chrome.runtime.onInstalled welcome tab on first install
  via chrome.storage.local guard; vanilla welcome.html/ts/css bundle
  with single "Начать запись" button consuming install-time activation.
  Uses centralized Logger pattern. 4 tasks (3 TDD + 1 operator empirical
  checkpoint), wave 3 (depends on 01-09).

CONTEXT.md amendment block appended with 4 disambiguated decisions:
- D-14-remux: WebM remux supersedes D-13 file-concat
- D-15-display-surface: whole-desktop + cursor visibility lifted from
  Phase 5 deferral
- D-16-toolbar: toolbar onClicked + popup SAVE-only + badge state
  machine + onStartup/recovery notifications
- D-17-onboarding: welcome tab on first install (distinct from
  D-17-port-lifecycle from Option C)
The earlier D-17 port-lifecycle heading also renamed to hyphenated
form for cross-ref consistency.

Plan-check loop: 3 iterations (initial + 2 revisions). Iteration 1
surfaced 11 findings (2 BLOCKER + 6 WARNING + 3 INFO); all addressed
via revision iter 1 with checker-recommended fixes. Iteration 2
surfaced 3 derivative regressions (literal-string grep anchors from
the iter-1 fixes did not match live CONTEXT.md); all addressed in iter
2 with empirically-validated literals. Iteration 3 PASSED clean.

Validation: gsd-sdk frontmatter.validate + verify.plan-structure both
return valid=True for all 3 plans. Plan 01-08 Task 4 verify-chain
grep tested end-to-end against live CONTEXT.md (exit 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:19:22 +02:00

29 KiB
Raw Permalink Blame History

Phase 1: Stabilize Video Pipeline — Context

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

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

## 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 tabCapturedesktopCapture 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).
## 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).

<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-73videoBuffer: VideoChunk[] array + cleanupVideoBuffer(). Structural pattern (header retention + age trim) is kept; the implementation is moved to src/offscreen/recorder.ts.
  • src/shared/types.tsMessage, VideoChunk, VideoBufferResponse, OFFSCREEN_READY, RECORDING_ERROR. Reuse as-is; wire up OFFSCREEN_READY and RECORDING_ERROR in Phase 1.
  • src/shared/logger.tsLogger (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-178setupKeepalive() and its chrome.alarms registration.
  • src/background/index.ts:445-475loadChunkFromIndexedDB() 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 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.

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_PERMISSIONSstartVideoCapture 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)