meeting-template

Meet Formatter v1.0 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the v1.0 release: a Manifest V3 Chrome extension that lets a user define a single Markdown/WYSIWYG template and auto-inserts (or manually inserts) it into the Details field of new Google Calendar events. Replaces an unreleased Manifest V2 prototype.

Architecture: Three contexts (popup / service-worker / content-script) glued by typed chrome.runtime messages. Pure library code (lib/) holds the testable logic (storage facade, Markdown→sanitized HTML, Details-field locator). The Preact popup hosts a Tiptap editor with Markdown shortcuts, undo/redo, auto-save, and a Save/Insert action bar. The content script runs only on calendar.google.com, observes the event dialog opening, and injects sanitized HTML when auto-insert is on and Details is empty.

Tech Stack: TypeScript, Vite + @crxjs/vite-plugin (MV3), Preact (@preact/preset-vite), Tiptap (StarterKit + Underline + Placeholder + History), marked, DOMPurify, Vitest + happy-dom + @testing-library/preact, Playwright.

Reference spec: docs/superpowers/specs/2026-05-20-meet-formatter-v1-design.md

Versioning note: This plan and the linked spec use product version “v1.0” throughout. Earlier drafts referred to “v2” because the rewrite superseded an unreleased Manifest V2 prototype; for the first public release we renumbered. “Manifest V2” still appears below in the context of the deprecated Chrome extension format that the prototype used — that’s a separate concept from the product version.

Note on commits: This repo is intentionally not under git for now. Steps labeled “Checkpoint” mark logical breakpoints but contain no git commands. If the repo is initialized later, those checkpoints make natural squash points.


File map

Files to delete (repo root, Manifest V2 leftovers):

Files to keep:

Files to create:

Path Responsibility
package.json npm scripts + pinned deps
tsconfig.json TS config (strict, ESM, JSX→Preact)
vite.config.ts Vite + crxjs + preact preset
vitest.config.ts Vitest with happy-dom, jsdom-style globals
playwright.config.ts Playwright with persistent context for extension load
eslint.config.js ESLint flat config
src/manifest.config.ts MV3 manifest as TS
src/messages.ts MessageKind enum + payload types
src/lib/storage.ts getSettings, setSettings, onSettingsChanged
src/lib/markdown.ts markdownToSanitizedHtml(md)
src/lib/details-locator.ts findDetailsEditor, findDescriptionExpander, waitForEventEditor, waitFor, isEditorEmpty
src/background/service-worker.ts Message router; relays INSERT_TEMPLATE to active calendar tab
src/content/calendar-injector.ts Observer + locator + DOM injection on calendar.google.com
src/popup/popup.html Vite popup entry
src/popup/main.tsx Preact mount
src/popup/App.tsx Top-level component
src/popup/Editor.tsx Tiptap wrapper
src/popup/Toolbar.tsx B / I / U / • / 1. / H
src/popup/SettingsRow.tsx Auto-insert toggle
src/popup/ActionBar.tsx Save / Insert
src/popup/popup.css Notion-ish design tokens
src/popup/useAutoSave.ts Debounced auto-save hook
tests/setup.ts Vitest setup (chrome API mocks, testing-library matchers)
tests/fixtures/fake-calendar.html Static Calendar-shaped page for E2E
tests/fixtures/event-dialog.html Snapshot of the dialog for locator unit tests
tests/e2e/popup.spec.ts Popup load + auto-save E2E
tests/e2e/auto-insert.spec.ts Auto-insert + manual-insert E2E
tests/verify-live.spec.ts Claude-driven live verification against real Calendar

Task 0: Remove Manifest V2 files

Files:

cd "/Users/pbrowne/scripts/Google Meets Formatter"
rm -f popup.html popup.js action.js background.js injector.js app.js jquery-3.5.1.min.js manifest.json
ls -1 | grep -E '^(popup|action|background|injector|app|jquery|manifest)\.' || echo "clean"

Expected output: clean


Task 1: Scaffold package.json with pinned dependencies

Files:

{
  "name": "meet-formatter",
  "version": "1.0.0",
  "description": "Inject a Markdown-defined template into Google Calendar event Details.",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:e2e": "playwright test --config=playwright.config.ts tests/e2e",
    "test:live": "playwright test --config=playwright.config.ts tests/verify-live.spec.ts --headed",
    "typecheck": "tsc --noEmit",
    "lint": "eslint src tests"
  },
  "dependencies": {
    "@tiptap/core": "^2.10.3",
    "@tiptap/extension-placeholder": "^2.10.3",
    "@tiptap/extension-underline": "^2.10.3",
    "@tiptap/pm": "^2.10.3",
    "@tiptap/react": "^2.10.3",
    "@tiptap/starter-kit": "^2.10.3",
    "dompurify": "^3.2.2",
    "marked": "^14.1.4",
    "preact": "^10.25.1",
    "turndown": "^7.2.0"
  },
  "devDependencies": {
    "@crxjs/vite-plugin": "^2.0.0-beta.30",
    "@playwright/test": "^1.49.1",
    "@preact/preset-vite": "^2.10.1",
    "@testing-library/jest-dom": "^6.6.3",
    "@testing-library/preact": "^3.2.4",
    "@testing-library/user-event": "^14.5.2",
    "@types/chrome": "^0.0.287",
    "@types/dompurify": "^3.2.0",
    "@types/node": "^22.10.2",
    "@types/turndown": "^5.0.5",
    "@typescript-eslint/eslint-plugin": "^8.18.1",
    "@typescript-eslint/parser": "^8.18.1",
    "eslint": "^9.17.0",
    "eslint-plugin-react-hooks": "^5.1.0",
    "happy-dom": "^15.11.7",
    "http-server": "^14.1.1",
    "typescript": "^5.7.2",
    "vite": "^5.4.11",
    "vitest": "^2.1.8"
  }
}

Run:

cd "/Users/pbrowne/scripts/Google Meets Formatter"
npm install

Expected: npm install completes without peerdependency errors. If @crxjs/vite-plugin@beta.30 is unavailable, try npm install @crxjs/vite-plugin@beta --save-dev to pull the current beta. The plan tolerates any beta ≥ 2.0.0-beta.25.

Vite pin gotcha: stay on vite@^5.4.x. Bumping to vite 6 trips exactOptionalPropertyTypes against vitest 2’s bundled vite types when both configs share a preact() plugin instance. If you must use vite 6, also bump to vitest 3.

Private npm registry: if the developer’s global ~/.npmrc points at a private registry (e.g. JFrog), add a project-level .npmrc to override:

registry=https://registry.npmjs.org/
npx playwright install chromium

Expected: download completes; final line includes “✔ Successfully installed”.


Task 2: TypeScript config

Files:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "jsxImportSource": "preact",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": false,
    "noEmit": true,
    "types": ["chrome", "node", "vitest/globals", "@testing-library/jest-dom"],
    "paths": {
      "react": ["./node_modules/preact/compat"],
      "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"],
      "react-dom": ["./node_modules/preact/compat"],
      "react-dom/*": ["./node_modules/preact/compat/*"]
    },
    "baseUrl": "."
  },
  "include": ["src", "tests", "*.config.ts", "*.config.js"]
}
npm run typecheck 2>&1 | head -5

Expected: tsc reports error TS18003: No inputs were found or succeeds silently. Either is fine — we just need confirmation tsc is wired up.


Task 3: Vite + crxjs config

Files:

import { defineManifest } from '@crxjs/vite-plugin'

export default defineManifest({
  manifest_version: 3,
  name: 'Meet Formatter',
  version: '1.0.0',
  description: 'Inject a Markdown-defined template into Google Calendar event Details.',
  action: {
    default_popup: 'src/popup/popup.html',
    default_title: 'Meet Formatter',
  },
  background: {
    service_worker: 'src/background/service-worker.ts',
    type: 'module',
  },
  content_scripts: [
    {
      matches: ['*://calendar.google.com/*'],
      js: ['src/content/calendar-injector.ts'],
      run_at: 'document_idle',
    },
  ],
  permissions: ['storage'],
  host_permissions: ['*://calendar.google.com/*'],
  icons: {
    '16': 'assets/images/icon16.png',
    '48': 'assets/images/icon48.png',
    '128': 'assets/images/icon128.png',
  },
})
import { defineConfig } from 'vite'
import { crx } from '@crxjs/vite-plugin'
import preact from '@preact/preset-vite'
import manifest from './src/manifest.config'

export default defineConfig({
  plugins: [preact(), crx({ manifest })],
  build: {
    outDir: 'dist',
    emptyOutDir: true,
    sourcemap: true,
  },
})

Task 4: Vitest config

Files:

import { defineConfig } from 'vitest/config'
import preact from '@preact/preset-vite'

export default defineConfig({
  plugins: [preact()],
  test: {
    environment: 'happy-dom',
    globals: true,
    setupFiles: ['./tests/setup.ts'],
    include: ['src/**/*.test.{ts,tsx}', 'tests/**/*.test.{ts,tsx}'],
    exclude: ['tests/e2e/**', 'tests/verify-live.spec.ts', 'node_modules/**'],
  },
  resolve: {
    alias: {
      react: 'preact/compat',
      'react-dom': 'preact/compat',
    },
  },
})
import '@testing-library/jest-dom/vitest'
import { vi, beforeEach } from 'vitest'

type StorageListener = (
  changes: Record<string, chrome.storage.StorageChange>,
  area: string,
) => void

interface MockStorage {
  data: Record<string, unknown>
  listeners: StorageListener[]
}

const stores: Record<string, MockStorage> = {
  sync: { data: {}, listeners: [] },
  local: { data: {}, listeners: [] },
}

