pocket-resume

AGENTS.md

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.

What this repo is

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):

Common commands

Install JS deps

The 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

Build the Pro (Clerk) bundle

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

Update vendored jsPDF

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

Update vendored LDRS loader

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

Run / debug in Chrome

No dev server.

Debugging:

Convex backend (optional)

npx convex dev

Requires CONVEX_DEPLOYMENT in .env.local. Generated code goes to convex/_generated/ (gitignored).

Tests / lint

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.

Versioning & changelog

On every manifest.json version bump, you MUST also update CHANGELOG.md and the popup “What’s New” announcement:

  1. Bump manifest.json → version.
  2. Add a new top entry to CHANGELOG.md (## [X.Y] — YYYY-MM-DD) summarizing the change under Added / Changed / Fixed / Removed. Newest first.
  3. Update the popup “What’s New” modal: ANNOUNCEMENT_VERSION + ANNOUNCEMENT_SEEN_VALUE in popup.js and the modal copy in popup.html (#whatsNewModal). The modal shows once per version.
  4. Build the store zip: 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.

Store build (zip)

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.

High-level architecture

Generation pipeline (main user flow)

  1. User clicks “Generate” in popup (popup.js).
  2. Popup calls chrome.runtime.sendMessage({ type: 'START_GENERATION', payload: { tabId, resumeStyle, resumeId } }).
  3. Background service worker (background.js) handles START_GENERATION:
    • Reads settings from chrome.storage.local (API key per provider, resumes, cover letter toggle).
    • Requests page content from content script via GET_PAGE_CONTENT. Falls back to injecting content.js via chrome.scripting.executeScript(...).
    • Calls the active AI provider:
      • Resume generation as strict JSON text (no markdown fences)
      • If cover letter is enabled, a second call generates the cover letter as strict JSON text
  4. Background replies: { status: 'success', data: <resumeJsonString>, coverLetterData: <coverLetterJsonString|null> } or { status: 'error', message: <string> }.
  5. Popup (success):
    • Strips accidental Markdown fences.
    • JSON.parse(...) the model output.
    • Generates / downloads PDFs via jsPDF:
      • PocketResume layouts (professional / faang): generatePDF(...) in popup.js
      • Alternative layouts (deedy / academic-cv): window.ResumeRenderers.generateResumePDF(...) in resume-renderers.js
    • Cover letter: generateCoverLetterPDF(...) in popup.js
  6. Popup (error): setError() 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).

Onboarding flow

New/unconfigured users get a setup card in the popup plus a spotlight tour on the options page instead of a red error.

  1. Popup load (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.
  2. Each “Do it” button writes 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).
  3. Options page load (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.
  4. 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.
  5. “Next”/”Back” persist the current step; Skip, Escape, or the final “Done” set onboarding: { step: null, dismissed: true }. Clicking Save Settings while the tour is active (tourNotifySaved()) jumps straight to the finish card.
  6. When the config check passes and 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.

Feature tour video (v8.4)

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.

  1. Onboarding completes → “Setup complete” card offers the tour (“Watch the feature tour” / “Not now”). Offer logged as feature_video_offered.
  2. Yes → PocketResumeFeatureVideo.open() (opened via prompt vs header logged as source on feature_video_played); card hides.
  3. “Not now” → sets 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.
  4. 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.
    • Regenerate frames whenever popup/options/tracker visuals change: 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.
    • Storage keys: featureVideoDeclined (video prompt shown once; onboardingCompleted gates the offer card).

Resume refinement flow (v8.2 — question pass)

  1. User clicks “Refine Resume” on the options page (options.js → handleRefineResume).
  2. Options first sends 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).
  3. If questions remain, options shows #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)”.
  4. Options sends 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.
  5. Options shows the side-by-side review panel with an ATS hero (ring-gauge odometer animating the shared 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.
  6. User can Apply (replaces source text; merges the run’s answers into resume.refineAnswers, newest first) or Cancel. Undo restores the last pre-refine backup (refineAnswers untouched).
  7. resume.refineAnswers ([{ question, answer, skipped, updatedAt }], max 10) persists with the other resume fields via Save Settings.

Resume JSON extraction flow

  1. User clicks “Extract JSON” on the options page.
  2. Options sends EXTRACT_RESUME_JSON to background with source text.
  3. Background calls the provider’s *ResumeExtraction(...) function — extracts structured JSON profile from raw text.
  4. JSON is saved as jsonContent on the resume entry and persisted. Used as jsonContent in the generation pipeline.

ATS check flow (v8.2)

Form filler setup (Application Questions, v8.0)

One-time onboarding for Form Filler: the user answers common application-form questions once; fills reuse them.

  1. Questionnaire: Options page → “Form Filler Setup” section (#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).
  2. “Auto-fill from my resume”: options sends 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”.
  3. Persistence: saved as applicationProfile via the global Save Settings button. Completion = non-empty firstName + lastName; the section’s status pill shows “Ready” / “Not set up”.
  4. Entry points: the v8.0 What’s New modal (“Set up Form Filler”) and the popup fill-gating card both set 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.
  5. Fill gating: Form Filler is a Pro feature. Popup Fill first asks background 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.

Form filler flow

  1. User clicks “Fill Form” in the popup (next to the Job Tracker button). Popup first checks Pro access via 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.
  2. Popup sends FILL_APPLICATION_FORM to background with { tabId, resumeId }.
  3. Background resolves the profile (same resolution as 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.
  4. Saved-answers-first resolution: 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.
  5. 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).
  6. Merged answers run through 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.
  7. Background toasts on the page’s main frame ("... (N from saved answers)"), replies { status: 'success', filled, total, cached }, and logs [FormFill] Filled X of Y fields. (Z saved, W AI).
  8. Popup shows “Filled X/Y” on the button; errors go through the standard 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.

Quick Download flow (v9.0)

  1. User clicks #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.
  2. The popup reads { 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).
  3. It calls 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.
  4. Error paths go through 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.
  5. The What’s New modal’s primary button (#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.
  6. Layout note: all four bottom buttons (Settings, Quick Download, Tracker, Fill Form) stay identical at 166×43px — .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.

Resume styles & layout mapping

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 + persistence

Settings are stored in chrome.storage.local, managed in options.js.

Important keys:

Legacy migration: userProfile → resumes[0].content

AI provider support

Five providers supported, selected via apiProvider:

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.

PocketResume Pro (optional feature)

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:

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:

Sign-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.

File layout

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)

Where to make common product changes

Coding conventions

Button color roles

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*).

Common pitfalls

Privacy posture

PocketResume is privacy-first by default. See privacy-policy.md for the full policy. The Chrome extension: