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%

fix lettrage .hl (couleur héritée — le .hl ka-ui imposait du blanc sur papier) + /ka bascule sur le widget ka-agent commun (bulle → plein écran), suppression du chat embarqué KaChat

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

3 changed files +103 −463

modified src/app/globals.css +6 −2
@@ -209,9 +209,13 @@ select.vp-input {
209 209 }
210 210 @keyframes pulse { 50% { box-shadow: 0 0 0 5px rgba(216, 55, 46, 0.18); } }
211 211
212 −/* surligné signature : marqueur rouge sous le texte (plus de boîte inclinée) */
212 +/* surligné signature : marqueur rouge sous le texte (plus de boîte inclinée).
213 + ⚠️ `color: inherit` est OBLIGATOIRE : le .hl de ka-ui (tokens.css) impose
214 + color: var(--on-accent) = blanc — illisible sur papier. Ici le texte garde
215 + la couleur du titre (encre sur papier, off-white sur .vp-dark). */
213 216 .hl {
214 − background: linear-gradient(transparent 64%, rgba(216, 55, 46, 0.3) 64%);
217 + background: linear-gradient(transparent 62%, rgba(216, 55, 46, 0.32) 62%);
218 + color: inherit;
215 219 padding: 0 2px; border-radius: 0; display: inline; transform: none;
216 220 }
217 221 .outline-txt { color: transparent; -webkit-text-stroke: 1.5px var(--ink); }
modified src/app/ka/KaHero.tsx +97 −34
@@ -1,56 +1,119 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Page /ka — présentation de l'agent. Le chat lui-même est le WIDGET COMMUN
4 + * de l'écosystème (public/ka-agent.js, synchronisé depuis ka-ui) : la bulle
5 + * en bas à droite, qui s'ouvre plein écran. Cette page ne fait que le
6 + * présenter et le déclencher — plus aucun fil de conversation embarqué.
7 + */
2 8 "use client";
3 −import KaChat from "@/components/KaChat";
4 9 import { useLang } from "@/components/LangContext";
5 10
11 +/** Ouvre le widget ka-agent (plein écran d'office) en cliquant sa bulle. */
12 +function openKa() {
13 + const btn = document.querySelector<HTMLButtonElement>(".kaa-btn");
14 + btn?.click();
15 +}
16 +
6 17 export default function KaHero() {
7 18 const { lang } = useLang();
8 19 const fr = lang === "fr";
9 20
10 − const powers = fr
21 + const powers: [string, string][] = fr
22 + ? [
23 + ["12 outils", "registre, moteur d'estimation, marché"],
24 + ["3,7 M", "unités d'évaluation interrogeables"],
25 + ["745 119", "ventes réelles 2021-2026"],
26 + ["En direct", "réponse en streaming, calcul montré"],
27 + ]
28 + : [
29 + ["12 tools", "registry, estimation engine, market"],
30 + ["3.7M", "queryable assessment units"],
31 + ["745,119", "real sales 2021-2026"],
32 + ["Live", "streaming answers, math shown"],
33 + ];
34 +
35 + const examples = fr
11 36 ? [
12 − ["12 outils", "registre, moteur, marché"],
13 − ["3,7 M", "unités interrogeables"],
14 − ["En direct", "réponse en streaming"],
37 + "Combien vaut le 1075, chemin Sainte-Foy à Québec ?",
38 + "Le marché des condos à Montréal, ça va où ?",
39 + "Trouve-moi les dernières ventes à Lévis.",
15 40 ]
16 41 : [
17 − ["12 tools", "registry, engine, market"],
18 − ["3.7M", "queryable units"],
19 − ["Live", "streaming answers"],
42 + "What is 1075 chemin Sainte-Foy in Québec City worth?",
43 + "Where is the Montréal condo market heading?",
44 + "Find me the latest sales in Lévis.",
20 45 ];
21 46
22 47 return (
23 − <main className="relative z-[1] mx-auto w-full max-w-[1080px] px-4 pb-16 pt-10 sm:px-6">
24 − <div className="mb-8 text-center">
25 − <span className="kicker justify-center">
26 − {fr ? "Agent IA · propulsé par Claude" : "AI agent · powered by Claude"}
27 − </span>
28 − <h1 className="vp-display mx-auto mt-3 max-w-[620px] text-[34px] font-bold leading-[1.05] tracking-[-0.04em] sm:text-[46px]">
29 − {fr ? (
30 − <>
31 − Jase avec <span className="hl">Ka</span>, il connaît chaque adresse du Québec.
32 − </>
33 − ) : (
34 − <>
35 − Chat with <span className="hl">Ka</span> — it knows every Québec address.
36 − </>
37 − )}
38 − </h1>
39 − <p className="mx-auto mt-3 max-w-[560px] text-[14.5px] text-ink-2">
48 + <main className="relative z-[1] mx-auto w-full max-w-6xl px-4 pb-16 pt-12 sm:px-6">
49 + <span className="kicker">
50 + {fr ? "Agent IA · propulsé par Claude" : "AI agent · powered by Claude"}
51 + </span>
52 + <h1 className="vp-display mt-3 max-w-3xl text-[clamp(32px,5.5vw,58px)] font-bold uppercase leading-[0.98] tracking-[-0.03em]">
53 + {fr ? (
54 + <>
55 + Jase avec <span className="hl">Ka</span>.
56 + <br />
57 + Il connaît chaque adresse du Québec.
58 + </>
59 + ) : (
60 + <>
61 + Chat with <span className="hl">Ka</span>.
62 + <br />
63 + It knows every Québec address.
64 + </>
65 + )}
66 + </h1>
67 + <p className="mt-4 max-w-2xl text-[15.5px] leading-relaxed text-ink-2">
68 + {fr
69 + ? "Donne-lui une adresse : il la retrouve dans le registre, l'évalue avec le même moteur transparent que Vrai-Prix, puis t'explique le prix — comparables, marché local, tendance. Même agent sur tous les sites du Groupe KA."
70 + : "Give it an address: it finds it in the registry, values it with Vrai-Prix's transparent engine, then explains the price — comparables, local market, trend. Same agent across every Groupe KA site."}
71 + </p>
72 +
73 + <div className="mt-8 flex flex-wrap items-center gap-4">
74 + <button type="button" onClick={openKa} className="btn btn-accent !px-8 !text-[15px]">
75 + {fr ? "Parler à Ka — plein écran" : "Talk to Ka — full screen"} →
76 + </button>
77 + <p className="vp-mono max-w-[260px] text-[10.5px] uppercase tracking-[0.06em] text-ink-3">
40 78 {fr
41 − ? "Donne-lui une adresse : il la retrouve dans le registre, l'évalue avec le même moteur transparent que Vrai-Prix, puis t'explique le prix — comparables, marché local, tendance."
42 − : "Give it an address: it finds it in the registry, values it with Vrai-Prix's transparent engine, then explains the price — comparables, local market, trend."}
79 + ? "Ou touchez la bulle Ka, en bas à droite — sur toutes les pages."
80 + : "Or tap the Ka bubble, bottom right — on every page."}
43 81 </p>
44 − <div className="mt-5 flex flex-wrap justify-center gap-2.5">
45 − {powers.map(([b, s]) => (
46 − <span key={b} className="stat-chip">
47 − <b>{b}</b> {s}
48 − </span>
82 + </div>
83 +
84 + {/* preuves de capacité */}
85 + <div className="mt-10 grid grid-cols-2 gap-x-6 gap-y-5 border-t-2 border-ink pt-6 sm:grid-cols-4">
86 + {powers.map(([b, s]) => (
87 + <div key={b}>
88 + <p className="vp-display text-[clamp(19px,2.4vw,26px)] font-bold leading-none tracking-[-0.02em]">
89 + {b}
90 + </p>
91 + <p className="vp-mono mt-1.5 text-[9.5px] uppercase tracking-[0.1em] text-ink-3">{s}</p>
92 + </div>
93 + ))}
94 + </div>
95 +
96 + {/* exemples : chaque question ouvre le widget */}
97 + <div className="mt-10">
98 + <p className="klabel">{fr ? "Essayez, par exemple" : "Try, for instance"}</p>
99 + <div className="mt-3">
100 + {examples.map((q) => (
101 + <button
102 + key={q}
103 + type="button"
104 + onClick={openKa}
105 + className="feed-row group w-full text-left"
106 + >
107 + <span className="flex items-baseline justify-between gap-4">
108 + <span className="text-[15px] font-semibold">{q}</span>
109 + <span className="vp-mono shrink-0 text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent">
110 + {fr ? "Demander à Ka" : "Ask Ka"} →
111 + </span>
112 + </span>
113 + </button>
49 114 ))}
50 115 </div>
51 116 </div>
52 −
53 − <KaChat />
54 117 </main>
55 118 );
56 119 }
deleted src/components/KaChat.tsx +0 −427
@@ -1,427 +0,0 @@
1 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 −/**
3 − * Ka — interface de conversation en streaming avec l'agent IA.
4 − * Lit le flux SSE de /api/ka : deltas de texte, activité d'outils, fin.
5 − * Le fil a son PROPRE défilement (jamais la page) et l'auto-scroll ne suit
6 − * que si l'utilisateur est déjà au bas du fil — zéro sursaut pendant le stream.
7 − */
8 −"use client";
9 −import { useEffect, useRef, useState } from "react";
10 −import { useLang } from "./LangContext";
11 −
12 −interface Msg {
13 − role: "user" | "assistant";
14 − content: string;
15 − tools?: string[];
16 −}
17 −
18 −const TOOL_LABELS: Record<string, { fr: string; en: string }> = {
19 − chercher_propriete: { fr: "Recherche dans le registre", en: "Searching the registry" },
20 − evaluer_propriete: { fr: "Évaluation en cours", en: "Running the valuation" },
21 − comparables_detailles: { fr: "Analyse des comparables", en: "Analyzing comparables" },
22 − indice_marche: { fr: "Lecture de l'indice de marché", en: "Reading the market index" },
23 − stats_municipalite: { fr: "Statistiques municipales", en: "Municipal statistics" },
24 − stats_provinciales: { fr: "Statistiques provinciales", en: "Provincial statistics" },
25 − evaluer_parc: { fr: "Évaluation du parc", en: "Evaluating the portfolio" },
26 − comparer_proprietes: { fr: "Comparaison des propriétés", en: "Comparing properties" },
27 − estimation_manuelle: { fr: "Estimation par caractéristiques", en: "Estimating from specs" },
28 − chercher_proprietes_secteur: { fr: "Balayage du secteur", en: "Scanning the area" },
29 − liens_rapports: { fr: "Préparation des rapports", en: "Preparing reports" },
30 −};
31 −
32 −/* ------------------------------------------------------------ markdown */
33 −
34 −function inline(s: string, key: string): React.ReactNode[] {
35 − const parts: React.ReactNode[] = [];
36 − const rx = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*/g;
37 − let last = 0;
38 − let m: RegExpExecArray | null;
39 − let i = 0;
40 − while ((m = rx.exec(s))) {
41 − if (m.index > last) parts.push(s.slice(last, m.index));
42 − if (m[1] && m[2])
43 − parts.push(
44 − <a
45 − key={`${key}-${i++}`}
46 − href={m[2]}
47 − target="_blank"
48 − rel="noopener noreferrer"
49 − className="font-bold underline decoration-[var(--lime)] decoration-2 underline-offset-2"
50 − >
51 − {m[1]}
52 − </a>
53 − );
54 − else if (m[3]) parts.push(<strong key={`${key}-${i++}`}>{m[3]}</strong>);
55 − else if (m[4]) parts.push(<em key={`${key}-${i++}`}>{m[4]}</em>);
56 − last = m.index + m[0].length;
57 − }
58 − if (last < s.length) parts.push(s.slice(last));
59 − return parts;
60 −}
61 −
62 −/** Rendu markdown minimal et sûr : gras, italique, listes, liens, TABLEAUX, titres. */
63 −function renderMd(text: string): React.ReactNode[] {
64 − const blocks: React.ReactNode[] = [];
65 − const lines = text.split("\n");
66 − let list: string[] = [];
67 − let table: string[][] = [];
68 − let k = 0;
69 −
70 − const flushList = () => {
71 − if (!list.length) return;
72 − blocks.push(
73 − <ul key={`ul-${k++}`} className="my-1.5 space-y-1 pl-1">
74 − {list.map((li, j) => (
75 − <li key={j} className="flex gap-2">
76 − <span className="mt-[7px] h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--lime)]" />
77 − <span className="min-w-0">{inline(li, `li-${k}-${j}`)}</span>
78 − </li>
79 − ))}
80 − </ul>
81 − );
82 − list = [];
83 − };
84 −
85 − const flushTable = () => {
86 − if (!table.length) return;
87 − const [head, ...rows] = table;
88 − blocks.push(
89 − <div
90 − key={`tw-${k++}`}
91 − className="my-2 max-w-full overflow-x-auto rounded-lg border-[1.5px] border-[var(--line)] bg-surface"
92 − >
93 − <table className="w-full border-collapse text-[12px]">
94 − <thead>
95 − <tr>
96 − {head.map((c, j) => (
97 − <th
98 − key={j}
99 − className="vp-mono whitespace-nowrap border-b-[1.5px] border-[var(--line)] bg-surface-2 px-2.5 py-1.5 text-left text-[9px] font-bold uppercase tracking-[0.08em] text-ink-3"
100 − >
101 − {inline(c, `th-${k}-${j}`)}
102 − </th>
103 − ))}
104 − </tr>
105 − </thead>
106 − <tbody>
107 − {rows.map((row, ri) => (
108 − <tr key={ri}>
109 − {row.map((c, j) => (
110 − <td
111 − key={j}
112 − className={`whitespace-nowrap px-2.5 py-1.5 align-top ${
113 − ri < rows.length - 1 ? "border-b border-[var(--line)]" : ""
114 − }`}
115 − >
116 − {inline(c, `td-${k}-${ri}-${j}`)}
117 − </td>
118 − ))}
119 − </tr>
120 − ))}
121 − </tbody>
122 − </table>
123 − </div>
124 − );
125 − table = [];
126 − };
127 −
128 − for (const line of lines) {
129 − const t = line.trim();
130 − // ligne de tableau |a|b| (ignore la ligne séparatrice |---|---|)
131 − if (t.startsWith("|") && t.endsWith("|") && t.length > 2) {
132 − const cells = t
133 − .slice(1, -1)
134 − .split("|")
135 − .map((c) => c.trim());
136 − if (!cells.every((c) => /^:?-{2,}:?$/.test(c))) {
137 − flushList();
138 − table.push(cells);
139 − }
140 − continue;
141 − }
142 − flushTable();
143 − if (/^[-•]\s+/.test(t)) {
144 − list.push(t.replace(/^[-•]\s+/, ""));
145 − continue;
146 − }
147 − flushList();
148 − if (t === "" || t === "---") continue;
149 − if (t.startsWith("[réf:")) continue; // réf machine pour la continuité — jamais affichée
150 − if (/^#{1,4}\s+/.test(t)) {
151 − blocks.push(
152 − <p key={`h-${k++}`} className="vp-display mt-2 text-[15px] font-bold">
153 − {inline(t.replace(/^#{1,4}\s+/, ""), `h-${k}`)}
154 − </p>
155 − );
156 − } else {
157 − blocks.push(
158 − <p key={`p-${k++}`} className="my-1.5">
159 − {inline(t, `p-${k}`)}
160 − </p>
161 − );
162 − }
163 − }
164 − flushList();
165 − flushTable();
166 − return blocks;
167 −}
168 −
169 −/* ------------------------------------------------------------ composant */
170 −
171 −export default function KaChat() {
172 − const { lang } = useLang();
173 − const fr = lang === "fr";
174 − const [messages, setMessages] = useState<Msg[]>([]);
175 − const [input, setInput] = useState("");
176 − const [busy, setBusy] = useState(false);
177 − const [activeTool, setActiveTool] = useState<string | null>(null);
178 − const feedRef = useRef<HTMLDivElement>(null);
179 − const stickBottom = useRef(true);
180 −
181 − // auto-scroll DU FIL seulement (jamais la page), et seulement si déjà en bas
182 − useEffect(() => {
183 − const el = feedRef.current;
184 − if (el && stickBottom.current) el.scrollTop = el.scrollHeight;
185 − }, [messages, activeTool]);
186 −
187 − const onFeedScroll = () => {
188 − const el = feedRef.current;
189 − if (!el) return;
190 − stickBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 140;
191 − };
192 −
193 − const suggestions = fr
194 − ? [
195 − "Combien vaut le 861 route Elgin à Saint-Pamphile ?",
196 − "Le marché des condos monte-t-il au Québec ?",
197 − "Trouve-moi une maison à Lévis sous 450 000 $",
198 − "Que vaut tout l'immobilier du Québec ?",
199 − ]
200 − : [
201 − "How much is 861 route Elgin in Saint-Pamphile worth?",
202 − "Is the Québec condo market going up?",
203 − "Find me a house in Lévis under $450,000",
204 − "What is all Québec real estate worth?",
205 − ];
206 −
207 − async function send(text: string) {
208 − const content = text.trim();
209 − if (!content || busy) return;
210 − setInput("");
211 − setBusy(true);
212 − setActiveTool(null);
213 − stickBottom.current = true;
214 −
215 − const history = [...messages, { role: "user" as const, content }];
216 − setMessages([...history, { role: "assistant", content: "", tools: [] }]);
217 −
218 − try {
219 − const res = await fetch("/api/ka", {
220 − method: "POST",
221 − headers: { "Content-Type": "application/json" },
222 − body: JSON.stringify({
223 − messages: history.map(({ role, content }) => ({ role, content })),
224 − }),
225 − });
226 − if (!res.ok || !res.body) throw new Error(String(res.status));
227 −
228 − const reader = res.body.getReader();
229 − const decoder = new TextDecoder();
230 − let buffer = "";
231 −
232 − const patch = (fn: (m: Msg) => Msg) =>
233 − setMessages((prev) => {
234 − const next = [...prev];
235 − next[next.length - 1] = fn(next[next.length - 1]);
236 − return next;
237 − });
238 −
239 − for (;;) {
240 − const { done, value } = await reader.read();
241 − if (done) break;
242 − buffer += decoder.decode(value, { stream: true });
243 − const events = buffer.split("\n\n");
244 − buffer = events.pop() ?? "";
245 − for (const ev of events) {
246 − const line = ev.split("\n").find((l) => l.startsWith("data: "));
247 − if (!line) continue;
248 − let data: {
249 − type: string;
250 − text?: string;
251 − name?: string;
252 − message?: string;
253 − refs?: { id: string; adresse: string; pdf?: string; pdf_pro?: string }[];
254 − };
255 − try {
256 − data = JSON.parse(line.slice(6));
257 − } catch {
258 − continue;
259 − }
260 − if (data.type === "text" && data.text) {
261 − setActiveTool(null);
262 − patch((m) => ({ ...m, content: m.content + data.text }));
263 − } else if (data.type === "tool" && data.name) {
264 − setActiveTool(data.name);
265 − patch((m) => ({
266 − ...m,
267 − tools: m.tools?.includes(data.name!) ? m.tools : [...(m.tools ?? []), data.name!],
268 − }));
269 − } else if (data.type === "refs" && Array.isArray(data.refs)) {
270 − const lines = (
271 − data.refs as { id: string; adresse: string; pdf?: string; pdf_pro?: string }[]
272 − )
273 − .map(
274 − (r) =>
275 − `[réf: ${r.adresse} → id ${r.id}` +
276 − (r.pdf ? ` | rapport standard: ${r.pdf} | rapport pro: ${r.pdf_pro}` : "") +
277 − "]"
278 − )
279 − .join("\n");
280 − patch((m) => ({ ...m, content: m.content + "\n\n" + lines }));
281 − } else if (data.type === "error") {
282 − patch((m) => ({
283 − ...m,
284 − content: m.content + (m.content ? "\n\n" : "") + `*${data.message}*`,
285 − }));
286 − }
287 − }
288 − }
289 − } catch {
290 − setMessages((prev) => {
291 − const next = [...prev];
292 − const last = next[next.length - 1];
293 − if (last?.role === "assistant" && !last.content) {
294 − last.content = fr
295 − ? "*Connexion interrompue — réessaie dans un instant.*"
296 − : "*Connection lost — try again in a moment.*";
297 − }
298 − return next;
299 − });
300 − } finally {
301 − setActiveTool(null);
302 − setBusy(false);
303 − }
304 − }
305 −
306 − const toolLabel = (name: string) =>
307 − TOOL_LABELS[name]?.[fr ? "fr" : "en"] ?? name.replace(/_/g, " ");
308 −
309 − return (
310 − <div className="mx-auto flex w-full max-w-[860px] flex-col">
311 − {/* fil de conversation — défilement interne, hauteur stable */}
312 − <div
313 − ref={feedRef}
314 − onScroll={onFeedScroll}
315 − className="vp-card flex h-[56dvh] flex-col gap-4 overflow-y-auto overscroll-contain p-3.5 sm:h-[58vh] sm:gap-5 sm:p-6"
316 − >
317 − {messages.length === 0 && (
318 − <div className="my-auto text-center">
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 − </div>
322 − <p className="vp-display px-2 text-[17px] font-bold sm:text-[19px]">
323 − {fr ? "Dis-moi quelle propriété t'intéresse." : "Tell me which property you're curious about."}
324 − </p>
325 − <p className="mx-auto mt-1.5 max-w-[440px] px-3 text-[13px] text-ink-2 sm:text-[13.5px]">
326 − {fr
327 − ? "Je fouille 3,7 millions d'unités d'évaluation et 745 000 ventes réelles, en direct, pendant qu'on jase."
328 − : "I search 3.7M assessment units and 745k real sales, live, while we chat."}
329 − </p>
330 − <div className="mx-auto mt-4 flex max-w-[560px] flex-col items-stretch gap-2 px-1 sm:mt-5 sm:flex-row sm:flex-wrap sm:justify-center">
331 − {suggestions.map((s) => (
332 − <button
333 − key={s}
334 − onClick={() => send(s)}
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 − >
337 − {s}
338 − </button>
339 − ))}
340 − </div>
341 − </div>
342 − )}
343 −
344 − {messages.map((m, i) => (
345 − <div key={i} className={m.role === "user" ? "flex justify-end" : "flex justify-start"}>
346 − {m.role === "user" ? (
347 − <div className="max-w-[88%] rounded-2xl rounded-br-md border-[1.5px] border-ink bg-ink px-3.5 py-2.5 text-[13.5px] font-medium text-paper sm:text-[14px]">
348 − {m.content}
349 − </div>
350 − ) : (
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-[2px] bg-[var(--ink-deep)] sm:flex">
353 − <span className="vp-display text-[12px] font-bold text-[var(--accent-bright)]">Ka</span>
354 − </div>
355 − <div className="min-w-0 flex-1">
356 − {m.tools && m.tools.length > 0 && (
357 − <div className="mb-1.5 flex flex-wrap gap-1.5">
358 − {m.tools.map((t) => (
359 − <span
360 − key={t}
361 − className="vp-mono inline-flex items-center gap-1.5 rounded-full border border-[var(--line)] bg-surface-2 px-2.5 py-[3px] text-[9.5px] uppercase tracking-[0.05em] text-ink-2"
362 − >
363 − <span
364 − className={`h-[6px] w-[6px] rounded-full ${
365 − busy && i === messages.length - 1 && activeTool === t
366 − ? "animate-pulse bg-[var(--lime)]"
367 − : "bg-[var(--green)]"
368 − }`}
369 − />
370 − {toolLabel(t)}
371 − </span>
372 − ))}
373 − </div>
374 − )}
375 − <div className="text-[13.5px] leading-relaxed sm:text-[14px]">
376 − {m.content ? (
377 − renderMd(m.content)
378 − ) : busy && i === messages.length - 1 ? (
379 − <span className="inline-flex gap-1 py-1">
380 − <span className="h-2 w-2 animate-bounce rounded-full bg-ink-3 [animation-delay:0ms]" />
381 − <span className="h-2 w-2 animate-bounce rounded-full bg-ink-3 [animation-delay:120ms]" />
382 − <span className="h-2 w-2 animate-bounce rounded-full bg-ink-3 [animation-delay:240ms]" />
383 − </span>
384 − ) : null}
385 − </div>
386 − </div>
387 − </div>
388 − )}
389 − </div>
390 − ))}
391 − </div>
392 −
393 − {/* zone de saisie — toujours visible sous le fil */}
394 − <form
395 − onSubmit={(e) => {
396 − e.preventDefault();
397 − send(input);
398 − }}
399 − className="mt-3 flex gap-2 sm:mt-4 sm:gap-2.5"
400 − >
401 − <input
402 − value={input}
403 − onChange={(e) => setInput(e.target.value)}
404 − placeholder={fr ? "Adresse ou question…" : "Address or question…"}
405 − className="vp-input min-w-0 flex-1"
406 − maxLength={2000}
407 − aria-label={fr ? "Message à Ka" : "Message to Ka"}
408 − />
409 − <button
410 − type="submit"
411 − disabled={busy || !input.trim()}
412 − className="btn btn-primary shrink-0 px-4 disabled:opacity-50 sm:px-5"
413 − aria-label={fr ? "Envoyer" : "Send"}
414 − >
415 − <span className="hidden sm:inline">{busy ? (fr ? "Ka réfléchit…" : "Thinking…") : fr ? "Envoyer" : "Send"}</span>
416 − <span className="sm:hidden">{busy ? "…" : "↑"}</span>
417 − </button>
418 − </form>
419 −
420 − <p className="vp-mono mt-2.5 px-2 text-center text-[9.5px] uppercase leading-relaxed tracking-[0.08em] text-ink-3 sm:mt-3 sm:text-[10px]">
421 − {fr
422 − ? "Ka est une IA (Claude d'Anthropic) — estimations indicatives, pas un évaluateur agréé"
423 − : "Ka is an AI (Anthropic's Claude) — indicative estimates, not a chartered appraiser"}
424 − </p>
425 − </div>
426 − );
427 −}
428