---
phase: 01-stabilize-video-pipeline
plan: 10
type: execute
wave: 4
depends_on:
  - 01-09
  - 01-13
files_modified:
  - manifest.json
  - vite.config.ts
  - vite.test.config.ts
  - src/welcome/welcome.html
  - src/welcome/welcome.ts
  - src/welcome/welcome.css
  - src/welcome/copy.ts
  - src/welcome/welcome-tokens.css
  - src/background/index.ts
  - tests/background/onboarding.test.ts
  - tests/uat/extension-page-harness.ts
  - tests/uat/extension-page-harness.html
  - tests/uat/lib/harness-page-driver.ts
  - tests/uat/harness.test.ts
autonomous: false
requirements:
  - REQ-video-ring-buffer
tags:
  - onboarding
  - welcome
  - chrome.runtime.onInstalled
  - chrome.storage
  - web_accessible_resources
  - design-swap-in-ready
  - css-variables
  - i18n-keys
  - D-17-onboarding
  - D-02-welcome-layout
  - D-08-tagline
  - harness-A15-A17

must_haves:
  truths:
    - "On first install (chrome.runtime.onInstalled with reason 'install') AND chrome.storage.local.get('onboarding-completed') returns nothing, exactly ONE welcome tab opens automatically pointing at chrome.runtime.getURL('src/welcome/welcome.html'). After the tab opens, chrome.storage.local.set is called with {'onboarding-completed': true, 'installed-at': <Date.now()>}."
    - "On subsequent installs (reason 'update' or 'chrome_update') OR when 'onboarding-completed' is already true, NO welcome tab opens (chrome.tabs.create is NOT called)."
    - "The welcome page renders a hero (.welcome-hero slot) containing a placeholder mark, the D-08 tagline string (Russian primary + English subtitle), 2-3 explainer lines, a primary instruction pointing the operator at the toolbar icon (NOT a Start button — toolbar onClicked owns the start path per Plan 01-09 D-16-toolbar), and a privacy/safety footer."
    - "The welcome page contains NO hardcoded hex color values in welcome.css — every color is sourced from a --mks-* CSS custom property defined in welcome-tokens.css (placeholder palette today; swapped for tokens.css import in Plan 01-12)."
    - "All copy strings live in a single source-of-truth src/welcome/copy.ts exporting a const COPY map; welcome.ts reads keys via COPY['welcome.hero.title'] style access. When Plan 01-12 lands _locales/, the swap is a one-commit change of copy.ts reading chrome.i18n.getMessage(key)."
    - "Typography uses font-family: var(--mks-font-display, <system fallback>) and var(--mks-font-ui, <system fallback>) — when Plan 01-12 self-hosts Newsreader (or designer-picked Cyrillic-capable alternative per brand-decisions-v1-followup-display-font.md) the bundled face resolves; until then the system fallback renders."
    - "The welcome page references NO REQUEST_PERMISSIONS message type, NO chrome.runtime.sendMessage start path, NO duplicate getDisplayMedia trigger. Its CTA is informational — points operator at the toolbar icon (01-09 owns toolbar onClicked → picker)."
    - "Onboarding unit test covers BOTH first-install AND subsequent-install paths AND the storage-key contract via three synthesized onInstalled events + chrome.storage.local.get mock setups."
    - "UAT harness extends with three new assertions (A15, A16, A17). A15 = onboarding flag observability (chrome.storage.local 'onboarding-completed' is true AND 'installed-at' is a number AT LEAST ONCE during this session). A16 = subsequent-install does NOT re-open (post-A15 settle observation: no new welcome tabs spontaneously appear; chrome.tabs.query count delta is 0 over 2-second window). A17 = design-swap readiness invariant: welcome.html parses; .welcome-hero slot exists; welcome.css contains zero #hex literals (regex /#[0-9a-fA-F]{3,8}/ match count == 0); welcome.html contains ≥ 7 data-mokosh-key attributes; bundled welcome JS chunk contains substring 'COPY['."
    - "npm run test:uat reports 18/18 GREEN at plan close (existing 15 + A15 + A16 + A17). npm test (vitest) reports baseline 98 GREEN + 3 new onboarding tests GREEN = 101 GREEN; Tier-1 forbidden-strings inventory unchanged at 10 (no new test-hook surface introduced)."
  artifacts:
    - path: "src/welcome/welcome.html"
      provides: "Welcome page markup. Hero wrapped in <main class='welcome'><section class='welcome-hero'>...<section class='welcome-body'>...<footer class='welcome-footer'>... per D-02 Surface Kit §05 option A (Hero + Loom dial layout). Mark slot is <div class='welcome-hero__mark' role='presentation' data-mokosh-slot='mark'> — Plan 01-12 swaps to mokosh-lockup.svg."
      min_lines: 30
    - path: "src/welcome/welcome.ts"
      provides: "Vanilla DOM entry point. Imports COPY from './copy'; on DOMContentLoaded populates each [data-mokosh-key='<key>'] element's textContent from COPY. No event handlers — welcome tab is informational + read-only (per D-16-toolbar charter). Uses centralized Logger from src/shared/logger.ts (new Logger('Welcome'))."
      min_lines: 25
    - path: "src/welcome/welcome.css"
      provides: "Welcome page styling. Every color references var(--mks-*) from welcome-tokens.css; every font references var(--mks-font-*). Layout: max-width 720px (matches --mks-welcome-max-w in tokens.css), centered, hero+body+footer vertical stack."
      min_lines: 30
    - path: "src/welcome/welcome-tokens.css"
      provides: "Placeholder token palette mirroring tokens.css semantic names — --mks-surface, --mks-surface-raised, --mks-fg-1, --mks-fg-2, --mks-rec, --mks-success, --mks-border, --mks-font-display, --mks-font-ui, --mks-welcome-max-w. Values are engineering-grade neutrals (warm-off-white / near-black / system serif / system sans). Plan 01-12 replaces this file with @import of canonical tokens.css."
      min_lines: 25
    - path: "src/welcome/copy.ts"
      provides: "Single source-of-truth copy map. Exports const COPY: Readonly<Record<string, string>> keyed by stable identifiers (welcome.hero.title, welcome.hero.tagline.ru, welcome.hero.tagline.en, welcome.body.explainer.line1, welcome.body.explainer.line2, welcome.body.cta.toolbar, welcome.footer.privacy). Plan 01-12 either swaps to chrome.i18n.getMessage post _locales/ landing OR hot-swaps the literal strings to brand-team final 8 strings (Brief §02)."
      min_lines: 20
    - path: "src/background/index.ts"
      provides: "openWelcomeIfFirstInstall(details) helper added (placed below ensureOffscreen). chrome.runtime.onInstalled handler extended to call helper AFTER existing IDB cleanup + initialize(). Helper: early-return on non-install reason; await chrome.storage.local.get; early-return if flag already true; chrome.tabs.create + chrome.storage.local.set. Fire-and-forget invocation wrapped in .catch(...)."
      contains: "openWelcomeIfFirstInstall"
    - path: "manifest.json"
      provides: "web_accessible_resources array added (was absent). Single entry {resources:['src/welcome/welcome.html'], matches:['<all_urls>']}. storage permission already present at line 11; no change. No new permissions."
      contains: "web_accessible_resources"
    - path: "vite.config.ts"
      provides: "rollupOptions.input gains welcome: 'src/welcome/welcome.html' alongside existing offscreen entry."
      contains: "welcome:"
    - path: "vite.test.config.ts"
      provides: "Mirror of vite.config.ts: rollupOptions.input gains welcome entry so dist-test/ also carries the page (harness A15-A17 navigates fresh-install Chrome with test build loaded)."
      contains: "welcome:"
    - path: "tests/background/onboarding.test.ts"
      provides: "Three RED→GREEN tests pinning the onInstalled contract. Test A: install reason + empty storage → exactly one tabs.create with welcome URL + storage.set with onboarding-completed:true + installed-at:<number>; additionally asserts chrome.storage.local.get called with EXACT key 'onboarding-completed' (storage-schema cross-version-compat pin, preserved from prior plan I-02 fix). Test B: update reason → zero tabs.create. Test C: install + flag already set → zero tabs.create."
      min_lines: 120
    - path: "tests/uat/extension-page-harness.ts"
      provides: "Three new page-side assertion methods on window.__mokoshHarness: assertA15 (onboarding flag observability), assertA16 (subsequent-install does NOT re-open — settle-window check), assertA17 (design-swap-readiness — welcome.html parse + .welcome-hero slot + welcome.css fetch + zero-hex regex + data-mokosh-key count + COPY[ substring in bundled JS)."
      min_lines: 150
    - path: "tests/uat/lib/harness-page-driver.ts"
      provides: "Three new host-side driver wrappers: driveA15, driveA16, driveA17. Standard page.evaluate pattern matching driveA14."
      min_lines: 50
    - path: "tests/uat/harness.test.ts"
      provides: "Drivers list extended with A15, A16, A17 entries. FORBIDDEN_HOOK_STRINGS unchanged. Total assertions become 18 (A0 + A1..A14 + A15 + A16 + A17)."
      contains: "A15"
  key_links:
    - from: "src/background/index.ts onInstalled handler (line 724+)"
      to: "openWelcomeIfFirstInstall → chrome.tabs.create"
      via: "Fire-and-forget invocation after initialize(); helper awaits chrome.storage.local.get + chrome.tabs.create + chrome.storage.local.set"
      pattern: "openWelcomeIfFirstInstall"
    - from: "manifest.json web_accessible_resources"
      to: "src/welcome/welcome.html"
      via: "MV3 web_accessible_resources array with matches:['<all_urls>']"
      pattern: "src/welcome/welcome.html"
    - from: "src/welcome/welcome.ts init"
      to: "src/welcome/copy.ts COPY map"
      via: "import { COPY } from './copy'; populates [data-mokosh-key='<key>'] text from COPY[key]"
      pattern: "data-mokosh-key"
    - from: "src/welcome/welcome.css"
      to: "src/welcome/welcome-tokens.css"
      via: "Implicit CSS cascade — welcome.html links both stylesheets; welcome.css references --mks-* vars declared in welcome-tokens.css"
      pattern: "var(--mks-"
    - from: "tests/uat/harness.test.ts drivers list"
      to: "tests/uat/lib/harness-page-driver.ts driveA15/A16/A17"
      via: "Driver tuple appended to drivers ReadonlyArray, same idiom as driveA14"
      pattern: "driveA15"
    - from: "tests/uat/extension-page-harness.ts window.__mokoshHarness"
      to: "assertA15/A16/A17 methods"
      via: "Added to existing __mokoshHarness surface alongside assertA1..A14"
      pattern: "assertA15"