function makeArea(name: 'sync' | 'local') {
  const store = stores[name]!
  return {
    get: vi.fn((keys: string | string[] | null) => {
      if (keys === null || keys === undefined) return Promise.resolve({ ...store.data })
      const arr = Array.isArray(keys) ? keys : [keys]
      const out: Record<string, unknown> = {}
      for (const k of arr) if (k in store.data) out[k] = store.data[k]
      return Promise.resolve(out)
    }),
    set: vi.fn((items: Record<string, unknown>) => {
      const changes: Record<string, chrome.storage.StorageChange> = {}
      for (const [k, v] of Object.entries(items)) {
        changes[k] = { oldValue: store.data[k], newValue: v }
        store.data[k] = v
      }
      for (const l of store.listeners) l(changes, name)
      return Promise.resolve()
    }),
    clear: vi.fn(() => {
      store.data = {}
      return Promise.resolve()
    }),
  }
}

const runtimeListeners: Array<(msg: unknown, sender: unknown, sendResponse: (r?: unknown) => void) => void> = []

;(globalThis as unknown as { chrome: typeof chrome }).chrome = {
  storage: {
    sync: makeArea('sync') as unknown as chrome.storage.StorageArea,
    local: makeArea('local') as unknown as chrome.storage.StorageArea,
    onChanged: {
      addListener: vi.fn((cb: StorageListener) => {
        stores.sync.listeners.push(cb)
        stores.local.listeners.push(cb)
      }),
      removeListener: vi.fn(),
      hasListener: vi.fn(),
      hasListeners: vi.fn(),
    } as unknown as chrome.storage.StorageChangedEvent,
  },
  runtime: {
    sendMessage: vi.fn(),
    onMessage: {
      addListener: vi.fn((cb: typeof runtimeListeners[number]) => runtimeListeners.push(cb)),
      removeListener: vi.fn(),
    },
    lastError: undefined,
  },
  tabs: {
    query: vi.fn(() => Promise.resolve([])),
    sendMessage: vi.fn(),
  },
} as unknown as typeof chrome

beforeEach(() => {
  stores.sync.data = {}
  stores.local.data = {}
  stores.sync.listeners.length = 0
  stores.local.listeners.length = 0
  runtimeListeners.length = 0
  vi.clearAllMocks()
})
npm test 2>&1 | tail -10

Expected: No test files found, exiting with code 1 or similar — confirms Vitest is wired up and finds zero specs.


Task 5: Shared message types

Files:

export const MessageKind = {
  InsertTemplate: 'INSERT_TEMPLATE',
} as const

export type MessageKind = typeof MessageKind[keyof typeof MessageKind]

export interface InsertTemplateMessage {
  kind: typeof MessageKind.InsertTemplate
  /** Markdown source; the content script renders → sanitizes → injects. */
  markdown: string
  /** When true, the content script must inject even if Details is non-empty. */
  manual: boolean
}

export type ExtensionMessage = InsertTemplateMessage

Task 6: lib/storage.ts — TDD

Files:

// src/lib/storage.test.ts
import { describe, it, expect, vi } from 'vitest'
import { getSettings, setSettings, onSettingsChanged, defaultSettings } from './storage'

describe('storage facade', () => {
  it('returns defaults when storage is empty', async () => {
    const s = await getSettings()
    expect(s).toEqual(defaultSettings)
  })

  it('round-trips template and autoInsert', async () => {
    await setSettings({ template: '# hi', autoInsert: true })
    const s = await getSettings()
    expect(s).toEqual({ template: '# hi', autoInsert: true })
  })

  it('partial updates do not clobber the other field', async () => {
    await setSettings({ template: 'a', autoInsert: true })
    await setSettings({ template: 'b' })
    const s = await getSettings()
    expect(s).toEqual({ template: 'b', autoInsert: true })
  })

  it('notifies subscribers on change', async () => {
    const listener = vi.fn()
    onSettingsChanged(listener)
    await setSettings({ template: 'x' })
    expect(listener).toHaveBeenCalledWith(
      expect.objectContaining({ template: 'x', autoInsert: false }),
    )
  })
})
npx vitest run src/lib/storage.test.ts

Expected: Cannot find module './storage' or getSettings is not a function.

export interface Settings {
  template: string
  autoInsert: boolean
}

export const defaultSettings: Settings = {
  template: '',
  autoInsert: false,
}

const KEY = 'settings'

export async function getSettings(): Promise<Settings> {
  const result = await chrome.storage.sync.get(KEY)
  const stored = (result[KEY] ?? {}) as Partial<Settings>
  return { ...defaultSettings, ...stored }
}

export async function setSettings(patch: Partial<Settings>): Promise<void> {
  const current = await getSettings()
  const next = { ...current, ...patch }
  await chrome.storage.sync.set({ [KEY]: next })
}

export function onSettingsChanged(cb: (s: Settings) => void): () => void {
  const listener = (
    changes: Record<string, chrome.storage.StorageChange>,
    area: string,
  ) => {
    if (area !== 'sync') return
    if (!changes[KEY]) return
    const next = { ...defaultSettings, ...(changes[KEY].newValue as Partial<Settings>) }
    cb(next)
  }
  chrome.storage.onChanged.addListener(listener)
  return () => chrome.storage.onChanged.removeListener(listener)
}
npx vitest run src/lib/storage.test.ts

Expected: 4 passing.


Task 7: lib/markdown.ts — TDD

Files:

// src/lib/markdown.test.ts
import { describe, it, expect } from 'vitest'
import { markdownToSanitizedHtml } from './markdown'

describe('markdownToSanitizedHtml', () => {
  it('renders bold, italic, and underline (raw <u>)', () => {
    const html = markdownToSanitizedHtml('**b** _i_ <u>u</u>')
    expect(html).toContain('<strong>b</strong>')
    expect(html).toContain('<em>i</em>')
    expect(html).toContain('<u>u</u>')
  })

  it('renders bullet and ordered lists', () => {
    const ul = markdownToSanitizedHtml('- one\n- two')
    expect(ul).toContain('<ul>')
    expect(ul).toContain('<li>one</li>')

    const ol = markdownToSanitizedHtml('1. one\n2. two')
    expect(ol).toContain('<ol>')
  })

  it('renders headings up to h3 but no h4+', () => {
    expect(markdownToSanitizedHtml('# a')).toContain('<h1>a</h1>')
    expect(markdownToSanitizedHtml('### c')).toContain('<h3>c</h3>')
    const h4 = markdownToSanitizedHtml('#### d')
    expect(h4).not.toContain('<h4>')
  })

  it('keeps http and https links, drops javascript: URLs', () => {
    expect(markdownToSanitizedHtml('[ok](https://example.com)'))
      .toContain('href="https://example.com"')
    const bad = markdownToSanitizedHtml('[x](javascript:alert(1))')
    expect(bad).not.toContain('javascript:')
  })

  it('strips <script> and on* handlers', () => {
    const html = markdownToSanitizedHtml('<script>alert(1)</script><img src=x onerror=alert(1)>')
    expect(html).not.toContain('<script>')
    expect(html).not.toContain('onerror')
  })

  it('preserves empty paragraph for empty input', () => {
    expect(markdownToSanitizedHtml('').trim()).toBe('')
  })
})
npx vitest run src/lib/markdown.test.ts

Expected: module-not-found errors.

import { marked } from 'marked'
import DOMPurify from 'dompurify'

const ALLOWED_TAGS = [
  'p', 'br', 'strong', 'em', 'u', 's',
  'h1', 'h2', 'h3',
  'ul', 'ol', 'li',
  'a', 'code', 'pre', 'blockquote',
]
const ALLOWED_ATTR = ['href']

marked.setOptions({
  gfm: true,
  breaks: false,
})

export function markdownToSanitizedHtml(md: string): string {
  if (!md) return ''
  const raw = marked.parse(md, { async: false }) as string
  return DOMPurify.sanitize(raw, {
    ALLOWED_TAGS,
    ALLOWED_ATTR,
    ALLOW_DATA_ATTR: false,
    ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|#)/i,
  })
}
npx vitest run src/lib/markdown.test.ts

Expected: 6 passing. If the heading test fails because marked still emits <h4> outside the allowed list, that’s fine — DOMPurify will strip it. The assertion not.toContain('<h4>') confirms this.


Task 8: lib/details-locator.ts — TDD

Files:

DOM source of truth: the locator targets real Google Calendar markup captured during development (see Spec §4 + Appendix A). Stable anchors used: #xDescIn, [contenteditable="true"][role="textbox"][aria-label="Add description"], [data-key="description"]. Class names and c#### ids are dynamic and must NOT appear in selectors.

<!doctype html>
<html><body>
  <div id="root">
    <!-- decoy: title field, also contenteditable, must NOT be picked by the locator -->
    <div contenteditable="true" data-role="title-decoy" aria-label="Add title"></div>

    <!-- Models the real Google Calendar event dialog with description expanded -->
    <div role="dialog" aria-label="Create event">
      <!-- collapsed prompt remains in DOM alongside the editor in real Calendar -->
      <span>
        Add
        <span class="JyrDof" data-key="description">description</span>
        or
        <span class="JyrDof" data-key="attachments">a Google Drive attachment</span>
      </span>

      <!-- expanded description panel -->
      <div data-expandable jsname="Ofl2hc">
        <button jsname="Zqjuqb" aria-expanded="true">Add description</button>
        <div jsname="xpDKHf">
          <div role="toolbar" aria-label="Formatting options">
            <div role="button" aria-label="Bold"></div>
            <div role="button" aria-label="Italic"></div>
            <div role="button" aria-label="Underline"></div>
          </div>
          <div id="xDescIn">
            <div aria-hidden="true">Add description</div>
            <div data-role="details-real"
                 role="textbox"
                 aria-multiline="true"
                 aria-label="Add description"
                 contenteditable="true"
                 g_editable="true">
              <div><br></div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</body></html>
// src/lib/details-locator.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { findDetailsEditor, waitForEventEditor } from './details-locator'
import fixture from '../../tests/fixtures/event-dialog.html?raw'

