Date: 2026-05-20 (drafted), 2026-05-21 (locator hardened against the real Calendar DOM)
Status: Shipped — first public release
Supersedes: an unreleased Manifest V2 prototype (root-level popup.html, popup.js, action.js, background.js, injector.js, app.js, bundled jQuery), all deleted as part of the v1.0 rewrite.
Note on versioning. Drafts of this spec used “v2” naming during the rewrite (because the prototype was conceptually v1 in development). For the first public release we renumbered to v1.0 — the prototype was never shipped. The product version in
manifest.jsonis1.0.0. References to “Manifest V2” elsewhere in this document refer to the deprecated Chrome extension manifest format, not to a product version.
A Chrome extension that lets a user define a single rich-text/Markdown template once and have it inserted into the Details field of any new Google Calendar event — automatically (when the toggle is on) or via a manual “Insert” button in the popup.
calendar.google.com when a new-event editor opens and Details is empty.calendar.google.com that verifies the Details field is correctly targeted and the rendered content lands in it.Three runtime contexts, each with one clear job:
flowchart LR
subgraph Extension["Chrome Extension (Manifest V3)"]
direction TB
subgraph Popup["Popup UI (Preact)"]
App["App.tsx"]
Editor["Editor.tsx<br/>(Tiptap StarterKit + History<br/>+ Placeholder + Markdown shortcuts)"]
Toolbar["Toolbar.tsx<br/>B / I / U / • / 1. / H"]
Settings["SettingsRow.tsx<br/>Auto-insert toggle"]
Actions["ActionBar.tsx<br/>Save | Insert"]
App --> Editor
App --> Toolbar
App --> Settings
App --> Actions
end
subgraph SW["Service Worker (background)"]
Router["Message router"]
TabFinder["Active calendar tab finder"]
Router --> TabFinder
end
subgraph Content["Content Script<br/>(matches calendar.google.com)"]
Observer["waitForEventEditor()<br/>MutationObserver"]
Locator["findDetailsEditor()"]
Injector["injectSanitizedHtml()"]
Observer --> Locator
Locator --> Injector
end
subgraph Lib["lib/ (pure, unit-tested)"]
Storage["storage.ts<br/>chrome.storage.sync facade"]
Markdown["markdown.ts<br/>marked + DOMPurify"]
DetailsLoc["details-locator.ts<br/>pure DOM helpers"]
end
Popup -- "load / save<br/>{template, autoInsert}" --> Storage
Popup -- "INSERT_TEMPLATE msg" --> Router
Router -- "tabs.sendMessage" --> Injector
Injector -- "uses" --> Markdown
Injector -- "uses" --> DetailsLoc
Observer -- "uses" --> DetailsLoc
Observer -- "reads autoInsert" --> Storage
end
Injector -.writes sanitized HTML.-> Calendar[("calendar.google.com<br/>Details contenteditable")]
classDef pure fill:#fef3c7,stroke:#d97706,color:#78350f
classDef ui fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef bg fill:#e0e7ff,stroke:#4f46e5,color:#312e81
classDef cs fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef external fill:#f3f4f6,stroke:#6b7280,color:#374151
class App,Editor,Toolbar,Settings,Actions ui
class Router,TabFinder bg
class Observer,Locator,Injector cs
class Storage,Markdown,DetailsLoc pure
class Calendar external
src/
├── popup/
│ ├── App.tsx # Top-level Preact component
│ ├── Editor.tsx # Tiptap editor wrapper
│ ├── Toolbar.tsx # B / I / U / • / 1. / H buttons
│ ├── SettingsRow.tsx # Auto-insert toggle
│ ├── ActionBar.tsx # Save | Insert
│ ├── popup.html # Vite entry
│ └── popup.css # Notion-ish design tokens
├── background/
│ └── service-worker.ts # Message router; dispatches INSERT_TEMPLATE
├── content/
│ └── calendar-injector.ts # Runs on calendar.google.com
├── lib/
│ ├── storage.ts # chrome.storage.sync facade
│ ├── markdown.ts # markdownToHtml(md) -> sanitized HTML
│ └── details-locator.ts # Pure DOM helpers
├── messages.ts # Shared message-type constants & TS types
└── manifest.config.ts # @crxjs/vite-plugin manifest
tests/
├── fixtures/
│ ├── fake-calendar.html # E2E target
│ └── calendar-dialog-snapshot.html # Locator unit-test fixture
├── e2e/
│ └── *.spec.ts # Playwright E2E specs
└── verify-live.spec.ts # Claude-driven live verification
lib/details-locator.ts is pure DOM — testable in JSDOM without any Chrome APIs.lib/markdown.ts is pure functions — fast unit tests, no I/O.lib/storage.ts mocks cleanly; popup code never calls chrome.storage.* directly.@crxjs/vite-plugin — purpose-built for Chrome extensions; HMR for the popup, validates the MV3 manifest at build time.@preact/preset-vite (aliases react/react-dom so Tiptap’s React bindings work with ~10 KB framework overhead).marked for Markdown → HTML.npm run … |
Effect |
|---|---|
dev |
Vite dev with HMR; dist/ is load-unpackable |
build |
Production build into dist/ |
test |
Vitest run once |
test:watch |
Vitest watch mode |
test:e2e |
Playwright E2E against fake Calendar |
test:live |
Claude-driven verification against real Calendar (requires --user-data-dir) |
typecheck |
tsc --noEmit |
lint |
eslint src tests |
type Settings = {
template: string // markdown source, NOT rendered HTML
autoInsert: boolean // default: false
}
Stored in chrome.storage.sync so settings roam with the user’s Chrome profile.
onUpdate → debounce 400 ms → serialize doc → Markdown → storage.sync.set({ template }).chrome.storage.sync quota: 8 KB per item, 100 KB total. storage.ts surfaces a friendly error if the user somehow exceeds it.Flushes the pending debounce immediately, then briefly swaps its label to Saved ✓ for 1.2 s before reverting to Save.
This is the riskiest part — Google can change the DOM. The implementation was hardened against real Google Calendar DOM samples taken during development (see Appendix A and Appendix B for the captured markup). Class names like JyrDof and id values like c4092 are minified and unstable; the stable anchors below are not.
Calendar exposes two distinct UIs for creating an event. The extension must work in both. The differences are not cosmetic:
| Inline modal | Full-page editor | |
|---|---|---|
| Opens via | Click on a calendar time-slot grid cell | Keyboard c, “More options” from modal, or direct URL navigation |
| URL | unchanged (/r/week etc.) |
/r/eventedit?overrides=... |
| Wrapper | [role="dialog"] |
none — anchors live at document level |
| Description starts | collapsed (must click [data-key="description"] to expand) |
already expanded; editor present at page load |
Editor aria-label |
"Add description" |
"Description" (singular) |
| Common anchors | #xDescIn, [contenteditable="true"][role="textbox"] |
same |
Collapsed (Details has not been opened yet) — modal surface only:
<div role="dialog" aria-label="Create event">
...
<span>
Add
<span class="JyrDof" data-key="description" jslog="39830">description</span>
or
<span class="JyrDof" data-key="attachments" jslog="64574">a Google Drive attachment</span>
</span>
<div data-expandable jsname="Ofl2hc" jscontroller="OHz5R">
<button jsname="Zqjuqb" aria-expanded="false">Add description</button>
<div jsname="xpDKHf" hidden> <!-- editor lives here once expanded --> </div>
</div>
</div>
Expanded (after the user — or the extension — clicks the expander):
<div data-expandable jsname="Ofl2hc" class="... sMVRZe">
<button jsname="Zqjuqb" aria-expanded="true">Add description</button>
<div jsname="xpDKHf">
<div role="toolbar" aria-label="Formatting options"> ... </div>
<div id="xDescIn">
<div aria-hidden="true">Add description</div> <!-- placeholder span -->
<div role="textbox"
aria-multiline="true"
aria-label="Add description"
contenteditable="true"
g_editable="true"
id="hj99tb..." >
<div><br></div>
</div>
</div>
</div>
</div>
Description (the injection target):
#xDescIn — the container element id is hardcoded by Google (“X Description Input”). Present on both surfaces when the description editor is mounted.#xDescIn: [contenteditable="true"][role="textbox"].[contenteditable="true"][role="textbox"][aria-label="Description"] — full-page editor’s editor (singular Description).[contenteditable="true"][role="textbox"][aria-label="Add description"] — modal editor (only after expansion).[data-key="description"] — the span Google uses to open the description panel from the collapsed state (modal flow only).<button> whose visible text is Add description — fallback expander (modal flow only).Title (the refocus target):
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 shared by both surfaces.role, aria-label, aria-multiline, g_editable, data-key, and jslog are tracking/accessibility hooks Google does not minify; the implementation relies only on these and the hardcoded id.
findDetailsEditor(root: HTMLElement = document.body): HTMLElement | null
// Synchronous; returns the editor if it's reachable inside root, else null.
// Pass `[role="dialog"]` for the modal surface; pass `document.body` (default)
// for the full-page editor. Strategy: #xDescIn → contenteditable, then
// [role=textbox] with either aria-label, then legacy aria-label wrappers.
findDescriptionExpander(root: HTMLElement = document.body): HTMLElement | null
// Synchronous; returns the click target that will open the panel (modal only):
// [data-key="description"] first, then a button reading "Add description".
// Returns null on the full-page editor (no expander needed).
findTitleField(root: HTMLElement = document.body): HTMLInputElement | null
// Synchronous; returns the event Title input on either surface. Resolution
// order: #xTiIn, then aria-label="Title", then aria-label="Add title",
// then placeholder="Add title" as a last-resort fallback.
waitForEventEditor(root: HTMLElement = document.body, opts): Promise<HTMLElement | null>
// Detects EITHER an inline modal ([role="dialog"]) OR the full-page editor
// (a description anchor already at document level). Resolves the surface root.
waitFor(predicate, { timeoutMs, intervalMs }): Promise<T | null>
// Generic polling helper used to wait for the editor after clicking the expander.
Match pattern — *://calendar.google.com/* in the manifest content_scripts. The same content script runs on both /r/week (modal flow) and /r/eventedit (full-page flow) since both URLs match. Plus host_permissions: ["*://calendar.google.com/*"] (see §7) so the service worker can resolve the calendar tab when forwarding the INSERT_TEMPLATE message.
findSurface() — picks the right search root for the current page:
document.querySelector('[role="dialog"]') exists → return the dialog (modal flow).findDetailsEditor(document.body) || findDescriptionExpander(document.body) → return document.body (full-page flow).MutationObserver on document.body — watches for either surface appearing. The callback runs attemptAutoInsert(). The observer is also fired once on attach so the full-page editor (whose editor exists before the observer attaches at document_idle) is detected immediately.
ensureAndInject({ markdown, manual }) — async:
const surface = findSurface(). If null, return false.findDetailsEditor(surface). If found, jump to step 6.findDescriptionExpander(surface). If null, return false.waitFor(() => findDetailsEditor(surface), { timeoutMs: 2000 }).Empty check — only auto-insert if isEditorEmpty(editor) (the editor’s text is empty AND no meaningful structural children exist; <div><br></div> counts as empty — that’s Calendar’s initial state).
Once-per-session guard — editor.dataset.meetFormatterInserted = 'true' after inserting; the observer’s callback respects this flag for the auto path so a single dialog session never gets double-filled.
Input event — editor.dispatchEvent(new Event('input', { bubbles: true })) so Calendar’s own change detection saves the description.
findTitleField() and .focus() on the result. The caret is placed at the end of any existing title text via setSelectionRange(end, end) so the user can continue typing the event name. This pairs cleanly with the keyboard-driven flow (c → editor opens → template fills → focus is already in Title → user types name → Save). No-op if the title field can’t be located on the current surface.Manual Insert (toggle off) runs the same flow but skips the empty-check and the once-per-session guard — explicit user action wins. When the editor is non-empty and manual=true, content is appended rather than replaced (insertAdjacentHTML('beforeend', html)), which preserves whatever the user had typed.
The full-page editor mounts the description contenteditable into the DOM before Calendar populates its content from the server (typical for “edit existing event”). The content script’s MutationObserver fires the moment the editor exists, but at that instant the editor’s innerHTML is <div><br></div> (empty). By the time Calendar populates the existing description, the once-per-session guard has already flipped — auto-insert is skipped, but the user’s existing content remains intact.
In practice this means: on the full-page editor, auto-insert may briefly race with Calendar’s content population; the empty-check still prevents overwrites because the content script writes the template and Calendar then writes back the server content (winning). The user can always click the popup’s manual Insert to append.
A clean fix (waiting for Calendar to settle before deciding) is reserved for v1.1 along with the rest of the edit-existing-event work in §10.
Tiptap doc ─► serialize to MD ─► chrome.storage.sync
│
▼
{INSERT_TEMPLATE, md}
│
▼
marked(md) ─► raw HTML string
│
▼
DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p','br','strong','em','u',
'h1','h2','h3','ul','ol','li','a'],
ALLOWED_ATTR: ['href']
})
│
▼
Range.insertNode + dispatch 'input' event
The input event is required — Calendar’s editor listens to it for autosave. Without it, the inserted content is visible but not persisted on Save.
Notion-ish warm direction, applied as concrete tokens:
| Token | Value |
|---|---|
| Popup width | 380 px |
| Popup max height | 600 px (Chrome cap) |
| Background | #fdfcfa |
| Surface (editor) | #ffffff |
| Border | 1px solid #e9e6e0 |
| Text primary | #37352f |
| Text muted (placeholder) | #b5b2ab |
| Toolbar button bg | #f1ede6 |
| Toggle on | #2eaadc |
| Primary button bg | #37352f (Insert) |
| Secondary button | white bg, #e9e6e0 border, #37352f text (Save) |
| Radius | 6 px general, 9 px toggle, 12 px popup root |
| Font | -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif |
| Body font size | 13 px popup chrome, 12 px editor body, 11 px toolbar |
| Shadow | 0 4px 16px rgba(0,0,0,0.06) on popup root |
B I U • 1. H (left-aligned, rounded buttons).Markdown supported placeholder when empty.Show active state (filled background #37352f with white glyph) when the cursor is inside the matching Tiptap mark. Each is also accessible via standard keyboard shortcut (⌘B, ⌘I, ⌘U, etc.) — Tiptap StarterKit provides these.
outline: 2px solid #2eaadc; outline-offset: 2px.aria-pressed.role="dialog" and a labeled heading.{
"manifest_version": 3,
"name": "Meet Formatter",
"version": "1.0.0",
"description": "Inject a Markdown-defined template into Google Calendar event Details.",
"action": { "default_popup": "popup.html" },
"background": { "service_worker": "background/service-worker.ts", "type": "module" },
"content_scripts": [
{
"matches": ["*://calendar.google.com/*"],
"js": ["content/calendar-injector.ts"],
"run_at": "document_idle"
}
],
"permissions": ["storage", "scripting"],
"host_permissions": ["*://calendar.google.com/*"],
"icons": {
"16": "assets/images/icon16.png",
"48": "assets/images/icon48.png",
"128": "assets/images/icon128.png"
}
}
host_permissions (not just activeTab) is required so the service worker’s chrome.tabs.query({ url: '*://calendar.google.com/*' }) actually returns the tab’s URL — without host access, Chrome omits the url field from tabs.query results and the message router can’t find the target tab.
The scripting permission lets the service worker call chrome.scripting.executeScript to programmatically inject the content script into Calendar tabs that were open before the extension was installed or reloaded. Without this, the user sees "Could not establish connection. Receiving end does not exist." after every extension update until they manually reload each Calendar tab. The service worker:
chrome.runtime.onInstalled and on service-worker boot, injecting the content script into each.chrome.tabs.sendMessage rejection (no listener), injects and retries once.chrome.runtime.getManifest() at runtime, since crxjs generates a hash-suffixed name per build.The @crxjs/vite-plugin generates the final manifest.json from this TypeScript config.
Colocated with source as *.test.ts. Target: <1 s total.
| Spec | What it asserts |
|---|---|
lib/markdown.test.ts |
Markdown → sanitized HTML; strips <script>, onerror, javascript: URLs |
lib/storage.test.ts |
Round-trips Settings; surfaces quota errors via mocked chrome.storage.sync |
lib/details-locator.test.ts |
Loads fixture HTML snapshot of the Calendar event dialog; locator finds the right contenteditable and ignores decoys (e.g. the Title field) |
popup/Editor.test.tsx |
Typing **bold** produces a Bold mark; undo/redo work via editor.commands.* |
popup/App.test.tsx |
Save button flushes; auto-save fires 400 ms after onUpdate; Insert posts the right message |
Runs against tests/fixtures/fake-calendar.html — a static page that mirrors the real Calendar’s collapsed-then-expand Description shape ([data-key="description"] span + jsname="Ofl2hc" expandable container + #xDescIn panel containing [role="textbox"][contenteditable="true"][aria-label="Add description"]). Deterministic, runs in CI, no Google login required.
HSTS workaround. Chrome force-upgrades calendar.google.com to HTTPS via its built-in HSTS preload list, regardless of the URL scheme the test passes. To still run the content script (whose match pattern is *://calendar.google.com/*) against a local fixture:
http-server using a self-signed cert at tests/fixtures/cert.pem (key at tests/fixtures/key.pem).--host-resolver-rules=MAP calendar.google.com 127.0.0.1:5174 (re-routes DNS) and --ignore-certificate-errors (accepts the self-signed cert).https://calendar.google.com/fake-calendar.html; the content script attaches because the URL matches the manifest pattern, even though traffic actually goes to the local server.Cases (in tests/e2e/):
Markdown supported placeholder.Saved ✓ confirmation.[data-key="description"]).window.__openDialogWithDescription(html) hook).calendar.google.comRuns once, manually, before declaring the task done. There are two viable harnesses; we use the second because it’s friction-free for the developer.
Option A — Playwright headed (kept as a checked-in spec, gated by RUN_LIVE=1). tests/verify-live.spec.ts launches Chromium with the unpacked extension and a persistent user-data dir. Requires the user to log into Google manually on first run.
Option B — Claude-driven via the Claude_in_Chrome MCP (preferred). Claude pairs with the user’s already-authenticated Chrome via the Claude_in_Chrome extension and the Control_Chrome / Claude_in_Chrome MCP tools. Steps:
dist/) into their normal Chrome and authorizes the Claude_in_Chrome extension to access calendar.google.com.list_connected_browsers → select_browser → tabs_context_mcp to grab a handle on a tab.https://calendar.google.com/calendar/u/0/r, clicks the calendar grid (or the Create button → Event) to open the event dialog, takes a screenshot.javascript_tool to run the same locator code the extension ships — document.querySelector('#xDescIn [contenteditable="true"][role="textbox"]') — and asserts the returned element matches the expected anchors.The harness produces a short verbal report (“auto-insert fired, template rendered as expected, description survived save+reload”) plus the three screenshots. Failure modes are concrete: locator returns null (DOM has shifted — update §4 anchors), or the click on [data-key="description"] doesn’t expand the panel (Google has changed the expander handler — update §4 step 3).
Files to delete from the current Manifest V2 codebase (all at repo root):
popup.html, popup.js, action.js, background.js, injector.js, app.jsjquery-3.5.1.min.jsmanifest.json (replaced by Vite-generated MV3 manifest emitted into dist/manifest.json)Files to keep:
assets/images/icon{16,48,128}.pngREADME.md (update for the new install/dev workflow)LICENSECLAUDE.md (update post-implementation to reflect the new architecture)Both are explicitly out of scope for v1.0 but called out here so the v1.0 data model and architecture leave room for them without rework (template: string becomes templates: Record<string, string> + activeId: string; the empty-check in §4 becomes a mode switch).
Two snippets captured during development. These are the source of truth for the §4 locator anchors.
<span id="c4092">
Add
<span jsaction="clickonly:s4Pcbe; mousedown:nzqhhd"
class="JyrDof" data-key="description" jslog="39830">description</span>
or
<span jsaction="clickonly:s4Pcbe; mousedown:nzqhhd"
class="JyrDof" data-key="attachments" jslog="64574">a Google Drive attachment</span>
</span>
The id="c4092" is dynamic (regenerated per session) and not used by the locator. The stable anchor is [data-key="description"].
<div data-expandable jsshadow class="anMZof AHjck dBA1M sMVRZe"
data-uid="c4094" jsname="Ofl2hc" jscontroller="OHz5R">
<div class="rdgVoe" jsname="L9hiGd" jsslot>
<div class="HZL2hc DX1o3" jsname="Zqjuqb" jscontroller="K8MFQc">
<button aria-expanded="false" aria-controls="c4094" ...>Add description</button>
</div>
</div>
<div class="drQEgd" jsname="xpDKHf" jsslot id="c4094">
...
<div jsname="L9AdLc" jslog="39830; track:OCqEwd;">
<div jsname="INgbqf" role="toolbar" aria-label="Formatting options">
<div role="button" aria-label="Bold" data-tooltip="Bold" aria-pressed="false" ... ></div>
<div role="button" aria-label="Italic" ...></div>
<div role="button" aria-label="Underline"...></div>
<div role="button" aria-label="Numbered list"...></div>
<div role="button" aria-label="Bulleted list"...></div>
<div role="button" aria-label="Insert link"...></div>
<div role="button" aria-label="Remove formatting"...></div>
</div>
<div id="xDescIn" class="tAVIoc I9OJHe">
<div jsname="V67aGc" class="GB6BTc snByac" aria-hidden="true">Add description</div>
<div jsname="yrriRe"
class="hj99tb KRoqRc editable"
role="textbox"
aria-multiline="true"
aria-label="Add description"
id="hj99tb2"
g_editable="true"
contenteditable="true"
style="direction: ltr;">
<div><br></div>
</div>
</div>
</div>
</div>
</div>
What this confirms:
[contenteditable="true"][role="textbox"][aria-label="Add description"].<div><br></div>.#xDescIn, which is the stable container id.<div aria-hidden="true">Add description</div> is a placeholder span, not the editor.sMVRZe is added to the outer [data-expandable] when the panel is expanded; the implementation does not rely on this since the presence of #xDescIn is more reliable.jslog="39830" attribute tags the Description feature consistently (also present on the [data-key="description"] span); could be used as a secondary anchor if the others ever break.Captured by pasting tests/manual-probe.js into DevTools console at https://calendar.google.com/calendar/u/0/r/eventedit?overrides=... (the URL Calendar navigates to when the user presses c from the calendar grid).
[role="dialog"]The first probe returned hasDialog: false. The full-page editor is not wrapped in a [role="dialog"] container. This invalidated an earlier version of the locator which scoped all queries to document.querySelector('[role="dialog"]'). The locator was widened to default to document.body.
A second probe at document level confirmed #xDescIn exists and the contenteditable inside it has these attributes (captured verbatim from the live page):
jsname = "yrriRe"
jsaction = "touchend:ufphYd; input:q3884e; paste:QD1hyc; drop:HZC9qb"
class = "hj99tb KRoqRc editable"
role = "textbox"
aria-multiline = "true"
aria-label = "Description" ← singular, NOT "Add description"
id = "hj99tb0" ← varies, do not depend on
g_editable = "true"
contenteditable = "true"
style = "direction: ltr;"
innerHTML on first open: <div><br></div> (same empty marker as the modal surface).
findDescriptionExpander(document.body) returned null — the editor is already mounted, no click required. This is the principal behavioral difference between the two surfaces and is encoded in §4’s runtime flow (the findSurface() → findDetailsEditor(surface) happy path skips straight to injection on the full-page editor).
Captured from the same /r/eventedit page, the Title <input> is:
<input jsname="YPqjbf"
type="text"
value=""
id="xTiIn"
class="Fgl6fe-fmcmS-wGMbrd"
jsaction="input:YPqjbf;focus:AHmuwe;blur:O22p3e"
aria-controls="xTiIn-help-text-id"
aria-describedby="xTiIn-help-text-id"
aria-label="Title"
placeholder="Add title"
dir="auto"
autofocus
autocomplete="off">
Stable anchors: id="xTiIn" (hardcoded by Google), aria-label="Title" (singular). The modal surface has a similar input but with aria-label="Add title" and a dynamic id="c###".
A user-provided skill documents the keyboard path:
1. t → today
2. d → day view
3. c → open full event editor (navigates to /r/eventedit)
4. Click "Add description" field; type description text
5. Click Save (top right)
The probe script tests/manual-probe.js documents the procedure for capturing future DOM samples when Calendar’s structure shifts.