---

<objective>
First-install operator-friendly activation: when the extension is installed (Chrome Web Store one day, or Load Unpacked today), open a welcome tab automatically pointing at src/welcome/welcome.html. The page renders a hero + tagline + 2-3 explainer lines + a CTA instructing the operator to click the toolbar icon to begin recording. Subsequent installs/updates do NOT re-open.

This complements Plan 01-09's runtime activation paths (toolbar onClicked → picker; onStartup notification at browser launch) by adding the install-time activation path. The welcome tab does NOT trigger recording itself — start remains a SINGLE path through chrome.action.onClicked in idle mode (D-16-toolbar). The welcome tab is informational + read-only; CTA copy directs the operator at the toolbar.

**Design-swap-in-ready architecture (the key directive).** Plan 01-12 has not yet landed; the designer follow-up on --mks-font-display Cyrillic gap is still open (brand-decisions-v1-followup-display-font.md). Plan 01-10 ships TODAY with engineering placeholders, architected so the Plan 01-12 swap is a small commit: every color via --mks-* CSS custom property in a separate welcome-tokens.css file (Plan 01-12 replaces with @import of tokens.css); every copy string via src/welcome/copy.ts map (Plan 01-12 swaps to chrome.i18n.getMessage post _locales/); every typography choice via var(--mks-font-*) with system fallback (Plan 01-12 self-hosts WOFF2 + drops the fallback dependency).

**Harness integration (A15-A17).** Extend the Plan 01-13 harness with three new assertions covering: onboarding flag observability (A15), subsequent-install does NOT re-open (A16), design-swap-readiness invariant (A17). Drivers register alongside driveA1..driveA14 in tests/uat/harness.test.ts. No new forbidden-hook strings — the Tier-1 grep gate inventory stays at 10.

Output:
- src/welcome/welcome.{html,ts,css} + src/welcome/welcome-tokens.css + src/welcome/copy.ts — 5 new files for the design-swap-in-ready welcome page.
- src/background/index.ts — openWelcomeIfFirstInstall helper + onInstalled wiring.
- manifest.json — web_accessible_resources array entry for the welcome page.
- vite.config.ts + vite.test.config.ts — rollupOptions.input gains welcome entry in BOTH bundles.
- tests/background/onboarding.test.ts — 3 RED→GREEN unit tests.
- tests/uat/extension-page-harness.{html,ts} + tests/uat/lib/harness-page-driver.ts + tests/uat/harness.test.ts — A15+A16+A17 harness assertions.

Cites: D-17-onboarding (CONTEXT.md line 537+), D-02 (welcome layout — brand-decisions-v1.md), D-08 (tagline — brand-decisions-v1.md), D-03 (Sober voice register — brand-decisions-v1.md), D-16-toolbar (toolbar owns start — 01-09-SUMMARY.md).
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
@.planning/phases/01-stabilize-video-pipeline/01-09-SUMMARY.md
@.planning/phases/01-stabilize-video-pipeline/01-11-SUMMARY.md
@.planning/phases/01-stabilize-video-pipeline/01-13-SUMMARY.md
@.planning/intel/brand-decisions-v1.md
@.planning/intel/brand-decisions-v1-followup-display-font.md
@.planning/intel/design-incoming/system/bundle/mokosh-handoff/tokens.css
@.planning/debug/resolved/01-09-save-stops-recording.md
@src/background/index.ts
@src/popup/index.html
@src/popup/index.ts
@src/shared/logger.ts
@manifest.json
@vite.config.ts
@vite.test.config.ts
@tests/background/onstartup-notification.test.ts
@tests/uat/extension-page-harness.html
@tests/uat/extension-page-harness.ts
@tests/uat/lib/harness-page-driver.ts
@tests/uat/harness.test.ts
@tests/background/no-test-hooks-in-prod-bundle.test.ts

<interfaces>
Key Chrome API surfaces and project conventions the executor needs.

1. chrome.runtime.onInstalled
=============================

Signature (from @types/chrome):
  chrome.runtime.onInstalled.addListener(
    callback: (details: chrome.runtime.InstalledDetails) => void): void
  interface InstalledDetails {
    reason: 'install' | 'update' | 'chrome_update' | 'shared_module_update';
    previousVersion?: string;
    id?: string;
  }

CURRENT handler in src/background/index.ts (line 724+, post-01-09):
  chrome.runtime.onInstalled.addListener((details) => {
    logger.log('Extension installed/updated:', details.reason);
    try {
      indexedDB.deleteDatabase('VideoRecorderDB');
      logger.log('Cleaned up orphaned VideoRecorderDB (if present)');
    } catch (e) {
      logger.warn('IDB cleanup failed:', e);
    }
    initialize();
  });

This plan EXTENDS the handler by appending a fire-and-forget call to
openWelcomeIfFirstInstall(details) AFTER initialize(). The existing
synchronous handler stays synchronous; the helper is async-IIFE-style
with its own try/catch.

2. chrome.storage.local
=======================

Signature:
  chrome.storage.local.get(key: string): Promise<{[key: string]: unknown}>
  chrome.storage.local.set(items: {[key: string]: unknown}): Promise<void>

Used pattern (helper):
  const stored = await chrome.storage.local.get(ONBOARDING_FLAG);
  if (stored[ONBOARDING_FLAG] === true) { return; }
  // open tab ...
  await chrome.storage.local.set({
    [ONBOARDING_FLAG]: true,
    'installed-at': Date.now(),
  });

storage permission is ALREADY in manifest.json:11. No change needed.

3. chrome.tabs.create
=====================

Signature:
  chrome.tabs.create({url: string, active?: boolean}): Promise<chrome.tabs.Tab>

For an extension-internal URL: chrome.runtime.getURL('src/welcome/welcome.html')
returns chrome-extension://<id>/src/welcome/welcome.html. This URL is
ONLY navigable if the path is declared in web_accessible_resources
(MV3 requirement).

4. manifest.json web_accessible_resources (MV3)
================================================

Current manifest.json HAS NO web_accessible_resources block (verified
via Read at plan time). This plan adds one.

Shape (JSON):
  "web_accessible_resources": [
    {
      "resources": ["src/welcome/welcome.html"],
      "matches": ["<all_urls>"]
    }
  ]

Insertion point: after "host_permissions" block (lines 15-17), before
"background" block. Verify storage permission still present at line 11.

5. vite.config.ts rollupOptions.input
======================================

Current state (vite.config.ts:52-55):
  rollupOptions: {
    input: { offscreen: 'src/offscreen/index.html' },
  },

