Skip to content

feat(cv): rewrite CV tab as experience feed with cross-platform sync - #198

Merged
guyghost merged 5 commits into
developfrom
guyghost-cv-tab-experience-feed-sync
Jul 9, 2026
Merged

feat(cv): rewrite CV tab as experience feed with cross-platform sync#198
guyghost merged 5 commits into
developfrom
guyghost-cv-tab-experience-feed-sync

Conversation

@guyghost

@guyghost guyghost commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Summary

The CV tab was a read-only canonical-source view that showed a profile preview and a LinkedIn import button. It did not let users edit their experiences, and imported LinkedIn experiences were silently discarded on merge (the UserProfile type had no experiences field at all).

This rewrites the tab as a feed of experiences (same pattern as MissionFeed) with full inline add/edit/delete and a sync button that copies a CV block to the clipboard and opens each connected platform (LinkedIn + 6 freelance connectors) for manual paste. The goal: one local source of truth for the professional journey, pushable everywhere.

CV tab - experience feed with sync

Follows the project's mandatory Model -> Review -> Implement -> Verify workflow. The authoritative state machine lives in src/models/cv-experience-sync.model.md and defines three cooperating machines (Feed, Edit, Sync) with explicit invariants:

  • One edit session at a time (the Add button is disabled while editing).
  • No re-entrancy during save/delete.
  • PROFILE_UPDATED is dropped during in-flight save/delete/sync and during an active edit (external merges do not clobber uncommitted work).
  • Per-platform independent status; one platform failing does not block the others.
  • Sync operates on committed experiences, never on a draft.

Short form: the LLM produces signals; the model decides. No state transition depends on an LLM.

Approach

  • Core (pure, no I/O): new Experience type and experiences[] on UserProfile; pure helpers in core/cv/experience-helpers.ts (normalizeExperience, recomputePositionIndex, mergeExperiences with case-insensitive dedup on (company, title, startDate), formatExperiencePayload, buildPlatformPayloads). mergeCandidateProfileIntoUserProfile now persists experiences instead of discarding them; backup, normalize-profile, defaults, and schemas carry the new field.
  • Shell: cv-experience.facade.ts wires the store deps to getProfile/saveProfile, navigator.clipboard, openExternalUrl, Date.now, crypto.randomUUID.
  • State: cv-experience.svelte.ts is a runes store ($state/$derived) implementing the three machines. A readSyncStatus() helper works around TS control-flow narrowing after await so cancelSync() can mutate status across an await boundary.
  • UI: ExperienceEditForm (molecule), ExperienceCard (molecule, expandable with inline edit), ExperienceFeed (organism, skeleton/empty/error states mirroring MissionFeed), CvSyncPanel (organism, per-platform status list). CvPage.svelte drops from 1022 to ~65 lines.

Surprising / non-obvious bits

  • CandidateExperienceDraft.skills is string[] (not CandidateSkillDraft[]); unionSkills in the merge helper operates on plain strings.
  • The sync flow does one global clipboard probe first; if the clipboard is denied, every platform is marked error and the run aborts before opening any tab. Otherwise platforms run sequentially (one clipboard write per target) so the user can paste in each opened tab.
  • No new bridge messages: experiences persist through the existing SAVE_PROFILE (full UserProfile). CvPage subscribes to PROFILE_UPDATED and forwards to store.applyProfileUpdate().

Verification

  • pnpm format:check
  • pnpm lint
  • pnpm typecheck
  • pnpm test (1744 tests, including 22 new unit tests for the pure helpers)
  • pnpm build
  • pnpm ci:check (green)
  • Visual check in dev server: feed renders with mock data, add/edit/delete works, sync marks all 7 platforms "Synchronise", edit invariant (Add disabled while editing) holds.

Checklist

  • No secrets, cookies, session tokens, or generated release artifacts committed
  • Core code remains pure; I/O stays in shell modules
  • Svelte changes use Svelte 5 runes only
  • Documentation updated when behavior or setup changes (model file + AGENTS.md-aligned structure)

Replace the old canonical-source/LinkedIn-preview CV page with a feed of
experiences that supports inline add/edit/delete and a sync button that
copies a CV block to the clipboard and opens each connected platform
(LinkedIn + 6 freelance connectors) for manual paste.

Follows the project Model -> Review -> Implement -> Verify workflow. The
authoritative state machine lives in src/models/cv-experience-sync.model.md
and defines three cooperating machines (Feed, Edit, Sync) with explicit
invariants: one edit session at a time, no re-entrancy during
save/delete, PROFILE_UPDATED dropped during in-flight sync/save/edit,
per-platform independent status, and sync operates on committed data.

Core (pure):
- Add Experience type and experiences[] on UserProfile
- Add pure helpers in core/cv/experience-helpers.ts (normalize, recompute
  position index, merge with dedup, format payload, build platform map)
- Persist experiences through mergeCandidateProfileIntoUserProfile (was
  discarded before) and through backup/normalize/defaults/schemas

Shell:
- Add cv-experience.facade.ts wiring store deps to profile + clipboard +
  external URL opener + Date.now/crypto.randomUUID

State:
- Add cv-experience.svelte.ts runes store implementing the three machines

UI:
- ExperienceEditForm, ExperienceCard, ExperienceFeed, CvSyncPanel
- Rewrite CvPage.svelte (1022 -> 65 lines) to header + sync panel + feed

Tests:
- 22 unit tests for pure helpers (no mocks)
- Update existing fixtures to include experiences: []
- Rewrite CvPage.test.ts for the new sync flow; delete obsolete
  CvPageSync.test.ts (old LinkedIn preview/import flow)
- Update operational-ui-constraints.test.ts for new CvPage wiring

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 9, 2026 12:21
@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pulse Ready Ready Preview, Comment Jul 9, 2026 1:53pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
pulse-dashboard Skipped Skipped Jul 9, 2026 1:53pm

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR rewrites the extension’s CV tab from a read-only “profile preview/import” view into an editable experience feed with a cross-platform sync action, backed by a new Experience domain type persisted on UserProfile.experiences and a Svelte 5 runes store that models feed/edit/sync state machines.

Changes:

  • Introduces Experience + UserProfile.experiences, updates schemas/defaults/backup/normalization, and adds pure core helpers for normalize/merge/indexing + payload formatting.
  • Adds a new CV experience store (cv-experience.svelte.ts) plus UI components (ExperienceFeed, ExperienceCard, ExperienceEditForm, CvSyncPanel) and simplifies CvPage.svelte to delegate behavior to the store.
  • Updates and adds unit tests for helper logic, UI operational constraints, and storage/profile normalization; removes the legacy CvPageSync regression test tied to the old LinkedIn-sync UI.

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
apps/extension/tests/unit/ui/operational-ui-constraints.test.ts Updates UI constraint assertions to match delegated CV loading/empty/error behavior via ExperienceFeed.
apps/extension/tests/unit/ui/CvPageSync.test.ts Removes legacy LinkedIn-sync-failure toast regression test (obsolete with new CV flow).
apps/extension/tests/unit/ui/CvPage.test.ts Updates clipboard-failure regression to assert sync-panel error/failed platform statuses instead of toasts.
apps/extension/tests/unit/storage/profile-db.test.ts Extends persisted profile fixture with experiences: [].
apps/extension/tests/unit/storage/connected-profile-cache.test.ts Extends connected-profile cache fixture with experiences: [].
apps/extension/tests/unit/profile/normalize-profile.test.ts Extends normalize-profile expectations to include experiences: [].
apps/extension/tests/unit/profile-extractors/merge-candidate-profile.test.ts Extends merge-candidate-profile fixtures to include experiences: [].
apps/extension/tests/unit/cv/experience-helpers.test.ts Adds unit test coverage for new pure experience helper functions.
apps/extension/src/ui/pages/CvPage.svelte Replaces prior 1k-line CV workflow UI with a store-driven page composing CvSyncPanel + ExperienceFeed.
apps/extension/src/ui/organisms/ExperienceFeed.svelte New experience feed organism: header + add button + loading/empty/error states + cards list.
apps/extension/src/ui/organisms/CvSyncPanel.svelte New sync panel organism: sync/cancel actions + per-platform statuses + headline results.
apps/extension/src/ui/molecules/ExperienceEditForm.svelte New inline add/edit form molecule for an experience draft.
apps/extension/src/ui/molecules/ExperienceCard.svelte New experience card molecule with expandable details and inline edit mode.
apps/extension/src/models/cv-experience-sync.model.md Adds authoritative model doc for the CV feed/edit/sync machines and invariants.
apps/extension/src/lib/state/cv-experience.svelte.ts New Svelte 5 runes store implementing feed/edit/sync state machines and orchestrating side effects via deps.
apps/extension/src/lib/shell/facades/cv-experience.facade.ts New shell facade wiring profile bridge + clipboard + tab opening into store deps, and building sync targets.
apps/extension/src/lib/core/types/schemas.ts Extends UserProfileSchema with experiences schema + defaults.
apps/extension/src/lib/core/types/profile.ts Adds Experience / ExperienceSource and extends UserProfile with optional experiences.
apps/extension/src/lib/core/profile/normalize-profile.ts Adds experiences propagation in profile defaults.
apps/extension/src/lib/core/profile/defaults.ts Adds experiences to default profile and default-profile checks.
apps/extension/src/lib/core/profile-extractors/merge-candidate-profile.ts Persists experiences when merging candidate drafts, via mergeExperiences(...) and injected now.
apps/extension/src/lib/core/cv/experience-helpers.ts New pure helper module: normalize, recompute index, merge, format payloads, and build platform payload map.
apps/extension/src/lib/core/backup/backup.ts Extends backup schema and normalizes profile to ensure experiences is always present.
apps/extension/src/dev/qa-seed.ts Adds seeded experiences to QA profile fixture.
apps/extension/src/dev/mocks.ts Adds mock experiences for dev-mode UI.

