Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions apps/extension/src/models/onboarding-workmode-location.model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Onboarding — Work-Mode & Location Fields Model

Source of truth for the onboarding profile-collection fields that capture
**where** a freelancer wants to work (location) and **how** (work mode / remote).
Companion to `profile-state.model.md` (persistence lifecycle) and
`keywords-unification.model.md` (keyword collection). Proposed change captured
here pending implementation; the `UserProfile` schema already supports every
field — this change only fixes what onboarding **collects**.

## Problem

`OnboardingWizard.svelte` conflates the two concepts today:

- A single `location` text field with placeholder `ex: Paris ou remote` mixes a
place with a work arrangement, so users type either/or and the signal is
ambiguous for the location scorer (`core/scoring/location-matching.ts`).
- `remote` is **hardcoded to `'any'`** in `handleComplete()`. The work-mode
preference is never collected at first run, even though `UserProfile.remote`
(`RemoteType | 'any'`, where `RemoteType = 'full' | 'hybrid' | 'onsite'`) and
the `remote` scoring weight (`DEFAULT_SCORING_WEIGHTS.remote = 15`) exist.

The profile edit surface (`ProfileSection.svelte`) already exposes two distinct
fields — a `profileLocation` text input and a `profileRemote` select with
options `Indifférent / Remote / Hybride / Présentiel`. Onboarding must match
that separation so first-run data feeds the same scorer with the same shape.

## Fields

| Field | Profile key | Type | Onboarding control | Default | Required |
| --------------- | ----------- | --------------------- | -------------------------- | ------- | -------- |
| Localisation | `location` | `string` | Text input (location only) | `''` | No |
| Mode de travail | `remote` | `RemoteType \| 'any'` | Segmented control (4 opts) | `'any'` | No |

Work-mode options (values and labels **identical** to `ProfileSection.remoteOptions`):

| value | label |
| ---------- | ----------- |
| `'any'` | Indifférent |
| `'full'` | Remote |
| `'hybrid'` | Hybride |
| `'onsite'` | Présentiel |

`'any'` is the explicit "no preference" value already used everywhere `remote`
is optional; it must remain the default so onboarding stays opt-in and the
existing `withProfileDefaults` fallback is unchanged.

## States (per field, local to the wizard)

```
location: untouched ('') ──input──► filled (string) ──clear──► untouched
remote: untouched ('any') ──select──► chosen (full|hybrid|onsite) ──select 'any'──► 'any'
```

Both fields are always "valid" — there is no error state for them. They are
optional and never block `canSubmit`.

## Events

| Event | Source | Effect |
| ---------------------- | ------------------- | ------------------------------------------------------------------- |
| `LOCATION_INPUT` | location text input | `location` state updated |
| `REMOTE_SELECT(value)` | segmented control | `remote` state updated to `value` |
| `SUBMIT` | "Sauvegarder" btn | build draft via `normalizeProfileDraft({ …, location, remote, … })` |

On `SUBMIT` the wizard calls `onUpdateProfile(profile)` then `onComplete(profile)`,
exactly as today — only the `remote` value stops being hardcoded.

## Data flow

```
OnboardingWizard (local $state: location, remote)
│ SUBMIT
normalizeProfileDraft({ firstName, jobTitle, location, remote, keywords, tjmMin, tjmMax, seniority })
│ (pure — already accepts `remote`, defaults to 'any')
UserProfile ──onUpdateProfile──► OnboardingPage.profileDraft ──PROFILE_UPDATED──► profile store
└──onComplete──► profile store.SUBMIT_PROFILE ──save()──► IndexedDB
scoring: relevance.ts uses location (matchLocation) + remote weight
```

No new message types, no bridge change, no schema change. `normalizeProfileDraft`
and `withProfileDefaults` already handle `remote` and `location`.

## Invariants

1. **Separation.** `location` carries a place only; `remote` carries the work
arrangement only. The location placeholder must not suggest "remote" as a
location value.
2. **Vocabulary parity.** Work-mode values/labels in onboarding are byte-for-byte
the same set as `ProfileSection.remoteOptions`. The filter bar
(`FilterBar.remoteTypes`) uses a different label set (`Full remote / Hybride /
Sur site`) because it is a mission filter, not a profile preference — that
inconsistency is intentional and out of scope here.
3. **Default safety.** A skipped or untouched work mode resolves to `'any'`;
an untouched location resolves to `''`. Both pass through
`withProfileDefaults` unchanged.
4. **No scoring change.** This change adds a collected signal; it does not alter
`relevance.ts`, `location-matching.ts`, or `DEFAULT_SCORING_WEIGHTS`.
5. **canSubmit unchanged.** Submission still requires `firstName`, `jobTitle`,
and ≥1 keyword. Work mode and location remain optional.

## Edge cases & migration

- **Existing profiles** stored with `remote: 'any'` (the only value onboarding
could ever produce): unchanged. No DB migration, no preprocessor change.
- **User selects 'Indifférent'**: `remote === 'any'` — identical to today's
hardcoded behavior; scoring treats it as "no remote preference."
- **Location left empty, work mode chosen**: valid; remote weight still applies,
location weight contributes 0 for that profile (existing behavior).
- **Browser autofill / long city names** ("Charenton-le-Pont"): the text input
must not truncate; existing input styling already handles this.
- **Keyboard selection** of the segmented control: each option is a real
`<button>` with `aria-pressed`, reachable via Tab, activatable via
Space/Enter — keyboard-first is a stated accessibility principle.

## UI mapping

- **Localisation** field: placeholder changes from `ex: Paris ou remote` to a
place-only example (e.g. `Paris, Lyon, Bordeaux…`). Label stays
`Localisation souhaitée`.
- **Mode de travail** field: new, placed immediately after Localisation
(where → how grouping). Segmented control, 4 options, full-width, default
`Indifférent`. Reuses the wizard's existing chip/selected vocabulary and
`blueprint-blue` accent so it reads as part of the same surface, not a
new component library.
- Both fields keep the existing onboarding input styling (border, focus ring,
radius) — no new design tokens.

## Out of scope

- Structured location picker (datalist / geocoding). Location stays free text;
`location-matching.ts` already normalizes synonyms and metro areas.
- Changing `ProfileSection` or `FilterBar` vocabulary.
- Adding `remote` to the connected alert preferences (alerts are TJM + stack
keyed today).
- Reordering onboarding steps or altering the 5-step tour.
18 changes: 18 additions & 0 deletions apps/extension/src/ui/constants/remote-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { RemoteType } from '$lib/core/types/mission';

export interface RemoteOption {
readonly value: RemoteType | 'any';
readonly label: string;
}

/**
* Shared work-mode options for the profile preference (onboarding + settings).
* Values map to `RemoteType | 'any'`; `'any'` means "no preference".
* Both surfaces must use this constant so labels/values stay in sync.
*/
export const REMOTE_OPTIONS: readonly RemoteOption[] = [
{ value: 'any', label: 'Indifférent' },
{ value: 'full', label: 'Remote' },
{ value: 'hybrid', label: 'Hybride' },
{ value: 'onsite', label: 'Présentiel' },
];
31 changes: 29 additions & 2 deletions apps/extension/src/ui/organisms/OnboardingWizard.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import type { UserProfile } from '$lib/core/types/profile';
import type { RemoteType } from '$lib/core/types/mission';
import { Chip } from '@pulse/ui';
import { Icon } from '@pulse/ui';
import { ripple } from '../actions/ripple';
Expand All @@ -9,6 +10,7 @@
DEFAULT_CONNECTED_ALERT_PREFERENCES,
type ConnectedAlertPreferences,
} from '$lib/core/types/alert-preferences';
import { REMOTE_OPTIONS as workModeOptions } from '../constants/remote-options';

type OnboardingStepId = 'understand' | 'source' | 'activity' | 'alert' | 'insight';

Expand Down Expand Up @@ -49,6 +51,7 @@
let firstName = $state('');
let jobTitle = $state('');
let location = $state('');
let remote = $state<RemoteType | 'any'>('any');
let keywords = $state<string[]>([]);
let keywordInput = $state('');
let tjm = $state(600);
Expand Down Expand Up @@ -110,7 +113,7 @@
keywords,
tjmMin: tjm,
tjmMax: tjm + 150,
remote: 'any',
remote,
seniority: 'senior',
});

Expand Down Expand Up @@ -448,11 +451,35 @@
id="ob-location"
type="text"
class="soft-ring w-full rounded-lg border border-border-light bg-page-canvas px-4 py-3 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-blueprint-blue/30 focus:ring-2 focus:ring-blueprint-blue/15 transition-all duration-200"
placeholder="ex: Paris ou remote"
placeholder="Paris, Lyon, Bordeaux…"
bind:value={location}
/>
</div>

<div>
<p
id="ob-workmode-label"
class="mb-2 block text-xs uppercase tracking-[0.18em] text-text-muted"
>
Mode de travail
</p>
<div class="grid grid-cols-4 gap-1.5" role="group" aria-labelledby="ob-workmode-label">
{#each workModeOptions as option}
<button
type="button"
class="min-h-11 rounded-lg border px-2 py-2.5 text-xs font-medium transition-colors {remote ===
option.value
? 'border-blueprint-blue/25 bg-blueprint-blue/8 text-blueprint-blue'
: 'border-border-light bg-page-canvas text-text-primary hover:bg-surface-white'}"
aria-pressed={remote === option.value}
onclick={() => (remote = option.value)}
>
{option.label}
</button>
{/each}
</div>
</div>

<div>
<label for="ob-tjm" class="mb-2 block text-xs uppercase tracking-[0.18em] text-text-muted"
>TJM cible (EUR/jour)</label
Expand Down
8 changes: 1 addition & 7 deletions apps/extension/src/ui/organisms/ProfileSection.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import type { RemoteType } from '$lib/core/types/mission';
import type { SeniorityLevel } from '$lib/core/types/profile';
import Tooltip from '../atoms/Tooltip.svelte';
import { REMOTE_OPTIONS as remoteOptions } from '../constants/remote-options';

/* eslint-disable prefer-const */
let {
Expand Down Expand Up @@ -46,13 +47,6 @@
} = $props();
/* eslint-enable prefer-const */

const remoteOptions: Array<{ value: RemoteType | 'any'; label: string }> = [
{ value: 'any', label: 'Indifférent' },
{ value: 'full', label: 'Remote' },
{ value: 'hybrid', label: 'Hybride' },
{ value: 'onsite', label: 'Présentiel' },
];

const seniorityOptions: Array<{ value: SeniorityLevel; label: string }> = [
{ value: 'junior', label: 'Junior' },
{ value: 'confirmed', label: 'Confirmé' },
Expand Down
2 changes: 1 addition & 1 deletion apps/extension/tests/e2e/accessibility/a11y.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ test.describe('Accessibility', () => {

// Aller jusqu'au bouton submit sans dépendre d'un nombre fixe de champs optionnels.
const submitButton = page.getByRole('button', { name: 'Sauvegarder mon profil' });
for (let i = 0; i < 8; i++) {
for (let i = 0; i < 12; i++) {
if (await submitButton.evaluate((el) => el === document.activeElement)) {
break;
}
Expand Down
Loading