Add welcome entry:
  rollupOptions: {
    input: {
      offscreen: 'src/offscreen/index.html',
      welcome: 'src/welcome/welcome.html',
    },
  },

Mirror in vite.test.config.ts at the same key path (line 82-92) so
dist-test/ also emits the welcome page. The harness A15/A16/A17
navigates against the test bundle.

6. src/welcome/welcome.html structure
======================================

  <!DOCTYPE html>
  <html lang="ru">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title data-mokosh-key="welcome.page.title">Добро пожаловать в Mokosh</title>
    <link rel="stylesheet" href="welcome-tokens.css">
    <link rel="stylesheet" href="welcome.css">
  </head>
  <body>
    <main class="welcome">
      <section class="welcome-hero" aria-labelledby="welcome-title">
        <div class="welcome-hero__mark" role="presentation" data-mokosh-slot="mark">
          <span class="welcome-hero__mark-placeholder" aria-hidden="true">Mokosh</span>
        </div>
        <h1 id="welcome-title" class="welcome-hero__title" data-mokosh-key="welcome.hero.title"></h1>
        <p class="welcome-hero__tagline-ru" data-mokosh-key="welcome.hero.tagline.ru"></p>
        <p class="welcome-hero__tagline-en" data-mokosh-key="welcome.hero.tagline.en"></p>
      </section>
      <section class="welcome-body">
        <p class="welcome-body__line" data-mokosh-key="welcome.body.explainer.line1"></p>
        <p class="welcome-body__line" data-mokosh-key="welcome.body.explainer.line2"></p>
        <p class="welcome-body__cta" data-mokosh-key="welcome.body.cta.toolbar"></p>
      </section>
      <footer class="welcome-footer">
        <p class="welcome-footer__privacy" data-mokosh-key="welcome.footer.privacy"></p>
      </footer>
    </main>
    <script type="module" src="welcome.ts"></script>
  </body>
  </html>

7. src/welcome/copy.ts shape
=============================

  // src/welcome/copy.ts — single source-of-truth for welcome-page copy.
  // Plan 01-10 D-17-onboarding ships with placeholder Russian strings
  // per D-03 Sober voice register. Plan 01-12 swaps to chrome.i18n.getMessage
  // (post _locales/) OR hot-swaps to brand-team final 8 strings (Brief §02).
  // STABLE KEYS — do NOT rename; Plan 01-12 binds to these.
  //
  // D-08 tagline locked: "«Тридцать секунд назад, всегда под рукой.»" +
  // "Thirty seconds ago, always at hand."

  export const COPY: Readonly<Record<string, string>> = Object.freeze({
    'welcome.page.title': 'Добро пожаловать в Mokosh',
    'welcome.hero.title': 'Mokosh',
    'welcome.hero.tagline.ru': '«Тридцать секунд назад, всегда под рукой.»',
    'welcome.hero.tagline.en': 'Thirty seconds ago, always at hand.',
    'welcome.body.explainer.line1':
      'Mokosh непрерывно записывает последние 30 секунд экрана и 10 минут ' +
      'логов вашего браузера.',
    'welcome.body.explainer.line2':
      'Когда возникает баг, вы одним кликом сохраняете архив для службы ' +
      'поддержки. Данные не отправляются никуда — только локально.',
    'welcome.body.cta.toolbar':
      'Чтобы начать запись, нажмите иконку AI Call Recorder на панели ' +
      'инструментов браузера (правый верхний угол).',
    'welcome.footer.privacy':
      'Mokosh не отправляет данные на серверы. Архив создаётся ' +
      'локально по вашему запросу и остаётся на вашем компьютере.',
  });

8. src/welcome/welcome-tokens.css shape (placeholder)
======================================================

  /* welcome-tokens.css — Plan 01-10 D-17-onboarding placeholder palette.
   * Mirrors tokens.css semantic names. Plan 01-12 replaces with
   * @import url('./tokens.css'); OR direct deletion + canonical link in
   * welcome.html. Variable NAMES match tokens.css exactly; only VALUES
   * differ. */

  :root {
    --mks-surface: #fafafa;
    --mks-surface-raised: #ffffff;
    --mks-surface-sunken: #ececec;
    --mks-border: #e0e0e0;
    --mks-fg-1: #181b2a;
    --mks-fg-2: #5b5f76;
    --mks-fg-3: #7a7e94;
    --mks-rec: #b2543d;
    --mks-success: #5a7349;
    --mks-font-display:
      'Iowan Old Style', 'Apple Garamond', 'Baskerville',
      'Times New Roman', 'Droid Serif', Times, serif;
    --mks-font-ui:
      -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
      'Helvetica Neue', Arial, sans-serif;
    --mks-welcome-max-w: 720px;
    --mks-space-4: 16px;
    --mks-space-8: 32px;
    --mks-space-12: 48px;
  }

NOTE on welcome-tokens.css hex literals: this file IS allowed to contain
hex literals — it IS the engineering-grade placeholder palette source.
Plan 01-12 replaces this file entirely; until then the hex values
provide the actual color rendering. The A17 grep gate excludes
welcome-tokens.css from its scan; only welcome.css must be hex-literal-free.

9. src/welcome/welcome.ts shape (filter-pipeline form; no `continue`)
=====================================================================

  import { Logger } from '../shared/logger';
  import { COPY } from './copy';

  const logger = new Logger('Welcome');

  function populateCopy(): void {
    const els = Array.from(document.querySelectorAll<HTMLElement>('[data-mokosh-key]'));
    const pairs = els
      .map((el) => ({ el, key: el.getAttribute('data-mokosh-key') }))
      .filter((p): p is { el: HTMLElement; key: string } =>
        typeof p.key === 'string')
      .map((p) => ({ ...p, value: COPY[p.key] }))
      .filter((p): p is { el: HTMLElement; key: string; value: string } =>
        typeof p.value === 'string');
    for (const { el, value } of pairs) {
      if (el.tagName === 'TITLE') {
        document.title = value;
      } else {
        el.textContent = value;
      }
    }
    const missing = els.length - pairs.length;
    if (missing > 0) {
      logger.warn('populateCopy: ' + String(missing) +
        ' [data-mokosh-key] elements had missing COPY entries');
    }
  }

  function init(): void {
    populateCopy();
    logger.log('welcome page ready');
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }

10. src/welcome/welcome.css invariant
======================================

KEY INVARIANT (A17 contract): regex /#[0-9a-fA-F]{3,8}/ must match ZERO
times in welcome.css (NOT welcome-tokens.css; that one is the source).
Every color = var(--mks-*); every font = var(--mks-font-*). Layout: max-width
via var(--mks-welcome-max-w). BEM-ish class names match the HTML.

11. Harness A15-A17 architecture
=================================

Pattern mirrors A14 (most recent addition):
  - Page-side: assertA15/A16/A17 in extension-page-harness.ts (added
    to window.__mokoshHarness global surface).
  - Host-side: driveA15/A16/A17 in lib/harness-page-driver.ts —
    standard page.evaluate wrapper.
  - Orchestrator: registered in drivers array in harness.test.ts.

