meeting-template

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Objective

Meeting Template for Google Calendar — a Chrome extension (Manifest V3) that lets the user 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.

The full product spec lives at docs/superpowers/specs/2026-05-20-meet-formatter-v1-design.md. The matching implementation plan is at docs/superpowers/plans/2026-05-20-meet-formatter-v1.md. Both predate the rename — they still refer to the working title “Meet Formatter” but the architecture is current.

Build / dev / test

npm install                       # one-time
npm run build                     # → dist/  (load-unpackable in chrome://extensions)
npm run dev                       # HMR for the popup; reload calendar tab for content-script changes
npm test                          # Vitest, happy-dom, ~80 tests, <4s
npm run test:e2e                  # Playwright against tests/fixtures/fake-calendar.html, ~12s
RUN_LIVE=1 npm run test:live      # headed Playwright against real calendar.google.com
npm run typecheck                 # tsc --noEmit
npm run lint                      # eslint

To run just one Vitest file: npx vitest run src/lib/markdown.test.ts. To run just one Playwright spec: npm run test:e2e -- tests/e2e/popup.spec.ts.

npm run lint is currently broken — eslint 9 is installed but no eslint.config.js exists yet (project predates the v9 flat-config migration). npm run typecheck and npm run build both run tsc --noEmit, which catches type errors. If you add a lint config, add a flat config matching the deps already in package.json (@typescript-eslint/*, eslint-plugin-react-hooks).

The E2E suite needs a self-signed cert at tests/fixtures/{cert,key}.pem (gitignored). Generate once with:

cd tests/fixtures && openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout key.pem -out cert.pem -days 365 \
  -subj "/CN=calendar.google.com" \
  -addext "subjectAltName=DNS:calendar.google.com,DNS:localhost,IP:127.0.0.1"

Playwright also needs a Chromium browser — npx playwright install chromium on a fresh checkout.

Packaging & Chrome Web Store

npm run build                                          # → dist/
NAME=$(node -p "require('./package.json').name") && \
  VERSION=$(node -p "require('./package.json').version") && \
  (cd dist && zip -r "../${NAME}-v${VERSION}.zip" .)

Produces meeting-template-for-google-calendar-v<version>.zip at the repo root. Always use the shell-variable form — the inline node -p 'require(\"../package.json\").version' substitution silently breaks inside bash double-quoted strings (the escaped double-quotes collapse and you get a zip named …-v.zip).

The zip lands in the repo root (gitignored via meet-formatter-v*.zip). Use it for:

dist/ ships only what’s referenced from src/manifest.config.ts — currently the four assets/images/icon{16,32,48,128}.png files (bold calendar+check, alpha-channel PNGs authored at each size). Promotional / store-listing assets — readme-header.png (1500×500), marquee-1400x560.png, social-1200x630.png, promo-440x280.png, brand-mark-128.png, icon-source.png, favicon* — live in assets/images/ for the README and Chrome Web Store dashboard but never end up in dist/ because no source file imports them. Keep that separation: anything that ships with the extension stays referenced from the manifest; landing / store / social art stays unreferenced.

The current icons are authored at each size (16/32/48/128 are not downscaled from 128). If you ever need to regenerate them from a single high-res source, scripts/resize-icons.mjs uses the already-installed Playwright Chromium to draw icon-source.png onto a canvas at each target size — no ImageMagick or sharp dependency. But prefer authored sizes when available: they’re noticeably sharper at 16px than any auto-downscale.

Chrome Web Store submission checklist (most items live in the CWS dashboard, not the repo):

Asset Spec Status
128×128 store icon 128×128 PNG assets/images/icon128.png
Screenshots (1–5) 1280×800 or 640×400 PNG/JPEG ⛔️ TODO — capture popup + a calendar-injection screenshot
Small promo tile 440×280 PNG/JPEG assets/images/promo-440x280.png
Marquee promo tile (optional) 1400×560 assets/images/marquee-1400x560.png
Privacy policy URL live HTTPS URL ⛔️ Required because the extension declares storage + host_permissions
Detailed listing description ≤ 16,000 chars ⛔️ Current manifest description is the short tagline only

Architecture

Three runtime contexts glued by typed chrome.runtime messages (see src/messages.ts):

Context Location Job
Popup (Preact + Tiptap) src/popup/ Editor, toolbar, auto-save, Save/Insert buttons. Talks to storage and posts INSERT_TEMPLATE to the service worker.
Service worker (MV3) src/background/service-worker.ts Receives popup messages, queries calendar tabs, forwards INSERT_TEMPLATE to the content script.
Content script src/content/calendar-injector.ts Runs on *://calendar.google.com/*. Observes the event dialog opening; expands the Description panel if collapsed; injects sanitized HTML.

Pure libraries (src/lib/) hold the testable logic with no Chrome API dependencies:

Editor capabilities

The Tiptap editor in the popup (src/popup/Editor.tsx) supports: bold, italic, underline, strikethrough, headings (H1–H3), bullet / ordered / task lists (GFM - [ ] / - [x]), hyperlinks (autolink on paste/typing, plus a toolbar button that opens window.prompt), blockquotes, inline code, fenced code blocks, horizontal rules, tables, and images.

Toolbar exposes 7 buttons (B, I, U, H, •, 1., 🔗). Strikethrough, checklist, blockquote, code block, and horizontal rule are intentionally markdown-only: they round-trip through marked on load and Tiptap’s input rules (~~strike~~, > , ` ``` , ) auto-convert as the user types. The extensions stay registered in [Editor.tsx](/src/popup/Editor.tsx) so the nodes render; only Toolbar.tsx hides the buttons. Same applies to tables and images — type the markdown, they render. Adding a button back is a one-line change in Toolbar.tsx's BUTTONS` array.

The DOMPurify allowlist in src/lib/markdown.ts is the single security boundary between user template content and the Calendar Description field. Tags: p, br, hr, strong, em, u, s, del, h1–h6, ul, ol, li, a, code, pre, blockquote, img, table*, input. Attrs: href, target, rel, title, src, alt, width, height, colspan, rowspan, align, type, checked, disabled, class, data-type, data-checked. URI regex: ^(?:https?:|mailto:|tel:|#|/)/i.

Calendar strips most of the fancy stuff on save. What survives in the actual saved event description: text formatting (bold/italic/strike/code), headings, lists, links, blockquotes. What gets stripped: <img>, <table>, <hr>, and <input> elements (including GFM task-list checkboxes). The popup editor shows them so the template looks right; the rendering loss happens inside Calendar’s contenteditable, not in our sanitizer.

Task-list checkboxes get a special pre-injection transform. In src/lib/markdown.ts, markdownToSanitizedHtml runs replaceTaskCheckboxes before DOMPurify — it regex-rewrites every <input> (with one trailing whitespace consumed) into a or Unicode glyph. The <input> tag is no longer in the sanitizer allowlist; the popup editor still renders real interactive checkboxes because that path goes through parseMarkdownToHtml in src/popup/markdown-bridge.ts, which has nothing to do with the sanitizer. Net effect: the user sees a real ☑ checkbox in the popup; attendees see ☐ Kickoff / ☑ Notes posted in the actual Calendar invite, surviving Calendar’s DOM cleanup pass.

Link extension is configured openOnClick: false, autolink: true, linkOnPaste: true, protocols restricted to http, https, mailto, tel, and emitted with rel="noopener noreferrer" target="_blank". The toolbar Link button rejects URLs that don’t match those protocols via window.alert. There is no inline link bar (intentional — keeps popup minimal).

The popup → background message contract:

type InsertTemplateMessage = {
  kind: 'INSERT_TEMPLATE'
  markdown: string
  manual: boolean    // true = explicit Insert click; false = auto-insert path
  overwrite: boolean // toggle from SettingsRow
}

// Sent (debounced) from the popup on every template edit. The content
// script applies this ONLY if the current Description text still equals
// the snapshot of what was last injected for the open dialog —
// personalized descriptions are left alone.
type TemplateUpdatedMessage = {
  kind: 'TEMPLATE_UPDATED'
  markdown: string
}

Insert decision tree

src/content/calendar-injector.ts:applyInjection decides between REPLACE, APPEND, and SKIP based on the current Description, the incoming template, and whether the call is manual or auto.

Current Description manual overwrite Outcome
empty * * REPLACE
matches template text (normalized) * * REPLACE (idempotent re-insert)
non-empty, non-matching true true REPLACE
non-empty, non-matching true false APPEND
non-empty, non-matching false (auto) * SKIP

“Matches template text (normalized)” = textContent of the description, trimmed and whitespace-collapsed, equals the same on the sanitized template HTML. This is what makes auto-insert safe even with Overwrite on — Calendar descriptions a user has personalized never get clobbered.

Live sync (popup-open continuous re-injection)

While the popup is open, every template edit fires a debounced (LIVE_SYNC_MS = 250ms) TEMPLATE_UPDATED message. The content script’s applyLiveSync writes a meetTemplateSnapshot dataset attribute on the description editor on every successful injection. Live-sync replaces the description ONLY when the current text still equals that snapshot — i.e., the user hasn’t typed anything custom into Calendar since we injected.

If the user edits the Description in Calendar, the snapshot no longer matches and live-sync becomes a silent no-op for that dialog session. If they edit it back to match again, sync resumes (stateless matching, no flag to clear).

Closing the popup ends live-sync naturally because the popup process is the message source.

The Google Calendar DOM (read before touching the locator)

Class names and c#### ids in Calendar’s DOM are minified and change per session. Do not put them in selectors. The stable anchors used by details-locator.ts:

Description (injection target):

  1. #xDescIn — Google’s hardcoded container id when the Description editor is mounted.
  2. [contenteditable="true"][role="textbox"][aria-label="Description"] — the editor on the full-page editor surface (singular Description).
  3. [contenteditable="true"][role="textbox"][aria-label="Add description"] — the editor on the modal surface after expansion.
  4. [data-key="description"] — the span clicked to expand the panel from the collapsed state (modal only).
  5. A <button> whose visible text equals Add description — fallback expander (modal only).

Title (refocus target after successful injection):

  1. input#xTiIn — hardcoded id on the full-page editor.
  2. input[aria-label="Title"] — full-page editor (singular).
  3. input[aria-label="Add title"] — modal editor.
  4. input[placeholder="Add title"] — last-resort fallback.

After every successful injection, applyInjection calls findTitleField()?.focus() and places the caret at the end of any existing title text. This keeps the keyboard-driven flow tight: c → editor mounts → template auto-inserts → caret already in Title → user types event name.

Calendar has TWO event-creation surfaces — handle both

  Modal Full-page
Trigger Click a calendar time slot Keyboard c, “More options”, or /r/eventedit URL
Wrapper [role="dialog"] none — anchors are at document level
Description starts collapsed (click expander first) already mounted
aria-label "Add description" "Description" (singular!)

The locator helpers default root to document.body, and findSurface() in calendar-injector.ts picks the right root: [role="dialog"] if present, otherwise document.body.

The reference DOM samples are in Appendix A (modal) and Appendix B (full-page) of the spec. To capture a fresh sample when Calendar’s DOM shifts, paste tests/manual-probe.js into DevTools console on the affected surface.

Known v1.0 limitation: existing-event description on full-page editor

The full-page editor mounts the contenteditable into the DOM before Calendar populates content from the server. The auto-insert observer sees the editor as empty and would inject before Calendar’s content arrives. Mitigated in practice because Calendar writes back the server content (winning the race), but a clean fix is v1.1 (Future Work, spec §10).

Manifest gotchas

Testing gotchas

E2E gotchas (fake-Calendar)

Live verification (real calendar.google.com)

Two harnesses, both Claude-driven:

Future work (not yet built)

Both reserved at the spec level (§10) so the v1.0 data model leaves room (template: stringtemplates: Record<string, string> + activeId: string).

Don’t