Comment thread apps/extension/src/lib/state/cv-experience.svelte.ts
Comment thread apps/extension/src/lib/state/cv-experience.svelte.ts Outdated
Comment thread apps/extension/src/ui/organisms/ExperienceFeed.svelte Outdated
Comment thread apps/extension/src/ui/organisms/ExperienceFeed.svelte
Comment thread apps/extension/src/models/cv-experience-sync.model.md Outdated
Comment thread apps/extension/src/ui/molecules/ExperienceCard.svelte Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 216296d405

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/extension/src/lib/core/profile-extractors/merge-candidate-profile.ts Outdated
Comment thread apps/extension/src/lib/core/cv/experience-helpers.ts Outdated
Comment thread apps/extension/src/lib/core/cv/experience-helpers.ts Outdated
Comment thread apps/extension/src/lib/state/cv-experience.svelte.ts
Comment thread apps/extension/src/ui/organisms/ExperienceFeed.svelte
Comment thread apps/extension/src/ui/organisms/ExperienceFeed.svelte Outdated
Comment thread apps/extension/src/lib/core/profile/normalize-profile.ts
Comment thread apps/extension/src/lib/shell/facades/cv-experience.facade.ts
Comment thread apps/extension/src/lib/state/cv-experience.svelte.ts
Comment thread apps/extension/src/ui/molecules/ExperienceEditForm.svelte Outdated
- Split shared store `error` into `feedError`/`editError`/`syncError` so a
  failure in one machine can no longer overwrite another's terminal error copy
- Honor `cancelSync()` during the `preparing` clipboard probe in `startSync()`
  by re-checking `readSyncStatus()` before entering the platform loop
- Make `now` required in `mergeCandidateProfileIntoUserProfile` (was defaulting
  to 0, causing colliding IDs and `updatedAt: 0`); inject `Date.now()` at both
  shell callers
- Tighten `onSave` prop type from `ExperienceFormData` to `Experience` and drop
  the unsafe `as Experience` cast in `ExperienceFeed.handleSave`
- Add `aria-busy`/`role="status"`/`aria-live="polite"` to the experience feed
  loading skeleton
- Fix typo `runnes` -> `runes` in the cv-experience-sync model

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…perience-feed-sync

# Conflicts:
#	apps/extension/src/dev/mocks.ts
#	apps/extension/src/dev/qa-seed.ts
#	apps/extension/src/lib/core/backup/backup.ts
#	apps/extension/src/lib/core/profile-extractors/merge-candidate-profile.ts
#	apps/extension/src/lib/core/profile/defaults.ts
#	apps/extension/src/lib/core/profile/normalize-profile.ts
#	apps/extension/src/lib/core/types/profile.ts
#	apps/extension/src/lib/core/types/schemas.ts
#	apps/extension/src/ui/pages/CvPage.svelte
#	apps/extension/tests/unit/profile-extractors/merge-candidate-profile.test.ts
#	apps/extension/tests/unit/profile/normalize-profile.test.ts
#	apps/extension/tests/unit/storage/profile-db.test.ts
#	apps/extension/tests/unit/ui/CvPageSync.test.ts
- Normalize imported LinkedIn dates (YYYY-MM-DD) to canonical YYYY-MM in
  mergeExperiences via new normalizeDateToMonth pure helper.
- Clear endDate when a merged entry becomes current (checks draft.isCurrent,
  not just existing.isCurrent).
- Preserve experiences through normalizeProfileDraft (ProfileDraftInput now
  accepts experiences; settings-page passes current?.experiences).
- Raise SAVE_PROFILE payload cap from 10KB to 80KB so full experiences array
  fits (matches PlatformProfileDraftSchema cap).
- Reset sync state (syncStatus, platformStatuses, lastSyncedAt, syncError) in
  applyProfileUpdate so external merges don't leave stale sync state.
- Remove redundant per-platform clipboard copy in sync loop (payload already
  on clipboard from the probe; re-copy fails once a tab steals focus).
- Keep failed add/edit drafts visible (isAdding/isEditing include error state
  with matching editingId).
- Surface edit errors in non-empty feed via alert banner.
- Restore LinkedIn import button on CvPage (preview + sync facades).
- Allow null endDate for past roles in ExperienceEditForm (end date optional).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The CV page was completely redesigned in PR #198:
- Changed heading from 'Préparer le même profil partout' to 'CV & expériences'
- Replaced preview-based LinkedIn flow with direct import + toast notifications
- Simplified UI: 'Prévisualiser LinkedIn' → 'Importer LinkedIn' button

Test changes:
1. Updated openCvPage() helper to expect new heading
2. Rewrote LinkedIn import tests to match new direct import flow
3. Fixed Settings profile tab test to use exact: true for 'Profil' heading
   (avoids matching other headings containing 'profil' substring)

Updated bridge mock to use IMPORT_LINKEDIN_PROFILE message type.

All 4 previously failing tests now pass locally.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@vercel
vercel Bot temporarily deployed to Preview – pulse-dashboard July 9, 2026 13:53 Inactive
@guyghost
guyghost merged commit 11b5104 into develop Jul 9, 2026
10 checks passed
guyghost added a commit that referenced this pull request Jul 12, 2026
…220)

* chore: update agent instructions

* fix(perf-report): use readiness metric for extension hard-load

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* docs(product): add canonical PRODUCT.md and impeccable critique snapshot

Establishes the root PRODUCT.md as the single source of truth for product
strategy (register, users, purpose, brand personality, anti-references,
design principles, accessibility targets) and persists the inaugural
impeccable critique snapshot for the extension (24/40, 2xP0, 2xP1) so
downstream /impeccable commands (polish, audit) can read the backlog.

- PRODUCT.md: calm/precise/trustworthy brand, WCAG 2.1 AA targets
- .impeccable/critique/2026-07-01T12-41-10Z__apps-extension.md: baseline
  heuristic scorecard + priority issues (contrast, FilterBar IA)
- apps/extension/.impeccable/live/config.json: live-mode injection config

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(feed): auto-collapse advanced panel on recovery; keep presets on default toolbar

Address review feedback on PR #162 about the showAdvancedControls toggle:

1. Auto-collapse when connectors recover: the auto-expand $effect only
   ever set showAdvancedControls=true, so once a connector broke and the
   panel opened, it never collapsed again once the connector recovered.
   Track whether the expansion was user-initiated
   (advancedControlsUserOpened) and auto-collapse only the auto-expanded
   state when brokenConnectors returns to 0; a user-opened panel is left
   untouched so manual toggling still wins.

2. Keep Presets metier on the default toolbar: those buttons call
   page.applyDecisionPreset(...) and are feed-filter decision shortcuts,
   not operational diagnostics. They were hidden behind the advanced
   toggle, regressing the always-visible filter flow. Remove the
   showAdvancedControls guard so presets stay discoverable by default.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* feat(scoring): add feed ranking algorithm with freshness, relevance, and source diversity (#58)

Pure Core ranking function that replaces single-key score sorting with a
composite of relevance + freshness, plus round-robin source interleaving
so multi-connector scans produce a curated feed instead of scrape-order noise.

- freshnessScore: linear decay from publishedAt (100 today → 0 at 14 days)
- missionRankScore: weighted relevance (0.75) + freshness (0.25)
- rankMissions: composite sort + source diversity interleaving
- Wired into feed-page: 'score' sort now uses composite ranking
- 27 unit tests; all 1616 tests pass

* feat(notifications): add daily top-3 digest alarm (#114)

Pushes the top unseen high-score missions once per day at 9 AM local time,
independent of scan activity. Surfaces value to users who never open the panel.

- sendDailyDigest: loads stored missions, filters to top 3 unseen, notifies
- scheduleDailyDigestAlarm: daily alarm aligned to 9 AM local time
- Reuses filterSmartNotifications (pure Core) for mission selection
- Notified missions marked as seen to avoid re-delivery
- Wired into background alarm lifecycle alongside auto-scan
- Click handler extended to open side panel from digest notification
- 7 unit tests for nextDigestTime; all 1623 tests pass

* docs(specs): add MVP feed interaction specification (#59)

Formalizes the 5 core feed interactions (see, favorite, filter, sort, search)
with testable acceptance criteria grounded in the actual implementation.

Each user story maps to its state module and pure Core functions, with
performance targets (< 100ms for filter/sort/search over ≤500 missions),
persistence guarantees, and edge-case documentation.

* feat(connectors): add Malt connector — 6th platform via JSON search API (#73)

Malt is the largest French freelance marketplace. As a SPA, it exposes mission
data via a JSON API rather than server-rendered HTML — following the Hiway
connector pattern.

- malt-parser.ts: pure Core parser with field-extraction helpers and generous
  fallbacks for API shape variations (title/name, company/client, skills as
  objects or strings, dailyRate/averageDailyRate/budget)
- malt.connector.ts: extends BaseConnector, fetches from Malt's search API,
  handles both flat-array and wrapped { results: [...] } response shapes
- Wired into all 11 registration points: MissionSource, ConnectorId, meta,
  registry, manifest host_permissions, settings defaults, Zod schemas,
  dedup priority, dev stubs, QA seed
- 52 unit tests; all 1675 tests pass

Note: the exact API endpoint needs live verification against malt.fr network
requests — it is clearly marked in the connector for easy updating.

* fix(manifest): add malt.io host_permission with coverage tests

Malt operates across both malt.fr and malt.io domains. Adds the missing
malt.io host_permission so cookie and fetch requests to that domain don't
silently fail in production.

Tests verify:
- malt.fr and malt.io are both present in host_permissions
- every registered connector has a matching host_permission entry
  (guards against forgetting permissions when adding connectors)

* docs(critique): add impeccable critique snapshots for extension, landing, dashboard

Capture baseline UI critique (Nielsen heuristic + cognitive-load) for the
three MissionPulse surfaces, produced by the impeccable skill. These
snapshots are the source backlogs for the follow-up UI full-pass PRs
(#178 extension, #179 landing, #180 dashboard) and sit alongside the
existing extension critique snapshot.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(scoring): honor freshnessDecayDays and precompute rank scores

Thread the freshnessDecayDays option through missionRankScore into
freshnessScore so the configured decay window is actually applied.
Replace the O(n log n) sort recomputation with a Schwartzian transform
that scores each mission exactly once.

Addresses PR #182 review thread 3524638927.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(feed): respect user's advanced-controls toggle during broken connectors

Add an advancedControlsUserInteracted flag so the broken-connector
auto-expand effect no longer overrides a user who has collapsed to
'Vue simple'. Once the user touches the toggle, their choice is final.

Addresses PR #182 review thread 3524638939.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(malt): correct detectSession false-positive and harden parser

detectSession now returns ok(false) when the cookie API throws,
matching the LeHibou/Collective pattern so the UI never shows a
false 'connected' state. fetchMissions still works via the public
search API regardless.

parseMaltJSON wraps parseMaltProjectRow in try/catch so one malformed
row is filtered out instead of aborting the entire batch.

Addresses PR #182 review threads 3524638948 and 3524645810.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(notifications): skip daily digest during mute window

Export isMutedUntilActive from notify-missions and add the same mute-
window guard used by scan-trigger notifications to sendDailyDigest,
so a digest never fires while notifications are muted-until-active.

Addresses PR #182 review thread 3524645808.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(feed): include malt source in bridge normalizer and default connectors

Add 'malt' to the missionSources allowlist in feed-controller so Malt
missions survive bridge normalization and reach the feed. Align the
DEFAULT_SETTINGS.enabledConnectors default which was the only source-
of-truth still missing Malt.

Addresses PR #182 review thread 3524645806.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* test(manifest): derive connector domains from registry

Replace the hardcoded connector-domain list with a derivation from
getConnectorsMeta() so the test stays in sync when connectors are
added or removed, matching the original 'every registered connector'
intent of the comment.

Addresses PR #182 review thread 3524638956.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* docs: reflect six platforms including Malt

Update PRODUCT.md and the feed-interaction spec to reference six
platforms/job boards and list Malt explicitly, so docs match the
connector registry.

Addresses PR #182 review threads 3524638962, 3524638965, 3524638975.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(e2e): use case-insensitive regex for Favoris button test

The Favoris button has aria-label="Filtrer les favoris" (lowercase 'f'), but
the test was looking for /Favoris/ (case-sensitive, uppercase 'F'). Since
Playwright's getByRole uses the aria-label when present, the test failed.

Make the regex case-insensitive to match both the aria-label and visible text.

This is a pre-existing issue on develop (confirmed by PR #179 landing-only
also failing the same test), but fixing it here to keep main green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Landing UI full pass (critique P0–P3) (#179)

* fix(landing): make differentiator the hero

Replace generic 'Les bonnes missions freelance' with the real
differentiator (5 plateformes / 1 feed scoré / Zéro doublon) as the
H1. Collapse the 3-label badge to one. Tighten the hero clamp to
<=5.5rem so the 3-line title never overflows on tablet/mobile, and
weave an honest outcome line (42 missions, 8 à contacter) into the
subhead instead of a buried hero metric.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(landing): consolidate 14 sections to 7

Layout density pass (P1 impeccable layout). The page exhausted users
with 14 sections of near-identical card grids before conversion.

Consolidated IA to 7 sections, varying density per surface:
  Hero → Showcase → Daily Radar → Features → How it works
  → Plans → Platforms → CTA

Structural changes:
- Delete experiment-loop section (also serves P2 harden: removes the
  beta roadmap that undermined trust on a marketing page).
- Delete product-map (3-card surfaces explainer) and proof-strip
  (4-card proof grid) — the 3-surfaces idea is now carried by the
  Showcase caption.
- Delete for-who persona grid — the audience is already named in the
  hero meta line.
- Delete standalone tech-stack section; merge GitHub link + compact
  badge row into the footer (.footer__stack).
- Features: 6 identical glass-cards → dense capability matrix
  (.feature-matrix <ul> with Gratuit/Premium tier badges). Breaks the
  card-grid sameness called out in P3.
- Platforms: 5 identical glass-cards → logo strip (.platform-strip
  <ul>).
- How-it-works: move BEFORE Plans (fixes dependency inversion flagged
  in critique) and change container to <ol> for proper ordered-list
  semantics.
- Nav menus (desktop + mobile): drop #for-who and #platforms anchors,
  reorder to match new page flow.

CSS: removed all orphan blocks for deleted sections; added
.feature-matrix and .platform-strip systems; reset .steps for <ol>.

Net: +page.svelte 1318 → 921 lines; app.css 2397 → 2009 lines.

Pure visual/structural change. No behavior, no state, no LLM.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(landing): rewrite pricing in plain benefit language

Copy clarify pass (P1 impeccable clarify). Plans + credits strip were
written in product-team jargon that a first-time visitor cannot price.

Rewrites, in plain benefit language:
- "snapshots normalisés" → "synchronise votre shortlist et vos
  candidatures entre vos appareils" (the user cares about the outcome,
  not the serialization format).
- "20 crédits IA par mois" (no unit) → "20 contenus générés par mois
  (pitch, message recruteur ou résumé CV). 1 crédit = 1 contenu." The
  credit now has a concrete unit and a list of what it produces.
- Gemini Nano defined inline on its user-facing first mention:
  "Scoring sémantique local via l'IA intégrée à Chrome (Gemini Nano),
  quand Chrome le permet."
- Cost anchor under the Premium price: "≈ 0,40€/jour — moins qu'un
  café par semaine." Recasts 12€/mois into a daily, emotional anchor
  so the price reads as cheap without a fake counter.

"TJM inversion" (the specific phrase the critique flagged) was already
removed with the product-map section in the prior commit; "TJM" itself
is universal French freelance jargon and stays.

New .plan-card__anchor style (blueprint accent, 13px, 4.80:1 contrast).

Copy + one small style only. No behavior, no state.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(landing): kill uppercase eyebrow; lift muted-text contrast

Quieter pass (P2 impeccable quieter) + a contrast defect the audit
gate requires before shipping.

Eyebrow cadence (the 2026 slop tell):
- .daily-radar__eyebrow was the last remaining tiny uppercase tracked
  kicker (text-transform: uppercase; letter-spacing: 0.12em; 12px).
  Replaced with the brand's color + weight system: blueprint-blue,
  500 weight, 14px, normal tracking, no transform. (All other
  *__eyebrow classes were already plain or died with the deleted
  sections in the layout commit.)

Muted-text contrast (verification gate: body text ≥4.5:1):
- --color-text-muted #a6a09b measured 2.37:1 on the cream canvas —
  the classic "muted gray for elegance" failure the brand register
  flags. Darkened to #6b6660 (5.21:1 on cream), still a clear step
  above text-subtle.
- --color-text-muted-dark #78716c measured 4.12:1 on the dark canvas;
  lightened to #8a857f (5.40:1) so footer copy / meta pass in dark
  mode too.

Verified in-browser: footer copy now 5.21:1 (light) / 5.40:1 (dark);
eyebrow renders 14px blueprint, no transform.

Pure visual (CSS tokens + one rule). No behavior, no state.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(landing): reframe trust with deterministic-scoring proof line

Harden pass (P2 impeccable harden). The "Experiment Loop" / beta roadmap
section that undermined trust was already deleted in the layout commit.
This commit replaces what it tried to signal with concrete,
verifiable confidence-building copy.

Added a quiet proof line under the install CTA, anchored on the brand's
"fiable" trait and on a claim the architecture actually backs:

  "Le même classement à chaque scan — scoring déterministe, aucune
   opacité, aucun tirage aléatoire. Vos 5 plateformes, scannées depuis
   vos sessions existantes."

Why this claim is honest: the scoring core (apps/extension/src/lib/
core/scoring/relevance.ts) is a pure function with zero RNG — same
inputs → same rank, every scan. The marketing surface now states what
the code already guarantees, instead of beta hedging.

New .cta__proof style: muted body text + small blueprint status dot
(stays on-palette; the brand palette has no green token, so the dot
reuses --color-blueprint-blue).

Pure copy + one small style. No behavior, no state, no LLM.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* feat(landing): add keyboard navigation for power users

Overdrive pass (P2 impeccable overdrive). The target user is a freelance
tech dev — a power user who lives in the keyboard. The landing had zero
keyboard affordances beyond Tab. This commit adds the three pieces the
critique asked for, as pure navigation aid (no application state).

1. Skip-to-content link
   First focusable element in the DOM. Visually hidden (top: -100%)
   until a keyboard user Tabs into the page, then it reveals at top-left
   and jumps straight to #hero. Standard a11y pattern.

2. Single-key shortcuts (single keydown listener on window)
   - `/`  focus + center the install CTA (primary conversion action)
   - `s`  jump to #workflow (product showcase)
   - `p`  jump to #plans (pricing)
   - `?`  toggle the shortcuts help overlay
   - `Escape` closes the overlay or the mobile menu

   Guards:
   - Ignored when focus is in an input, textarea, select, or
     contentEditable (isTypingTarget) so users can type these chars.
   - Ignored when metaKey / ctrlKey / altKey are held (don't clobber OS
     and browser shortcuts).
   - scrollIntoView respects prefers-reduced-motion (instant vs smooth).

3. Shortcuts help overlay
   A lightweight <dialog>-style modal (role="dialog", aria-modal,
   aria-label) listing every shortcut. Toggled by `?`, closed by Esc or
   the close button. Surfaced discreetly via a one-line kbd hint in the
   footer (hidden below 640px to avoid mobile clutter).

Verified in-browser with real keypresses:
- `p` (outside input) → scrollY 0 → 5088, #plans at viewport top:96px ✓
- `/` (outside input) → focus lands on #install [data-primary-cta] ✓
- `?` → overlay.is-open = true ✓
- Escape → overlay.is-open = false ✓
- `p` typed inside a focused input → char typed into field, no nav ✓
- Skip link is the first focusable element in DOM order ✓
- Footer kbd hint display:none at 375px viewport ✓

No state machine, no LLM, no data fetching — this is navigation only.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(landing): vary density in how-it-works to break card-grid sameness

Final layout pass (P3 impeccable layout). The critique flagged
"identical card grids — same-sized cards with icon + heading + text,
repeated endlessly." The earlier layout commit already converted
Platforms → logo strip and Features → feature|tier table, but the
how-it-works steps were still 4 identical filled cards.

This commit re-weights the steps as a lighter editorial block, so the
page breathes between two denser moments (the feature matrix above,
the pricing cards below):

- Remove the card fill + border + radius from .step; replace with a
  single top hairline (border-top: 1px var(--color-border-light)).
  The result reads as a list of rows, not a grid of cards.
- Shrink the number badge from a 48px filled blueprint circle to a
  40px outlined blueprint circle (1.5px border, transparent fill,
  blueprint-blue text) — matches the editorial weight and keeps the
  brand on-palette.
- Drop the 768px padding bump so the row rhythm stays tight on
  desktop; widen the column gap instead (16/40) for breathing room.

Density scale now reads: Showcase (densest, filled shell) → Features
(table) → How-it-works (lightest, hairline rows) → Plans (two solid
cards) → Platforms (logo strip). No three adjacent sections share the
same treatment.

Pure CSS. No markup, state, or behavior change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* test(landing): track distilled hero, plain pricing, CTA proof copy

Update string-assertion guards to match the intentional copy rewritten
in the UI quality pass (hero distill, pricing clarify, proof-strip ->
CTA consolidation, snapshots-jargon removal). Each test's intent is
preserved; asserted strings now track the shipped copy.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(landing): a11y — focus trap, aria-modal, z-index, spacing tokens

Address PR #179 review feedback on the keyboard-shortcuts overlay:

- Focus management: openShortcuts() saves document.activeElement, awaits
  tick(), then moves focus into .shortcuts-card. closeShortcuts() restores
  focus to the saved element. Fixes the 'focus never enters the dialog'
  gap (copilot-pull-request-reviewer, chatgpt-codex-connector).
- Shortcut guard: while the overlay is open, only Escape and ? close it
  and Tab is trapped; all other global shortcuts (/, s, p) are blocked.
- aria-modal is now bound to shortcutsOpen state (was hardcoded 'true').
- Removed role='document' from the card (inherits dialog semantics).
- Added trapTab() to cycle Tab/Shift+Tab within the overlay.
- CSS: .shortcuts-overlay visibility shows instantly on open (0s delay)
  and hides after the opacity fade on close (0.15s delay) so the card is
  focusable the moment it opens.
- CSS: skip-link z-index 1000 -> 1100 (above the fixed nav at 1000) so
  it can never be covered when focused.
- CSS: shortcuts-overlay z-index 1000 -> 1200 (above skip-link and nav).
- CSS: declared --spacing-28 and --spacing-56 in :root (were undefined);
  mapped off-grid --spacing-6 to --spacing-8 in two rules.

Verified in Chrome DevTools: focus moves into the card on open, restores
to the CTA on close, Tab/Shift+Tab wrap inside the dialog, and s/p//
are inert while open. Build green, 25/25 tests pass.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Dashboard UI full pass: feed-first, hardened metrics, batch selection (#180)

* feat(dash): add dashboard surface, metrics, batch-selection models

Pure models under apps/dashboard/src/models/ — the source of truth consumed
by UI + shell. FC&IS: zero I/O, zero async, fully testable without mocks.

- dashboard-surface.model.ts (M1): deriveDashboardPhase decides what renders
  per lifecycle phase (unconfigured/onboarding/live_empty/live_populated);
  feed is primary in live phases, metrics only in live_populated.
- metrics-visibility.model.ts (M2): empty metrics are omitted, never shown
  as 0/N/A/Aucun; hidden/partial/ready phases.
- batch-selection.machine.ts (M3): pure transition() table for multi-select
  + bulk actions (idle/selecting/applying/done/error) with guards + invariants.
  Typed reducer instead of xstate to match the dashboard pure-core pattern.

38 unit tests cover authorized + rejected transitions and all invariants.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* refactor(dash): surface mission feed above setup chrome (distill)

The mission feed - the product's reason to exist - was buried under
~900px of setup chrome and capped at slice(0,6). Adopt the M1 dashboard
phase model as the source of truth for what renders and in what order:

- Gate the feed with {dashboardPhase === 'onboarding' || feedIsPrimary}
  so it renders first in onboarding (empty-state CTA) and live phases.
- Replace slice(0,6) with progressive disclosure (FEED_PAGE_SIZE=12 +
  'Afficher plus de missions'), so a populated feed stays scannable.
- Remove the 'Surfaces activées après setup' preview grid and its
  DashboardSetupPreviewItem interface/derived - setup is now a slim
  checklist below the feed, not 400 lines of chrome above it.
- Collapse the 3-tab anchor nav that sat between the user and the feed.

operational-story.test.ts: rewrite the setup-preview + nav-vocab tests
to pin the new M1 feed-primary model (deriveDashboardPhase, feedIsPrimary,
visibleMissionFeed, no slice, no preview). The ordering + readiness +
sync-conflict tests are unchanged and still pass.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(dash): hide empty hero metrics until data exists (harden)

M2 (metrics-visibility model) now gates the hero metrics region. When
deriveMetricsVisibility() returns phase 'hidden' (0 applications, 0
interviews, no follow-up, no score), the 4-card grid is replaced by a
single honest line: 'Aucune candidature suivie. Le feed est votre point
de départ.' In 'partial' phase only cards with real data render, so no
giant '0 / N/A / Aucun' ever ships on first run. Vacuous badge variants
removed — a card with no data simply is not there.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* feat(dash): add batch selection + bulk archive/select (optimize)

Acting on N missions no longer costs N clicks. The feed gains a
"Sélectionner" mode toggle: per-card checkboxes, a sticky bulk-action bar
with select-all-visible / clear / Archiver (n) / Sélectionner (n), and
inline success/error feedback via use:enhance (no full reload).

State decision is owned by the M3 model (batch-selection.machine.ts):
the new BatchSelectionStore ($lib/state/batch-selection.svelte.ts) is a
thin Svelte 5 runes wrapper that only dispatches events to the pure
transition() table. The LLM never decides a transition; the model does.
Selection persists across applying → error so users can retry.

Server (+page.server.ts): extracts applyMissionStageTransition() shared
helper from the ~200-line selectMission/archiveMission bodies, plus
resolveAuthenticatedDashboardRequest() and readBulkMissionIds()
(capped at 50). Adds bulkSelectMissions + bulkArchiveMissions actions
reusing the same auth + write path per id, returning an
{applied,skipped,total} summary.

Bulk scope is archive + select only — favorite/mark-seen have no
dashboard server action and are out of scope (flagged in the PR).

Tests: 6-test contract suite pins the store delegation; existing
pipeline-event-order test updated for the consolidated transition path.
120 tests green, build green.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* refactor(dash): quiet eyebrows, badges, and surface sameness (quieter)

The uppercase tracked eyebrow above every section is the saturated AI
scaffold — the dashboard had 12 of them. Removes 11 decorative kickers
(the 12th, "État opérationnel", rides along with the P3 story rework)
and lets section headings stand on their own. The pinned "Résultats
débloqués" string is folded into the milestones supporting line so the
operational-story test still passes without the eyebrow tell.

Also flattens the milestones container: the bordered section wrapped
four bordered milestone articles (card-in-card, an impeccable ban).
The container is now a plain region; the grid items keep their
state-tinted treatment and stand as legitimate parallel cards.

Trims one redundant Badge ("Synchronisé / En attente extension") that
duplicated the live count line beside it. Badge count 45 → 44, eyebrow
count 12 → 1.

120 tests green, build green.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* refactor(dash): replace operational story with concrete status banner (clarify)

The "État opérationnel" block was always-on corporate storytelling: a
tone-tinted card with an eyebrow, a Badge, an "Impact / Action
recommandée" two-column grid, a signals chip row, and a generic "Aller
à l'action" button — rendered even when the cockpit was perfectly fine.

Replaces it with a compact status banner that renders ONLY when the
decision function returns attention or incident (sync conflict, relance,
CV precondition). One concrete headline with verifiable counts
("3 conflits de sync à arbitrer · 1 erreur"), one supporting sentence,
one short verb-phrase action. Success tones paint nothing — the feed is
the primary surface, the story isn't.

The decision function (getDashboardOperationalStory) stays as the pure
model; copy is tightened to counts/dates and the dead signals/badge
fields drop from the interface. Incident uses a subtle red tint;
attention uses a plain surface so the two read distinctly.

operational-story.test.ts rewritten to pin the new banner model
(gated render, concrete copy, absent decorative chrome, ordering).

120 tests green, build green.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(dash): satisfy svelte-check (TDZ order, route SubmitFunction, model field)

The pre-push typecheck gate ( stricter than vite build ) flagged three
latent issues in the batch/metrics wiring introduced earlier in this pass:

- `$derived` TDZ: `metricsVisibility` (used averageScore/nextFollowUp) and
  the progressive-disclosure feed cluster (used filteredMissionFeed) were
  declared above their dependencies. Relocated each block to sit after the
  values it reads, with a comment marking the original spot.
- `SubmitFunction` is route-typed (./$types), not exported from $app/forms.
  Fixed import; the typed callback now infers `result`/`update` (no `any`).
- M1 call site passed `missionFeedCount` but the model field is
  `missionFeedLength`; aligned the call site with the model.

svelte-check: 0 errors, 0 warnings. Build 4/4, tests 6/6.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(dash): distinguish bulk applied from no-ops + real error retry

Address review threads on bulk actions and the batch-selection model:

1. MissionTransitionOutcome now carries `changed: boolean` on the success
   variant. Bulk archive/select loops count only `changed` outcomes as
   `applied`; no-ops (mission already tracked) count as `skipped`, so the
   summary never claims a transition that didn't happen.

2. The batch-selection model accepts APPLY_BULK from both `selecting` and
   `error`, making the documented "selection persists for retry"
   invariant real. A "Réessayer" control in the error branch resubmits
   the failed action end-to-end (error → applying → done/error).

Machine tests pin both the retry transition and the empty-selection guard.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(dash): report bulk truncation + lock per-card actions during apply

Address Codex review threads on the bulk workflow:

1. Truncation was silent. readBulkMissionIds now returns the pre-cap
   requestedCount; both bulk actions report `total: requestedCount` plus a
   `truncated` delta. BulkSummary gains `truncatedCount`, surfaced in the
   feedback label ("N au-delà du plafond"), so a 75-item archive over the
   50-mission cap no longer shows as a clean 50/50 success.

2. Per-card archive/select forms rendered during applying/done/error
   (isSelecting false but isLocked true), allowing competing transitions
   against the same cards. The card action branch is now gated on
   !isLocked, closing the race window.

Source-inspection tests pin both the helper contract and the per-action
total/truncated/changed invariants.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* ext: UI quality pass (side panel) — score hierarchy, chrome layout, undo window, cadence, kbd discoverability (#178)

* fix(ext): restore MissionCard score/chevron hierarchy

The relevance score badge and expand chevron had near-identical visual
weight (both ~h-7 / 11px), flattening hierarchy on the primary signal.

Promote the score (dominant): text-[13px], font-bold, tabular-nums,
min-w so single-digit grades stay legible, centered pill.
Demote the chevron (secondary affordance): h-6 w-6 container, 12px icon,
rounded-md, muted color with hover-only emphasis.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* feat(ext): soft-delete undo window for hide & saved-view (model + reducer)

Replaces the optimistic hard-delete + restore-on-click pattern in
handleHide / deleteSavedView with a true soft-delete undo window:
the destructive change is applied in-memory only, and persistence is
deferred to the commit step (timer expiry). If the panel closes mid-
window, nothing is committed and storage still holds the pre-action
state — the mission/view reappears on next load (safe-by-default).

- Model (source of truth): src/models/undo-window.model.md
  states: idle | pending-undo | committed; events: REQUEST/UNDO/TIMEOUT;
  6 invariants; timer ownership is shell-side (single setTimeout per
  pending target); toast auto-dismiss is non-authoritative.
- Pure reducer: src/lib/core/undo/undo-window.ts (no I/O, no timers,
  no Date.now — now injected). 13 unit tests cover every transition +
  invariants I1/I3/I4/I5/I6 + purity.
- Shell controller: src/lib/shell/undo/undo-controller.ts owns the
  timer, Date.now() and toast; onCommit = persistence (only commit
  step), onRestore = in-memory revert, dispose = cancel timers, commit
  nothing (safe-by-default).
- feed-page.svelte.ts: handleHide + deleteSavedView rewritten to the
  soft-delete pattern via two UndoController instances; dispose()
  cancels both windows. FeedPage.svelte wires onDestroy(() =>
  page.dispose()) so panel close cancels pending windows (I5).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* refactor(ext): vary OperationalStoryCard cadence across surfaces

P2 quieter — the same full OperationalStoryCard on every surface caused
pattern fatigue. Introduce a `variant` prop ('full' | 'compact' | 'inline')
as the source of truth (legacy `compact` boolean kept as alias).

- inline: single-row status strip (icon + title/status + inline action).
  Used by FeedPage compact mode so the feed-context story stops pushing
  missions below the fold.
- compact: reduced card — tighter spacing, description hidden so the
  title + status + evidence carry the signal without the long prose.
  Applied to page-level chrome where the story is secondary: Profile,
  TJM, Applications, Settings (x4), Cv.
- full: unchanged. Kept where the story IS the primary decision content:
  FeedPage empty state, TJMDashboard pricing decision, and the
  MissionInvestigationDrawer sticky decision anchor.

Cadence now varies: inline strip on the loaded feed, compact summaries on
secondary surfaces, full cards reserved for decisions that earn the space.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(ext): invert FeedPage chrome so first mission leads

P1 layout — on a loaded feed, four layers of chrome stacked before the
first mission card (hero → inline story → search/filter toolbar →
redundant feed header). Invert so a mission is visible in the first
viewport.

- Tighten compact hero padding (pt-3 pb-2 → pt-2.5 pb-1.5) and bring the
  inline story closer (mt-3 → mt-2).
- Make the search + filter toolbar condensed-sticky in compact mode:
  sticky top-0 with a translucent surface-white/90 + backdrop-blur so it
  stays usable while scrolling without re-stacking chrome above the feed.
- Collapse the redundant 'Missions à examiner' feed header when the hero
  is already compact (the count is already shown in the hero row). Keep
  the h2 as sr-only so the feed region keeps its accessible heading.

Net effect: the first mission card now renders within the first viewport
on a 600–700px tall side panel; chrome becomes secondary and the toolbar
persists as a quiet sticky strip.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* feat(ext): surface keyboard shortcuts (? badge + first-run tip)

PRODUCT.md promises keyboard-first navigation, but the ? cheat-sheet
was hidden behind a generic help icon with no visible kbd affordance.

- Replace generic help-circle icon button with a visible kbd-styled ?
  badge (font-mono, bordered) that clearly signals 'press this key'.
- Add a / kbd hint inside SearchInput (right side, when empty) to
  signal the focus-search shortcut; decorative, aria-hidden.
- Add a one-time first-run tip toast on feed load: 'Navigation clavier
  — appuie sur ?', with a 'Voir les raccourcis' action that opens the
  cheat-sheet. Persisted via a new kbd_cheatsheet_tip_seen flag wired
  through the full FC&IS stack (storage -> bridge -> background ->
  facade -> FeedPage effect). Never repeats after first show.

Existing scan (r) and favorites (f) tooltips already carry kbd hints,
so the full primary-action set is now discoverable.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(ext): harden undo reducer (EXPIRE_ALL, kind guard) and scope onCommit

Addresses PR #178 review threads on the undo-window model:

- EXPIRE_ALL now emits a single `commit-all` effect listing EVERY expired
  target, instead of committing only the first and silently dropping the rest.
- REQUEST rejects a same-target conflict with a different `kind` (invariant I1);
  same-kind re-arm still replaces the snapshot + deadline.
- `onCommit` now receives `{ stillPending }` (the other genuinely-open windows).
  The committer scopes persistence to the committing target: hide omits
  still-pending ids from the persisted map; delete-view re-includes still-pending
  views. A batch sibling is finalizing too, so it is persisted, not excluded.
- Removed the `DISMISS` event: the toast's × and auto-dismiss are cosmetic; the
  shell timer is the single source of timing truth. The controller dismisses the
  toast on UNDO / commit / re-arm / dispose so no stale toast lingers. A
  compile-time guard test pins DISMISS out of the event union.
- Toast duration aligns with the undo window (was window + 1s).

Adds undo-controller.test.ts locking in the shell orchestration invariants
(commit scoping, simultaneous expiry, UNDO via toast onClick, safe-by-default
dispose) that the pure reducer cannot express.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* perf(sidepanel): instrument launch marks, fix CSP-inline-script boot

The side panel shell performance mark was silently broken in production:
MV3 enforces script-src 'self', which drops every inline script. Two inline
blocks in index.html never ran, so the onboarding skeleton bootstrap and the
shell-ready mark were dead code in shipped builds.

- Externalize both inline scripts into shell-boot.ts (CSP-compliant ES module)
- Add production-safe launch-marks module exposing window.__mpPerf for
  reproducible measurement without DEV mode
- Preload FeedPage chunk before mount (phase-3 overlap per launch model)
- Set build.target esnext (Chrome-only, skip down-level transpile)
- Add launch-performance.model.md as source of truth: phases, budgets,
  invariants, measurement protocol, and the proven V8 cold-compile floor
  (95% of boot is JS parse/compile, not mount)

Verified: 1701 tests green, build 1.90s, real-Chrome launch 0 errors.
Boot measures ~70ms (V8 compile floor, owner-accepted); all inter-page
navigations are ~0ms (preload-all design).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(review): DST-safe digest, CSS timing filter, preload error guard

Address PR #183 review feedback:

- daily-digest: nextDigestTime now advances by calendar day (setDate +
  setHours) instead of a fixed 24h offset, so DIGEST_HOUR stays pinned in
  local time across DST transitions. The alarm is now one-shot and
  reschedules itself from the alarm handler, dropping periodInMinutes
  (which drifted +/-1h on DST changeovers). Added DST regression tests.
- launch-marks: observeCssReady now accepts initiatorType 'link' (what
  <link rel="stylesheet"> actually reports per Resource Timing) in
  addition to 'css', with a refined name filter. cssReady was staying
  null in production under the old 'css'-only filter.
- main: FeedPage preload has a .catch() so a failed pre-warm surfaces a
  DEV-only warning instead of an unhandled rejection, preserving the
  "0 console errors" launch invariant. The page-load mark is skipped on
  failure so the snapshot stays honest.

Verified: typecheck clean, 1704 tests pass, build 1.81s.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* refactor(state): drop xstate for svelte 5 runes profile store

Remove the XState runtime dependency and all machine files in favor of an
idiomatic Svelte 5 runes module, per the project standard (AGENTS.md).

- Delete dead app-lifecycle.machine.ts (only imported by its own test) and
  its test.
- Migrate the live profile.machine.ts to src/lib/state/profile.svelte.ts:
  same 6-state graph (loading/missing/editing/saving/ready/error), same
  transitions, side effects, and invariants. Documented in
  src/models/profile-state.model.md (source of truth).
- Delete the xstate.svelte.ts actor adapter; consumers (OnboardingPage,
  SettingsPage) now call createProfileStore(deps) directly. The public
  surface (snapshot.value/context/matches, send, subscribe) is preserved,
  so submitProfile promise patterns are unchanged.
- Rewrite the machine test as profile-store.test.ts (4 original scenarios
  + 2 new invariant tests: saving no-re-entrancy, RETRY hasDraft guard).
- Remove xstate from apps/extension/package.json; pnpm-lock.yaml purged.
- Update the stale ONB-02 QA codeRef to the real production seeding path
  (app-navigation.svelte.ts completeOnboarding).

Bundle: the lazy xstate.svelte chunk (42.88 kB / 13.79 kB gzip) is gone
from the OnboardingPage and SettingsPage routes, replaced by a 1.41 kB
(0.64 kB gzip) runes module.

Verified: typecheck clean, 1699 tests pass, build OK, extension launches
with 0 console/page errors.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(pr-183): address review feedback (shell-boot race, bulk accounting, undo siblings, archive transitions)

P1 shell-boot race: gate the onboarding-shell DOM write behind a
__missionPulseAppMounted flag set synchronously in main.ts before mount(),
so an async sendMessage callback resolving after mount can never rewrite
the live Svelte tree or strand an uncaptured skeleton.

P2 bulk accounting: add failedCount to BulkSummary. skipped now means a
genuine no-op (ok && !changed); failures (!ok) surface as failed and trip
fail(500) when the batch made no progress (failed>0 && applied===0).

P2 undo sibling hides: restore only the target entry from the snapshot
instead of replacing the entire hidden map, so a sibling hide whose undo
window is still open survives an undo.

P2 bulk archive transitions: replace the hardcoded existing.stage ===
'detected' check with the real domain transition graph
(isAllowedApplicationTransition(canonicalizeLegacyApplicationStage(stage), toStage)),
so a tracked mission past detection is still archived instead of skipped.

Update the pipeline-event-order doc test to assert the new write-order
marker and the applied/skipped/failed accounting split.

Verified: typecheck clean, 1699 tests pass, build OK, EXTENSION_LAUNCHES: PASS (0 errors).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(e2e): wait for DevPanel import before opening in tests

The E2E test 'shows empty state via DevPanel' was failing because
the DevPanel component is dynamically imported in App.svelte, creating
a race condition where tests could try to open the panel before the
import completed.

Fix:
- App.svelte now signals readiness via window.__devPanelReady flag
  once DevPanel and MetricsPanel imports complete
- E2E helper openDevPanel() now waits for this flag before pressing
  the keyboard shortcut

This eliminates the timing-dependent failure and makes the test robust.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* feat(db): IndexedDB migration orchestrator with quarantine & downgrade detection (#185)

* feat(db): add IndexedDB migration orchestrator with non-destructive quarantine

Ensures every app update reconciles the IndexedDB schema without breaking
the app. Introduces a formal state model (Model -> Review -> Implement ->
Verify) for the migration lifecycle.

Key pieces:
- Single shared opener openDB() with onversionchange/onblocked retry,
  eliminating the dual-opener version conflict between db.ts and tracking.ts
- Orchestrator state machine (checking -> readVersions -> migratingStruct
  -> migratingData -> verifying -> quarantine -> idle) driven by
  DB_VERSION (structural) and APP_DATA_VERSION (applicative)
- Downgrade detection via probeStoredDbVersion() before any versioned
  open -> routes to non-destructive 'downgrade' state (no data loss)
- Non-destructive quarantine: invalid records (>10% of store) are moved
  to a 'quarantine' store, valid records are kept (never deleteDatabase
  based on validation stats)
- corruptRepair with whole-records-only backup (capped at 4MB) before
  deleteDatabase, only on structural open failure
- Cold-start guard: runMigrations() runs on SW boot and onInstalled,
  deduplicated via migrationInProgress singleton
- Runtime reject counting in the 4 mission readers (invariant 10)
- Append-only migration registries, reactive runes store, bridge messages

Verification: typecheck, lint, production build, and 69/69 storage tests
pass (including 5 new migration tests covering cascade, idempotency,
downgrade, quarantine, and concurrency).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* test(db): add runMigrations/getMigrationStatus to background test mock

The background test mocks the db module but did not export runMigrations
and getMigrationStatus, which are now imported by background/index.ts.
Add stubs returning the idle state so the cold-start guard runs cleanly.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(extension): production hardening pass (overflow, lint, dead code) (#187)

* fix: address review feedback on migration error handling and bridge usage

- isVersionError: rely only on err.name === 'VersionError', drop deprecated err.code === 0
- backupBeforeDestruction: skip unserializable records with try/catch; use TextEncoder for correct byte counting
- RUN_MIGRATIONS handler: send MIGRATION_FAILED when runMigrations() resolves with ok: false
- Cold-start guard: log warning when result.ok === false (not only on rejection)
- onInstalled handler: log warning when result.ok === false
- migration.svelte.ts: rewrite to use bridge (sendMessage/subscribeMessages) instead of importing db directly
- Test mocks: return proper MigrationResult and MigrationSnapshot shapes
- db-migration.model.md: fix verifying doc to name actual stores checked; remove false backup.skipped claim

* Save uncommitted changes

* Save uncommitted changes

* docs: update pulse workflow instructions

* Save uncommitted changes

* perf(scoring): eliminate allocations in dedup similarity primitives (#191)

`jaccardSimilarity` and `overlapCoefficient` run per mission-pair during
deduplication (and several times per pair via compareClients/Locations/Stacks),
so each call previously allocated 2-3 throwaway collections:

  new Set([...a].filter((x) => b.has(x)))   // intersection
  new Set([...a, ...b])                      // union
  [...a].filter((token) => b.has(token))     // intersection count

Rewrite both to use a single counting loop over the smaller set, and compute
the union via inclusion-exclusion (|A∪B| = |A| + |B| − |A∩B|). Output is
mathematically identical.

Micro-benchmark on realistic token-set sizes (~2-10 elements, like mission
titles/stacks): 3.01x faster (912 → 303 ns/call), max numeric diff vs old
implementation: 0. On a 100-mission scan this removes thousands of temporary
Set/Array allocations from the hot dedup path, reducing GC pressure.

Verification:
- dedup.test.ts (20) + relevance.test.ts (21): pass
- parser-regression goldens (17): pass
- typecheck + lint: pass (0 errors)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* perf(scoring): core hot-path optimizations (#193)

Three behavior-preserving perf wins in the pure scoring layer
(core/, no I/O):

- relevance.ts rawStackScore: build a Set<string> from the normalized
  profile stack once and use .has() for O(1) lookup instead of an O(m)
  Array.includes inside the filter. Same match count and percentage.
- dedup.ts compareMissions: pre-compute a per-mission token bundle
  (title/client/location/stack tokens + derived values) once per mission
  in a Map<id, MissionComparisonCache>, so pairwise comparisons no longer
  re-tokenize the same fields across candidates.
- dedup.ts deduplicateMissionsDetailed: maintain an incremental
  canonicalId -> relation-indices map and rewrite re-canonicalized
  relations in O(affected) instead of scanning the whole relations array
  on every canonical replacement. Final duplicateRelations contents and
  ordering are byte-identical.

Invariant preserved: every confidence value, reason string, and the
duplicateRelations array are unchanged. tokenize/normalizeText/STOP_WORDS
untouched. jaccardSimilarity/overlapCoefficient left as-is (owned by #191).

Added a focused 3-way chain test asserting relations re-point to the
final canonical when the canonical is replaced twice.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* perf(ui): memoize feed-page derived state (#194)

Three feed UI rendering/state optimizations across two files. All outputs
are byte-for-byte equivalent (verified by the existing feed-page aggregate
test suite + 1706 unit tests).

Item #8 - FilterBar.svelte
Hoist the static option lists (sources, remoteTypes, seniorityLevels) into a
script module block so they are allocated once at module load instead of on
every component render. getConnectorsMeta() is pure and returns the constant
platform catalog (independent of enabled-connector state), so it is safe to
evaluate at module scope.

Item #7 - feed-page.svelte.ts availableStacks (audit, no code change)
availableStacks reads only missions (= feedStore.filteredMissions). Feed-page
filters (selectedSource/Remote/Stacks/Seniority/ScoreBucket, decisionPreset,
showNewOnly, showFavoritesOnly, showHidden) are local state that is never
written back to feedStore, so filteredMissions reference is stable across
filter/sort changes. Svelte 5 fine-grained reactivity therefore already
prevents the recompute - no narrowing required. Documented here as the
Model/Review outcome; decoupling from search would change observable output
and is intentionally out of scope.

Item #6 - feed-page.svelte.ts single-pass base filter
Extract baseFilteredMissions (enabled connectors + favorites + hidden), the
prefix previously duplicated verbatim in sourceCountBaseMissions and
dashboardScopeMissions. Both consumers now start from baseFilteredMissions and
apply only their differentiating suffix filters. The transitive reactive
signal set is unchanged (no stale UI, no wasted recompute); the shared prefix
now runs once instead of twice. feedAggregates and displayMissions consume
these unchanged, so every count/bucket/preset/insight value is identical.

Verification:
- pnpm --filter @pulse/extension typecheck: clean
- pnpm --filter @pulse/extension lint: 0 errors
- pnpm --filter @pulse/extension test: 1706/1706 pass
  (incl. feed-page.test.ts aggregate equivalence oracle)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* perf(storage): indexed queries + cache dedup + lazy validation (#195)

* perf(storage): dedupe semantic cache index in O(1) via Set

Replaces an O(n) Array.includes inside cacheSemanticScores' filter with
a Set.has membership test, avoiding O(n²) behavior as the cache index
approaches MAX_SEMANTIC_CACHE_ENTRIES. Resulting key contents and order
are byte-identical (cacheKeys comes from a Map so it has no duplicates;
the Set only replaces the lookup). Adds a regression test asserting
re-caching existing keys produces no duplicate index entries.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* perf(storage): index tracking queries by currentStatus

getTrackingsByStatus previously did a full table scan (store.getAll())
plus a client-side filter on every call. It now uses the existing
currentStatus index (created by the v4 migration) via index.getAll(key).

The index is built on the STORED value, but legacy records written
before the canonical-status rename are still keyed under their old stage
(e.g. 'interested' rather than canonical 'selected'), and saveTracking
never normalizes on write. The query therefore unions the canonical key
with its legacy aliases (a local reverse-map mirroring LEGACY_STAGE_MAP
in packages/domain), normalizes each hit, dedupes by missionId, and
sorts by missionId to match getAll()'s primary-key ordering. A
defensive full-scan fallback is retained for the impossible-on-v4 case
where the index is absent.

No DB migration: the index already exists (landed in 81b5b93c / #185
after the original full-scan query in 3d4f7083 / #149). DB_VERSION stays
4. Adds tests for index presence, full-scan equivalence across every
canonical status, and missionId ordering.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* perf(storage): gate startup verifyStores to migration-only runs

verifyStores reads and synchronously validates every record in the
missions and profile stores. It previously ran on every cold start,
even when no migration was pending. It now runs only when a structural
or data migration was actually applied this session
(structuralPending || dataPending) — the realistic corruption window
where schema/data drift could leave invalid records behind.

On steady-state cold starts (both versions current) the O(n) sweep is
skipped, taking it off the startup critical path. Corruption safety is
preserved: when a migration occurs the full verify + quarantine path
runs exactly as before, and day-to-day corruption is still caught
lazily by the runtime parse-on-read guard (trackRuntimeReject) and
surfaced via rejectedCount / the dev panel. No verification,
quarantine, or corrupt-repair code is removed — only the trigger
condition changes.

Updates the db-migration state model: verifying is reachable only from
migratingStruct/migratingData; the no-migration path goes straight to
idle, with a new invariant noting verify is migration-gated. The
quarantine test is restructured so verify fires via dataPending (the
app-data marker is unset), and a new test asserts steady-state runs
skip the scan (bad records stay in place, quarantine stays empty).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* feat(premium): dormant feature flag disables all premium gating (#197)

* feat(premium): add dormant feature flag to disable all premium gating

Introduces PREMIUM_FEATURE_ENABLED (default false) so the extension ships
with premium dormant: all surfaces (TJM/CV/Applications pages, GENERATE_ASSET,
Settings plan display) are accessible without a paywall. The existing premium
infrastructure (store/facade/bridge/service-worker) is preserved untouched;
only the gating decision points now consult the flag via pure helpers
shouldPremiumGate() and canAccessPremium() in core.

Adds a dev-only override (localStorage) and a DevPanel scenario toggle
(dormant / active-premium / active-free) so all three states can be exercised
during development without code changes. Includes an authoritative state model
and regression tests for the gate logic.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(premium): strictly validate stored feature flag + add truth-table tests

Addresses review feedback: untyped storage values (e.g. the string
'false') are truthy and could incorrectly activate premium gating.
Adds a pure resolvePremiumFeatureFlag() that trusts only strict
booleans and falls back to the dormant default otherwise. Wires it
into the service-worker GENERATE_ASSET gate and the dev stubs, and
adds unit tests covering the full truth table and the resolver.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* refactor(profile): unify stack and searchKeywords into keywords (#199)

Merge the two separate UserProfile fields — stack (local scoring) and
searchKeywords (platform API free-text query) — into a single keywords
field. The app must be usable by non-technical users, so only one
"mots-clés" concept remains, surfaced consistently in onboarding and
the profile section.

Model & review:
- apps/extension/src/models/keywords-unification.model.md (authoritative)
- openspec/changes/keywords-unification/proposal.md

Core layer (pure, no I/O):
- types/profile.ts: UserProfile.keywords: string[] (drops stack + searchKeywords)
- types/schemas.ts: UserProfileSchema with z.preprocess shim that merges
  legacy stack/searchKeywords into keywords (dedup, cap 40) on every read
  path — protects before data migration runs
- profile/normalize-profile.ts: ProfileDraftInput uses keywords +
  keywordInput; appendUniqueNormalized (case-insensitive, first-seen wins)
- profile/defaults.ts: DEFAULT_PROFILE.keywords = []
- profile/profile-impact.ts: single keywords impact item (weight 35,
  was stack 25 + search-keywords 10); total weights still sum to 100
- scoring/relevance.ts: rawStackScore(mission.stack, profile.keywords);
  denominator stays missionStack.length so adding unmatched domain
  keywords is scoring-neutral
- connectors/search-context.ts: buildSearchContext reads profile.keywords
- sync/connected-dashboard.ts: dashboard skills -> profile.keywords
- backup/backup.ts: BackupDataSchema.profile uses shared UserProfileSchema

State layer:
- state/settings-page.svelte.ts: profileKeywords / keywordInput / addKeyword
- state/feed-page.svelte.ts: toProfileImpactInput picks keywords

Shell / generation / dev:
- semantic-cache.ts, build-cv-summary.ts, build-cover-message.ts,
  build-pitch-prompt.ts, semantic-scoring.ts, merge-candidate-profile.ts,
  dev/mocks.ts, dev/qa-seed.ts all reference profile.keywords

UI layer (Svelte 5 runes):
- organisms/ProfileSection.svelte: single "Mots-clés" editor
  (input id profile-keywords-input)
- organisms/OnboardingWizard.svelte: single "Mots-clés" step
  (input id ob-keywords); copy now says mots-clés, not stack
- pages/ProfilePage.svelte, SettingsPage.svelte, TJMPage.svelte,
  CvPage.svelte all updated to keywords

Data migration:
- shell/storage/db.ts: APP_DATA_VERSION bumped 1 -> 2
- shell/storage/migration-registry.ts: v1->v2 merges legacy
  stack + searchKeywords into keywords (dedup, cap 40)

Cross-app contracts preserved (NOT renamed):
- Mission.stack (platform-parsed tech stack)
- ScoringWeights.stack / DeterministicBreakdown.stack (scoring axis name)
- ConnectedAlertPreferences.requiredStacks (dashboard column contract)
- profileStacks (TJM history filter parameter)

Verification: Model -> Review -> Implement -> Verify
- typecheck: pass
- lint: pass (1 pre-existing warning in untouched undo-window.test.ts)
- format:check: pass
- unit tests: 1723 passed (123 files)
- parser regression: 17/17 fixtures pass
- ci:check: pass

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* feat(cv): rewrite CV tab as experience feed with cross-platform sync (#198)

* feat(cv): rewrite CV tab as experience feed with cross-platform sync

Replace the old canonical-source/LinkedIn-preview CV page with a feed of
experiences that supports inline add/edit/delete and a sync button that
copies a CV block to the clipboard and opens each connected platform
(LinkedIn + 6 freelance connectors) for manual paste.

Follows the project Model -> Review -> Implement -> Verify workflow. The
authoritative state machine lives in src/models/cv-experience-sync.model.md
and defines three cooperating machines (Feed, Edit, Sync) with explicit
invariants: one edit session at a time, no re-entrancy during
save/delete, PROFILE_UPDATED dropped during in-flight sync/save/edit,
per-platform independent status, and sync operates on committed data.

Core (pure):
- Add Experience type and experiences[] on UserProfile
- Add pure helpers in core/cv/experience-helpers.ts (normalize, recompute
  position index, merge with dedup, format payload, build platform map)
- Persist experiences through mergeCandidateProfileIntoUserProfile (was
  discarded before) and through backup/normalize/defaults/schemas

Shell:
- Add cv-experience.facade.ts wiring store deps to profile + clipboard +
  external URL opener + Date.now/crypto.randomUUID

State:
- Add cv-experience.svelte.ts runes store implementing the three machines

UI:
- ExperienceEditForm, ExperienceCard, ExperienceFeed, CvSyncPanel
- Rewrite CvPage.svelte (1022 -> 65 lines) to header + sync panel + feed

Tests:
- 22 unit tests for pure helpers (no mocks)
- Update existing fixtures to include experiences: []
- Rewrite CvPage.test.ts for the new sync flow; delete obsolete
  CvPageSync.test.ts (old LinkedIn preview/import flow)
- Update operational-ui-constraints.test.ts for new CvPage wiring

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(cv): address review feedback on CV experience feed sync

- Split shared store `error` into `feedError`/`editError`/`syncError` so a
  failure in one machine can no longer overwrite another's terminal error copy
- Honor `cancelSync()` during the `preparing` clipboard probe in `startSync()`
  by re-checking `readSyncStatus()` before entering the platform loop
- Make `now` required in `mergeCandidateProfileIntoUserProfile` (was defaulting
  to 0, causing colliding IDs and `updatedAt: 0`); inject `Date.now()` at both
  shell callers
- Tighten `onSave` prop type from `ExperienceFormData` to `Experience` and drop
  the unsafe `as Experience` cast in `ExperienceFeed.handleSave`
- Add `aria-busy`/`role="status"`/`aria-live="polite"` to the experience feed
  loading skeleton
- Fix typo `runnes` -> `runes` in the cv-experience-sync model

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(cv): address review feedback on experience feed and sync

- Normalize imported LinkedIn dates (YYYY-MM-DD) to canonical YYYY-MM in
  mergeExperiences via new normalizeDateToMonth pure helper.
- Clear endDate when a merged entry becomes current (checks draft.isCurrent,
  not just existing.isCurrent).
- Preserve experiences through normalizeProfileDraft (ProfileDraftInput now
  accepts experiences; settings-page passes current?.experiences).
- Raise SAVE_PROFILE payload cap from 10KB to 80KB so full experiences array
  fits (matches PlatformProfileDraftSchema cap).
- Reset sync state (syncStatus, platformStatuses, lastSyncedAt, syncError) in
  applyProfileUpdate so external merges don't leave stale sync state.
- Remove redundant per-platform clipboard copy in sync loop (payload already
  on clipboard from the probe; re-copy fails once a tab steals focus).
- Keep failed add/edit drafts visible (isAdding/isEditing include error state
  with matching editingId).
- Surface edit errors in non-empty feed via alert banner.
- Restore LinkedIn import button on CvPage (preview + sync facades).
- Allow null endDate for past roles in ExperienceEditForm (end date optional).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(e2e): update tests for redesigned CV page and LinkedIn import flow

The CV page was completely redesigned in PR #198:
- Changed heading from 'Préparer le même profil partout' to 'CV & expériences'
- Replaced preview-based LinkedIn flow with direct import + toast notifications
- Simplified UI: 'Prévisualiser LinkedIn' → 'Importer LinkedIn' button

Test changes:
1. Updated openCvPage() helper to expect new heading
2. Rewrote LinkedIn import tests to match new direct import flow
3. Fixed Settings profile tab test to use exact: true for 'Profil' heading
   (avoids matching other headings containing 'profil' substring)

Updated bridge mock to use IMPORT_LINKEDIN_PROFILE message type.

All 4 previously failing tests now pass locally.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(profile): request LinkedIn host permission from side panel (#201)

* fix(profile): request LinkedIn host permission from side panel

The LinkedIn import flow failed with "Open a LinkedIn profile tab before
importing" even when the user was on a profile page. Two MV3 bugs combined:

1. `tab.url` was undefined in production because the manifest has `activeTab`
   but not `tabs`, and LinkedIn lives in `optional_host_permissions` (not
   granted at install). `activeTab` is revoked once the user navigates, so
   `chrome.tabs.query` returned a tab without `url`, tripping the misleading
   guard.
2. `chrome.permissions.request()` was called from the service worker, which
   MV3 forbids (it must run in a UI context during a user gesture).

Fix (privacy-first, minimal-permission):
- Add `ensureLinkedInHostPermission()` in the profile-sync facade, called
  from the side panel (CvPage) before sending the IMPORT_LINKEDIN_PROFILE
  bridge message. It runs `contains` then `request` on click.
- Reorder the extractor so `ensureExtractionPermission` (now contains-only)
  runs BEFORE `resolveTab`/URL validation. Once granted, `tab.url` is
  populated for LinkedIn tabs.
- Add a `permissions` stub to dev chrome-stubs so dev/e2e pass the gate.
- Add `src/models/linkedin-import.model.md` (Model->Review->Implement->Verify).

LinkedIn stays in `optional_host_permissions`; no `tabs` permission added.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* test(profile): import afterEach and clarify permission test name

Address review feedback on the facade test file:
- Import `afterEach` explicitly from vitest instead of relying on globals,
  matching the existing explicit `beforeEach` import and repo convention.
- Rename the "service-worker context" test case: the stub removes
  `chrome.permissions` entirely, which models a missing permissions API,
  not the MV3 service worker (where `chrome.permissions` exists but
  `request()` must run in a UI context with a user gesture).

Co-authored-by: Copilot Ap…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants