This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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.
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.
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:
chrome://extensions/ (Developer mode on) — or “Load unpacked” the dist/ folder directly during development.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 |
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:
chrome.storage.sync (single settings key holding { template, autoInsert }).markdownToSanitizedHtml(md) via marked + DOMPurify allowlist.findDetailsEditor, findDescriptionExpander, waitFor, isEditorEmpty.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
}
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.
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.
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):
#xDescIn — Google’s hardcoded container id when the Description editor is mounted.[contenteditable="true"][role="textbox"][aria-label="Description"] — the editor on the full-page editor surface (singular Description).[contenteditable="true"][role="textbox"][aria-label="Add description"] — the editor on the modal surface after expansion.[data-key="description"] — the span clicked to expand the panel from the collapsed state (modal only).<button> whose visible text equals Add description — fallback expander (modal only).Title (refocus target after successful injection):
input#xTiIn — hardcoded id on the full-page editor.input[aria-label="Title"] — full-page editor (singular).input[aria-label="Add title"] — modal editor.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.
| 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.
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).
host_permissions: ["*://calendar.google.com/*"] is required for the service worker’s chrome.tabs.query({ url: '*://calendar.google.com/*' }) to actually return tab URLs. Without it, tabs.query returns tabs but with url: undefined, and the message router silently drops every INSERT_TEMPLATE.active: true — when the popup is the active tab, the calendar tab is not."scripting" permission lets the service worker chrome.scripting.executeScript into Calendar tabs that predate the extension install/reload. The declarative content_scripts block only injects into pages loaded after the extension is installed — without programmatic injection, the user has to reload every Calendar tab after each extension update or they see "Could not establish connection. Receiving end does not exist." The service worker reads the bundle filename from chrome.runtime.getManifest() since crxjs hash-suffixes it per build.<script> tag (more aggressive than browser DOMPurify). Don’t rely on adjacent markdown surviving alongside a <script> tag in tests; use <img onerror> or other non-script vectors when testing “preserve good content next to bad”.test.server.deps.inline: [/@tiptap\//, 'use-sync-external-store'] plus a react/jsx-runtime → preact/jsx-runtime alias in vitest.config.ts. Tiptap’s ESM bundles import React from 'react' and won’t pick up the Preact compat alias otherwise.vite@^5.4.x unless you also bump vitest to 3.x. Mixed vite 6 + vitest 2 trips exactOptionalPropertyTypes against the bundled vite types.chrome.storage.sync mock lives in tests/setup.ts. Both chrome.runtime.sendMessage and chrome.tabs.query are mocked with vi.fn() — tests should .mockReset() between cases.calendar.google.com is on Chrome’s HSTS preload list. Plain http:// is force-upgraded to https:// regardless of the URL scheme passed to page.goto(). The local fixture server therefore serves HTTPS with a self-signed cert (tests/fixtures/cert.pem, gitignored), and Chrome is launched with --ignore-certificate-errors + --host-resolver-rules=MAP calendar.google.com 127.0.0.1:5174.[data-key="description"] span or the “Add description” button mounts the editor. Tests that seed pre-existing description content use window.__openDialogWithDescription(html).Two harnesses, both Claude-driven:
npm run test:live (tests/verify-live.spec.ts). Self-contained but spins up a fresh Chromium with a persistent profile that needs Google login on first run.Claude_in_Chrome MCP (preferred when an authenticated Chrome is already open). Claude pairs to the developer’s browser via list_connected_browsers → select_browser → tabs_context_mcp, navigates to Calendar, exercises the locator via javascript_tool, and captures screenshots. Procedure documented in plan Task 24.Both reserved at the spec level (§10) so the v1.0 data model leaves room (template: string → templates: Record<string, string> + activeId: string).
c#### ids into selectors. They drift across Calendar deploys.activeTab and remove host_permissions — tabs.query won’t return URLs.markdownToSanitizedHtml when writing into the editor. Always pass through DOMPurify before innerHTML/insertAdjacentHTML.