beforeEach(() => {
  document.body.innerHTML = fixture
})

describe('findDetailsEditor', () => {
  it('finds the description contenteditable, not the title decoy', () => {
    const dialog = document.querySelector('[role="dialog"]')!
    const editor = findDetailsEditor(dialog as HTMLElement)
    expect(editor).toBeInstanceOf(HTMLElement)
    expect(editor?.dataset.role).toBe('details-real')
  })

  it('returns null when no matching label exists', () => {
    document.body.innerHTML = '<div role="dialog"><div contenteditable="true"></div></div>'
    const dialog = document.querySelector('[role="dialog"]')!
    expect(findDetailsEditor(dialog as HTMLElement)).toBeNull()
  })

  it('also accepts "Add description" as aria-label', () => {
    document.body.innerHTML = `
      <div role="dialog">
        <div aria-label="Add description">
          <div contenteditable="true" data-role="real"></div>
        </div>
      </div>`
    const dialog = document.querySelector('[role="dialog"]')!
    expect(findDetailsEditor(dialog as HTMLElement)?.dataset.role).toBe('real')
  })
})

describe('waitForEventEditor', () => {
  it('resolves with the dialog when one appears', async () => {
    document.body.innerHTML = '<main></main>'
    const promise = waitForEventEditor(document.querySelector('main') as HTMLElement)
    const dialog = document.createElement('div')
    dialog.setAttribute('role', 'dialog')
    dialog.setAttribute('aria-label', 'Create event')
    document.querySelector('main')!.appendChild(dialog)
    const found = await promise
    expect(found).toBe(dialog)
  })

  it('times out and resolves null if no dialog appears within timeoutMs', async () => {
    const result = await waitForEventEditor(document.body, { timeoutMs: 20 })
    expect(result).toBeNull()
  })
})
npx vitest run src/lib/details-locator.test.ts

Expected: module-not-found.

const DESCRIPTION_LABELS = ['Description', 'Add description']

/**
 * Returns the Details contenteditable if the description panel is already
 * expanded, or null otherwise.
 */
export function findDetailsEditor(dialog: HTMLElement): HTMLElement | null {
  // 1) Real Google Calendar: editor lives inside #xDescIn
  const xDescIn = dialog.querySelector<HTMLElement>('#xDescIn')
  if (xDescIn) {
    const editable = xDescIn.querySelector<HTMLElement>('[contenteditable="true"]')
    if (editable) return editable
  }

  // 2) Generic role=textbox + aria-label
  const textbox = dialog.querySelector<HTMLElement>(
    '[contenteditable="true"][role="textbox"][aria-label="Add description"]',
  )
  if (textbox) return textbox

  // 3) Legacy aria-label="Description" wrapper containing a contenteditable
  for (const label of DESCRIPTION_LABELS) {
    const wrappers = dialog.querySelectorAll<HTMLElement>(`[aria-label="${label}"]`)
    for (const wrapper of wrappers) {
      const editable = wrapper.matches('[contenteditable="true"]')
        ? wrapper
        : wrapper.querySelector<HTMLElement>('[contenteditable="true"]')
      if (editable) return editable
    }
  }
  return null
}

/**
 * Returns the element to click to expand the description panel, or null.
 */
export function findDescriptionExpander(dialog: HTMLElement): HTMLElement | null {
  const dataKeySpan = dialog.querySelector<HTMLElement>('[data-key="description"]')
  if (dataKeySpan) return dataKeySpan

  const buttons = dialog.querySelectorAll<HTMLElement>('button')
  for (const btn of buttons) {
    const text = (btn.textContent ?? '').trim()
    if (text === 'Add description' || text === 'Add description or attachment') {
      return btn
    }
  }
  return null
}

export interface WaitOptions {
  /** Hard upper bound; resolves null if exceeded. Default: 15s */
  timeoutMs?: number
}

export function waitForEventEditor(
  root: HTMLElement,
  opts: WaitOptions = {},
): Promise<HTMLElement | null> {
  const timeoutMs = opts.timeoutMs ?? 15_000
  const existing = root.querySelector<HTMLElement>('[role="dialog"]')
  if (existing) return Promise.resolve(existing)
  return new Promise((resolve) => {
    const timer = setTimeout(() => { observer.disconnect(); resolve(null) }, timeoutMs)
    const observer = new MutationObserver(() => {
      const dialog = root.querySelector<HTMLElement>('[role="dialog"]')
      if (dialog) { clearTimeout(timer); observer.disconnect(); resolve(dialog) }
    })
    observer.observe(root, { childList: true, subtree: true })
  })
}

/** Poll until `find()` returns non-null or the timeout elapses. */
export function waitFor<T>(
  find: () => T | null,
  opts: { timeoutMs?: number; intervalMs?: number } = {},
): Promise<T | null> {
  const timeoutMs = opts.timeoutMs ?? 3_000
  const intervalMs = opts.intervalMs ?? 50
  const start = Date.now()
  return new Promise((resolve) => {
    const tick = () => {
      const r = find()
      if (r) return resolve(r)
      if (Date.now() - start >= timeoutMs) return resolve(null)
      setTimeout(tick, intervalMs)
    }
    tick()
  })
}

/** Google Calendar's empty Details editor is typically `<div><br></div>`. */
export function isEditorEmpty(editor: HTMLElement): boolean {
  const text = editor.textContent?.trim() ?? ''
  if (text.length > 0) return false
  const meaningful = editor.querySelector(':not(br):not(div):not(p)')
  if (meaningful) return false
  return true
}

Tests must cover both branches of findDetailsEditor (real-DOM #xDescIn path AND legacy aria-label="Description" fallback), both branches of findDescriptionExpander ([data-key="description"] AND the button fallback), and waitFor (resolves and times out).

Add to src/lib/details-locator.test.ts:

import { findDetailsEditor, waitForEventEditor, isEditorEmpty } from './details-locator'

describe('isEditorEmpty', () => {
  it('is true for <div></div>', () => {
    const el = document.createElement('div')
    expect(isEditorEmpty(el)).toBe(true)
  })

  it('is true for <div><br></div>', () => {
    const el = document.createElement('div')
    el.innerHTML = '<br>'
    expect(isEditorEmpty(el)).toBe(true)
  })

  it('is false when there is text', () => {
    const el = document.createElement('div')
    el.textContent = 'hello'
    expect(isEditorEmpty(el)).toBe(false)
  })

  it('is false when there is a structural child like <strong>', () => {
    const el = document.createElement('div')
    el.innerHTML = '<strong></strong>'
    expect(isEditorEmpty(el)).toBe(false)
  })
})
npx vitest run src/lib/details-locator.test.ts

Expected: 9 passing.


Task 9: Popup CSS design tokens

Files:

:root {
  --bg: #fdfcfa;
  --surface: #ffffff;
  --border: #e9e6e0;
  --border-soft: #f1ede6;
  --text: #37352f;
  --text-muted: #b5b2ab;
  --toolbar-bg: #f1ede6;
  --accent: #2eaadc;
  --primary: #37352f;
  --primary-text: #ffffff;
  --radius-sm: 6px;
  --radius-md: 9px;
  --radius-lg: 12px;
  --shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
  --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font);
  font-size: 13px;
  line-height: 1.45;
}

body {
  width: 380px;
  max-height: 600px;
  overflow: hidden;
}

#app {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.header {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 14px 16px 10px;
  border-bottom: 1px solid var(--border-soft);
  font-weight: 600;
}

.header-icon { font-size: 16px; }

.settings-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px 16px;
  font-size: 13px;
}

.toggle {
  width: 30px;
  height: 17px;
  border-radius: 9px;
  background: #d1d5db;
  position: relative;
  cursor: pointer;
  transition: background 0.15s;
  border: none;
  padding: 0;
}
.toggle[aria-checked="true"] { background: var(--accent); }
.toggle::after {
  content: "";
  position: absolute;
  top: 1px;
  left: 1px;
  width: 14px;
  height: 14px;
  background: #fff;
  border-radius: 50%;
  transition: transform 0.15s;
}
.toggle[aria-checked="true"]::after { transform: translateX(13px); }

.toolbar {
  display: flex;
  gap: 3px;
  padding: 6px 16px 0;
}
.tb-btn {
  font-size: 11px;
  padding: 4px 8px;
  border-radius: var(--radius-sm);
  background: var(--toolbar-bg);
  color: var(--text);
  border: none;
  cursor: pointer;
  font-family: inherit;
  min-width: 26px;
}
.tb-btn[aria-pressed="true"] {
  background: var(--primary);
  color: var(--primary-text);
}
.tb-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.editor-wrapper {
  margin: 8px 16px 0;
  padding: 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  min-height: 220px;
  max-height: 360px;
  overflow-y: auto;
  font-size: 12px;
}
.editor-wrapper .ProseMirror { outline: none; min-height: 200px; }
.editor-wrapper .ProseMirror p.is-editor-empty:first-child::before {
  content: attr(data-placeholder);
  float: left;
  color: var(--text-muted);
  pointer-events: none;
  height: 0;
}

.action-bar {
  display: flex;
  gap: 6px;
  justify-content: flex-end;
  padding: 10px 16px 14px;
}
.btn {
  font-size: 12px;
  padding: 5px 14px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-family: inherit;
}
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn-secondary { background: var(--surface); border: 1px solid var(--border); color: var(--text); }
.btn-primary { background: var(--primary); border: none; color: var(--primary-text); }

Task 10: popup.html entry

Files:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Meet Formatter</title>
    <link rel="stylesheet" href="./popup.css" />
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="./main.tsx"></script>
  </body>
</html>

Task 11: ActionBar component — TDD

Files:

// src/popup/ActionBar.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/preact'
import userEvent from '@testing-library/user-event'
import { ActionBar } from './ActionBar'

