This file provides guidance to AI coding agents and human contributors when working on this repository. It is intentionally public so contributors and AI tools can share a common operating manual.
PocketResume is a Chrome Extension (Manifest V3) built with vanilla HTML/CSS/JS. No bundler / build pipeline for app code; the only build step is esbuild for the Pro (Clerk) bundle. Edit source files, reload the unpacked extension in Chrome.
Key runtime entrypoints (declared in manifest.json):
background.js — pipeline orchestration + AI API callscontent.js — extracts page text from the active tabpopup.html + popup.js — user actions + PDF generation for PocketResume layoutsfeature-video.js / feature-video.css (player) + scripts/capture-tour.mjs / assets/tour/ (frames) — scripted cursor-follow walkthrough, popup-onlyoptions.html + options.js — API keys + multiple resumes + togglesresume-renderers.js — Jake, Deedy, Academic CV PDF layoutssrc/cloud-sync.js (source) → cloud-sync.js (options page, with Clerk UI) + cloud-sync-core.js (popup / tracker / worker, no UI) — both bundles gitignored — auth, plan gating, pricing, resume syncconvex/ — auth config + resume schema/functionsThe extension runs without Node at runtime, but the repo uses npm to manage dependencies (and to bundle cloud-sync.js + update the vendored jsPDF build).
npm ci
# or
npm install
src/cloud-sync.js (Pro sign-in, plan gating, pricing table, resume sync) is bundled by esbuild into two outputs at the repo root (both gitignored). Required env vars (CLERK_PUBLISHABLE_KEY, CONVEX_URL) are injected at build time, and a __CLERK_UI__ define decides whether Clerk’s UI (≈1.9 MB of React) is bundled at all:
| output | __CLERK_UI__ |
loaded by | size |
|---|---|---|---|
cloud-sync.js |
true |
options.html |
~2.8 MB |
cloud-sync-core.js |
false |
popup.html, tracker.html, background.js |
~0.9 MB |
The UI build is the only place @clerk/ui is imported. Keep that import inside if (__CLERK_UI__) — a HAS_CLERK_UI-style const does not help, because esbuild resolves dynamic imports before its minifier folds the constant, so the whole UI ends up in both bundles. See .env.example and package.json → scripts/build-clerk.mjs.
cp .env.example .env.local
# fill in your own Clerk + Convex values
npm run build:clerk
popup.html loads jsPDF from libs/jspdf.umd.min.js (vendored). If you bump jspdf, copy the built artifact into libs/.
npm install jspdf@latest
cp node_modules/jspdf/dist/jspdf.umd.min.js libs/jspdf.umd.min.js
tracker.html loads the Newton’s Cradle loader from libs/ldrs-newtons-cradle.js (vendored esbuild bundle of the ldrs web component, used as the plan-check loader in the Job Tracker header). If you bump ldrs, rebuild the bundle:
npm install ldrs@latest
npm run build:ldrs
No dev server.
chrome://extensions → Developer mode → Load unpacked → select repo rootDebugging:
npx convex dev
Requires CONVEX_DEPLOYMENT in .env.local. Generated code goes to convex/_generated/ (gitignored).
None configured. No test runner or linter. Validate by hand: load unpacked, generate a resume with a real API key, inspect the PDF + the service worker console.
On every manifest.json version bump, you MUST also update CHANGELOG.md and the popup “What’s New” announcement:
manifest.json → version.CHANGELOG.md (## [X.Y] — YYYY-MM-DD) summarizing the change under Added / Changed / Fixed / Removed. Newest first.ANNOUNCEMENT_VERSION + ANNOUNCEMENT_SEEN_VALUE in popup.js and the modal copy in popup.html (#whatsNewModal). The modal shows once per version.npm run build:clerk first (if src/cloud-sync.js changed), then npm run build:zip to write dist/pocketresume-vX.Y.zip from the runtime file list — see “Store build” below.Chrome Web Store upload = a zip with manifest.json at the root and only runtime files. Include:
manifest.json, background.js, content.js, form-filler.js, form-profile.js, popup.html, popup.js, options.html, options.js, tracker.html, tracker.js, resume-renderers.js, feature-video.js, feature-video.css, cloud-sync.js, cloud-sync-core.js, icon.png, libs/ (jsPDF, ldrs loader, animations), assets/tour/.
Exclude: node_modules/, dist/, .git, .github/, convex/, scripts/, src/, store-assets/, redesign/, models/, *.md, package*.json, .env*. Output to dist/pocketresume-vX.Y.zip.
scripts/build-zip.mjs (npm run build:zip) owns that list, reads the version from manifest.json, fails loudly if a runtime file is missing (e.g. you forgot npm run build:clerk), and prints the archive contents. Prefer it over zipping by hand — the file list has to match the manifest.json script graph, and a zip missing cloud-sync-core.js kills the service worker on boot.
popup.js).chrome.runtime.sendMessage({ type: 'START_GENERATION', payload: { tabId, resumeStyle, resumeId } }).background.js) handles START_GENERATION:
chrome.storage.local (API key per provider, resumes, cover letter toggle).GET_PAGE_CONTENT. Falls back to injecting content.js via chrome.scripting.executeScript(...).{ status: 'success', data: <resumeJsonString>, coverLetterData: <coverLetterJsonString|null> } or { status: 'error', message: <string> }.JSON.parse(...) the model output.generatePDF(...) in popup.jswindow.ResumeRenderers.generateResumePDF(...) in resume-renderers.jsgenerateCoverLetterPDF(...) in popup.jssetError() stores the raw message, maps it to a short human-readable string via mapErrorMessage(...), and persists the red data-status="error" state until the popup closes or Generate is clicked again. It also reveals the “?” button (#errorInfoBtn), which opens the error modal (#errorModal) with the mapped message plus a Copy Details button for the full raw error. Exception: a missing/unconfigured API key or resume no longer produces the red error — the setup card shows instead (see onboarding flow below).New/unconfigured users get a setup card in the popup plus a spotlight tour on the options page instead of a red error.
popup.js): if the config check (API key per provider / custom endpoint + resume content) fails, renderSetupCard(...) shows #setupCard — a checklist (provider / API key / master resume / save settings) with live checkmarks. Generate stays disabled.onboarding: { step: N } to chrome.storage.local (N is 1-based into TOUR_STEPS in options.js) and calls chrome.runtime.openOptionsPage(). “Skip setup” sets onboarding.dismissed = true, which collapses the card to the compact #setupCompact variant on later opens (the red error never returns for a missing-config state).options.js): if onboarding.step is a number, the spotlight tour opens at that step. A chrome.storage.onChanged listener also starts the tour if the options page is already open when the popup sets the step.TOUR_STEPS (options.js) walks through: provider icons → API key → model (optional) → #resumeContentTextarea → #refineResumeBtn (explain only, no AI call forced) → #save. The highlight uses a box-shadow spotlight and is pointer-events: none, so the user interacts with the real UI while the tour guides.onboarding: { step: null, dismissed: true }. Clicking Save Settings while the tour is active (tourNotifySaved()) jumps straight to the finish card.onboardingCompleted is not yet set, the popup shows the one-time “Setup complete” card (with a “Watch the feature tour” / “Not now” offer) and persists onboardingCompleted: true.A “follow the cursor” walkthrough built on real app screenshots: scripts/capture-tour.mjs drives the actual popup/options/tracker pages with a chrome.* shim and saves 13 cropped WebP frames + a frames.js manifest into assets/tour/ (committed, ~110 KB total). feature-video.js (timeline player: cursor glide, click ripples, DO/DON’T badges over the real blue/amber glow states; setup steps like API key / resume paste are deliberately NOT shown — onboarding covers them) + feature-video.css render it in the popup; exposes window.PocketResumeFeatureVideo.open(source) / close(). Choreography (frame + hotspot coords) is expressed in the 14-step TIMELINE array in feature-video.js.
feature_video_offered.PocketResumeFeatureVideo.open() (opened via prompt vs header logged as source on feature_video_played); card hides.featureVideoDeclined: true (prompt never reappears), logs feature_video_declined, and swaps the card text to point at the always-visible ▶ button in the popup header.#featureVideoBtn (top-right, next to the app icon) opens the tour anytime (source: 'header'). Player: play/pause (space), per-step progress dots, Esc/✕ close; end caption points back at the header button.
npm run build:tour. Frames are centered 340px-wide clips (clipH set per frame); hotspot coords are normalized [0..1] so the player maps them onto the rendered image box.featureVideoDeclined (video prompt shown once; onboardingCompleted gates the offer card).options.js → handleRefineResume).GET_REFINE_QUESTIONS with source text. Background generateRefineQuestions(...) does one strict-JSON call: reads the master resume and returns up to 5 grounded questions about gaps that materially affect the rewrite (company problem before joining, team size, project scope/ownership, scale). Empty array → no popup. Pre-pass failure → refine proceeds without questions (feature never blocks).#refineQuestionsModal: one question at a time, textarea, and a single primary button that reads “Skip, let AI fill it” when the textarea is empty and “Next” (or “Refine Resume” on the last question) otherwise; Back appears from question 2, Cancel sits right-aligned. Existing answers are pre-seeded from resume.refineAnswers (matched by normalized question text) and marked “(from last time)”.REFINE_RESUME with answers: [{ id, question, answer, skipped }]. Background refineResumeSource(context, sourceText, answers) — answered Q&A is highest-authority fact; skipped questions get conservative in-scope AI filler (no invented specific numbers/names) reported in the response’s aiFilled array.ats.before → ats.after scores, “Before X → After Y” line), a “Critical ATS issues” card (each row: issue + why an ATS trips on it + reveal-only Fix button showing suggestedFix; amber “You’ll need to:” lines for userMustFix items, shown even at score 100), change summary, “AI filled in (please double-check)” card, and warnings.resume.refineAnswers, newest first) or Cancel. Undo restores the last pre-refine backup (refineAnswers untouched).resume.refineAnswers ([{ question, answer, skipped, updatedAt }], max 10) persists with the other resume fields via Save Settings.EXTRACT_RESUME_JSON to background with source text.*ResumeExtraction(...) function — extracts structured JSON profile from raw text.jsonContent on the resume entry and persisted. Used as jsonContent in the generation pipeline.#checkAtsBtn → handleCheckAts): one CHECK_ATS message per click → background generateAtsCheck(context, sourceText) (one strict-JSON call, fresh every time) → #atsResultModal: ring-gauge odometer count-up 0 → score with the shared renderAtsIssuesCard issues list (“Fix” reveals + “You’ll need to:” lines). Scores the current resume text in the textarea.buildAtsScoringRules() (background.js), shared verbatim by generateAtsCheck and the refine prompt, so scores are comparable across the two flows.bulletIssues list sits beside criticalIssues in the same response (normalized by the same normalizeAtsIssues, rendered by renderBulletIssuesCard in options.js) and never moves the number. It flags exactly two things: bullets that do not open with a strong action verb, and bullets that state no result/scale. Rows are namespaced with a data-bullet-issue-* prefix (vs data-ats-issue-*) so wireAtsFixToggles() resolves each “Fix” reveal to its own card.ats: { before, after, criticalIssues, bulletIssues } inside the refine response (before = source resume, after = proposed refinedText; userMustFix items listed even when after = 100). A failing or missing ats block never blocks the refine — the hero is simply hidden.One-time onboarding for Form Filler: the user answers common application-form questions once; fills reuse them.
#appProfileDetails, left panel). Groups: Basics (first/last name, email, phone), Location (street, apt, city, state, postal code, country), Work eligibility (authorized / sponsorship / 18+ / relocate / remote preference — Yes/No selects), Preferences (salary amount + currency + period, start date, years of experience), Links (LinkedIn, portfolio, GitHub), opt-in EEO self-identification (gender, race, hispanic/Latino, veteran, disability — only used when a form asks; local only), and Custom Q&A (free-form question/answer rows matched by question text).PROFILE_AUTOFILL with the active resume’s jsonContent (fallback content). Background generateApplicationProfileFromResume(...) does one strict-JSON extraction call → options fills empty inputs only and reports “Filled X of Y fields”.applicationProfile via the global Save Settings button. Completion = non-empty firstName + lastName; the section’s status pill shows “Ready” / “Not set up”.appProfileOnboarding: { active: true } and open the options page; options consumes the flag and starts the profile spotlight tour (PROFILE_TOUR_STEPS, reuses the tour overlay via tourOpenAt(step, 'profile')). “Guide me” in the section restarts it. Saved answers keep working if the tour is skipped.CHECK_PRO_ACCESS; when access is denied it shows #fillProCard (“Create a free account” during the promo, else “See Pro plans”). Only after Pro access passes does it check applicationProfile completeness and show #fillProfileCard (“Complete setup”) if incomplete. Background also re-checks getFormFillAccess() and replies { status: 'upgrade_required', promoActive } so the gate can’t be bypassed. Generation flow is unaffected.CHECK_PRO_ACCESS (blocks with #fillProCard if denied), then checks applicationProfile completeness (see Form filler setup above) and blocks with #fillProfileCard if incomplete. Background re-checks Pro access on FILL_APPLICATION_FORM.FILL_APPLICATION_FORM to background with { tabId, resumeId }.START_GENERATION), injects form-filler.js into all frames via chrome.scripting.executeScript, then runs __PocketResumeForm.detect(...) per frame and merges results (field ids are frame-token prefixed). Detection covers text inputs, textareas, selects (only placeholder-unselected ones; matched by option text and value), radio groups, checkbox groups, single question-style checkboxes (consent labels excluded), email/url/date inputs, and contenteditables.resolveFormAnswers(fields, applicationProfile) in form-profile.js splits fields. Tier 1: canonical label matchers → applicationProfile values (name split, address, salary formatting by field type incl. hourly conversion, yes/no, EEO gated by eeoOptIn). Tier 2: customQA match (normalized equality, containment, Jaccard ≥ 0.85). Select/radio/checkbox-group answers must fuzzy-match one of the field’s options, else the field falls to AI. Single checkboxes get boolean intent (Yes → check, No → leave unchecked). Zero tokens for resolved fields.generateFormAnswers(context, userProfile, unresolvedFields, applicationProfile) — one AI call for only the unresolved fields (essays, company-specific questions). Same prompt + a SAVED PROFILE JSON line for grounding; skipped entirely when nothing is unresolved (0 tokens).normalizeFormAnswers (id validation + maxLength truncation), then __PocketResumeForm.fill(...) per frame (native value setters + input/change events for React/Vue compatibility). Checkbox groups: only matching options are checked, never unchecked; already-ticked groups are skipped entirely."... (N from saved answers)"), replies { status: 'success', filled, total, cached }, and logs [FormFill] Filled X of Y fields. (Z saved, W AI).setError path. The popup background glows via body[data-fill-status]: filling (amber pulse) → fill-success (green, auto-reverts after 4s) or fill-failure (red, persists until the next Fill/Generate click). Kept separate from body[data-status] so the two flows never fight over the glow.Safety rules (enforced in form-filler.js + prompt): never submits the form, never overwrites already-filled fields, never unchecks anything the user already ticked, checkbox/consent widgets with consent-style labels (consent/agree/terms/privacy/newsletter/marketing/subscribe/opt-in/gdpr/cookies) are never touched, skips hidden/disabled/readonly/captcha/search fields, caps at 30 fields. Known gap: custom div widgets ([role="checkbox"]/[role="radio"] without real inputs, legacy Workday) are not detected.
#quickDownloadBtn (popup button paired with Settings in the same .list-row-pair row). No config gate, no API key, no active-tab requirement — it stays clickable even when Generate is disabled by onboarding.{ resumes, selectedResumeId, userProfile } from chrome.storage.local, resolves the selected resume (legacy userProfile fallback), and takes resume.jsonContent — the same schema extractResumeProfileJson produces and generatePDF consumes. Markdown fences are stripped before JSON.parse (same helper the Generate path uses).generatePDF(resumeData, 'professional') directly in the popup. No AI call, no START_GENERATION, no cover letter — the style is always the professional PocketResume layout, regardless of the popup’s style selector. Costs 0 tokens.setError (red glow + ? → error modal with Open Provider Settings, which appears because the messages mention Settings): no resumes at all, no jsonContent (prompt to run Extract JSON), or unparseable JSON. mapErrorMessage has a /quick download|extract json/i pass-through so these user-facing messages are not rewritten by the generic “invalid JSON” rule.#whatsNewPlansBtn) dismisses the modal and opens the options page (it triggered Quick Download in v9.0, when that modal announced Quick Download); Quick Download’s “New” badge (#quickDownloadNewBadge) is removed on first hover/focus and persisted via quickDownloadNewBadgeDismissed, matching the Tracker/Fill Form badges..list-row-pair .list-row carries flex: 1 + min-height: 43px so the badge-bearing rows (43px) and the badge-less rows (41px) line up. “Quick Download” cannot fit next to its NEW badge in a 166px button, so while the badge is on screen the button carries .has-badge: label drops to 10px, icon to 15px, row gap to 4px, and the badge shrinks to 7.5px. popup.js adds/removes .has-badge alongside the badge itself, so the first hover restores the full 13px label in the same 200ms font-size transition that fades the badge.Configured by getResumeStyleConfig(...) in background.js:
| UI Style | promptStyle | layout | PDF Renderer |
|---|---|---|---|
| professional | professional | pocketresume | popup.js → generatePDF |
| faang | faang | pocketresume | popup.js → generatePDF |
| deedy | faang | deedy | resume-renderers.js → renderDeedyLayout |
| academic-cv | academic-cv | academic-cv | resume-renderers.js → renderAcademicCvLayout |
Bullet-format rules in generateTailoredResume(...) (background.js): every promptStyle injects a shared actionVerbRule (verb-first mandate, tense rule, banned filler openers, ~60-verb list, weak→strong examples) via bulletImpactRule — do not overwrite bulletImpactRule with terser text or the verb requirement is lost. On top of that: professional promptStyle gets 2-3 accomplishment bullets per experience and project, ordered strongest-result-first, with problem context allowed only as a short embedded clause; faang promptStyle (FAANG, Double Sided) gets an exact 3-bullet outcome-first / second-accomplishment / scale format per experience and project, with estimated metrics allowed only there; academic-cv gets no bullet-format rule but still obeys actionVerbRule.
Cover letter tone in generateCoverLetterText(...) (background.js, buildCoverLetterToneGuide) forks on the raw UI style (not promptStyle, because deedy maps to a faang promptStyle but gets the corporate letter): professional + deedy → corporate story tone (why company/role, skills-to-need, no resume recitation, portfolio-website emphasis); faang → para 1 corporate-style fit, para 2 metrics-heavy results proof (metrics grounded in the tailoredResumeJson passed at the call site; fallback to raw profile; never invent numbers); academic-cv → research/scholar tone (methods + tools + JD research-area fit, learner posture). Shared: 250-350 words, JSON-only, no invented facts, same JSON schema.
The stored "deedy" value corresponds to the UI label “Double Sided” — renaming the value would break existing users’ saved settings, so keep "deedy" as the internal id.
Settings are stored in chrome.storage.local, managed in options.js.
Important keys:
apiProvider: "google" | "openrouter" | "openai" | "anthropic" | "custom"geminiApiKey / openrouterApiKey / openaiApiKey / anthropicApiKey: stringgoogleModel / openaiModel / anthropicModel / openrouterModel: string model override (“” = provider default)customEndpoints: array of { id, name, baseUrl, apiKey, model, extraBody } (OpenAI-compatible endpoints; apiKey may be empty for local servers; extraBody is an optional raw JSON string shallow-merged into the request body)activeCustomEndpointId: which custom endpoint is active when apiProvider is "custom"resumes: array of { id, label, content, jsonContent, lastRefineBackup, lastRefineAppliedAt, refineAnswers, updatedAt } (up to 3). updatedAt (ms) drives cloud sync last-write-wins.deletedResumeIds: [{ id, deletedAt }] — delete tombstones so automatic cloud restore doesn’t resurrect a locally deleted resumeselectedResumeId: which resume is active in the popupcloudSyncStatus: "idle" | "syncing" | "synced" | "error" (cloud sync indicator, written by src/cloud-sync.js)resumeType: "professional" | "faang" | "deedy" | "academic-cv"coverLetterEnabled: booleanapplicationProfile: Form Filler answers — { firstName, lastName, email, phone, streetAddress, addressLine2, city, state, postalCode, country, salaryAmount, salaryCurrency, salaryPeriod, startDate, yearsExperience, workAuthorized, needsSponsorship, over18, willingToRelocate, remotePreference, linkedin, website, github, eeoOptIn, eeo: { gender, race, hispanicLatino, veteran, disability }, customQA: [{ id, question, answer }], updatedAt }appProfileOnboarding: { active: boolean } — trigger for the Form Filler setup spotlight tour (set by the popup, consumed by the options page)refineNudge: { active: boolean } — trigger for the v8.2 “Smarter Refine” spotlight on #refineResumeBtn (set by the popup’s What’s New modal via startRefineNudge(), consumed by the options page via NUDGE_TOUR_STEPS + a 'nudge' tour mode)atsNudge: { active: boolean } — trigger for the v8.2 “Check ATS” spotlight on #checkAtsBtn (set by the popup’s What’s New modal via startAtsNudge(), consumed by the options page via ATS_NUDGE_TOUR_STEPS + an 'atsnudge' tour mode)lastSeenAnnouncement: last version whose What’s New modal the user saw ('9.0' current)featureVideoDeclined: boolean — user declined the one-time feature-tour video offer; prompt never reappears (header button still opens it)quickDownloadNewBadgeDismissed: boolean — the Quick Download “New” badge is removed after the user’s first hover/focus and never returnstrackerPlanCache: { proAccess, isPro, promoActive, signedIn, checkedAt } — last Job Tracker access resolution, used for first paint / offline (ignored when checkedAt is older than 60 min)proAccessCache: same shape as trackerPlanCache plus source and resolved/healthy — the page-published access snapshot. Written by getAccessState() in every page realm (options / popup / tracker) for both signed-in and signed-out resolutions; the service worker never writes it (it has no session to report) and never deletes it. Background getFormFillAccess() uses its own live answer when healthy && signedIn, otherwise this snapshot while it is younger than 60 min, otherwise denies. A Clerk addListener in the pages rewrites it the moment a session starts or ends, so a session expiry cannot leave a stale “Pro” behind.trackerLockDismissed: boolean — user dismissed the Job Tracker lock bannerLegacy migration: userProfile → resumes[0].content
Five providers supported, selected via apiProvider:
gemini-3.1-flash-lite, API key from Google AI Studiogpt-5-nano, API key from OpenAI Platformclaude-haiku-4-5, API key from Anthropic Consolenvidia/nemotron-3-super-120b-a12b:free, API key from OpenRoutercustomEndpoints; the active one is used. No API key required for local servers.Model overrides per provider are stored in the *Model keys; empty string falls back to the defaults in PROVIDER_DEFAULT_MODELS (background.js). The options page can fetch available models from each provider’s list endpoint.
All providers share one request path: executeProviderChat(context, prompt, label) in background.js handles the three wire formats (OpenAI-compatible chat completions, Anthropic messages, Gemini generateContent). The pipelines call it via generateTailoredResume, generateCoverLetterText, extractResumeProfileJson, generateRefineQuestions, refineResumeSource, generateFormAnswers, and generateApplicationProfileFromResume.
Custom endpoints require a runtime host permission for the endpoint’s origin. manifest.json declares optional_host_permissions: ["https://*/*", "http://*/*"]; the options page calls chrome.permissions.request({ origins: [origin + '/*'] }) when saving or testing an endpoint.
Plans: Free (free_user, auto-assigned to every signed-in user) includes resume cloud sync. Pro (pocketresume_pro, paid) adds the full Job Tracker and Form Filler; legacy cloud_sync/pro/premium subscriptions and metadata still count as Pro. Sign-in, the embedded pricing table, and sync run through Clerk + Convex. Plans live in Clerk instance config under billing.plans, editable via clerk config patch / Dashboard.
Launch promo trial (PROMO_TRIAL in src/cloud-sync.js): a fixed window (Sep 27 → Oct 31, local time) during which any free-plan subscriber gets Pro access (Job Tracker + Form Filler + cloud sync). Non-subscribers are locked out of every Pro feature and prompted to create a free account; promoActive && !signedIn drives the “Start free” copy in the tracker lock banner. After the window closes, gating reverts to the steady state (free = cloud sync only; Pro = everything). This replaced the old per-user 30-day tracker trial — do not reintroduce trackerTrialStartedAt/trackerUnlocked.
Access resolution lives in src/cloud-sync.js:
getAccessState() → { signedIn, isFreeSubscriber, isPro, promoActive, cloudSyncAccess, proAccess, resolved, healthy }hasCloudSyncAccess() = free + Pro + legacy (cloud sync)hasProAccess() = Pro + legacy, or promo-active free subscriber (Job Tracker + Form Filler)Auth lives in page realms only. clerk-js keeps its client token in an httpOnly cookie on Clerk’s Frontend API domain, so only a page (options / popup / tracker) can hold and refresh a session — a service worker never can, and @clerk/chrome-extension’s background client does not fix that (it is a second, unrelated token store). The worker therefore holds no Clerk client at all (initClerk() returns early in the SW) and is told the plan by the pages through proAccessCache. Do not reintroduce a worker-side Clerk client or a worker-side await import() of Clerk: dynamic import() is not supported in extension service workers, and a second token store reads permanently signed-out and fails the Pro gate closed. Consequence to remember: Convex calls also cannot be authenticated from the worker, so resume sync must run in a page.
Architecture:
src/cloud-sync.js — IIFE source, bundled by esbuild → cloud-sync.js (options page, with Clerk UI) and cloud-sync-core.js (popup / tracker / worker, no UI); both gitignored. Provides auth (signIn/signUp/isSignedIn/getUserProfile), plan checks (hasCloudSyncAccess/hasProAccess/getAccessState/isPromoTrialActive), the Clerk pricing table mount, and resume sync. syncResumes() is the bidirectional auto-sync: it reads local resumes + deletedResumeIds, pulls the cloud list, merges by last-write-wins on updatedAt, restores cloud-only resumes locally, pushes local-only/newer resumes, applies delete tombstones, and writes back only when the result changed (prevents sync loops). startAutoSync() binds the chrome.storage.onChanged listener that debounces syncResumes() by 2s (page realms only). getAccessState() publishes the snapshot to proAccessCache (page realms only, both signed-in and signed-out), reloads the Clerk user first when the cached copy is over a minute old so dashboard metadata edits apply, and reports healthy (Clerk initialised and hydrated) alongside resolved — never treat “a client object exists” as resolved. A Clerk addListener rewrites the snapshot whenever a session starts or ends. init() is memoized (one shared in-flight promise, cleared on failure so a retry can happen): initClerk() assigns clerkClient before load() resolves, and Clerk only attaches its UI components during load(), so a second caller that grabbed the client mid-load used to fail mountPricingTable / openSignIn with “Clerk was not loaded with Ui components” — reproducible whenever the options page opens #cloud-pricing while its own startup is still loading Clerk. No caller may observe clerkClient before await init() settles. The UI must also be handed over as Clerk’s branded ui object ((await import('@clerk/ui/no-rhc')).ui), not the module namespace — with the namespace, load() reports success but silently skips attaching UI components, and mountPricingTable / openSignIn fail with “Clerk was not loaded with Ui components”. A page-realm init() that ends with Clerk unhealthy rejects (so the memo clears and the next call retries); the service worker is exempt, because it never starts Clerk and its getAccessState() must keep returning so the snapshot gate can run.background.js — answers CHECK_PRO_ACCESS and gates FILL_APPLICATION_FORM on getFormFillAccess(): its own answer when healthy && signedIn, else the page-published proAccessCache snapshot while it is younger than 60 min, else deny. It never writes or deletes that snapshot.options.js — always-visible account card at the top of the left panel: signed-out offer (badge + feature bullets + Create free account / See Pro plans / Sign in) or signed-in row (avatar + name + plan pill + Upgrade · Manage plan / Sign out) with a “resumes sync automatically” note. No manual push/restore controls; runAutoSync() fires on load and after sign-in, and CloudSync.startAutoSync() is bound on load. Resume edits bump updatedAt; deletes record a deletedResumeIds tombstone and sync. Pricing table mounts inline (needs the UI build).popup.js — header #accountBtn (Sign in / Start free / Upgrade / Pro pill) and the bulleted #fillProCard upgrade card. checkProAccess() prefers the popup’s own in-page getAccessState() (2.5 s timeout) because that is the realm holding the session, falls back to CHECK_PRO_ACCESS (the snapshot), and retries once on chrome.runtime.lastError so the pill can never silently vanish.tracker.js — checkAccess() gates the Job Tracker via window.CloudSync.getAccessState(); caches the result in trackerPlanCacheconvex/auth.config.ts — Clerk → Convex auth wiring; requires CLERK_FRONTEND_API_URL env varconvex/schema.ts — resumes table shapeconvex/resumes.ts — list, upsert, remove queries/mutationsSign-in behavior: all Clerk redirects (signInForceRedirectUrl, signUpForceRedirectUrl, afterSignOutUrl, signOut redirectUrl) point at options.html — never popup.html (navigating the Settings tab to the popup breaks the React tree with removeChild errors). The sign-in modal is themed dark via appearance.variables passed to clerk.load(...) — explicit input colors are required or typed text inherits the page’s light color and becomes invisible inside Clerk’s light-styled inputs.
Credentials are never hardcoded. The build step injects CLERK_PUBLISHABLE_KEY and CONVEX_URL from .env.local into the bundle. Contributors must set up their own Clerk + Convex accounts.
PocketResume/
├── manifest.json # Manifest V3 entrypoint wiring
├── background.js # Service worker: pipeline + AI calls
├── content.js # Content script: page text extraction
├── form-filler.js # [injected on demand] Form detect/fill/toast for the Fill Form feature
├── form-profile.js # Saved-answer resolver: canonical matchers + custom Q&A matching
├── popup.html / popup.js # Popup UI + PocketResume PDF generation
├── feature-video.js / feature-video.css # Cursor-follow feature tour over real screenshots (popup-only)
├── scripts/capture-tour.mjs # Generates assets/tour/*.webp + frames.js (npm run build:tour)
├── options.html / options.js# Settings: API keys, resumes, toggles
├── resume-renderers.js # Jake / Deedy / Academic CV PDF layouts
├── src/cloud-sync.js # Pro auth/plan/pricing/sync source (bundled → cloud-sync.js + cloud-sync-core.js)
├── cloud-sync.js # [generated, gitignored] esbuild bundle, Clerk UI (options.html)
├── cloud-sync-core.js # [generated, gitignored] esbuild bundle, no UI (popup / tracker / worker)
├── convex/ # Convex backend
│ ├── auth.config.ts
│ ├── schema.ts
│ ├── resumes.ts
│ └── _generated/ # [generated, gitignored]
├── libs/jspdf.umd.min.js # Vendored jsPDF
├── libs/ldrs-newtons-cradle.js # [generated, gitignored? no—committed] vendored ldrs Newton's Cradle web component
├── scripts/build-clerk.mjs # Build script for the Pro (Clerk) bundles
├── scripts/build-zip.mjs # Builds dist/pocketresume-vX.Y.zip from the runtime file list (npm run build:zip)
├── .env.example # Template for .env.local
├── AGENTS.md # This file
├── CHANGELOG.md # Version history — update on every version bump
├── CONTRIBUTING.md
├── CODE_OF_CONDUCT.md
├── SECURITY.md
├── LICENSE
└── package.json # Build scripts only (no runtime deps)
background.js (executeProviderChat + the 5 pipeline functions). Update the style config table above if the schema or layout mapping changes.content.js (extractPageText) and the truncation logic in background.js.form-filler.js (detect / fill / toast) and the FILL_APPLICATION_FORM handler + generateFormAnswers prompt in background.js.src/cloud-sync.js (getAccessState/hasProAccess/PROMO_TRIAL), background.js (CHECK_PRO_ACCESS + getFormFillAccess), popup gating in popup.js (showFillProCard/#fillProCard).form-profile.js (CANONICAL_MATCHERS, resolveFormAnswers) — imported by background.js.options.html + options.js (#appProfileDetails section, APP_PROFILE_FIELDS, PROFILE_TOUR_STEPS), background.js (PROFILE_AUTOFILL handler + generateApplicationProfileFromResume), popup gating in popup.js (isFormFillerProfileComplete / #fillProfileCard).popup.js (generatePDF / generateCoverLetterPDF).resume-renderers.js (renderDeedyLayout / renderAcademicCvLayout).options.js / options.html.popup.html / popup.js.popup.js (setError / mapErrorMessage). The keyword-based map turns long provider errors into short friendly strings; un-matched messages truncate to ~200 chars.manifest.json.src/cloud-sync.js (then npm run build:clerk), options.js (account card + auto-sync), background.js (CHECK_PRO_ACCESS), tracker.js (checkAccess), popup.js (checkProAccess).convex/schema.ts, convex/resumes.ts, convex/auth.config.ts (then npx convex dev).background.js).convex/ backend.chrome.storage.local for persistence; do not introduce new global state.Buttons use semantic accent tokens so paired actions both read as intentional (never “one orange, one disabled-looking”). Tokens live in :root of popup.html / options.html / tracker.html (--accent-blue*, --accent-green*, --accent-red*, plus the existing --orange*).
--orange*) = primary/most important action. Classes: .secondary-action-btn (options), .setup-goto (popup), .btn-primary / #generateBtn / .cloud-primary-btn.--accent-blue*) = secondary, parallel-but-valid action. Classes: .accent-btn (options), .setup-goto.accent (popup), .btn-accent (tracker). Used for: Check ATS, See Pro plans, Guide me.--accent-green*) = confirm/apply. Classes: .success-btn (options), .setup-goto.success (popup), .btn-success (tracker). Used for: Apply Refined Resume, Replace Local with Cloud.--accent-red*) = destructive. Classes: .danger-btn (options), .btn-danger (tracker)..ghost-btn (options), .setup-goto.ghost (popup), .btn-ghost / .btn (tracker).key field is intentionally absent. Chrome assigns a fresh extension ID on first load. Do not re-add it (it would lock all contributors to one ID).host_permissions in manifest.json includes the Clerk + Convex domains contributors will need to override. Update both the manifest and this file if you add a new provider.chrome.storage.local each time.content.js. The page’s CSP applies.cloud-sync.js / cloud-sync-core.js (build artifacts), convex/_generated/, or anything from .env.local. See .gitignore.PocketResume is privacy-first by default. See privacy-policy.md for the full policy. The Chrome extension:
chrome.storage.local; cloud sync is opt-in via a free PocketResume account (Pro adds the Job Tracker + Form Filler) and only talks to the user’s own Convex backend