SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

Refonte editoriale v2 de l accueil — direction artistique premium

Moins de boites, plus de typographie : le contenu structure la page.

- Hero : composition typographique geante (clamp 44-88px), surlignage orange
  irregulier en trait SVG sous chez-vous., plus de gros boutons rectangulaires
- Donnees vivantes : ligne mono LIVE avec compteur anime (count-up 0,9s),
  derniere synchro reelle qui tictaque a la seconde (max last_sync des
  connecteurs), sources et loyer moyen — separes par filets, zero chip
- Recherche au coeur : grand champ Ou voulez-vous habiter ? souligne 2px
  (focus accent), criteres inline Ville/Quartier/Loyer separes par filets
  verticaux, bouton texte Tous les criteres avec badge — la boite filterbar
  disparait de l accueil
- Barre de resultats sticky : N logements · Liste|Carte (onglets textes a
  soulignement anime scaleX) · Filtres (mobile) — remplace le FAB flottant
- Fiches editoriales : plus de carte-boite — l image domine (radius 10,
  zoom 1,025 au survol), prix 22px niveau 1, adresse niveau 2, quartier/ville
  en mono uppercase niveau 3, source en micro-mono niveau 4
- Grille a rythme magazine : 3 colonnes, 1 grand format panoramique 16/9
  toutes les 7 fiches, apparition en decale par colonne, align-items start
- Pagination nue : chiffres sans boites, page courante soulignee accent
- Nav mobile : tabbar flottante detachee des bords (radius 18, blur, ombre
  portee), etat actif = trait accent sous l icone ; widget KA Agent remonte
- Primitives locales v2 : rayons 0/6/10/18, durees 150/240/350ms, hairlines
- Feuille mobile : resume des criteres actifs sous la recherche (remplace le
  FAB), poignee, application sticky
- Classes partagees avec CourtTerme (.hero, .filterbar, .stat-chip…)
  intactes : la refonte vit dans de nouvelles classes + une couche finale
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent 0c49ec4

2 changed files +439 −131

