feat(cv): rewrite CV tab as experience feed with cross-platform sync - #198
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
There was a problem hiding this comment.
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 simplifiesCvPage.svelteto delegate behavior to the store. - Updates and adds unit tests for helper logic, UI operational constraints, and storage/profile normalization; removes the legacy
CvPageSyncregression 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. |
There was a problem hiding this comment.
💡 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".
- 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>
…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…
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
UserProfiletype had noexperiencesfield 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.Follows the project's mandatory Model -> Review -> Implement -> Verify workflow. The authoritative state machine lives in
src/models/cv-experience-sync.model.mdand defines three cooperating machines (Feed, Edit, Sync) with explicit invariants:PROFILE_UPDATEDis dropped during in-flight save/delete/sync and during an active edit (external merges do not clobber uncommitted work).Short form: the LLM produces signals; the model decides. No state transition depends on an LLM.
Approach
Experiencetype andexperiences[]onUserProfile; pure helpers incore/cv/experience-helpers.ts(normalizeExperience,recomputePositionIndex,mergeExperienceswith case-insensitive dedup on(company, title, startDate),formatExperiencePayload,buildPlatformPayloads).mergeCandidateProfileIntoUserProfilenow persists experiences instead of discarding them;backup,normalize-profile,defaults, andschemascarry the new field.cv-experience.facade.tswires the store deps togetProfile/saveProfile,navigator.clipboard,openExternalUrl,Date.now,crypto.randomUUID.cv-experience.svelte.tsis a runes store ($state/$derived) implementing the three machines. AreadSyncStatus()helper works around TS control-flow narrowing afterawaitsocancelSync()can mutate status across an await boundary.ExperienceEditForm(molecule),ExperienceCard(molecule, expandable with inline edit),ExperienceFeed(organism, skeleton/empty/error states mirroringMissionFeed),CvSyncPanel(organism, per-platform status list).CvPage.sveltedrops from 1022 to ~65 lines.Surprising / non-obvious bits
CandidateExperienceDraft.skillsisstring[](notCandidateSkillDraft[]);unionSkillsin the merge helper operates on plain strings.SAVE_PROFILE(fullUserProfile).CvPagesubscribes toPROFILE_UPDATEDand forwards tostore.applyProfileUpdate().Verification
pnpm format:checkpnpm lintpnpm typecheckpnpm test(1744 tests, including 22 new unit tests for the pure helpers)pnpm buildpnpm ci:check(green)Checklist