SPB Git forge

spb/job-ka

Public
227commits 1branches 0releases
37.7 MBsize
maindefault branch
1 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%

Refonte visuelle majeure du front-end : design editorial premium

- Hero : halo cyan, pastille « marche en direct », recherche centrale
  (barre blanche a ombre decalee cyan) + chiffres live (offres,
  employeurs, % salaire affiche)
- Filtres : rail de selecteurs discrets sur desktop (pilules, etat
  actif encre/cyan), panneau bas coulissant sur mobile, puces de
  filtres actifs retirables + « Tout effacer »
- Offres : fini les cartes bordees — rangees editoriales a filets
  fins, titre Space Grotesk, salaire en exergue cyan a droite avec
  equivalent annuel, metadonnees calmes en mono, barre d'accent au
  survol ; favoris alignes sur la meme structure
- Fiche : mise en page ouverte (plus de grosse boite), metriques en
  blocs a filet superieur, CTA « Postuler » en accent
- Tableau employeurs allege (filets fins), pagination sobre en mono,
  menu mobile avec entree en cascade, prefers-reduced-motion respecte
- Bulle KA Agent masquee quand un panneau plein ecran est ouvert

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent 05f95e9

6 changed files +768 −245

modified frontend/src/App.tsx +5 −2
@@ -6,7 +6,7 @@
6 6 * Fichier : frontend/src/App.tsx
7 7 * Rôle : Gabarit global (en-tête + badge Groupe KA + connexion KA ID,
8 8 * navigation, routes, pied de page commun Groupe KA)
9 − * Créé : 2026-08-17 Modifié : 2026-08-17
9 + * Créé : 2026-08-17 Modifié : 2026-08-25
10 10 * =============================================================================
11 11 */
12 12 import { lazy, Suspense, useEffect, useState } from "react";
@@ -100,17 +100,20 @@ export default function App() {
100 100 setMenuOpen(false);
101 101 }, [location.pathname]);
102 102
103 − // Menu ouvert : verrou du scroll d'arrière-plan + fermeture à Escape (avec cleanup).
103 + // Menu ouvert : verrou du scroll d'arrière-plan, bulle KA Agent masquée
104 + // (z-index maximal chez elle) + fermeture à Escape (avec cleanup).
104 105 useEffect(() => {
105 106 if (!menuOpen) return;
106 107 const prev = document.body.style.overflow;
107 108 document.body.style.overflow = "hidden";
109 + document.body.classList.add("ka-hide-agent");
108 110 const onKey = (e: KeyboardEvent) => {
109 111 if (e.key === "Escape") setMenuOpen(false);
110 112 };
111 113 window.addEventListener("keydown", onKey);
112 114 return () => {
113 115 document.body.style.overflow = prev;
116 + document.body.classList.remove("ka-hide-agent");
114 117 window.removeEventListener("keydown", onKey);
115 118 };
116 119 }, [menuOpen]);
modified frontend/src/components/JobCard.tsx +48 −35
@@ -4,8 +4,10 @@
4 4 * Auteur : Simon-Pierre Boucher
5 5 * Contact : contact@spboucher.ai
6 6 * Fichier : frontend/src/components/JobCard.tsx
7 − * Rôle : Carte d'offre dans la liste — salaire avec équivalent annuel,
8 − * badges (nouveau, séniorité, exigences), fraîcheur, cœur ♥ favoris
7 + * Rôle : Rangée d'offre éditoriale — titre fort, salaire en exergue à
8 + * droite, métadonnées calmes (mode, type, séniorité, exigences),
9 + * fraîcheur, cœur ♥ favoris. Zéro carte bordée : filets fins +
10 + * barre d'accent au survol.
9 11 * Créé : 2026-08-17 Modifié : 2026-08-25
10 12 * =============================================================================
11 13 */
@@ -26,44 +28,55 @@ export default function JobCard({ job }: { job: Job }) {
26 28 const exp = job.requirements?.experience_years;
27 29 const langs = job.requirements?.languages;
28 30
31 + // métadonnées calmes, séparées par des points médians (pas de soupe de pilules)
32 + const tags: string[] = [];
33 + if (job.work_mode) tags.push(MODE_FR[job.work_mode] ?? job.work_mode);
34 + if (job.employment_type) tags.push(TYPE_FR[job.employment_type] ?? job.employment_type);
35 + if (job.seniority) tags.push(SENIORITY_FR[job.seniority] ?? job.seniority);
36 + if (typeof exp === "number") tags.push(`${exp} an${exp > 1 ? "s" : ""} d'exp.`);
37 + if (langs && langs.length === 2) tags.push("Bilingue");
38 + if (job.category) tags.push(job.category);
39 +
29 40 return (
30 − <Link to={`/emploi/${job.uid}`} className="job-card">
41 + <Link to={`/emploi/${job.uid}`} className="job-row">
31 42 <FavButton job={job} />
32 − <h3>{displayTitle(job)}</h3>
33 − <span className="employer">{job.employer}</span>
34 − {job.city ? <span className="muted"> — {job.city}</span> : null}
35 − <div className="meta">
36 − {isNew && <span className="badge new">Nouveau</span>}
37 − {job.is_direct !== false && (
38 − <span
39 − className="badge direct"
40 − title="Offre publiée à la source : page carrière de l'employeur, pas un portail"
41 − >
42 − Offre directe
43 − </span>
44 − )}
45 − {salary && (
46 − <span className="badge salary">
47 − {salary}{yearly ? <em className="approx">{yearly}</em> : null}
48 − </span>
49 − )}
50 − {job.seniority && <span className="badge seniority">{SENIORITY_FR[job.seniority] ?? job.seniority}</span>}
51 − {job.work_mode && <span className="badge mode">{MODE_FR[job.work_mode] ?? job.work_mode}</span>}
52 − {job.employment_type && <span className="badge">{TYPE_FR[job.employment_type] ?? job.employment_type}</span>}
53 − {typeof exp === "number" && (
54 − <span className="badge" title="Années d'expérience minimales exigées dans l'offre">
55 − {exp} an{exp > 1 ? "s" : ""} d'exp. min.
56 − </span>
43 + <div className="jr-main">
44 + <h3 className="jr-title">
45 + {isNew && <span className="jr-new">Nouveau</span>}
46 + {displayTitle(job)}
47 + </h3>
48 + <p className="jr-org">
49 + <span className="employer">{job.employer}</span>
50 + {job.city && <span className="jr-city">{job.city}</span>}
51 + {job.is_direct !== false && (
52 + <span
53 + className="jr-direct"
54 + title="Offre publiée à la source : page carrière de l'employeur, pas un portail"
55 + >
56 + source directe
57 + </span>
58 + )}
59 + </p>
60 + {tags.length > 0 && (
61 + <p className="jr-tags">
62 + {tags.map((t, i) => (
63 + <span key={i}>{t}</span>
64 + ))}
65 + </p>
57 66 )}
58 − {langs && langs.length === 2 && <span className="badge">Bilingue exigé</span>}
59 − {job.category && <span className="badge">{job.category}</span>}
60 67 </div>
61 − <div className="jc-foot muted">
62 − {days !== null && <span>Publiée {agoLabel(days)}</span>}
63 − {job.benefits && job.benefits.length > 0 && (
64 − <span>{job.benefits.length} avantage{job.benefits.length > 1 ? "s" : ""} listé{job.benefits.length > 1 ? "s" : ""}</span>
68 + <div className="jr-side">
69 + {salary ? (
70 + <>
71 + <span className="jr-salary">{salary}</span>
72 + {yearly && <span className="jr-yearly">{yearly}</span>}
73 + </>
74 + ) : (
75 + <span className="jr-nosalary">Salaire non affiché</span>
65 76 )}
66 − {job.ats && <span className="ats">{job.ats}</span>}
77 + <span className="jr-when">
78 + {days !== null ? `Publiée ${agoLabel(days)}` : job.ats}
79 + </span>
67 80 </div>
68 81 </Link>
69 82 );
modified frontend/src/pages/Favoris.tsx +12 −7
@@ -6,7 +6,8 @@
6 6 * Fichier : frontend/src/pages/Favoris.tsx
7 7 * Rôle : Mes favoris ♥ « Mon univers Ka » — offres mises en favoris,
8 8 * lues au hub Groupe KA (magasin central, aucun stockage local).
9 − * Créé : 2026-08-23 Modifié : 2026-08-23
9 + * Rangées éditoriales alignées sur la liste d'offres (job-row).
10 + * Créé : 2026-08-23 Modifié : 2026-08-25
10 11 * =============================================================================
11 12 */
12 13 import { Link } from "react-router-dom";
@@ -27,10 +28,14 @@ function FavCard({ f }: { f: FavItem }) {
27 28 const path = localPath(f);
28 29 const inner = (
29 30 <>
30 − <h3>{f.title}</h3>
31 − {f.subtitle && <span className="employer">{f.subtitle}</span>}
32 − <div className="meta">
33 − {f.price_label && <span className="badge salary">{f.price_label}</span>}
31 + <div className="jr-main">
32 + <h3 className="jr-title">{f.title}</h3>
33 + {f.subtitle && <p className="jr-org"><span className="employer">{f.subtitle}</span></p>}
34 + </div>
35 + <div className="jr-side">
36 + {f.price_label
37 + ? <span className="jr-salary">{f.price_label}</span>
38 + : <span className="jr-nosalary">Salaire non affiché</span>}
34 39 </div>
35 40 <button
36 41 type="button"
@@ -46,8 +51,8 @@ function FavCard({ f }: { f: FavItem }) {
46 51 </>
47 52 );
48 53 return path
49 − ? <Link to={path} className="job-card fav-card">{inner}</Link>
50 − : <a href={f.url} className="job-card fav-card" target="_blank" rel="noopener noreferrer">{inner}</a>;
54 + ? <Link to={path} className="job-row">{inner}</Link>
55 + : <a href={f.url} className="job-row" target="_blank" rel="noopener noreferrer">{inner}</a>;
51 56 }
52 57
53 58 export default function FavorisPage() {
modified frontend/src/pages/Home.tsx +294 −69
@@ -4,26 +4,121 @@
4 4 * Auteur : Simon-Pierre Boucher
5 5 * Contact : contact@spboucher.ai
6 6 * Fichier : frontend/src/pages/Home.tsx
7 − * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres
7 + * Rôle : Accueil — hero éditorial avec recherche centrale et chiffres du
8 + * marché en direct, filtres en rail discret (desktop) + panneau bas
9 + * (mobile), puces de filtres actifs, liste d'offres paginée
8 10 * Créé : 2026-08-17 Modifié : 2026-08-25
9 11 * =============================================================================
10 12 */
11 −import { useEffect, useMemo, useState } from "react";
13 +import { FormEvent, useEffect, useMemo, useState } from "react";
12 14 import { useSearchParams } from "react-router-dom";
13 −import { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR } from "../api";
15 +import {
16 + Facets, fetchFacets, fetchJobs, fetchStats, Job, LANG_FR, MODE_FR,
17 + SENIORITY_FR, Stats, TYPE_FR,
18 +} from "../api";
14 19 import JobCard from "../components/JobCard";
15 20
16 21 const PAGE = 30;
17 22
23 +/** Paramètres d'URL des filtres (hors recherche q et tri). */
24 +const FILTER_KEYS = ["ville", "region", "categorie", "mode", "type", "seniorite", "langue", "salaire"] as const;
25 +
26 +interface FilterValues {
27 + q: string;
28 + city: string;
29 + region: string;
30 + category: string;
31 + work_mode: string;
32 + employment_type: string;
33 + seniority: string;
34 + language: string;
35 + with_salary: 1 | undefined;
36 + sort: string;
37 +}
38 +
39 +/** Les 7 sélecteurs de filtres — mêmes contrôles dans le rail (desktop)
40 + * et le panneau bas (mobile), seul le style CSS change selon le contexte. */
41 +function FilterSelects({ facets, filters, setParam }: {
42 + facets: Facets | null;
43 + filters: FilterValues;
44 + setParam: (key: string, value: string) => void;
45 +}) {
46 + return (
47 + <>
48 + <select
49 + className="f-select" data-on={filters.city ? "1" : "0"} aria-label="Ville"
50 + value={filters.city} onChange={(e) => setParam("ville", e.target.value)}
51 + >
52 + <option value="">Ville</option>
53 + {facets?.cities.map((c) => <option key={c} value={c}>{c}</option>)}
54 + </select>
55 + <select
56 + className="f-select" data-on={filters.region ? "1" : "0"} aria-label="Région"
57 + value={filters.region} onChange={(e) => setParam("region", e.target.value)}
58 + >
59 + <option value="">Région</option>
60 + {facets?.regions?.map((r) => (
61 + <option key={r.region} value={r.region}>{r.region} ({r.n})</option>
62 + ))}
63 + </select>
64 + <select
65 + className="f-select" data-on={filters.category ? "1" : "0"} aria-label="Catégorie"
66 + value={filters.category} onChange={(e) => setParam("categorie", e.target.value)}
67 + >
68 + <option value="">Catégorie</option>
69 + {facets?.categories.map((c) => (
70 + <option key={c.category} value={c.category}>{c.category} ({c.n})</option>
71 + ))}
72 + </select>
73 + <select
74 + className="f-select" data-on={filters.work_mode ? "1" : "0"} aria-label="Mode de travail"
75 + value={filters.work_mode} onChange={(e) => setParam("mode", e.target.value)}
76 + >
77 + <option value="">Mode</option>
78 + {facets?.work_modes.map((m) => <option key={m} value={m}>{MODE_FR[m] ?? m}</option>)}
79 + </select>
80 + <select
81 + className="f-select" data-on={filters.employment_type ? "1" : "0"} aria-label="Type d'emploi"
82 + value={filters.employment_type} onChange={(e) => setParam("type", e.target.value)}
83 + >
84 + <option value="">Type</option>
85 + {facets?.employment_types.map((t) => <option key={t} value={t}>{TYPE_FR[t] ?? t}</option>)}
86 + </select>
87 + <select
88 + className="f-select" data-on={filters.seniority ? "1" : "0"} aria-label="Séniorité"
89 + value={filters.seniority} onChange={(e) => setParam("seniorite", e.target.value)}
90 + >
91 + <option value="">Séniorité</option>
92 + {facets?.seniorities?.map((s) => (
93 + <option key={s.seniority} value={s.seniority}>
94 + {SENIORITY_FR[s.seniority] ?? s.seniority} ({s.n})
95 + </option>
96 + ))}
97 + </select>
98 + <select
99 + className="f-select" data-on={filters.language ? "1" : "0"} aria-label="Langue"
100 + value={filters.language} onChange={(e) => setParam("langue", e.target.value)}
101 + >
102 + <option value="">Langue</option>
103 + {facets?.languages?.map((l) => (
104 + <option key={l.language} value={l.language}>{LANG_FR[l.language] ?? l.language} ({l.n})</option>
105 + ))}
106 + </select>
107 + </>
108 + );
109 +}
110 +
18 111 export default function Home() {
19 112 const [params, setParams] = useSearchParams();
20 113 const [facets, setFacets] = useState<Facets | null>(null);
114 + const [stats, setStats] = useState<Stats | null>(null);
21 115 const [jobs, setJobs] = useState<Job[]>([]);
22 116 const [total, setTotal] = useState(0);
23 117 const [loading, setLoading] = useState(true);
24 118 const [error, setError] = useState("");
119 + const [sheetOpen, setSheetOpen] = useState(false);
25 120
26 − const filters = useMemo(() => ({
121 + const filters = useMemo<FilterValues>(() => ({
27 122 q: params.get("q") ?? "",
28 123 city: params.get("ville") ?? "",
29 124 region: params.get("region") ?? "",
@@ -38,6 +133,7 @@ export default function Home() {
38 133 const page = Math.max(1, parseInt(params.get("page") ?? "1", 10));
39 134
40 135 useEffect(() => { fetchFacets().then(setFacets).catch(() => {}); }, []);
136 + useEffect(() => { fetchStats().then(setStats).catch(() => {}); }, []);
41 137
42 138 useEffect(() => {
43 139 setLoading(true);
@@ -48,6 +144,23 @@ export default function Home() {
48 144 .finally(() => setLoading(false));
49 145 }, [filters, page]);
50 146
147 + // Panneau de filtres ouvert : verrou du scroll, bulle KA Agent masquée
148 + // (z-index maximal chez elle — on la cache plutôt que de la couvrir),
149 + // fermeture à Escape.
150 + useEffect(() => {
151 + if (!sheetOpen) return;
152 + const prev = document.body.style.overflow;
153 + document.body.style.overflow = "hidden";
154 + document.body.classList.add("ka-hide-agent");
155 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); };
156 + window.addEventListener("keydown", onKey);
157 + return () => {
158 + document.body.style.overflow = prev;
159 + document.body.classList.remove("ka-hide-agent");
160 + window.removeEventListener("keydown", onKey);
161 + };
162 + }, [sheetOpen]);
163 +
51 164 const setParam = (key: string, value: string) => {
52 165 const next = new URLSearchParams(params);
53 166 if (value) next.set(key, value); else next.delete(key);
@@ -55,67 +168,81 @@ export default function Home() {
55 168 setParams(next, { replace: true });
56 169 };
57 170
171 + const resetFilters = () => {
172 + const next = new URLSearchParams(params);
173 + for (const k of FILTER_KEYS) next.delete(k);
174 + next.delete("q");
175 + next.delete("page");
176 + setParams(next, { replace: true });
177 + };
178 +
179 + const submitSearch = (e: FormEvent<HTMLFormElement>) => {
180 + e.preventDefault();
181 + const input = e.currentTarget.elements.namedItem("q") as HTMLInputElement | null;
182 + setParam("q", input?.value.trim() ?? "");
183 + };
184 +
185 + const activeCount = FILTER_KEYS.filter((k) => params.get(k)).length;
186 +
187 + // puces des filtres actifs (retrait en un geste)
188 + const chips: Array<{ key: string; label: string }> = [];
189 + if (filters.q) chips.push({ key: "q", label: `« ${filters.q} »` });
190 + if (filters.city) chips.push({ key: "ville", label: filters.city });
191 + if (filters.region) chips.push({ key: "region", label: filters.region });
192 + if (filters.category) chips.push({ key: "categorie", label: filters.category });
193 + if (filters.work_mode) chips.push({ key: "mode", label: MODE_FR[filters.work_mode] ?? filters.work_mode });
194 + if (filters.employment_type) chips.push({ key: "type", label: TYPE_FR[filters.employment_type] ?? filters.employment_type });
195 + if (filters.seniority) chips.push({ key: "seniorite", label: SENIORITY_FR[filters.seniority] ?? filters.seniority });
196 + if (filters.language) chips.push({ key: "langue", label: LANG_FR[filters.language] ?? filters.language });
197 + if (filters.with_salary === 1) chips.push({ key: "salaire", label: "Salaire affiché" });
198 +
58 199 const pages = Math.max(1, Math.ceil(total / PAGE));
200 + const pctSalary = stats && stats.total > 0
201 + ? Math.round((stats.with_salary / stats.total) * 100) : null;
59 202
60 203 return (
61 204 <>
62 205 <section className="hero">
63 206 <div className="container">
64 − <span className="kicker">Pages carrières · Zéro intermédiaire</span>
65 − <h1>L'emploi au Québec, <em>à la source</em>.</h1>
66 − <p>
67 − Job·Ka agrège les offres directement depuis les pages carrières des
68 − employeurs québécois. Salaires transparents, lien vers l'offre
69 − originale, aucun intermédiaire.
207 + <span className="kicker"><i className="pulse" aria-hidden="true" />Marché de l'emploi en direct · zéro intermédiaire</span>
208 + <h1>L'emploi au Québec,<br /><em>à la source</em>.</h1>
209 + <p className="hero-lede">
210 + Chaque offre vient de la page carrière de l'employeur — traçable,
211 + datée, avec le salaire quand il est publié.
70 212 </p>
71 − <div className="filters">
213 + <form className="searchbar" onSubmit={submitSearch} role="search">
214 + <svg viewBox="0 0 24 24" aria-hidden="true">
215 + <circle cx="11" cy="11" r="7" />
216 + <line x1="16.5" y1="16.5" x2="21" y2="21" />
217 + </svg>
72 218 <input
219 + key={filters.q}
73 220 type="search"
74 − placeholder="Titre, employeur, mots-clés…"
221 + name="q"
222 + placeholder="Poste, employeur, mot-clé…"
75 223 defaultValue={filters.q}
76 − onKeyDown={(e) => {
77 − if (e.key === "Enter") setParam("q", (e.target as HTMLInputElement).value);
78 − }}
224 + autoComplete="off"
79 225 />
80 − <select value={filters.city} onChange={(e) => setParam("ville", e.target.value)}>
81 − <option value="">Toutes les villes</option>
82 − {facets?.cities.map((c) => <option key={c} value={c}>{c}</option>)}
83 − </select>
84 − <select value={filters.region} onChange={(e) => setParam("region", e.target.value)}>
85 − <option value="">Toutes les régions</option>
86 − {facets?.regions?.map((r) => (
87 − <option key={r.region} value={r.region}>{r.region} ({r.n})</option>
88 − ))}
89 − </select>
90 − <select value={filters.category} onChange={(e) => setParam("categorie", e.target.value)}>
91 − <option value="">Toutes les catégories</option>
92 − {facets?.categories.map((c) => (
93 − <option key={c.category} value={c.category}>{c.category} ({c.n})</option>
94 − ))}
95 − </select>
96 − <select value={filters.work_mode} onChange={(e) => setParam("mode", e.target.value)}>
97 − <option value="">Tous les modes</option>
98 − {facets?.work_modes.map((m) => <option key={m} value={m}>{MODE_FR[m] ?? m}</option>)}
99 − </select>
100 − <select value={filters.employment_type} onChange={(e) => setParam("type", e.target.value)}>
101 − <option value="">Tous les types</option>
102 − {facets?.employment_types.map((t) => <option key={t} value={t}>{TYPE_FR[t] ?? t}</option>)}
103 − </select>
104 − <select value={filters.seniority} onChange={(e) => setParam("seniorite", e.target.value)}>
105 − <option value="">Toutes les séniorités</option>
106 − {facets?.seniorities?.map((s) => (
107 − <option key={s.seniority} value={s.seniority}>
108 − {SENIORITY_FR[s.seniority] ?? s.seniority} ({s.n})
109 − </option>
110 − ))}
111 − </select>
112 − <select value={filters.language} onChange={(e) => setParam("langue", e.target.value)}>
113 − <option value="">Toutes les langues</option>
114 − {facets?.languages?.map((l) => (
115 − <option key={l.language} value={l.language}>{LANG_FR[l.language] ?? l.language} ({l.n})</option>
116 − ))}
117 − </select>
118 − <label className="check">
226 + <button type="submit">Rechercher</button>
227 + </form>
228 + {stats && (
229 + <p className="hero-stats">
230 + <span><b>{stats.total.toLocaleString("fr-CA")}</b> offres en ligne</span>
231 + <span><b>{stats.employers.toLocaleString("fr-CA")}</b> employeurs</span>
232 + {pctSalary !== null && <span><b>{pctSalary} %</b> avec salaire affiché</span>}
233 + <span className="hs-sync">synchronisé jour et nuit</span>
234 + </p>
235 + )}
236 + </div>
237 + </section>
238 +
239 + <div className="filter-zone">
240 + <div className="container">
241 + {/* rail desktop : sélecteurs discrets */}
242 + <div className="filter-rail" aria-label="Filtres">
243 + <span className="fr-label">Affiner</span>
244 + <FilterSelects facets={facets} filters={filters} setParam={setParam} />
245 + <label className="f-toggle" data-on={filters.with_salary === 1 ? "1" : "0"}>
119 246 <input
120 247 type="checkbox"
121 248 checked={filters.with_salary === 1}
@@ -124,37 +251,135 @@ export default function Home() {
124 251 Salaire affiché
125 252 </label>
126 253 </div>
254 +
255 + {/* mobile : bouton panneau + bascule salaire */}
256 + <div className="filter-mobile">
257 + <button type="button" className="fm-btn" onClick={() => setSheetOpen(true)}>
258 + <svg viewBox="0 0 24 24" aria-hidden="true">
259 + <line x1="4" y1="7" x2="20" y2="7" /><circle cx="9" cy="7" r="2.4" />
260 + <line x1="4" y1="16" x2="20" y2="16" /><circle cx="15" cy="16" r="2.4" />
261 + </svg>
262 + Filtres{activeCount > 0 && <b>{activeCount}</b>}
263 + </button>
264 + <label className="f-toggle" data-on={filters.with_salary === 1 ? "1" : "0"}>
265 + <input
266 + type="checkbox"
267 + checked={filters.with_salary === 1}
268 + onChange={(e) => setParam("salaire", e.target.checked ? "1" : "")}
269 + />
270 + Salaire affiché
271 + </label>
272 + </div>
273 +
274 + {chips.length > 0 && (
275 + <div className="filter-chips">
276 + {chips.map((c) => (
277 + <button
278 + key={c.key}
279 + type="button"
280 + className="fchip"
281 + onClick={() => setParam(c.key, "")}
282 + title="Retirer ce filtre"
283 + >
284 + {c.label}<span aria-hidden="true">✕</span>
285 + </button>
286 + ))}
287 + <button type="button" className="fchip-clear" onClick={resetFilters}>
288 + Tout effacer
289 + </button>
290 + </div>
291 + )}
127 292 </div>
128 − </section>
293 + </div>
129 294
130 295 <main className="container">
131 − <p className="muted" style={{ marginTop: 18 }}>
132 − {loading ? "Chargement…" : `${total.toLocaleString("fr-CA")} offre${total > 1 ? "s" : ""} trouvée${total > 1 ? "s" : ""}`}
133 − {" · "}
134 − <select
135 − value={filters.sort}
136 − onChange={(e) => setParam("tri", e.target.value)}
137 − style={{ border: "none", background: "none", color: "inherit", font: "inherit", cursor: "pointer" }}
138 − >
139 − <option value="recent">Plus récentes d'abord</option>
140 − <option value="salary">Meilleurs salaires d'abord</option>
141 − </select>
142 − </p>
296 + <div className="results-head">
297 + <h2 className="results-count" aria-live="polite">
298 + {loading
299 + ? "Chargement…"
300 + : <>{total.toLocaleString("fr-CA")} offre{total > 1 ? "s" : ""}</>}
301 + </h2>
302 + <label className="sort-quiet">
303 + Trier :
304 + <select value={filters.sort} onChange={(e) => setParam("tri", e.target.value)}>
305 + <option value="recent">plus récentes</option>
306 + <option value="salary">meilleurs salaires</option>
307 + </select>
308 + </label>
309 + </div>
143 310 {error && <div className="empty">Erreur de chargement : {error}</div>}
144 311 <div className="job-list">
145 312 {jobs.map((j) => <JobCard key={j.uid} job={j} />)}
146 313 </div>
147 314 {!loading && jobs.length === 0 && !error && (
148 − <div className="empty">Aucune offre ne correspond à ces filtres.</div>
315 + <div className="empty">
316 + <p>Aucune offre ne correspond à ces filtres.</p>
317 + {chips.length > 0 && (
318 + <button type="button" className="btn" onClick={resetFilters}>Tout effacer</button>
319 + )}
320 + </div>
149 321 )}
150 322 {pages > 1 && (
151 323 <div className="pager">
152 324 <button disabled={page <= 1} onClick={() => setParam("page", String(page - 1))}>← Précédent</button>
153 − <button disabled>{page} / {pages}</button>
325 + <span className="pg-pos">{page} / {pages}</span>
154 326 <button disabled={page >= pages} onClick={() => setParam("page", String(page + 1))}>Suivant →</button>
155 327 </div>
156 328 )}
157 329 </main>
330 +
331 + {/* panneau bas mobile — rendu en continu pour la transition de sortie */}
332 + {sheetOpen && <div className="ka-overlay" onClick={() => setSheetOpen(false)} />}
333 + <div
334 + className={sheetOpen ? "fsheet open" : "fsheet"}
335 + role="dialog"
336 + aria-modal="true"
337 + aria-label="Filtrer les offres"
338 + aria-hidden={!sheetOpen}
339 + >
340 + <div className="fs-head">
341 + <span className="klabel">Affiner la recherche</span>
342 + <button
343 + type="button"
344 + className="fs-close"
345 + aria-label="Fermer les filtres"
346 + onClick={() => setSheetOpen(false)}
347 + tabIndex={sheetOpen ? undefined : -1}
348 + >
349 + ✕
350 + </button>
351 + </div>
352 + <div className="fs-body">
353 + <FilterSelects facets={facets} filters={filters} setParam={setParam} />
354 + <label className="f-toggle" data-on={filters.with_salary === 1 ? "1" : "0"}>
355 + <input
356 + type="checkbox"
357 + checked={filters.with_salary === 1}
358 + onChange={(e) => setParam("salaire", e.target.checked ? "1" : "")}
359 + tabIndex={sheetOpen ? undefined : -1}
360 + />
361 + Salaire affiché seulement
362 + </label>
363 + </div>
364 + <div className="fs-foot">
365 + <button
366 + type="button"
367 + className="btn btn-ghost"
368 + onClick={resetFilters}
369 + tabIndex={sheetOpen ? undefined : -1}
370 + >
371 + Réinitialiser
372 + </button>
373 + <button
374 + type="button"
375 + className="btn btn-accent"
376 + onClick={() => setSheetOpen(false)}
377 + tabIndex={sheetOpen ? undefined : -1}
378 + >
379 + Voir {loading ? "les" : total.toLocaleString("fr-CA")} offre{total > 1 ? "s" : ""}
380 + </button>
381 + </div>
382 + </div>
158 383 </>
159 384 );
160 385 }
modified frontend/src/pages/Job.tsx +2 −2
@@ -88,7 +88,7 @@ export default function JobPage() {
88 88 <FavButton job={job} big />
89 89 </div>
90 90 <p style={{ margin: "6px 0 0" }}>
91 − <span className="employer" style={{ color: "var(--green)", fontWeight: 600 }}>{job.employer}</span>
91 + <span className="employer" style={{ color: "var(--accent-deep)", fontWeight: 600 }}>{job.employer}</span>
92 92 {typeof job.employer_jobs === "number" && job.employer_jobs > 1 && (
93 93 <span className="muted"> · {job.employer_jobs} offres actives</span>
94 94 )}
@@ -172,7 +172,7 @@ export default function JobPage() {
172 172 </div>
173 173
174 174 <p>
175 − <a className="btn" href={job.apply_url || job.url} target="_blank" rel="noopener noreferrer">
175 + <a className="btn btn-apply" href={job.apply_url || job.url} target="_blank" rel="noopener noreferrer">
176 176 Postuler chez {job.employer} ↗
177 177 </a>
178 178 </p>
modified frontend/src/styles.css +407 −130
@@ -5,12 +5,12 @@ Auteur : Simon-Pierre Boucher
5 5 Contact : contact@spboucher.ai
6 6 Fichier : frontend/src/styles.css
7 7 Rôle : CSS local Job·Ka — s'appuie sur le design system commun Groupe KA
8 − (ka/tokens.css, importé AVANT ce fichier dans main.tsx). Ici :
9 − seulement l'accent de la marque + les classes métier (offres,
10 − filtres, carte) au style « éditorial sharp » du groupe (bordures
11 − encre 1,5 px + ombres décalées dures, étiquettes mono). Le socle
12 − (body, grain, sélection, .container, .btn, .card, .chip,
13 − ka-footer…) vit dans tokens.css.
8 + (ka/tokens.css, importé AVANT ce fichier dans main.tsx). Refonte
9 + « éditoriale premium » 2026-08-25 : moins de boîtes bordées, plus
10 + de filets fins et de hiérarchie typographique. Hero avec recherche
11 + centrale, filtres en rail discret + panneau bas mobile, offres en
12 + rangées éditoriales (salaire en exergue). Le socle (body, grain,
13 + .container, .btn, .card, .chip, ka-footer…) vit dans tokens.css.
14 14 Créé : 2026-08-17 Modifié : 2026-08-25
15 15 =============================================================================
16 16 */
@@ -24,7 +24,7 @@ Créé : 2026-08-17 Modifié : 2026-08-25
24 24 --lime: var(--accent);
25 25 --lime-soft: var(--accent-soft);
26 26
27 − /* ---- alias hérités du CSS v1 (classes métier ci-dessous) ---- */
27 + /* ---- alias hérités du CSS v1 ---- */
28 28 --brand: var(--ink);
29 29 --border: var(--line);
30 30 --green-soft: #e7f2ec;
@@ -86,8 +86,7 @@ nav.main a.active { color: var(--accent-soft); background: rgb(255 255 255 / 10%
86 86 }
87 87 .auth-user button:hover { color: #fff; text-decoration: underline; }
88 88
89 −/* bascule mobile du header : sous 980 px la nav passe sur sa propre rangée
90 − (défilement horizontal contenu — jamais de débordement de page) */
89 +/* bascule mobile du header : sous 980 px la nav passe sur sa propre rangée */
91 90 @media (max-width: 979px) {
92 91 nav.main {
93 92 order: 10; flex-basis: 100%; margin-left: 0;
@@ -105,9 +104,8 @@ nav.main a.active { color: var(--accent-soft); background: rgb(255 255 255 / 10%
105 104 }
106 105
107 106 /* --- menu mobile (hamburger) -------------------------------------------------
108 − Sous 700 px, la nav en flex-wrap (2-3 lignes) est remplacée par un bouton
109 − 44×44 + panneau déroulant sous le header ; backdrop fixe rendu HORS du
110 − header (frère dans App.tsx), verrou de scroll géré côté React. */
107 + Sous 700 px, panneau PLEIN ÉCRAN fixed inset:0 (KA Nav v2, 2026-08-25),
108 + backdrop fixe rendu HORS du header, verrou de scroll géré côté React. */
111 109 .menu-btn {
112 110 display: none; width: 44px; height: 44px; margin-left: 4px;
113 111 flex-direction: column; align-items: center; justify-content: center; gap: 5px;
@@ -122,10 +120,6 @@ nav.main a.active { color: var(--accent-soft); background: rgb(255 255 255 / 10%
122 120 .menu-btn[aria-expanded="true"] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
123 121 .menu-btn[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
124 122 .menu-btn[aria-expanded="true"] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
125 −/* KA Nav v2 (2026-08-25) : panneau PLEIN ÉCRAN en position:fixed inset:0 —
126 − visible peu importe la position de scroll (l'ancien dropdown absolute 480px
127 − se dessinait sous le header). Le rang du header (marque + burger→✕) reste
128 − au-dessus du panneau via .menu-open .container. */
129 123 .mobile-menu {
130 124 display: none; position: fixed; inset: 0;
131 125 background: var(--brand);
@@ -135,10 +129,8 @@ nav.main a.active { color: var(--accent-soft); background: rgb(255 255 255 / 10%
135 129 }
136 130 .mobile-menu.open { animation: mm-in 0.16s ease; }
137 131 @keyframes mm-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
138 −/* burger masqué à l'ouverture : la fermeture passe par le ✕ fixe du panneau
139 − (le burger vit dans le header — hors écran si le header n'est pas épinglé) */
132 +/* burger masqué à l'ouverture : la fermeture passe par le ✕ fixe du panneau */
140 133 header.site.menu-open .menu-btn { visibility: hidden; }
141 −/* bouton de fermeture du panneau : fixe en haut à droite du viewport */
142 134 .mm-close {
143 135 position: fixed; top: 10px; right: 14px; z-index: 2;
144 136 width: 44px; height: 44px; display: flex; align-items: center; justify-content: center;
@@ -157,6 +149,16 @@ header.site.menu-open .menu-btn { visibility: hidden; }
157 149 font-family: var(--font-mono, ui-monospace, monospace); font-size: 11px;
158 150 font-weight: 700; color: var(--accent-soft);
159 151 }
152 +/* entrée en cascade des liens : le panneau prend vie sans devenir lent */
153 +.mobile-menu.open a { animation: mm-link 0.32s cubic-bezier(0.22, 0.7, 0.3, 1) both; }
154 +.mobile-menu.open a:nth-child(2) { animation-delay: 0.03s; }
155 +.mobile-menu.open a:nth-child(3) { animation-delay: 0.06s; }
156 +.mobile-menu.open a:nth-child(4) { animation-delay: 0.09s; }
157 +.mobile-menu.open a:nth-child(5) { animation-delay: 0.12s; }
158 +.mobile-menu.open a:nth-child(6) { animation-delay: 0.15s; }
159 +.mobile-menu.open a:nth-child(7) { animation-delay: 0.18s; }
160 +.mobile-menu.open a:nth-child(8) { animation-delay: 0.21s; }
161 +@keyframes mm-link { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
160 162 .mobile-menu a:last-child { border-bottom: 0; }
161 163 .mobile-menu a:hover { color: #fff; text-decoration: none; }
162 164 .mobile-menu a.active { color: var(--accent-soft); }
@@ -170,89 +172,381 @@ header.site.menu-open .menu-btn { visibility: hidden; }
170 172 .mobile-menu.open { display: flex; }
171 173 }
172 174
173 −/* --- héros (encre — identité Job·Ka) ---------------------------------------- */
174 −.hero { background: var(--brand); color: #fff; padding: 42px 0 36px; }
175 +/* --- héros éditorial (encre + halo cyan, recherche centrale) ------------------ */
176 +.hero {
177 + background:
178 + radial-gradient(680px 340px at 85% -60px, rgb(12 133 153 / 28%), transparent 70%),
179 + var(--brand);
180 + color: #fff;
181 + padding: 54px 0 46px;
182 +}
175 183 .hero .kicker { color: var(--accent-soft); }
176 −.hero .kicker::before { background: var(--accent); }
177 −.hero h1 { margin: 6px 0 8px; font-size: clamp(28px, 2.6vw + 16px, 42px); font-weight: 700; letter-spacing: -0.03em; color: #fff; line-height: 1.08; }
184 +.hero .kicker::before { display: none; }
185 +.hero .kicker .pulse {
186 + width: 8px; height: 8px; border-radius: 50%; background: var(--accent);
187 + box-shadow: 0 0 0 0 rgb(12 133 153 / 55%);
188 + animation: pulse 2.2s ease-out infinite;
189 +}
190 +@keyframes pulse {
191 + 0% { box-shadow: 0 0 0 0 rgb(12 133 153 / 55%); }
192 + 70% { box-shadow: 0 0 0 9px rgb(12 133 153 / 0%); }
193 + 100% { box-shadow: 0 0 0 0 rgb(12 133 153 / 0%); }
194 +}
195 +.hero h1 {
196 + margin: 14px 0 10px;
197 + font-size: clamp(32px, 3.4vw + 16px, 54px);
198 + font-weight: 700; letter-spacing: -0.035em; color: #fff; line-height: 1.04;
199 +}
178 200 .hero h1 em {
179 201 /* surligné d'accent incliné — signature du groupe (.hl), déclinaison encre */
180 202 display: inline-block; font-style: normal; background: var(--accent);
181 − color: var(--on-accent); border-radius: 8px; padding: 0 10px 2px;
203 + color: var(--on-accent); border-radius: 10px; padding: 0 14px 3px;
182 204 transform: rotate(-1deg);
183 205 }
184 −.hero p { margin: 0 0 22px; color: #b9c2be; max-width: 640px; }
206 +.hero-lede { margin: 0 0 26px; color: #b9c2be; max-width: 560px; font-size: 15.5px; }
185 207
186 −/* --- filtres (champs sharp : bordure encre + focus accent décalé) ------------ */
187 −.filters { display: flex; flex-wrap: wrap; gap: 10px; }
188 −.filters input[type="search"], .filters select {
189 − height: 44px; padding: 0 12px; border-radius: var(--r-ctl);
190 − border: 1.5px solid var(--ink);
191 − background: var(--surface); color: var(--ink); font: inherit; min-width: 130px;
208 +/* barre de recherche centrale — LA porte d'entrée du site */
209 +.searchbar {
210 + display: flex; align-items: center; gap: 4px;
211 + max-width: 720px; min-height: 62px; padding: 6px 6px 6px 18px;
212 + background: var(--surface); border: 2px solid var(--ink);
213 + border-radius: 14px;
214 + box-shadow: 8px 8px 0 rgb(12 133 153 / 32%);
215 + transition: box-shadow 0.18s, transform 0.18s;
216 +}
217 +.searchbar:focus-within {
218 + box-shadow: 8px 8px 0 var(--accent);
219 + transform: translate(-1px, -1px);
220 +}
221 +.searchbar svg {
222 + width: 21px; height: 21px; flex-shrink: 0;
223 + fill: none; stroke: var(--ink-3); stroke-width: 2.2; stroke-linecap: round;
224 +}
225 +.searchbar input {
226 + flex: 1; min-width: 0; border: 0; background: none; padding: 10px 12px;
227 + font: 500 17px/1.3 var(--font-body); color: var(--ink);
228 +}
229 +.searchbar input:focus { outline: none; }
230 +.searchbar input::placeholder { color: var(--ink-3); }
231 +.searchbar input::-webkit-search-cancel-button { -webkit-appearance: none; }
232 +.searchbar button {
233 + flex-shrink: 0; min-height: 48px; padding: 0 22px;
234 + border: 0; border-radius: 9px; cursor: pointer;
235 + background: var(--accent); color: var(--on-accent);
236 + font-family: var(--font-display); font-weight: 700; font-size: 15px;
237 + letter-spacing: -0.01em;
238 + transition: background 0.15s;
239 +}
240 +.searchbar button:hover { background: var(--accent-deep); }
241 +
242 +/* chiffres du marché en direct sous la recherche */
243 +.hero-stats {
244 + display: flex; flex-wrap: wrap; gap: 6px 26px; margin: 22px 0 0;
245 + font-family: var(--font-mono); font-size: 11px; font-weight: 500;
246 + text-transform: uppercase; letter-spacing: 0.08em; color: #8f9a95;
192 247 }
193 −.filters input[type="search"]:focus, .filters select:focus {
194 − outline: none; box-shadow: 3px 3px 0 var(--accent);
248 +.hero-stats b {
249 + color: var(--accent-soft); font-weight: 700; font-size: 13px;
250 + font-variant-numeric: tabular-nums; letter-spacing: 0;
251 +}
252 +.hero-stats .hs-sync { opacity: 0.65; }
253 +@media (max-width: 700px) {
254 + .hero { padding: 40px 0 36px; }
255 + .searchbar { min-height: 56px; padding-left: 14px; border-radius: 12px; }
256 + .searchbar button { padding: 0 16px; font-size: 14px; min-height: 44px; }
257 + .hero-stats { gap: 4px 18px; }
258 +}
259 +
260 +/* --- zone de filtres sous le héros -------------------------------------------
261 + Desktop : rail de sélecteurs discrets (pas de formulaire lourd).
262 + Mobile : bouton « Filtres » → panneau bas + bascule salaire. */
263 +.filter-zone {
264 + background: var(--paper);
265 + border-bottom: 1px solid var(--line);
266 + padding: 12px 0 14px;
267 +}
268 +.filter-rail { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
269 +.fr-label {
270 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
271 + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);
272 + margin-right: 4px;
273 +}
274 +.f-select {
275 + appearance: none; -webkit-appearance: none;
276 + max-width: 168px; height: 38px; padding: 0 28px 0 13px;
277 + border: 1px solid var(--line-strong); border-radius: var(--r-pill);
278 + background: transparent no-repeat right 11px center;
279 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%234d5551' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E");
280 + font: 600 13px/1 var(--font-body); color: var(--ink-2);
281 + white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
282 + cursor: pointer; transition: border-color 0.12s, background-color 0.12s;
283 +}
284 +.f-select:hover { border-color: var(--ink); color: var(--ink); }
285 +.f-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgb(12 133 153 / 15%); }
286 +.f-select[data-on="1"] {
287 + background-color: var(--ink); border-color: var(--ink); color: #f5f3ee;
288 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23def0f4' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E");
289 +}
290 +.f-toggle {
291 + display: inline-flex; align-items: center; gap: 8px;
292 + height: 38px; padding: 0 14px; cursor: pointer; user-select: none;
293 + border: 1px solid var(--line-strong); border-radius: var(--r-pill);
294 + font: 600 13px/1 var(--font-body); color: var(--ink-2); white-space: nowrap;
295 + transition: border-color 0.12s, background-color 0.12s;
296 +}
297 +.f-toggle:hover { border-color: var(--ink); color: var(--ink); }
298 +.f-toggle input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; }
299 +.f-toggle[data-on="1"] { background: var(--accent); border-color: var(--accent-deep); color: var(--on-accent); }
300 +.f-toggle[data-on="1"] input { accent-color: #fff; }
301 +
302 +/* mobile : bouton d'ouverture du panneau */
303 +.filter-mobile { display: none; gap: 8px; align-items: center; }
304 +.fm-btn {
305 + display: inline-flex; align-items: center; gap: 9px;
306 + height: 44px; padding: 0 16px; cursor: pointer;
307 + border: 1.5px solid var(--ink); border-radius: var(--r-pill);
308 + background: var(--surface); color: var(--ink);
309 + font-family: var(--font-display); font-weight: 700; font-size: 14px;
195 310 }
196 −.filters input[type="search"] { flex: 1 1 220px; }
197 −.filters input[type="search"]::placeholder { color: var(--ink-3); }
198 −.filters label.check {
199 − display: inline-flex; align-items: center; gap: 7px; background: var(--surface);
200 − color: var(--ink); /* le héros est blanc sur encre — ne pas hériter */
201 − border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 0 14px;
202 − height: 44px; cursor: pointer; user-select: none; white-space: nowrap;
311 +.fm-btn svg {
312 + width: 17px; height: 17px; fill: none; stroke: currentColor;
313 + stroke-width: 1.8; stroke-linecap: round;
314 +}
315 +.fm-btn svg circle { fill: var(--surface); }
316 +.fm-btn b {
317 + display: inline-flex; align-items: center; justify-content: center;
318 + min-width: 20px; height: 20px; padding: 0 5px; border-radius: 999px;
319 + background: var(--accent); color: var(--on-accent);
320 + font-family: var(--font-mono); font-size: 11px;
321 +}
322 +@media (max-width: 899px) {
323 + .filter-rail { display: none; }
324 + .filter-mobile { display: flex; }
325 + .filter-mobile .f-toggle { height: 44px; }
326 +}
327 +
328 +/* puces des filtres actifs — retrait en un geste */
329 +.filter-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: 11px; }
330 +.fchip {
331 + display: inline-flex; align-items: center; gap: 8px;
332 + min-height: 30px; padding: 3px 11px; cursor: pointer;
333 + border: 1px solid var(--accent); border-radius: var(--r-pill);
334 + background: var(--accent-soft); color: var(--accent-deep);
335 + font-family: var(--font-mono); font-size: 11px; font-weight: 700;
336 + letter-spacing: 0.03em;
337 +}
338 +.fchip span { font-size: 10px; opacity: 0.7; }
339 +.fchip:hover { background: var(--accent); color: var(--on-accent); }
340 +.fchip:hover span { opacity: 1; }
341 +.fchip-clear {
342 + border: 0; background: none; cursor: pointer; min-height: 30px; padding: 0 6px;
343 + font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;
344 + text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3);
345 + text-decoration: underline; text-underline-offset: 3px;
346 +}
347 +.fchip-clear:hover { color: var(--ink); }
348 +
349 +/* --- panneau bas de filtres (mobile) ------------------------------------------ */
350 +.fsheet {
351 + position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);
352 + display: flex; flex-direction: column;
353 + max-height: 88vh; max-height: 88dvh;
354 + background: var(--surface);
355 + border-top: 2px solid var(--ink);
356 + border-radius: 18px 18px 0 0;
357 + transform: translateY(103%); visibility: hidden;
358 + transition: transform 0.28s cubic-bezier(0.32, 0.72, 0.28, 1), visibility 0.28s;
359 +}
360 +.fsheet.open { transform: none; visibility: visible; }
361 +.fs-head {
362 + display: flex; align-items: center; justify-content: space-between;
363 + padding: 15px 12px 13px 20px; border-bottom: 1px solid var(--line);
364 +}
365 +.fs-close {
366 + width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;
367 + border: 1px solid var(--line-strong); border-radius: 999px; background: none;
368 + color: var(--ink); font-size: 15px; font-weight: 700; cursor: pointer; padding: 0;
369 +}
370 +.fs-body {
371 + padding: 16px 20px; overflow-y: auto; -webkit-overflow-scrolling: touch;
372 + overscroll-behavior: contain; display: grid; gap: 10px;
373 +}
374 +.fs-body .f-select {
375 + max-width: none; width: 100%; height: 48px; border-radius: 10px;
376 + font-size: 16px; background-color: var(--surface-2);
377 + border-color: var(--line-strong);
378 +}
379 +.fs-body .f-select[data-on="1"] { background-color: var(--ink); border-color: var(--ink); }
380 +.fs-body .f-toggle { height: 48px; border-radius: 10px; font-size: 16px; justify-content: flex-start; }
381 +.fs-foot {
382 + display: flex; gap: 10px; padding: 13px 20px calc(15px + env(safe-area-inset-bottom));
383 + border-top: 1px solid var(--line);
384 +}
385 +.fs-foot .btn { flex: 1; }
386 +@media (min-width: 900px) { .fsheet { display: none; } }
387 +
388 +/* --- tête de résultats --------------------------------------------------------- */
389 +.results-head {
390 + display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between;
391 + gap: 6px 16px; margin: 26px 0 10px;
392 +}
393 +.results-count {
394 + margin: 0; font-family: var(--font-display); font-weight: 700;
395 + font-size: clamp(19px, 1.4vw + 13px, 24px); letter-spacing: -0.02em;
396 + font-variant-numeric: tabular-nums;
397 +}
398 +.sort-quiet {
399 + display: inline-flex; align-items: center; gap: 6px;
400 + font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;
401 + text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-3);
203 402 }
204 −.filters label.check:has(input:checked) {
205 − background: var(--accent-soft); box-shadow: 3px 3px 0 var(--accent);
403 +.sort-quiet select {
404 + border: 0; background: none; cursor: pointer; padding: 8px 0;
405 + font-family: var(--font-mono); font-size: 11px; font-weight: 700;
406 + text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink);
407 + text-decoration: underline; text-underline-offset: 3px;
206 408 }
207 −/* champs >=16px sur mobile : évite le zoom automatique d'iOS au focus */
409 +.sort-quiet select:focus { outline: none; color: var(--accent-deep); }
410 +
411 +/* --- liste d'offres : rangées éditoriales (fini les cartes bordées) ------------- */
412 +.job-list { margin: 0 0 30px; border-top: 2px solid var(--ink); }
413 +.job-row {
414 + position: relative;
415 + display: grid; grid-template-columns: minmax(0, 1fr) auto auto;
416 + grid-template-areas: "main side fav";
417 + gap: 2px 26px; align-items: start;
418 + padding: 19px 10px 19px 18px;
419 + border-bottom: 1px solid var(--line);
420 + color: inherit; text-decoration: none;
421 + transition: background 0.14s;
422 +}
423 +.job-row .jr-main { grid-area: main; }
424 +.job-row .jr-side { grid-area: side; }
425 +.job-row .fav-btn { grid-area: fav; }
426 +.job-row::before {
427 + content: ""; position: absolute; left: 0; top: -1px; bottom: -1px; width: 3px;
428 + background: var(--accent); transform: scaleY(0); transform-origin: top;
429 + transition: transform 0.16s ease;
430 +}
431 +.job-row:hover { background: var(--surface); text-decoration: none; }
432 +.job-row:hover::before { transform: scaleY(1); }
433 +.jr-main { min-width: 0; }
434 +.jr-title {
435 + margin: 0; font-family: var(--font-display); font-size: 18px; font-weight: 700;
436 + letter-spacing: -0.02em; line-height: 1.22; color: var(--ink);
437 +}
438 +.job-row:hover .jr-title {
439 + text-decoration: underline; text-decoration-color: var(--accent);
440 + text-decoration-thickness: 2px; text-underline-offset: 3px;
441 +}
442 +.jr-new {
443 + display: inline-block; vertical-align: 2px; margin-right: 9px;
444 + padding: 2px 8px 3px; border-radius: 5px;
445 + background: var(--accent); color: var(--on-accent);
446 + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
447 + text-transform: uppercase; letter-spacing: 0.07em;
448 +}
449 +.jr-org { margin: 5px 0 0; font-size: 14px; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; }
450 +.jr-org .employer { font-weight: 600; color: var(--ink); }
451 +.jr-city { color: var(--ink-3); }
452 +.jr-city::before { content: "· "; }
453 +.jr-direct {
454 + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
455 + text-transform: uppercase; letter-spacing: 0.07em; color: var(--accent-deep);
456 +}
457 +.jr-direct::before { content: "✓ "; }
458 +.jr-tags {
459 + margin: 7px 0 0; display: flex; flex-wrap: wrap; gap: 3px 7px;
460 + font-family: var(--font-mono); font-size: 10.5px; font-weight: 500;
461 + text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink-3);
462 +}
463 +.jr-tags span:not(:last-child)::after { content: " ·"; color: var(--line-strong); }
464 +.jr-side {
465 + display: flex; flex-direction: column; align-items: flex-end; gap: 3px;
466 + text-align: right; padding-top: 2px;
467 +}
468 +.jr-salary {
469 + font-family: var(--font-display); font-weight: 700; font-size: 16.5px;
470 + letter-spacing: -0.01em; color: var(--accent-deep);
471 + font-variant-numeric: tabular-nums; white-space: nowrap;
472 +}
473 +.jr-yearly { font-size: 12px; color: var(--ink-3); white-space: nowrap; }
474 +.jr-nosalary {
475 + font-family: var(--font-mono); font-size: 10px; font-weight: 500;
476 + text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3);
477 + padding-top: 4px;
478 +}
479 +.jr-when { margin-top: 4px; font-size: 12px; color: var(--ink-3); white-space: nowrap; }
208 480 @media (max-width: 700px) {
209 − .filters input[type="search"], .filters select, .filters label.check { font-size: 16px; }
481 + .job-row {
482 + grid-template-columns: minmax(0, 1fr) auto;
483 + grid-template-areas: "main fav" "side side";
484 + padding: 17px 6px 17px 14px;
485 + }
486 + .jr-side {
487 + grid-column: 1 / -1; flex-direction: row; align-items: baseline;
488 + flex-wrap: wrap; gap: 3px 14px; text-align: left; margin-top: 9px; padding-top: 0;
489 + }
490 + .jr-when { margin: 0 0 0 auto; }
491 + .jr-salary { font-size: 15.5px; }
210 492 }
211 493
212 −/* --- cartes d'offres (sharp : bordure encre + ombre décalée) --------------------- */
213 −.job-list { display: grid; gap: 14px; margin: 22px 0; }
214 −.job-card {
215 − display: block; background: var(--surface); border: 1.5px solid var(--ink);
216 − border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
217 − padding: 18px 20px; color: inherit;
218 − transition: transform 0.15s, box-shadow 0.15s;
494 +/* cœur ♥ favoris — discret dans la rangée, plein quand actif */
495 +.fav-btn {
496 + width: 38px; height: 38px; padding: 0; flex-shrink: 0;
497 + display: inline-flex; align-items: center; justify-content: center;
498 + border: 1px solid var(--line-strong); border-radius: 999px; cursor: pointer;
499 + background: transparent; color: var(--ink-2);
500 + transition: transform 0.12s ease, background 0.12s ease, border-color 0.12s ease;
219 501 }
220 −.job-card:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); text-decoration: none; }
221 −.job-card h3 { margin: 0 0 2px; font-family: var(--font-display); font-size: 17.5px; font-weight: 700; color: var(--ink); letter-spacing: -0.02em; }
222 −.job-card:hover h3 { text-decoration: underline; text-decoration-color: var(--accent); text-decoration-thickness: 2px; text-underline-offset: 3px; }
223 −.job-card .employer { color: var(--accent-deep); font-weight: 600; }
224 −.job-card .meta { color: var(--ink-3); font-size: 13px; margin-top: 8px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }
502 +.fav-btn svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; }
503 +.fav-btn:hover { transform: scale(1.08); background: var(--accent-soft); border-color: var(--accent); color: var(--accent-deep); }
504 +.fav-btn.on { background: var(--accent); border-color: var(--accent-deep); color: var(--on-accent); }
505 +.fav-btn.on svg { fill: currentColor; }
506 +/* fiche d'offre : le cœur à droite du titre */
507 +.fav-btn.big { width: 44px; height: 44px; }
508 +.fav-btn.big svg { width: 21px; height: 21px; }
225 509
226 −/* étiquettes façon .chip du groupe : mono, uppercase, pilule bordée */
510 +/* étiquettes de la fiche : mono, calmes, filets fins */
227 511 .badge {
228 512 display: inline-flex; align-items: center; gap: 5px;
229 513 padding: 3px 10px; border-radius: var(--r-pill);
230 514 font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;
231 515 text-transform: uppercase; letter-spacing: 0.05em;
232 516 background: var(--surface-2); color: var(--ink-2);
233 − border: 1.5px solid var(--line);
517 + border: 1px solid var(--line);
234 518 }
235 519 .badge.salary { background: var(--green-soft); color: var(--green); border-color: var(--green); }
236 520 .badge.mode { background: var(--accent-soft); color: var(--accent-deep); border-color: var(--line); }
237 521 .badge.direct { background: transparent; border-color: var(--accent); color: var(--accent-deep); }
238 522 .badge.new { background: var(--accent); color: var(--on-accent); border-color: var(--accent-deep); }
239 −.badge.seniority { background: var(--surface-2); color: var(--ink); border-color: var(--ink); }
523 +.badge.seniority { background: var(--surface-2); color: var(--ink); border-color: var(--line-strong); }
240 524 .badge.salary .approx { font-style: normal; font-weight: 600; opacity: 0.75; margin-left: 5px; text-transform: none; letter-spacing: 0; }
241 525
242 −/* pied de carte : fraîcheur · avantages · plateforme source */
243 −.jc-foot { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12.5px; }
244 −.jc-foot .ats { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.8; }
526 +/* --- fiche d'offre : mise en page éditoriale ouverte (fini la grosse boîte) ----- */
527 +.sheet { padding: 34px 0 10px; margin: 0 0 20px; }
528 +.sheet h1 {
529 + margin: 0; font-size: clamp(26px, 2.6vw + 14px, 40px);
530 + letter-spacing: -0.03em; line-height: 1.08;
531 +}
532 +.sheet .facts { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0 8px; }
533 +.sheet .description {
534 + white-space: pre-line; color: var(--ink-2);
535 + max-width: 760px; font-size: 15.5px; line-height: 1.62;
536 +}
537 +.sheet .btn-apply {
538 + min-height: 52px; padding: 12px 26px; font-size: 15.5px;
539 + background: var(--accent); color: var(--on-accent); border-color: var(--accent-deep);
540 +}
541 +.sheet .btn-apply:hover { background: var(--accent-deep); }
245 542
246 −/* --- métriques « En un coup d'œil » (fiche d'offre) -------------------------- */
543 +/* métriques « En un coup d'œil » : blocs ouverts à filet supérieur (éditorial) */
247 544 .metrics {
248 − display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
249 − gap: 12px; margin: 12px 0 22px;
545 + display: grid; grid-template-columns: repeat(auto-fit, minmax(215px, 1fr));
546 + gap: 18px 26px; margin: 14px 0 26px;
250 547 }
251 −.metric {
252 − background: var(--surface-2); border: 1.5px solid var(--line);
253 − border-radius: var(--r-card); padding: 13px 15px;
254 −}
255 −.metric .m-value { font-family: var(--font-display); font-size: 19px; font-weight: 700; letter-spacing: -0.02em; }
548 +.metric { border-top: 2px solid var(--ink); padding: 11px 2px 0; }
549 +.metric .m-value { font-family: var(--font-display); font-size: 20px; font-weight: 700; letter-spacing: -0.02em; }
256 550 .metric .m-label { color: var(--ink-3); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin-top: 3px; }
257 551 .metric .m-sub { color: var(--ink-3); font-size: 12px; margin-top: 6px; line-height: 1.45; }
258 552 .delta-up { color: var(--green); }
@@ -268,25 +562,11 @@ header.site.menu-open .menu-btn { visibility: hidden; }
268 562 .salary-history { margin: 8px 0 0; padding-left: 18px; }
269 563 .salary-history li { margin: 3px 0; font-size: 14px; }
270 564
271 −/* --- fiche ------------------------------------------------------------------------- */
272 −.sheet {
273 − background: var(--surface); border: 1.5px solid var(--ink);
274 − border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
275 − padding: 28px 32px; margin: 24px 0;
276 −}
277 −.sheet h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.3px; }
278 −.sheet .facts { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0 20px; }
279 −.sheet .description { white-space: pre-line; color: var(--ink-2); }
280 −
281 −/* --- tuiles de statistiques (hero numbers) --------------------------------------- */
282 −.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin: 22px 0; }
283 −.tile {
284 − background: var(--surface); border: 1.5px solid var(--ink);
285 − border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
286 − padding: 18px 20px; position: relative; overflow: hidden;
287 −}
288 −.tile::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 4px; background: var(--accent); }
289 −.tile .value { font-family: var(--font-display); font-size: 27px; font-weight: 700; letter-spacing: -0.5px; }
565 +/* --- tuiles de statistiques : blocs ouverts à filet supérieur -------------------- */
566 +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 18px 26px; margin: 22px 0; }
567 +.tile { border-top: 2px solid var(--ink); padding: 11px 2px 0; position: relative; }
568 +.tile::before { content: ""; position: absolute; top: -2px; left: 0; width: 34px; height: 2px; background: var(--accent); }
569 +.tile .value { font-family: var(--font-display); font-size: 28px; font-weight: 700; letter-spacing: -0.5px; font-variant-numeric: tabular-nums; }
290 570 .tile .label { color: var(--ink-3); font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin-top: 4px; }
291 571
292 572 /* --- barres horizontales (magnitude, teinte unique) --------------------------------- */
@@ -297,15 +577,17 @@ header.site.menu-open .menu-btn { visibility: hidden; }
297 577 .bar-row .fill { background: var(--accent); border-radius: 4px; height: 14px; min-width: 3px; }
298 578 .bar-row .n { font-size: 13px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }
299 579
300 −/* --- tableaux ----------------------------------------------------------------------- */
301 −table.data { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); overflow: hidden; box-shadow: var(--shadow-off-soft); }
302 −table.data th, table.data td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 14px; }
303 −table.data th { background: var(--surface-2); color: var(--ink-2); font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1.5px solid var(--ink); }
304 −table.data tr:last-child td { border-bottom: none; }
580 +/* --- tableaux : filets fins, plus de boîte lourde ------------------------------------ */
581 +table.data { width: 100%; border-collapse: separate; border-spacing: 0; }
582 +table.data th, table.data td { text-align: left; padding: 11px 14px 11px 2px; border-bottom: 1px solid var(--border); font-size: 14px; }
583 +table.data th {
584 + color: var(--ink-3); font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;
585 + text-transform: uppercase; letter-spacing: 0.08em;
586 + border-bottom: 2px solid var(--ink); background: none;
587 +}
588 +table.data tbody tr:hover td { background: var(--surface); }
305 589
306 590 /* --- carte -------------------------------------------------------------------------- */
307 −/* hauteur = viewport - hauteur RÉELLE du header (--header-h posée par App.tsx
308 − via ResizeObserver) ; dvh évite le saut quand la barre du navigateur se replie */
309 591 .map-wrap {
310 592 height: calc(100vh - var(--header-h, 58px));
311 593 height: calc(100dvh - var(--header-h, 58px));
@@ -322,20 +604,26 @@ table.data tr:last-child td { border-bottom: none; }
322 604 .map-popup { font: 13px "Inter", sans-serif; }
323 605
324 606 /* --- pagination / divers ------------------------------------------------------------- */
325 −.pager { display: flex; gap: 10px; justify-content: center; margin: 26px 0; }
607 +.pager { display: flex; gap: 22px; align-items: center; justify-content: center; margin: 30px 0; }
326 608 .pager button {
327 − background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
328 − padding: 8px 16px; cursor: pointer; font: inherit;
329 − font-family: var(--font-display); font-weight: 700; font-size: 14px;
330 − min-height: var(--touch);
331 − transition: transform 0.15s, box-shadow 0.15s;
609 + border: 0; background: none; cursor: pointer; padding: 10px 4px; min-height: var(--touch);
610 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 700;
611 + text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink);
612 +}
613 +.pager button:hover:not(:disabled) {
614 + color: var(--accent-deep); text-decoration: underline;
615 + text-decoration-thickness: 2px; text-underline-offset: 4px;
332 616 }
333 −.pager button:hover:not(:disabled) { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); }
334 −.pager button:active { transform: none; box-shadow: none; }
335 −.pager button:disabled { opacity: 0.4; cursor: default; }
336 −.empty { text-align: center; color: var(--ink-3); padding: 60px 0; }
617 +.pager button:disabled { opacity: 0.35; cursor: default; }
618 +.pager .pg-pos {
619 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 700;
620 + color: var(--ink-3); font-variant-numeric: tabular-nums;
621 +}
622 +.empty { text-align: center; color: var(--ink-3); padding: 64px 0; display: grid; gap: 14px; justify-items: center; }
623 +.empty p { margin: 0; }
337 624 .section-title { font-family: var(--font-display); font-size: 19px; font-weight: 700; letter-spacing: -0.02em; margin: 30px 0 4px; }
338 625 .muted { color: var(--ink-3); font-size: 13px; }
626 +.sub, .lede { color: var(--ink-2); max-width: 640px; }
339 627
340 628 /* --- formulaire de dépôt direct (page Publier) ------------------------------------------ */
341 629 .depot-grid { display: grid; gap: 14px; grid-template-columns: 1fr 1fr; }
@@ -371,15 +659,14 @@ table.data tr:last-child td { border-bottom: none; }
371 659 }
372 660 .contact-note {
373 661 border-left: 3px solid var(--accent); background: var(--surface);
374 − border-top: 1.5px solid var(--ink); border-right: 1.5px solid var(--ink);
375 − border-bottom: 1.5px solid var(--ink); border-radius: 0 var(--r-card) var(--r-card) 0;
662 + border-radius: 0 var(--r-card) var(--r-card) 0;
376 663 padding: 14px 18px; max-width: 720px; color: var(--ink-2); font-size: 14px;
377 664 }
378 665 .contact-note b { color: var(--ink); }
379 666 .contact-sites { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
380 667 .contact-sites a {
381 668 display: inline-flex; align-items: center; min-height: 34px;
382 − padding: 4px 12px; border: 1.5px solid var(--ink); border-radius: var(--r-pill);
669 + padding: 4px 12px; border: 1px solid var(--line-strong); border-radius: var(--r-pill);
383 670 background: var(--surface); font-family: var(--font-mono); font-size: 11px;
384 671 font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
385 672 color: var(--ink); transition: transform 0.12s, box-shadow 0.12s;
@@ -388,7 +675,7 @@ table.data tr:last-child td { border-bottom: none; }
388 675 .contact-hub { margin-top: 26px; }
389 676
390 677 /* --- rangée de navigation locale au-dessus du footer commun ----------------------------- */
391 −.prefooter { border-top: 1.5px solid var(--ink); background: var(--surface-2); padding: 18px 0; margin-top: 60px; }
678 +.prefooter { border-top: 2px solid var(--ink); background: var(--surface-2); padding: 18px 0; margin-top: 60px; }
392 679 .prefooter .container {
393 680 display: flex; flex-wrap: wrap; align-items: center; gap: 6px 20px;
394 681 }
@@ -403,26 +690,16 @@ table.data tr:last-child td { border-bottom: none; }
403 690
404 691 @media (max-width: 640px) {
405 692 .bar-row { grid-template-columns: 120px 1fr 44px; }
406 − .sheet { padding: 20px; }
407 693 }
408 694
409 −/* --- favoris ♥ « Mon univers Ka » (hub Groupe KA) ---------------------------------- */
410 −.job-card { position: relative; }
411 −.job-card h3 { padding-right: 48px; }
412 −.fav-btn {
413 − position: absolute; right: 14px; top: 14px; z-index: 2;
414 − width: 40px; height: 40px; padding: 0;
415 − display: inline-flex; align-items: center; justify-content: center;
416 − border: 1.5px solid var(--ink); border-radius: 999px; cursor: pointer;
417 − background: rgba(255, 255, 255, 0.95); color: var(--ink);
418 − transition: transform 0.12s ease, background 0.12s ease;
695 +/* bulle KA Agent masquée quand un panneau plein écran est ouvert (filtres,
696 + menu mobile) : son z-index maximal (2147483000) passerait au-dessus */
697 +body.ka-hide-agent .kaa-btn { opacity: 0 !important; pointer-events: none !important; }
698 +
699 +/* --- mouvement réduit : on coupe les animations décoratives ----------------------------- */
700 +@media (prefers-reduced-motion: reduce) {
701 + .hero .kicker .pulse { animation: none; }
702 + .mobile-menu.open, .mobile-menu.open a { animation: none; }
703 + .fsheet { transition: none; }
704 + .job-row, .job-row::before, .searchbar { transition: none; }
419 705 }
420 −.fav-btn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; }
421 −.fav-btn:hover { transform: scale(1.08); background: var(--accent-soft); }
422 −.fav-btn.on { background: var(--accent); border-color: var(--accent-deep); color: var(--on-accent); }
423 −.fav-btn.on svg { fill: currentColor; }
424 −/* fiche d'offre : le cœur à droite du titre */
425 −.fav-btn.big { position: static; flex-shrink: 0; width: 44px; height: 44px; }
426 −.fav-btn.big svg { width: 21px; height: 21px; }
427 −/* page /favoris */
428 −.fav-card { padding-right: 64px; }
429 706