modified frontend/src/pages/Home.tsx +129 −119
@@ -15,7 +15,7 @@ import {
15 15 import ListingCard from "../components/ListingCard";
16 16 import Pager from "../components/Pager";
17 17 import {
18 − IcoAlert, IcoBolt, IcoHeart, IcoHouse, IcoList, IcoMap, IcoPaw, IcoPin,
18 + IcoAlert, IcoBolt, IcoList, IcoMap, IcoPaw,
19 19 IcoSearch, IcoSliders, IcoSofa,
20 20 } from "../components/Icons";
21 21
@@ -34,6 +34,41 @@ function useDebounced<T>(value: T, delayMs = 300): T {
34 34 }, [value, delayMs]);
35 35 return debounced;
36 36 }
37 +
38 +/** Compteur animé du héro (~0,9 s, easing cubique) — l'impression d'un vrai
39 + moteur qui indexe. Respecte prefers-reduced-motion (valeur directe). */
40 +function useCountUp(target: number | null | undefined, ms = 900): string | null {
41 + const [v, setV] = useState<number | null>(null);
42 + useEffect(() => {
43 + if (target == null) return;
44 + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setV(target); return; }
45 + let raf = 0;
46 + const t0 = performance.now();
47 + const step = (t: number) => {
48 + const p = Math.min(1, (t - t0) / ms);
49 + setV(Math.round(target * (1 - Math.pow(1 - p, 3))));
50 + if (p < 1) raf = requestAnimationFrame(step);
51 + };
52 + raf = requestAnimationFrame(step);
53 + return () => cancelAnimationFrame(raf);
54 + }, [target, ms]);
55 + return v == null ? null : v.toLocaleString("fr-CA");
56 +}
57 +
58 +/** « il y a X s » vivant — re-rendu chaque seconde tant que le ts existe. */
59 +function useAgo(ts: number | null): string | null {
60 + const [, tick] = useState(0);
61 + useEffect(() => {
62 + if (ts == null) return;
63 + const id = setInterval(() => tick((x) => x + 1), 1000);
64 + return () => clearInterval(id);
65 + }, [ts]);
66 + if (ts == null) return null;
67 + const s = Math.max(0, Math.floor(Date.now() / 1000 - ts));
68 + if (s < 90) return `il y a ${s} s`;
69 + if (s < 5400) return `il y a ${Math.round(s / 60)} min`;
70 + return `il y a ${Math.round(s / 3600)} h`;
71 +}
37 72 const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"];
38 73 const PRICE_STEPS = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000];
39 74 const AREA_STEPS = [400, 600, 800, 1000, 1200];
@@ -112,11 +147,19 @@ export default function Home() {
112 147 // compteur partagé de la vue carte (remonté par MapSearch : liste = carte)
113 148 const [mapTotal, setMapTotal] = useState<number | null>(null);
114 149
150 + // données vivantes du héro : dernière synchro réelle (max des connecteurs)
151 + const [lastSync, setLastSync] = useState<number | null>(null);
115 152 useEffect(() => {
116 − fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
153 + fetchSources().then((r) => {
154 + registerSourceNames(r.sources);
155 + const ts = Math.max(0, ...r.sources.map((s) => s.last_sync ?? 0));
156 + if (ts > 0) setLastSync(ts > 1e12 ? Math.round(ts / 1000) : ts);
157 + }).catch(() => {});
117 158 fetchFacets().then(setFacets).catch(() => {});
118 159 fetchStats().then(setStats).catch(() => {});
119 160 }, []);
161 + const totalLive = useCountUp(stats?.total);
162 + const syncAgo = useAgo(lastSync);
120 163
121 164 // Géolocalisation d'accueil : à l'arrivée sur le site (URL nue, une fois
122 165 // par session), demander la position pour afficher directement les
@@ -246,71 +289,26 @@ export default function Home() {
246 289 return (
247 290 <div className="container">
248 291 <section className="hero">
249 − <span className="kicker">La porte d'entrée vers votre prochain chez-vous.</span>
250 − <h1>
251 − Trouvez votre prochain <span className="hl">chez-vous.</span>
292 + <span className="kicker">Location — tout le Québec, un seul endroit</span>
293 + <h1 className="hero-display">
294 + Trouvez votre prochain<br />
295 + <span className="brush">chez-vous.</span>
252 296 </h1>
253 297 <p className="lede">
254 − Tous les logements à louer au Québec, regroupés au même endroit — mis à jour
255 − automatiquement, avec toutes les photos et un lien direct vers l'annonce originale.
298 + Tous les logements à louer au Québec, agrégés en continu — photos complètes,
299 + juste valeur estimée, lien direct vers l'annonce originale.
256 300 </p>
257 − <div className="hero-cta">
258 − <button
259 − className="btn btn-primary btn-lg"
260 − onClick={() => {
261 − setView("liste");
262 − resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
263 − }}
264 − >
265 − <IcoSearch size={16} /> Explorer les logements
266 − </button>
267 − <button
268 − className="btn btn-lg"
269 − onClick={() => {
270 − setView("carte");
271 − resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
272 − }}
273 − >
274 − <IcoMap size={16} /> Voir la carte
275 − </button>
276 − </div>
277 − <div className="benefits" aria-label="Avantages">
278 − <div className="benefit">
279 − <span className="benefit-ico"><IcoHouse size={20} /></span>
280 − <span className="benefit-label">Nouveaux logements</span>
281 − </div>
282 − <div className="benefit">
283 − <span className="benefit-ico"><IcoSearch size={20} /></span>
284 − <span className="benefit-label">Recherche facile</span>
285 − </div>
286 − <div className="benefit">
287 − <span className="benefit-ico"><IcoHeart size={20} /></span>
288 − <span className="benefit-label">Favoris et alertes</span>
289 − </div>
290 − <div className="benefit">
291 − <span className="benefit-ico blue"><IcoPin size={20} /></span>
292 − <span className="benefit-label">Partout au Québec</span>
293 − </div>
294 − </div>
295 − <div className="stat-row">
296 − <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span>
297 − {stats && (
298 − <>
299 − <span className="stat-chip"><b>{stats.total}</b> logements actifs</span>
300 − <span className="stat-chip"><b>{stats.quebec ?? 0}</b> à Québec</span>
301 − <span className="stat-chip"><b>{stats.levis ?? 0}</b> à Lévis</span>
302 − {(stats.montreal ?? 0) > 0 && (
303 − <span className="stat-chip"><b>{stats.montreal}</b> Grand Montréal</span>
304 − )}
305 − {(stats.autres ?? 0) > 0 && (
306 − <span className="stat-chip"><b>{stats.autres}</b> ailleurs au Québec</span>
307 − )}
308 − {stats.avg_price != null && (
309 − <span className="stat-chip">
310 − loyer moyen <b>{Math.round(stats.avg_price)} $</b>
311 − </span>
312 − )}
313 − </>
301 + <div className="live-line" aria-label="Données en direct">
302 + <span className="live-flag"><span className="live-dot" /> live</span>
303 + {totalLive && (
304 + <span className="live-item"><b>{totalLive}</b> logements indexés</span>
305 + )}
306 + {stats && stats.sources > 0 && (
307 + <span className="live-item"><b>{stats.sources}</b> sources</span>
308 + )}
309 + {syncAgo && <span className="live-item">synchro {syncAgo}</span>}
310 + {stats?.avg_price != null && (
311 + <span className="live-item">loyer moyen <b>{Math.round(stats.avg_price)} $</b></span>
314 312 )}
315 313 </div>
316 314 </section>
@@ -318,29 +316,31 @@ export default function Home() {
318 316 {sheetOpen && (
319 317 <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />
320 318 )}
321 − <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">
319 + <section className={`search-zone ${sheetOpen ? "open" : ""}`} aria-label="Recherche et filtres">
322 320 <div className="sheet-handle" aria-hidden="true" />
323 321 <div className="sheet-head">
324 − <span>Filtres</span>
322 + <span>Affiner la recherche</span>
325 323 <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">
326 324 ✕
327 325 </button>
328 326 </div>
329 327
330 − {/* — rangée principale : recherche, ville, quartier, loyer, + filtres — */}
331 − <div className="f-primary">
332 − <div className="f-search">
333 − <IcoSearch size={15} strokeWidth={2.4} />
334 − <input
335 − id="f-q" placeholder="Ville, quartier ou code postal" value={q}
336 − onChange={(e) => setQ(e.target.value)}
337 − aria-label="Recherche"
338 − />
339 − {q && (
340 − <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>
341 − )}
342 − </div>
343 − <label className="f-ctl">
328 + {/* — la recherche, au cœur du produit : un grand champ souligné — */}
329 + <div className="q-big">
330 + <IcoSearch size={22} strokeWidth={2} />
331 + <input
332 + id="f-q" placeholder="Où voulez-vous habiter ?" value={q}
333 + onChange={(e) => setQ(e.target.value)}
334 + aria-label="Recherche — ville, quartier ou code postal"
335 + />
336 + {q && (
337 + <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>
338 + )}
339 + </div>
340 +
341 + {/* — critères inline, séparés par filets (pas de boîtes) — */}
342 + <div className="crit-line">
343 + <label className="crit">
344 344 <span>Ville</span>
345 345 <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}>
346 346 <option value="">Toutes</option>
@@ -349,7 +349,7 @@ export default function Home() {
349 349 ))}
350 350 </select>
351 351 </label>
352 − <label className="f-ctl">
352 + <label className="crit">
353 353 <span>Quartier</span>
354 354 <select value={sector} onChange={(e) => setSector(e.target.value)}>
355 355 <option value="">Tous</option>
@@ -358,7 +358,7 @@ export default function Home() {
358 358 ))}
359 359 </select>
360 360 </label>
361 − <div className="f-ctl">
361 + <div className="crit">
362 362 <span>Loyer</span>
363 363 <div className="range-pair">
364 364 <select aria-label="Loyer minimum" value={priceMin}
@@ -383,12 +383,12 @@ export default function Home() {
383 383 </div>
384 384 </div>
385 385 <button
386 − className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`}
386 + className={`crit-more ${advOpen || advCount > 0 ? "on" : ""}`}
387 387 onClick={() => setAdvOpen(!advOpen)}
388 388 aria-expanded={advOpen}
389 389 >
390 − <IcoSliders size={14} /> Plus de filtres
391 − {advCount > 0 && <span className="f-more-badge">{advCount}</span>}
390 + Tous les critères
391 + {advCount > 0 && <span className="crit-badge">{advCount}</span>}
392 392 <span className={`f-chev${advOpen ? " up" : ""}`} aria-hidden="true" />
393 393 </button>
394 394 </div>
@@ -478,10 +478,21 @@ export default function Home() {
478 478 )}
479 479
480 480 <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>
481 − Voir les résultats {listings ? `(${total})` : ""}
481 + Voir {listings ? `les ${total.toLocaleString("fr-CA")} logements` : "les résultats"}
482 482 </button>
483 483 </section>
484 484
485 + {/* mobile : résumé des critères — ouvre la feuille (recherche intégrée, pas de FAB) */}
486 + <button className="crit-summary" onClick={() => setSheetOpen(true)}>
487 + <IcoSliders size={14} />
488 + <span className="cs-txt">
489 + {pills.length > 0
490 + ? pills.slice(0, 3).map((p) => p.label).join(" · ") + (pills.length > 3 ? ` · +${pills.length - 3}` : "")
491 + : "Ville, quartier, budget, critères…"}
492 + </span>
493 + <span className="f-chev" aria-hidden="true" />
494 + </button>
495 +
485 496 <div className="chips" role="group" aria-label="Filtres rapides">
486 497 {availableTypes.map((t) => (
487 498 <button
@@ -534,30 +545,37 @@ export default function Home() {
534 545 </div>
535 546 )}
536 547
537 − <div className="results-head" ref={resultsRef}>
538 − <h2>Logements disponibles</h2>
539 − <div className="results-tools">
548 + <div className="results-bar" ref={resultsRef}>
549 + <h2 className="rb-count">
540 550 {view === "carte"
541 − ? mapTotal != null && (
542 − <span>{mapTotal.toLocaleString("fr-CA")} résultat{mapTotal > 1 ? "s" : ""}</span>
543 − )
544 − : listings && <span>{total} résultat{total > 1 ? "s" : ""}</span>}
545 − <div className="view-toggle" role="tablist" aria-label="Mode d'affichage">
551 + ? (mapTotal != null
552 + ? <><b>{mapTotal.toLocaleString("fr-CA")}</b> logement{mapTotal > 1 ? "s" : ""}</>
553 + : "Logements")
554 + : (listings
555 + ? <><b>{total.toLocaleString("fr-CA")}</b> logement{total > 1 ? "s" : ""}</>
556 + : "Logements")}
557 + </h2>
558 + <div className="rb-tools">
559 + <div className="rb-tabs" role="tablist" aria-label="Mode d'affichage">
546 560 <button
547 561 role="tab" aria-selected={view === "liste"}
548 − className={view === "liste" ? "on" : ""}
562 + className={`rb-tab ${view === "liste" ? "on" : ""}`}
549 563 onClick={() => setView("liste")}
550 564 >
551 565 <IcoList size={13} /> Liste
552 566 </button>
553 567 <button
554 568 role="tab" aria-selected={view === "carte"}
555 − className={view === "carte" ? "on" : ""}
569 + className={`rb-tab ${view === "carte" ? "on" : ""}`}
556 570 onClick={() => setView("carte")}
557 571 >
558 572 <IcoMap size={13} /> Carte
559 573 </button>
560 574 </div>
575 + <button className="rb-filters" onClick={() => setSheetOpen(true)}>
576 + <IcoSliders size={13} /> Filtres
577 + {activeFilters > 0 && <span className="rb-badge">{activeFilters}</span>}
578 + </button>
561 579 </div>
562 580 </div>
563 581
@@ -573,7 +591,7 @@ export default function Home() {
573 591 )}
574 592
575 593 {!error && view === "liste" && listings === null && (
576 − <div className="grid" aria-busy="true">
594 + <div className="grid grid-edito" aria-busy="true">
577 595 {Array.from({ length: 8 }).map((_, i) => (
578 596 <div className="skel" key={i}>
579 597 <div className="sk-img" />
@@ -598,37 +616,29 @@ export default function Home() {
598 616 )}
599 617
600 618 {!error && view === "carte" && (
601 − <Suspense
602 − fallback={
603 − <div className="map-split">
604 − <div className="mapview map-loading">Chargement de la carte…</div>
605 − </div>
606 − }
607 − >
608 − <MapSearch filters={filters} onTotal={setMapTotal} />
609 − </Suspense>
619 + <div className="view-pane" key="carte">
620 + <Suspense
621 + fallback={
622 + <div className="map-split">
623 + <div className="mapview map-loading">Chargement de la carte…</div>
624 + </div>
625 + }
626 + >
627 + <MapSearch filters={filters} onTotal={setMapTotal} />
628 + </Suspense>
629 + </div>
610 630 )}
611 631
612 632 {!error && view === "liste" && listings !== null && listings.length > 0 && (
613 − <>
614 − <div className="grid">
633 + <div className="view-pane" key="liste">
634 + <div className="grid grid-edito">
615 635 {listings.map((l) => (
616 636 <ListingCard key={l.uid} l={l} />
617 637 ))}
618 638 </div>
619 639 <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} />
620 − </>
640 + </div>
621 641 )}
622 −
623 − {/* Bouton flottant mobile — ouvre la feuille de filtres */}
624 − <button
625 − className={`fab${view === "carte" ? " fab-carte" : ""}`}
626 − onClick={() => setSheetOpen(true)}
627 − aria-label="Ouvrir les filtres"
628 − >
629 − <IcoSliders size={15} /> Filtres
630 − {activeFilters > 0 && <span className="fab-badge">{activeFilters}</span>}
631 − </button>
632 642 </div>
633 643 );
634 644 }
modified frontend/src/styles.css +310 −12
@@ -110,18 +110,6 @@ html, body { overflow-x: clip; }
110 110 .hero h1 .outline { color: var(--navy); }
111 111 .hero p.lede { color: var(--ink-2); font-size: 17px; max-width: 640px; margin: 18px 0 0; }
112 112
113 −/* — CTA du héro : accès direct aux résultats / à la carte — */
114 −.hero-cta { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; }
115 −.btn-lg { min-height: 52px; padding: 14px 26px; font-size: 15px; border-radius: 12px; }
116 −.btn-lg svg { flex: none; }
117 −.hero-cta .btn-primary { box-shadow: 5px 5px 0 var(--accent); }
118 −.hero-cta .btn-primary:hover { transform: translate(-2px, -2px); box-shadow: 7px 7px 0 var(--accent); }
119 −.hero-cta .btn-primary:active { transform: none; box-shadow: 3px 3px 0 var(--accent); }
120 −@media (max-width: 640px) {
121 − .hero-cta { gap: 10px; }
122 − .hero-cta .btn { flex: 1 1 45%; }
123 −}
124 −
125 113 .stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; }
126 114 .stat-chip {
127 115 background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-pill);
@@ -2407,3 +2395,313 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
2407 2395 .hd-crit { min-width: 86px; font-size: 10px; }
2408 2396 .hiver-detail li { font-size: 12.5px; }
2409 2397 }
2398 +
2399 +/* =============================================================================
2400 + REFONTE ÉDITORIALE v2 (2026-08-25) — accueil « produit premium »
2401 + Direction : moins de boîtes, hiérarchie typographique, hairlines, données
2402 + vivantes, image dominante. Primitives locales : rayons 0/6/10/18,
2403 + durées 150/240/350 ms. Couche posée en FIN de fichier : elle a le dernier
2404 + mot sur la cascade (y compris sur la couche « harmonisation » ci-dessus).
2405 +============================================================================= */
2406 +:root {
2407 + --r-0: 0px; --r-1: 6px; --r-2: 10px; --r-3: 18px;
2408 + --dur-1: 150ms; --dur-2: 240ms; --dur-3: 350ms;
2409 + --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
2410 + --hairline: rgba(20, 24, 20, 0.14);
2411 + --hairline-strong: rgba(20, 24, 20, 0.6);
2412 +}
2413 +
2414 +/* ---- Héro : composition typographique, zéro boîte ---- */
2415 +.hero h1.hero-display {
2416 + font-size: clamp(44px, 7.2vw, 88px);
2417 + line-height: 1.0; letter-spacing: -0.045em; max-width: 1020px; margin-top: 18px;
2418 +}
2419 +.brush { position: relative; display: inline-block; padding: 0 0.06em; }
2420 +.brush::after { /* surlignage orange irrégulier (trait SVG) — pas un rectangle */
2421 + content: ""; position: absolute; left: -3%; right: -3%; bottom: 0.03em; height: 0.34em;
2422 + z-index: -1; transform: rotate(-0.6deg);
2423 + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 26' preserveAspectRatio='none'%3E%3Cpath d='M5 17 C 60 8, 118 21, 168 13 S 258 10, 295 15' fill='none' stroke='%23ff6a00' stroke-width='11' stroke-linecap='round' opacity='0.92'/%3E%3C/svg%3E") no-repeat center / 100% 100%;
2424 +}
2425 +.hero p.lede { font-size: 16.5px; max-width: 580px; }
2426 +.hero::before { opacity: 0.8; }
2427 +.hero::after { opacity: 0.55; }
2428 +
2429 +/* ---- Ligne de données vivantes (mono + filets — plus de chips) ---- */
2430 +.live-line {
2431 + display: flex; flex-wrap: wrap; align-items: baseline;
2432 + margin-top: 34px; font-family: var(--font-mono); font-size: 12px;
2433 + color: var(--ink-2); letter-spacing: 0.02em; row-gap: 10px;
2434 +}
2435 +.live-flag {
2436 + display: inline-flex; align-items: center; gap: 7px;
2437 + font-weight: 700; font-size: 10.5px; letter-spacing: 0.18em;
2438 + text-transform: uppercase; color: var(--accent-deep);
2439 + padding-right: 14px; margin-right: 14px; border-right: 1px solid var(--hairline-strong);
2440 +}
2441 +.live-dot {
2442 + width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
2443 + animation: pulse 2.4s ease infinite;
2444 +}
2445 +.live-item { padding-right: 14px; margin-right: 14px; border-right: 1px solid var(--hairline); }
2446 +.live-item:last-child { border-right: 0; padding-right: 0; margin-right: 0; }
2447 +.live-item b { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; }
2448 +@media (max-width: 640px) {
2449 + .live-line { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
2450 + white-space: nowrap; margin-right: -16px; padding-right: 16px; }
2451 + .live-line::-webkit-scrollbar { display: none; }
2452 +}
2453 +
2454 +/* ---- Recherche au cœur : grand champ souligné + critères en filets ---- */
2455 +.search-zone { margin: 42px 0 0; }
2456 +.q-big {
2457 + display: flex; align-items: center; gap: 14px;
2458 + border: 0; border-bottom: 2px solid var(--ink); border-radius: 0;
2459 + background: transparent; padding: 4px 2px 14px; color: var(--ink-3);
2460 + transition: border-color var(--dur-2) var(--ease-out), color var(--dur-2) ease;
2461 +}
2462 +.q-big:focus-within { border-color: var(--accent); color: var(--accent-deep); }
2463 +.q-big svg { flex: none; }
2464 +.q-big input {
2465 + flex: 1; min-width: 0; border: 0; background: none; outline: none; padding: 0;
2466 + font-family: var(--font-display); font-weight: 600;
2467 + font-size: clamp(19px, 2.8vw, 28px); letter-spacing: -0.02em; color: var(--ink);
2468 +}
2469 +.q-big input::placeholder { color: var(--ink-3); font-weight: 500; }
2470 +.crit-line { display: flex; align-items: stretch; flex-wrap: wrap; }
2471 +.crit {
2472 + display: flex; flex-direction: column; justify-content: center; gap: 1px;
2473 + padding: 12px 26px 12px 0; margin-right: 26px;
2474 + border-right: 1px solid var(--hairline); min-width: 0; cursor: pointer;
2475 +}
2476 +.crit > span {
2477 + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
2478 + text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3);
2479 +}
2480 +.crit select {
2481 + border: 0; background: transparent; outline: none; cursor: pointer;
2482 + font-family: var(--font-display); font-weight: 700; font-size: 15.5px;
2483 + color: var(--ink); appearance: none; -webkit-appearance: none;
2484 + padding: 2px 18px 2px 0; min-width: 0; max-width: 190px; text-overflow: ellipsis;
2485 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%238b928c'/%3E%3C/svg%3E");
2486 + background-repeat: no-repeat; background-position: right center;
2487 +}
2488 +.crit:hover select { color: var(--accent-deep); }
2489 +.crit .range-pair select { max-width: 88px; }
2490 +.crit-more {
2491 + display: inline-flex; align-items: center; gap: 9px; align-self: center;
2492 + border: 0; background: none; padding: 12px 0; cursor: pointer;
2493 + font-family: var(--font-display); font-weight: 700; font-size: 14.5px; color: var(--ink);
2494 + transition: color var(--dur-1) ease;
2495 +}
2496 +.crit-more:hover, .crit-more.on { color: var(--accent-deep); }
2497 +.crit-badge, .rb-badge {
2498 + display: inline-grid; place-items: center; min-width: 19px; height: 19px; padding: 0 5px;
2499 + border-radius: var(--r-pill); background: var(--accent); color: var(--on-accent);
2500 + font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums;
2501 +}
2502 +/* panneau avancé : filet, pas de boîte (réutilise .f-adv/.f-group/.seg) */
2503 +.search-zone .f-adv { border-top: 1px solid var(--hairline); margin-top: 2px; padding-top: 18px; }
2504 +.search-zone .seg button {
2505 + border: 1px solid var(--hairline-strong); background: transparent; color: var(--ink-2);
2506 + min-height: 38px; font-weight: 600; margin-left: -1px;
2507 +}
2508 +.search-zone .seg button:first-child { border-radius: var(--r-1) 0 0 var(--r-1); }
2509 +.search-zone .seg button:last-child { border-radius: 0 var(--r-1) var(--r-1) 0; }
2510 +.search-zone .seg button:hover { background: var(--accent-soft); color: var(--accent-deep); }
2511 +.search-zone .seg button.on { background: var(--ink); border-color: var(--ink); color: var(--paper); box-shadow: none; }
2512 +.search-zone .f-native { border: 1px solid var(--hairline-strong); background: transparent; }
2513 +
2514 +/* résumé mobile des critères — remplace le FAB (voir media 640 plus bas) */
2515 +.crit-summary { display: none; }
2516 +
2517 +/* ---- Barre de résultats sticky : N logements · Liste|Carte · Filtres ---- */
2518 +.results-bar {
2519 + position: sticky; top: 64px; z-index: var(--z-sticky, 300);
2520 + display: flex; align-items: center; gap: 16px;
2521 + margin: 30px 0 22px; padding: 12px 0 11px;
2522 + border-bottom: 1px solid var(--hairline-strong);
2523 + background: color-mix(in srgb, var(--paper) 86%, transparent);
2524 + backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
2525 +}
2526 +@supports not (background: color-mix(in srgb, red 50%, blue)) {
2527 + .results-bar { background: rgba(245, 243, 238, 0.92); }
2528 +}
2529 +.rb-count { margin: 0; font-size: clamp(16px, 2vw, 20px); letter-spacing: -0.02em; color: var(--ink); }
2530 +.rb-count b { font-variant-numeric: tabular-nums; }
2531 +.rb-tools { margin-left: auto; display: flex; align-items: center; gap: 20px; }
2532 +.rb-tabs { display: flex; gap: 18px; }
2533 +.rb-tab {
2534 + position: relative; display: inline-flex; align-items: center; gap: 6px;
2535 + border: 0; background: none; padding: 8px 0; cursor: pointer;
2536 + font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink-3);
2537 + transition: color var(--dur-1) ease;
2538 +}
2539 +.rb-tab:hover, .rb-tab.on { color: var(--ink); }
2540 +.rb-tab::after {
2541 + content: ""; position: absolute; left: 0; right: 0; bottom: -12px; height: 2.5px;
2542 + background: var(--accent); transform: scaleX(0); transform-origin: left;
2543 + transition: transform var(--dur-2) var(--ease-out);
2544 +}
2545 +.rb-tab.on::after { transform: scaleX(1); }
2546 +.rb-filters {
2547 + display: inline-flex; align-items: center; gap: 7px;
2548 + border: 0; background: none; padding: 8px 0; cursor: pointer;
2549 + font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink);
2550 +}
2551 +.rb-filters:hover { color: var(--accent-deep); }
2552 +@media (min-width: 641px) { .rb-filters { display: none; } }
2553 +
2554 +/* ---- Transition douce liste ↔ carte ---- */
2555 +.view-pane { animation: pane-in var(--dur-2) var(--ease-out); }
2556 +@keyframes pane-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
2557 +
2558 +/* ---- Chips rapides : plus légères (hairline, texte d'abord) ---- */
2559 +.chip {
2560 + border: 1px solid var(--hairline-strong); background: transparent;
2561 + min-height: 38px; padding: 7px 15px; font-size: 12.5px; box-shadow: none;
2562 +}
2563 +.chip:hover { transform: none; box-shadow: none; background: var(--accent-soft); color: var(--accent-deep); }
2564 +.chip.on, .chip.chip-deal.on { background: var(--ink); border-color: var(--ink); color: var(--paper); box-shadow: none; }
2565 +.chips { margin: 20px 0 0; }
2566 +
2567 +/* ---- Pastilles actives : discrètes, survol = retrait ---- */
2568 +.pills { margin: 14px 0 0; }
2569 +.pills .pill { border: 1px solid var(--hairline-strong); background: transparent; box-shadow: none; padding: 5px 12px; font-size: 12px; }
2570 +.pills .pill:hover { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
2571 +
2572 +/* ---- Cartes éditoriales : l'image domine, plus de boîte ---- */
2573 +.card, .card:hover { background: transparent; border: 0; border-radius: 0; box-shadow: none; transform: none; }
2574 +.card-img { border-radius: var(--r-2); aspect-ratio: 4/3; }
2575 +.card-img img { transition: transform var(--dur-3) var(--ease-out); }
2576 +@media (hover: hover) {
2577 + .card:hover .card-img img { transform: scale(1.025); }
2578 + .card:hover .card-price { color: var(--accent-deep); }
2579 +}
2580 +.card:focus-visible { outline: 2px solid var(--green); outline-offset: 4px; border-radius: var(--r-2); }
2581 +.card-body { padding: 13px 2px 0; gap: 3px; }
2582 +.card-price { font-size: 22px; letter-spacing: -0.03em; color: var(--ink); transition: color var(--dur-1) ease; }
2583 +.card-title { font-size: 14px; font-weight: 500; color: var(--ink-2); }
2584 +.card-meta { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); }
2585 +.card-meta .sep { width: 3px; height: 3px; border-radius: 50%; background: var(--accent); }
2586 +.card-foot { border-top: 0; padding-top: 8px; }
2587 +.source-tag { background: none; border: 0; border-radius: 0; padding: 0; color: var(--ink-3); font-size: 10px; letter-spacing: 0.08em; max-width: 62%; }
2588 +.avail { font-size: 11px; color: var(--ink-3); }
2589 +.badge { border: 0; border-radius: 4px; background: rgba(20, 24, 20, 0.78); color: var(--paper);
2590 + backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
2591 + font-size: 9.5px; padding: 4px 9px; letter-spacing: 0.08em; }
2592 +.badge.type { background: var(--accent); color: var(--on-accent); }
2593 +.badge.right { background: rgba(20, 24, 20, 0.55); }
2594 +
2595 +/* ---- Grille à rythme éditorial (accueil) : grand / petits / panoramique ---- */
2596 +.grid.grid-edito { grid-template-columns: repeat(3, 1fr); gap: 36px 26px; }
2597 +.grid-edito > *:nth-child(7n+1) { grid-column: span 2; }
2598 +.grid-edito > *:nth-child(7n+1) .card-img,
2599 +.grid-edito > .skel:nth-child(7n+1) .sk-img { aspect-ratio: 16/9; }
2600 +.grid-edito > *:nth-child(7n+1) .card-price { font-size: 27px; }
2601 +.grid-edito > *:nth-child(7n+1) .card-title { font-size: 15px; }
2602 +@media (max-width: 980px) {
2603 + .grid.grid-edito { grid-template-columns: 1fr 1fr; gap: 30px 20px; }
2604 +}
2605 +@media (max-width: 640px) {
2606 + .grid.grid-edito { grid-template-columns: 1fr; gap: 32px; }
2607 + .grid-edito > *:nth-child(7n+1) { grid-column: auto; }
2608 +}
2609 +@media (prefers-reduced-motion: no-preference) {
2610 + .grid-edito > * { animation: rise 0.5s var(--ease-out) backwards; }
2611 + .grid-edito > *:nth-child(3n+2) { animation-delay: 60ms; }
2612 + .grid-edito > *:nth-child(3n) { animation-delay: 120ms; }
2613 +}
2614 +@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
2615 +
2616 +/* ---- Squelettes sans boîte ---- */
2617 +.skel { border: 0; border-radius: 0; background: transparent; }
2618 +.skel .sk-img { aspect-ratio: 4/3; border-radius: var(--r-2); }
2619 +.skel .sk-line { margin: 12px 2px; }
2620 +
2621 +/* ---- Pagination éditoriale : chiffres nus, page courante soulignée ---- */
2622 +.pager { border-top: 1px solid var(--hairline); padding-top: 16px; }
2623 +.pager-info { font-family: var(--font-mono); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); }
2624 +.pager-btn {
2625 + min-width: 34px; height: 38px; padding: 0 8px; border: 0; border-radius: var(--r-1);
2626 + background: none; color: var(--ink-2);
2627 + font-family: var(--font-display); font-size: 13.5px; font-weight: 600; position: relative;
2628 +}
2629 +.pager-btn:hover:not(:disabled):not(.on) { background: var(--accent-soft); color: var(--accent-deep); border: 0; }
2630 +.pager-btn.on { background: none; border: 0; color: var(--ink); font-weight: 700; }
2631 +.pager-btn.on::after { content: ""; position: absolute; left: 9px; right: 9px; bottom: 3px; height: 2.5px; background: var(--accent); }
2632 +.pager-arrow { border: 1px solid var(--hairline-strong); }
2633 +
2634 +/* ---- Navigation mobile flottante (pilule détachée du bord) ---- */
2635 +@media (max-width: 760px) {
2636 + .tabbar {
2637 + left: 12px; right: 12px; bottom: calc(10px + env(safe-area-inset-bottom));
2638 + border: 1px solid var(--hairline-strong); border-radius: var(--r-3);
2639 + background: rgba(250, 249, 245, 0.88);
2640 + box-shadow: 0 14px 34px rgba(20, 24, 20, 0.16);
2641 + padding: 6px;
2642 + }
2643 + .tabbar a { border-radius: 12px; font-size: 10px; color: var(--ink-3); gap: 2px; }
2644 + .tabbar a.active { color: var(--ink); }
2645 + .tabbar a.active::after { content: ""; width: 16px; height: 2.5px; border-radius: 2px; background: var(--accent); }
2646 + body { padding-bottom: calc(80px + env(safe-area-inset-bottom)); }
2647 + .footer { padding-bottom: calc(86px + env(safe-area-inset-bottom)); }
2648 +}
2649 +
2650 +/* ---- Mobile : recherche condensée + feuille de critères premium ---- */
2651 +@media (max-width: 640px) {
2652 + .results-bar { top: 56px; gap: 10px; }
2653 + .search-zone { margin: 30px 0 0; }
2654 + .crit-line, .search-zone .f-adv, .search-zone .sheet-apply { display: none; }
2655 + .crit-summary {
2656 + display: flex; align-items: center; gap: 10px; width: 100%;
2657 + border: 0; border-bottom: 1px solid var(--hairline); background: none;
2658 + padding: 14px 2px; margin: 0; cursor: pointer; text-align: left;
2659 + font-family: var(--font-mono); font-size: 12px; color: var(--ink-2);
2660 + }
2661 + .crit-summary svg { flex: none; color: var(--ink); }
2662 + .crit-summary .cs-txt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2663 + .crit-summary .f-chev { margin: 0 2px 0 0; transform: rotate(45deg); }
2664 +
2665 + .search-zone.open {
2666 + display: flex; flex-direction: column; gap: 12px;
2667 + position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);
2668 + margin: 0; border-radius: var(--r-3) var(--r-3) 0 0; background: var(--surface);
2669 + max-height: 86dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
2670 + padding: 10px 18px calc(16px + env(safe-area-inset-bottom));
2671 + box-shadow: 0 -18px 50px rgba(20, 24, 20, 0.3);
2672 + animation: sheet-up 0.26s var(--ease-out);
2673 + }
2674 + .search-zone.open .sheet-handle {
2675 + display: block; flex: none; width: 40px; height: 4.5px;
2676 + border-radius: 999px; background: var(--line); margin: 0 auto;
2677 + }
2678 + .search-zone.open .sheet-head {
2679 + display: flex; justify-content: space-between; align-items: center;
2680 + font-family: var(--font-display); font-weight: 700; font-size: 17px;
2681 + letter-spacing: -0.01em; color: var(--ink);
2682 + position: sticky; top: -10px; background: var(--surface); padding: 6px 0 8px;
2683 + border-bottom: 1px solid var(--hairline); z-index: 1;
2684 + }
2685 + .search-zone.open .crit-summary { display: none; }
2686 + .search-zone.open .q-big { padding: 2px 0 10px; }
2687 + .search-zone.open .q-big input { font-size: 17px; }
2688 + .search-zone.open .crit-line { display: flex; flex-direction: column; }
2689 + .search-zone.open .crit { border-right: 0; border-bottom: 1px solid var(--hairline); margin: 0; padding: 10px 0; }
2690 + .search-zone.open .crit select { max-width: none; width: 100%; font-size: 16px; }
2691 + .search-zone.open .crit .range-pair select { width: auto; flex: 1; }
2692 + .search-zone.open .crit-more { display: none; }
2693 + .search-zone.open .f-adv { display: grid; border-top: 0; padding-top: 4px; margin-top: 0; }
2694 + .search-zone.open .sheet-apply {
2695 + display: flex; position: sticky; bottom: 0; z-index: 1; width: 100%; min-height: 50px;
2696 + box-shadow: 0 -14px 18px -12px rgba(20, 24, 20, 0.35);
2697 + }
2698 +}
2699 +
2700 +/* ---- Widget KA Agent : dégagé de la tabbar flottante au doigt ---- */
2701 +@media (max-width: 760px) {
2702 + .kaa-btn.kaa-btn { bottom: calc(86px + env(safe-area-inset-bottom, 0px)); right: 14px; }
2703 + .kaa-panel.kaa-panel:not(.kaa-full) { bottom: calc(152px + env(safe-area-inset-bottom, 0px)); }
2704 +}
2705 +
2706 +/* les fiches s'alignent en haut : pas d'étirement vide sous les petites */
2707 +.grid.grid-edito { align-items: start; }
2410 2708