describe('ActionBar', () => {
  it('renders Save then Insert, with Save left of Insert', () => {
    render(<ActionBar onSave={() => {}} onInsert={() => {}} />)
    const buttons = screen.getAllByRole('button')
    expect(buttons[0]).toHaveTextContent('Save')
    expect(buttons[1]).toHaveTextContent('Insert')
  })

  it('clicking Save calls onSave', async () => {
    const onSave = vi.fn()
    render(<ActionBar onSave={onSave} onInsert={() => {}} />)
    await userEvent.click(screen.getByRole('button', { name: /save/i }))
    expect(onSave).toHaveBeenCalledOnce()
  })

  it('clicking Insert calls onInsert', async () => {
    const onInsert = vi.fn()
    render(<ActionBar onSave={() => {}} onInsert={onInsert} />)
    await userEvent.click(screen.getByRole('button', { name: /insert/i }))
    expect(onInsert).toHaveBeenCalledOnce()
  })

  it('shows "Saved ✓" briefly after save confirmation', async () => {
    const { rerender } = render(
      <ActionBar onSave={() => {}} onInsert={() => {}} confirmation="saved" />,
    )
    expect(screen.getByRole('button', { name: /saved/i })).toBeInTheDocument()
    rerender(<ActionBar onSave={() => {}} onInsert={() => {}} confirmation="idle" />)
    expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument()
  })
})
npx vitest run src/popup/ActionBar.test.tsx

Expected: module-not-found.

export type SaveConfirmation = 'idle' | 'saved'

export interface ActionBarProps {
  onSave: () => void
  onInsert: () => void
  confirmation?: SaveConfirmation
}

export function ActionBar({ onSave, onInsert, confirmation = 'idle' }: ActionBarProps) {
  const saveLabel = confirmation === 'saved' ? 'Saved ✓' : 'Save'
  return (
    <div class="action-bar">
      <button type="button" class="btn btn-secondary" onClick={onSave}>
        {saveLabel}
      </button>
      <button type="button" class="btn btn-primary" onClick={onInsert}>
        Insert
      </button>
    </div>
  )
}
npx vitest run src/popup/ActionBar.test.tsx

Expected: 4 passing.


Task 12: SettingsRow component — TDD

Files:

// src/popup/SettingsRow.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/preact'
import userEvent from '@testing-library/user-event'
import { SettingsRow } from './SettingsRow'

describe('SettingsRow', () => {
  it('renders the auto-insert label and toggle', () => {
    render(<SettingsRow autoInsert={false} onChange={() => {}} />)
    expect(screen.getByText(/auto-insert on event open/i)).toBeInTheDocument()
    expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
  })

  it('reflects autoInsert=true in aria-checked', () => {
    render(<SettingsRow autoInsert={true} onChange={() => {}} />)
    expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
  })

  it('clicking the toggle calls onChange with the inverse value', async () => {
    const onChange = vi.fn()
    render(<SettingsRow autoInsert={false} onChange={onChange} />)
    await userEvent.click(screen.getByRole('switch'))
    expect(onChange).toHaveBeenCalledWith(true)
  })
})
npx vitest run src/popup/SettingsRow.test.tsx
export interface SettingsRowProps {
  autoInsert: boolean
  onChange: (next: boolean) => void
}

export function SettingsRow({ autoInsert, onChange }: SettingsRowProps) {
  return (
    <div class="settings-row">
      <span id="auto-insert-label">Auto-insert on event open</span>
      <button
        type="button"
        role="switch"
        aria-checked={autoInsert}
        aria-labelledby="auto-insert-label"
        class="toggle"
        onClick={() => onChange(!autoInsert)}
      />
    </div>
  )
}
npx vitest run src/popup/SettingsRow.test.tsx

Expected: 3 passing.


Task 13: Toolbar component — TDD

Files:

// src/popup/Toolbar.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/preact'
import userEvent from '@testing-library/user-event'
import { Toolbar } from './Toolbar'

const labels = ['Bold', 'Italic', 'Underline', 'Bullet list', 'Ordered list', 'Heading']

describe('Toolbar', () => {
  it('renders six buttons in the documented order', () => {
    render(<Toolbar active= onCommand={() => {}} />)
    const buttons = screen.getAllByRole('button')
    expect(buttons).toHaveLength(6)
    for (let i = 0; i < labels.length; i++) {
      expect(buttons[i]).toHaveAccessibleName(new RegExp(labels[i]!, 'i'))
    }
  })

  it('aria-pressed reflects active marks', () => {
    render(<Toolbar active= onCommand={() => {}} />)
    expect(screen.getByRole('button', { name: /bold/i })).toHaveAttribute('aria-pressed', 'true')
    expect(screen.getByRole('button', { name: /italic/i })).toHaveAttribute('aria-pressed', 'false')
  })

  it('clicking a button fires onCommand with its kind', async () => {
    const onCommand = vi.fn()
    render(<Toolbar active= onCommand={onCommand} />)
    await userEvent.click(screen.getByRole('button', { name: /bullet list/i }))
    expect(onCommand).toHaveBeenCalledWith('bulletList')
  })
})
npx vitest run src/popup/Toolbar.test.tsx
export type ToolbarCommand =
  | 'bold' | 'italic' | 'underline'
  | 'bulletList' | 'orderedList'
  | 'heading'

export interface ToolbarProps {
  active: Partial<Record<ToolbarCommand, boolean>>
  onCommand: (kind: ToolbarCommand) => void
}

const BUTTONS: { kind: ToolbarCommand; label: string; glyph: string }[] = [
  { kind: 'bold',        label: 'Bold',         glyph: 'B' },
  { kind: 'italic',      label: 'Italic',       glyph: 'I' },
  { kind: 'underline',   label: 'Underline',    glyph: 'U' },
  { kind: 'bulletList',  label: 'Bullet list',  glyph: '' },
  { kind: 'orderedList', label: 'Ordered list', glyph: '1.' },
  { kind: 'heading',     label: 'Heading',      glyph: 'H' },
]

export function Toolbar({ active, onCommand }: ToolbarProps) {
  return (
    <div class="toolbar" role="toolbar" aria-label="Text formatting">
      {BUTTONS.map((b) => (
        <button
          key={b.kind}
          type="button"
          class="tb-btn"
          aria-label={b.label}
          aria-pressed={Boolean(active[b.kind])}
          onClick={() => onCommand(b.kind)}
        >
          {b.glyph}
        </button>
      ))}
    </div>
  )
}
npx vitest run src/popup/Toolbar.test.tsx

Expected: 3 passing.


Task 14: useAutoSave hook — TDD

Files:

// src/popup/useAutoSave.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/preact'
import { useAutoSave } from './useAutoSave'

beforeEach(() => {
  vi.useFakeTimers()
})

describe('useAutoSave', () => {
  it('debounces save: calls once after the wait, not on every change', () => {
    const save = vi.fn()
    const { result } = renderHook(() => useAutoSave(save, 400))

    act(() => { result.current.schedule('one') })
    act(() => { result.current.schedule('two') })
    act(() => { result.current.schedule('three') })

    expect(save).not.toHaveBeenCalled()
    act(() => { vi.advanceTimersByTime(400) })
    expect(save).toHaveBeenCalledTimes(1)
    expect(save).toHaveBeenCalledWith('three')
  })

  it('flush() persists the pending value immediately', () => {
    const save = vi.fn()
    const { result } = renderHook(() => useAutoSave(save, 400))

    act(() => { result.current.schedule('pending') })
    act(() => { result.current.flush() })

    expect(save).toHaveBeenCalledWith('pending')
    act(() => { vi.advanceTimersByTime(400) })
    expect(save).toHaveBeenCalledTimes(1)
  })

  it('flush() is a no-op when nothing is pending', () => {
    const save = vi.fn()
    const { result } = renderHook(() => useAutoSave(save, 400))
    act(() => { result.current.flush() })
    expect(save).not.toHaveBeenCalled()
  })
})
npx vitest run src/popup/useAutoSave.test.tsx
import { useEffect, useRef } from 'preact/hooks'

export interface AutoSave<T> {
  schedule: (value: T) => void
  flush: () => void
}

export function useAutoSave<T>(save: (v: T) => void, waitMs: number): AutoSave<T> {
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
  const pending = useRef<{ value: T } | null>(null)
  const saveRef = useRef(save)
  saveRef.current = save

  useEffect(() => () => {
    if (timer.current) clearTimeout(timer.current)
  }, [])

  function flush() {
    if (timer.current) {
      clearTimeout(timer.current)
      timer.current = null
    }
    if (pending.current) {
      const v = pending.current.value
      pending.current = null
      saveRef.current(v)
    }
  }

  function schedule(value: T) {
    pending.current = { value }
    if (timer.current) clearTimeout(timer.current)
    timer.current = setTimeout(flush, waitMs)
  }

  return { schedule, flush }
}
npx vitest run src/popup/useAutoSave.test.tsx

Expected: 3 passing.


Task 15: Editor component (Tiptap + Markdown)

Files:

Note: Tiptap’s ProseMirror view layer has some quirks in happy-dom. The unit test here exercises the wrapper’s API (props, onUpdate firing) rather than ProseMirror’s internal selection model. End-to-end formatting is exercised in the Playwright tests in Task 22+.

// src/popup/Editor.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, waitFor } from '@testing-library/preact'
import { Editor } from './Editor'