A15 — onboarding flag observability:
  Approach: read chrome.storage.local.get(['onboarding-completed',
  'installed-at']) and assert both keys present + correct types.
  Under puppeteer.launch({enableExtensions: [DIST_TEST_DIR]}) with a
  fresh user-data-dir, the extension load IS the install event; the
  helper fires; storage gets the flag set. If the harness's
  launchHarnessBrowser uses a persistent user-data-dir between runs,
  the flag may already be set from a previous run — still GREEN
  (the contract is "first install set the flag", not "this run set
  the flag").

A16 — subsequent-install does NOT re-open:
  Approach: snapshot chrome.tabs.query({}) tab URLs BEFORE a 2-second
  settle window. After the settle, snapshot again. Assert no new tab
  URLs containing 'src/welcome/welcome.html' appeared. Combined with
  A15's flag-is-set assertion: A15 proves install handler ran AT LEAST
  ONCE; A16 proves the handler isn't spuriously re-firing.

A17 — design-swap-readiness invariant:
  Fetch chrome-extension://<id>/src/welcome/welcome.html via
  fetch(chrome.runtime.getURL('src/welcome/welcome.html')).
  Same for src/welcome/welcome.css and src/welcome/copy.ts (the
  bundled chunk path; locate via parsing the <script src> attribute
  in welcome.html — Vite's hashed chunk filename).
  Assertions:
    1. welcome.html parses; document.querySelector('.welcome-hero') !== null.
    2. welcome.css text content: NO match for /#[0-9a-fA-F]{3,8}/.
    3. welcome.css text content: AT LEAST 5 occurrences of 'var(--mks-'.
    4. welcome.html text: AT LEAST 7 occurrences of 'data-mokosh-key='.
    5. The bundled welcome.js chunk text content: contains 'COPY['
       substring (proves COPY-map indexing pattern survives the build).

No new forbidden-strings entries — A15-A17 use existing chrome.tabs.query
+ chrome.storage.local.get + fetch on public surfaces (web_accessible_resources).
The Tier-1 grep gate inventory stays at 10.

12. Centralized Logger pattern
===============================

src/shared/logger.ts exports Logger class. Welcome page uses
new Logger('Welcome'). If the executor prefers, they MAY add a
WelcomeLogger class mirroring OffscreenLogger / ContentLogger pattern.

13. Centralized chrome stub pattern for unit tests
===================================================

tests/background/onstartup-notification.test.ts exemplifies the chrome
stub used across Plan 01-09 unit tests. Copy the buildBgStub factory +
extend with:
  - chrome.tabs.create: vi.fn().mockResolvedValue({id:99, url:'x'})
  - chrome.storage.local.{get,set} mocks
  - chrome.runtime.onInstalled rigged with _callbacks array (same idiom
    as onStartup at line 24).

Per 01-09 SUMMARY deviation 4: every new listener registration in SW
MUST be in try/catch. The onInstalled handler is ALREADY registered
(line 724); this plan ONLY extends its body. The new openWelcomeIfFirstInstall
invocation gets its own try/catch via fire-and-forget .catch(...) chain.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: RED unit tests — onInstalled install creates welcome tab; update does NOT; already-completed flag suppresses; storage key contract.</name>
  <read_first>
    - tests/background/onstartup-notification.test.ts lines 1-235 (chrome stub scaffold + reset+inject pattern)
    - src/background/index.ts lines 724-737 (existing onInstalled handler)
    - manifest.json (confirms storage permission already at line 11)
  </read_first>
  <files>tests/background/onboarding.test.ts</files>
  <behavior>
    Three RED tests pinning truths 1 + 2 + 8 from must_haves:

    Test A — first install + empty storage:
      chrome.storage.local.get.mockResolvedValue({});
      Fire onInstalled with {reason: 'install'}.
      Assert:
        1. chrome.tabs.create called exactly once with object whose
           url contains 'src/welcome/welcome.html'.
        2. chrome.storage.local.set called with object containing
           {'onboarding-completed': true} AND {'installed-at': <number>}.
        3. chrome.storage.local.get called with EXACT key
           'onboarding-completed' (pins schema for cross-version compat;
           preserves the I-02 lesson from prior plan draft).

    Test B — subsequent install (reason='update'):
      chrome.storage.local.get.mockResolvedValue({});
      Fire onInstalled with {reason: 'update'}.
      Assert chrome.tabs.create NOT called AND chrome.storage.local.set
      NOT called.

    Test C — install but flag already set:
      chrome.storage.local.get.mockResolvedValue(
        {'onboarding-completed': true});
      Fire onInstalled with {reason: 'install'}.
      Assert chrome.tabs.create NOT called.
  </behavior>
  <action>
    1. Create tests/background/onboarding.test.ts.
    2. Copy the buildBgStub factory + types from
       tests/background/onstartup-notification.test.ts lines 59-102.
       Extend the stub with:
         - chrome.tabs.create: vi.fn().mockResolvedValue({id:99, url:'x'})
         - chrome.storage = { local: { get: vi.fn(), set: vi.fn() } }
         - chrome.runtime.onInstalled = { addListener: vi.fn(),
             _callbacks: [] }, with addListener.mockImplementation
             pushing into _callbacks
    3. Three tests inside describe('Plan 01-10 onboarding contract', ...)
       — beforeEach { vi.resetModules() }; pattern:
         const stub = buildBgStub();
         stub.storage.local.get.mockResolvedValue(<per test>);
         (globalThis as ...).chrome = stub;
         (globalThis as ...).indexedDB = { deleteDatabase: vi.fn() };
         await import('../../src/background/index');
         const cb = stub.runtime.onInstalled._callbacks[0];
         expect(cb).toBeTypeOf('function');
         cb({ reason: '<per test>' });
         for (let i = 0; i < 16; i++) { await Promise.resolve(); }
         <assertions per behavior>
    4. Run npx vitest run tests/background/onboarding.test.ts — all 3
       MUST be RED (openWelcomeIfFirstInstall helper does not exist;
       chrome.tabs.create never called).
    5. DO NOT modify src/background/index.ts. RED only.

    Style/naming: full-word variable names; no `as any` (use the typed
    extension); no `continue` in test loops (drain via for-i).

    Commit (using Mark's em-dash + Co-Authored-By style):
      test(01-10): wave-0 task-1 — RED onboarding tests (3 failing, pins install/update/flag + storage-key)

      Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  </action>
  <verify>
    <automated>npx vitest run tests/background/onboarding.test.ts 2>&amp;1 | grep -E "3 failed" || (echo "expected 3 failed"; exit 1)</automated>
  </verify>
  <acceptance_criteria>
    - tests/background/onboarding.test.ts exists with exactly 3 tests.
    - All 3 tests RED.
    - npx tsc --noEmit exit 0.
    - Baseline (98 passing after 01-13) preserved; new state 98 + 3 RED = 101 total.
    - No changes to src/background/index.ts.
  </acceptance_criteria>
  <done>3 RED tests pin onInstalled welcome-tab + storage-flag contract; baseline preserved; commit landed.</done>
</task>

<task type="auto">
  <name>Task 2: Welcome page bundle (welcome.html + welcome.ts + welcome.css + welcome-tokens.css + copy.ts) + Vite entries in both bundles + manifest web_accessible_resources.</name>
  <read_first>
    - src/popup/index.html (style analog — lang=ru + module script pattern)
    - src/popup/style.css (sizing reference — DO NOT inherit hex literals)
    - vite.config.ts lines 52-55 (rollupOptions.input current shape)
    - vite.test.config.ts lines 82-92 (mirrored input shape)
    - manifest.json (insertion point for web_accessible_resources)
    - .planning/intel/brand-decisions-v1.md §D-02 (welcome layout: Hero + Loom dial)
    - .planning/intel/brand-decisions-v1.md §D-08 (tagline RU+EN)
    - .planning/intel/brand-decisions-v1.md §D-03 (Sober voice register)
    - .planning/intel/design-incoming/system/bundle/mokosh-handoff/tokens.css (semantic-name reference)
    - src/shared/logger.ts (Logger class shape)
  </read_first>
  <files>
    src/welcome/welcome.html, src/welcome/welcome.ts, src/welcome/welcome.css,
    src/welcome/welcome-tokens.css, src/welcome/copy.ts,
    vite.config.ts, vite.test.config.ts, manifest.json
  </files>
  <action>
    1. Create src/welcome/copy.ts per interfaces §7. Placeholder Russian
       strings per D-03 Sober (short, declarative, period-terminated, no
       superlatives). D-08 tagline strings LOCKED verbatim. Keys STABLE.

    2. Create src/welcome/welcome-tokens.css per interfaces §8. Comment
       header cites Plan 01-12 as the replacement moment + brand-decisions-v1.md
       → tokens.css as canonical source. Use EXACT --mks-* names from
       tokens.css §Surface / §Foreground / §Accents / §Type / §Layout.

    3. Create src/welcome/welcome.html per interfaces §6 — semantic
       sections (welcome-hero / welcome-body / welcome-footer); every
       textContent target carries data-mokosh-key=stable-key; mark slot
       is data-mokosh-slot=mark (Plan 01-12 swap target); <title> also
       has data-mokosh-key='welcome.page.title'. Two <link> stylesheet
       tags: welcome-tokens.css BEFORE welcome.css (tokens cascade first).

    4. Create src/welcome/welcome.ts per interfaces §9. Use the
       filter-pipeline form (no `continue`). Import Logger from
       '../shared/logger' + COPY from './copy'. document.readyState
       guard: init immediately if already loaded.

    5. Create src/welcome/welcome.css per interfaces §10. KEY INVARIANT:
       grep -E '#[0-9a-fA-F]{3,8}' src/welcome/welcome.css MUST exit 1
       (no matches). Every color = var(--mks-*). BEM-ish class names
       matching the HTML.

    6. Update vite.config.ts lines 52-55 — add welcome entry alongside
       offscreen:
         rollupOptions: {
           input: {
             offscreen: 'src/offscreen/index.html',
             welcome: 'src/welcome/welcome.html',
           },
         },

    7. Update vite.test.config.ts lines 82-92 — mirror the welcome entry
       alongside extension_page_harness:
         rollupOptions: {
           input: {
             extension_page_harness: 'tests/uat/extension-page-harness.html',
             welcome: 'src/welcome/welcome.html',
           },
         },

    8. Update manifest.json — insert web_accessible_resources AFTER
       host_permissions block (lines 15-17) BEFORE background block
       (lines 18-21). Verify storage still in permissions array line 11.
       The block to insert (proper JSON, comma after host_permissions,
       comma after new block):

         "web_accessible_resources": [
           {
             "resources": ["src/welcome/welcome.html"],
             "matches": ["<all_urls>"]
           }
         ],

    9. Run npm run build — exit 0; verify:
       - dist/src/welcome/welcome.html exists
       - dist/manifest.json carries the web_accessible_resources block
       - grep -E '#[0-9a-fA-F]{3,8}' dist/src/welcome/welcome.css exits 1

    10. Run npm run build:test — exit 0; verify dist-test/src/welcome/welcome.html
        exists.

    11. Run npx tsc --noEmit — exit 0.

    12. Run npx vitest run — baseline preserved (3 RED from Task 1 stay
        RED; 98 others stay GREEN).

    Style/naming: full-word names; no `as any`; no `continue` in welcome.ts.
    welcome.html: lang="ru" matches popup precedent. Russian strings live
    ONLY in copy.ts.

    Commit:
      feat(01-10): wave-1 task-2 — welcome page bundle + Vite entries + web_accessible_resources — design-swap-ready (D-02 + D-08 + D-03; placeholders for D-04 + D-05 per Plan 01-12)

      Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  </action>
  <verify>
    <automated>npm run build &amp;&amp; npx tsc --noEmit &amp;&amp; test -f dist/src/welcome/welcome.html &amp;&amp; grep -q web_accessible_resources dist/manifest.json &amp;&amp; ! grep -E '#[0-9a-fA-F]{3,8}' dist/src/welcome/welcome.css &amp;&amp; npm run build:test &amp;&amp; test -f dist-test/src/welcome/welcome.html</automated>
  </verify>
  <acceptance_criteria>
    - 5 new files exist under src/welcome/.
    - welcome.css ZERO #hex literals (grep -E '#[0-9a-fA-F]{3,8}' exits 1).
    - welcome.html ≥ 7 data-mokosh-key attributes (grep -o count).
    - welcome.ts imports Logger + COPY; NO `as any`; NO `continue`.
    - copy.ts exports frozen Record with stable keys; D-08 tagline verbatim.
    - vite.config.ts + vite.test.config.ts both have welcome rollup entry.
    - manifest.json has web_accessible_resources block; storage permission at line 11.
    - npm run build + npm run build:test clean.
    - npx tsc --noEmit exit 0.
    - Vitest baseline: 3 RED from Task 1 still RED; 98 others GREEN.
  </acceptance_criteria>
  <done>Welcome page bundle staged + Vite picks both bundles + manifest declares page accessible + design-swap-readiness invariants hold in BUILT artifact.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: GREEN — openWelcomeIfFirstInstall helper + onInstalled wiring in src/background/index.ts; flips Task 1 tests GREEN.</name>
  <read_first>
    - tests/background/onboarding.test.ts (contracts from Task 1)
    - src/background/index.ts lines 70-90 (existing top-level constants area)
    - src/background/index.ts lines 159-186 (ensureOffscreen pattern)
    - src/background/index.ts lines 724-737 (existing onInstalled handler)
    - .planning/phases/01-stabilize-video-pipeline/01-09-SUMMARY.md "Defense-in-depth chrome.* try/catch" note
  </read_first>
  <files>src/background/index.ts</files>
  <action>
    1. Add top-level constants near badge/notification block at line 73-88:

         // Plan 01-10 onboarding constants (D-17-onboarding).
         const ONBOARDING_FLAG = 'onboarding-completed';
         const ONBOARDING_INSTALLED_AT = 'installed-at';
         const WELCOME_PATH = 'src/welcome/welcome.html';

       SCREAMING_SNAKE per project naming rule for true constants.

    2. Add openWelcomeIfFirstInstall helper just below ensureOffscreen
       (line ~186). Full JSDoc citing Plan 01-10 D-17-onboarding (CONTEXT.md
       line 537+; the SUFFIX disambiguates from D-17-port-lifecycle per
       CONTEXT.md lines 540-545). Defense-in-depth try/catch wraps the body:

         /**
          * Open the welcome page on first install (Plan 01-10 D-17-onboarding).
          *
          * Trigger conditions (all must hold):
          *   - details.reason === 'install' (NOT 'update' / 'chrome_update' /
          *     'shared_module_update');
          *   - chrome.storage.local key 'onboarding-completed' NOT === true.
          *
          * Side effects:
          *   - chrome.tabs.create({url: chrome.runtime.getURL('src/welcome/welcome.html')})
          *   - chrome.storage.local.set({'onboarding-completed': true,
          *     'installed-at': Date.now()})
          *
          * Failure mode: any thrown chrome.* call caught and logged via
          * logger.warn. Welcome tab failing is NOT fatal — toolbar onClicked
          * (D-16-toolbar) remains the operator's start path.
          */
         async function openWelcomeIfFirstInstall(
           details: chrome.runtime.InstalledDetails,
         ): Promise<void> {
           if (details.reason !== 'install') {
             return;
           }
           try {
             const stored = await chrome.storage.local.get(ONBOARDING_FLAG);
             if (stored[ONBOARDING_FLAG] === true) {
               logger.log('Onboarding already completed; skipping welcome tab.');
               return;
             }
             const url = chrome.runtime.getURL(WELCOME_PATH);
             await chrome.tabs.create({ url });
             await chrome.storage.local.set({
               [ONBOARDING_FLAG]: true,
               [ONBOARDING_INSTALLED_AT]: Date.now(),
             });
             logger.log(
               'Welcome tab opened (D-17-onboarding); onboarding flag set.',
             );
           } catch (err) {
             logger.warn('openWelcomeIfFirstInstall failed:', err);
           }
         }

    3. Extend the onInstalled handler at line 724. Fire-and-forget call
       AFTER initialize(); .catch() so rejected Promise cannot escape
       the synchronous listener boundary:

         chrome.runtime.onInstalled.addListener((details) => {
           logger.log('Extension installed/updated:', details.reason);
           try {
             indexedDB.deleteDatabase('VideoRecorderDB');
             logger.log('Cleaned up orphaned VideoRecorderDB (if present)');
           } catch (e) {
             logger.warn('IDB cleanup failed:', e);
           }
           initialize();
           // Plan 01-10 D-17-onboarding: open welcome tab on first install.
           // Fire-and-forget — helper logs its own errors.
           openWelcomeIfFirstInstall(details).catch((err) => {
             logger.warn('openWelcomeIfFirstInstall threw:', err);
           });
         });

    4. Run npx vitest run tests/background/onboarding.test.ts — all 3 RED
       MUST flip GREEN.

    5. Run full vitest suite — baseline 98 + 3 new = 101 GREEN; zero RED.

    6. Run npx tsc --noEmit — exit 0. If type complaint on
       chrome.runtime.InstalledDetails, narrow parameter to { reason: string }.

    7. Run npm run build + npm run build:test — both exit 0.

    Style/naming pre-commit:
      - No `as any` (chrome.runtime.InstalledDetails type or { reason: string } narrow)
      - No `continue` (if-else early-return only)
      - No new dependencies
      - No edits outside src/background/index.ts

    Commit:
      feat(01-10): wave-2 task-3 — openWelcomeIfFirstInstall helper + onInstalled wiring (D-17-onboarding) — 3 RED → GREEN

      Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  </action>
  <verify>
    <automated>npx vitest run tests/background/onboarding.test.ts 2>&amp;1 | grep -E "3 passed" &amp;&amp; npx tsc --noEmit &amp;&amp; npm run build &amp;&amp; npm run build:test</automated>
  </verify>
  <acceptance_criteria>
    - openWelcomeIfFirstInstall helper exists with documented behavior.
    - onInstalled handler invokes helper after initialize().
    - All 3 onboarding tests GREEN.
    - Full suite 98 + 3 = 101 GREEN.
    - npx tsc --noEmit exit 0.
    - npm run build + build:test exit 0.
    - Tier-1 grep gate (tests/background/no-test-hooks-in-prod-bundle.test.ts)
      stays GREEN — no new forbidden strings introduced.
  </acceptance_criteria>
  <done>onInstalled extended; SW handler tests GREEN; welcome flow wired end-to-end at the SW layer.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 4: Harness A15+A16+A17 extension — page-side asserts in extension-page-harness.ts, host-side drivers in harness-page-driver.ts, registration in harness.test.ts. npm run test:uat 18/18 GREEN.</name>
  <read_first>
    - tests/uat/extension-page-harness.ts lines 1820-1966 (assertA14 + window.__mokoshHarness install pattern)
    - tests/uat/lib/harness-page-driver.ts lines 971-1000 (driveA14 standard wrapper)
    - tests/uat/harness.test.ts lines 49-77 + 252-313 (driver imports + drivers array)
    - tests/background/no-test-hooks-in-prod-bundle.test.ts lines 105-116 (FORBIDDEN_HOOK_STRINGS — DO NOT extend; A15-A17 use public APIs)
    - src/welcome/welcome.html (the artifact A17 fetches + parses)
    - dist-test/src/welcome/welcome.html (the BUILT artifact A15/A16/A17 actually navigate against)
  </read_first>
  <files>
    tests/uat/extension-page-harness.ts,
    tests/uat/lib/harness-page-driver.ts,
    tests/uat/harness.test.ts
  </files>
  <action>
    1. **Page-side (extension-page-harness.ts)**: append three new assertion
       methods AFTER assertA14 (~line 1905, before the window.__mokoshHarness
       installation block at line 1920).

       assertA15 — onboarding flag observability:
         async function assertA15(): Promise<AssertionResult> {
           const result: AssertionResult = {
             passed: false,
             name: "A15 — onboarding flag set on first install (chrome.storage.local 'onboarding-completed' === true; 'installed-at' is number)",
             checks: [],
             diagnostics: [],
           };
           try {
             const stored = await chrome.storage.local.get([
               'onboarding-completed', 'installed-at',
             ]);
             diag(result, 'storage.local read: ' + JSON.stringify(stored));
             result.checks.push({
               name: 'A15.1: onboarding-completed === true',
               expected: true,
               actual: stored['onboarding-completed'],
               passed: stored['onboarding-completed'] === true,
             });
             result.checks.push({
               name: 'A15.2: installed-at is a number',
               expected: 'number',
               actual: typeof stored['installed-at'],
               passed: typeof stored['installed-at'] === 'number',
             });
             result.passed = result.checks.every((c) => c.passed);
           } catch (err) {
             result.error = err instanceof Error ? err.message : String(err);
             diag(result, 'THREW: ' + result.error);
           }
           return result;
         }

       assertA16 — subsequent-install does NOT re-open:
         async function assertA16(): Promise<AssertionResult> {
           const result: AssertionResult = {
             passed: false,
             name: 'A16 — subsequent install does NOT re-open welcome tab (2s settle: no new welcome.html tabs appear)',
             checks: [],
             diagnostics: [],
           };
           try {
             const welcomeUrlSuffix = 'src/welcome/welcome.html';
             const tabsBefore = await chrome.tabs.query({});
             const beforeCount = tabsBefore.filter(
               (t) => typeof t.url === 'string' &&
                 t.url.endsWith(welcomeUrlSuffix),
             ).length;
             diag(result, 'A16: welcome tabs before settle: ' + String(beforeCount));
             await new Promise((r) => setTimeout(r, 2000));
             const tabsAfter = await chrome.tabs.query({});
             const afterCount = tabsAfter.filter(
               (t) => typeof t.url === 'string' &&
                 t.url.endsWith(welcomeUrlSuffix),
             ).length;
             diag(result, 'A16: welcome tabs after settle: ' + String(afterCount));
             const delta = afterCount - beforeCount;
             result.checks.push({
               name: 'A16.1: welcome-tab delta over 2s settle is 0 (onInstalled flag-gating works)',
               expected: 0,
               actual: delta,
               passed: delta === 0,
             });
             result.passed = result.checks.every((c) => c.passed);
           } catch (err) {
             result.error = err instanceof Error ? err.message : String(err);
             diag(result, 'THREW: ' + result.error);
           }
           return result;
         }

       assertA17 — design-swap-readiness invariant:
         async function assertA17(): Promise<AssertionResult> {
           const result: AssertionResult = {
             passed: false,
             name: 'A17 — design-swap-readiness: welcome.html parses; .welcome-hero exists; welcome.css zero hex literals; ≥7 data-mokosh-key; bundled JS contains COPY[',
             checks: [],
             diagnostics: [],
           };
           try {
             const welcomeUrl = chrome.runtime.getURL('src/welcome/welcome.html');
             const htmlRes = await fetch(welcomeUrl);
             const htmlText = await htmlRes.text();
             const parsed = new DOMParser().parseFromString(htmlText, 'text/html');
             const hero = parsed.querySelector('.welcome-hero');
             result.checks.push({
               name: 'A17.1: welcome.html parses + .welcome-hero exists',
               expected: 'truthy',
               actual: hero === null ? 'null' : 'element',
               passed: hero !== null,
             });
             const keyMatches = htmlText.match(/data-mokosh-key=/g) ?? [];
             result.checks.push({
               name: 'A17.2: welcome.html has ≥ 7 data-mokosh-key attributes',
               expected: '≥ 7',
               actual: String(keyMatches.length),
               passed: keyMatches.length >= 7,
             });
             const cssUrl = chrome.runtime.getURL('src/welcome/welcome.css');
             const cssRes = await fetch(cssUrl);
             const cssText = await cssRes.text();
             const hexMatches = cssText.match(/#[0-9a-fA-F]{3,8}/g) ?? [];
             result.checks.push({
               name: 'A17.3: welcome.css contains ZERO hex literals (var(--mks-*) only)',
               expected: 0,
               actual: hexMatches.length,
               passed: hexMatches.length === 0,
             });
             const varMatches = cssText.match(/var\(--mks-/g) ?? [];
             result.checks.push({
               name: 'A17.4: welcome.css contains ≥ 5 var(--mks- references',
               expected: '≥ 5',
               actual: String(varMatches.length),
               passed: varMatches.length >= 5,
             });
             // Locate the bundled welcome JS chunk via <script src> in the
             // parsed HTML. Vite emits a hashed chunk name; we fetch the
             // referenced path relative to welcome.html.
             const scriptEl = parsed.querySelector(
               'script[type="module"][src]',
             );
             const scriptSrc = scriptEl?.getAttribute('src') ?? '';
             const baseUrl = new URL(welcomeUrl);
             const jsUrl = new URL(scriptSrc, baseUrl).href;
             const jsRes = await fetch(jsUrl);
             const jsText = await jsRes.text();
             // The bundled chunk minifies but the COPY[ access pattern
             // survives (subscript access is preserved by Vite's minifier
             // because copy.ts uses a Readonly<Record> indexed by string
             // keys).
             result.checks.push({
               name: 'A17.5: bundled welcome JS chunk contains COPY[ subscript pattern',
               expected: 'contains "COPY["',
               actual: jsText.includes('COPY[') ? 'present' : 'absent',
               passed: jsText.includes('COPY['),
             });
             result.passed = result.checks.every((c) => c.passed);
             diag(result, 'A17: ' + result.checks.filter((c) => c.passed).length + '/' + result.checks.length + ' subchecks passed');
           } catch (err) {
             result.error = err instanceof Error ? err.message : String(err);
             diag(result, 'THREW: ' + result.error);
           }
           return result;
         }

       NOTE A17.5 caveat: if the executor finds that Vite's minifier
       transforms `COPY[` (e.g., inlines the Object.freeze entries),
       relax to `var(--mks-` substring in welcome.css OR change A17.5
       to assert that the bundled JS contains AT LEAST ONE of the
       D-08 tagline strings verbatim — that string is uniquely identifiable
       and survives minification. The check is "COPY data flows through
       the bundle"; the literal-string-check fallback proves it equally well.

    2. Update the global type declaration at line 1921+:
         declare global {
           interface Window {
             __mokoshHarness: {
               // ... existing assertA1..A14, getManifestVersion ...
               assertA15: () => Promise<AssertionResult>;
               assertA16: () => Promise<AssertionResult>;
               assertA17: () => Promise<AssertionResult>;
             };
           }
         }
         window.__mokoshHarness = {
           // ... existing ...
           assertA15, assertA16, assertA17,
         };

    3. Update the page-ready log line at line 1966 to mention A17.

    4. **Host-side (lib/harness-page-driver.ts)**: append three new
       drivers AFTER driveA14 (~line 991):

         export async function driveA15(page: Page): Promise<AssertionRecord> {
           return await page.evaluate(async () => {
             const harness = (window as any).__mokoshHarness;
             const r: AssertionRecord = await harness.assertA15();
             return r;
           }) as AssertionRecord;
         }
         export async function driveA16(page: Page): Promise<AssertionRecord> {
           return await page.evaluate(async () => {
             const harness = (window as any).__mokoshHarness;
             const r: AssertionRecord = await harness.assertA16();
             return r;
           }) as AssertionRecord;
         }
         export async function driveA17(page: Page): Promise<AssertionRecord> {
           return await page.evaluate(async () => {
             const harness = (window as any).__mokoshHarness;
             const r: AssertionRecord = await harness.assertA17();
             return r;
           }) as AssertionRecord;
         }

       Re-add the same eslint-disable comment on the `as any` line that
       driveA14 carries (line 985). Standard wrapper — no host-side fs
       polling needed (these are read-only assertions on the SW state +
       fetched static assets).

    5. **Orchestrator (harness.test.ts)**: update the imports at line 55+:
         import {
           driveA1, ..., driveA14, driveA15, driveA16, driveA17, getManifestVersion,
         } from './lib/harness-page-driver';

       Update the drivers array at line 289+ to append A15+A16+A17:
         { name: 'A14', drive: driveA14 },
         { name: 'A15', drive: driveA15 },
         { name: 'A16', drive: driveA16 },
         { name: 'A17', drive: driveA17 },

    6. Run npm run build:test — exit 0 (rebuilds dist-test with the new
       page-side asserts).

    7. Run npm run test:uat — bail-on-first-failure orchestrator now
       executes 18 assertions. Expected output: "UAT harness: 18/18
       assertions passed". If any A15/A16/A17 fails, surface the
       diagnostic; iterate within this task.

    8. Run full vitest suite — 101 GREEN preserved (no source under test
       changed; the harness-page changes are dist-test/ only).

    9. Run npx tsc --noEmit — exit 0.

    10. Verify Tier-1 grep gate stays at 10 strings:
        npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts
        — all 11 sub-tests GREEN (1 build + 10 string assertions). No
        new forbidden strings; the inventory is unchanged.

    Style/naming pre-commit:
      - No new `__mokoshTest`/`__MOKOSH_UAT__` test-hook surface
        (A15-A17 use only chrome.tabs.query / chrome.storage.local.get /
         fetch on public web_accessible_resources surfaces).
      - No new entries in FORBIDDEN_HOOK_STRINGS.
      - `as any` only inside the page.evaluate wrapper (eslint-disable
        line comment matches driveA14 idiom).

    Commit:
      test(01-10): wave-3 task-4 — harness A15+A16+A17 (onboarding flag observability + no-re-open settle check + design-swap-readiness invariant); 18/18 GREEN

      Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  </action>
  <verify>
    <automated>npm run build:test &amp;&amp; npm run test:uat 2>&amp;1 | grep -E "UAT harness: 18/18" &amp;&amp; npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts &amp;&amp; npx tsc --noEmit</automated>
  </verify>
  <acceptance_criteria>
    - assertA15, assertA16, assertA17 exist in extension-page-harness.ts.
    - window.__mokoshHarness exposes all three new methods.
    - driveA15, driveA16, driveA17 exist in lib/harness-page-driver.ts.
    - harness.test.ts drivers array includes A15, A16, A17 entries.
    - npm run test:uat reports 18/18 assertions passed.
    - Tier-1 forbidden-strings inventory unchanged at 10 (no new test-hook surface).
    - npm test (vitest) baseline 101 GREEN preserved.
    - npx tsc --noEmit clean.
  </acceptance_criteria>
  <done>Harness covers welcome-tab activation + design-swap-readiness; Plan 01-12 design swap is now testable as a one-commit delta.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 5: Operator empirical UAT — fresh-profile install opens welcome tab; placeholders render coherently; reload does NOT re-open; harness 18/18 GREEN.</name>
  <files>(operator-driven; no specific source file modified)</files>
  <action>See &lt;how-to-verify&gt; below — operator-driven empirical check.</action>
  <verify>
    <automated>echo "checkpoint:human-verify — see how-to-verify; resume signal is the gate"</automated>
  </verify>
  <done>Operator types "approved" after running the how-to-verify steps. See &lt;resume-signal&gt; for the gate.</done>
  <what-built>
    Tasks 1-4 landed:
    - src/welcome/* page bundle (5 files: welcome.html, welcome.ts, welcome.css, welcome-tokens.css, copy.ts).
    - manifest.json web_accessible_resources for welcome.html.
    - vite.config.ts + vite.test.config.ts rollupOptions.input welcome entry in BOTH bundles.
    - src/background/index.ts openWelcomeIfFirstInstall helper + onInstalled wiring.
    - tests/background/onboarding.test.ts 3 GREEN unit tests.
    - tests/uat/* harness A15+A16+A17 extensions; 18/18 GREEN.
    - Tier-1 forbidden-strings inventory unchanged at 10.
    - vitest baseline 101 GREEN (98 from 01-13 + 3 new from this plan).

    This checkpoint validates real Chrome behavior + brand placeholder
    coherence (the design-swap-in-ready architecture should render
    presentably even with engineering-grade placeholders; Plan 01-12
    will swap to the canonical D-04 Loom palette + D-05 Newsreader +
    IBM Plex Sans WOFF2 hosting).
  </what-built>
  <how-to-verify>
    Pre-checkpoint bundle gates (operator OR executor verifies before
    surfacing this checkpoint — per the orchestrator-loaded
    feedback-pre-checkpoint-bundle-gates.md memory):

      1. npm run build — exit 0; dist/src/welcome/welcome.html exists.
      2. npm run build:test — exit 0.
      3. grep -E '#[0-9a-fA-F]{3,8}' dist/src/welcome/welcome.css — exit 1
         (no matches — design-swap-readiness invariant holds in BUILT artifact).
      4. grep -q web_accessible_resources dist/manifest.json — exit 0.
      5. npx vitest run — 101 passed.
      6. npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts —
         all GREEN; 10 forbidden strings absent from dist/.
      7. npm run test:uat — 18/18 GREEN.

    Operator empirical steps:

      1. Verify dist/ is fresh: npm run build (exit 0).
      2. Wipe smoke profile: rm -rf /tmp/mokosh-smoke-profile (or
         KEEP_PROFILE=0 ./smoke.sh which does the wipe).
      3. Launch fresh-profile Chrome: KEEP_PROFILE=0 ./smoke.sh.
      4. chrome://extensions → Load Unpacked → select dist/. THE WELCOME
         TAB SHOULD AUTOMATICALLY OPEN within ~1 second. URL should be
         chrome-extension://&lt;id&gt;/src/welcome/welcome.html.
      5. Verify the welcome page renders coherently:
         - Centered card, max-width ~720px.
         - Hero block at top: 'Mokosh' placeholder mark + 'Mokosh' H1 + RU tagline
           '«Тридцать секунд назад, всегда под рукой.»' + EN tagline
           'Thirty seconds ago, always at hand.' in italic serif.
         - Body: 2 explainer lines about 30s screen + 10min logs +
           local-only data + how to save archive.
         - CTA box: 'Чтобы начать запись, нажмите иконку AI Call Recorder
           на панели инструментов браузера (правый верхний угол).'
         - Footer: privacy/safety note about no-server-uploads.
         - Typography: serif for hero (system fallback today; Plan 01-12
           swaps to Newsreader/PT-Serif WOFF2); sans for body.
         - NO console errors in the welcome tab's DevTools console.
         - NO inline "Start" button (D-16-toolbar charter — the welcome
           tab is informational; toolbar owns start).
      6. Close the welcome tab.
      7. Click the AI Call Recorder toolbar icon (right-side toolbar).
         Chrome's screen-share picker should appear (monitor-only per
         01-09). Pick 'Entire screen' + accept. Badge transitions to
         REC. (This validates that the welcome tab CTA accurately
         describes the toolbar start path.)
      8. Click the toolbar again (now in REC state). The popup opens
         with the SAVE-only UI per 01-09 SAVE-only charter; a zip is
         downloaded. Badge returns to OFF (per Plan 01-13 save-stops-
         recording charter).
      9. Reload the extension at chrome://extensions (toggle off + on).
         Observe: the welcome tab does NOT re-open. This validates
         A16's flag-gating contract.
      10. Re-validate the install branch: Cmd+Q Chrome →
          rm -rf /tmp/mokosh-smoke-profile → re-launch smoke.sh →
          Load Unpacked again. Welcome tab opens again (chrome.storage.local
          wiped with profile → onboarding-completed absent → first-install
          path fires).

    If step 4 (welcome tab opens), step 5 (page renders coherently),
    step 7 (toolbar CTA validates), or step 9 (no re-open on reload)
    fails: document the failure mode + Chrome version + SW console
    errors. Iterate on Task 2 (assets) or Task 3 (SW handler) or
    Task 4 (harness) accordingly.

    Design-coherence note for operator:
    The welcome page renders with engineering-grade placeholders TODAY
    — system serif/sans, neutral grays. This is INTENDED. Plan 01-12
    swaps welcome-tokens.css for an @import of tokens.css + bundles
    Newsreader (or designer-picked Cyrillic-capable alternative per
    brand-decisions-v1-followup-display-font.md) and the welcome page
    transitions to the canonical D-04 Loom palette + D-05 type pairing
    without any other code change. The harness A17 contract pins the
    invariants that make this swap mechanical.
  </how-to-verify>
  <resume-signal>
    Type 'approved' after pre-checkpoint gates 1-7 ALL pass AND operator
    steps 4, 5, 7, 9 ALL pass. If any step fails, paste the failure
    diagnostic. The harness 18/18 GREEN is the canonical functional gate;
    operator empirical is the brand-coherence + Chrome-empirical gate.
  </resume-signal>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| welcome page (extension-page context) ↔ SW | Welcome page is a same-origin extension page (chrome-extension://&lt;id&gt;/src/welcome/welcome.html). It does NOT call chrome.runtime.sendMessage in this plan — it is informational + read-only. No new IPC boundary introduced. |
| chrome.storage.local ↔ SW | Storage flag is non-secret (boolean + timestamp); the worst case if leaked or tampered is suppressing/re-firing the welcome tab. No PII; no sensitive content. |
| chrome.runtime.onInstalled ↔ SW | Existing Chrome MV3 boundary; this plan EXTENDS the handler body but does not introduce a new listener registration. |
| web_accessible_resources ↔ external pages | welcome.html is enumerable by any page that probes chrome-extension:// URLs in web_accessible_resources. Existing extension-fingerprinting concern; matches=['&lt;all_urls&gt;'] is the standard pattern for welcome flows. |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-1-10-01 | Tampering | Adversary clears chrome.storage.local 'onboarding-completed' flag to spam welcome tabs across SW respawns | accept | Welcome tab is non-destructive; worst case is an extra tab on each install (NOT each SW respawn — onInstalled only fires on install/update/chrome_update). chrome.storage.local clearing requires either operator action via Chrome DevTools OR an adversarial extension installed alongside — both out-of-band. No data exfiltration path. |
| T-1-10-02 | Information Disclosure | welcome.html fingerprinting via web_accessible_resources enumeration | accept | Extension identifier is already discoverable through chrome.runtime.getURL exposure on any extension page (popup + offscreen + harness page); matches=['&lt;all_urls&gt;'] is the standard MV3 welcome-flow pattern. Phase 5 hardening could narrow matches to a specific install-confirmation domain; out of scope. |
| T-1-10-03 | Denial of Service | Adversary controls chrome.storage.local quota | mitigate | Two values stored (boolean true + Date.now() number) ~80 bytes; storage.local quota is 10 MB; not exploitable. |
| T-1-10-04 | Elevation of Privilege | Welcome page tricks SW into bypassing checkpoints | accept | Welcome page sends NO messages to the SW in this plan (informational only). No new EoP path. Plan 01-12 may add an i18n probe via chrome.i18n.getMessage (synchronous, no IPC) — also no EoP path. |
| T-1-10-05 | Information Disclosure | Russian copy strings leak via web_accessible_resources | accept | Copy strings are operator-facing public marketing content (D-08 tagline; D-03 voice register); no PII. |
| T-1-10-06 | Tampering | Adversary modifies copy.ts contents via supply-chain attack on the source bundle | accept | Generic supply-chain risk applying to every TS file in the project; outside this plan's scope. The Tier-1 grep gate prevents test-hook leaks; brand-copy integrity is a Phase 5 / brand-team concern. |
| T-1-10-07 | Tampering | Adversary modifies welcome-tokens.css to inject malicious styles (CSP-bypass via background-image: url(data:...)) | mitigate | The token CSS contains ONLY color/font/spacing values — no url() expressions, no @import (except to tokens.css path post Plan 01-12, which is same-origin extension URL). MV3 CSP forbids inline scripts; CSS-only injection cannot escalate. Plan 01-12 swap is from canonical tokens.css delivered by the brand team — supply-chain risk shifts to brand-team handoff verification. |
</threat_model>

<verification>
- npx vitest run shows 101/101 GREEN (98 baseline from 01-13 + 3 new from onboarding.test.ts).
- npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts: all GREEN; FORBIDDEN_HOOK_STRINGS inventory unchanged at 10.
- npx tsc --noEmit: exit 0.
- npm run build: exit 0; dist/src/welcome/welcome.html exists; dist/manifest.json contains web_accessible_resources with welcome.html entry.
- npm run build:test: exit 0; dist-test/src/welcome/welcome.html exists.
- grep -E '#[0-9a-fA-F]{3,8}' dist/src/welcome/welcome.css: exit 1 (no matches — design-swap-readiness invariant survives the build pipeline).
- grep -o 'data-mokosh-key' dist/src/welcome/welcome.html | wc -l ≥ 7.
- grep -n 'chrome.tabs.create' src/background/index.ts ≥ 1 (the helper's call).
- grep -n 'welcome.html' manifest.json ≥ 1.
- grep -n 'openWelcomeIfFirstInstall' src/background/index.ts ≥ 2 (helper definition + call site).
- grep -n 'D-17-onboarding' src/background/index.ts ≥ 1 (cited in JSDoc).
- grep -nE 'REQUEST_PERMISSIONS' src/welcome/welcome.ts: exit 1 (no match — welcome tab does NOT reference REQUEST_PERMISSIONS per the deletion in 01-09 + per the D-16-toolbar single-start-path charter).
- npm run test:uat: "UAT harness: 18/18 assertions passed".
- Operator empirical steps 4, 5, 7, 9 from how-to-verify: ALL PASS.
</verification>

<success_criteria>
Plan 01-10 is complete when:
1. The 3 onboarding unit tests are GREEN; full vitest suite 101/101 GREEN.
2. UAT harness reports 18/18 GREEN (existing A0..A14 + new A15+A16+A17).
3. Tier-1 forbidden-strings inventory stays at 10 (no new test-hook surface).
4. Welcome page renders coherently in fresh-profile Chrome with engineering-grade placeholders (Plan 01-12 swap target).
5. Design-swap-readiness invariants hold in the BUILT artifact (welcome.css zero hex literals; ≥ 7 data-mokosh-key attributes; bundled JS uses COPY[ subscript pattern).
6. Operator confirms via how-to-verify steps 4 / 5 / 7 / 9 + the harness 18/18 GREEN gate.
7. tsc + build + build:test all clean; manifest + vite + welcome assets consistent across both bundles.
8. No references to REQUEST_PERMISSIONS in src/welcome/ (start path remains toolbar-onClicked-owned per D-16-toolbar).
9. No `await import(...)` introduced into src/background/index.ts (MV3 SW dynamic-import blocker per 01-11-SUMMARY).
</success_criteria>

<output>
After completion, create .planning/phases/01-stabilize-video-pipeline/01-10-SUMMARY.md per the standard template. Cite:
- 3 new onboarding unit tests landed GREEN (Test A / B / C).
- 5 new src/welcome/* files (welcome.html / welcome.ts / welcome.css / welcome-tokens.css / copy.ts).
- manifest web_accessible_resources delta.
- vite.config.ts + vite.test.config.ts rollupOptions.input delta in BOTH bundles.
- openWelcomeIfFirstInstall helper + onInstalled wiring in src/background/index.ts.
- Harness A15 + A16 + A17 extensions; 15/15 → 18/18 GREEN delta.
- Tier-1 forbidden-strings inventory unchanged at 10 (no new test-hook surface).
- Design-swap-in-ready architecture summary: CSS variables, copy.ts source-of-truth, .welcome-hero slot, var(--mks-font-*) typography fallback chain.
- Cross-references: D-02 (welcome layout), D-08 (tagline), D-03 (voice register), D-17-onboarding (this plan's amendment marker), D-16-toolbar (start path ownership).
- Plan 01-12 swap path: replace welcome-tokens.css with @import 'tokens.css' + swap copy.ts to chrome.i18n.getMessage post _locales/ landing + bundle Newsreader (or PT-Serif/EB-Garamond/Lora per the designer follow-up reply, currently outstanding) WOFF2 self-hosted.
- Operator empirical UAT outcome (welcome tab opens; brand-coherence acceptable for placeholders; reload does NOT re-open; harness 18/18 GREEN).
</output>
