SPB Git

spb/vrai-prix

Public

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

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 (Aug 25, 2026) parent 983557a Browse files

3 changed files +103 −463

modified src/app/globals.css +6 −2
@@ -209,9 +209,13 @@ select.vp-input {
209 } 209 }
210 @keyframes pulse { 50% { box-shadow: 0 0 0 5px rgba(216, 55, 46, 0.18); } } 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 .hl { 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 padding: 0 2px; border-radius: 0; display: inline; transform: none; 219 padding: 0 2px; border-radius: 0; display: inline; transform: none;
216 } 220 }
217 .outline-txt { color: transparent; -webkit-text-stroke: 1.5px var(--ink); } 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 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 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 "use client"; 8 "use client";
3−import KaChat from "@/components/KaChat";
4 import { useLang } from "@/components/LangContext"; 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 export default function KaHero() { 17 export default function KaHero() {
7 const { lang } = useLang(); 18 const { lang } = useLang();
8 const fr = lang === "fr"; 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é"], 37+ "Combien vaut le 1075, chemin Sainte-Foy à Québec ?",
13− ["3,7 M", "unités interrogeables"], 38+ "Le marché des condos à Montréal, ça va où ?",
14− ["En direct", "réponse en streaming"], 39+ "Trouve-moi les dernières ventes à Lévis.",
15 ] 40 ]
16 : [ 41 : [
17− ["12 tools", "registry, engine, market"], 42+ "What is 1075 chemin Sainte-Foy in Québec City worth?",
18− ["3.7M", "queryable units"], 43+ "Where is the Montréal condo market heading?",
19− ["Live", "streaming answers"], 44+ "Find me the latest sales in Lévis.",
20 ]; 45 ];
21 46
22 return ( 47 return (
23− <main className="relative z-[1] mx-auto w-full max-w-[1080px] px-4 pb-16 pt-10 sm:px-6"> 48+ <main className="relative z-[1] mx-auto w-full max-w-6xl px-4 pb-16 pt-12 sm:px-6">
24− <div className="mb-8 text-center"> 49+ <span className="kicker">
25− <span className="kicker justify-center"> 50+ {fr ? "Agent IA · propulsé par Claude" : "AI agent · powered by Claude"}
26− {fr ? "Agent IA · propulsé par Claude" : "AI agent · powered by Claude"} 51+ </span>
27− </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]">
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]"> 53+ {fr ? (
29− {fr ? ( 54+ <>
30− <> 55+ Jase avec <span className="hl">Ka</span>.
31− Jase avec <span className="hl">Ka</span>, il connaît chaque adresse du Québec. 56+ <br />
32− </> 57+ Il connaît chaque adresse du Québec.
33− ) : ( 58+ </>
34− <> 59+ ) : (
35− Chat with <span className="hl">Ka</span> — it knows every Québec address. 60+ <>
36− </> 61+ Chat with <span className="hl">Ka</span>.
37− )} 62+ <br />
38− </h1> 63+ It knows every Québec address.
39− <p className="mx-auto mt-3 max-w-[560px] text-[14.5px] text-ink-2"> 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 {fr 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." 79+ ? "Ou touchez la bulle Ka, en bas à droite — sur toutes les pages."
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."} 80+ : "Or tap the Ka bubble, bottom right — on every page."}
43 </p> 81 </p>
44− <div className="mt-5 flex flex-wrap justify-center gap-2.5"> 82+ </div>
45− {powers.map(([b, s]) => ( 83+
46− <span key={b} className="stat-chip"> 84+ {/* preuves de capacité */}
47− <b>{b}</b> {s} 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">
48− </span> 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 </div> 115 </div>
51 </div> 116 </div>
52−
53− <KaChat />
54 </main> 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
Menu

Theme