describe('Editor', () => {
  it('renders a ProseMirror surface with the placeholder', async () => {
    const { container } = render(
      <Editor markdown="" placeholder="Markdown supported" onChange={() => {}} onActiveMarksChange={() => {}} />,
    )
    await waitFor(() => {
      const pm = container.querySelector('.ProseMirror')
      expect(pm).not.toBeNull()
    })
    const empty = container.querySelector('p.is-editor-empty')
    expect(empty?.getAttribute('data-placeholder')).toBe('Markdown supported')
  })

  it('loading initial markdown produces matching HTML', async () => {
    const { container } = render(
      <Editor markdown="**hi**" placeholder="" onChange={() => {}} onActiveMarksChange={() => {}} />,
    )
    await waitFor(() => {
      expect(container.querySelector('strong')?.textContent).toBe('hi')
    })
  })

  it('typing fires onChange with the serialized markdown', async () => {
    const onChange = vi.fn()
    const { container } = render(
      <Editor markdown="" placeholder="" onChange={onChange} onActiveMarksChange={() => {}} />,
    )
    await waitFor(() => container.querySelector('.ProseMirror'))
    const pm = container.querySelector('.ProseMirror') as HTMLElement
    pm.focus()
    pm.textContent = 'hello'
    pm.dispatchEvent(new Event('input', { bubbles: true }))
    await waitFor(() => expect(onChange).toHaveBeenCalled())
    expect(onChange.mock.calls.at(-1)?.[0]).toContain('hello')
  })
})
npx vitest run src/popup/Editor.test.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import Placeholder from '@tiptap/extension-placeholder'
import type { Editor as TiptapEditor } from '@tiptap/core'
import { useEffect, forwardRef, useImperativeHandle } from 'preact/compat'
import { serializeDocToMarkdown, parseMarkdownToHtml } from './markdown-bridge'
import type { ToolbarCommand } from './Toolbar'

export interface EditorProps {
  markdown: string
  placeholder: string
  onChange: (markdown: string) => void
  onActiveMarksChange: (active: Partial<Record<ToolbarCommand, boolean>>) => void
}

export interface EditorHandle {
  runCommand: (kind: ToolbarCommand) => void
}

export const Editor = forwardRef<EditorHandle, EditorProps>(function Editor(
  { markdown, placeholder, onChange, onActiveMarksChange },
  ref,
) {
  const editor = useEditor({
    extensions: [
      StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
      Underline,
      Placeholder.configure({ placeholder }),
    ],
    content: parseMarkdownToHtml(markdown),
    onUpdate: ({ editor }) => {
      onChange(serializeDocToMarkdown(editor))
      onActiveMarksChange(readActiveMarks(editor))
    },
    onSelectionUpdate: ({ editor }) => onActiveMarksChange(readActiveMarks(editor)),
  })

  useEffect(() => {
    if (!editor) return
    const current = serializeDocToMarkdown(editor)
    if (markdown !== current) {
      editor.commands.setContent(parseMarkdownToHtml(markdown), false)
    }
  }, [editor, markdown])

  useImperativeHandle(ref, () => ({
    runCommand(kind) {
      if (!editor) return
      const chain = editor.chain().focus()
      switch (kind) {
        case 'bold':        chain.toggleBold().run(); break
        case 'italic':      chain.toggleItalic().run(); break
        case 'underline':   chain.toggleUnderline().run(); break
        case 'bulletList':  chain.toggleBulletList().run(); break
        case 'orderedList': chain.toggleOrderedList().run(); break
        case 'heading':     chain.toggleHeading({ level: 2 }).run(); break
      }
    },
  }), [editor])

  return (
    <div class="editor-wrapper">
      <EditorContent editor={editor} />
    </div>
  )
})

function readActiveMarks(editor: TiptapEditor): Partial<Record<ToolbarCommand, boolean>> {
  return {
    bold:        editor.isActive('bold'),
    italic:      editor.isActive('italic'),
    underline:   editor.isActive('underline'),
    bulletList:  editor.isActive('bulletList'),
    orderedList: editor.isActive('orderedList'),
    heading:     editor.isActive('heading'),
  }
}

Create src/popup/markdown-bridge.ts:

import { marked } from 'marked'
import type { Editor as TiptapEditor } from '@tiptap/core'
import TurndownService from 'turndown'

// Lazily constructed because turndown reaches into globalThis.
let _td: TurndownService | null = null
function td(): TurndownService {
  if (_td) return _td
  _td = new TurndownService({
    headingStyle: 'atx',
    bulletListMarker: '-',
    codeBlockStyle: 'fenced',
  })
  _td.addRule('underline', {
    filter: ['u'],
    replacement: (content) => `<u>${content}</u>`,
  })
  return _td
}

export function parseMarkdownToHtml(md: string): string {
  if (!md) return ''
  return marked.parse(md, { async: false }) as string
}

export function serializeDocToMarkdown(editor: TiptapEditor): string {
  const html = editor.getHTML()
  return td().turndown(html)
}

Edit package.json and add to dependencies:

"turndown": "^7.2.0"

Then add to devDependencies:

"@types/turndown": "^5.0.5"

Run:

npm install
npx vitest run src/popup/Editor.test.tsx

Expected: 3 passing.

If the typing fires onChange test is flaky in happy-dom because ProseMirror swallows the synthetic input event, replace the inner pm.textContent = … portion with an Editor-imperative call: import EditorHandle via ref and invoke runCommand('bold') instead. The point of this test is to confirm onChange wiring, not to validate ProseMirror’s input pipeline (that’s the E2E job).


Task 16: App component — wires everything together

Files:

// src/popup/App.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/preact'
import userEvent from '@testing-library/user-event'
import { App } from './App'
import { setSettings, getSettings } from '../lib/storage'

beforeEach(() => {
  vi.useFakeTimers({ shouldAdvanceTime: true })
})

describe('App', () => {
  it('loads existing settings into the UI on mount', async () => {
    await setSettings({ template: '# saved', autoInsert: true })
    render(<App />)
    await waitFor(() => {
      expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
    })
  })

  it('toggling auto-insert persists', async () => {
    render(<App />)
    await waitFor(() => screen.getByRole('switch'))
    await userEvent.click(screen.getByRole('switch'))
    await waitFor(async () => {
      expect((await getSettings()).autoInsert).toBe(true)
    })
  })

  it('clicking Save flushes auto-save and shows Saved ✓', async () => {
    render(<App />)
    await waitFor(() => screen.getByRole('button', { name: /^save$/i }))
    await userEvent.click(screen.getByRole('button', { name: /^save$/i }))
    expect(screen.getByRole('button', { name: /saved/i })).toBeInTheDocument()
  })

  it('clicking Insert sends an INSERT_TEMPLATE message', async () => {
    await setSettings({ template: 'hello', autoInsert: false })
    const sendMessage = vi.spyOn(chrome.runtime, 'sendMessage')
    render(<App />)
    await waitFor(() => screen.getByRole('button', { name: /insert/i }))
    await userEvent.click(screen.getByRole('button', { name: /insert/i }))
    expect(sendMessage).toHaveBeenCalledWith(
      expect.objectContaining({ kind: 'INSERT_TEMPLATE', markdown: 'hello', manual: true }),
    )
  })
})
npx vitest run src/popup/App.test.tsx
import { useEffect, useRef, useState } from 'preact/hooks'
import { Editor } from './Editor'
import type { EditorHandle } from './Editor'
import { Toolbar } from './Toolbar'
import type { ToolbarCommand } from './Toolbar'
import { SettingsRow } from './SettingsRow'
import { ActionBar } from './ActionBar'
import type { SaveConfirmation } from './ActionBar'
import { getSettings, setSettings, defaultSettings, type Settings } from '../lib/storage'
import { useAutoSave } from './useAutoSave'
import { MessageKind, type InsertTemplateMessage } from '../messages'

const AUTO_SAVE_MS = 400
const CONFIRMATION_MS = 1200
const PLACEHOLDER = 'Markdown supported'

export function App() {
  const [settings, setLocal] = useState<Settings>(defaultSettings)
  const [active, setActive] = useState<Partial<Record<ToolbarCommand, boolean>>>({})
  const [confirmation, setConfirmation] = useState<SaveConfirmation>('idle')
  const editorRef = useRef<EditorHandle>(null)

  useEffect(() => {
    getSettings().then(setLocal)
  }, [])

  const autoSave = useAutoSave((md: string) => {
    setSettings({ template: md })
  }, AUTO_SAVE_MS)

  function onEditorChange(md: string) {
    setLocal((s) => ({ ...s, template: md }))
    autoSave.schedule(md)
  }

  function onAutoInsertChange(next: boolean) {
    setLocal((s) => ({ ...s, autoInsert: next }))
    setSettings({ autoInsert: next })
  }

  function onSaveClick() {
    autoSave.flush()
    setConfirmation('saved')
    setTimeout(() => setConfirmation('idle'), CONFIRMATION_MS)
  }

  function onInsertClick() {
    autoSave.flush()
    const msg: InsertTemplateMessage = {
      kind: MessageKind.InsertTemplate,
      markdown: settings.template,
      manual: true,
    }
    chrome.runtime.sendMessage(msg)
  }

  return (
    <>
      <div class="header">
        <span class="header-icon" aria-hidden="true">📅</span>
        <span>Meet Formatter</span>
      </div>
      <SettingsRow autoInsert={settings.autoInsert} onChange={onAutoInsertChange} />
      <Toolbar active={active} onCommand={(c) => editorRef.current?.runCommand(c)} />
      <Editor
        ref={editorRef}
        markdown={settings.template}
        placeholder={PLACEHOLDER}
        onChange={onEditorChange}
        onActiveMarksChange={setActive}
      />
      <ActionBar onSave={onSaveClick} onInsert={onInsertClick} confirmation={confirmation} />
    </>
  )
}
import { render } from 'preact'
import { App } from './App'

const root = document.getElementById('app')!
render(<App />, root)
npx vitest run src/popup/App.test.tsx

Expected: 4 passing.

npm test

Expected: every spec green.


Task 17: Background service worker — message router

Files:

// src/background/service-worker.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { MessageKind } from '../messages'
import { handleMessage } from './service-worker'

