SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%

refonte v2 « terminal éditorial » : hero encre pleine largeur, recherche centrale CTA rouge, graphique marché multi-séries interactif (palette CVD validée), flux de ventes prix-d'abord, rapport de valeur recomposé (grands chiffres, sections à filets), méthodologie article + chaîne de calcul, header compact, zéro ombre décalée/carte superflue

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

19 changed files +1,398 −803

modified src/app/HomeClient.tsx +327 −207
@@ -1,9 +1,10 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 2 "use client";
3 +import { useState } from "react";
3 4 import Link from "next/link";
4 5 import SearchBox from "@/components/SearchBox";
5 6 import EstimateForm from "@/components/EstimateForm";
6 −import { IndexSpark, locNum } from "@/components/MetricViz";
7 +import { locNum, MarketPulseChart } from "@/components/MetricViz";
7 8 import { useLang } from "@/components/LangContext";
8 9
9 10 /** Indice de marché d'un grand type — série + variations (calculé côté serveur). */
@@ -31,21 +32,57 @@ const PULSE_LABELS: Record<PulseItem["type"], { fr: string; en: string }> = {
31 32 condo: { fr: "Condo", en: "Condo" },
32 33 plex: { fr: "Plex", en: "Plex" },
33 34 };
35 +/* attribution FIXE des couleurs de série (jamais recyclée) */
36 +const PULSE_COLORS: Record<PulseItem["type"], string> = {
37 + unifamilial: "var(--chart-1)",
38 + condo: "var(--chart-2)",
39 + plex: "var(--chart-3)",
40 +};
34 41
35 −function TrendBadge({ pct, label }: { pct: number | null; label: string }) {
36 − if (pct == null) return null;
42 +/** Variation signée — l'encre porte le positif, le rouge danger le négatif. */
43 +function Pct({ v, digits = 1 }: { v: number | null; digits?: number }) {
44 + if (v == null) return <span className="text-ink-3">—</span>;
37 45 return (
38 − <span
39 − className={`vp-mono rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${
40 − pct >= 0 ? "bg-lime text-ink" : "bg-danger text-white"
41 − }`}
42 − >
43 − {label} {pct >= 0 ? "+" : ""}
44 − {pct.toFixed(1)} %
46 + <span className={v >= 0 ? "text-ink" : "text-[var(--danger)]"}>
47 + {v >= 0 ? "+" : "−"}
48 + {Math.abs(v).toFixed(digits)}&nbsp;%
45 49 </span>
46 50 );
47 51 }
48 52
53 +/** En-tête de section éditorial : filet fort, numéro mono rouge, titre display. */
54 +function SectionHead({
55 + num,
56 + kicker,
57 + title,
58 + lede,
59 +}: {
60 + num: string;
61 + kicker: string;
62 + title: string;
63 + lede?: string;
64 +}) {
65 + return (
66 + <header className="sec">
67 + <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1">
68 + <span className="sec-num">
69 + {num} — {kicker}
70 + </span>
71 + </div>
72 + <h2 className="vp-display mt-2 max-w-3xl text-[clamp(24px,3.6vw,36px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">
73 + {title}
74 + </h2>
75 + {lede && <p className="mt-3 max-w-2xl text-[14.5px] leading-relaxed text-ink-2">{lede}</p>}
76 + </header>
77 + );
78 +}
79 +
80 +const PERIODS = [
81 + { key: "12", months: 12, fr: "12 mois", en: "12 mo" },
82 + { key: "24", months: 24, fr: "24 mois", en: "24 mo" },
83 + { key: "all", months: null as number | null, fr: "Depuis 2021", en: "Since 2021" },
84 +] as const;
85 +
49 86 export default function HomeClient({
50 87 pulse,
51 88 recent,
@@ -57,6 +94,7 @@ export default function HomeClient({
57 94 }) {
58 95 const { lang, t } = useLang();
59 96 const fr = lang === "fr";
97 + const [period, setPeriod] = useState<(typeof PERIODS)[number]["key"]>("24");
60 98 const money = (v: number) =>
61 99 new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", {
62 100 style: "currency",
@@ -69,233 +107,315 @@ export default function HomeClient({
69 107 month: "short",
70 108 });
71 109
72 − return (
73 − <div className="pb-6 pt-10 sm:pt-14">
74 − {/* ---- Héros ---- */}
75 − <section>
76 − <span className="kicker">
77 − {lang === "fr" ? "Québec · 2021-2026" : "Québec · 2021-2026"}
78 − </span>
79 − <h1 className="vp-display mt-3 max-w-4xl text-[clamp(34px,6.4vw,72px)] font-bold uppercase leading-[0.98] tracking-[-0.035em]">
80 − {lang === "fr" ? (
81 − <>
82 − La <span className="outline-txt">vraie</span> valeur.
83 − <br />
84 − <span className="hl">Sans boîte noire.</span>
85 − </>
86 − ) : (
87 − <>
88 − The <span className="outline-txt">true</span> value.
89 − <br />
90 − <span className="hl">No black box.</span>
91 − </>
92 − )}
93 − </h1>
94 − <p className="mt-5 max-w-xl text-[16.5px] text-ink-2">{t("sub")}</p>
110 + const months = PERIODS.find((p) => p.key === period)?.months ?? 24;
111 + const chartData = pulse.map((p) => ({
112 + key: p.type,
113 + label: PULSE_LABELS[p.type][lang],
114 + color: PULSE_COLORS[p.type],
115 + series: p.series,
116 + }));
95 117
96 − <div className="mt-7 flex gap-2.5 overflow-x-auto pb-1.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
97 − <span className="stat-chip">
98 − <span className="pulse" />
99 − <b>3 747 008</b> {lang === "fr" ? "propriétés" : "properties"}
100 − </span>
101 − <span className="stat-chip">
102 − <b>745 119</b> {lang === "fr" ? "ventes réelles" : "real sales"}
103 − </span>
104 − <span className="stat-chip">
105 − <b>{locNum(salesLast30d, lang, { maxFrac: 0 })}</b>{" "}
106 − {lang === "fr" ? "ventes captées / 30 j" : "sales captured / 30 d"}
107 − </span>
108 − <span className="stat-chip">
109 − <b>1 100</b> {lang === "fr" ? "municipalités" : "municipalities"}
110 − </span>
111 − <span className="stat-chip">
112 − MdAPE <b>11 %</b>
113 − </span>
114 − </div>
115 − </section>
118 + const proofs: [string, string][] = [
119 + ["3 747 008", fr ? "propriétés estimées" : "estimated properties"],
120 + ["745 119", fr ? "ventes réelles 2021-2026" : "real sales 2021-2026"],
121 + [locNum(salesLast30d, lang, { maxFrac: 0 }), fr ? "ventes captées / 30 jours" : "sales captured / 30 days"],
122 + ["1 100", fr ? "municipalités couvertes" : "municipalities covered"],
123 + ];
116 124
117 − {/* ---- Recherche ---- */}
118 − <section className="mt-9">
119 − <div className="vp-card p-4 sm:p-5">
120 − <p className="klabel mb-2 pl-0.5">
121 − {lang === "fr" ? "Adresse de la propriété" : "Property address"}
125 + return (
126 + <div className="pb-6">
127 + {/* ================= HÉRO — terminal encre pleine largeur ================= */}
128 + <section className="bleed vp-dark">
129 + <div className="mx-auto max-w-6xl px-4 pb-10 pt-12 sm:px-6 sm:pb-14 sm:pt-16">
130 + <span className="kicker !text-[var(--accent-bright)] [&::before]:!bg-[var(--accent-bright)]">
131 + {fr
132 + ? "Terminal public de valeur immobilière — Québec"
133 + : "Public real-estate value terminal — Québec"}
134 + </span>
135 + <h1 className="vp-display mt-4 text-[clamp(38px,8vw,92px)] font-bold uppercase leading-[0.95] tracking-[-0.035em]">
136 + {fr ? (
137 + <>
138 + La <span className="outline-txt">vraie</span> valeur.
139 + <br />
140 + <span className="text-[var(--accent-bright)]">Sans boîte noire.</span>
141 + </>
142 + ) : (
143 + <>
144 + The <span className="outline-txt">true</span> value.
145 + <br />
146 + <span className="text-[var(--accent-bright)]">No black box.</span>
147 + </>
148 + )}
149 + </h1>
150 + <p className="mt-5 max-w-xl text-[15.5px] leading-relaxed text-[rgba(245,243,238,0.72)]">
151 + {t("sub")}
122 152 </p>
123 − <SearchBox />
153 +
154 + {/* -------- la recherche : l'action centrale de la page -------- */}
155 + <div className="mt-9 max-w-3xl">
156 + <p className="vp-mono mb-1 text-[10.5px] font-medium uppercase tracking-[0.16em] text-[rgba(245,243,238,0.55)]">
157 + {fr ? "Entrez une adresse au Québec" : "Enter a Québec address"}
158 + </p>
159 + <SearchBox variant="hero" />
160 + <p className="vp-mono mt-2.5 text-[10px] uppercase tracking-[0.06em] text-[rgba(245,243,238,0.4)]">
161 + {t("dataNote")}
162 + </p>
163 + </div>
164 +
165 + {/* -------- preuves de profondeur de données, intégrées -------- */}
166 + <div className="mt-10 grid grid-cols-2 gap-x-6 gap-y-5 border-t border-[rgba(245,243,238,0.16)] pt-6 sm:grid-cols-4">
167 + {proofs.map(([num, label], i) => (
168 + <div key={label}>
169 + <p className="vp-display flex items-baseline gap-2 text-[clamp(20px,2.6vw,28px)] font-bold leading-none tracking-[-0.02em] text-[var(--paper-on-ink)]">
170 + {num}
171 + {i === 2 && (
172 + <span
173 + aria-hidden="true"
174 + className="inline-block h-[7px] w-[7px] rounded-full bg-[var(--accent-bright)] [animation:pulse_2.4s_ease_infinite]"
175 + />
176 + )}
177 + </p>
178 + <p className="vp-mono mt-1.5 text-[9.5px] uppercase tracking-[0.12em] text-[rgba(245,243,238,0.5)]">
179 + {label}
180 + </p>
181 + </div>
182 + ))}
183 + </div>
124 184 </div>
125 − <p className="vp-mono mt-2.5 pl-1 text-[11px] uppercase tracking-[0.06em] text-ink-3">
126 − {t("dataNote")}
127 − </p>
128 185 </section>
129 186
130 − {/* ---- Comment ça marche (bande compacte) ---- */}
131 − <section className="mt-10 grid gap-3 sm:grid-cols-3">
132 − {(fr
133 − ? [
134 − ["01", "Cherchez l'adresse", "3,7 M d'unités d'évaluation, recherche tolérante aux fautes."],
135 − ["02", "Le moteur croise deux sources", "Modèle hédonique (690 k ventes) + comparables réels ajustés en dollars."],
136 − ["03", "Fourchette + confiance", "P10-P90, indice A-D, rapport PDF — le calcul montré en entier."],
137 − ]
138 − : [
139 − ["01", "Search the address", "3.7M assessment units, typo-tolerant search."],
140 − ["02", "Two sources, crossed", "Hedonic model (690k sales) + real comparables adjusted in dollars."],
141 − ["03", "Range + confidence", "P10-P90, A-D index, PDF report — the full math, shown."],
142 − ]
143 − ).map(([num, title, body], i) => (
144 − <div key={num} className="flex items-start gap-3 rounded-[10px] border-[1.5px] border-dashed border-[rgba(20,24,20,0.3)] bg-surface-2 p-4">
145 − <span className="vp-mono flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-[1.5px] border-ink bg-ink text-[12px] font-bold text-lime">
146 − {num}
147 − </span>
148 − <div>
149 − <p className="vp-display text-[14.5px] font-bold uppercase tracking-[-0.01em]">
150 − {title}
151 − {i < 2 && <span className="ml-2 text-ink-3">→</span>}
187 + {/* ============ le principe, en une ligne (pas de boîtes) ============ */}
188 + <section className="border-b border-[var(--line)] py-6">
189 + <div className="grid gap-x-8 gap-y-4 sm:grid-cols-3">
190 + {(fr
191 + ? [
192 + ["01", "Cherchez l'adresse", "3,7 M d'unités d'évaluation, recherche tolérante aux fautes."],
193 + ["02", "Deux sources croisées", "Modèle hédonique (690 k ventes) + comparables réels ajustés en dollars."],
194 + ["03", "Fourchette + confiance", "P10-P90, indice A-D — le calcul montré en entier."],
195 + ]
196 + : [
197 + ["01", "Search the address", "3.7M assessment units, typo-tolerant search."],
198 + ["02", "Two sources, crossed", "Hedonic model (690k sales) + real comparables adjusted in dollars."],
199 + ["03", "Range + confidence", "P10-P90, A-D index — the full math, shown."],
200 + ]
201 + ).map(([num, title, body]) => (
202 + <div key={num} className="flex items-start gap-3">
203 + <span className="vp-mono pt-[3px] text-[11px] font-bold text-accent">{num}</span>
204 + <p className="text-[13px] leading-snug text-ink-2">
205 + <span className="vp-display block text-[14px] font-bold uppercase tracking-[-0.01em] text-ink">
206 + {title}
207 + </span>
208 + {body}
152 209 </p>
153 − <p className="mt-1 text-[12.5px] leading-snug text-ink-2">{body}</p>
154 210 </div>
155 − </div>
156 − ))}
211 + ))}
212 + </div>
157 213 </section>
158 214
159 − {/* ---- Pouls du marché ---- */}
160 − <section className="mt-14">
161 − <span className="kicker">{fr ? "Indice Vrai-Prix" : "Vrai-Prix index"}</span>
162 − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
163 − {fr ? "Le pouls du marché québécois" : "The pulse of the Québec market"}
164 − </h2>
165 − <p className="mt-2 max-w-2xl text-[14px] text-ink-2">
166 − {fr
167 − ? "L'indice mensuel calculé sur les ventes réelles — celui-là même qui ajuste les comparables de chaque estimation. Base 100 il y a 24 mois."
168 − : "The monthly index built from real sales — the same one adjusting every estimate's comparables. Base 100 twenty-four months ago."}
169 − </p>
170 − <div className="mt-4 grid gap-4 sm:grid-cols-3">
215 + {/* ================= MARCHÉ — data visualization éditoriale ================= */}
216 + <section className="mt-12">
217 + <SectionHead
218 + num="01"
219 + kicker={fr ? "Indice Vrai-Prix" : "Vrai-Prix index"}
220 + title={fr ? "Le pouls du marché québécois" : "The pulse of the Québec market"}
221 + lede={
222 + fr
223 + ? "L'indice mensuel calculé sur les ventes réelles — celui-là même qui ajuste les comparables de chaque estimation. Trois grands types, une même échelle : base 100 au début de la période."
224 + : "The monthly index built from real sales — the same one adjusting every estimate's comparables. Three major types, one scale: base 100 at the period start."
225 + }
226 + />
227 +
228 + {/* contrôles : période (une rangée, au-dessus du graphique) */}
229 + <div className="mt-5 flex flex-wrap items-end justify-between gap-x-6 gap-y-2 border-b border-[var(--line)]">
230 + <div className="flex gap-5">
231 + {PERIODS.map((p) => (
232 + <button
233 + key={p.key}
234 + type="button"
235 + className="vp-tab"
236 + aria-pressed={period === p.key}
237 + onClick={() => setPeriod(p.key)}
238 + >
239 + {p[lang]}
240 + </button>
241 + ))}
242 + </div>
243 + {/* légende — l'identité ne repose jamais sur la couleur seule */}
244 + <div className="flex flex-wrap gap-x-4 gap-y-1 pb-2">
245 + {chartData.map((s) => (
246 + <span key={s.key} className="vp-mono flex items-center gap-1.5 text-[10px] uppercase tracking-[0.08em] text-ink-2">
247 + <span className="inline-block h-[2px] w-[14px]" style={{ background: s.color }} />
248 + {s.label}
249 + </span>
250 + ))}
251 + </div>
252 + </div>
253 +
254 + <div className="mt-4">
255 + <MarketPulseChart data={chartData} lang={lang} months={months} />
256 + </div>
257 +
258 + {/* lecture par type : variations en colonnes de données */}
259 + <div className="mt-5 grid gap-x-8 gap-y-3 border-t border-[var(--line)] pt-4 sm:grid-cols-3">
171 260 {pulse.map((p) => (
172 − <div key={p.type} className="vp-card vp-card-hover p-5">
173 − <div className="flex items-center justify-between gap-2">
174 − <p className="vp-display text-[16px] font-bold uppercase tracking-[-0.01em]">
175 − {PULSE_LABELS[p.type][lang]}
176 − </p>
177 − <TrendBadge pct={p.pct12m} label={fr ? "12 mois" : "12 mo"} />
178 − </div>
179 − <div className="mt-3">
180 − <IndexSpark series={p.series} />
181 − </div>
182 − {p.since2021 != null && (
183 − <p className="vp-mono mt-2 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
184 − {fr ? "Depuis 2021" : "Since 2021"} :{" "}
185 − <b className="text-ink">
186 − {p.since2021 >= 0 ? "+" : ""}
187 − {p.since2021.toFixed(0)} %
261 + <div key={p.type} className="flex items-baseline justify-between gap-4 sm:block">
262 + <p className="vp-mono flex items-center gap-1.5 text-[10.5px] font-bold uppercase tracking-[0.08em] text-ink">
263 + <span className="inline-block h-[2px] w-[12px]" style={{ background: PULSE_COLORS[p.type] }} />
264 + {PULSE_LABELS[p.type][lang]}
265 + </p>
266 + <p className="vp-mono mt-1 flex gap-4 text-[11px] text-ink-2">
267 + <span>
268 + 12&nbsp;{fr ? "mois" : "mo"}&nbsp;
269 + <b className="vp-display text-[14px]">
270 + <Pct v={p.pct12m} />
188 271 </b>
189 − </p>
190 − )}
272 + </span>
273 + <span>
274 + 2021→&nbsp;
275 + <b className="vp-display text-[14px]">
276 + <Pct v={p.since2021} digits={0} />
277 + </b>
278 + </span>
279 + </p>
191 280 </div>
192 281 ))}
193 282 </div>
194 283 </section>
195 284
196 − {/* ---- Dernières ventes captées ---- */}
285 + {/* ================= VENTES RÉELLES — flux prix-d'abord ================= */}
197 286 {recent.length > 0 && (
198 287 <section className="mt-14">
199 − <span className="kicker">
200 − <span className="pulse" style={{ width: 8, height: 8 }} />
201 − {fr ? "Flux en continu" : "Live feed"}
202 − </span>
203 − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
204 − {fr ? "Dernières ventes réelles captées" : "Latest real sales captured"}
205 − </h2>
206 − <p className="mt-2 max-w-2xl text-[14px] text-ink-2">
207 − {fr
208 − ? "Transactions publiées, appariées automatiquement à leur unité d'évaluation. Cliquez pour voir l'estimation complète de la propriété vendue."
209 − : "Published transactions, automatically matched to their assessment unit. Click for the sold property's full estimate."}
210 − </p>
211 − <div className="src-wrap mt-4">
212 − <table className="src-table">
213 − <thead>
214 − <tr>
215 − <th>{fr ? "Date" : "Date"}</th>
216 − <th>{fr ? "Propriété" : "Property"}</th>
217 − <th className="text-right">{fr ? "Prix de vente" : "Sale price"}</th>
218 − <th className="text-right"></th>
219 − </tr>
220 − </thead>
221 − <tbody>
222 − {recent.map((s) => (
223 − <tr key={s.id}>
224 − <td className="vp-mono whitespace-nowrap text-[12px] font-bold uppercase">
288 + <SectionHead
289 + num="02"
290 + kicker={fr ? "Flux en continu" : "Live feed"}
291 + title={fr ? "Dernières ventes réelles captées" : "Latest real sales captured"}
292 + lede={
293 + fr
294 + ? "Transactions publiées, appariées automatiquement à leur unité d'évaluation. Chaque vente mène à l'estimation complète de la propriété."
295 + : "Published transactions, automatically matched to their assessment unit. Each sale leads to the property's full estimate."
296 + }
297 + />
298 + <div className="mt-5">
299 + {recent.map((s) => {
300 + const inner = (
301 + <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 sm:flex-nowrap">
302 + {/* le prix d'abord — la donnée mène */}
303 + <p className="vp-display w-full text-[22px] font-bold leading-none tracking-[-0.02em] sm:w-[168px] sm:shrink-0 sm:text-[20px]">
304 + {money(s.amount)}
305 + </p>
306 + <div className="min-w-0 flex-1">
307 + <p className="truncate text-[14.5px] font-semibold leading-snug">
308 + {s.street}
309 + {s.city ? `, ${s.city}` : ""}
310 + </p>
311 + <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
225 312 {dateFmt(s.date)}
226 − </td>
227 − <td>
228 − <p className="font-semibold leading-tight">
229 − {s.street}
230 − {s.city ? `, ${s.city}` : ""}
231 − </p>
232 − {s.propertyType && s.propertyType !== "indéterminé" && (
233 − <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">
234 − {s.propertyType}
235 − </p>
236 − )}
237 − </td>
238 − <td className="text-right">
239 − <span className="vp-display font-bold">{money(s.amount)}</span>
313 + {s.propertyType && s.propertyType !== "indéterminé" ? ` · ${s.propertyType}` : ""}
240 314 {s.vsRolePct != null && Math.abs(s.vsRolePct) < 400 && (
241 − <p className="vp-mono mt-0.5 text-[10px] text-ink-3">
242 − {s.vsRolePct >= 0 ? "+" : "−"}
243 − {Math.abs(s.vsRolePct).toFixed(0)} %{" "}
244 − {fr ? "vs rôle" : "vs roll"}
245 − </p>
246 − )}
247 − </td>
248 − <td className="text-right">
249 − {s.idProvinc && (
250 − <Link
251 − href={`/estimation/${encodeURIComponent(s.idProvinc)}`}
252 − className="vp-mono whitespace-nowrap rounded-[5px] border-[1.5px] border-ink bg-surface-2 px-2.5 py-1.5 text-[11px] font-bold uppercase tracking-[0.04em] transition-colors hover:bg-lime"
253 − >
254 − {fr ? "Estimer" : "Estimate"} →
255 − </Link>
315 + <>
316 + {" · "}
317 + <span className={s.vsRolePct >= 0 ? "text-ink-2" : "text-[var(--danger)]"}>
318 + {s.vsRolePct >= 0 ? "+" : "−"}
319 + {Math.abs(s.vsRolePct).toFixed(0)} % {fr ? "vs rôle" : "vs roll"}
320 + </span>
321 + </>
256 322 )}
257 − </td>
258 − </tr>
259 − ))}
260 − </tbody>
261 − </table>
323 + </p>
324 + </div>
325 + {s.idProvinc && (
326 + <p className="vp-mono shrink-0 text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent">
327 + {fr ? "Voir l'estimation" : "See the estimate"} →
328 + </p>
329 + )}
330 + </div>
331 + );
332 + return s.idProvinc ? (
333 + <Link key={s.id} href={`/estimation/${encodeURIComponent(s.idProvinc)}`} className="feed-row">
334 + {inner}
335 + </Link>
336 + ) : (
337 + <div key={s.id} className="feed-row">
338 + {inner}
339 + </div>
340 + );
341 + })}
262 342 </div>
263 343 </section>
264 344 )}
265 345
266 − {/* ---- Formulaire manuel ---- */}
346 + {/* ================= LE CALCUL, MONTRÉ — promesse centrale ================= */}
267 347 <section className="mt-14">
268 − <span className="kicker">{lang === "fr" ? "Plan B" : "Plan B"}</span>
269 − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
270 − {t("manualTitle")}
271 − </h2>
272 − <div className="vp-card mt-4 p-4 sm:p-6">
273 − <EstimateForm />
348 + <SectionHead
349 + num="03"
350 + kicker={fr ? "Sans boîte noire" : "No black box"}
351 + title={fr ? "Voici exactement comment le prix est calculé" : "Here is exactly how the price is computed"}
352 + />
353 + <div className="mt-6 grid gap-10 lg:grid-cols-[1.2fr_1fr]">
354 + <ol className="tl">
355 + {(fr
356 + ? [
357 + ["Propriété identifiée", "3,7 M d'unités d'évaluation géoréférencées (MAMH)."],
358 + ["Rôle d'évaluation récupéré", "Valeurs terrain + bâtiment, six millésimes 2021-2026."],
359 + ["Ventes comparables sélectionnées", "Rayon adaptatif, même famille de type, aire ±20 %."],
360 + ["Caractéristiques ajustées", "Marché, superficie, âge — chaque ajustement affiché en dollars."],
361 + ["Estimation calculée", "65 % modèle hédonique + 35 % comparables pondérés."],
362 + ["Intervalle de confiance produit", "Fourchette P10-P90 et indice A-D, toujours affichés."],
363 + ]
364 + : [
365 + ["Property identified", "3.7M georeferenced assessment units (MAMH)."],
366 + ["Assessment roll retrieved", "Land + building values, six vintages 2021-2026."],
367 + ["Comparable sales selected", "Adaptive radius, same type family, area ±20%."],
368 + ["Characteristics adjusted", "Market, area, age — every adjustment shown in dollars."],
369 + ["Estimate computed", "65% hedonic model + 35% weighted comparables."],
370 + ["Confidence interval produced", "P10-P90 range and A-D index, always shown."],
371 + ]
372 + ).map(([title, body], i, arr) => (
373 + <li key={title} className="tl-step" data-accent={i === arr.length - 1 ? "true" : undefined}>
374 + <p className="vp-display flex items-baseline gap-2.5 text-[15px] font-bold uppercase tracking-[-0.01em]">
375 + <span className="vp-mono text-[10.5px] font-bold text-accent">0{i + 1}</span>
376 + {title}
377 + </p>
378 + <p className="mt-1 text-[13px] leading-snug text-ink-2">{body}</p>
379 + </li>
380 + ))}
381 + </ol>
382 + <div>
383 + {(lang === "fr"
384 + ? [
385 + ["Le calcul, montré", "Chaque ajustement de chaque comparable est affiché en dollars. Pas de score magique."],
386 + ["Fourchette honnête", "Un prix unique est un mensonge statistique. On donne P10-P90 et un indice de confiance A-D."],
387 + ["Données réelles", "Rôles fonciers officiels (MAMH) et ventes publiées — locales et récentes en priorité."],
388 + ]
389 + : [
390 + ["The math, shown", "Every adjustment on every comparable is displayed in dollars. No magic score."],
391 + ["Honest range", "A single price is a statistical lie. We give P10-P90 and an A-D confidence index."],
392 + ["Real data", "Official assessment rolls (MAMH) and published sales — local and recent first."],
393 + ]
394 + ).map(([title, body]) => (
395 + <div key={title} className="border-t border-[var(--line)] py-4 first:border-t-0 first:pt-0">
396 + <h3 className="vp-display text-[15px] font-bold uppercase tracking-[-0.01em]">{title}</h3>
397 + <p className="mt-1.5 text-[13px] leading-snug text-ink-2">{body}</p>
398 + </div>
399 + ))}
400 + <p className="vp-mono mt-2 border-t border-[var(--line)] pt-4 text-[11px] uppercase tracking-[0.06em]">
401 + <Link href="/methodologie" className="border-b border-accent pb-px text-accent-deep hover:text-accent">
402 + {fr ? "Méthodologie complète — niveau rapport technique" : "Full methodology — technical-report depth"} →
403 + </Link>
404 + </p>
405 + </div>
274 406 </div>
275 407 </section>
276 408
277 − {/* ---- Manifeste transparence ---- */}
278 − <section className="mt-14 grid gap-4 sm:grid-cols-3">
279 − {(lang === "fr"
280 − ? [
281 − ["01", "Le calcul, montré", "Chaque ajustement de chaque comparable est affiché en dollars. Pas de score magique."],
282 − ["02", "Fourchette honnête", "Un prix unique est un mensonge statistique. On donne P10-P90 et un indice de confiance A-D."],
283 − ["03", "Données réelles", "Rôles fonciers officiels (MAMH) et ventes publiées — locales et récentes en priorité."],
284 − ]
285 − : [
286 − ["01", "The math, shown", "Every adjustment on every comparable is displayed in dollars. No magic score."],
287 − ["02", "Honest range", "A single price is a statistical lie. We give P10-P90 and an A-D confidence index."],
288 − ["03", "Real data", "Official assessment rolls (MAMH) and published sales — local and recent first."],
289 − ]
290 − ).map(([num, title, body]) => (
291 − <div key={num} className="vp-card vp-card-hover p-5">
292 − <p className="vp-mono text-[11px] font-bold text-green">{num}</p>
293 − <h3 className="vp-display mt-1.5 text-[17px] font-bold uppercase tracking-[-0.01em]">
294 − {title}
295 − </h3>
296 − <p className="mt-2 text-[13.5px] text-ink-2">{body}</p>
297 − </div>
298 − ))}
409 + {/* ================= PLAN B — estimation sans adresse ================= */}
410 + <section className="mt-14">
411 + <SectionHead
412 + num="04"
413 + kicker={fr ? "Plan B" : "Plan B"}
414 + title={t("manualTitle")}
415 + />
416 + <div className="mt-6 max-w-3xl">
417 + <EstimateForm />
418 + </div>
299 419 </section>
300 420 </div>
301 421 );
modified src/app/compte/CompteClient.tsx +1 −1
@@ -345,7 +345,7 @@ export default function CompteClient({
345 345 href={socialUrl(net, v)}
346 346 target="_blank"
347 347 rel="noopener noreferrer"
348 − className="stat-chip hover:bg-lime-soft"
348 + className="stat-chip transition-colors hover:text-accent-deep"
349 349 >
350 350 <b>{SOCIAL_LABELS[net]}</b> {v}
351 351 </a>
modified src/app/globals.css +180 −88
@@ -1,10 +1,11 @@
1 1 /* -----------------------------------------------------------------------------
2 2 Vrai-Prix — estimation immobilière transparente (Québec)
3 3 Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 − Système de design « éditorial sharp » — identité ROUGE distincte de Lou-Ka :
4 + Système de design « terminal éditorial » v2 — Bloomberg × FT data × institutionnel :
5 5 · Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
6 − · Signature : bordures encre + ombres décalées (néo-brutalisme raffiné)
7 − · Accent rouge vif #ff5148 + bordeaux #9e2a25 sur encre
6 + · Signature : filets fins (hairlines), grands chiffres, compositions asymétriques,
7 + encre profonde + rouge signal réservé à l'information importante
8 + · Zéro ombre décalée, zéro pilule, radius 0-2 px — la donnée fait le décor
8 9 ----------------------------------------------------------------------------- */
9 10 @import "tailwindcss";
10 11 /* ka-ui (Groupe KA) — tokens + composants communs (.gk-badge, .ka-footer, .card…).
@@ -16,7 +17,8 @@
16 17
17 18 :root {
18 19 /* ---- Accent Vrai-Prix (tokens canoniques ka-ui) — identité rouge ---- */
19 − --accent: #ff5148;
20 + --accent: #d8372e; /* rouge signal éditorial (lisible sur papier) */
21 + --accent-bright: #ff5148; /* rouge vif — marqueurs, points de donnée sur encre */
20 22 --accent-soft: #ffe3e0;
21 23 --accent-deep: #9e2a25;
22 24 --on-accent: #ffffff;
@@ -28,6 +30,7 @@
28 30 --ink-2: #4d5551;
29 31 --ink-3: #8b928c;
30 32 --line: rgba(20, 24, 20, 0.14);
33 + --line-soft: rgba(20, 24, 20, 0.08);
31 34 --line-strong: rgba(20, 24, 20, 0.85);
32 35 --green: #9e2a25;
33 36 --green-deep: #771f1b;
@@ -37,11 +40,20 @@
37 40 --amber-soft: #fdf3e2;
38 41 --danger: #b3423a;
39 42 --danger-soft: #fbe9e7;
40 − --r-card: 10px;
41 − --r-ctl: 6px;
43 + /* série de graphiques (palette validée : CVD 28,6 · contraste ≥3:1) */
44 + --chart-1: #141814; /* unifamiliale */
45 + --chart-2: #d8372e; /* condo */
46 + --chart-3: #6b736d; /* plex */
47 + --r-card: 2px;
48 + --r-ctl: 2px;
42 49 --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
43 − --shadow-off: 6px 6px 0 var(--ink);
44 − --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
50 + /* ombres décalées héritées — aplaties : le v2 ne porte aucune ombre dure */
51 + --shadow-off: 0 1px 2px rgba(20, 24, 20, 0.08);
52 + --shadow-off-soft: none;
53 + --shadow-off-mid: 0 1px 2px rgba(20, 24, 20, 0.08);
54 + /* encre légèrement plus profonde pour les aplats pleine largeur */
55 + --ink-deep: #101410;
56 + --paper-on-ink: #edeae2;
45 57 }
46 58
47 59 @theme inline {
@@ -58,6 +70,9 @@
58 70 --color-amber: var(--amber);
59 71 --color-amber-soft: var(--amber-soft);
60 72 --color-danger: var(--danger);
73 + --color-accent: var(--accent);
74 + --color-accent-deep: var(--accent-deep);
75 + --color-accent-bright: var(--accent-bright);
61 76 --font-display: var(--font-space-grotesk), system-ui, sans-serif;
62 77 --font-body: var(--font-inter), system-ui, sans-serif;
63 78 --font-mono: var(--font-jetbrains), ui-monospace, monospace;
@@ -80,10 +95,10 @@ body {
80 95 -webkit-font-smoothing: antialiased;
81 96 padding-bottom: env(safe-area-inset-bottom);
82 97 }
83 −/* grain subtil — texture signature */
98 +/* grain subtil — texture signature (discret, presque imperceptible) */
84 99 body::before {
85 100 content: "";
86 − position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.35;
101 + position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.22;
87 102 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
88 103 }
89 104 /* au-dessus du grain (::before positionné, premier dans l'ordre de peinture) ;
@@ -95,146 +110,223 @@ h1, h2, h3, h4 {
95 110 font-family: var(--font-space-grotesk), system-ui, sans-serif;
96 111 letter-spacing: -0.03em;
97 112 }
98 −::selection { background: var(--lime); color: var(--ink); }
113 +::selection { background: var(--ink); color: var(--paper); }
99 114
100 115 /* ---------- primitives signature ---------- */
101 116 .vp-display { font-family: var(--font-space-grotesk), system-ui, sans-serif; }
102 117 .vp-mono { font-family: var(--font-jetbrains), ui-monospace, monospace; }
103 118
119 +/* pleine largeur depuis un conteneur centré (héro, bandes encre) */
120 +.bleed { width: 100vw; margin-left: calc(50% - 50vw); }
121 +
122 +/* aplat encre : héro, blocs terminal */
123 +.vp-dark { background: var(--ink-deep); color: var(--paper-on-ink); }
124 +.vp-dark ::selection { background: var(--accent-bright); color: var(--ink); }
125 +
104 126 .kicker {
105 127 font-family: var(--font-jetbrains), ui-monospace, monospace;
106 − font-size: 11.5px; font-weight: 500; text-transform: uppercase;
107 − letter-spacing: 0.14em; color: var(--green);
108 − display: inline-flex; align-items: center; gap: 8px;
128 + font-size: 11px; font-weight: 500; text-transform: uppercase;
129 + letter-spacing: 0.16em; color: var(--accent);
130 + display: inline-flex; align-items: center; gap: 9px;
109 131 }
110 −.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
132 +.kicker::before { content: ""; width: 20px; height: 1.5px; background: var(--accent); }
111 133
112 134 .klabel {
113 135 font-family: var(--font-jetbrains), ui-monospace, monospace;
114 − font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em;
115 − color: var(--ink-3); font-weight: 700;
136 + font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em;
137 + color: var(--ink-3); font-weight: 500;
138 +}
139 +
140 +/* en-tête de section éditorial : filet fort + numéro mono */
141 +.sec {
142 + border-top: 2px solid var(--ink);
143 + padding-top: 14px;
144 +}
145 +.sec-num {
146 + font-family: var(--font-jetbrains), ui-monospace, monospace;
147 + font-size: 11px; font-weight: 700; color: var(--accent);
148 + letter-spacing: 0.1em;
116 149 }
117 150
151 +/* carte héritée — aplatie : surface + hairline, aucune ombre */
118 152 .vp-card {
119 153 background: var(--surface);
120 − border: 1.5px solid var(--ink);
154 + border: 1px solid var(--line);
121 155 border-radius: var(--r-card);
122 − box-shadow: var(--shadow-off-soft);
156 + box-shadow: none;
123 157 }
124 −.vp-card-hover { transition: transform 0.16s ease, box-shadow 0.16s ease; }
125 −.vp-card-hover:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); }
158 +.vp-card-hover { transition: border-color 0.15s ease; }
159 +.vp-card-hover:hover { transform: none; box-shadow: none; border-color: var(--line-strong); }
126 160
161 +/* champ : soulignement éditorial (le cadre disparaît) */
127 162 .vp-input {
128 − border: 1.5px solid var(--line); background: var(--surface-2);
129 − border-radius: var(--r-ctl); padding: 11px 12px; font-size: 15px;
163 + border: 0; border-bottom: 1px solid var(--line-strong); background: transparent;
164 + border-radius: 0; padding: 10px 2px; font-size: 15px;
130 165 color: var(--ink); outline: none; font-family: inherit; width: 100%;
131 166 transition: border-color 0.15s ease, box-shadow 0.15s ease; min-height: 44px;
132 167 appearance: none; -webkit-appearance: none;
133 168 }
134 −.vp-input:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
169 +.vp-input::placeholder { color: var(--ink-3); }
170 +.vp-input:focus { border-color: var(--accent); box-shadow: 0 1px 0 var(--accent); }
135 171 select.vp-input {
136 172 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E");
137 − background-repeat: no-repeat; background-position: right 12px center; padding-right: 30px;
173 + background-repeat: no-repeat; background-position: right 6px center; padding-right: 26px;
138 174 }
139 175 @media (max-width: 900px) { .vp-input { font-size: 16px; } } /* anti-zoom iOS */
140 176
141 177 .btn {
142 − border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 20px;
143 − font-weight: 700; font-size: 14px; cursor: pointer; min-height: 44px;
144 − transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease;
145 − font-family: var(--font-space-grotesk), system-ui, sans-serif; letter-spacing: 0.01em;
178 + border: 1px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 22px;
179 + font-weight: 600; font-size: 13.5px; cursor: pointer; min-height: 44px;
180 + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
181 + font-family: var(--font-space-grotesk), system-ui, sans-serif; letter-spacing: 0.02em;
146 182 display: inline-flex; align-items: center; justify-content: center; gap: 8px;
147 183 }
148 −.btn:active { transform: translate(2px, 2px); box-shadow: none !important; }
149 −.btn-primary { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.25); }
150 −.btn-primary:hover { background: var(--green-deep); }
151 −.btn-ghost { background: transparent; color: var(--ink); border-color: var(--ink); }
152 −.btn-ghost:hover { background: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.2); }
184 +.btn:active { opacity: 0.85; }
185 +.btn-primary { background: var(--ink); color: var(--paper); }
186 +.btn-primary:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
187 +.btn-ghost { background: transparent; color: var(--ink); border-color: var(--line-strong); }
188 +.btn-ghost:hover { border-color: var(--accent); color: var(--accent-deep); }
189 +.btn-accent { background: var(--accent); border-color: var(--accent); color: #fff; }
190 +.btn-accent:hover { background: var(--accent-deep); border-color: var(--accent-deep); }
153 191
192 +/* preuve de données : chiffre fort + libellé mono, séparés par filets */
154 193 .stat-chip {
155 − background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px;
156 − padding: 8px 16px; font-family: var(--font-jetbrains), ui-monospace, monospace;
157 − font-size: 12px; color: var(--ink-2); display: inline-flex; gap: 8px; align-items: center;
158 − box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12); white-space: nowrap;
194 + background: transparent; border: 0; border-left: 1px solid var(--line);
195 + border-radius: 0; padding: 2px 0 2px 14px;
196 + font-family: var(--font-jetbrains), ui-monospace, monospace;
197 + font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
198 + color: var(--ink-2); display: inline-flex; gap: 7px; align-items: baseline;
199 + box-shadow: none; white-space: nowrap;
200 +}
201 +.stat-chip:first-child { border-left: 0; padding-left: 0; }
202 +.stat-chip b {
203 + color: var(--ink); font-weight: 700; font-size: 15px;
204 + font-family: var(--font-space-grotesk), system-ui, sans-serif; letter-spacing: -0.01em;
159 205 }
160 −.stat-chip b { color: var(--ink); font-weight: 700; }
161 206 .stat-chip .pulse {
162 − width: 8px; height: 8px; border-radius: 50%; background: var(--green);
163 − box-shadow: 0 0 0 4px var(--lime-soft); animation: pulse 2.4s ease infinite;
207 + width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
208 + align-self: center; animation: pulse 2.4s ease infinite;
164 209 }
165 −@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(255, 81, 72, 0.4); } }
210 +@keyframes pulse { 50% { box-shadow: 0 0 0 5px rgba(216, 55, 46, 0.18); } }
166 211
212 +/* surligné signature : marqueur rouge sous le texte (plus de boîte inclinée) */
167 213 .hl {
168 − background: var(--lime); padding: 0 10px; border-radius: 8px; display: inline-block;
169 − transform: rotate(-1deg);
214 + background: linear-gradient(transparent 64%, rgba(216, 55, 46, 0.3) 64%);
215 + padding: 0 2px; border-radius: 0; display: inline; transform: none;
170 216 }
171 −.outline-txt { color: transparent; -webkit-text-stroke: 2px var(--ink); }
217 +.outline-txt { color: transparent; -webkit-text-stroke: 1.5px var(--ink); }
218 +.vp-dark .outline-txt { -webkit-text-stroke: 1.5px var(--paper-on-ink); }
172 219
173 −/* ticker */
220 +/* ticker — bande de cotation (signature terminal) */
174 221 .ticker {
175 − background: var(--ink); color: var(--lime); overflow: hidden;
222 + background: var(--ink-deep); color: var(--paper-on-ink); overflow: hidden;
176 223 font-family: var(--font-jetbrains), ui-monospace, monospace;
177 − font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase;
178 − padding: 7px 0; white-space: nowrap;
179 − border-bottom: 1px solid rgba(255, 81, 72, 0.25);
224 + font-size: 10.5px; letter-spacing: 0.09em; text-transform: uppercase;
225 + padding: 6px 0; white-space: nowrap;
180 226 }
181 −.ticker-track { display: inline-flex; animation: ticker 44s linear infinite; will-change: transform; }
182 −.ticker span { padding: 0 26px; position: relative; }
183 −.ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.5; font-size: 8px; top: 3px; }
227 +.ticker-track { display: inline-flex; animation: ticker 48s linear infinite; will-change: transform; }
228 +.ticker span { padding: 0 24px; position: relative; opacity: 0.85; }
229 +.ticker span::after { content: ""; position: absolute; right: -2px; top: 50%; width: 4px; height: 4px; transform: translateY(-50%) rotate(45deg); background: var(--accent-bright); opacity: 0.8; }
184 230 @keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }
185 231 @media (prefers-reduced-motion: reduce) {
186 232 .ticker-track { animation: none; }
187 233 * { transition-duration: 0.01ms !important; }
188 234 }
189 235
190 −/* kv cells (fiche) */
236 +/* kv cells (fiche) — cellule de données éditoriale : filet haut, pas de boîte */
191 237 .kv-cell {
192 − background: var(--surface-2); border: 1.5px solid var(--line);
193 − border-radius: var(--r-ctl); padding: 10px 13px;
238 + background: transparent; border: 0; border-top: 1px solid var(--line);
239 + border-radius: 0; padding: 8px 2px 2px;
194 240 }
195 241 .kv-cell .k {
196 242 font-family: var(--font-jetbrains), ui-monospace, monospace;
197 243 font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.1em;
198 − color: var(--ink-3); font-weight: 700;
244 + color: var(--ink-3); font-weight: 500;
199 245 }
200 246 .kv-cell .v {
201 247 font-weight: 700; font-size: 14.5px; margin-top: 2px;
202 248 font-family: var(--font-space-grotesk), system-ui, sans-serif;
203 249 }
204 250
205 −/* pills */
251 +/* pills (indice de confiance) — carrés, fins */
206 252 .pill {
207 − display: inline-block; border-radius: 4px; padding: 3px 10px;
253 + display: inline-block; border-radius: 2px; padding: 3px 9px;
208 254 font-family: var(--font-jetbrains), ui-monospace, monospace;
209 255 font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
210 256 }
211 −.pill-conf-A { background: var(--lime); color: var(--ink); border: 1px solid var(--ink); }
212 −.pill-conf-B { background: var(--lime-soft); color: var(--green-deep); border: 1px solid var(--green); }
257 +.pill-conf-A { background: var(--ink); color: var(--paper); border: 1px solid var(--ink); }
258 +.pill-conf-B { background: transparent; color: var(--ink); border: 1px solid var(--line-strong); }
213 259 .pill-conf-C { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); }
214 260 .pill-conf-D { background: var(--danger-soft); color: var(--danger); border: 1px solid var(--danger); }
215 261
216 −/* tableau éditorial */
262 +/* tableau éditorial — filets fins, en-tête discret, zéro cadre épais */
217 263 .src-wrap {
218 − overflow-x: auto; border: 1.5px solid var(--ink); border-radius: var(--r-card);
219 − box-shadow: var(--shadow-off-soft); background: var(--surface);
264 + overflow-x: auto; border: 0; border-top: 2px solid var(--ink); border-radius: 0;
265 + box-shadow: none; background: transparent;
220 266 }
221 267 .src-table { width: 100%; border-collapse: separate; border-spacing: 0; }
222 268 .src-table th {
223 269 text-align: left; font-family: var(--font-jetbrains), ui-monospace, monospace;
224 270 font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
225 − padding: 12px 14px; border-bottom: 1.5px solid var(--ink); background: var(--surface-2);
226 − white-space: nowrap;
271 + padding: 10px 12px 8px; border-bottom: 1px solid var(--line-strong); background: transparent;
272 + white-space: nowrap; font-weight: 500;
227 273 }
228 −.src-table td { padding: 11px 14px; border-bottom: 1px solid var(--line); font-size: 13.5px; vertical-align: top; }
274 +.src-table td { padding: 12px; border-bottom: 1px solid var(--line); font-size: 13.5px; vertical-align: top; }
229 275 .src-table tr:last-child td { border-bottom: none; }
230 276 .src-table tr:hover td { background: var(--surface-2); }
231 277
278 +/* flux de ventes (accueil) — rangée prix-d'abord */
279 +.feed-row {
280 + display: block; border-bottom: 1px solid var(--line); padding: 14px 2px;
281 + transition: background 0.12s ease;
282 +}
283 +.feed-row:hover { background: var(--surface-2); }
284 +
285 +/* onglets de contrôle (période / catégorie) */
286 +.vp-tab {
287 + font-family: var(--font-jetbrains), ui-monospace, monospace;
288 + font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
289 + color: var(--ink-3); padding: 8px 2px 6px; border: 0; background: transparent;
290 + border-bottom: 2px solid transparent; cursor: pointer; min-height: 34px;
291 + transition: color 0.12s ease, border-color 0.12s ease;
292 +}
293 +.vp-tab:hover { color: var(--ink); }
294 +.vp-tab[aria-pressed="true"], .vp-tab[aria-selected="true"] {
295 + color: var(--ink); border-bottom-color: var(--accent);
296 +}
297 +
298 +/* infobulle de graphique */
299 +.vv-tip {
300 + position: absolute; pointer-events: none; z-index: 5;
301 + background: var(--ink-deep); color: var(--paper-on-ink);
302 + border-radius: 2px; padding: 8px 10px; min-width: 130px;
303 + font-family: var(--font-jetbrains), ui-monospace, monospace; font-size: 10.5px;
304 + box-shadow: 0 2px 10px rgba(20, 24, 20, 0.25);
305 +}
306 +.vv-tip .row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 1.5px 0; }
307 +.vv-tip .key { display: inline-block; width: 10px; height: 2px; margin-right: 6px; vertical-align: middle; }
308 +.vv-tip .val { font-weight: 700; color: #fff; }
309 +
310 +/* timeline méthodologie */
311 +.tl { position: relative; padding-left: 26px; }
312 +.tl::before {
313 + content: ""; position: absolute; left: 7px; top: 6px; bottom: 6px;
314 + width: 1px; background: var(--line-strong);
315 +}
316 +.tl-step { position: relative; padding: 0 0 26px; }
317 +.tl-step:last-child { padding-bottom: 0; }
318 +.tl-step::before {
319 + content: ""; position: absolute; left: -24px; top: 5px; width: 9px; height: 9px;
320 + background: var(--paper); border: 1.5px solid var(--ink); border-radius: 50%;
321 +}
322 +.tl-step[data-accent="true"]::before { background: var(--accent); border-color: var(--accent); }
323 +
232 324 /* animations de révélation */
233 325 @keyframes rise {
234 − from { opacity: 0; transform: translateY(12px); }
326 + from { opacity: 0; transform: translateY(10px); }
235 327 to { opacity: 1; transform: none; }
236 328 }
237 −.rise { animation: rise 0.55s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
329 +.rise { animation: rise 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
238 330 @keyframes growX { from { transform: scaleX(0); } }
239 331 .grow-x { transform-origin: left; animation: growX 0.9s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
240 332 @keyframes winPop { from { opacity: 0; } }
@@ -243,13 +335,13 @@ select.vp-input {
243 335 .rise, .grow-x, .vv-win { animation: none; }
244 336 }
245 337
246 −/* barres horizontales (historique) */
247 −.hbar-track { background: var(--surface-2); border-radius: 0 4px 4px 0; height: 18px; overflow: hidden; }
338 +/* barres horizontales (historique) — fines, encre, millésime courant en rouge */
339 +.hbar-track { background: var(--line-soft); border-radius: 0; height: 12px; overflow: hidden; }
248 340 .hbar-fill {
249 − display: block; height: 100%; background: var(--green); border-radius: 0 4px 4px 0;
341 + display: block; height: 100%; background: var(--ink); border-radius: 0;
250 342 min-width: 2px; transition: background 0.12s ease;
251 343 }
252 −.hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); }
344 +.hbar-row:hover .hbar-fill { background: var(--accent); }
253 345
254 346
255 347 /* Vrai-Prix Maps (framework Ka Maps par Groupe Ka) — recherche par carte */
@@ -258,20 +350,20 @@ select.vp-input {
258 350 height: 62vh;
259 351 min-height: 420px;
260 352 width: 100%;
261 − border: 1.5px solid var(--ink);
262 − border-radius: 16px;
353 + border: 1px solid var(--line-strong);
354 + border-radius: 2px;
263 355 overflow: hidden;
264 − box-shadow: 6px 6px 0 rgba(20, 24, 20, 0.22);
356 + box-shadow: none;
265 357 z-index: 0;
266 358 }
267 359 .vpm-wrap .ka-map {
268 − --ka-accent: var(--lime);
360 + --ka-accent: var(--accent);
269 361 --ka-on-accent: #ffffff;
270 362 --ka-surface: var(--surface);
271 363 --ka-ink: var(--ink);
272 − --ka-line: var(--ink);
273 − --ka-radius: 10px;
274 − --ka-shadow: 4px 4px 0 rgba(20, 24, 20, 0.2);
364 + --ka-line: var(--line-strong);
365 + --ka-radius: 2px;
366 + --ka-shadow: 0 2px 10px rgba(20, 24, 20, 0.18);
275 367 --ka-font: var(--font-inter), system-ui, sans-serif;
276 368 position: absolute;
277 369 inset: 0;
@@ -281,10 +373,10 @@ select.vp-input {
281 373 bottom: 12px;
282 374 left: 12px;
283 375 z-index: 9;
284 − background: var(--surface);
285 − color: var(--ink);
286 − border: 1.5px solid var(--ink);
287 − border-radius: 999px;
376 + background: var(--ink-deep);
377 + color: var(--paper-on-ink);
378 + border: 0;
379 + border-radius: 2px;
288 380 padding: 6px 12px;
289 381 font-family: var(--font-jetbrains), monospace;
290 382 font-size: 10.5px;
@@ -306,11 +398,11 @@ select.vp-input {
306 398 margin-top: 12px;
307 399 padding: 9px 14px;
308 400 background: var(--ink);
309 − color: var(--lime);
310 − border: 1.5px solid var(--ink);
311 − border-radius: 999px;
401 + color: var(--paper);
402 + border: 1px solid var(--ink);
403 + border-radius: 2px;
312 404 font-size: 12.5px;
313 405 font-weight: 600;
314 406 cursor: pointer;
315 407 }
316 −.vpm-pop-cta:active { transform: translate(2px, 2px); }
408 +.vpm-pop-cta:active { opacity: 0.85; }
modified src/app/layout.tsx +20 −14
@@ -111,25 +111,30 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
111 111 dangerouslySetInnerHTML={{ __html: JSON.stringify(ORG_JSONLD) }}
112 112 />
113 113 <LangProvider>
114 − {/* header opaque (standard header/menu KA 2026-08-22) */}
115 − <header className="sticky top-0 z-[var(--z-header,500)] border-b-2 border-ink bg-paper">
116 − <div className="relative mx-auto flex h-16 max-w-6xl items-center gap-5 px-4 sm:px-6">
114 + {/* header opaque compact (standard header/menu KA 2026-08-22) */}
115 + <header className="sticky top-0 z-[var(--z-header,500)] border-b border-[var(--line-strong)] bg-paper">
116 + <div className="relative mx-auto flex h-14 max-w-6xl items-center gap-6 px-4 sm:px-6">
117 117 <Link
118 118 href="/"
119 − className="vp-display group relative flex items-center text-[22px] font-bold leading-none tracking-[-0.04em] after:absolute after:-inset-2 after:content-[''] sm:text-[26px]"
119 + className="vp-display relative flex items-baseline gap-1.5 text-[19px] font-bold leading-none tracking-[-0.04em] after:absolute after:-inset-2 after:content-['']"
120 120 >
121 − Vrai
122 − <span className="ml-1 rotate-[-2deg] rounded-md bg-ink px-[7px] pb-1 pt-[2px] text-lime transition-transform duration-200 group-hover:rotate-0">
123 − Prix
124 − </span>
121 + Vrai&nbsp;Prix
122 + <span
123 + aria-hidden="true"
124 + className="inline-block h-[7px] w-[7px] bg-accent"
125 + />
125 126 </Link>
126 127 {/* badge Groupe KA — desktop seulement (l'espace manque sous xl ;
127 128 le menu mobile porte sa propre entrée) */}
128 129 <span className="hidden xl:inline-flex">
129 130 <GroupeKaBadge />
130 131 </span>
131 − <div className="ml-auto flex items-center gap-2">
132 + <div className="ml-auto flex items-center gap-3 sm:gap-4">
132 133 <HeaderNav />
134 + <span
135 + aria-hidden="true"
136 + className="hidden h-4 w-px bg-[var(--line)] lg:inline-block"
137 + />
133 138 <AccountButton />
134 139 <LangToggle />
135 140 </div>
@@ -147,11 +152,12 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
147 152 puis le bloc encre final = KaFooter (commun Groupe KA) */}
148 153 <div className="mx-auto max-w-6xl px-4 sm:px-6">
149 154 <div className="border-t-2 border-ink pt-7 text-[13px] text-ink-2">
150 − <p className="vp-display mb-3 text-[26px] font-bold tracking-[-0.04em] text-ink">
151 − Vrai
152 − <span className="ml-1 inline-block rotate-[-2deg] rounded-md bg-ink px-[6px] pb-[3px] pt-[1px] text-lime">
153 − Prix
154 − </span>
155 + <p className="vp-display mb-3 flex items-baseline gap-2 text-[24px] font-bold tracking-[-0.04em] text-ink">
156 + Vrai&nbsp;Prix
157 + <span
158 + aria-hidden="true"
159 + className="inline-block h-[8px] w-[8px] bg-accent"
160 + />
155 161 </p>
156 162 <p className="max-w-2xl">
157 163 Estimations bâties sur les rôles d&apos;évaluation foncière du
modified src/app/methodologie/MethodologieClient.tsx +70 −46
@@ -192,71 +192,95 @@ export default function MethodologieClient() {
192 192 <span>{fr ? "Méthodologie" : "Methodology"}</span>
193 193 </nav>
194 194 <span className="kicker">{fr ? "Le calcul, en entier" : "The math, in full"}</span>
195 − <h1 className="vp-display mt-3 max-w-3xl text-[clamp(30px,5vw,52px)] font-bold uppercase leading-[1] tracking-[-0.03em]">
195 + <h1 className="vp-display mt-3 max-w-3xl text-[clamp(32px,5.5vw,58px)] font-bold uppercase leading-[0.98] tracking-[-0.03em]">
196 196 {fr ? (
197 197 <>
198 − Méthodologie <span className="hl">ultra-détaillée</span>
198 + Voici exactement
199 + <br />
200 + <span className="hl">comment le prix est calculé</span>
199 201 </>
200 202 ) : (
201 203 <>
202 − The <span className="hl">full</span> methodology
204 + Here is exactly
205 + <br />
206 + <span className="hl">how the price is computed</span>
203 207 </>
204 208 )}
205 209 </h1>
206 − <p className="mt-4 max-w-2xl text-[16px] text-ink-2">
210 + <p className="mt-4 max-w-2xl text-[15.5px] leading-relaxed text-ink-2">
207 211 {fr
208 212 ? "Notre différenciateur est la transparence totale. Cette page documente chaque étape — données, fusion, modèle, validation, moteur — au niveau de détail d'un rapport technique."
209 213 : "Our differentiator is total transparency. This page documents every step — data, merge, model, validation, engine — at technical-report depth."}
210 214 </p>
211 215
212 − <div className="mt-10 space-y-8">
216 + {/* la chaîne de calcul, d'un coup d'œil */}
217 + <ol className="mt-9 grid gap-x-6 gap-y-4 border-y border-[var(--line)] py-5 sm:grid-cols-3 lg:grid-cols-6">
218 + {(fr
219 + ? ["Propriété identifiée", "Rôle d'évaluation récupéré", "Ventes comparables sélectionnées", "Caractéristiques ajustées", "Estimation calculée", "Intervalle de confiance produit"]
220 + : ["Property identified", "Assessment roll retrieved", "Comparable sales selected", "Characteristics adjusted", "Estimate computed", "Confidence interval produced"]
221 + ).map((step, i, arr) => (
222 + <li key={step} className="relative flex items-start gap-2.5 lg:block">
223 + <span className={`vp-mono text-[11px] font-bold ${i === arr.length - 1 ? "text-accent" : "text-ink-3"}`}>
224 + 0{i + 1}
225 + </span>
226 + <p className="vp-display text-[13px] font-bold uppercase leading-tight tracking-[-0.01em] lg:mt-1.5">
227 + {step}
228 + {i < arr.length - 1 && <span className="ml-1.5 text-ink-3" aria-hidden="true">→</span>}
229 + </p>
230 + </li>
231 + ))}
232 + </ol>
233 +
234 + <div className="mt-12 space-y-12">
213 235 {sections.map((sct) => (
214 − <section key={sct.num} className="vp-card p-6 sm:p-8">
215 − <div className="flex items-baseline gap-4">
216 − <span className="vp-mono text-[13px] font-bold text-green">{sct.num}</span>
217 − <h2 className="vp-display text-[20px] font-bold uppercase tracking-[-0.02em]">
236 + <section key={sct.num} className="sec grid gap-x-10 gap-y-3 lg:grid-cols-[110px_1fr]">
237 + <span className="vp-display text-[clamp(26px,3vw,36px)] font-bold leading-none text-accent lg:pt-1">
238 + {sct.num}
239 + </span>
240 + <div>
241 + <h2 className="vp-display text-[clamp(19px,2.4vw,24px)] font-bold uppercase tracking-[-0.02em]">
218 242 {sct.title}
219 243 </h2>
220 − </div>
221 − <div className="mt-4 space-y-3.5">
222 − {sct.paras.map((p, i) => (
223 − <p key={i} className="max-w-3xl text-[14.5px] leading-relaxed text-ink-2">
224 − {p}
225 − </p>
226 − ))}
227 − </div>
228 − {sct.kv && (
229 − <div className="mt-5 grid grid-cols-2 gap-2.5 sm:grid-cols-3">
230 − {sct.kv.map(([k, v]) => (
231 − <div key={k} className="kv-cell">
232 − <p className="k">{k}</p>
233 − <p className="v">{v}</p>
234 − </div>
244 + <div className="mt-4 space-y-3.5">
245 + {sct.paras.map((p, i) => (
246 + <p key={i} className="max-w-3xl text-[14.5px] leading-relaxed text-ink-2">
247 + {p}
248 + </p>
235 249 ))}
236 250 </div>
237 − )}
238 − {sct.table && (
239 − <div className="src-wrap mt-5">
240 − <table className="src-table">
241 − <thead>
242 − <tr>
243 − {sct.table.head.map((h) => (
244 − <th key={h}>{h}</th>
245 − ))}
246 − </tr>
247 − </thead>
248 − <tbody>
249 − {sct.table.rows.map(([a, b, c]) => (
250 − <tr key={a}>
251 − <td className="font-semibold">{a}</td>
252 − <td className="vp-mono text-[12.5px]">{b}</td>
253 − <td className="vp-mono text-[12.5px]">{c}</td>
251 + {sct.kv && (
252 + <div className="mt-5 grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-3">
253 + {sct.kv.map(([k, v]) => (
254 + <div key={k} className="kv-cell">
255 + <p className="k">{k}</p>
256 + <p className="v">{v}</p>
257 + </div>
258 + ))}
259 + </div>
260 + )}
261 + {sct.table && (
262 + <div className="src-wrap mt-6 max-w-3xl">
263 + <table className="src-table">
264 + <thead>
265 + <tr>
266 + {sct.table.head.map((h) => (
267 + <th key={h}>{h}</th>
268 + ))}
254 269 </tr>
255 − ))}
256 − </tbody>
257 − </table>
258 − </div>
259 − )}
270 + </thead>
271 + <tbody>
272 + {sct.table.rows.map(([a, b, c]) => (
273 + <tr key={a}>
274 + <td className="font-semibold">{a}</td>
275 + <td className="vp-mono text-[12.5px]">{b}</td>
276 + <td className="vp-mono text-[12.5px]">{c}</td>
277 + </tr>
278 + ))}
279 + </tbody>
280 + </table>
281 + </div>
282 + )}
283 + </div>
260 284 </section>
261 285 ))}
262 286 </div>
modified src/app/municipalite/[slug]/[type]/page.tsx +2 −2
@@ -164,7 +164,7 @@ export default async function MunTypePage({
164 164 <li key={x.type}>
165 165 <Link
166 166 href={`/municipalite/${slug}/${TYPE_SLUGS[x.type] ?? x.type}`}
167 − className="stat-chip hover:bg-lime"
167 + className="stat-chip transition-colors hover:text-accent-deep"
168 168 >
169 169 {TYPE_LABELS[x.type]?.pluriel ?? x.type}
170 170 <b>{numFr(x.n)}</b>
@@ -172,7 +172,7 @@ export default async function MunTypePage({
172 172 </li>
173 173 ))}
174 174 <li>
175 − <Link href={`/municipalite/${slug}`} className="stat-chip hover:bg-lime">
175 + <Link href={`/municipalite/${slug}`} className="stat-chip transition-colors hover:text-accent-deep">
176 176 ← Tout {mun.nom}
177 177 </Link>
178 178 </li>
modified src/app/municipalite/[slug]/page.tsx +1 −1
@@ -219,7 +219,7 @@ export default async function MunicipalitePage({
219 219 <li key={v.slug}>
220 220 <Link
221 221 href={`/municipalite/${v.slug}`}
222 − className="stat-chip hover:bg-lime"
222 + className="stat-chip transition-colors hover:text-accent-deep"
223 223 >
224 224 {v.nom}
225 225 </Link>
modified src/app/page.tsx +21 −16
@@ -36,11 +36,11 @@ const TOP_VILLES = (
36 36 stats as { par_ville: { ville: string; n: number; mediane: number | null }[] }
37 37 ).par_ville.slice(0, 24);
38 38
39 −/** Séries d'indice par grand type — 25 derniers mois, avec variations. */
39 +/** Séries d'indice par grand type — historique complet (le client fenêtre 12/24/tout). */
40 40 function buildPulse(): PulseItem[] {
41 41 return (["unifamilial", "condo", "plex"] as const).map((type) => {
42 42 const full = getMarketIndex(type);
43 − const series = full.slice(-25);
43 + const series = full;
44 44 const last = full.length ? full[full.length - 1].idx : null;
45 45 const yearAgo = full.length >= 13 ? full[full.length - 13].idx : null;
46 46 const first = full.length >= 2 ? full[0].idx : null;
@@ -89,33 +89,38 @@ export default function Home() {
89 89 />
90 90 {/* ---- Explorer par municipalité (maillage interne indexable) ---- */}
91 91 <section className="mt-14">
92 − <span className="kicker">Explorer par municipalité</span>
93 − <h2 className="vp-display mt-3 text-[clamp(22px,3.6vw,34px)] font-bold uppercase leading-[1.05] tracking-[-0.03em]">
94 − La valeur des propriétés, ville par ville
95 − </h2>
96 − <p className="mt-3 max-w-2xl text-[15px] text-ink-2">
97 − Statistiques d&apos;évaluation, valeur médiane et propriétés estimées
98 − pour chacune des 1 097 municipalités du Québec.
99 − </p>
100 − <ul className="mt-6 grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-3 lg:grid-cols-4">
92 + <header className="sec">
93 + <span className="sec-num">05 — Explorer par municipalité</span>
94 + <h2 className="vp-display mt-2 max-w-3xl text-[clamp(24px,3.6vw,36px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">
95 + La valeur des propriétés, ville par ville
96 + </h2>
97 + <p className="mt-3 max-w-2xl text-[14.5px] leading-relaxed text-ink-2">
98 + Statistiques d&apos;évaluation, valeur médiane et propriétés estimées
99 + pour chacune des 1 097 municipalités du Québec.
100 + </p>
101 + </header>
102 + <ul className="mt-6 grid grid-cols-2 gap-x-8 sm:grid-cols-3 lg:grid-cols-4">
101 103 {TOP_VILLES.map((v) => (
102 104 <li key={v.ville}>
103 105 <Link
104 106 href={`/municipalite/${slugify(v.ville)}`}
105 − className="group flex items-baseline justify-between gap-2 border-b border-[rgba(20,24,20,0.12)] py-1.5 text-[14px]"
107 + className="group flex items-baseline justify-between gap-2 border-b border-[rgba(20,24,20,0.1)] py-2 text-[13.5px]"
106 108 >
107 − <span className="font-medium group-hover:underline">
109 + <span className="font-medium group-hover:text-accent-deep">
108 110 {v.ville}
109 111 </span>
110 − <span className="vp-mono text-[11px] text-ink-2">
112 + <span className="vp-mono text-[10.5px] text-ink-3">
111 113 {v.mediane != null ? moneyFr(v.mediane) : ""}
112 114 </span>
113 115 </Link>
114 116 </li>
115 117 ))}
116 118 </ul>
117 − <p className="mt-5">
118 − <Link href="/municipalites" className="btn btn-ghost">
119 + <p className="mt-6">
120 + <Link
121 + href="/municipalites"
122 + className="vp-mono border-b border-accent pb-px text-[11px] uppercase tracking-[0.08em] text-accent-deep hover:text-accent"
123 + >
119 124 Toutes les municipalités →
120 125 </Link>
121 126 </p>
modified src/components/AccountButton.tsx +5 −5
@@ -45,7 +45,7 @@ export default function AccountButton() {
45 45 return (
46 46 <a
47 47 href={`/api/auth/ka/login?next=${encodeURIComponent(path || "/")}`}
48 − className="vp-mono inline-flex min-h-[36px] items-center rounded-full border-[1.5px] border-ink bg-ink px-4 py-1.5 text-[12px] font-bold uppercase tracking-[0.08em] text-lime transition-all hover:bg-green-deep"
48 + className="vp-mono inline-flex min-h-[36px] items-center rounded-[2px] bg-ink px-3.5 py-1.5 text-[11px] font-medium uppercase tracking-[0.1em] text-paper transition-colors hover:bg-accent"
49 49 title={fr ? "Se connecter avec KA ID" : "Sign in with KA ID"}
50 50 >
51 51 KA&nbsp;ID
@@ -57,7 +57,7 @@ export default function AccountButton() {
57 57 return (
58 58 <Link
59 59 href="/compte"
60 − className="group inline-flex min-h-[36px] items-center gap-2 rounded-full border-[1.5px] border-ink bg-surface py-1 pl-1 pr-3 transition-all hover:bg-lime-soft"
60 + className="group inline-flex min-h-[36px] items-center gap-2 py-1"
61 61 title={fr ? "Mon compte — carte de membre Groupe KA" : "My account — Groupe KA member card"}
62 62 >
63 63 {user.picture ? (
@@ -66,14 +66,14 @@ export default function AccountButton() {
66 66 src={user.picture}
67 67 alt=""
68 68 referrerPolicy="no-referrer"
69 − className="h-7 w-7 rounded-full border border-ink object-cover"
69 + className="h-7 w-7 rounded-full object-cover grayscale transition-[filter] group-hover:grayscale-0"
70 70 />
71 71 ) : (
72 − <span className="vp-display flex h-7 w-7 items-center justify-center rounded-full bg-ink text-[13px] font-bold text-lime">
72 + <span className="vp-display flex h-7 w-7 items-center justify-center rounded-full bg-ink text-[13px] font-bold text-paper">
73 73 {prenom.charAt(0).toUpperCase()}
74 74 </span>
75 75 )}
76 − <span className="vp-display max-w-[92px] truncate text-[13px] font-bold text-ink">
76 + <span className="vp-display max-w-[92px] truncate border-b-2 border-transparent text-[13px] font-bold text-ink group-hover:border-accent">
77 77 {prenom}
78 78 </span>
79 79 </Link>
modified src/components/CookieConsent.tsx +5 −5
@@ -164,8 +164,8 @@ export default function CookieConsent() {
164 164 <>
165 165 {showBanner && (
166 166 <div className="fixed inset-x-0 bottom-0 z-[var(--z-toast,950)] p-3 pb-[calc(12px+env(safe-area-inset-bottom))] sm:p-5 sm:pb-[calc(20px+env(safe-area-inset-bottom))]">
167 − <div className="mx-auto max-w-3xl rounded-[10px] border-[1.5px] border-ink bg-ink p-5 text-paper shadow-[0_-8px_32px_rgba(16,18,16,0.35),6px_6px_0_rgba(20,24,20,0.3)]">
168 − <p className="klabel !text-lime">🍪 {t.prefsTitle}</p>
167 + <div className="mx-auto max-w-3xl rounded-[2px] bg-[var(--ink-deep)] p-5 text-paper shadow-[0_-8px_32px_rgba(16,18,16,0.35)]">
168 + <p className="klabel !text-[var(--accent-bright)]">🍪 {t.prefsTitle}</p>
169 169 <p className="mt-2 text-[13.5px] leading-relaxed text-[rgba(245,243,238,0.85)]">
170 170 {t.banner}{" "}
171 171 <a href="/confidentialite" className="border-b border-lime text-lime">
@@ -173,7 +173,7 @@ export default function CookieConsent() {
173 173 </a>
174 174 </p>
175 175 <div className="mt-4 flex flex-wrap gap-2.5">
176 − <button className="btn border-lime bg-lime text-ink" onClick={() => persist(true, true)}>
176 + <button className="btn btn-accent" onClick={() => persist(true, true)}>
177 177 {t.acceptAll}
178 178 </button>
179 179 <button
@@ -199,7 +199,7 @@ export default function CookieConsent() {
199 199 onClick={() => setPanel(false)}
200 200 >
201 201 <div
202 − className="w-full max-w-lg rounded-[10px] border-[1.5px] border-ink bg-surface p-6 shadow-[8px_8px_0_rgba(20,24,20,0.3)]"
202 + className="w-full max-w-lg rounded-[2px] border border-[var(--line-strong)] bg-surface p-6 shadow-[0_12px_40px_rgba(20,24,20,0.3)]"
203 203 onClick={(e) => e.stopPropagation()}
204 204 role="dialog"
205 205 aria-modal="true"
@@ -214,7 +214,7 @@ export default function CookieConsent() {
214 214 { k: t.prefs, d: t.prefsDesc, on: prefs, lock: false, set: setPrefs },
215 215 { k: t.analytics, d: t.analyticsDesc, on: analytics, lock: false, set: setAnalytics },
216 216 ].map((row) => (
217 − <div key={row.k} className="flex items-start justify-between gap-4 rounded-lg border-[1.5px] border-[rgba(20,24,20,0.14)] bg-surface-2 p-4">
217 + <div key={row.k} className="flex items-start justify-between gap-4 border-t border-[var(--line)] p-4 pl-0">
218 218 <div>
219 219 <p className="vp-display text-[14px] font-bold">
220 220 {row.k}
modified src/components/HeaderNav.tsx +34 −31
@@ -40,16 +40,16 @@ export default function HeaderNav() {
40 40
41 41 return (
42 42 <>
43 − {/* nav pilule — desktop */}
44 − <nav className="hidden items-center gap-1 lg:flex">
43 + {/* nav texte — desktop : soulignement rouge = page courante */}
44 + <nav className="hidden items-center gap-5 lg:flex">
45 45 {items.map((it) => (
46 46 <Link
47 47 key={it.href}
48 48 href={it.href}
49 − className={`vp-display inline-flex min-h-[38px] items-center rounded-full border-[1.5px] px-3.5 py-1.5 text-[13.5px] font-bold transition-all ${
49 + className={`vp-mono inline-flex min-h-[38px] items-center border-b-2 pt-[2px] text-[11px] font-medium uppercase tracking-[0.1em] transition-colors ${
50 50 isActive(it.href)
51 − ? "border-ink bg-ink text-lime"
52 − : "border-transparent text-ink-2 hover:border-ink hover:text-ink"
51 + ? "border-accent text-ink"
52 + : "border-transparent text-ink-2 hover:text-ink"
53 53 }`}
54 54 >
55 55 {it.label}
@@ -71,39 +71,36 @@ export default function HeaderNav() {
71 71 <label
72 72 htmlFor="vp-menu"
73 73 aria-hidden="true"
74 − className="flex h-11 w-11 cursor-pointer flex-col items-center justify-center gap-[5px] rounded-full border-[1.5px] border-ink bg-surface active:bg-lime-soft"
74 + className="flex h-11 w-11 cursor-pointer flex-col items-center justify-center gap-[5px]"
75 75 >
76 − <span className="h-[2px] w-[18px] bg-ink" />
77 − <span className="h-[2px] w-[18px] bg-ink" />
78 − <span className="h-[2px] w-[18px] bg-ink" />
76 + <span className="h-px w-[20px] bg-ink" />
77 + <span className="h-px w-[20px] bg-ink" />
78 + <span className="h-px w-[12px] self-center ml-[8px] bg-accent" />
79 79 </label>
80 80
81 − {/* panneau plein écran — sibling du checkbox, affiché via peer-checked */}
81 + {/* panneau plein écran encre — sibling du checkbox, affiché via peer-checked */}
82 82 <div
83 − className="fixed inset-0 z-[var(--z-modal,900)] hidden flex-col overflow-y-auto peer-checked:flex"
84 − style={{ background: "var(--paper)" }}
83 + className="vp-dark fixed inset-0 z-[var(--z-modal,900)] hidden flex-col overflow-y-auto peer-checked:flex"
85 84 role="dialog"
86 85 aria-modal="true"
87 86 aria-label="Menu"
88 87 >
89 88 {/* barre du haut : marque + fermer */}
90 − <div
91 − className="flex h-16 shrink-0 items-center justify-between border-b-2 border-ink px-4"
92 − style={{ background: "var(--paper)" }}
93 − >
89 + <div className="flex h-14 shrink-0 items-center justify-between border-b border-[rgba(245,243,238,0.18)] px-4">
94 90 <Link
95 91 href="/"
96 − className="vp-display flex items-center text-[22px] font-bold leading-none tracking-[-0.04em]"
92 + className="vp-display flex items-baseline gap-1.5 text-[19px] font-bold leading-none tracking-[-0.04em] text-[var(--paper-on-ink)]"
97 93 >
98 − Vrai
99 − <span className="ml-1 rotate-[-2deg] rounded-md bg-ink px-[7px] pb-1 pt-[2px] text-lime">
100 − Prix
101 − </span>
94 + Vrai&nbsp;Prix
95 + <span
96 + aria-hidden="true"
97 + className="inline-block h-[7px] w-[7px] bg-[var(--accent-bright)]"
98 + />
102 99 </Link>
103 100 <label
104 101 htmlFor="vp-menu"
105 102 aria-label={fr ? "Fermer le menu" : "Close menu"}
106 − className="vp-display flex h-11 w-11 cursor-pointer items-center justify-center rounded-full border-[1.5px] border-ink bg-ink text-[17px] font-bold text-lime"
103 + className="vp-mono flex h-11 w-11 cursor-pointer items-center justify-center text-[15px] text-[var(--paper-on-ink)]"
107 104 >
108 105 ✕
109 106 </label>
@@ -115,37 +112,43 @@ export default function HeaderNav() {
115 112 <Link
116 113 key={it.href}
117 114 href={it.href}
118 − className="group flex items-baseline gap-4 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.18)] py-5 last:border-0"
115 + className="group flex items-baseline gap-4 border-b border-[rgba(245,243,238,0.12)] py-[18px] last:border-0"
119 116 >
120 − <span className="vp-mono text-[12px] font-bold text-green">
117 + <span className="vp-mono text-[11px] font-medium text-[var(--accent-bright)]">
121 118 0{i + 1}
122 119 </span>
123 120 <span
124 − className={`vp-display text-[29px] font-bold uppercase leading-none tracking-[-0.03em] ${
121 + className={`vp-display text-[28px] font-bold uppercase leading-none tracking-[-0.03em] ${
125 122 isActive(it.href)
126 − ? "rounded-md bg-ink px-2.5 py-1 text-lime"
127 − : "text-ink group-active:text-green-deep"
123 + ? "text-[var(--accent-bright)]"
124 + : "text-[var(--paper-on-ink)] group-active:text-[var(--accent-bright)]"
128 125 }`}
129 126 >
130 127 {it.label}
131 128 </span>
129 + {isActive(it.href) && (
130 + <span
131 + aria-hidden="true"
132 + className="mb-[2px] inline-block h-[6px] w-[6px] self-center bg-[var(--accent-bright)]"
133 + />
134 + )}
132 135 </Link>
133 136 ))}
134 137 </nav>
135 138
136 139 {/* pied du menu */}
137 − <div className="shrink-0 border-t-2 border-ink px-6 pb-[max(20px,env(safe-area-inset-bottom))] pt-4">
140 + <div className="shrink-0 border-t border-[rgba(245,243,238,0.18)] px-6 pb-[max(20px,env(safe-area-inset-bottom))] pt-4">
138 141 {/* entrée « Un service Groupe KA » du menu mobile */}
139 − <div className="mb-3">
142 + <div className="mb-4">
140 143 <GroupeKaBadge />
141 144 </div>
142 145 <button
143 146 onClick={() => setLang(fr ? "en" : "fr")}
144 − className="vp-mono mb-3 rounded-full border-[1.5px] border-ink bg-surface px-4 py-2 text-[12px] font-bold uppercase tracking-[0.08em]"
147 + className="vp-mono mb-4 border-b border-[var(--paper-on-ink)] pb-1 text-[12px] font-medium uppercase tracking-[0.1em] text-[var(--paper-on-ink)]"
145 148 >
146 149 {fr ? "English version" : "Version française"}
147 150 </button>
148 − <div className="vp-mono flex flex-wrap gap-x-5 gap-y-1.5 text-[10.5px] uppercase tracking-[0.06em] text-ink-3">
151 + <div className="vp-mono flex flex-wrap gap-x-5 gap-y-1.5 text-[10.5px] uppercase tracking-[0.06em] text-[rgba(245,243,238,0.55)]">
149 152 <Link href="/conditions">{fr ? "Conditions" : "Terms"}</Link>
150 153 <Link href="/confidentialite">
151 154 {fr ? "Confidentialité" : "Privacy"}
modified src/components/KaChat.tsx +5 −5
@@ -316,8 +316,8 @@ export default function KaChat() {
316 316 >
317 317 {messages.length === 0 && (
318 318 <div className="my-auto text-center">
319 − <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border-[1.5px] border-ink bg-ink shadow-[5px_5px_0_var(--lime)] sm:h-16 sm:w-16">
320 − <span className="vp-display text-[22px] font-bold text-lime sm:text-[26px]">Ka</span>
319 + <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-[2px] bg-[var(--ink-deep)] sm:h-16 sm:w-16">
320 + <span className="vp-display text-[22px] font-bold text-[var(--accent-bright)] sm:text-[26px]">Ka</span>
321 321 </div>
322 322 <p className="vp-display px-2 text-[17px] font-bold sm:text-[19px]">
323 323 {fr ? "Dis-moi quelle propriété t'intéresse." : "Tell me which property you're curious about."}
@@ -332,7 +332,7 @@ export default function KaChat() {
332 332 <button
333 333 key={s}
334 334 onClick={() => send(s)}
335 − className="rounded-full border-[1.5px] border-ink bg-surface px-3.5 py-2 text-[12.5px] font-semibold transition-all hover:bg-lime-soft hover:shadow-[3px_3px_0_var(--ink)] active:translate-y-[1px]"
335 + className="rounded-[2px] border border-[var(--line-strong)] bg-surface px-3.5 py-2 text-[12.5px] font-semibold transition-colors hover:border-accent hover:text-accent-deep"
336 336 >
337 337 {s}
338 338 </button>
@@ -349,8 +349,8 @@ export default function KaChat() {
349 349 </div>
350 350 ) : (
351 351 <div className="flex w-full max-w-full gap-2 sm:max-w-[94%] sm:gap-3">
352 − <div className="mt-0.5 hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg border-[1.5px] border-ink bg-ink sm:flex">
353 − <span className="vp-display text-[12px] font-bold text-lime">Ka</span>
352 + <div className="mt-0.5 hidden h-8 w-8 shrink-0 items-center justify-center rounded-[2px] bg-[var(--ink-deep)] sm:flex">
353 + <span className="vp-display text-[12px] font-bold text-[var(--accent-bright)]">Ka</span>
354 354 </div>
355 355 <div className="min-w-0 flex-1">
356 356 {m.tools && m.tools.length > 0 && (
modified src/components/LangContext.tsx +1 −1
@@ -37,7 +37,7 @@ export function LangToggle() {
37 37 return (
38 38 <button
39 39 onClick={() => setLang(lang === "fr" ? "en" : "fr")}
40 − className="vp-mono min-h-[36px] rounded-full border-[1.5px] border-ink bg-surface px-4 py-1.5 text-[12px] font-bold uppercase tracking-[0.08em] text-ink transition-all hover:bg-ink hover:text-lime"
40 + className="vp-mono min-h-[36px] border-b-2 border-transparent px-1 py-1.5 text-[11px] font-medium uppercase tracking-[0.1em] text-ink-2 transition-colors hover:border-accent hover:text-ink"
41 41 aria-label="Changer de langue / Switch language"
42 42 >
43 43 {lang === "fr" ? "EN" : "FR"}
modified src/components/LeadForm.tsx +7 −5
@@ -29,25 +29,27 @@ export default function LeadForm({
29 29
30 30 if (done)
31 31 return (
32 − <p className="vp-mono rounded-md border border-lime bg-[rgba(255,81,72,0.12)] px-4 py-3 text-[12px] font-bold uppercase tracking-[0.05em] text-lime">
32 + <p className="vp-mono border-l-2 border-[var(--accent-bright)] py-1 pl-4 text-[12px] font-bold uppercase tracking-[0.05em] text-[var(--paper-on-ink)]">
33 33 ✓ {t("leadOk")}
34 34 </p>
35 35 );
36 36 return (
37 − <form onSubmit={submit} className="flex flex-col gap-2.5 sm:flex-row">
37 + <form onSubmit={submit} className="flex flex-col gap-3 sm:flex-row sm:gap-0">
38 38 <input
39 39 type="email"
40 40 required
41 41 value={email}
42 42 onChange={(e) => setEmail(e.target.value)}
43 43 placeholder={t("emailPlaceholder")}
44 − className={`min-h-[44px] flex-1 rounded-md border-[1.5px] bg-[rgba(245,243,238,0.08)] px-3.5 py-2.5 text-paper placeholder-[rgba(245,243,238,0.4)] outline-none transition-shadow focus:shadow-[3px_3px_0_var(--lime)] ${
45 − err ? "border-danger" : "border-[rgba(245,243,238,0.35)] focus:border-lime"
44 + className={`min-h-[50px] flex-1 border-0 border-b bg-transparent px-1 py-2.5 text-[16px] text-[var(--paper-on-ink)] outline-none transition-colors placeholder:text-[rgba(245,243,238,0.4)] ${
45 + err
46 + ? "border-[var(--danger)]"
47 + : "border-[rgba(245,243,238,0.45)] focus:border-[var(--accent-bright)]"
46 48 }`}
47 49 />
48 50 <button
49 51 type="submit"
50 − className="btn min-h-[44px] border-lime bg-lime text-ink shadow-[4px_4px_0_rgba(255,81,72,0.25)] hover:bg-paper"
52 + className="vp-display flex min-h-[50px] shrink-0 items-center justify-center gap-2 bg-[var(--accent-bright)] px-6 text-[14px] font-bold text-ink transition-colors hover:bg-[#ff6b63]"
51 53 >
52 54 {t("leadBtn")} →
53 55 </button>
modified src/components/MetricViz.tsx +428 −184
@@ -1,14 +1,14 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 2 /**
3 3 * MetricViz — visualisations signature générées par les données du registre.
4 − * Chaque propriété produit ses propres dessins : façade SVG (étages, logements,
5 − * genre de construction), schéma de terrain à l'échelle (frontage × profondeur),
6 − * composition de valeur terrain/bâtiment, jauge de confiance, sparkline 2021-2026,
7 − * compteurs animés. SVG pur, palette encre/lime, aucun aléatoire : mêmes données,
8 − * même dessin.
4 + * Langage v2 « terminal éditorial » : lignes 2 px, grilles hairline pleines,
5 + * étiquettes directes sur les marques, point courant rouge signal, infobulles
6 + * crosshair au pointeur ET au toucher. SVG pur, aucun aléatoire : mêmes données,
7 + * même dessin. Palette séries validée (CVD 28,6 / contraste ≥ 3:1) :
8 + * encre #141814 · rouge #d8372e · gris #6b736d.
9 9 */
10 10 "use client";
11 −import { useEffect, useState } from "react";
11 +import { useEffect, useRef, useState } from "react";
12 12
13 13 /** Nombre localisé (« 1 206,6 » en FR, « 1,206.6 » en EN). */
14 14 export function locNum(
@@ -23,6 +23,14 @@ export function locNum(
23 23 return opts?.unit ? `${s} ${opts.unit}` : s;
24 24 }
25 25
26 +const MONTHS_FR = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."];
27 +const MONTHS_EN = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
28 +function fmtMonth(month: string, lang: string): string {
29 + const [y, m] = month.split("-").map(Number);
30 + const names = lang === "fr" ? MONTHS_FR : MONTHS_EN;
31 + return `${names[(m ?? 1) - 1]} ${y}`;
32 +}
33 +
26 34 /* ---------------------------- compteur animé ---------------------------- */
27 35 /** Rendu serveur = valeur finale (jamais « 0 $ » à l'écran) ; anime 0 → valeur au montage. */
28 36 export function CountUp({
@@ -72,38 +80,50 @@ export function ConfidenceDial({
72 80 const CIRC = Math.PI * R; // demi-cercle
73 81 const frac = mounted ? Math.max(0.02, Math.min(1, pct / 100)) : 0.02;
74 82 const color =
75 − level === "A" ? "var(--lime)" : level === "B" ? "var(--green)" : level === "C" ? "var(--amber)" : "var(--danger)";
83 + level === "A" || level === "B"
84 + ? "var(--ink)"
85 + : level === "C"
86 + ? "var(--amber)"
87 + : "var(--danger)";
76 88 return (
77 89 <div className="flex flex-col items-center" aria-label={`${label} : ${level} ${pct} %`}>
78 − <svg viewBox="0 0 128 78" className="w-[128px]">
79 − <path d="M 12 66 A 52 52 0 0 1 116 66" fill="none" stroke="var(--surface-2)" strokeWidth="12" strokeLinecap="round" />
80 − <path d="M 12 66 A 52 52 0 0 1 116 66" fill="none" stroke="rgba(20,24,20,0.85)" strokeWidth="13.5" strokeLinecap="round" opacity="0.12" />
90 + <svg viewBox="0 0 128 78" className="w-[124px]">
91 + <path d="M 12 66 A 52 52 0 0 1 116 66" fill="none" stroke="var(--line)" strokeWidth="8" strokeLinecap="butt" />
81 92 <path
82 93 d="M 12 66 A 52 52 0 0 1 116 66"
83 94 fill="none"
84 95 stroke={color}
85 − strokeWidth="12"
86 − strokeLinecap="round"
96 + strokeWidth="8"
97 + strokeLinecap="butt"
87 98 strokeDasharray={`${CIRC * frac} ${CIRC}`}
88 99 style={{ transition: "stroke-dasharray 1s cubic-bezier(.2,.8,.2,1)" }}
89 100 />
90 101 {/* graduations */}
91 102 {[0, 0.25, 0.5, 0.75, 1].map((g) => {
92 103 const a = Math.PI * (1 - g);
93 − const x1 = 64 + Math.cos(a) * 40;
94 − const y1 = 66 - Math.sin(a) * 40;
95 − const x2 = 64 + Math.cos(a) * 45;
96 − const y2 = 66 - Math.sin(a) * 45;
97 − return <line key={g} x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--ink)" strokeWidth="1.4" opacity="0.5" />;
104 + const x1 = 64 + Math.cos(a) * 42;
105 + const y1 = 66 - Math.sin(a) * 42;
106 + const x2 = 64 + Math.cos(a) * 46;
107 + const y2 = 66 - Math.sin(a) * 46;
108 + return <line key={g} x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--ink)" strokeWidth="1" opacity="0.4" />;
98 109 })}
99 − <text x="64" y="52" textAnchor="middle" fontSize="26" fontWeight="700" fill="var(--ink)" fontFamily="var(--font-space-grotesk)">
110 + {/* pointe de lecture — rouge signal */}
111 + {(() => {
112 + const a = Math.PI * (1 - Math.max(0.02, Math.min(1, pct / 100)));
113 + const x1 = 64 + Math.cos(a) * 44;
114 + const y1 = 66 - Math.sin(a) * 44;
115 + const x2 = 64 + Math.cos(a) * 60;
116 + const y2 = 66 - Math.sin(a) * 60;
117 + return <line x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--accent)" strokeWidth="2" />;
118 + })()}
119 + <text x="64" y="52" textAnchor="middle" fontSize="27" fontWeight="700" fill="var(--ink)" fontFamily="var(--font-space-grotesk)">
100 120 {level}
101 121 </text>
102 − <text x="64" y="70" textAnchor="middle" fontSize="10.5" fontWeight="700" fill="var(--ink-2)" fontFamily="var(--font-jetbrains)">
122 + <text x="64" y="70" textAnchor="middle" fontSize="10.5" fontWeight="500" fill="var(--ink-2)" fontFamily="var(--font-jetbrains)">
103 123 {pct} %
104 124 </text>
105 125 </svg>
106 − <p className="klabel mt-1 text-center">{label}</p>
126 + <p className="klabel mt-1 max-w-[150px] text-center">{label}</p>
107 127 </div>
108 128 );
109 129 }
@@ -113,7 +133,7 @@ export function ConfidenceDial({
113 133 function VacantGlyph({ lang }: { lang: string }) {
114 134 const tree = (x: number, sc: number) => (
115 135 <g transform={`translate(${x}, 128) scale(${sc})`}>
116 − <path d="M0,-46 L13,-14 L5,-14 L16,8 L-16,8 L-5,-14 L-13,-14 Z" fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" />
136 + <path d="M0,-46 L13,-14 L5,-14 L16,8 L-16,8 L-5,-14 L-13,-14 Z" fill="none" stroke="var(--ink)" strokeWidth="1.6" />
117 137 <rect x="-2.5" y="8" width="5" height="9" fill="var(--ink)" />
118 138 </g>
119 139 );
@@ -125,9 +145,9 @@ function VacantGlyph({ lang }: { lang: string }) {
125 145 {/* borne d'arpenteur */}
126 146 <g transform="translate(24, 128)">
127 147 <rect x="-2.5" y="-26" width="5" height="26" fill="var(--ink)" />
128 − <rect x="-8" y="-34" width="16" height="10" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.4" />
148 + <rect x="-8" y="-34" width="16" height="10" fill="var(--accent)" stroke="var(--ink)" strokeWidth="1.4" />
129 149 </g>
130 − <line x1="8" y1="145" x2="182" y2="145" stroke="var(--ink)" strokeWidth="2.2" />
150 + <line x1="8" y1="145" x2="182" y2="145" stroke="var(--ink)" strokeWidth="1.6" />
131 151 <text x="95" y="120" textAnchor="middle" fontSize="9" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-2)" letterSpacing="2">
132 152 {lang === "fr" ? "SANS BÂTIMENT" : "NO BUILDING"}
133 153 </text>
@@ -178,9 +198,9 @@ export function BuildingGlyph({
178 198 y={bodyTop + f * fh + 6}
179 199 width="12"
180 200 height="11"
181 − fill={idx < nD ? "var(--lime)" : "var(--surface-2)"}
201 + fill={idx < nD ? "var(--accent)" : "var(--surface-2)"}
182 202 stroke="var(--ink)"
183 − strokeWidth="1.2"
203 + strokeWidth="1"
184 204 className="vv-win"
185 205 style={{ animationDelay: `${idx * 60}ms` }}
186 206 />
@@ -191,33 +211,30 @@ export function BuildingGlyph({
191 211 return (
192 212 <svg viewBox={`0 0 ${W} 150`} className="w-full max-w-[210px]" aria-label="Silhouette du bâtiment selon le registre">
193 213 {/* soleil-repère */}
194 − <circle cx={W - 22} cy="22" r="9" fill="none" stroke="var(--ink)" strokeWidth="1.5" />
195 − <circle cx={W - 22} cy="22" r="3.5" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1" />
196 − {/* ombre décalée signature */}
197 − <rect x={bx + 5} y={bodyTop + 5} width={bw} height={nF * fh} fill="rgba(20,24,20,0.12)" />
214 + <circle cx={W - 22} cy="22" r="9" fill="none" stroke="var(--ink)" strokeWidth="1.2" />
215 + <circle cx={W - 22} cy="22" r="3" fill="var(--accent)" />
198 216 {/* corps */}
199 − <rect x={bx} y={bodyTop} width={bw} height={nF * fh} fill="var(--surface)" stroke="var(--ink)" strokeWidth="2" />
217 + <rect x={bx} y={bodyTop} width={bw} height={nF * fh} fill="var(--surface)" stroke="var(--ink)" strokeWidth="1.6" />
200 218 {/* séparations d'étages */}
201 219 {Array.from({ length: nF - 1 }, (_, i) => (
202 − <line key={i} x1={bx} y1={bodyTop + (i + 1) * fh} x2={bx + bw} y2={bodyTop + (i + 1) * fh} stroke="var(--ink)" strokeWidth="1" strokeDasharray="3 3" opacity="0.4" />
220 + <line key={i} x1={bx} y1={bodyTop + (i + 1) * fh} x2={bx + bw} y2={bodyTop + (i + 1) * fh} stroke="var(--ink)" strokeWidth="0.8" opacity="0.35" />
203 221 ))}
204 222 {/* toit */}
205 223 {flat ? (
206 − <rect x={bx - 6} y={bodyTop - 8} width={bw + 12} height="8" fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" />
224 + <rect x={bx - 6} y={bodyTop - 8} width={bw + 12} height="8" fill="var(--ink)" />
207 225 ) : mansard ? (
208 − <path d={`M ${bx - 6} ${bodyTop} L ${bx + 10} ${bodyTop - 20} L ${bx + bw - 10} ${bodyTop - 20} L ${bx + bw + 6} ${bodyTop} Z`} fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" />
226 + <path d={`M ${bx - 6} ${bodyTop} L ${bx + 10} ${bodyTop - 20} L ${bx + bw - 10} ${bodyTop - 20} L ${bx + bw + 6} ${bodyTop} Z`} fill="var(--ink)" />
209 227 ) : (
210 − <path d={`M ${bx - 8} ${bodyTop} L ${bx + bw / 2} ${bodyTop - 24} L ${bx + bw + 8} ${bodyTop} Z`} fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" />
228 + <path d={`M ${bx - 8} ${bodyTop} L ${bx + bw / 2} ${bodyTop - 24} L ${bx + bw + 8} ${bodyTop} Z`} fill="var(--ink)" />
211 229 )}
212 230 {/* cheminée */}
213 − <rect x={bx + bw - 22} y={bodyTop - (flat ? 20 : 30)} width="9" height={flat ? 14 : 18} fill="var(--ink)" />
231 + <rect x={bx + bw - 22} y={bodyTop - (flat ? 20 : 30)} width="8" height={flat ? 14 : 18} fill="var(--ink)" />
214 232 {windows}
215 233 {/* porte */}
216 234 <rect x={bx + bw / 2 - 7} y={groundY - 17} width="14" height="17" fill="var(--ink)" />
217 − <circle cx={bx + bw / 2 + 3.5} cy={groundY - 8} r="1.4" fill="var(--lime)" />
235 + <circle cx={bx + bw / 2 + 3.5} cy={groundY - 8} r="1.4" fill="var(--accent)" />
218 236 {/* sol */}
219 − <line x1="8" y1={groundY} x2={W - 8} y2={groundY} stroke="var(--ink)" strokeWidth="2.2" />
220 − <line x1="8" y1={groundY + 4} x2={W - 8} y2={groundY + 4} stroke="var(--ink)" strokeWidth="1" opacity="0.3" />
237 + <line x1="8" y1={groundY} x2={W - 8} y2={groundY} stroke="var(--ink)" strokeWidth="1.6" />
221 238 {/* millésime gravé */}
222 239 {yearBuilt && (
223 240 <text x={W / 2} y="146" textAnchor="middle" fontSize="10" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-2)" letterSpacing="2">
@@ -246,7 +263,7 @@ export function LotDiagram({
246 263 if (!areaM2 || areaM2 <= 0) {
247 264 return (
248 265 <svg viewBox="0 0 190 150" className="w-full max-w-[210px]" aria-label={lang === "fr" ? "Terrain non applicable" : "Lot not applicable"}>
249 − <rect x="30" y="30" width="130" height="86" fill="var(--surface-2)" stroke="var(--ink)" strokeWidth="1.6" strokeDasharray="5 5" rx="6" />
266 + <rect x="30" y="30" width="130" height="86" fill="var(--surface-2)" stroke="var(--ink)" strokeWidth="1.2" strokeDasharray="5 5" />
250 267 <text x="95" y="66" textAnchor="middle" fontSize="20" fill="var(--ink-3)">⌂</text>
251 268 <text x="95" y="90" textAnchor="middle" fontSize="8.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-2)" letterSpacing="1">
252 269 {isCondo
@@ -284,35 +301,34 @@ export function LotDiagram({
284 301 <svg viewBox="0 0 190 150" className="w-full max-w-[210px]" aria-label="Schéma du terrain à l'échelle">
285 302 <defs>
286 303 <pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
287 − <line x1="0" y1="0" x2="0" y2="7" stroke="var(--green)" strokeWidth="1" opacity="0.35" />
304 + <line x1="0" y1="0" x2="0" y2="7" stroke="var(--ink)" strokeWidth="0.8" opacity="0.18" />
288 305 </pattern>
289 306 </defs>
290 − {/* ombre + parcelle */}
291 − <rect x={x + 4} y={y + 4} width={w} height={h} fill="rgba(20,24,20,0.12)" />
292 − <rect x={x} y={y} width={w} height={h} fill="var(--lime-soft)" stroke="var(--ink)" strokeWidth="2" />
307 + {/* parcelle */}
308 + <rect x={x} y={y} width={w} height={h} fill="var(--surface-2)" stroke="var(--ink)" strokeWidth="1.6" />
293 309 <rect x={x} y={y} width={w} height={h} fill="url(#hatch)" />
294 310 {/* empreinte bâtiment */}
295 311 {fpFrac > 0.005 && (
296 − <rect x={x + (w - fw) / 2} y={y + h - fh - h * 0.08} width={fw} height={fh} fill="var(--ink)" rx="2">
312 + <rect x={x + (w - fw) / 2} y={y + h - fh - h * 0.08} width={fw} height={fh} fill="var(--ink)">
297 313 <title>{lang === "fr" ? "Empreinte approximative du bâtiment" : "Approximate building footprint"}</title>
298 314 </rect>
299 315 )}
300 316 {/* cotes : frontage */}
301 − <line x1={x} y1={y + h + 12} x2={x + w} y2={y + h + 12} stroke="var(--ink)" strokeWidth="1.3" />
302 − <line x1={x} y1={y + h + 8} x2={x} y2={y + h + 16} stroke="var(--ink)" strokeWidth="1.3" />
303 − <line x1={x + w} y1={y + h + 8} x2={x + w} y2={y + h + 16} stroke="var(--ink)" strokeWidth="1.3" />
317 + <line x1={x} y1={y + h + 12} x2={x + w} y2={y + h + 12} stroke="var(--ink)" strokeWidth="1" />
318 + <line x1={x} y1={y + h + 8} x2={x} y2={y + h + 16} stroke="var(--ink)" strokeWidth="1" />
319 + <line x1={x + w} y1={y + h + 8} x2={x + w} y2={y + h + 16} stroke="var(--ink)" strokeWidth="1" />
304 320 <text x={x + w / 2} y={y + h + 24} textAnchor="middle" fontSize="9.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink)">
305 321 {frontageM ? fmtM(front) : `≈ ${fmtM(front)}`}
306 322 </text>
307 323 {/* cotes : profondeur */}
308 − <line x1={x - 10} y1={y} x2={x - 10} y2={y + h} stroke="var(--ink)" strokeWidth="1.3" />
309 − <line x1={x - 14} y1={y} x2={x - 6} y2={y} stroke="var(--ink)" strokeWidth="1.3" />
310 − <line x1={x - 14} y1={y + h} x2={x - 6} y2={y + h} stroke="var(--ink)" strokeWidth="1.3" />
324 + <line x1={x - 10} y1={y} x2={x - 10} y2={y + h} stroke="var(--ink)" strokeWidth="1" />
325 + <line x1={x - 14} y1={y} x2={x - 6} y2={y} stroke="var(--ink)" strokeWidth="1" />
326 + <line x1={x - 14} y1={y + h} x2={x - 6} y2={y + h} stroke="var(--ink)" strokeWidth="1" />
311 327 <text x={x - 16} y={y + h / 2} textAnchor="middle" fontSize="9.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink)" transform={`rotate(-90 ${x - 16} ${y + h / 2})`}>
312 328 ≈ {fmtM(depth)}
313 329 </text>
314 330 {/* rue */}
315 − <line x1="8" y1="136" x2="182" y2="136" stroke="var(--ink)" strokeWidth="2.2" />
331 + <line x1="8" y1="136" x2="182" y2="136" stroke="var(--ink)" strokeWidth="1.6" />
316 332 <text x="95" y="147" textAnchor="middle" fontSize="8.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)" letterSpacing="3">
317 333 {lang === "fr" ? "RUE" : "STREET"}
318 334 </text>
@@ -351,42 +367,43 @@ export function ValueSplit({
351 367 const delta = previous && previous > 0 && total ? ((total / previous - 1) * 100) : null;
352 368 return (
353 369 <div>
354 − <div className="flex h-9 w-full overflow-hidden rounded-md border-[1.5px] border-ink">
370 + {/* segments séparés par un jour de 2 px (le fond fait la séparation) */}
371 + <div className="flex h-7 w-full gap-[2px]">
355 372 {lPct >= 1 && (
356 373 <div
357 − className="flex items-center justify-center overflow-hidden bg-green transition-[width] duration-1000 ease-out"
374 + className="flex items-center justify-center overflow-hidden bg-[var(--chart-3)] transition-[width] duration-1000 ease-out"
358 375 style={{ width: on ? `${lPct}%` : "2%" }}
359 376 >
360 377 {lPct >= 14 && (
361 − <span className="vp-mono px-1 text-[10px] font-bold text-paper">{Math.round(lPct)} %</span>
378 + <span className="vp-mono px-1 text-[10px] font-bold text-white">{Math.round(lPct)} %</span>
362 379 )}
363 380 </div>
364 381 )}
365 382 {lPct <= 99 && (
366 383 <div className="flex flex-1 items-center justify-center overflow-hidden bg-ink">
367 384 {100 - lPct >= 14 && (
368 − <span className="vp-mono px-1 text-[10px] font-bold text-lime">{Math.round(100 - lPct)} %</span>
385 + <span className="vp-mono px-1 text-[10px] font-bold text-paper">{Math.round(100 - lPct)} %</span>
369 386 )}
370 387 </div>
371 388 )}
372 389 </div>
373 390 <div className="vp-mono mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10.5px] uppercase tracking-[0.05em]">
374 391 <span className="flex items-center gap-1.5 text-ink-2">
375 − <span className="inline-block h-2.5 w-2.5 rounded-[2px] border border-ink bg-green" />
392 + <span className="inline-block h-2.5 w-2.5 bg-[var(--chart-3)]" />
376 393 {labels.land} {fmt(l)}
377 394 </span>
378 395 <span className="flex items-center gap-1.5 text-ink-2">
379 − <span className="inline-block h-2.5 w-2.5 rounded-[2px] border border-ink bg-ink" />
396 + <span className="inline-block h-2.5 w-2.5 bg-ink" />
380 397 {labels.building} {fmt(b)}
381 398 </span>
382 399 </div>
383 400 {delta != null && (
384 401 <p className="vp-mono mt-2.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
385 402 {labels.previous} {fmt(previous!)}{" "}
386 − <span className={`ml-1 rounded-[4px] border border-ink px-1.5 py-0.5 font-bold ${delta >= 0 ? "bg-lime text-ink" : "bg-danger text-white"}`}>
403 + <b className={delta >= 0 ? "ml-1 text-ink" : "ml-1 text-[var(--danger)]"}>
387 404 {delta >= 0 ? "+" : ""}
388 405 {delta.toFixed(1)} %
389 − </span>
406 + </b>
390 407 </p>
391 408 )}
392 409 </div>
@@ -412,20 +429,15 @@ export function Sparkline({
412 429 height - 8 - ((p.value - min) / span) * (height - 18),
413 430 ]);
414 431 const line = xy.map(([x, y]) => `${x},${y}`).join(" ");
415 − const area = `${xy[0][0]},${height - 4} ${line} ${xy[xy.length - 1][0]},${height - 4}`;
416 432 const growth = ((pts[pts.length - 1].value / pts[0].value - 1) * 100);
417 433 const [x2, y2] = xy[xy.length - 1];
418 434 return (
419 435 <div className="flex items-end gap-2">
420 436 <svg viewBox={`0 0 ${W} ${height}`} width={W} height={height} aria-label={`Évolution ${pts[0].year}-${pts[pts.length - 1].year}`}>
421 − <polygon points={area} fill="var(--lime-soft)" />
422 − <polyline points={line} fill="none" stroke="var(--green)" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round" />
423 − {xy.slice(0, -1).map(([x, y], i) => (
424 − <circle key={i} cx={x} cy={y} r="2" fill="var(--surface)" stroke="var(--ink)" strokeWidth="1.1" />
425 − ))}
426 − <circle cx={x2} cy={y2} r="3.6" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.5" />
437 + <polyline points={line} fill="none" stroke="var(--ink)" strokeWidth="1.8" strokeLinejoin="round" strokeLinecap="round" />
438 + <circle cx={x2} cy={y2} r="3.6" fill="var(--accent)" stroke="var(--paper)" strokeWidth="2" />
427 439 </svg>
428 − <span className={`vp-mono mb-0.5 rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${growth >= 0 ? "bg-lime text-ink" : "bg-danger text-white"}`}>
440 + <span className={`vp-mono mb-0.5 text-[10.5px] font-bold ${growth >= 0 ? "text-ink" : "text-[var(--danger)]"}`}>
429 441 {growth >= 0 ? "+" : ""}
430 442 {growth.toFixed(0)} %
431 443 </span>
@@ -434,7 +446,7 @@ export function Sparkline({
434 446 }
435 447
436 448 /* ------------------------- fourchette visuelle P10-P90 ------------------------- */
437 −/** Barre de fourchette : segment P10→P90, losange = estimation, tiret = rôle. */
449 +/** Barre de fourchette : segment P10→P90, losange rouge = estimation, tiret = rôle. */
438 450 export function RangeBar({
439 451 low,
440 452 estimate,
@@ -460,7 +472,7 @@ export function RangeBar({
460 472 const min = lo - pad;
461 473 const max = hi + pad;
462 474 const X = (v: number) => Math.max(0, Math.min(100, ((v - min) / (max - min)) * 100));
463 − // les étiquettes (centrées, nowrap) restent dans la carte même aux extrêmes
475 + // les étiquettes (centrées, nowrap) restent dans la composition même aux extrêmes
464 476 const clampX = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, X(v)));
465 477 const fmt = (v: number) =>
466 478 new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {
@@ -477,46 +489,47 @@ export function RangeBar({
477 489 Math.abs(X(role) - X(high)) < 15);
478 490 return (
479 491 <div aria-label={`${fmt(low)} – ${fmt(high)}`}>
480 − <div className="relative h-[64px]">
492 + <div className="relative h-[62px]">
481 493 {/* étiquette estimation */}
482 494 <div
483 495 className="absolute top-0 -translate-x-1/2 whitespace-nowrap transition-[left] duration-700 ease-out"
484 496 style={{ left: `${on ? clampX(estimate, 18, 82) : 50}%` }}
485 497 >
486 − <span className="vp-mono rounded-[4px] border border-ink bg-ink px-1.5 py-0.5 text-[10px] font-bold text-lime">
498 + <span className="vp-mono bg-ink px-1.5 py-0.5 text-[10px] font-bold text-paper">
487 499 {lang === "fr" ? "ESTIMATION" : "ESTIMATE"} {fmt(estimate)}
488 500 </span>
489 501 </div>
490 − {/* piste */}
491 − <div className="absolute left-0 right-0 top-[30px] h-[14px] rounded-[7px] border-[1.5px] border-ink bg-surface-2" />
492 − {/* segment P10-P90 */}
502 + {/* piste hairline */}
503 + <div className="absolute left-0 right-0 top-[35px] h-px bg-[var(--line-strong)] opacity-40" />
504 + {/* segment P10-P90 — encre pleine, fine */}
493 505 <div
494 − className="absolute top-[30px] h-[14px] origin-left rounded-[7px] border-[1.5px] border-ink transition-transform duration-1000 ease-out"
506 + className="absolute top-[33px] h-[5px] origin-left bg-ink transition-transform duration-1000 ease-out"
495 507 style={{
496 508 left: `${X(low)}%`,
497 509 width: `${Math.max(2, X(high) - X(low))}%`,
498 510 transform: on ? "scaleX(1)" : "scaleX(0.05)",
499 − background:
500 − "repeating-linear-gradient(-45deg, var(--lime-soft), var(--lime-soft) 5px, rgba(255,81,72,0.35) 5px, rgba(255,81,72,0.35) 7px)",
501 511 }}
502 512 />
503 − {/* marqueur estimation (losange) */}
513 + {/* bornes : ticks */}
514 + <div className="absolute top-[28px] h-[15px] w-px bg-ink" style={{ left: `${X(low)}%` }} />
515 + <div className="absolute top-[28px] h-[15px] w-px bg-ink" style={{ left: `${X(high)}%` }} />
516 + {/* marqueur estimation (losange rouge signal) */}
504 517 <div
505 − className="absolute top-[29px] -translate-x-1/2 transition-[left] duration-700 ease-out"
518 + className="absolute top-[35px] -translate-x-1/2 -translate-y-1/2 transition-[left] duration-700 ease-out"
506 519 style={{ left: `${on ? X(estimate) : 50}%` }}
507 520 >
508 − <div className="h-[16px] w-[16px] rotate-45 border-[1.8px] border-ink bg-lime" />
521 + <div className="h-[13px] w-[13px] rotate-45 border-2 border-[var(--paper)] bg-[var(--accent)]" />
509 522 </div>
510 523 {/* marqueur rôle */}
511 524 {role != null && role > 0 && (
512 525 <>
513 526 <div
514 − className="absolute top-[22px] h-[30px] w-0 -translate-x-1/2 border-l-[2px] border-dashed border-ink opacity-70"
527 + className="absolute top-[24px] h-[23px] w-0 -translate-x-1/2 border-l border-dashed border-ink opacity-70"
515 528 style={{ left: `${X(role)}%` }}
516 529 />
517 530 {!roleClose && (
518 531 <span
519 − className="vp-mono absolute top-[52px] -translate-x-1/2 whitespace-nowrap text-[9px] font-bold uppercase tracking-[0.06em] text-ink-2"
532 + className="vp-mono absolute top-[49px] -translate-x-1/2 whitespace-nowrap text-[9px] font-medium uppercase tracking-[0.06em] text-ink-2"
520 533 style={{ left: `${clampX(role, 11, 89)}%` }}
521 534 >
522 535 {lang === "fr" ? "Rôle" : "Roll"} {fmt(role)}
@@ -526,13 +539,13 @@ export function RangeBar({
526 539 )}
527 540 {/* bornes */}
528 541 <span
529 − className="vp-mono absolute top-[50px] -translate-x-1/2 whitespace-nowrap text-[10px] font-bold text-ink-2"
542 + className="vp-mono absolute top-[49px] -translate-x-1/2 whitespace-nowrap text-[10px] font-medium text-ink-2"
530 543 style={{ left: `${clampX(low, 11, 89)}%` }}
531 544 >
532 545 P10 {fmt(low)}
533 546 </span>
534 547 <span
535 − className="vp-mono absolute top-[50px] -translate-x-1/2 whitespace-nowrap text-[10px] font-bold text-ink-2"
548 + className="vp-mono absolute top-[49px] -translate-x-1/2 whitespace-nowrap text-[10px] font-medium text-ink-2"
536 549 style={{ left: `${clampX(high, 11, 89)}%` }}
537 550 >
538 551 P90 {fmt(high)}
@@ -542,6 +555,213 @@ export function RangeBar({
542 555 );
543 556 }
544 557
558 +/* ----------------------- pouls du marché (multi-séries) ----------------------- */
559 +export interface PulseChartSeries {
560 + key: string;
561 + label: string;
562 + color: string; // var(--chart-1|2|3)
563 + series: { month: string; idx: number }[];
564 +}
565 +
566 +/**
567 + * Comparaison des grands types sur une même échelle (base 100 au début de la
568 + * fenêtre). Étiquettes directes en fin de ligne + légende + crosshair tactile :
569 + * l'identité ne repose jamais sur la couleur seule.
570 + */
571 +export function MarketPulseChart({
572 + data,
573 + lang,
574 + months,
575 +}: {
576 + data: PulseChartSeries[];
577 + lang: string;
578 + /** Fenêtre en mois (null = tout l'historique). */
579 + months: number | null;
580 +}) {
581 + const wrap = useRef<HTMLDivElement>(null);
582 + const [hi, setHi] = useState<number | null>(null);
583 +
584 + // fenêtre commune : mois de la série la plus longue, tronquée à `months`
585 + const longest = data.reduce((a, b) => (b.series.length > a.series.length ? b : a), data[0]);
586 + if (!longest || longest.series.length < 2) return null;
587 + const windowMonths = (months ? longest.series.slice(-(months + 1)) : longest.series).map((p) => p.month);
588 + const n = windowMonths.length;
589 + if (n < 2) return null;
590 +
591 + // valeurs rebasées à 100 au premier mois de la fenêtre, par série
592 + const rebased = data.map((s) => {
593 + const byMonth = new Map(s.series.map((p) => [p.month, p.idx]));
594 + const first = windowMonths.find((m) => byMonth.has(m));
595 + const base = first != null ? byMonth.get(first)! : null;
596 + return {
597 + ...s,
598 + vals: windowMonths.map((m) => {
599 + const v = byMonth.get(m);
600 + return v != null && base ? (v / base) * 100 : null;
601 + }),
602 + };
603 + });
604 +
605 + const W = 720;
606 + const H = 264;
607 + const padL = 34;
608 + const padR = 8;
609 + const padT = 14;
610 + const padB = 40; // réserve l'espace des étiquettes directes sous la ligne de base
611 + const all = rebased.flatMap((s) => s.vals).filter((v): v is number => v != null);
612 + const min = Math.min(...all);
613 + const max = Math.max(...all);
614 + const span = max - min || 1;
615 + const x = (i: number) => padL + (i / (n - 1)) * (W - padL - padR);
616 + const y = (v: number) => padT + (1 - (v - min) / span) * (H - padT - padB);
617 +
618 + // grille : 100 + min/max arrondis à 5
619 + const gridVals = [...new Set([Math.ceil(min / 5) * 5, 100, Math.floor(max / 5) * 5])].filter(
620 + (v) => v >= min - 0.5 && v <= max + 0.5
621 + );
622 + // graduations x : janvier de chaque année (ou premier/dernier si fenêtre courte)
623 + const yearTicks = windowMonths
624 + .map((m, i) => ({ i, m }))
625 + .filter((p) => p.m.endsWith("-01"));
626 +
627 + // étiquettes directes en fin de ligne, anticollision (≥ 15 px d'écart)
628 + const ends = rebased
629 + .map((s) => {
630 + const lastIdx = s.vals.reduce<number>((acc, v, i) => (v != null ? i : acc), -1);
631 + return lastIdx >= 0 ? { s, i: lastIdx, v: s.vals[lastIdx]! } : null;
632 + })
633 + .filter((e): e is { s: (typeof rebased)[number]; i: number; v: number } => e != null)
634 + .sort((a, b) => y(a.v) - y(b.v));
635 + const labelYs: number[] = [];
636 + for (const e of ends) {
637 + let ly = y(e.v);
638 + const prev = labelYs[labelYs.length - 1];
639 + if (prev != null && ly - prev < 15) ly = prev + 15;
640 + labelYs.push(ly);
641 + }
642 +
643 + const onMove = (clientX: number) => {
644 + const el = wrap.current;
645 + if (!el) return;
646 + const r = el.getBoundingClientRect();
647 + const fx = ((clientX - r.left) / r.width) * W;
648 + const i = Math.round(((fx - padL) / (W - padL - padR)) * (n - 1));
649 + setHi(Math.max(0, Math.min(n - 1, i)));
650 + };
651 +
652 + const fmtIdx = (v: number | null) =>
653 + v == null ? "—" : v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", { maximumFractionDigits: 1 });
654 +
655 + return (
656 + <div className="relative" ref={wrap}>
657 + <svg
658 + viewBox={`0 0 ${W} ${H}`}
659 + className="h-auto w-full touch-pan-y select-none"
660 + role="img"
661 + aria-label={
662 + lang === "fr"
663 + ? `Indice de marché par type, ${windowMonths[0]} à ${windowMonths[n - 1]}`
664 + : `Market index by type, ${windowMonths[0]} to ${windowMonths[n - 1]}`
665 + }
666 + onPointerDown={(e) => onMove(e.clientX)}
667 + onPointerMove={(e) => onMove(e.clientX)}
668 + onPointerLeave={() => setHi(null)}
669 + >
670 + {/* grille hairline pleine */}
671 + {gridVals.map((v) => (
672 + <g key={v}>
673 + <line x1={padL} y1={y(v)} x2={W - padR} y2={y(v)} stroke="var(--ink)" strokeWidth="0.7" opacity={v === 100 ? 0.35 : 0.12} />
674 + <text x={padL - 6} y={y(v) + 3.5} textAnchor="end" fontSize="10" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)">
675 + {v}
676 + </text>
677 + </g>
678 + ))}
679 + {yearTicks.map((p) => (
680 + <text key={p.m} x={x(p.i)} y={H - padB + 16} textAnchor="middle" fontSize="10" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)">
681 + {p.m.slice(0, 4)}
682 + </text>
683 + ))}
684 + {/* crosshair */}
685 + {hi != null && (
686 + <line x1={x(hi)} y1={padT} x2={x(hi)} y2={H - padB} stroke="var(--ink)" strokeWidth="0.8" opacity="0.4" />
687 + )}
688 + {/* lignes */}
689 + {rebased.map((s) => {
690 + const pts = s.vals
691 + .map((v, i) => (v != null ? `${x(i).toFixed(1)},${y(v).toFixed(1)}` : null))
692 + .filter(Boolean)
693 + .join(" ");
694 + return (
695 + <polyline
696 + key={s.key}
697 + points={pts}
698 + fill="none"
699 + stroke={s.color}
700 + strokeWidth="2"
701 + strokeLinejoin="round"
702 + strokeLinecap="round"
703 + />
704 + );
705 + })}
706 + {/* points survolés (anneau papier) */}
707 + {hi != null &&
708 + rebased.map((s) =>
709 + s.vals[hi] != null ? (
710 + <circle key={s.key} cx={x(hi)} cy={y(s.vals[hi]!)} r="4" fill={s.color} stroke="var(--paper)" strokeWidth="2" />
711 + ) : null
712 + )}
713 + {/* fins de ligne : point + étiquette directe (série + valeur).
714 + Étiquettes masquées en mobile (SVG trop réduit) : la légende et les
715 + colonnes de lecture sous le graphique portent alors l'identité. */}
716 + {ends.map((e, k) => (
717 + <g key={e.s.key}>
718 + <circle cx={x(e.i)} cy={y(e.v)} r="3.5" fill={e.s.color} stroke="var(--paper)" strokeWidth="2" />
719 + <g className="hidden sm:block">
720 + {labelYs[k] !== y(e.v) && (
721 + <line x1={x(e.i)} y1={y(e.v)} x2={x(e.i) - 4} y2={labelYs[k]} stroke={e.s.color} strokeWidth="0.8" opacity="0.6" />
722 + )}
723 + <text
724 + x={x(e.i) - 8}
725 + y={labelYs[k] + 3.5}
726 + textAnchor="end"
727 + fontSize="10.5"
728 + fontWeight="700"
729 + fontFamily="var(--font-jetbrains)"
730 + fill="var(--ink)"
731 + style={{ paintOrder: "stroke", stroke: "var(--paper)", strokeWidth: 3 }}
732 + >
733 + {e.s.label} · {Math.round(e.v)}
734 + </text>
735 + </g>
736 + </g>
737 + ))}
738 + </svg>
739 + {/* infobulle : toutes les séries au mois visé */}
740 + {hi != null && (
741 + <div
742 + className="vv-tip"
743 + style={{
744 + left: `${Math.min(78, Math.max(2, (x(hi) / W) * 100))}%`,
745 + top: 0,
746 + transform: "translateX(-50%)",
747 + }}
748 + >
749 + <p className="mb-1 uppercase tracking-[0.08em] opacity-70">{fmtMonth(windowMonths[hi], lang)}</p>
750 + {rebased.map((s) => (
751 + <p key={s.key} className="row">
752 + <span>
753 + <span className="key" style={{ background: s.color === "var(--chart-1)" ? "var(--paper)" : s.color }} />
754 + {s.label}
755 + </span>
756 + <span className="val">{fmtIdx(s.vals[hi])}</span>
757 + </p>
758 + ))}
759 + </div>
760 + )}
761 + </div>
762 + );
763 +}
764 +
545 765 /* --------------------------- courbe d'indice de marché --------------------------- */
546 766 /** Indice mensuel rebasé à 100 au premier mois — la trajectoire du marché local. */
547 767 export function MarketIndexChart({
@@ -551,15 +771,17 @@ export function MarketIndexChart({
551 771 series: { month: string; idx: number }[];
552 772 lang: string;
553 773 }) {
774 + const wrap = useRef<HTMLDivElement>(null);
775 + const [hi, setHi] = useState<number | null>(null);
554 776 if (series.length < 6) return null;
555 777 const base = series[0].idx || 1;
556 778 const pts = series.map((p) => (p.idx / base) * 100);
557 779 const W = 640;
558 780 const H = 200;
559 − const padL = 40;
560 − const padR = 52;
561 − const padT = 18;
562 − const padB = 28;
781 + const padL = 36;
782 + const padR = 46;
783 + const padT = 14;
784 + const padB = 26;
563 785 const min = Math.min(...pts);
564 786 const max = Math.max(...pts);
565 787 const span = max - min || 1;
@@ -571,98 +793,124 @@ export function MarketIndexChart({
571 793 const years = series
572 794 .map((p, i) => ({ i, month: p.month }))
573 795 .filter((p) => p.month.endsWith("-01"));
574 − // lignes horizontales : min / 100 / max arrondis
575 796 const gridVals = [...new Set([Math.round(min), 100, Math.round(max)])].filter(
576 797 (v) => v >= min - 1 && v <= max + 1
577 798 );
578 799 const lastV = pts[pts.length - 1];
800 +
801 + const onMove = (clientX: number) => {
802 + const el = wrap.current;
803 + if (!el) return;
804 + const r = el.getBoundingClientRect();
805 + const fx = ((clientX - r.left) / r.width) * W;
806 + const i = Math.round(((fx - padL) / (W - padL - padR)) * (pts.length - 1));
807 + setHi(Math.max(0, Math.min(pts.length - 1, i)));
808 + };
809 +
579 810 return (
580 − <svg
581 − viewBox={`0 0 ${W} ${H}`}
582 − className="h-auto w-full"
583 − role="img"
584 − aria-label={
585 − lang === "fr"
586 − ? `Indice de marché ${series[0].month} à ${series[series.length - 1].month}`
587 − : `Market index ${series[0].month} to ${series[series.length - 1].month}`
588 − }
589 − >
590 − {gridVals.map((v) => (
591 − <g key={v}>
592 − <line
593 − x1={padL}
594 − y1={y(v)}
595 − x2={W - padR}
596 − y2={y(v)}
597 − stroke="var(--ink)"
598 − strokeWidth="1"
599 − strokeDasharray="3 5"
600 − opacity={v === 100 ? 0.45 : 0.18}
601 − />
602 − <text
603 − x={padL - 6}
604 − y={y(v) + 3.5}
605 − textAnchor="end"
606 − fontSize="10"
607 − fontFamily="var(--font-jetbrains)"
608 − fill="var(--ink-3)"
609 − >
610 − {v}
611 − </text>
612 − </g>
613 − ))}
614 − {years.map((p) => (
615 − <g key={p.month}>
616 − <line
617 − x1={x(p.i)}
618 − y1={padT}
619 − x2={x(p.i)}
620 − y2={H - padB}
621 − stroke="var(--ink)"
622 − strokeWidth="1"
623 − opacity="0.1"
624 − />
811 + <div className="relative" ref={wrap}>
812 + <svg
813 + viewBox={`0 0 ${W} ${H}`}
814 + className="h-auto w-full touch-pan-y select-none"
815 + role="img"
816 + aria-label={
817 + lang === "fr"
818 + ? `Indice de marché ${series[0].month} à ${series[series.length - 1].month}`
819 + : `Market index ${series[0].month} to ${series[series.length - 1].month}`
820 + }
821 + onPointerDown={(e) => onMove(e.clientX)}
822 + onPointerMove={(e) => onMove(e.clientX)}
823 + onPointerLeave={() => setHi(null)}
824 + >
825 + {gridVals.map((v) => (
826 + <g key={v}>
827 + <line
828 + x1={padL}
829 + y1={y(v)}
830 + x2={W - padR}
831 + y2={y(v)}
832 + stroke="var(--ink)"
833 + strokeWidth="0.7"
834 + opacity={v === 100 ? 0.35 : 0.12}
835 + />
836 + <text
837 + x={padL - 6}
838 + y={y(v) + 3.5}
839 + textAnchor="end"
840 + fontSize="10"
841 + fontFamily="var(--font-jetbrains)"
842 + fill="var(--ink-3)"
843 + >
844 + {v}
845 + </text>
846 + </g>
847 + ))}
848 + {years.map((p) => (
625 849 <text
850 + key={p.month}
626 851 x={x(p.i)}
627 − y={H - 10}
852 + y={H - 8}
628 853 textAnchor="middle"
629 854 fontSize="10"
630 − fontWeight="700"
631 855 fontFamily="var(--font-jetbrains)"
632 − fill="var(--ink-2)"
856 + fill="var(--ink-3)"
633 857 >
634 858 {p.month.slice(0, 4)}
635 859 </text>
636 − </g>
637 − ))}
638 − <polygon points={area} fill="var(--lime-soft)" opacity="0.8" />
639 − <polyline
640 − points={line}
641 − fill="none"
642 − stroke="var(--green)"
643 − strokeWidth="2.6"
644 − strokeLinejoin="round"
645 − strokeLinecap="round"
646 − />
647 − <circle
648 − cx={x(pts.length - 1)}
649 − cy={y(lastV)}
650 − r="5"
651 − fill="var(--lime)"
652 − stroke="var(--ink)"
653 − strokeWidth="1.8"
654 − />
655 − <text
656 − x={x(pts.length - 1) + 9}
657 − y={y(lastV) + 4}
658 − fontSize="11.5"
659 − fontWeight="700"
660 − fontFamily="var(--font-jetbrains)"
661 − fill="var(--ink)"
662 − >
663 − {Math.round(lastV)}
664 − </text>
665 − </svg>
860 + ))}
861 + <polygon points={area} fill="var(--ink)" opacity="0.05" />
862 + {hi != null && (
863 + <line x1={x(hi)} y1={padT} x2={x(hi)} y2={H - padB} stroke="var(--ink)" strokeWidth="0.8" opacity="0.4" />
864 + )}
865 + <polyline
866 + points={line}
867 + fill="none"
868 + stroke="var(--ink)"
869 + strokeWidth="2"
870 + strokeLinejoin="round"
871 + strokeLinecap="round"
872 + />
873 + {hi != null && (
874 + <circle cx={x(hi)} cy={y(pts[hi])} r="4" fill="var(--ink)" stroke="var(--paper)" strokeWidth="2" />
875 + )}
876 + <circle
877 + cx={x(pts.length - 1)}
878 + cy={y(lastV)}
879 + r="4"
880 + fill="var(--accent)"
881 + stroke="var(--paper)"
882 + strokeWidth="2"
883 + />
884 + <text
885 + x={x(pts.length - 1) + 8}
886 + y={y(lastV) + 4}
887 + fontSize="11.5"
888 + fontWeight="700"
889 + fontFamily="var(--font-jetbrains)"
890 + fill="var(--ink)"
891 + >
892 + {Math.round(lastV)}
893 + </text>
894 + </svg>
895 + {hi != null && (
896 + <div
897 + className="vv-tip"
898 + style={{
899 + left: `${Math.min(74, Math.max(4, (x(hi) / W) * 100))}%`,
900 + top: 0,
901 + transform: "translateX(-50%)",
902 + }}
903 + >
904 + <p className="mb-0.5 uppercase tracking-[0.08em] opacity-70">{fmtMonth(series[hi].month, lang)}</p>
905 + <p className="row">
906 + <span>{lang === "fr" ? "Indice" : "Index"}</span>
907 + <span className="val">
908 + {pts[hi].toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", { maximumFractionDigits: 1 })}
909 + </span>
910 + </p>
911 + </div>
912 + )}
913 + </div>
666 914 );
667 915 }
668 916
@@ -670,10 +918,9 @@ export function MarketIndexChart({
670 918 /** Distribution stylisée (cloche) : les barres sous le percentile sont pleines. */
671 919 export function PercentileBar({
672 920 pct,
673 − lang,
674 921 }: {
675 922 pct: number;
676 − lang: string;
923 + lang?: string;
677 924 }) {
678 925 const N = 36;
679 926 const bars = Array.from({ length: N }, (_, i) => {
@@ -690,23 +937,20 @@ export function PercentileBar({
690 937 return (
691 938 <div
692 939 key={i}
693 − className="min-w-0 flex-1 rounded-t-[2px] border border-ink transition-colors"
940 + className="min-w-0 flex-1 transition-colors"
694 941 style={{
695 942 height: `${8 + (b / maxB) * 88}%`,
696 − background: filled ? "var(--lime)" : "var(--surface-2)",
697 − borderWidth: "1px",
698 − opacity: filled ? 1 : 0.55,
943 + background: filled ? "var(--ink)" : "var(--line-soft)",
699 944 }}
700 945 />
701 946 );
702 947 })}
703 948 <div
704 − className="absolute bottom-0 top-[-6px] w-0 border-l-[2px] border-ink"
949 + className="absolute bottom-0 top-[-6px] w-0 border-l-[2px] border-[var(--accent)]"
705 950 style={{ left: `${p}%` }}
706 951 >
707 − <span className="vp-mono absolute -top-1 left-1.5 whitespace-nowrap rounded-[4px] border border-ink bg-ink px-1.5 py-0.5 text-[10px] font-bold text-lime" style={p > 55 ? { left: "auto", right: "6px" } : undefined}>
708 − {lang === "fr" ? "P" : "P"}
709 − {Math.round(p)}
952 + <span className="vp-mono absolute -top-1 left-1.5 whitespace-nowrap bg-ink px-1.5 py-0.5 text-[10px] font-bold text-paper" style={p > 55 ? { left: "auto", right: "6px" } : undefined}>
953 + P{Math.round(p)}
710 954 </span>
711 955 </div>
712 956 </div>
@@ -719,10 +963,12 @@ export function IndexSpark({
719 963 series,
720 964 width = 150,
721 965 height = 46,
966 + color = "var(--ink)",
722 967 }: {
723 968 series: { month: string; idx: number }[];
724 969 width?: number;
725 970 height?: number;
971 + color?: string;
726 972 }) {
727 973 if (series.length < 2) return null;
728 974 const base = series[0].idx || 1;
@@ -735,13 +981,11 @@ export function IndexSpark({
735 981 height - 6 - ((v - min) / span) * (height - 14),
736 982 ]);
737 983 const line = xy.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
738 − const area = `${xy[0][0]},${height - 3} ${line} ${xy[xy.length - 1][0]},${height - 3}`;
739 984 const [lx, ly] = xy[xy.length - 1];
740 985 return (
741 986 <svg viewBox={`0 0 ${width} ${height}`} className="h-auto w-full" aria-hidden="true">
742 − <polygon points={area} fill="var(--lime-soft)" />
743 − <polyline points={line} fill="none" stroke="var(--green)" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round" />
744 − <circle cx={lx} cy={ly} r="3.4" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.4" />
987 + <polyline points={line} fill="none" stroke={color} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
988 + <circle cx={lx} cy={ly} r="3.2" fill="var(--accent)" stroke="var(--paper)" strokeWidth="1.8" />
745 989 </svg>
746 990 );
747 991 }
@@ -757,12 +1001,12 @@ export function EraLine({ year, lang }: { year: number | null; lang: string }) {
757 1001 const age = 2026 - year;
758 1002 return (
759 1003 <svg viewBox={`0 0 ${W} 40`} className="w-full max-w-[210px]" aria-label={`Construction ${year}`}>
760 − <line x1="12" y1="24" x2={W - 12} y2="24" stroke="var(--ink)" strokeWidth="1.6" />
1004 + <line x1="12" y1="24" x2={W - 12} y2="24" stroke="var(--ink)" strokeWidth="1.2" />
761 1005 {[...new Set([min, Math.round((min + max) / 2 / 50) * 50, 2000])].map((yr) => {
762 1006 const gx = 12 + ((yr - min) / (max - min)) * (W - 24);
763 1007 return (
764 1008 <g key={yr}>
765 − <line x1={gx} y1="20" x2={gx} y2="28" stroke="var(--ink)" strokeWidth="1.2" opacity="0.5" />
1009 + <line x1={gx} y1="20" x2={gx} y2="28" stroke="var(--ink)" strokeWidth="1" opacity="0.5" />
766 1010 <text x={gx} y="38" textAnchor="middle" fontSize="7.5" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)">
767 1011 {yr}
768 1012 </text>
@@ -770,7 +1014,7 @@ export function EraLine({ year, lang }: { year: number | null; lang: string }) {
770 1014 );
771 1015 })}
772 1016 <g transform={`translate(${x}, 24)`}>
773 − <rect x="-7" y="-7" width="14" height="14" transform="rotate(45)" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.6" />
1017 + <rect x="-6" y="-6" width="12" height="12" transform="rotate(45)" fill="var(--accent)" stroke="var(--paper)" strokeWidth="1.6" />
774 1018 <text y="-13" textAnchor="middle" fontSize="9.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink)">
775 1019 {year}
776 1020 </text>
modified src/components/ResultView.tsx +185 −158
@@ -1,4 +1,10 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * ResultView — le « rapport de valeur » : l'écran central du produit.
4 + * Composition éditoriale v2 : masthead de rapport, estimation en très grands
5 + * chiffres, sections numérotées à filets (registre, calcul, marché, historique,
6 + * comparables), zéro carte — la hiérarchie vient de la typographie et des filets.
7 + */
2 8 "use client";
3 9 import { useState } from "react";
4 10 import Link from "next/link";
@@ -43,6 +49,20 @@ function Chip({ k, v }: { k: string; v: string | number }) {
43 49 );
44 50 }
45 51
52 +/** En-tête de section du rapport : filet fort + numéro mono rouge. */
53 +function Sect({ num, kicker, title }: { num: string; kicker: string; title: string }) {
54 + return (
55 + <header className="sec">
56 + <span className="sec-num">
57 + {num} — {kicker}
58 + </span>
59 + <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">
60 + {title}
61 + </h2>
62 + </header>
63 + );
64 +}
65 +
46 66 export default function ResultView({ data }: { data: UnitEstimate }) {
47 67 const { lang, t } = useLang();
48 68 const [activeComp, setActiveComp] = useState<string | null>(null);
@@ -60,49 +80,53 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
60 80 }).format(Math.round(v / 100) * 100);
61 81
62 82 return (
63 − <div className="space-y-10 py-8">
64 − {/* fil d'ariane */}
65 − <nav className="vp-mono flex flex-wrap items-center gap-2 text-[11.5px] uppercase tracking-[0.06em] text-ink-3">
66 − <Link href="/" className="border-b-[1.5px] border-transparent hover:border-green hover:text-green">
67 − Vrai-Prix
68 − </Link>
69 − <span>/</span>
70 − <span>{u ? `${u.adresse ?? ""} · ${u.municipalite ?? ""}` : t("manualTitle")}</span>
71 − </nav>
83 + <div className="space-y-12 py-8">
84 + {/* ================= masthead du rapport ================= */}
85 + <section className="rise">
86 + <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">
87 + <nav className="vp-mono flex min-w-0 flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">
88 + <Link href="/" className="text-ink-2 hover:text-accent-deep">
89 + Vrai-Prix
90 + </Link>
91 + <span aria-hidden="true">/</span>
92 + <span>{fr ? "Rapport de valeur" : "Value report"}</span>
93 + {u && (
94 + <>
95 + <span aria-hidden="true">/</span>
96 + <span className="truncate">{u.id}</span>
97 + </>
98 + )}
99 + </nav>
100 + {u && (
101 + <FavButton
102 + size="lg"
103 + item={{
104 + item_id: u.id,
105 + title: `${u.adresse ?? u.id}${s?.apt ? ` app. ${s.apt}` : ""}`,
106 + subtitle: u.municipalite ?? "",
107 + price_label: r.estimate > 0 ? moneyFmt(r.estimate) : "",
108 + url: `https://www.vrai-prix.com/estimation/${encodeURIComponent(u.id)}`,
109 + }}
110 + />
111 + )}
112 + </div>
72 113
73 − {/* ---- Panneau prix ---- */}
74 − <section className="vp-card rise p-6 sm:p-8">
75 − <div className="flex flex-wrap items-start justify-between gap-6">
76 − {/* basis large : sous ~380px de large la jauge passe dessous au lieu d'écraser la colonne */}
77 − <div className="min-w-0 flex-1 basis-[300px]">
78 − <div className="flex items-center justify-between gap-3">
79 − <span className="kicker">{t("estimatedValue")}</span>
80 − {/* ♥ Mon univers Ka — favori unifié Groupe KA */}
81 − {u && (
82 − <FavButton
83 − size="lg"
84 − item={{
85 − item_id: u.id,
86 − title: `${u.adresse ?? u.id}${s?.apt ? ` app. ${s.apt}` : ""}`,
87 − subtitle: u.municipalite ?? "",
88 − price_label: r.estimate > 0 ? moneyFmt(r.estimate) : "",
89 − url: `https://www.vrai-prix.com/estimation/${encodeURIComponent(u.id)}`,
90 − }}
91 − />
92 − )}
93 − </div>
114 + <div className="mt-6 grid gap-x-12 gap-y-8 lg:grid-cols-[1.5fr_1fr]">
115 + {/* colonne principale : adresse + estimation en très grands chiffres */}
116 + <div className="min-w-0">
94 117 {u && (
95 − <h1 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]">
118 + <h1 className="vp-display text-[clamp(19px,2.6vw,26px)] font-bold uppercase leading-tight tracking-[-0.02em]">
96 119 {u.adresse}
97 120 {s?.apt ? ` app. ${s.apt}` : ""}
98 121 {u.municipalite ? <span className="text-ink-3"> · {u.municipalite}</span> : null}
99 122 </h1>
100 123 )}
101 − <p className="vp-display mt-3 text-[clamp(44px,7vw,72px)] font-bold leading-none tracking-[-0.04em]">
124 + <p className="klabel mt-5">{t("estimatedValue")}</p>
125 + <p className="vp-display mt-1 text-[clamp(52px,9vw,96px)] font-bold leading-none tracking-[-0.045em]">
102 126 <CountUp value={r.estimate} format={moneyFmt} />
103 127 </p>
104 − {/* faits rapides */}
105 − <div className="mt-4 flex flex-wrap gap-2">
128 + {/* faits rapides — ligne mono, pas de pastilles */}
129 + <p className="vp-mono mt-4 text-[11px] uppercase tracking-[0.06em] text-ink-2">
106 130 {[
107 131 u ? t(u.typeProp as TKey) : null,
108 132 u?.aireEtagesM2 && r.estimate > 0
@@ -115,17 +139,10 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
115 139 : null,
116 140 ]
117 141 .filter((x): x is string => x != null)
118 − .map((x) => (
119 − <span
120 − key={x}
121 − className="vp-mono whitespace-nowrap rounded-[5px] border-[1.5px] border-ink bg-surface-2 px-2.5 py-1 text-[11px] font-bold uppercase tracking-[0.04em]"
122 − >
123 − {x}
124 − </span>
125 − ))}
126 − </div>
142 + .join(" · ")}
143 + </p>
127 144 {/* fourchette visuelle P10-P90 avec position du rôle */}
128 − <div className="mt-6 max-w-2xl">
145 + <div className="mt-7 max-w-2xl">
129 146 <p className="klabel mb-1">{t("range")}</p>
130 147 <RangeBar
131 148 low={r.low}
@@ -135,25 +152,8 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
135 152 lang={lang}
136 153 />
137 154 </div>
138 − <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3">
139 − {u?.valeurRole != null && (
140 − <div>
141 − <p className="klabel">{t("assessed")}</p>
142 − <p className="vp-display text-[19px] font-bold text-ink-2">{fmt(u.valeurRole, lang)}</p>
143 − </div>
144 − )}
145 − {u?.valeurRole != null && u.valeurRole > 0 && r.estimate > 0 && (
146 − <div>
147 − <p className="klabel">{fr ? "Écart vs rôle" : "vs assessment"}</p>
148 − <p className="vp-display text-[19px] font-bold text-green-deep">
149 − {r.estimate >= u.valeurRole ? "+" : ""}
150 − {((r.estimate / u.valeurRole - 1) * 100).toFixed(0)} %
151 − </p>
152 − </div>
153 − )}
154 − </div>
155 155 {u && (
156 − <div className="mt-6 flex flex-wrap gap-2.5">
156 + <div className="mt-7 flex flex-wrap gap-2.5">
157 157 <a
158 158 href={`/api/report?id=${encodeURIComponent(u.id)}`}
159 159 className="btn btn-primary"
@@ -167,37 +167,63 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
167 167 download
168 168 title={fr ? "Structure de rapport d'évaluation — présentation bancaire" : "Appraisal-style structure — bank presentation"}
169 169 >
170 − {fr ? "Rapport détaillé format professionnel (banque)" : "Detailed professional report (bank)"} ↓
170 + {fr ? "Format professionnel (banque)" : "Professional format (bank)"} ↓
171 171 </a>
172 172 </div>
173 173 )}
174 174 </div>
175 − <div className="shrink-0">
176 − <ConfidenceDial
177 − pct={r.confidencePct}
178 − level={r.confidenceLevel}
179 − label={`${t("confidence")} — ${t(`conf${r.confidenceLevel}` as TKey)}`}
180 − />
181 − </div>
175 +
176 + {/* colonne de lecture : confiance + rôle + écart, en cellules de données */}
177 + <aside className="lg:border-l lg:border-[var(--line)] lg:pl-10">
178 + <div className="flex items-start justify-between gap-6 lg:block">
179 + <ConfidenceDial
180 + pct={r.confidencePct}
181 + level={r.confidenceLevel}
182 + label={`${t("confidence")} — ${t(`conf${r.confidenceLevel}` as TKey)}`}
183 + />
184 + <dl className="grid flex-1 grid-cols-1 gap-x-6 sm:grid-cols-2 lg:mt-6 lg:grid-cols-1">
185 + {u?.valeurRole != null && (
186 + <div className="border-t border-[var(--line)] py-3">
187 + <dt className="klabel">{t("assessed")}</dt>
188 + <dd className="vp-display mt-0.5 text-[22px] font-bold tracking-[-0.02em] text-ink-2">
189 + {fmt(u.valeurRole, lang)}
190 + </dd>
191 + </div>
192 + )}
193 + {u?.valeurRole != null && u.valeurRole > 0 && r.estimate > 0 && (
194 + <div className="border-t border-[var(--line)] py-3">
195 + <dt className="klabel">{fr ? "Écart vs rôle" : "vs assessment"}</dt>
196 + <dd
197 + className={`vp-display mt-0.5 text-[22px] font-bold tracking-[-0.02em] ${
198 + r.estimate >= u.valeurRole ? "text-ink" : "text-[var(--danger)]"
199 + }`}
200 + >
201 + {r.estimate >= u.valeurRole ? "+" : ""}
202 + {((r.estimate / u.valeurRole - 1) * 100).toFixed(0)} %
203 + </dd>
204 + </div>
205 + )}
206 + </dl>
207 + </div>
208 + </aside>
182 209 </div>
183 − <p className="vp-mono mt-5 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-3 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
210 + <p className="vp-mono mt-7 border-t border-[var(--line)] pt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">
184 211 {t("disclaimer")}
185 212 </p>
186 213 </section>
187 214
188 − {/* ---- Portrait de la propriété (bento du registre) ---- */}
215 + {/* ================= 01 — portrait du registre ================= */}
189 216 {u && s && (
190 217 <section>
191 − <span className="kicker">{fr ? "Registre officiel — dessiné par les données" : "Official record — drawn by the data"}</span>
192 − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
193 − {fr ? "Portrait de la propriété" : "Property portrait"}
194 − </h2>
195 − <div className="mt-4 grid gap-4 lg:grid-cols-3">
218 + <Sect
219 + num="01"
220 + kicker={fr ? "Registre officiel" : "Official record"}
221 + title={fr ? "Portrait de la propriété" : "Property portrait"}
222 + />
223 + <div className="mt-6 grid gap-x-10 gap-y-8 lg:grid-cols-3 lg:divide-x lg:divide-[var(--line)]">
196 224 {/* Bâtiment */}
197 − <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "60ms" }}>
198 − <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">
199 − {fr ? "Bâtiment" : "Building"}
200 − </p>
225 + <div className="rise" style={{ animationDelay: "60ms" }}>
226 + <p className="klabel">{fr ? "Bâtiment" : "Building"}</p>
201 227 <div className="flex justify-center py-2">
202 228 <BuildingGlyph
203 229 floors={s.nbEtages}
@@ -209,7 +235,7 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
209 235 />
210 236 </div>
211 237 <EraLine year={u.anneeConstruction} lang={lang} />
212 − <div className="mt-3 grid grid-cols-2 gap-2">
238 + <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
213 239 <Chip k={fr ? "Aire d'étages" : "Floor area"} v={u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"} />
214 240 <Chip k={fr ? "Étages × logements" : "Storeys × dwellings"} v={`${s.nbEtages ?? "—"} × ${u.nbLogements ?? "—"}`} />
215 241 <Chip k={fr ? "Genre" : "Style"} v={s.genreConstruction ?? "—"} />
@@ -217,10 +243,8 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
217 243 </div>
218 244 </div>
219 245 {/* Terrain */}
220 − <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "140ms" }}>
221 − <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">
222 − {fr ? "Terrain — à l'échelle" : "Lot — to scale"}
223 − </p>
246 + <div className="rise lg:pl-10" style={{ animationDelay: "140ms" }}>
247 + <p className="klabel">{fr ? "Terrain — à l'échelle" : "Lot — to scale"}</p>
224 248 <div className="flex justify-center py-2">
225 249 <LotDiagram
226 250 areaM2={u.superficieTerrainM2}
@@ -232,7 +256,7 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
232 256 isCondo={!!s.apt || (u.nbLogements != null && u.nbLogements >= 1 && !u.superficieTerrainM2)}
233 257 />
234 258 </div>
235 − <div className="mt-3 grid grid-cols-2 gap-2">
259 + <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
236 260 <Chip k={fr ? "Superficie" : "Area"} v={u.superficieTerrainM2 ? locNum(u.superficieTerrainM2, lang, { unit: "m²" }) : "—"} />
237 261 <Chip k={fr ? "Frontage" : "Frontage"} v={s.frontTerrainM ? locNum(s.frontTerrainM, lang, { unit: "m" }) : "—"} />
238 262 <Chip k="CUBF" v={s.cubf ?? "—"} />
@@ -240,11 +264,9 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
240 264 </div>
241 265 </div>
242 266 {/* Valeur au rôle */}
243 − <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "220ms" }}>
244 − <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">
245 − {fr ? "Valeur au rôle 2026 — composition" : "2026 assessed value — split"}
246 − </p>
247 − <p className="vp-display py-2 text-[26px] font-bold tracking-[-0.02em]">
267 + <div className="rise lg:pl-10" style={{ animationDelay: "220ms" }}>
268 + <p className="klabel">{fr ? "Valeur au rôle 2026 — composition" : "2026 assessed value — split"}</p>
269 + <p className="vp-display py-2 text-[30px] font-bold tracking-[-0.025em]">
248 270 {fmt(u.valeurRole, lang)}
249 271 </p>
250 272 <ValueSplit
@@ -259,18 +281,18 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
259 281 previous: fr ? "Rôle antérieur" : "Previous roll",
260 282 }}
261 283 />
262 − <div className="mt-3 grid grid-cols-2 gap-2">
284 + <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
263 285 <Chip k={fr ? "Cond. marché au" : "Market cond."} v={s.datCondMarche ?? "—"} />
264 286 <Chip k={fr ? "Matricule" : "Roll no."} v={s.matricule ?? "—"} />
265 287 </div>
266 288 </div>
267 289 </div>
268 290 {/* registre brut complet */}
269 − <details className="vp-card mt-4 p-5">
270 − <summary className="vp-mono cursor-pointer text-[11px] font-bold uppercase tracking-[0.08em] text-green-deep">
271 − {fr ? "▸ Toutes les données du registre (brutes)" : "▸ All registry data (raw)"}
291 + <details className="mt-6 border-t border-[var(--line)] pt-4">
292 + <summary className="vp-mono cursor-pointer text-[11px] font-bold uppercase tracking-[0.08em] text-accent-deep hover:text-accent">
293 + ▸ {fr ? "Toutes les données du registre (brutes)" : "All registry data (raw)"}
272 294 </summary>
273 − <div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-4">
295 + <div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
274 296 <Chip k={fr ? "Année construction" : "Year built"} v={u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? (fr ? " (estimée)" : " (est.)") : ""}` : "—"} />
275 297 <Chip k={fr ? "Locaux non résid." : "Non-res. units"} v={s.nbLocauxNonResid ?? "—"} />
276 298 <Chip k={fr ? "Chambres locatives" : "Rental rooms"} v={s.nbChambresLocatives ?? "—"} />
@@ -286,74 +308,78 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
286 308 </section>
287 309 )}
288 310
289 − {/* ---- Transparence du calcul ---- */}
311 + {/* ================= 02 — transparence du calcul ================= */}
290 312 <section>
291 − <span className="kicker">{fr ? "Transparence" : "Transparency"}</span>
292 − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
293 − {t("whyTitle")}
294 − </h2>
295 − <div className="mt-4 grid gap-4 sm:grid-cols-2">
296 − <div className="vp-card vp-card-hover rise p-5">
313 + <Sect num="02" kicker={fr ? "Transparence" : "Transparency"} title={t("whyTitle")} />
314 + <div className="mt-6 grid gap-x-10 gap-y-6 sm:grid-cols-2 sm:divide-x sm:divide-[var(--line)]">
315 + <div className="rise">
297 316 <p className="klabel">{t("modelPart")}</p>
298 − <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]">
317 + <p className="vp-display mt-2 text-[clamp(30px,4vw,40px)] font-bold leading-none tracking-[-0.03em]">
299 318 {r.modelEstimate != null ? (
300 319 <CountUp value={r.modelEstimate} format={moneyFmt} />
301 320 ) : (
302 321 "—"
303 322 )}
304 323 </p>
305 − <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep">
306 − {t("weightModel")} : {(r.modelWeight * 100).toFixed(0)} %
324 + <p className="vp-mono mt-2 text-[11px] uppercase tracking-[0.06em] text-ink-2">
325 + {t("weightModel")} : <b className="text-ink">{(r.modelWeight * 100).toFixed(0)} %</b>
307 326 </p>
308 327 </div>
309 − <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "100ms" }}>
328 + <div className="rise sm:pl-10" style={{ animationDelay: "100ms" }}>
310 329 <p className="klabel">{t("compsPart")}</p>
311 − <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]">
330 + <p className="vp-display mt-2 text-[clamp(30px,4vw,40px)] font-bold leading-none tracking-[-0.03em]">
312 331 {r.compsEstimate != null ? (
313 332 <CountUp value={r.compsEstimate} format={moneyFmt} />
314 333 ) : (
315 334 "—"
316 335 )}
317 336 </p>
318 − <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep">
319 − {t("weightComps")} : {((1 - r.modelWeight) * 100).toFixed(0)} % · {r.nCompsUsed} comps
337 + <p className="vp-mono mt-2 text-[11px] uppercase tracking-[0.06em] text-ink-2">
338 + {t("weightComps")} : <b className="text-ink">{((1 - r.modelWeight) * 100).toFixed(0)} %</b> · {r.nCompsUsed} comps
320 339 {r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""}
321 340 </p>
322 341 </div>
323 342 </div>
324 343 </section>
325 344
326 − {/* ---- Marché local ---- */}
345 + {/* ================= 03 — marché local ================= */}
327 346 {ctx && (ctx.index.length >= 6 || ctx.sector || ctx.muniPercentile != null) && (
328 347 <section>
329 − <span className="kicker">{fr ? "Contexte de marché" : "Market context"}</span>
330 − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
331 − {fr ? "Le marché autour de cette propriété" : "The market around this property"}
332 − </h2>
333 − <div className="mt-4 grid items-stretch gap-4 lg:grid-cols-[1.35fr_1fr]">
348 + <Sect
349 + num="03"
350 + kicker={fr ? "Contexte de marché" : "Market context"}
351 + title={fr ? "Le marché autour de cette propriété" : "The market around this property"}
352 + />
353 + <div className="mt-6 grid items-start gap-x-10 gap-y-8 lg:grid-cols-[1.4fr_1fr]">
334 354 {/* trajectoire du marché */}
335 355 {ctx.index.length >= 6 && (
336 − <div className="vp-card rise p-5">
356 + <div className="rise">
337 357 <div className="flex flex-wrap items-center justify-between gap-2">
338 358 <p className="klabel">
339 359 {fr
340 360 ? `Indice de marché — ${t(ctx.typeProp as TKey)} (base 100 en ${ctx.index[0].month.slice(0, 4)})`
341 361 : `Market index — ${t(ctx.typeProp as TKey)} (base 100 in ${ctx.index[0].month.slice(0, 4)})`}
342 362 </p>
343 − <div className="flex gap-2">
363 + <p className="vp-mono flex gap-4 text-[11px] text-ink-2">
344 364 {ctx.index12mPct != null && (
345 − <span className={`vp-mono rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${ctx.index12mPct >= 0 ? "bg-lime text-ink" : "bg-danger text-white"}`}>
346 − 12 {fr ? "mois" : "mo"} {ctx.index12mPct >= 0 ? "+" : ""}
347 − {ctx.index12mPct.toFixed(1)} %
365 + <span>
366 + 12 {fr ? "mois" : "mo"}{" "}
367 + <b className={ctx.index12mPct >= 0 ? "text-ink" : "text-[var(--danger)]"}>
368 + {ctx.index12mPct >= 0 ? "+" : ""}
369 + {ctx.index12mPct.toFixed(1)} %
370 + </b>
348 371 </span>
349 372 )}
350 373 {ctx.indexSince2021Pct != null && (
351 − <span className="vp-mono rounded-[4px] border border-ink bg-surface-2 px-1.5 py-0.5 text-[10px] font-bold text-ink-2">
352 − {ctx.index[0].month.slice(0, 4)}→ {ctx.indexSince2021Pct >= 0 ? "+" : ""}
353 − {ctx.indexSince2021Pct.toFixed(0)} %
374 + <span>
375 + {ctx.index[0].month.slice(0, 4)}→{" "}
376 + <b className={ctx.indexSince2021Pct >= 0 ? "text-ink" : "text-[var(--danger)]"}>
377 + {ctx.indexSince2021Pct >= 0 ? "+" : ""}
378 + {ctx.indexSince2021Pct.toFixed(0)} %
379 + </b>
354 380 </span>
355 381 )}
356 − </div>
382 + </p>
357 383 </div>
358 384 <div className="mt-3">
359 385 <MarketIndexChart series={ctx.index} lang={lang} />
@@ -365,16 +391,16 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
365 391 </p>
366 392 </div>
367 393 )}
368 − <div className="flex flex-col gap-4">
394 + <div className="flex flex-col gap-7 lg:border-l lg:border-[var(--line)] lg:pl-10">
369 395 {/* ventes du secteur */}
370 396 {ctx.sector && (
371 − <div className="vp-card rise p-5" style={{ animationDelay: "80ms" }}>
397 + <div className="rise" style={{ animationDelay: "80ms" }}>
372 398 <p className="klabel">
373 399 {fr
374 400 ? `Ventes réelles — rayon ${ctx.sector.radiusKm} km · 12 mois`
375 401 : `Real sales — ${ctx.sector.radiusKm} km radius · 12 months`}
376 402 </p>
377 − <div className="mt-3 grid grid-cols-3 gap-2">
403 + <div className="mt-2 grid grid-cols-3 gap-x-4">
378 404 <div className="kv-cell">
379 405 <p className="k">{fr ? "Ventes" : "Sales"}</p>
380 406 <p className="v">{ctx.sector.nSales12m}</p>
@@ -406,7 +432,7 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
406 432 )}
407 433 {/* position dans la municipalité */}
408 434 {ctx.muniPercentile != null && u?.municipalite && (
409 − <div className="vp-card rise flex-1 p-5" style={{ animationDelay: "160ms" }}>
435 + <div className="rise" style={{ animationDelay: "160ms" }}>
410 436 <p className="klabel">
411 437 {fr ? `Position dans ${u.municipalite}` : `Position in ${u.municipalite}`}
412 438 </p>
@@ -437,14 +463,11 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
437 463 </section>
438 464 )}
439 465
440 − {/* ---- Historique ---- */}
466 + {/* ================= 04 — historique ================= */}
441 467 {u && u.history.some((h) => h.value != null) && (
442 468 <section>
443 − <span className="kicker">2021 → 2026</span>
444 − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
445 − {t("history")}
446 − </h2>
447 − <div className="vp-card rise mt-4 p-5 sm:p-6">
469 + <Sect num="04" kicker="2021 → 2026" title={t("history")} />
470 + <div className="mt-6 max-w-3xl">
448 471 <div className="flex flex-col gap-2">
449 472 {u.history.map((h, i) => {
450 473 const prev = i > 0 ? u.history[i - 1].value : null;
@@ -461,14 +484,14 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
461 484 style={{
462 485 width: `${((h.value ?? 0) / histMax) * 100}%`,
463 486 animationDelay: `${i * 90}ms`,
464 − background: h.year === 2026 ? "var(--ink)" : undefined,
487 + background: h.year === 2026 ? "var(--accent)" : undefined,
465 488 }}
466 489 />
467 490 </span>
468 491 <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold">
469 492 {fmt(h.value, lang)}
470 493 {yoy != null && (
471 − <span className={`rounded-[4px] border border-ink px-1 py-px text-[9px] ${yoy >= 0 ? "bg-lime-soft text-green-deep" : "bg-[var(--danger-soft)] text-[var(--danger)]"}`}>
494 + <span className={`text-[10px] font-medium ${yoy >= 0 ? "text-ink-2" : "text-[var(--danger)]"}`}>
472 495 {yoy >= 0 ? "+" : ""}
473 496 {yoy.toFixed(0)} %
474 497 </span>
@@ -482,16 +505,15 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
482 505 </section>
483 506 )}
484 507
485 − {/* ---- Plan des comparables (carte maison) ---- */}
508 + {/* ================= 05 — comparables ================= */}
486 509 <section>
487 − <span className="kicker">
488 − {r.nCompsUsed} {fr ? "ventes réelles" : "real sales"}
489 − </span>
490 − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
491 − {t("compsTitle")}
492 − </h2>
493 − <div className="mt-4 grid items-start gap-4 lg:grid-cols-[1.05fr_1fr]">
494 − <div className="overflow-hidden rounded-[10px] border-[1.5px] border-ink shadow-[8px_8px_0_rgba(20,24,20,0.08)]">
510 + <Sect
511 + num="05"
512 + kicker={`${r.nCompsUsed} ${fr ? "ventes réelles" : "real sales"}`}
513 + title={t("compsTitle")}
514 + />
515 + <div className="mt-6 grid items-start gap-x-8 gap-y-6 lg:grid-cols-[1.05fr_1fr]">
516 + <div className="overflow-hidden border border-[var(--line-strong)]">
495 517 <RadarMap
496 518 subject={{
497 519 lat: u?.lat ?? r.comps[0]?.lat ?? 46.81,
@@ -515,7 +537,7 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
515 537 </div>
516 538 <div className="src-wrap max-h-[560px] overflow-y-auto">
517 539 <table className="src-table">
518 − <thead className="sticky top-0 z-10">
540 + <thead className="sticky top-0 z-10 bg-paper">
519 541 <tr>
520 542 <th>#</th>
521 543 <th>{fr ? "Vente" : "Sale"}</th>
@@ -528,12 +550,12 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
528 550 key={c.id}
529 551 onMouseEnter={() => setActiveComp(c.id)}
530 552 onMouseLeave={() => setActiveComp(null)}
531 − className={activeComp === c.id ? "!bg-lime-soft" : ""}
553 + className={activeComp === c.id ? "!bg-surface-2" : ""}
532 554 >
533 555 <td>
534 556 <span
535 − className={`vp-mono inline-flex h-6 w-6 items-center justify-center rounded-full border-[1.5px] border-ink text-[11px] font-bold ${
536 − activeComp === c.id ? "bg-lime text-ink" : "bg-green text-paper"
557 + className={`vp-mono inline-flex h-6 w-6 items-center justify-center text-[11px] font-bold ${
558 + activeComp === c.id ? "bg-accent text-white" : "bg-ink text-paper"
537 559 }`}
538 560 >
539 561 {i + 1}
@@ -579,13 +601,18 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
579 601 </div>
580 602 </section>
581 603
582 − {/* ---- Lead ---- */}
583 − <section className="rounded-[10px] border-[1.5px] border-ink bg-ink p-6 text-paper shadow-[8px_8px_0_rgba(20,24,20,0.2)] sm:p-8">
584 − <h2 className="vp-display text-[22px] font-bold uppercase tracking-[-0.02em] text-lime">
585 − {t("leadTitle")}
586 − </h2>
587 − <div className="mt-4 max-w-xl">
588 − <LeadForm unitId={u?.id ?? null} estimate={r.estimate} />
604 + {/* ================= alerte de valeur ================= */}
605 + <section className="bleed vp-dark">
606 + <div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-12">
607 + <span className="sec-num !text-[var(--accent-bright)]">
608 + 06 — {fr ? "Suivi" : "Watch"}
609 + </span>
610 + <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase tracking-[-0.025em] text-[var(--paper-on-ink)]">
611 + {t("leadTitle")}
612 + </h2>
613 + <div className="mt-5 max-w-xl">
614 + <LeadForm unitId={u?.id ?? null} estimate={r.estimate} />
615 + </div>
589 616 </div>
590 617 </section>
591 618 </div>
modified src/components/SearchBox.tsx +101 −29
@@ -15,18 +15,23 @@ export interface Suggestion {
15 15 export default function SearchBox({
16 16 onPick,
17 17 placeholder,
18 + variant = "default",
18 19 }: {
19 20 /** Si fourni : appelé au choix d'une adresse (le champ se vide) au lieu de naviguer. */
20 21 onPick?: (s: Suggestion) => void;
21 22 placeholder?: string;
23 + /** « hero » : grand champ sur aplat encre (page d'accueil). */
24 + variant?: "default" | "hero";
22 25 }) {
23 − const { t } = useLang();
26 + const { t, lang } = useLang();
27 + const fr = lang === "fr";
24 28 const router = useRouter();
25 29 const [q, setQ] = useState("");
26 30 const [sugg, setSugg] = useState<Suggestion[]>([]);
27 31 const [open, setOpen] = useState(false);
28 32 const [searched, setSearched] = useState(false);
29 33 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
34 + const input = useRef<HTMLInputElement>(null);
30 35
31 36 const onChange = (value: string) => {
32 37 setQ(value);
@@ -45,39 +50,100 @@ export default function SearchBox({
45 50 }, 200);
46 51 };
47 52
53 + const pick = (s: Suggestion) => {
54 + if (onPick) {
55 + onPick(s);
56 + setQ("");
57 + setSugg([]);
58 + setSearched(false);
59 + } else {
60 + router.push(`/estimation/${s.id}`);
61 + }
62 + };
63 +
64 + const hero = variant === "hero";
65 +
48 66 return (
49 67 <div className="relative w-full">
50 − <input
51 − value={q}
52 − onChange={(e) => onChange(e.target.value)}
53 − onFocus={() => setOpen(true)}
54 − onBlur={() => setTimeout(() => setOpen(false), 200)}
55 − placeholder={placeholder ?? t("searchPlaceholder")}
56 − className="vp-input text-base"
57 − aria-label={placeholder ?? t("searchPlaceholder")}
58 − />
68 + <div className={hero ? "flex items-stretch gap-0" : undefined}>
69 + <input
70 + ref={input}
71 + value={q}
72 + onChange={(e) => onChange(e.target.value)}
73 + onFocus={() => setOpen(true)}
74 + onBlur={() => setTimeout(() => setOpen(false), 200)}
75 + placeholder={
76 + placeholder ?? (hero ? (fr ? "ex. 861 route Elgin" : "e.g. 861 route Elgin") : t("searchPlaceholder"))
77 + }
78 + className={
79 + hero
80 + ? "min-h-[56px] w-full min-w-0 flex-1 border-0 border-b border-[rgba(245,243,238,0.45)] bg-transparent px-1 py-3 text-[16px] text-[var(--paper-on-ink)] outline-none transition-colors placeholder:text-[rgba(245,243,238,0.45)] focus:border-[var(--accent-bright)] sm:text-[19px]"
81 + : "vp-input text-base"
82 + }
83 + aria-label={placeholder ?? t("searchPlaceholder")}
84 + />
85 + {hero && (
86 + <button
87 + type="button"
88 + aria-label={fr ? "Estimer cette adresse" : "Estimate this address"}
89 + onMouseDown={(e) => {
90 + // mousedown : devance le blur de l'input (sinon le menu se ferme avant)
91 + e.preventDefault();
92 + if (sugg.length > 0) pick(sugg[0]);
93 + else input.current?.focus();
94 + }}
95 + className="vp-display -mb-px flex min-h-[56px] shrink-0 items-center gap-2 self-end bg-[var(--accent-bright)] px-4 text-[14px] font-bold text-ink transition-colors hover:bg-[#ff6b63] sm:px-7 sm:text-[15px]"
96 + >
97 + {fr ? "Estimer" : "Estimate"}
98 + <span aria-hidden="true">→</span>
99 + </button>
100 + )}
101 + </div>
59 102 {open && sugg.length > 0 && (
60 − <ul className="absolute z-[var(--z-dropdown,700)] mt-2 w-full overflow-hidden rounded-[10px] border-[1.5px] border-ink bg-surface shadow-[6px_6px_0_rgba(20,24,20,0.85)]">
61 − {sugg.map((s) => (
62 − <li key={s.id} className="border-b border-[rgba(20,24,20,0.1)] last:border-0">
103 + <ul
104 + className={`absolute z-[var(--z-dropdown,700)] mt-2 w-full overflow-hidden rounded-[2px] ${
105 + hero
106 + ? "border border-[rgba(245,243,238,0.25)] bg-[var(--ink-deep)] shadow-[0_14px_40px_rgba(0,0,0,0.5)]"
107 + : "border border-[var(--line-strong)] bg-surface shadow-[0_10px_30px_rgba(20,24,20,0.14)]"
108 + }`}
109 + >
110 + {sugg.map((s, i) => (
111 + <li
112 + key={s.id}
113 + className={
114 + hero
115 + ? "border-b border-[rgba(245,243,238,0.1)] last:border-0"
116 + : "border-b border-[rgba(20,24,20,0.08)] last:border-0"
117 + }
118 + >
63 119 <button
64 − className="flex w-full items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors hover:bg-lime-soft"
65 − onMouseDown={() => {
66 − if (onPick) {
67 − onPick(s);
68 − setQ("");
69 − setSugg([]);
70 − setSearched(false);
71 − } else {
72 − router.push(`/estimation/${s.id}`);
73 − }
74 − }}
120 + className={`flex w-full items-baseline justify-between gap-3 px-4 py-3 text-left transition-colors ${
121 + hero ? "hover:bg-[rgba(245,243,238,0.08)]" : "hover:bg-surface-2"
122 + }`}
123 + onMouseDown={() => pick(s)}
75 124 >
76 − <span className="vp-display font-bold">
77 − {s.adresse}
78 − {s.apt ? ` app. ${s.apt}` : ""}
125 + <span className="flex min-w-0 items-baseline gap-2.5">
126 + <span
127 + className={`vp-mono shrink-0 text-[10px] ${
128 + hero ? "text-[var(--accent-bright)]" : "text-accent"
129 + }`}
130 + >
131 + {String(i + 1).padStart(2, "0")}
132 + </span>
133 + <span
134 + className={`vp-display truncate font-bold ${
135 + hero ? "text-[var(--paper-on-ink)]" : ""
136 + }`}
137 + >
138 + {s.adresse}
139 + {s.apt ? ` app. ${s.apt}` : ""}
140 + </span>
79 141 </span>
80 − <span className="vp-mono shrink-0 text-[11px] uppercase tracking-[0.05em] text-ink-3">
142 + <span
143 + className={`vp-mono shrink-0 text-[10.5px] uppercase tracking-[0.05em] ${
144 + hero ? "text-[rgba(245,243,238,0.55)]" : "text-ink-3"
145 + }`}
146 + >
81 147 {s.municipalite}
82 148 </span>
83 149 </button>
@@ -86,7 +152,13 @@ export default function SearchBox({
86 152 </ul>
87 153 )}
88 154 {open && searched && sugg.length === 0 && q.trim().length >= 3 && (
89 − <div className="vp-mono absolute z-[var(--z-dropdown,700)] mt-2 w-full rounded-[10px] border-[1.5px] border-ink bg-surface px-4 py-3 text-[12px] uppercase tracking-[0.04em] text-ink-2 shadow-[6px_6px_0_rgba(20,24,20,0.85)]">
155 + <div
156 + className={`vp-mono absolute z-[var(--z-dropdown,700)] mt-2 w-full rounded-[2px] px-4 py-3 text-[12px] uppercase tracking-[0.04em] ${
157 + hero
158 + ? "border border-[rgba(245,243,238,0.25)] bg-[var(--ink-deep)] text-[rgba(245,243,238,0.7)]"
159 + : "border border-[var(--line-strong)] bg-surface text-ink-2"
160 + }`}
161 + >
90 162 {t("searchNoResult")}
91 163 </div>
92 164 )}
modified src/components/StatsDashboard.tsx +1 −1
@@ -61,7 +61,7 @@ export default function StatsDashboard({ initial }: { initial: DashboardPayload
61 61 return (
62 62 <div>
63 63 {/* ---- fraîcheur + rapports PDF (5 modes) ---- */}
64 − <div className="mb-2 mt-10 flex flex-wrap items-center justify-between gap-3 rounded-[10px] border-[1.5px] border-ink bg-surface px-4 py-3">
64 + <div className="mb-2 mt-10 flex flex-wrap items-center justify-between gap-3 border-y border-[var(--line)] px-1 py-3">
65 65 <div>
66 66 <p className="vp-mono text-[10px] font-bold uppercase tracking-[0.08em] text-ink-3">
67 67 {fr
modified src/components/StatsView.tsx +4 −4
@@ -71,8 +71,8 @@ export default function StatsView({ s }: { s: ProvStats }) {
71 71 </>
72 72 )}
73 73 </h1>
74 − <div className="rise mt-6 rounded-[10px] border-[1.5px] border-ink bg-ink p-6 shadow-[10px_10px_0_rgba(20,24,20,0.25)] sm:p-10">
75 − <p className="vp-display break-all text-[clamp(30px,6.2vw,76px)] font-bold leading-none tracking-[-0.03em] text-lime">
74 + <div className="rise mt-6 rounded-[2px] bg-[var(--ink-deep)] p-6 sm:p-10">
75 + <p className="vp-display break-all text-[clamp(30px,6.2vw,76px)] font-bold leading-none tracking-[-0.03em] text-[var(--accent-bright)]">
76 76 <CountUp
77 77 value={s.valeur_totale_2026}
78 78 duration={2600}
@@ -93,8 +93,8 @@ export default function StatsView({ s }: { s: ProvStats }) {
93 93 [fr ? "depuis 2021 (périmètre constant)" : "since 2021 (constant perimeter)", `+${s.croissance_2021_2026_pct.toLocaleString(fr ? "fr-CA" : "en-CA")} %`],
94 94 ] as [string, string][]
95 95 ).map(([k, v]) => (
96 − <span key={k} className="stat-chip max-w-full !whitespace-normal !border-[rgba(255,81,72,0.5)] !bg-transparent !text-[rgba(245,243,238,0.85)] !shadow-none">
97 − <b className="!text-lime">{v}</b> {k}
96 + <span key={k} className="stat-chip max-w-full !whitespace-normal !border-[rgba(245,243,238,0.25)] !text-[rgba(245,243,238,0.85)]">
97 + <b className="!text-[var(--accent-bright)]">{v}</b> {k}
98 98 </span>
99 99 ))}
100 100 </div>
101 101