From 0b9ab7a699774e8d32d3087b3d1fc063be14831c Mon Sep 17 00:00:00 2001 From: Guy MANDINA Date: Tue, 28 Jul 2026 16:26:41 +0200 Subject: [PATCH 1/5] feat(onboarding): redesign flow with machine-driven model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the legacy single-card wizard with a model-first onboarding flow following Model → Review → Implement → Verify. Model (src/models/): - onboarding-flow.contract.ts: pure types, validators, step guards (canAdvanceStep), phase projection (phaseFromStateValue) - onboarding-flow.logic.ts: factory createOnboardingFlowSetup(isAdmittedEvent) receiving the admission-set reader from the machine module - onboarding-flow.machine.ts: XState v5 machine + controller facade owning the single ACTIVE_FLOW_EVENTS WeakSet (writer + reader) so admitted events always match the guard - onboarding-flow.model.md: authoritative prose model + invariants Flow (welcome → connecting → wizard → notifying → persisting → scanning → completed) with SKIP → scanning escape. Pure OnboardingFlowEffect descriptors (PERSIST_PROFILE, START_SCAN) are consumed by the shell; the machine never performs I/O. Re-entry is forbidden (final states); partial-profile scans always run ("never block first value"). UI: - OnboardingWelcome.svelte: outcome-led hero, single CTA - OnboardingFlow.svelte: renders all phases from the machine snapshot, reports intent via onEvent - OnboardingPage.svelte: rewritten as machine-driven shell that subscribes, runs pendingEffect, reports back, calls onComplete - ProfileChecklistPill.svelte: dismissible completion pill on the feed Atoms (packages/ui): - SegmentedControl.svelte: exclusive select (radiogroup), iOS pill - ChipGroup.svelte: multi-select (role=group) Verify: - 20 new model tests (allowed/forbidden transitions, invariants, re-entry guard, partial-profile scan, terminal idempotency) - The suite caught a real Phase 1 bug: a duplicate WeakSet in logic.ts desynced from the machine's guard set, silently rejecting every event. Consolidated to one set owned by the machine module. - typecheck clean; lint 0 errors; 3994 tests pass (+20) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/models/onboarding-flow.contract.ts | 245 ++++++++++ .../src/models/onboarding-flow.logic.ts | 157 +++++++ .../src/models/onboarding-flow.machine.ts | 166 +++++++ .../src/models/onboarding-flow.model.md | 103 +++++ .../ui/molecules/ProfileChecklistPill.svelte | 76 ++++ .../src/ui/organisms/OnboardingFlow.svelte | 429 ++++++++++++++++++ .../src/ui/organisms/OnboardingWelcome.svelte | 85 ++++ apps/extension/src/ui/pages/FeedPage.svelte | 24 + .../src/ui/pages/OnboardingPage.svelte | 211 +++++---- .../onboarding-flow-machine.model.test.ts | 319 +++++++++++++ packages/ui/src/index.ts | 4 + packages/ui/src/lib/atoms/ChipGroup.svelte | 59 +++ .../ui/src/lib/atoms/SegmentedControl.svelte | 62 +++ 13 files changed, 1848 insertions(+), 92 deletions(-) create mode 100644 apps/extension/src/models/onboarding-flow.contract.ts create mode 100644 apps/extension/src/models/onboarding-flow.logic.ts create mode 100644 apps/extension/src/models/onboarding-flow.machine.ts create mode 100644 apps/extension/src/models/onboarding-flow.model.md create mode 100644 apps/extension/src/ui/molecules/ProfileChecklistPill.svelte create mode 100644 apps/extension/src/ui/organisms/OnboardingFlow.svelte create mode 100644 apps/extension/src/ui/organisms/OnboardingWelcome.svelte create mode 100644 apps/extension/tests/unit/models/onboarding-flow-machine.model.test.ts create mode 100644 packages/ui/src/lib/atoms/ChipGroup.svelte create mode 100644 packages/ui/src/lib/atoms/SegmentedControl.svelte diff --git a/apps/extension/src/models/onboarding-flow.contract.ts b/apps/extension/src/models/onboarding-flow.contract.ts new file mode 100644 index 00000000..fe629f84 --- /dev/null +++ b/apps/extension/src/models/onboarding-flow.contract.ts @@ -0,0 +1,245 @@ +/** + * Onboarding flow — contract (pure, no I/O). + * + * Models the end-to-end onboarding UI flow as an explicit state machine: + * welcome → connecting → wizard(identity → preferences → skills) → + * notifying → persisting → scanning → completed | skipped + * + * Invariant (binding): the first scan is NEVER blocked. A persist failure or a + * scan failure still advances to `completed` (with a typed error surfaced). The + * only way to not reach the feed is an explicit terminal `skipped` that still + * ran a default partial scan. + * + * Discipline (binding): + * - This file is pure: no fetch, no chrome.*, no async, no Date.now/UUID. The + * only non-deterministic value (`attemptId`) is injected via {@link OnboardingFlowInput}. + * - The machine NEVER performs a side effect. It emits a pure {@link OnboardingFlowEffect} + * descriptor in context; the shell (OnboardingPage) consumes it and reports + * back via events (SCAN_DONE / SCAN_FAILED / PERSISTED). The model decides + * transitions; the shell executes effects. "The LLM produces signals; the + * model decides." + * - Every transition is guarded. There are no implicit/free-text transitions. + * + * See `onboarding-flow.model.md` for the authoritative prose model. + */ + +import type { UserProfile } from '$lib/core/types/profile'; + +export const ONBOARDING_FLOW_MODEL_VERSION = 1; + +/** Wizard step, in strict order. */ +export const ONBOARDING_WIZARD_STEPS = ['identity', 'preferences', 'skills'] as const; +export type OnboardingWizardStep = (typeof ONBOARDING_WIZARD_STEPS)[number]; + +/** Top-level phase the UI renders. */ +export type OnboardingFlowPhase = + | 'welcome' + | 'connecting' + | 'wizard' + | 'notifying' + | 'persisting' + | 'scanning' + | 'completed' + | 'skipped'; + +/** Draft profile built during the wizard. Partial until completion. */ +export type OnboardingProfileDraft = Pick< + UserProfile, + 'firstName' | 'jobTitle' | 'location' | 'remote' | 'keywords' | 'tjmMin' | 'tjmMax' +>; + +export interface OnboardingFlowContext { + readonly attemptId: string; + /** + * Current wizard step. Source of truth for the wizard UI; the top-level + * phase is derived from the actor's state value (see {@link projectOnboardingFlow}). + */ + readonly wizardStep: OnboardingWizardStep; + readonly profile: OnboardingProfileDraft; + /** Source ids the user marked as connected during `connecting`. */ + readonly connectedSources: readonly string[]; + readonly notifyEnabled: boolean; + /** + * Pure effect descriptor the shell must run. Cleared back to `null` once the + * shell acknowledges it (via SCAN_DONE / SCAN_FAILED / PERSISTED). The machine + * never executes it. + */ + readonly pendingEffect: OnboardingFlowEffect | null; + /** Human-readable, typed error surfaced to the UI (never thrown). */ + readonly error: OnboardingFlowError | null; +} + +/** Pure side-effect descriptor. The shell is the only executor. */ +export type OnboardingFlowEffect = + | { readonly kind: 'START_SCAN'; readonly attemptId: string; readonly partial: boolean } + | { + readonly kind: 'PERSIST_PROFILE'; + readonly attemptId: string; + readonly profile: UserProfile; + readonly notifyEnabled: boolean; + }; + +export type OnboardingFlowError = + | { readonly type: 'scan_failed'; readonly message: string } + | { readonly type: 'persist_failed'; readonly message: string }; + +/** Events the shell may dispatch. Unknown events are ignored by the machine. */ +export type OnboardingFlowEvent = + | { type: 'START' } + | { type: 'CONNECT_SOURCE'; sourceId: string } + | { type: 'DISCONNECT_SOURCE'; sourceId: string } + | { type: 'SOURCE_SESSION'; sourceId: string; hasSession: boolean } + | { type: 'UPDATE_PROFILE'; partial: Partial } + | { type: 'SET_NOTIFY'; enabled: boolean } + | { type: 'NEXT' } + | { type: 'BACK' } + | { type: 'SKIP' } + | { type: 'SCAN_DONE' } + | { type: 'SCAN_FAILED'; message: string } + | { type: 'PERSISTED' } + | { type: 'PERSIST_FAILED'; message: string }; + +export interface OnboardingFlowInput { + readonly attemptId: string; + /** Catalog of selectable sources (already filtered to included connectors). */ + readonly sources: readonly { readonly id: string }[]; + /** Optional re-hydration seed (e.g. a profile loaded from storage). */ + readonly initialProfile?: Partial; +} + +/** Projection consumed by the UI. Stable shape; hides internal context. */ +export interface OnboardingFlowSnapshot { + readonly phase: OnboardingFlowPhase; + readonly wizardStep: OnboardingWizardStep; + readonly profile: OnboardingProfileDraft; + readonly connectedSources: readonly string[]; + readonly notifyEnabled: boolean; + readonly progress: { readonly current: number; readonly total: number }; + readonly pendingEffect: OnboardingFlowEffect | null; + readonly error: OnboardingFlowError | null; + readonly terminal: boolean; + /** True when the current wizard step's guard is satisfied (decides NEXT). */ + readonly canAdvance: boolean; +} + +/** Normalize + validate input. Throws on invalid (deterministic guard). */ +export function parseOnboardingFlowInput(input: OnboardingFlowInput): OnboardingFlowInput { + if (!input || typeof input !== 'object') { + throw new Error('onboarding-flow: input required'); + } + if (!input.attemptId || typeof input.attemptId !== 'string') { + throw new Error('onboarding-flow: attemptId required'); + } + if (!Array.isArray(input.sources)) { + throw new Error('onboarding-flow: sources catalog required'); + } + if (input.sources.length === 0) { + throw new Error('onboarding-flow: sources catalog must not be empty'); + } + const seen = new Set(); + for (const s of input.sources) { + if (!s || typeof s.id !== 'string' || s.id.length === 0) { + throw new Error('onboarding-flow: invalid source entry'); + } + if (seen.has(s.id)) { + throw new Error(`onboarding-flow: duplicate source id "${s.id}"`); + } + seen.add(s.id); + } + return input; +} + +export function initialOnboardingFlowContext(input: OnboardingFlowInput): OnboardingFlowContext { + const seed = input.initialProfile ?? {}; + return { + attemptId: input.attemptId, + wizardStep: 'identity', + profile: { + firstName: seed.firstName ?? '', + jobTitle: seed.jobTitle ?? '', + location: seed.location ?? '', + remote: seed.remote ?? 'any', + keywords: seed.keywords ?? [], + tjmMin: seed.tjmMin ?? 500, + tjmMax: seed.tjmMax ?? 800, + }, + connectedSources: [], + notifyEnabled: false, + pendingEffect: null, + error: null, + }; +} + +/** + * Derive the UI phase from the actor's state value. State names mirror phases + * 1:1 (wizard is a flat state whose current step lives in `context.wizardStep`), + * so phase === the active state value. This removes any possibility of + * `context.phase` desynchronising from the actual machine state. + */ +export function phaseFromStateValue(value: string | undefined): OnboardingFlowPhase { + const known: OnboardingFlowPhase[] = [ + 'welcome', + 'connecting', + 'wizard', + 'notifying', + 'persisting', + 'scanning', + 'completed', + 'skipped', + ]; + return value && (known as readonly string[]).includes(value) + ? (value as OnboardingFlowPhase) + : 'welcome'; +} + +export function projectOnboardingFlow( + ctx: OnboardingFlowContext, + phase: OnboardingFlowPhase +): OnboardingFlowSnapshot { + const order: OnboardingFlowPhase[] = [ + 'welcome', + 'connecting', + 'wizard', + 'notifying', + 'persisting', + 'scanning', + 'completed', + ]; + const current = order.indexOf(phase) + 1; + return { + phase, + wizardStep: ctx.wizardStep, + profile: ctx.profile, + connectedSources: ctx.connectedSources, + notifyEnabled: ctx.notifyEnabled, + progress: { current: current < 0 ? 0 : current, total: order.length }, + pendingEffect: ctx.pendingEffect, + error: ctx.error, + terminal: phase === 'completed' || phase === 'skipped', + canAdvance: canAdvanceStep(ctx), + }; +} + +/** Pure guard: can the user leave the current wizard step via NEXT? */ +export function canAdvanceStep(ctx: OnboardingFlowContext): boolean { + switch (ctx.wizardStep) { + case 'identity': + return ctx.profile.firstName.trim().length > 0 && ctx.profile.jobTitle.trim().length > 0; + case 'preferences': + return ctx.profile.tjmMin > 0 && ctx.profile.tjmMax >= ctx.profile.tjmMin; + case 'skills': + return ctx.profile.keywords.length > 0; + default: + return false; + } +} + +export function deepFreeze(value: T): Readonly { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + for (const key of Object.keys(value as Record)) { + deepFreeze((value as Record)[key]); + } + } + return value as Readonly; +} diff --git a/apps/extension/src/models/onboarding-flow.logic.ts b/apps/extension/src/models/onboarding-flow.logic.ts new file mode 100644 index 00000000..ae12d29d --- /dev/null +++ b/apps/extension/src/models/onboarding-flow.logic.ts @@ -0,0 +1,157 @@ +/** + * Onboarding flow — pure guards & actions (XState v5 setup). + * + * No I/O, no async, no non-determinism. Each action returns a Partial context + * patch; XState merges it into the frozen context. Side effects live only as + * pure {@link OnboardingFlowEffect} descriptors emitted into `pendingEffect`. + */ +import { assign, setup } from 'xstate'; + +import type { UserProfile } from '$lib/core/types/profile'; +import { + ONBOARDING_WIZARD_STEPS, + canAdvanceStep, + type OnboardingFlowContext, + type OnboardingFlowEffect, + type OnboardingFlowEvent, + type OnboardingFlowInput, +} from './onboarding-flow.contract'; + +/** + * Pure XState setup. The concrete machine, the admission WeakSet, and the actor + * remain private to the machine module (`onboarding-flow.machine.ts`). Only the + * guard predicate is injected here so a single shared WeakSet can back both + * `admitFlowEvent` (writer) and the `admittedEvent` guard (reader). + */ +export function createOnboardingFlowSetup( + isAdmittedEvent: (event: OnboardingFlowEvent) => boolean +) { + return setup({ + types: { + context: {} as OnboardingFlowContext, + events: {} as OnboardingFlowEvent, + input: {} as OnboardingFlowInput, + }, + guards: { + admittedEvent: ({ event }) => isAdmittedEvent(event), + canAdvanceWizardStep: ({ context }) => canAdvanceStep(context), + hasConnectedSource: ({ context }) => context.connectedSources.length > 0, + isFirstWizardStep: ({ context }) => context.wizardStep === 'identity', + isLastWizardStep: ({ context }) => context.wizardStep === 'skills', + }, + actions: { + /** Entering the wizard from `connecting` resets to the first step. */ + initWizardStep: () => ({ wizardStep: 'identity', error: null }), + toggleSource: assign(({ context, event }) => { + if (event.type !== 'CONNECT_SOURCE' && event.type !== 'DISCONNECT_SOURCE') { + return {}; + } + const id = event.sourceId; + const has = context.connectedSources.includes(id); + const next = + event.type === 'CONNECT_SOURCE' + ? has + ? context.connectedSources + : [...context.connectedSources, id] + : context.connectedSources.filter((s) => s !== id); + return { connectedSources: Object.freeze(next) }; + }), + markSession: assign(({ context, event }) => { + if (event.type !== 'SOURCE_SESSION' || !event.hasSession) { + return {}; + } + if (context.connectedSources.includes(event.sourceId)) { + return {}; + } + return { + connectedSources: Object.freeze([...context.connectedSources, event.sourceId]), + }; + }), + mergeProfile: assign(({ context, event }) => { + if (event.type !== 'UPDATE_PROFILE') { + return {}; + } + return { profile: { ...context.profile, ...event.partial } }; + }), + setNotify: assign(({ event }) => { + if (event.type !== 'SET_NOTIFY') { + return {}; + } + return { notifyEnabled: event.enabled }; + }), + goNextStep: assign(({ context }) => { + const idx = ONBOARDING_WIZARD_STEPS.indexOf(context.wizardStep); + const next = ONBOARDING_WIZARD_STEPS[idx + 1] ?? context.wizardStep; + return { wizardStep: next }; + }), + goBackStep: assign(({ context }) => { + const idx = ONBOARDING_WIZARD_STEPS.indexOf(context.wizardStep); + const prev = ONBOARDING_WIZARD_STEPS[idx - 1] ?? context.wizardStep; + return { wizardStep: prev }; + }), + requestPersist: assign(({ context }) => ({ + pendingEffect: { + kind: 'PERSIST_PROFILE', + attemptId: context.attemptId, + profile: finalizeProfile(context), + notifyEnabled: context.notifyEnabled, + }, + error: null, + })), + requestScan: assign(({ context }) => ({ + pendingEffect: startScanEffect(context.attemptId, context.connectedSources.length === 0), + })), + skipToScan: assign(({ context }) => ({ + notifyEnabled: false, + pendingEffect: startScanEffect(context.attemptId, true), + error: null, + })), + failPersist: assign(({ context, event }) => { + if (event.type !== 'PERSIST_FAILED') { + return {}; + } + // Never block first value: persist failure is recorded but we still scan. + return { + pendingEffect: startScanEffect(context.attemptId, context.connectedSources.length === 0), + error: { type: 'persist_failed', message: event.message }, + }; + }), + failScan: assign(({ event }) => { + if (event.type !== 'SCAN_FAILED') { + return {}; + } + // Never block first value: scan failure still completes. + return { + pendingEffect: null, + error: { type: 'scan_failed', message: event.message }, + }; + }), + complete: () => ({ pendingEffect: null }), + }, + }); +} + +function startScanEffect(attemptId: string, partial: boolean): OnboardingFlowEffect { + return { kind: 'START_SCAN', attemptId, partial }; +} + +/** + * Build the final {@link UserProfile} from the draft. Pure: fills sane defaults + * for fields the wizard does not capture. The shell may further normalize via + * `normalizeProfileDraft` / `withProfileDefaults` before persisting. + */ +function finalizeProfile(ctx: OnboardingFlowContext): UserProfile { + const p = ctx.profile; + return { + firstName: p.firstName.trim() || 'Invité', + jobTitle: p.jobTitle.trim() || 'Freelance', + keywords: [...p.keywords], + tjmMin: p.tjmMin, + tjmMax: Math.max(p.tjmMax, p.tjmMin + 100), + location: p.location || '', + remote: p.remote, + seniority: 'senior', + experiences: [], + availability: null, + }; +} diff --git a/apps/extension/src/models/onboarding-flow.machine.ts b/apps/extension/src/models/onboarding-flow.machine.ts new file mode 100644 index 00000000..a15f9acb --- /dev/null +++ b/apps/extension/src/models/onboarding-flow.machine.ts @@ -0,0 +1,166 @@ +/** + * Onboarding flow — XState v5 machine + public controller facade. + * + * The machine is pure: it emits {@link OnboardingFlowEffect} descriptors but + * never executes them. The shell (OnboardingPage) owns all I/O: it subscribes, + * consumes `pendingEffect`, runs persist/scan, and reports back via events. + * + * Re-use: source-level consent (durable `enabledConnectors` + permissions) is + * owned by `onboarding-source.machine.ts`. This flow machine only orchestrates + * the UI phases and treats "connected source" as an in-memory UI fact. + */ +import { and, createActor, type Subscription } from 'xstate'; + +import { + initialOnboardingFlowContext, + parseOnboardingFlowInput, + phaseFromStateValue, + projectOnboardingFlow, + type OnboardingFlowEvent, + type OnboardingFlowInput, + type OnboardingFlowSnapshot, +} from './onboarding-flow.contract'; +import { createOnboardingFlowSetup } from './onboarding-flow.logic'; + +export * from './onboarding-flow.contract'; + +/** + * Single source of truth for event admission. The controller writes here via + * {@link admitFlowEvent} before dispatching; the `admittedEvent` guard reads + * here via the closure passed to {@link createOnboardingFlowSetup}. Both the + * writer and reader MUST reference the SAME set — a second set (e.g. one living + * inside logic.ts) would silently reject every event. + */ +const ACTIVE_FLOW_EVENTS = new WeakSet(); + +/** Mark an event as admitted (originated from the controller, not injected). */ +function admitFlowEvent(event: OnboardingFlowEvent): void { + ACTIVE_FLOW_EVENTS.add(event); +} + +const onboardingFlowSetup = createOnboardingFlowSetup((event) => ACTIVE_FLOW_EVENTS.has(event)); + +const onboardingFlowMachine = onboardingFlowSetup.createMachine({ + id: 'onboardingFlow', + context: ({ input }) => initialOnboardingFlowContext(input), + initial: 'welcome', + states: { + welcome: { + on: { + START: { guard: and(['admittedEvent']), target: 'connecting' }, + SKIP: { guard: and(['admittedEvent']), target: 'scanning', actions: 'skipToScan' }, + }, + }, + connecting: { + on: { + CONNECT_SOURCE: { guard: and(['admittedEvent']), actions: 'toggleSource' }, + DISCONNECT_SOURCE: { guard: and(['admittedEvent']), actions: 'toggleSource' }, + SOURCE_SESSION: { guard: and(['admittedEvent']), actions: 'markSession' }, + NEXT: { + guard: and(['admittedEvent', 'hasConnectedSource']), + target: 'wizard', + actions: 'initWizardStep', + }, + BACK: { guard: and(['admittedEvent']), target: 'welcome' }, + SKIP: { guard: and(['admittedEvent']), target: 'scanning', actions: 'skipToScan' }, + }, + }, + wizard: { + // Flat state: `context.wizardStep` (identity|preferences|skills) is the + // single source of truth for the current step. NEXT/BACK without a target + // are internal transitions that only mutate context. Phase is derived from + // the active state value, so it can never desync from `wizardStep`. + on: { + UPDATE_PROFILE: { guard: and(['admittedEvent']), actions: 'mergeProfile' }, + NEXT: [ + { + guard: and(['admittedEvent', 'canAdvanceWizardStep', 'isLastWizardStep']), + target: 'notifying', + }, + { + guard: and(['admittedEvent', 'canAdvanceWizardStep']), + actions: 'goNextStep', + }, + ], + BACK: [ + { + guard: and(['admittedEvent', 'isFirstWizardStep']), + target: 'connecting', + }, + { guard: and(['admittedEvent']), actions: 'goBackStep' }, + ], + SKIP: { guard: and(['admittedEvent']), target: 'scanning', actions: 'skipToScan' }, + }, + }, + notifying: { + on: { + SET_NOTIFY: { guard: and(['admittedEvent']), actions: 'setNotify' }, + NEXT: { guard: and(['admittedEvent']), target: 'persisting', actions: 'requestPersist' }, + // Back to wizard preserves wizardStep (no decrement): the user returns + // to the last step they were on. + BACK: { guard: and(['admittedEvent']), target: 'wizard' }, + SKIP: { guard: and(['admittedEvent']), target: 'scanning', actions: 'skipToScan' }, + }, + }, + persisting: { + on: { + PERSISTED: { guard: and(['admittedEvent']), target: 'scanning', actions: 'requestScan' }, + PERSIST_FAILED: { + guard: and(['admittedEvent']), + target: 'scanning', + actions: 'failPersist', + }, + }, + }, + scanning: { + on: { + SCAN_DONE: { guard: and(['admittedEvent']), target: 'completed', actions: 'complete' }, + SCAN_FAILED: { guard: and(['admittedEvent']), target: 'completed', actions: 'failScan' }, + }, + }, + completed: { type: 'final' }, + skipped: { type: 'final' }, + }, +}); + +export interface OnboardingFlowController { + start(): void; + stop(): void; + send(event: OnboardingFlowEvent): void; + getSnapshot(): OnboardingFlowSnapshot; + subscribe(listener: (snapshot: OnboardingFlowSnapshot) => void): Subscription; +} + +export function createOnboardingFlowController( + input: OnboardingFlowInput +): OnboardingFlowController { + const parsed = parseOnboardingFlowInput(input); + const actor = createActor(onboardingFlowMachine, { input: parsed }); + + const toSnapshot = (state: ReturnType): OnboardingFlowSnapshot => { + const value = + typeof state.value === 'string' ? state.value : (Object.keys(state.value)[0] ?? 'welcome'); + return projectOnboardingFlow(state.context, phaseFromStateValue(value)); + }; + + return { + start() { + actor.start(); + }, + stop() { + actor.stop(); + }, + send(event: OnboardingFlowEvent) { + admitFlowEvent(event); + actor.send(event); + }, + getSnapshot() { + return toSnapshot(actor.getSnapshot()); + }, + subscribe(listener) { + return actor.subscribe((s) => listener(toSnapshot(s))); + }, + }; +} + +export { onboardingFlowMachine }; diff --git a/apps/extension/src/models/onboarding-flow.model.md b/apps/extension/src/models/onboarding-flow.model.md new file mode 100644 index 00000000..2dd12c90 --- /dev/null +++ b/apps/extension/src/models/onboarding-flow.model.md @@ -0,0 +1,103 @@ +# Onboarding Flow — State Model + +> Authoritative prose model for the end-to-end onboarding UI flow. The machine +> in `onboarding-flow.machine.ts` (+ `.contract.ts`, `.logic.ts`) is the +> executable form of this document. If the two disagree, **the model is wrong**. + +## Purpose + +Take a first-run user from "extension installed" to "first scan running" without +ever blocking the delivery of first value. The flow collects the minimum +profile signal needed to score missions, asks for one connected source, and +launches a scan — even if the user skips every question. + +## Scope + +- **In scope:** UI phase orchestration (welcome → connect → wizard → notify → + persist → scan → done), step validity guards, the `pendingEffect` protocol + that the shell consumes to run persist/scan. +- **Out of scope:** durable source consent + permissions + session detection + (owned by `onboarding-source.machine.ts`, reused as-is), credential storage + (none — local-first), OTP/compliance (none — cookie-based sessions). + +## Phases (states) + +``` +welcome ──START──▶ connecting ──NEXT(hasSource)──▶ wizard ──NEXT(lastStep)──▶ notifying + │ │ │ │ + │ BACK BACK NEXT + │ ▼ │ ▼ + │ welcome ◀─────────── BACK(first)──┘ persisting + │ │ + └──────────SKIP──────────────────────────────────────────┬─SKIP───────────────┤ + │ │ │ + ▼ ▼ ▼ + scanning ◀──PERSISTED/PERSIST_FAILED──── persisting ◀──┘ + │ + SCAN_DONE / SCAN_FAILED + ▼ + completed (final) +``` + +`wizard` is a **flat** state: `context.wizardStep ∈ {identity, preferences, skills}` +is the single source of truth. The top-level **phase is derived from the actor's +state value** (`phaseFromStateValue`), so context and active state can never +desync. + +## Invariants (binding) + +1. **Never block first value.** A scan always runs. The only terminal besides + `completed` is `skipped`, which _also_ runs a partial scan first. There is + no aborted dead-end. +2. **Idempotent terminal.** `completed` and `skipped` are XState `final` + states. No event is admitted after the terminal is reached. +3. **One primary action per screen.** Each phase has at most one forward (NEXT) + and one back (BACK); SKIP is the only escape hatch. +4. **Guards decide, components don't.** `canAdvanceStep` (pure, in contract) is + the sole arbiter of whether NEXT is enabled on a wizard step. The UI never + invents a transition. +5. **Effects are descriptors, not executions.** The machine writes a pure + `OnboardingFlowEffect` into `pendingEffect`; the shell reads it, runs the + I/O, and reports back via `SCAN_DONE` / `SCAN_FAILED` / `PERSISTED` / + `PERSIST_FAILED`. The machine never calls fetch/storage/chrome.*. +6. **Admitted events only.** Events created by the controller are tagged via a + `WeakSet` owned by the **machine module** (not `logic.ts`); the controller + calls `admitFlowEvent(event)` before dispatching, and the `admittedEvent` + guard reads the same set via the closure passed to + `createOnboardingFlowSetup`. Splitting the writer and reader across two + modules (two disjoint sets) silently rejects every event — this is a + load-bearing invariant: **one set, one module**. + +## Wizard step validity (`canAdvanceStep`) + +| Step | Guard | +| ----------- | -------------------------------------------------- | +| identity | `firstName` and `jobTitle` are non-empty (trimmed) | +| preferences | `tjmMin > 0` and `tjmMax >= tjmMin` | +| skills | at least one keyword | + +## Effect protocol (machine → shell) + +| When (transition) | `pendingEffect` written | Shell reports back | +| ---------------------- | ------------------------------ | ------------------------------ | +| notifying → persisting | `PERSIST_PROFILE` | `PERSISTED` / `PERSIST_FAILED` | +| persisting → scanning | `START_SCAN` | `SCAN_DONE` / `SCAN_FAILED` | +| *_SKIP → scanning | `START_SCAN { partial: true }` | `SCAN_DONE` / `SCAN_FAILED` | + +On `PERSIST_FAILED`, the machine still writes `START_SCAN` and transitions to +`scanning` (never blocks first value); the typed error is surfaced in +`context.error` for the UI. + +## Re-entry guard + +`completed` / `skipped` are `final`. The extension gates on +`hasCompletedOnboarding` (storage flag set by the shell once `completed` is +reached), so the flow is never re-entered. The controller is created fresh each +onboarding run. + +## Snapshot (projection) + +`projectOnboardingFlow(ctx, phase)` exposes a stable `OnboardingFlowSnapshot`: +`phase`, `wizardStep`, `profile`, `connectedSources`, `notifyEnabled`, +`progress {current,total}`, `pendingEffect`, `error`, `terminal`, `canAdvance`. +The UI consumes only this shape; it never touches context directly. diff --git a/apps/extension/src/ui/molecules/ProfileChecklistPill.svelte b/apps/extension/src/ui/molecules/ProfileChecklistPill.svelte new file mode 100644 index 00000000..d42714e5 --- /dev/null +++ b/apps/extension/src/ui/molecules/ProfileChecklistPill.svelte @@ -0,0 +1,76 @@ + + +
+ + + + + +
diff --git a/apps/extension/src/ui/organisms/OnboardingFlow.svelte b/apps/extension/src/ui/organisms/OnboardingFlow.svelte new file mode 100644 index 00000000..62584c29 --- /dev/null +++ b/apps/extension/src/ui/organisms/OnboardingFlow.svelte @@ -0,0 +1,429 @@ + + +{#if snapshot.phase === 'welcome'} + onEvent({ type: 'START' })} + onSkip={() => onEvent({ type: 'SKIP' })} + /> +{:else if snapshot.phase === 'connecting'} +
+
+

+ Étape {snapshot.progress.current}/{snapshot.progress.total} +

+

+ Connectez vos sources +

+

+ Sélectionnez les plateformes où vous avez déjà une session Chrome active. Pulse se charge du + reste. +

+ +
    + {#each sources as s (s.id)} + {@const selected = snapshot.connectedSources.includes(s.id)} +
  • + +
  • + {/each} +
+
+ +
+ + +
+
+{:else if snapshot.phase === 'wizard'} +
+ +
+
+
+ +
+

{stepLabel}

+

+ {snapshot.wizardStep === 'identity' + ? 'Qui êtes-vous ?' + : snapshot.wizardStep === 'preferences' + ? 'Quels sont vos critères ?' + : 'Vos compétences clés'} +

+

{stepHint}

+ +
+ {#if snapshot.wizardStep === 'identity'} + + + + {:else if snapshot.wizardStep === 'preferences'} +
+ Mode de travail +
+ patch({ remote: v })} + /> +
+
+
+ + +
+ {:else} +
+ Compétences +
+ + e.key === 'Enter' && (e.preventDefault(), addKeyword(keywordsInput))} + placeholder="Ajouter puis Entrée" + class="h-11 flex-1 rounded-xl border border-border-light bg-surface-white px-3 text-sm text-text-primary outline-none transition-colors focus:border-blueprint-blue/50 focus:ring-2 focus:ring-blueprint-blue/15" + /> + +
+ {#if snapshot.profile.keywords.length > 0} +
+ {#each snapshot.profile.keywords as k (k)} + + {/each} +
+ {/if} +

Suggestions

+
+ ({ value: s, label: s }))} + onchange={(_values) => { + const v = _values as string[]; + patch({ keywords: v }); + }} + /> +
+
+ {/if} +
+
+ +
+ + +
+
+{:else if snapshot.phase === 'notifying'} + +
+
+

+ Étape {snapshot.progress.current}/{snapshot.progress.total} +

+

+ Soyez alerté·e +

+

+ Recevez une notification Chrome quand une mission à haut score correspond à votre profil. +

+ +
+
+
+

Notifications de missions

+

+ Vous pouvez changer cela à tout moment dans les réglages. +

+
+ onEvent({ type: 'SET_NOTIFY', enabled: !snapshot.notifyEnabled })} + /> +
+
+
+ +
+ + +
+
+{:else if snapshot.phase === 'persisting' || snapshot.phase === 'scanning'} + +
+
+ +
+

+ {snapshot.phase === 'persisting' ? 'Enregistrement…' : 'Premier scan en cours'} +

+

+ {scanningPartial + ? 'Profil partiel — on lance quand même un scan par défaut pour vous montrer la valeur.' + : 'Pulse récupère et score vos missions. Cela prend quelques secondes.'} +

+
+{:else if snapshot.phase === 'completed' || snapshot.phase === 'skipped'} +
+
+ +
+

+ {snapshot.error ? 'Presque terminé' : "C'est prêt"} +

+

+ {snapshot.error + ? 'Une étape a échoué, mais votre feed est prêt. Vous pourrez compléter plus tard.' + : 'Votre feed est prêt. Redirection…'} +

+
+{/if} diff --git a/apps/extension/src/ui/organisms/OnboardingWelcome.svelte b/apps/extension/src/ui/organisms/OnboardingWelcome.svelte new file mode 100644 index 00000000..a1d77b34 --- /dev/null +++ b/apps/extension/src/ui/organisms/OnboardingWelcome.svelte @@ -0,0 +1,85 @@ + + +
+
+

MissionPulse

+ +

+ Toutes vos missions freelance,
centralisées et scorées. +

+ +

+ Connectez vos plateformes existantes. Pulse récupère, déduplique et classe les missions pour + ne rater que celles qui comptent. +

+ +
    + {#each pillars as p (p.title)} +
  • + + + +
    +

    {p.title}

    +

    {p.detail}

    +
    +
  • + {/each} +
+
+ +
+ + +
+
diff --git a/apps/extension/src/ui/pages/FeedPage.svelte b/apps/extension/src/ui/pages/FeedPage.svelte index 9590cf81..897d84cf 100644 --- a/apps/extension/src/ui/pages/FeedPage.svelte +++ b/apps/extension/src/ui/pages/FeedPage.svelte @@ -132,6 +132,9 @@ $state(null); let ProfileRefinementBanner: typeof import('../molecules/ProfileRefinementBanner.svelte').default | null = $state(null); + let ProfileChecklistPill: + typeof import('../molecules/ProfileChecklistPill.svelte').default | null = $state(null); + let checklistPillDismissed = $state(false); let ConnectorAlertBar: typeof import('../molecules/ConnectorAlertBar.svelte').default | null = $state(null); let FeedTourOverlay: typeof import('../molecules/FeedTourOverlay.svelte').default | null = @@ -214,6 +217,11 @@ ProfileRefinementBanner = module.default; }); } + if (!ProfileChecklistPill) { + import('../molecules/ProfileChecklistPill.svelte').then((module) => { + ProfileChecklistPill = module.default; + }); + } } function loadConnectorAlertBar(): void { @@ -1237,6 +1245,22 @@ /> {/if} + {#if !checklistPillDismissed && page.profileLoaded && page.profileCompletion < 100 && ProfileChecklistPill} +
+ { + if (onNavigateToProfile) { + onNavigateToProfile(); + return; + } + onNavigateToOnboarding?.(); + }} + onDismiss={() => (checklistPillDismissed = true)} + /> +
+ {/if} + {#if feedChromeBusy}
diff --git a/apps/extension/src/ui/pages/OnboardingPage.svelte b/apps/extension/src/ui/pages/OnboardingPage.svelte index 54ffe2ab..a4a44bbb 100644 --- a/apps/extension/src/ui/pages/OnboardingPage.svelte +++ b/apps/extension/src/ui/pages/OnboardingPage.svelte @@ -1,123 +1,150 @@ {#snippet wizardContent()} - + {/snippet} diff --git a/apps/extension/tests/unit/models/onboarding-flow-machine.model.test.ts b/apps/extension/tests/unit/models/onboarding-flow-machine.model.test.ts new file mode 100644 index 00000000..65e92919 --- /dev/null +++ b/apps/extension/tests/unit/models/onboarding-flow-machine.model.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from 'vitest'; + +import { + createOnboardingFlowController, + type OnboardingFlowController, + type OnboardingFlowEvent, +} from '../../../src/models/onboarding-flow.machine'; + +const ATTEMPT_ID = 'onb_test-attempt-0001'; +const SOURCES = [{ id: 'free-work' }, { id: 'lehibou' }] as const; + +function makeController(): OnboardingFlowController { + const c = createOnboardingFlowController({ + attemptId: ATTEMPT_ID, + sources: [...SOURCES], + }); + c.start(); + return c; +} + +/** Drive the controller through a sequence of events, returning the final snapshot. */ +function run(controller: OnboardingFlowController, events: OnboardingFlowEvent[]) { + for (const e of events) { + controller.send(e); + } + return controller.getSnapshot(); +} + +/** Fill identity + preferences + skills to satisfy every step guard. */ +function fullProfileEvents(): OnboardingFlowEvent[] { + return [ + { type: 'UPDATE_PROFILE', partial: { firstName: 'Alex', jobTitle: 'Dev' } }, + { type: 'NEXT' }, // identity → preferences + { type: 'UPDATE_PROFILE', partial: { tjmMin: 600, tjmMax: 800 } }, + { type: 'NEXT' }, // preferences → skills + { type: 'UPDATE_PROFILE', partial: { keywords: ['React', 'TS'] } }, + { type: 'NEXT' }, // skills → notifying + ]; +} + +describe('onboarding-flow machine', () => { + describe('nominal path', () => { + it('reaches completed via welcome→connect→wizard→notify→persist→scan', () => { + const c = makeController(); + const final = run(c, [ + { type: 'START' }, + { type: 'CONNECT_SOURCE', sourceId: 'free-work' }, + { type: 'NEXT' }, // → wizard (identity) + ...fullProfileEvents(), // → notifying + { type: 'SET_NOTIFY', enabled: true }, + { type: 'NEXT' }, // → persisting (emits PERSIST_PROFILE) + { type: 'PERSISTED' }, // → scanning (emits START_SCAN) + { type: 'SCAN_DONE' }, // → completed + ]); + + expect(final.phase).toBe('completed'); + expect(final.terminal).toBe(true); + expect(final.error).toBeNull(); + expect(final.notifyEnabled).toBe(true); + expect(final.connectedSources).toEqual(['free-work']); + }); + + it('derives phase from state value (never desyncs from wizardStep)', () => { + const c = makeController(); + run(c, [ + { type: 'START' }, + { type: 'CONNECT_SOURCE', sourceId: 'free-work' }, + { type: 'NEXT' }, + ]); + const s = c.getSnapshot(); + expect(s.phase).toBe('wizard'); + expect(s.wizardStep).toBe('identity'); + }); + + it('emits PERSIST_PROFILE then START_SCAN as distinct pending effects', () => { + const c = makeController(); + run(c, [ + { type: 'START' }, + { type: 'CONNECT_SOURCE', sourceId: 'free-work' }, + { type: 'NEXT' }, + ...fullProfileEvents(), + { type: 'NEXT' }, // → persisting + ]); + const persistEffect = c.getSnapshot().pendingEffect; + expect(persistEffect?.kind).toBe('PERSIST_PROFILE'); + + c.send({ type: 'PERSISTED' }); + const scanEffect = c.getSnapshot().pendingEffect; + expect(scanEffect?.kind).toBe('START_SCAN'); + expect(scanEffect).toMatchObject({ kind: 'START_SCAN', partial: false }); + }); + }); + + describe('skip path', () => { + it('SKIP from welcome → scanning → completed with partial scan', () => { + const c = makeController(); + run(c, [{ type: 'SKIP' }]); + expect(c.getSnapshot().phase).toBe('scanning'); + expect(c.getSnapshot().pendingEffect).toMatchObject({ kind: 'START_SCAN', partial: true }); + + c.send({ type: 'SCAN_DONE' }); + expect(c.getSnapshot().phase).toBe('completed'); + expect(c.getSnapshot().terminal).toBe(true); + }); + + it('SKIP mid-wizard still reaches scanning (never a dead-end)', () => { + const c = makeController(); + run(c, [ + { type: 'START' }, + { type: 'CONNECT_SOURCE', sourceId: 'free-work' }, + { type: 'NEXT' }, + { type: 'SKIP' }, + ]); + expect(c.getSnapshot().phase).toBe('scanning'); + expect(c.getSnapshot().pendingEffect?.kind).toBe('START_SCAN'); + }); + }); + + describe('back-navigation', () => { + it('connecting BACK → welcome', () => { + const c = makeController(); + run(c, [{ type: 'START' }, { type: 'BACK' }]); + expect(c.getSnapshot().phase).toBe('welcome'); + }); + + it('wizard BACK from first step → connecting', () => { + const c = makeController(); + run(c, [ + { type: 'START' }, + { type: 'CONNECT_SOURCE', sourceId: 'free-work' }, + { type: 'NEXT' }, + ]); + expect(c.getSnapshot().phase).toBe('wizard'); + c.send({ type: 'BACK' }); + expect(c.getSnapshot().phase).toBe('connecting'); + }); + + it('wizard BACK from later steps decrements wizardStep internally', () => { + const c = makeController(); + run(c, [ + { type: 'START' }, + { type: 'CONNECT_SOURCE', sourceId: 'free-work' }, + { type: 'NEXT' }, + ...fullProfileEvents().slice(0, 2), // identity filled + NEXT → preferences + ]); + expect(c.getSnapshot().wizardStep).toBe('preferences'); + c.send({ type: 'BACK' }); + expect(c.getSnapshot().phase).toBe('wizard'); + expect(c.getSnapshot().wizardStep).toBe('identity'); + }); + + it('notifying BACK → wizard preserves the current step', () => { + const c = makeController(); + run(c, [ + { type: 'START' }, + { type: 'CONNECT_SOURCE', sourceId: 'free-work' }, + { type: 'NEXT' }, + ...fullProfileEvents(), // ends in notifying + ]); + expect(c.getSnapshot().phase).toBe('notifying'); + c.send({ type: 'BACK' }); + expect(c.getSnapshot().phase).toBe('wizard'); + expect(c.getSnapshot().wizardStep).toBe('skills'); + }); + }); + + describe('error paths (never block first value)', () => { + it('SCAN_FAILED → completed with typed error', () => { + const c = makeController(); + run(c, [ + { type: 'START' }, + { type: 'CONNECT_SOURCE', sourceId: 'free-work' }, + { type: 'NEXT' }, + ...fullProfileEvents(), + { type: 'NEXT' }, + { type: 'PERSISTED' }, + { type: 'SCAN_FAILED', message: 'boom' }, + ]); + const s = c.getSnapshot(); + expect(s.phase).toBe('completed'); + expect(s.terminal).toBe(true); + expect(s.error).toEqual({ type: 'scan_failed', message: 'boom' }); + }); + + it('PERSIST_FAILED → scanning still runs (never blocks first value)', () => { + const c = makeController(); + run(c, [ + { type: 'START' }, + { type: 'CONNECT_SOURCE', sourceId: 'free-work' }, + { type: 'NEXT' }, + ...fullProfileEvents(), + { type: 'NEXT' }, // → persisting + { type: 'PERSIST_FAILED', message: 'disk full' }, + ]); + const s = c.getSnapshot(); + expect(s.phase).toBe('scanning'); + expect(s.pendingEffect?.kind).toBe('START_SCAN'); + expect(s.error).toEqual({ type: 'persist_failed', message: 'disk full' }); + }); + }); + + describe('wizard step guards (pure decisions)', () => { + it('cannot leave identity with empty firstName or jobTitle', () => { + const c = makeController(); + run(c, [ + { type: 'START' }, + { type: 'CONNECT_SOURCE', sourceId: 'free-work' }, + { type: 'NEXT' }, + ]); + expect(c.getSnapshot().canAdvance).toBe(false); + c.send({ type: 'NEXT' }); + expect(c.getSnapshot().wizardStep).toBe('identity'); // blocked + c.send({ type: 'UPDATE_PROFILE', partial: { firstName: 'Alex' } }); + expect(c.getSnapshot().canAdvance).toBe(false); // still missing jobTitle + c.send({ type: 'UPDATE_PROFILE', partial: { jobTitle: 'Dev' } }); + expect(c.getSnapshot().canAdvance).toBe(true); + }); + + it('cannot leave preferences when tjmMax < tjmMin', () => { + const c = makeController(); + run(c, [ + { type: 'START' }, + { type: 'CONNECT_SOURCE', sourceId: 'free-work' }, + { type: 'NEXT' }, + { type: 'UPDATE_PROFILE', partial: { firstName: 'A', jobTitle: 'B' } }, + { type: 'NEXT' }, // → preferences + ]); + c.send({ type: 'UPDATE_PROFILE', partial: { tjmMin: 900, tjmMax: 500 } }); + expect(c.getSnapshot().canAdvance).toBe(false); + c.send({ type: 'NEXT' }); + expect(c.getSnapshot().wizardStep).toBe('preferences'); // blocked + }); + + it('cannot leave skills with no keywords', () => { + const c = makeController(); + run(c, [ + { type: 'START' }, + { type: 'CONNECT_SOURCE', sourceId: 'free-work' }, + { type: 'NEXT' }, + { type: 'UPDATE_PROFILE', partial: { firstName: 'A', jobTitle: 'B' } }, + { type: 'NEXT' }, + { type: 'UPDATE_PROFILE', partial: { tjmMin: 500, tjmMax: 800 } }, + { type: 'NEXT' }, // → skills + ]); + expect(c.getSnapshot().canAdvance).toBe(false); + }); + }); + + describe('connecting guard', () => { + it('cannot advance to wizard without a connected source', () => { + const c = makeController(); + run(c, [{ type: 'START' }]); + c.send({ type: 'NEXT' }); + expect(c.getSnapshot().phase).toBe('connecting'); // blocked + c.send({ type: 'CONNECT_SOURCE', sourceId: 'lehibou' }); + c.send({ type: 'NEXT' }); + expect(c.getSnapshot().phase).toBe('wizard'); + }); + + it('DISCONNECT_SOURCE removes a source', () => { + const c = makeController(); + run(c, [ + { type: 'START' }, + { type: 'CONNECT_SOURCE', sourceId: 'free-work' }, + { type: 'CONNECT_SOURCE', sourceId: 'lehibou' }, + { type: 'DISCONNECT_SOURCE', sourceId: 'free-work' }, + ]); + expect(c.getSnapshot().connectedSources).toEqual(['lehibou']); + }); + }); + + describe('re-entry guard & terminal idempotency', () => { + it('completed is final: further events do not change the snapshot', () => { + const c = makeController(); + run(c, [{ type: 'SKIP' }, { type: 'SCAN_DONE' }]); + const terminal = c.getSnapshot(); + expect(terminal.terminal).toBe(true); + // Attempt to re-drive; final states admit no transitions. + c.send({ type: 'START' }); + c.send({ type: 'NEXT' }); + c.send({ type: 'SKIP' }); + expect(c.getSnapshot()).toStrictEqual(terminal); + }); + + it('re-running the full flow on a fresh controller is deterministic', () => { + const a = makeController(); + const b = makeController(); + const events: OnboardingFlowEvent[] = [ + { type: 'START' }, + { type: 'CONNECT_SOURCE', sourceId: 'free-work' }, + { type: 'NEXT' }, + ...fullProfileEvents(), + { type: 'NEXT' }, + { type: 'PERSISTED' }, + { type: 'SCAN_DONE' }, + ]; + run(a, events); + run(b, events); + expect(a.getSnapshot()).toStrictEqual(b.getSnapshot()); + }); + }); + + describe('input validation', () => { + it('rejects an empty sources catalog', () => { + expect(() => createOnboardingFlowController({ attemptId: ATTEMPT_ID, sources: [] })).toThrow( + /sources/ + ); + }); + + it('rejects a missing attemptId', () => { + expect(() => + createOnboardingFlowController({ + attemptId: '', + sources: [...SOURCES], + }) + ).toThrow(/attemptId/); + }); + }); +}); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 766eb325..d135e2d5 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -9,6 +9,10 @@ export { default as GlowButton } from './lib/atoms/GlowButton.svelte'; export { default as Toast } from './lib/atoms/Toast.svelte'; export { default as Toggle } from './lib/atoms/Toggle.svelte'; export type { ToggleSize } from './lib/atoms/Toggle.svelte'; +export { default as SegmentedControl } from './lib/atoms/SegmentedControl.svelte'; +export type { SegmentedControlOption } from './lib/atoms/SegmentedControl.svelte'; +export { default as ChipGroup } from './lib/atoms/ChipGroup.svelte'; +export type { ChipGroupOption } from './lib/atoms/ChipGroup.svelte'; export { default as Icon } from './lib/atoms/Icon.svelte'; // Icons (tree-shakeable) diff --git a/packages/ui/src/lib/atoms/ChipGroup.svelte b/packages/ui/src/lib/atoms/ChipGroup.svelte new file mode 100644 index 00000000..81ab431d --- /dev/null +++ b/packages/ui/src/lib/atoms/ChipGroup.svelte @@ -0,0 +1,59 @@ + + + + +
+ {#each options as opt (opt.value)} + + {/each} +
diff --git a/packages/ui/src/lib/atoms/SegmentedControl.svelte b/packages/ui/src/lib/atoms/SegmentedControl.svelte new file mode 100644 index 00000000..fce95fad --- /dev/null +++ b/packages/ui/src/lib/atoms/SegmentedControl.svelte @@ -0,0 +1,62 @@ + + + + +
+ {#each options as opt (opt.value)} + + {/each} +
From e1e0bd2583cfb6b4c3fd0ecea99acd689087776b Mon Sep 17 00:00:00 2001 From: Guy MANDINA Date: Tue, 28 Jul 2026 18:17:10 +0200 Subject: [PATCH 2/5] fix(onboarding): address code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OnboardingFlow: Continue button now disabled solely by connectedSources.length === 0 (canAdvance mixed wizard-step guards with source selection and silently guard-blocked NEXT). - OnboardingPage: dispose feedController on unmount to stop the bridge listener leaking across navigations. - flow contract/machine/model: remove the `skipped` terminal phase (dead code — every SKIP transition targets `scanning` -> `completed`); the sole terminal is `completed`, matching the executable machine. - flow contract: accept an empty sources catalog (degenerate build where every connector is excluded) so the flow can still reach `completed` via SKIP instead of crashing during controller construction. - ProfileChecklistPill: correct the docstring — dismissal persistence is the parent host's responsibility, not this presentational atom's. - ChipGroup: drop the broad `as readonly string[]` / `as unknown` casts by using a Svelte 5 generics script; value/option types now flow. - SegmentedControl: implement the WAI-ARIA radiogroup pattern (roving tabindex + ArrowLeft/Right/Home/End keyboard nav) so the role is backed by real keyboard interaction. Tests: 22 onboarding-flow machine tests pass (was 20; +2 input-validation cases, empty-sources case now asserts the degenerate build is accepted). 3996 extension unit tests pass; 26 pre-existing failures are isolated to release-artifact/canonical-artifact scripts (native binary attestation, environmental, untouched by this PR). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/models/onboarding-flow.contract.ts | 34 +++++----- .../src/models/onboarding-flow.machine.ts | 1 - .../src/models/onboarding-flow.model.md | 18 ++--- .../ui/molecules/ProfileChecklistPill.svelte | 6 +- .../src/ui/organisms/OnboardingFlow.svelte | 4 +- .../src/ui/pages/OnboardingPage.svelte | 1 + .../onboarding-flow-machine.model.test.ts | 28 ++++++-- packages/ui/src/lib/atoms/ChipGroup.svelte | 15 ++-- .../ui/src/lib/atoms/SegmentedControl.svelte | 68 +++++++++++++++++-- 9 files changed, 126 insertions(+), 49 deletions(-) diff --git a/apps/extension/src/models/onboarding-flow.contract.ts b/apps/extension/src/models/onboarding-flow.contract.ts index fe629f84..ee1f8663 100644 --- a/apps/extension/src/models/onboarding-flow.contract.ts +++ b/apps/extension/src/models/onboarding-flow.contract.ts @@ -3,12 +3,12 @@ * * Models the end-to-end onboarding UI flow as an explicit state machine: * welcome → connecting → wizard(identity → preferences → skills) → - * notifying → persisting → scanning → completed | skipped + * notifying → persisting → scanning → completed * * Invariant (binding): the first scan is NEVER blocked. A persist failure or a - * scan failure still advances to `completed` (with a typed error surfaced). The - * only way to not reach the feed is an explicit terminal `skipped` that still - * ran a default partial scan. + * scan failure still advances to `completed` (with a typed error surfaced). + * SKIP fast-forwards to `scanning` with a partial-default scan and then reaches + * the same sole terminal `completed` — there is no second terminal state. * * Discipline (binding): * - This file is pure: no fetch, no chrome.*, no async, no Date.now/UUID. The @@ -33,14 +33,7 @@ export type OnboardingWizardStep = (typeof ONBOARDING_WIZARD_STEPS)[number]; /** Top-level phase the UI renders. */ export type OnboardingFlowPhase = - | 'welcome' - | 'connecting' - | 'wizard' - | 'notifying' - | 'persisting' - | 'scanning' - | 'completed' - | 'skipped'; + 'welcome' | 'connecting' | 'wizard' | 'notifying' | 'persisting' | 'scanning' | 'completed'; /** Draft profile built during the wizard. Partial until completion. */ export type OnboardingProfileDraft = Pick< @@ -101,7 +94,12 @@ export type OnboardingFlowEvent = export interface OnboardingFlowInput { readonly attemptId: string; - /** Catalog of selectable sources (already filtered to included connectors). */ + /** + * Catalog of selectable sources (already filtered to included connectors). + * May be empty in a degenerate build where every connector is excluded; the + * flow still reaches `completed` (the user can SKIP to a default scan, and + * the `connecting` phase simply renders an empty list). + */ readonly sources: readonly { readonly id: string }[]; /** Optional re-hydration seed (e.g. a profile loaded from storage). */ readonly initialProfile?: Partial; @@ -133,9 +131,10 @@ export function parseOnboardingFlowInput(input: OnboardingFlowInput): Onboarding if (!Array.isArray(input.sources)) { throw new Error('onboarding-flow: sources catalog required'); } - if (input.sources.length === 0) { - throw new Error('onboarding-flow: sources catalog must not be empty'); - } + // An empty catalog is valid: it represents a degenerate build where every + // connector is excluded. The flow still reaches `completed` via SKIP; the + // `connecting` phase simply has nothing to render. Per-entry validation still + // rejects malformed or duplicate entries when sources are present. const seen = new Set(); for (const s of input.sources) { if (!s || typeof s.id !== 'string' || s.id.length === 0) { @@ -185,7 +184,6 @@ export function phaseFromStateValue(value: string | undefined): OnboardingFlowPh 'persisting', 'scanning', 'completed', - 'skipped', ]; return value && (known as readonly string[]).includes(value) ? (value as OnboardingFlowPhase) @@ -215,7 +213,7 @@ export function projectOnboardingFlow( progress: { current: current < 0 ? 0 : current, total: order.length }, pendingEffect: ctx.pendingEffect, error: ctx.error, - terminal: phase === 'completed' || phase === 'skipped', + terminal: phase === 'completed', canAdvance: canAdvanceStep(ctx), }; } diff --git a/apps/extension/src/models/onboarding-flow.machine.ts b/apps/extension/src/models/onboarding-flow.machine.ts index a15f9acb..7e96c6e7 100644 --- a/apps/extension/src/models/onboarding-flow.machine.ts +++ b/apps/extension/src/models/onboarding-flow.machine.ts @@ -119,7 +119,6 @@ const onboardingFlowMachine = onboardingFlowSetup.createMachine({ }, }, completed: { type: 'final' }, - skipped: { type: 'final' }, }, }); diff --git a/apps/extension/src/models/onboarding-flow.model.md b/apps/extension/src/models/onboarding-flow.model.md index 2dd12c90..46c4db57 100644 --- a/apps/extension/src/models/onboarding-flow.model.md +++ b/apps/extension/src/models/onboarding-flow.model.md @@ -46,11 +46,12 @@ desync. ## Invariants (binding) -1. **Never block first value.** A scan always runs. The only terminal besides - `completed` is `skipped`, which _also_ runs a partial scan first. There is - no aborted dead-end. -2. **Idempotent terminal.** `completed` and `skipped` are XState `final` - states. No event is admitted after the terminal is reached. +1. **Never block first value.** A scan always runs. `SKIP` fast-forwards to + `scanning` with a partial-default profile; `_FAILED` transitions still + advance to the next phase. There is no aborted dead-end and no terminal + short of `completed`. +2. **Idempotent terminal.** `completed` is the sole XState `final` state. No + event is admitted after it is reached. 3. **One primary action per screen.** Each phase has at most one forward (NEXT) and one back (BACK); SKIP is the only escape hatch. 4. **Guards decide, components don't.** `canAdvanceStep` (pure, in contract) is @@ -90,10 +91,9 @@ On `PERSIST_FAILED`, the machine still writes `START_SCAN` and transitions to ## Re-entry guard -`completed` / `skipped` are `final`. The extension gates on -`hasCompletedOnboarding` (storage flag set by the shell once `completed` is -reached), so the flow is never re-entered. The controller is created fresh each -onboarding run. +`completed` is `final`. The extension gates on `hasCompletedOnboarding` +(storage flag set by the shell once `completed` is reached), so the flow is +never re-entered. The controller is created fresh each onboarding run. ## Snapshot (projection) diff --git a/apps/extension/src/ui/molecules/ProfileChecklistPill.svelte b/apps/extension/src/ui/molecules/ProfileChecklistPill.svelte index d42714e5..c47f949a 100644 --- a/apps/extension/src/ui/molecules/ProfileChecklistPill.svelte +++ b/apps/extension/src/ui/molecules/ProfileChecklistPill.svelte @@ -1,9 +1,9 @@ - diff --git a/packages/ui/src/lib/atoms/SegmentedControl.svelte b/packages/ui/src/lib/atoms/SegmentedControl.svelte index fce95fad..182d88b1 100644 --- a/packages/ui/src/lib/atoms/SegmentedControl.svelte +++ b/packages/ui/src/lib/atoms/SegmentedControl.svelte @@ -2,6 +2,8 @@ // Exclusive select (single value) rendered as a pill-shaped segmented control. // iOS-style: rounded-full container, active segment fills with blueprint-blue, // tap feedback scales to 0.98. Use for small option sets (2–4 items). + // Implements the WAI-ARIA radiogroup pattern: roving tabindex (only the + // checked option is tabbable) + ArrowLeft/Right/Home/End keyboard nav. export type SegmentedControlOption = { value: T; label: string; @@ -9,8 +11,8 @@ }; -
{#each options as opt (opt.value)} @@ -49,7 +107,9 @@ type="button" role="radio" aria-checked={value === opt.value} + tabindex={tabIndexFor(opt.value)} disabled={disabled || opt.disabled} + data-segment={opt.value} onclick={() => select(opt)} class="inline-flex h-8 items-center rounded-full px-3 text-xs font-medium transition-all duration-150 ease-out focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blueprint-blue active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 {value === opt.value From 6ceb5f7a6f89b71ef74413a6d071812b5abc3c7b Mon Sep 17 00:00:00 2001 From: Guy MANDINA Date: Tue, 28 Jul 2026 18:30:49 +0200 Subject: [PATCH 3/5] fix(onboarding): address second round of review feedback Threads A,C,D,E,F,H,L from chatgpt-codex review: - Persist alert preference for BOTH notify states (disabled was skipped, leaving the enabled default active). (A) - Admit UPDATE_PROFILE in welcome/connecting so the shell can rehydrate an existing profile before the user advances. (C) - Write connected sources to settings.enabledConnectors so the scanner filters by the user's selection. (D) - Add `autoLoad` option to createFeedController; onboarding passes `autoLoad: false` so a scan is not fired before the user consents. (E) - Use event-based oninput (e.currentTarget.value) for identity inputs; the stale bound value caused the previous keystroke to be sent. (F) - Respect the user's explicit tjmMax instead of widening it to tjmMin+100. (H) - Await onComplete and surface a retry when finalization fails instead of leaving the user on "Redirection...". (L) Adds model tests for welcome-state profile rehydration and tjmMax preservation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../shell/facades/feed-controller.svelte.ts | 32 ++++-- .../src/models/onboarding-flow.logic.ts | 5 +- .../src/models/onboarding-flow.machine.ts | 5 + .../src/ui/organisms/OnboardingFlow.svelte | 50 +++++++--- .../src/ui/pages/OnboardingPage.svelte | 98 ++++++++++++++++--- .../onboarding-flow-machine.model.test.ts | 40 ++++++++ 6 files changed, 193 insertions(+), 37 deletions(-) diff --git a/apps/extension/src/lib/shell/facades/feed-controller.svelte.ts b/apps/extension/src/lib/shell/facades/feed-controller.svelte.ts index 3178dd7b..de665cb0 100644 --- a/apps/extension/src/lib/shell/facades/feed-controller.svelte.ts +++ b/apps/extension/src/lib/shell/facades/feed-controller.svelte.ts @@ -313,16 +313,25 @@ export interface FeedController { * Creates a feed controller that manages scan orchestration and data loading. * * @param feedStore - The feed store to update with missions + * @param options - Optional configuration. + * - `autoLoad` (default `true`): when `false`, skips the automatic + * `smartLoad()` (which scans when storage is empty) on mount. Used by hosts + * that only need to trigger scans explicitly (e.g. onboarding), so a scan + * is not fired before the user consents. * @returns FeedController API with reactive state and methods */ -export function createFeedController(feedStore: { - readonly state?: FeedState; - readonly missions?: Mission[]; - load(): void; - reset?(): void; - setMissions(missions: Mission[]): void; - setError(msg: string): void; -}): FeedController { +export function createFeedController( + feedStore: { + readonly state?: FeedState; + readonly missions?: Mission[]; + load(): void; + reset?(): void; + setMissions(missions: Mission[]): void; + setError(msg: string): void; + }, + options: { autoLoad?: boolean } = {} +): FeedController { + const { autoLoad = true } = options; // ============================================================ // Reactive state // ============================================================ @@ -978,8 +987,11 @@ export function createFeedController(feedStore: { // Check source sessions on mount checkSourceSessions(); - // Smart load initial data - smartLoad(); + // Smart load initial data (skipped when autoLoad is disabled — e.g. the + // onboarding host triggers its first scan explicitly via START_SCAN). + if (autoLoad) { + smartLoad(); + } } // Run initialization — surface errors instead of swallowing them. diff --git a/apps/extension/src/models/onboarding-flow.logic.ts b/apps/extension/src/models/onboarding-flow.logic.ts index ae12d29d..f93a7ff0 100644 --- a/apps/extension/src/models/onboarding-flow.logic.ts +++ b/apps/extension/src/models/onboarding-flow.logic.ts @@ -147,7 +147,10 @@ function finalizeProfile(ctx: OnboardingFlowContext): UserProfile { jobTitle: p.jobTitle.trim() || 'Freelance', keywords: [...p.keywords], tjmMin: p.tjmMin, - tjmMax: Math.max(p.tjmMax, p.tjmMin + 100), + // Respect the user's explicit max. The `preferences` guard already enforces + // tjmMax >= tjmMin; silently widening a valid narrow range (e.g. 800-850) + // would discard the user's intent. + tjmMax: p.tjmMax, location: p.location || '', remote: p.remote, seniority: 'senior', diff --git a/apps/extension/src/models/onboarding-flow.machine.ts b/apps/extension/src/models/onboarding-flow.machine.ts index 7e96c6e7..ee6973e8 100644 --- a/apps/extension/src/models/onboarding-flow.machine.ts +++ b/apps/extension/src/models/onboarding-flow.machine.ts @@ -47,6 +47,10 @@ const onboardingFlowMachine = onboardingFlowSetup.createMachine({ states: { welcome: { on: { + // UPDATE_PROFILE is admitted so the shell can rehydrate an existing + // profile before the user advances (best-effort, races with START). + // The pure mergeProfile action is harmless in this state. + UPDATE_PROFILE: { guard: and(['admittedEvent']), actions: 'mergeProfile' }, START: { guard: and(['admittedEvent']), target: 'connecting' }, SKIP: { guard: and(['admittedEvent']), target: 'scanning', actions: 'skipToScan' }, }, @@ -56,6 +60,7 @@ const onboardingFlowMachine = onboardingFlowSetup.createMachine({ CONNECT_SOURCE: { guard: and(['admittedEvent']), actions: 'toggleSource' }, DISCONNECT_SOURCE: { guard: and(['admittedEvent']), actions: 'toggleSource' }, SOURCE_SESSION: { guard: and(['admittedEvent']), actions: 'markSession' }, + UPDATE_PROFILE: { guard: and(['admittedEvent']), actions: 'mergeProfile' }, NEXT: { guard: and(['admittedEvent', 'hasConnectedSource']), target: 'wizard', diff --git a/apps/extension/src/ui/organisms/OnboardingFlow.svelte b/apps/extension/src/ui/organisms/OnboardingFlow.svelte index efb243b0..812bf086 100644 --- a/apps/extension/src/ui/organisms/OnboardingFlow.svelte +++ b/apps/extension/src/ui/organisms/OnboardingFlow.svelte @@ -19,10 +19,14 @@ snapshot, sources, onEvent, + onRetry, + navFailed = false, }: { snapshot: OnboardingFlowSnapshot; sources: { id: string; name: string }[]; onEvent: (event: OnboardingFlowEvent) => void; + onRetry?: () => void; + navFailed?: boolean; } = $props(); // Local mirrors of inputs, synced FROM the snapshot (single source of truth). @@ -206,8 +210,11 @@ Prénom patch({ firstName })} + value={firstName} + oninput={(e) => { + firstName = e.currentTarget.value; + patch({ firstName: e.currentTarget.value }); + }} placeholder="Alex" class="mt-1 h-11 w-full rounded-xl border border-border-light bg-surface-white px-3 text-sm text-text-primary outline-none transition-colors focus:border-blueprint-blue/50 focus:ring-2 focus:ring-blueprint-blue/15" /> @@ -216,8 +223,11 @@ Métier patch({ jobTitle })} + value={jobTitle} + oninput={(e) => { + jobTitle = e.currentTarget.value; + patch({ jobTitle: e.currentTarget.value }); + }} placeholder="Développeur·euse" class="mt-1 h-11 w-full rounded-xl border border-border-light bg-surface-white px-3 text-sm text-text-primary outline-none transition-colors focus:border-blueprint-blue/50 focus:ring-2 focus:ring-blueprint-blue/15" /> @@ -226,8 +236,11 @@ Localisation (optionnel) patch({ location })} + value={location} + oninput={(e) => { + location = e.currentTarget.value; + patch({ location: e.currentTarget.value }); + }} placeholder="Paris, France" class="mt-1 h-11 w-full rounded-xl border border-border-light bg-surface-white px-3 text-sm text-text-primary outline-none transition-colors focus:border-blueprint-blue/50 focus:ring-2 focus:ring-blueprint-blue/15" /> @@ -413,17 +426,30 @@ transition:fade={{ duration: 120 }} >
- +

- {snapshot.error ? 'Presque terminé' : "C'est prêt"} + {navFailed ? 'Finalisation impossible' : snapshot.error ? 'Presque terminé' : "C'est prêt"}

- {snapshot.error - ? 'Une étape a échoué, mais votre feed est prêt. Vous pourrez compléter plus tard.' - : 'Votre feed est prêt. Redirection…'} + {navFailed + ? 'La sauvegarde de votre progression a échoué. Réessayez.' + : snapshot.error + ? 'Une étape a échoué, mais votre feed est prêt. Vous pourrez compléter plus tard.' + : 'Votre feed est prêt. Redirection…'}

+ {#if navFailed && onRetry} + + {/if} {/if} diff --git a/apps/extension/src/ui/pages/OnboardingPage.svelte b/apps/extension/src/ui/pages/OnboardingPage.svelte index b17f862e..89fb0051 100644 --- a/apps/extension/src/ui/pages/OnboardingPage.svelte +++ b/apps/extension/src/ui/pages/OnboardingPage.svelte @@ -18,7 +18,12 @@ OnboardingFlowEffect, } from '../../models/onboarding-flow.machine'; import { createOnboardingFlowController } from '../../models/onboarding-flow.machine'; - import { getProfile, saveProfile } from '$lib/shell/facades/settings.facade'; + import { + getProfile, + saveProfile, + getSettings, + setSettings, + } from '$lib/shell/facades/settings.facade'; import { saveAlertPreferences, getAlertPreferences, @@ -31,7 +36,7 @@ import { createFeedController } from '$lib/shell/facades/feed-controller.svelte'; import { getConnectorsMeta } from '$lib/shell/connectors/meta'; - const { onComplete }: { onComplete?: () => void } = $props(); + const { onComplete }: { onComplete?: () => Promise | boolean } = $props(); // Inputs (injected, non-deterministic values live in the shell). const attemptId = `onb_${crypto.randomUUID()}`; @@ -74,8 +79,10 @@ // Scan runs through the same service-worker path as the feed. Results persist // to IndexedDB; FeedPage hydrates them on mount after onboarding completes. + // `autoLoad: false` prevents a scan from firing on mount — the scan is + // triggered explicitly by the START_SCAN effect once the user consents. const feedStore = createFeedStore(); - const feedController = createFeedController(feedStore); + const feedController = createFeedController(feedStore, { autoLoad: false }); const unsubscribe = controller.subscribe((next) => { snapshot = next; @@ -88,7 +95,6 @@ // ── Effect executor ────────────────────────────────────────────────────── // Fires once per emitted effect (ref-equality guard prevents re-runs). let lastHandled: OnboardingFlowEffect | null = null; - let completed = false; $effect(() => { const effect = snapshot.pendingEffect; @@ -103,12 +109,18 @@ if (effect.kind === 'PERSIST_PROFILE') { try { await saveProfile(effect.profile); - if (effect.notifyEnabled) { - try { - alertPreferences = await saveAlertPreferences(alertPreferences); - } catch { - // Alert save failure is non-fatal: profile persisted, still scan. - } + // Persist the user's notification choice for BOTH states. Previously + // a "disabled" choice was skipped, so global notifications silently + // stayed at their enabled default. + try { + const next: ConnectedAlertPreferences = { + ...alertPreferences, + enabled: effect.notifyEnabled, + }; + alertPreferences = next; + await saveAlertPreferences(next); + } catch { + // Alert save failure is non-fatal: profile persisted, still scan. } controller.send({ type: 'PERSISTED' }); } catch (err) { @@ -121,6 +133,9 @@ } // START_SCAN try { + // Persist the sources the user marked as connected so the service worker + // scans exactly those (and the feed honors them on next mount). + await applyConnectedSources(effect.attemptId); await feedController.startScan(); controller.send({ type: 'SCAN_DONE' }); } catch (err) { @@ -131,21 +146,76 @@ } } + /** + * Write the user's source selection to settings.enabledConnectors so the + * scanner filters by it. Best-effort: a failure does not abort the scan + * (the scanner falls back to the canonical enabled set). + */ + async function applyConnectedSources(attemptId: string): Promise { + const connected = controller.getSnapshot().connectedSources; + if (connected.length === 0) { + return; + } + void attemptId; + try { + const settings = await getSettings(); + const next = { + ...settings, + // Preserve any currently-enabled connector not part of onboarding's + // catalog (e.g. toggled elsewhere); union with the new selection. + enabledConnectors: Array.from(new Set([...settings.enabledConnectors, ...connected])), + }; + await setSettings(next); + // Reflect the change in the feed controller's in-memory set immediately. + for (const id of connected) { + feedController.enabledConnectorIds.add(id); + } + } catch { + // Non-fatal: the scanner still runs with persisted defaults. + } + } + // ── Terminal → onComplete ──────────────────────────────────────────────── - $effect(() => { - if (snapshot.terminal && !completed) { + // Await the navigation callback: if persisting the onboarding-completed flag + // fails, the machine is already terminal, so we surface a retry instead of + // leaving the user stuck on "Redirection…". + let completed = false; + let navFailed = $state(false); + + async function finalize(): Promise { + if (completed) { + return; + } + try { + const result = await onComplete?.(); + if (result === false) { + navFailed = true; + return; + } completed = true; - onComplete?.(); + } catch { + navFailed = true; + } + } + + $effect(() => { + if (snapshot.terminal && !completed && !navFailed) { + void finalize(); } }); + function retryFinalize(): void { + navFailed = false; + void finalize(); + } + function handleEvent(event: OnboardingFlowEvent) { controller.send(event); } {#snippet wizardContent()} - + {/snippet} diff --git a/apps/extension/tests/unit/models/onboarding-flow-machine.model.test.ts b/apps/extension/tests/unit/models/onboarding-flow-machine.model.test.ts index 7c5be16b..8fe7393e 100644 --- a/apps/extension/tests/unit/models/onboarding-flow-machine.model.test.ts +++ b/apps/extension/tests/unit/models/onboarding-flow-machine.model.test.ts @@ -89,6 +89,31 @@ describe('onboarding-flow machine', () => { expect(scanEffect?.kind).toBe('START_SCAN'); expect(scanEffect).toMatchObject({ kind: 'START_SCAN', partial: false }); }); + + it('PERSIST_PROFILE preserves a narrow tjmMax instead of widening it', () => { + // Regression: finalizeProfile used to write tjmMax = max(tjmMax, tjmMin+100), + // silently discarding a valid narrow range (e.g. 800-850). The guard only + // enforces tjmMax >= tjmMin, so the user's explicit max must be honored. + const c = makeController(); + run(c, [ + { type: 'START' }, + { type: 'CONNECT_SOURCE', sourceId: 'free-work' }, + { type: 'NEXT' }, + { type: 'UPDATE_PROFILE', partial: { firstName: 'A', jobTitle: 'B' } }, + { type: 'NEXT' }, + { type: 'UPDATE_PROFILE', partial: { tjmMin: 800, tjmMax: 850 } }, + { type: 'NEXT' }, + { type: 'UPDATE_PROFILE', partial: { keywords: ['React'] } }, + { type: 'NEXT' }, // skills → notifying + { type: 'NEXT' }, // notifying → persisting + ]); + const effect = c.getSnapshot().pendingEffect; + expect(effect?.kind).toBe('PERSIST_PROFILE'); + if (effect.kind === 'PERSIST_PROFILE') { + expect(effect.profile.tjmMin).toBe(800); + expect(effect.profile.tjmMax).toBe(850); // not widened to 900 + } + }); }); describe('skip path', () => { @@ -123,6 +148,21 @@ describe('onboarding-flow machine', () => { expect(c.getSnapshot().phase).toBe('welcome'); }); + it('UPDATE_PROFILE is admitted in welcome so the shell can rehydrate an existing profile', () => { + const c = makeController(); + // The shell rehydrates a persisted profile before the user advances. + c.send({ + type: 'UPDATE_PROFILE', + partial: { firstName: 'Rehydrated', jobTitle: 'Dev', tjmMin: 700, tjmMax: 900 }, + }); + // Stays in welcome, but the draft is merged (pre-fills the wizard later). + const snap = c.getSnapshot(); + expect(snap.phase).toBe('welcome'); + expect(snap.profile.firstName).toBe('Rehydrated'); + expect(snap.profile.tjmMin).toBe(700); + expect(snap.profile.tjmMax).toBe(900); + }); + it('wizard BACK from first step → connecting', () => { const c = makeController(); run(c, [ From 0022fcf6015a7e5cb969ce0c417e2b97025ca696 Mon Sep 17 00:00:00 2001 From: Guy MANDINA Date: Tue, 28 Jul 2026 18:34:18 +0200 Subject: [PATCH 4/5] fix(feed): decouple and persist checklist pill nudge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads I, J from chatgpt-codex review: - Load ProfileChecklistPill on its own condition (profileCompletion < 100) instead of bundling its lazy import into loadRefinementBanner(). Previously the pill never loaded when the banner was dismissed or not yet needed, even though its render condition was true. (I) - Persist the pill's dismiss state across reloads. It now shares the existing profile-completion nudge flag (getProfileBannerDismissed), so dismissing the pill or the banner suppresses both — they serve the same intent. (J) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/extension/src/ui/pages/FeedPage.svelte | 31 +++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/extension/src/ui/pages/FeedPage.svelte b/apps/extension/src/ui/pages/FeedPage.svelte index 897d84cf..eae68bb7 100644 --- a/apps/extension/src/ui/pages/FeedPage.svelte +++ b/apps/extension/src/ui/pages/FeedPage.svelte @@ -36,7 +36,11 @@ import type { FeedTourStep } from '../molecules/FeedTourOverlay.svelte'; import OperationalStoryCard from '../molecules/OperationalStoryCard.svelte'; import Tooltip from '../atoms/Tooltip.svelte'; - import { getProfileBannerDismissed, setFeedTourSeen } from '$lib/shell/facades/app-flags.facade'; + import { + getProfileBannerDismissed, + setProfileBannerDismissed, + setFeedTourSeen, + } from '$lib/shell/facades/app-flags.facade'; import { getKbdCheatsheetTipSeen, setKbdCheatsheetTipSeen, @@ -217,6 +221,13 @@ ProfileRefinementBanner = module.default; }); } + } + + // The checklist pill loads on its own condition (profile < 100%), decoupled + // from the refinement banner. Previously it was bundled into + // loadRefinementBanner(), so it never loaded when the banner was dismissed + // or not yet needed — even though the pill's own render condition was true. + function loadChecklistPill(): void { if (!ProfileChecklistPill) { import('../molecules/ProfileChecklistPill.svelte').then((module) => { ProfileChecklistPill = module.default; @@ -318,6 +329,12 @@ } }); + $effect(() => { + if (page.profileLoaded && page.profileCompletion < 100) { + loadChecklistPill(); + } + }); + $effect(() => { if (brokenConnectors.length > 0) { loadConnectorAlertBar(); @@ -759,6 +776,10 @@ getAlertPreferences(), ]); showRefinementBanner = !bannerDismissed; + // The checklist pill shares the profile-completion nudge's dismiss state: + // if the user dismissed the banner, don't re-surface the pill (and vice + // versa). Persisted via the existing flag so it survives reloads. + checklistPillDismissed = bannerDismissed; alertPreferences = storedAlertPreferences; })().catch(() => {}); @@ -1256,7 +1277,13 @@ } onNavigateToOnboarding?.(); }} - onDismiss={() => (checklistPillDismissed = true)} + onDismiss={() => { + checklistPillDismissed = true; + // Persist: the pill and the refinement banner share the same + // profile-completion nudge, so one dismiss state covers both. + showRefinementBanner = false; + void setProfileBannerDismissed().catch(() => {}); + }} />
{/if} From e2c0cc5ca2440b5cb7bd3d1d663aa07532befe3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:32:22 +0000 Subject: [PATCH 5/5] fix(background): add settingsSnapshot param to persistPostCommitEffects The function used `settingsSnapshot` in its body (to call `notifyHighScoreMissions` and drive the badge count from notifiable missions) but the parameter was absent from its signature. This caused `TS2304: Cannot find name 'settingsSnapshot'` in the typecheck step of CI. - Add `settingsSnapshot: SettingsReleaseSnapshot` as second parameter - Update the call site to pass the already-in-scope `settingsSnapshot` - Restore the notification-driven badge logic (use `notification.notifiableMissionIds.length` as badge count, save seen IDs for notified missions) from the original HEAD branch intent --- apps/extension/src/background/index.ts | 29 +++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/extension/src/background/index.ts b/apps/extension/src/background/index.ts index c40ba56a..1231d37d 100644 --- a/apps/extension/src/background/index.ts +++ b/apps/extension/src/background/index.ts @@ -798,7 +798,7 @@ async function executeAcceptedScanOperation( } try { - await persistPostCommitEffects(result); + await persistPostCommitEffects(result, settingsSnapshot); } catch (error) { console.warn('[MissionPulse] Post-commit scan effects failed:', error); } @@ -1097,7 +1097,8 @@ async function recheckConnectorHealth( } async function persistPostCommitEffects( - result: Pick + result: Pick, + settingsSnapshot: import('../lib/shell/settings-release/settings-release.contract').SettingsReleaseSnapshot ): Promise { const { missions, errors } = result; const now = Date.now(); @@ -1150,20 +1151,34 @@ async function persistPostCommitEffects( return; } - // Update badge with new mission count + // Unseen missions feed the notification filter. The icon badge must mirror + // that notifiable differential (high-score / smart-alert matches), not the + // raw unseen pool — otherwise a first scan of ~1000 missions badges "1000" + // while the Chrome notification correctly reports only ~2 new ones. const seenIds = await getSeenIds(); const seenSet = new Set(seenIds); const newMissions = missions.filter((m) => !seenSet.has(m.id)); - const newCount = newMissions.length; - if (newCount > 0) { - await setNewMissionCount(newCount); - await chrome.action.setBadgeText({ text: String(newCount) }); + const notification = + newMissions.length > 0 + ? await notifyHighScoreMissions(newMissions, settingsSnapshot) + : { shown: false, notifiedMissionIds: [], notifiableMissionIds: [] }; + + const badgeCount = notification.notifiableMissionIds.length; + if (badgeCount > 0) { + await setNewMissionCount(badgeCount); + await chrome.action.setBadgeText({ text: String(badgeCount) }); await chrome.action.setBadgeBackgroundColor({ color: '#58d9a9' }); await chrome.action.setBadgeTextColor({ color: '#ffffff' }); } else { await clearNewMissionBadge(); } + + // notifyHighScoreMissions persists its focus intent before showing Chrome's + // notification, so a fast click cannot race ahead of that write. + if (notification.shown && notification.notifiedMissionIds.length > 0) { + await saveSeenIds(markAsSeen(seenIds, notification.notifiedMissionIds)); + } } catch { // Badge projection is non-critical after commit. High-score notifications // are evaluated separately, after semantic enrichment, so fused semantic