describe('service-worker handleMessage', () => {
  beforeEach(() => {
    ;(chrome.tabs.query as ReturnType<typeof vi.fn>).mockReset()
    ;(chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).mockReset()
  })

  it('forwards INSERT_TEMPLATE to the active calendar tab', async () => {
    ;(chrome.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValue([
      { id: 42, url: 'https://calendar.google.com/calendar/u/0/r' },
    ])
    await handleMessage({ kind: MessageKind.InsertTemplate, markdown: 'hi', manual: true })
    expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
      42,
      expect.objectContaining({ kind: 'INSERT_TEMPLATE', markdown: 'hi' }),
    )
  })

  it('ignores when no calendar tab is active', async () => {
    ;(chrome.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValue([])
    await handleMessage({ kind: MessageKind.InsertTemplate, markdown: 'hi', manual: true })
    expect(chrome.tabs.sendMessage).not.toHaveBeenCalled()
  })
})
npx vitest run src/background/service-worker.test.ts
import { MessageKind, type ExtensionMessage } from '../messages'

const CALENDAR_URL_MATCH = '*://calendar.google.com/*'

export async function handleMessage(message: ExtensionMessage): Promise<void> {
  switch (message.kind) {
    case MessageKind.InsertTemplate: {
      // Query ANY open calendar tab — not just `active:true`+`currentWindow`.
      // When the popup is open, the calendar tab isn't the active one, and the
      // popup may live in its own window. Filtering on `active` would return
      // the popup instead of the calendar tab. host_permissions in the manifest
      // is required for the URL field to be populated on the query result.
      const tabs = await chrome.tabs.query({ url: CALENDAR_URL_MATCH })
      const tabId = tabs.find((t) => typeof t.id === 'number')?.id
      if (typeof tabId !== 'number') return
      await chrome.tabs.sendMessage(tabId, message)
      return
    }
  }
}

if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage) {
  chrome.runtime.onMessage.addListener((message: unknown) => {
    void handleMessage(message as ExtensionMessage)
  })
}
npx vitest run src/background/service-worker.test.ts

Expected: 2 passing.


Task 18: Content script — calendar injector

Files:

// src/content/calendar-injector.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import fixture from '../../tests/fixtures/event-dialog.html?raw'
import { injectTemplate, INSERTED_FLAG } from './calendar-injector'

beforeEach(() => {
  document.body.innerHTML = fixture
})

function realEditor() {
  return document.querySelector<HTMLElement>('[data-role="details-real"]')!
}

describe('injectTemplate', () => {
  it('inserts sanitized HTML when Details is empty', () => {
    injectTemplate({ markdown: '**hi**', manual: false })
    expect(realEditor().innerHTML).toContain('<strong>hi</strong>')
  })

  it('does NOT overwrite non-empty Details when manual=false', () => {
    realEditor().textContent = 'user wrote this'
    injectTemplate({ markdown: '**hi**', manual: false })
    expect(realEditor().textContent).toBe('user wrote this')
  })

  it('appends to (does not replace) non-empty Details when manual=true', () => {
    realEditor().textContent = 'user wrote this'
    injectTemplate({ markdown: '**hi**', manual: true })
    expect(realEditor().innerHTML).toContain('<strong>hi</strong>')
    expect(realEditor().textContent).toContain('user wrote this')
  })

  it('flags the editor so a second auto-insert is a no-op', () => {
    injectTemplate({ markdown: '**one**', manual: false })
    injectTemplate({ markdown: '**two**', manual: false })
    expect(realEditor().innerHTML).toContain('<strong>one</strong>')
    expect(realEditor().innerHTML).not.toContain('<strong>two</strong>')
    expect(realEditor().dataset[INSERTED_FLAG]).toBe('true')
  })

  it('strips <script> tags from the markdown payload', () => {
    injectTemplate({ markdown: '<script>alert(1)</script>**ok**', manual: true })
    expect(realEditor().innerHTML).not.toContain('<script>')
    expect(realEditor().innerHTML).toContain('<strong>ok</strong>')
  })

  it('does nothing if no Details editor is present', () => {
    document.body.innerHTML = '<div role="dialog"></div>'
    expect(() => injectTemplate({ markdown: 'x', manual: false })).not.toThrow()
  })

  it('dispatches an input event so Calendar persists the change', () => {
    const editor = realEditor()
    const listener = vi.fn()
    editor.addEventListener('input', listener)
    injectTemplate({ markdown: 'hi', manual: false })
    expect(listener).toHaveBeenCalled()
  })
})
npx vitest run src/content/calendar-injector.test.ts
import { MessageKind, type ExtensionMessage } from '../messages'
import { markdownToSanitizedHtml } from '../lib/markdown'
import {
  findDetailsEditor,
  findDescriptionExpander,
  isEditorEmpty,
  waitFor,
} from '../lib/details-locator'
import { getSettings, onSettingsChanged } from '../lib/storage'

export const INSERTED_FLAG = 'meetFormatterInserted'

export interface InjectArgs {
  markdown: string
  manual: boolean
}

/**
 * Synchronous, fixture-friendly injection used by unit tests. Requires the
 * editor to already be present in the DOM.
 */
export function injectTemplate({ markdown, manual }: InjectArgs): void {
  const dialog = document.querySelector<HTMLElement>('[role="dialog"]')
  if (!dialog) return
  const editor = findDetailsEditor(dialog)
  if (!editor) return
  applyInjection(editor, markdown, manual)
}

/**
 * Runtime version against real Google Calendar: clicks the description
 * expander if the panel is still collapsed, then waits for the contenteditable
 * to appear, then injects.
 */
export async function ensureAndInject({ markdown, manual }: InjectArgs): Promise<boolean> {
  const dialog = document.querySelector<HTMLElement>('[role="dialog"]')
  if (!dialog) return false

  let editor = findDetailsEditor(dialog)
  if (!editor) {
    const expander = findDescriptionExpander(dialog)
    if (!expander) return false
    expander.click()
    editor = await waitFor(() => findDetailsEditor(dialog), { timeoutMs: 2_000 })
    if (!editor) return false
  }
  return applyInjection(editor, markdown, manual)
}

function applyInjection(editor: HTMLElement, markdown: string, manual: boolean): boolean {
  const empty = isEditorEmpty(editor)
  if (!manual && !empty) return false
  if (!manual && editor.dataset[INSERTED_FLAG] === 'true') return false

  const html = markdownToSanitizedHtml(markdown)
  if (!html) return false

  if (manual && !empty) {
    editor.insertAdjacentHTML('beforeend', html)
  } else {
    editor.innerHTML = html
  }
  editor.dataset[INSERTED_FLAG] = 'true'
  editor.dispatchEvent(new Event('input', { bubbles: true }))
  return true
}

// --- Runtime wiring (no-op in unit tests where chrome.runtime is mocked) ---

let unsubscribeSettings: (() => void) | null = null
let currentAutoInsert = false

async function ensureAutoInsert() {
  const settings = await getSettings()
  currentAutoInsert = settings.autoInsert
  unsubscribeSettings?.()
  unsubscribeSettings = onSettingsChanged((s) => {
    currentAutoInsert = s.autoInsert
  })
}

async function attachAutoInsertObserver() {
  await ensureAutoInsert()
  const observer = new MutationObserver(async () => {
    if (!currentAutoInsert) return
    const dialog = document.querySelector<HTMLElement>('[role="dialog"]')
    if (!dialog) return
    // Cheap early-out: if neither editor nor expander is in this mutation batch
    if (!findDetailsEditor(dialog) && !findDescriptionExpander(dialog)) return
    // Once-per-dialog guard
    const probeEditor = findDetailsEditor(dialog)
    if (probeEditor?.dataset[INSERTED_FLAG] === 'true') return
    const { template } = await getSettings()
    if (!template) return
    await ensureAndInject({ markdown: template, manual: false })
  })
  observer.observe(document.body, { childList: true, subtree: true })
}

if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage) {
  chrome.runtime.onMessage.addListener((msg: unknown) => {
    const m = msg as ExtensionMessage
    if (m.kind === MessageKind.InsertTemplate) {
      void ensureAndInject({ markdown: m.markdown, manual: m.manual })
    }
  })
  void attachAutoInsertObserver()
}

The unit tests must cover both the synchronous injectTemplate (when the editor is already in the fixture) AND the async ensureAndInject (collapsed → click expander → editor appears → inject).

npx vitest run src/content/calendar-injector.test.ts

Expected: 7 passing.


Task 19: First production build (smoke test)

npm run typecheck

Expected: zero errors.

npm run build

Expected: dist/ directory created. Should contain:

ls dist/
test -f dist/manifest.json && echo "manifest ok"
test -d dist/assets && echo "assets ok"

Expected: both ok lines.

Manually: open chrome://extensions, enable Developer mode, click “Load unpacked”, pick the dist/ directory. Click the toolbar icon — the popup should open and show the header + toggle + toolbar + empty editor with “Markdown supported” placeholder + Save/Insert buttons.

Type some Markdown (e.g. **bold**) — confirm bold appears. Close the popup, reopen it — text should persist (auto-save worked).


Task 20: Fake Calendar fixture for E2E

Files:

<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Fake Calendar</title>
  <style>
    body { font-family: -apple-system, sans-serif; padding: 20px; background: #fafafa; }
    button { padding: 8px 16px; cursor: pointer; }
    #dialog { display: none; margin-top: 16px; padding: 16px; background: white; border: 1px solid #ddd; border-radius: 8px; max-width: 480px; }
    #dialog[data-open="true"] { display: block; }
    .field { margin-bottom: 12px; }
    .field label { display: block; font-size: 12px; color: #555; margin-bottom: 4px; }
    [contenteditable] { min-height: 60px; padding: 8px; border: 1px solid #ccc; border-radius: 4px; background: white; }
    [contenteditable]:empty::before { content: attr(data-empty-text); color: #aaa; }
  </style>
</head>
<body>
  <h1>Fake Calendar</h1>
  <button id="create">+ Create event</button>

  <div id="dialog" role="dialog" aria-label="Create event">
    <div class="field">
      <label>Title</label>
      <div contenteditable="true" data-role="title-decoy"></div>
    </div>
    <div class="field">
      <label aria-label="Description">Description</label>
      <div aria-label="Description">
        <div contenteditable="true" data-role="details-real" data-empty-text="Add a description"></div>
      </div>
    </div>
    <button id="save-event">Save</button>
  </div>

  <script>
    document.getElementById('create').addEventListener('click', () => {
      const d = document.getElementById('dialog')
      d.setAttribute('data-open', 'true')
    })
    document.getElementById('save-event').addEventListener('click', () => {
      const details = document.querySelector('[data-role="details-real"]')
      window.__savedDescription = details ? details.innerHTML : ''
      document.getElementById('dialog').setAttribute('data-open', 'false')
    })
  </script>
</body>
</html>

Task 21: Playwright config

Files:

cd "/Users/pbrowne/scripts/Google Meets Formatter/tests/fixtures"
openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout key.pem -out cert.pem -days 3650 \
  -subj "/CN=calendar.google.com"

The cert + key go under tests/fixtures/ and are listed in .gitignore. They’re consumed by http-server -S -C cert.pem -K key.pem so the test server can speak HTTPS. Chrome’s HSTS preload list force-upgrades any http://calendar.google.com/... URL to HTTPS, so the fixture server must serve TLS — Chrome’s --ignore-certificate-errors flag (added in the E2E launch args, Task 23) lets it accept the self-signed cert.

import { defineConfig } from '@playwright/test'
import path from 'node:path'

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,
  fullyParallel: false,
  workers: 1,
  reporter: 'list',
  use: {
    baseURL: 'https://localhost:5174',
    actionTimeout: 5_000,
    trace: 'retain-on-failure',
    video: 'retain-on-failure',
  },
  webServer: {
    command:
      'npx http-server tests/fixtures -p 5174 -s ' +
      '-S -C tests/fixtures/cert.pem -K tests/fixtures/key.pem',
    url: 'https://localhost:5174/fake-calendar.html',
    ignoreHTTPSErrors: true,
    reuseExistingServer: true,
    timeout: 10_000,
  },
})

export const EXTENSION_PATH = path.resolve('./dist')

E2E spec launch flags (in Task 23) include --ignore-certificate-errors and --host-resolver-rules=MAP calendar.google.com 127.0.0.1:5174.

In package.json devDependencies:

"http-server": "^14.1.1"

Then:

npm install

Task 22: E2E — popup + auto-save

Files:

import { test, expect, chromium, type BrowserContext } from '@playwright/test'
import path from 'node:path'
import os from 'node:os'
import fs from 'node:fs'

const EXT_PATH = path.resolve('./dist')

async function launchExtension(): Promise<BrowserContext> {
  const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meet-formatter-'))
  return chromium.launchPersistentContext(userDataDir, {
    headless: true,
    channel: 'chromium',
    args: [
      `--disable-extensions-except=${EXT_PATH}`,
      `--load-extension=${EXT_PATH}`,
      '--no-sandbox',
    ],
  })
}

async function openPopup(context: BrowserContext) {
  // Resolve extension ID by inspecting service workers.
  let worker = context.serviceWorkers()[0]
  if (!worker) worker = await context.waitForEvent('serviceworker')
  const extId = new URL(worker.url()).host
  const popup = await context.newPage()
  await popup.goto(`chrome-extension://${extId}/src/popup/popup.html`)
  return popup
}

test('popup loads and shows the empty editor with placeholder', async () => {
  const ctx = await launchExtension()
  try {
    const popup = await openPopup(ctx)
    await expect(popup.locator('text=Meet Formatter')).toBeVisible()
    await expect(popup.locator('text=Auto-insert on event open')).toBeVisible()
    await expect(popup.locator('.ProseMirror')).toBeVisible()
    await expect(popup.locator('p.is-editor-empty'))
      .toHaveAttribute('data-placeholder', 'Markdown supported')
    await expect(popup.locator('button:has-text("Save")')).toBeVisible()
    await expect(popup.locator('button:has-text("Insert")')).toBeVisible()
  } finally {
    await ctx.close()
  }
})

test('typing in the editor auto-saves; reopening shows the same content', async () => {
  const ctx = await launchExtension()
  try {
    let popup = await openPopup(ctx)
    await popup.locator('.ProseMirror').click()
    await popup.keyboard.type('Hello world')
    await popup.waitForTimeout(600) // > 400 ms debounce
    await popup.close()

    popup = await openPopup(ctx)
    await expect(popup.locator('.ProseMirror')).toContainText('Hello world')
  } finally {
    await ctx.close()
  }
})

test('Save button shows "Saved ✓" confirmation', async () => {
  const ctx = await launchExtension()
  try {
    const popup = await openPopup(ctx)
    await popup.locator('button:has-text("Save")').click()
    await expect(popup.locator('button:has-text("Saved")')).toBeVisible()
  } finally {
    await ctx.close()
  }
})
npm run build
npm run test:e2e -- tests/e2e/popup.spec.ts

Expected: 3 passing.


Task 23: E2E — auto-insert + manual-insert against fake Calendar

Files:

import { test, expect, chromium, type BrowserContext, type Page } from '@playwright/test'
import path from 'node:path'
import os from 'node:os'
import fs from 'node:fs'

const EXT_PATH = path.resolve('./dist')

// Important: the content script's match pattern is calendar.google.com only.
// For E2E we serve the fake page from a hosts-mapped subdomain by using
// Playwright's request routing to alias calendar.google.com → localhost:5174.

async function launchExtension(): Promise<BrowserContext> {
  const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meet-formatter-'))
  return chromium.launchPersistentContext(userDataDir, {
    headless: true,
    channel: 'chromium',
    args: [
      `--disable-extensions-except=${EXT_PATH}`,
      `--load-extension=${EXT_PATH}`,
      '--no-sandbox',
      '--host-resolver-rules=MAP calendar.google.com 127.0.0.1:5174',
      '--ignore-certificate-errors',
    ],
  })
}

async function openPopup(context: BrowserContext): Promise<Page> {
  let worker = context.serviceWorkers()[0]
  if (!worker) worker = await context.waitForEvent('serviceworker')
  const extId = new URL(worker.url()).host
  const popup = await context.newPage()
  await popup.goto(`chrome-extension://${extId}/src/popup/popup.html`)
  return popup
}

async function seedTemplate(context: BrowserContext, markdown: string, autoInsert: boolean) {
  const popup = await openPopup(context)
  // Use the popup's storage API directly via evaluate
  await popup.evaluate(
    async ([md, auto]) => {
      await chrome.storage.sync.set({ settings: { template: md, autoInsert: auto } })
    },
    [markdown, autoInsert] as const,
  )
  await popup.close()
}

test('auto-insert fills empty Details when toggle is on', async () => {
  const ctx = await launchExtension()
  try {
    await seedTemplate(ctx, '**Agenda**\n- one\n- two', true)
    const page = await ctx.newPage()
    await page.goto('https://calendar.google.com/fake-calendar.html')
    await page.locator('#create').click()
    await expect(page.locator('[data-role="details-real"] strong')).toHaveText('Agenda')
    await expect(page.locator('[data-role="details-real"] li')).toHaveCount(2)
  } finally {
    await ctx.close()
  }
})

test('auto-insert does NOT overwrite non-empty Details', async () => {
  const ctx = await launchExtension()
  try {
    await seedTemplate(ctx, '**Agenda**', true)
    const page = await ctx.newPage()
    await page.goto('https://calendar.google.com/fake-calendar.html')
    await page.evaluate(() => {
      const e = document.querySelector('[data-role="details-real"]') as HTMLElement
      e.textContent = 'pre-existing'
    })
    await page.locator('#create').click()
    await expect(page.locator('[data-role="details-real"]')).toContainText('pre-existing')
    await expect(page.locator('[data-role="details-real"] strong')).toHaveCount(0)
  } finally {
    await ctx.close()
  }
})

test('auto-insert is skipped when toggle is off', async () => {
  const ctx = await launchExtension()
  try {
    await seedTemplate(ctx, '**Agenda**', false)
    const page = await ctx.newPage()
    await page.goto('https://calendar.google.com/fake-calendar.html')
    await page.locator('#create').click()
    await expect(page.locator('[data-role="details-real"] strong')).toHaveCount(0)
  } finally {
    await ctx.close()
  }
})

test('manual Insert appends even when Details is non-empty', async () => {
  const ctx = await launchExtension()
  try {
    await seedTemplate(ctx, '**Agenda**', false)
    const page = await ctx.newPage()
    await page.goto('https://calendar.google.com/fake-calendar.html')
    await page.evaluate(() => {
      const e = document.querySelector('[data-role="details-real"]') as HTMLElement
      e.textContent = 'user typed first'
    })
    await page.locator('#create').click()

    const popup = await openPopup(ctx)
    await popup.locator('button:has-text("Insert")').click()
    await popup.close()

    await expect(page.locator('[data-role="details-real"]')).toContainText('user typed first')
    await expect(page.locator('[data-role="details-real"] strong')).toHaveText('Agenda')
  } finally {
    await ctx.close()
  }
})
npm run build
npm run test:e2e -- tests/e2e/auto-insert.spec.ts

Expected: 4 passing.


Task 24: Live verification (Claude-driven, two harnesses)

Files:

Purpose: Verify on the real calendar.google.com DOM. We support two harnesses; pick whichever matches the situation.

Option A — Playwright headed (npm run test:live). Self-contained but launches a fresh Chromium with a persistent profile that needs Google login on first run.

Option B — Claude-driven via Claude_in_Chrome MCP (preferred for interactive use). Pairs to the developer’s already-authenticated Chrome via the Claude_in_Chrome extension. No login automation needed.

Option B — Claude_in_Chrome MCP runbook

There is no checked-in file for this harness — it’s a sequence Claude runs at the end of execution:

  1. User opens the unpacked extension (chrome://extensions → Load unpacked → dist/) in their normal Chrome and authorizes the Claude_in_Chrome extension to access calendar.google.com.
  2. Claude: mcp__Claude_in_Chrome__list_connected_browsersselect_browser.
  3. Claude: tabs_context_mcp({ createIfEmpty: true }) to get a tab handle.
  4. Claude: navigate({ url: 'https://calendar.google.com/calendar/u/0/r' }). (Permission prompt may appear; user grants.)
  5. Claude: computer({ action: 'screenshot' }) → screenshot 1 of the calendar grid.
  6. Claude: computer({ action: 'left_click' }) on the calendar grid (a free time slot) to open the event dialog, OR navigate via the CreateEvent button by find({ query: 'Create button' }).
  7. Claude: javascript_tool runs the exact locator we ship:

    (() => {
      const dialog = document.querySelector('[role="dialog"]')
      if (!dialog) return { error: 'no dialog' }
      const xDescIn = dialog.querySelector('#xDescIn')
      const editor = xDescIn?.querySelector('[contenteditable="true"][role="textbox"]')
                   ?? dialog.querySelector('[contenteditable="true"][role="textbox"][aria-label="Add description"]')
      return {
        hasDialog: true,
        hasXDescIn: !!xDescIn,
        hasEditor: !!editor,
        editorHtml: editor?.innerHTML ?? null,
      }
    })()
    

    Asserts: hasDialog: true, hasEditor: true, and after auto-insert fires, editorHtml contains the template’s first heading (e.g. <strong>Live verification template</strong>).

  8. Claude: screenshot → screenshot 2 showing the injected template visible.
  9. Claude: find({ query: 'Save button' })left_click. Wait. screenshot → screenshot 3 of the saved event.
  10. Claude reports verbally: locator anchors hit (yes), auto-insert fired (yes), description survived save (yes / no — if no, list what broke and which §4 step needs updating).

If any step fails, Claude updates §4 of the spec (Appendix A captures the new DOM sample) and re-runs the unit fixture before reporting.

Option A — Playwright spec (still checked in)

(Empty file. The directory holds the user’s authenticated Chrome profile.)

/* eslint-disable no-console */
import { test, expect, chromium } from '@playwright/test'
import path from 'node:path'
import fs from 'node:fs'

const EXT_PATH = path.resolve('./dist')
const PROFILE_PATH = path.resolve('./tests/live-profile')
const SCREENSHOT_DIR = path.resolve('./tests/live-screenshots')

const TEMPLATE_MARKDOWN = `**Live verification template**

- Agenda
- Notes
`

test.describe('LIVE: real Google Calendar', () => {
  test.skip(
    !process.env['RUN_LIVE'],
    'Set RUN_LIVE=1 to run against real calendar.google.com',
  )

  test('finds Details, auto-inserts, persists on Save', async () => {
    test.setTimeout(180_000) // allow time for manual login
    fs.mkdirSync(SCREENSHOT_DIR, { recursive: true })

    const ctx = await chromium.launchPersistentContext(PROFILE_PATH, {
      headless: false,
      channel: 'chromium',
      args: [
        `--disable-extensions-except=${EXT_PATH}`,
        `--load-extension=${EXT_PATH}`,
      ],
    })

    // Service worker can take >5s to register in headed mode; allow 30s.
    let worker = ctx.serviceWorkers()[0]
    if (!worker) worker = await ctx.waitForEvent('serviceworker', { timeout: 30_000 })
    const extId = new URL(worker.url()).host

    const popup = await ctx.newPage()
    await popup.goto(`chrome-extension://${extId}/src/popup/popup.html`)
    await popup.evaluate(async (md) => {
      await chrome.storage.sync.set({ settings: { template: md, autoInsert: true } })
    }, TEMPLATE_MARKDOWN)
    await popup.close()

    const page = await ctx.newPage()
    await page.goto('https://calendar.google.com/calendar/u/0/r')

    // Allow up to 2 minutes for the user to log in if needed. The Create button
    // appearing is our signal that the calendar app finished loading.
    console.log('Waiting for Calendar (log in via the opened window if prompted)…')
    await page.locator('[aria-label="Create"]').first().waitFor({ timeout: 120_000 })

    await page.locator('[aria-label="Create"]').first().click()
    await page.locator('[role="menuitem"]:has-text("Event")').click()

    const dialog = page.locator('[role="dialog"]').first()
    await dialog.waitFor({ state: 'visible', timeout: 15_000 })
    await page.screenshot({ path: path.join(SCREENSHOT_DIR, '1-dialog-open.png') })

    // Probe the live DOM with the same locator strategy the extension ships.
    const detailsHtml = await page.evaluate(() => {
      const dialog = document.querySelector('[role="dialog"]')
      if (!dialog) return null
      const xDescIn = dialog.querySelector('#xDescIn')
      const editor = (xDescIn?.querySelector('[contenteditable="true"]')
        ?? dialog.querySelector(
          '[contenteditable="true"][role="textbox"][aria-label="Add description"]',
        )) as HTMLElement | null
      return editor?.innerHTML ?? null
    })
    expect(detailsHtml, 'locator must find the Details contenteditable').not.toBeNull()
    expect(detailsHtml!).toContain('<strong>Live verification template</strong>')
    await page.screenshot({ path: path.join(SCREENSHOT_DIR, '2-auto-inserted.png') })

    // Save the event and reload to confirm the description persisted.
    await page.locator('[aria-label*="Save"]').click()
    await page.waitForLoadState('networkidle')
    await page.screenshot({ path: path.join(SCREENSHOT_DIR, '3-after-save.png') })

    console.log(`Screenshots saved to ${SCREENSHOT_DIR}`)
    await ctx.close()
  })
})

Append to README.md (final form in Task 25 — for now just keep this in mind).


Task 25: README rewrite

Files:

# Meet Formatter

A Chrome extension (Manifest V3) that lets you define a single Markdown/WYSIWYG template once and have it inserted into the **Details** field of any new Google Calendar event — automatically when the toggle is on, or manually via the popup's **Insert** button.

## Install (development)

1. `npm install`
2. `npm run build`
3. Open `chrome://extensions/`, enable Developer mode, click **Load unpacked**, pick the `dist/` folder.

For HMR-driven development of the popup:

```bash
npm run dev

Then “Load unpacked” the dist/ folder once; subsequent saves hot-reload the popup. (Reload the calendar tab to pick up content-script changes.)

Usage

  1. Click the toolbar icon to open the popup.
  2. Type your template in the editor. Markdown shortcuts work (**bold**, _italic_, - bullet, # heading). Use the toolbar for B / I / U / lists / heading; ⌘Z / ⌘⇧Z for undo/redo. Auto-save runs after 400 ms of inactivity.
  3. Toggle Auto-insert on event open if you want the template injected automatically the next time you create a calendar event with an empty Details field.
  4. Click Save for a confirmation flash, or Insert to inject into the current calendar tab on demand.

Tests

npm test          # unit (Vitest + happy-dom)
npm run test:e2e  # Playwright against the fake-Calendar fixture (CI-safe)
RUN_LIVE=1 npm run test:live  # headed Playwright run against real calendar.google.com

The live test requires a Chrome profile pre-authenticated with Google — it stores the profile under tests/live-profile/ (gitignored). On first run, log in manually in the launched browser, close it, then re-run.

Architecture

See docs/superpowers/specs/2026-05-20-meet-formatter-v1-design.md for the full design.

License

See LICENSE.


- [ ] **Step 2: Extend `.gitignore`**

Append to `.gitignore`:

Node / build artifacts

node_modules/ dist/ *.tsbuildinfo

Test artifacts

tests/live-profile/* !tests/live-profile/.gitkeep tests/live-screenshots/ playwright-report/ test-results/


- [ ] **Step 3: Checkpoint.**

---

## Task 26: Final verification (full green build)

- [ ] **Step 1: Typecheck**

```bash
npm run typecheck

Expected: zero errors.

Create eslint.config.js:

import tseslint from '@typescript-eslint/eslint-plugin'
import tsparser from '@typescript-eslint/parser'

export default [
  {
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      parser: tsparser,
      parserOptions: { project: false, ecmaFeatures: { jsx: true } },
    },
    plugins: { '@typescript-eslint': tseslint },
    rules: {
      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
      '@typescript-eslint/no-explicit-any': 'warn',
    },
  },
  {
    ignores: ['dist/**', 'node_modules/**', '.superpowers/**'],
  },
]

Run:

npm run lint

Expected: zero errors. Warnings are OK.

npm test

Expected: every spec green.

npm run build

Expected: dist/ populated, build exits 0.

npm run test:e2e

Expected: 7 passing across popup.spec.ts (3) and auto-insert.spec.ts (4).

The executing agent (Claude) runs the live spec interactively:

RUN_LIVE=1 npm run test:live

On first execution: the browser opens to Calendar; if not logged in, the executing agent must pause, log in manually, then re-run. After login, the script:

  1. Seeds the template via the popup.
  2. Clicks CreateEvent on real calendar.google.com.
  3. Reads the Details contenteditable using the same locator we ship.
  4. Asserts the live DOM contains <strong>Live verification template</strong>.
  5. Saves the event and screenshots tests/live-screenshots/{1,2,3}.png.

The executing agent reads each screenshot via the Read tool and visually confirms:

Only after all three screenshots are read and confirmed visually does the agent report the task complete.

Replace the architecture/run sections in CLAUDE.md to describe the new MV3 + Vite + Preact + Tiptap setup, drop the V2-era gotchas (in-memory background storage, duplicate storage paths, jQuery dead code — none of that exists anymore), and point to this plan and the spec.


Self-review notes

Coverage of the spec:

Known compromises: