Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1// -----------------------------------------------------------------------------2// Lou-Ka — Générateur de fichier Figma (plugin de développement)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// Source de vérité : frontend/src/ka/tokens.css + frontend/src/styles.css du5// repo lou-ka (déployé sur M4M64a:~/apps/lou-ka → www.lou-ka.com), relu le6// 2026-09-04, et captures du site en production embarquées (REFS).7// Le plugin construit dans le fichier Figma courant :8// ⓪ Couverture ① Fondations (couleurs, typo, espacements, ombres, logo ;9// styles + variables) ② Composants (jeux de variantes + blocs)10// ③ Écrans desktop 1440 ④ Écrans mobile 390 ⑤ Références live11// JavaScript pur, sans dépendance. `REFS` (captures + photos base64) est12// préfixé par build.mjs → plugin/code.js.13// -----------------------------------------------------------------------------14/* global figma, REFS */1516const R = typeof REFS !== "undefined" ? REFS : [];1718// ============================================================================19// 1. JETONS (tokens.css + surcharge Lou-Ka dans styles.css)20// ============================================================================21const C = {22 paper: "#f5f3ee", surface: "#ffffff", surface2: "#faf9f5",23 ink: "#141814", ink2: "#4d5551", ink3: "#8b928c",24 green: "#1c5c41", greenDeep: "#123f2e", greenSoft: "#e8f0ea", greenOk: "#2e7d4a", greenOkSoft: "#eef7f0",25 amber: "#e8a33d", amberSoft: "#fdf3e2",26 danger: "#b3423a", dangerSoft: "#fbe9e7",27 accent: "#ff6a00", accentSoft: "#fff1e6", accentDeep: "#cc5500", onAccent: "#ffffff",28 navy: "#0b1330", navy2: "#101a3d", white: "#ffffff",29};30const LINE = 0.14, LINE_STRONG = 0.85;31const RAD = { card: 10, ctl: 6, pill: 999, bar: 16, sheet: 24 };32const SP = [4, 8, 12, 16, 24, 32, 48, 64];3334const FONT = { display: "Space Grotesk", body: "Inter", mono: "JetBrains Mono" };35const STYLE_CANDIDATES = { 400: ["Regular"], 500: ["Medium"], 600: ["SemiBold", "Semi Bold"], 700: ["Bold"] };36const FN = {};3738function rgb(hex) {39 const h = hex.replace("#", "");40 return { r: parseInt(h.slice(0, 2), 16) / 255, g: parseInt(h.slice(2, 4), 16) / 255, b: parseInt(h.slice(4, 6), 16) / 255 };41}42function paint(hex, opacity = 1) { return { type: "SOLID", color: rgb(hex), opacity }; }43function rgba(hex, a) { return Object.assign({}, rgb(hex), { a }); }44function shadow(x, y, blur, hex, a, spread = 0) {45 return { type: "DROP_SHADOW", color: rgba(hex, a), offset: { x, y }, radius: blur, spread, visible: true, blendMode: "NORMAL" };46}47const SH = {48 flat: shadow(0, 1, 2, C.ink, 0.05),49 off: shadow(6, 6, 0, C.ink, 1),50 offSoft: shadow(8, 8, 0, C.ink, 0.08),51 offMid: shadow(4, 4, 0, C.ink, 0.18),52 hard4: shadow(4, 4, 0, C.ink, 1),53 accent3: shadow(3, 3, 0, C.accent, 1),54 fabBlur: shadow(0, 10, 28, C.ink, 0.25),55};5657// ============================================================================58// 2. POLICES59// ============================================================================60async function tryLoad(family, style) {61 try { await figma.loadFontAsync({ family, style }); return { family, style }; } catch (e) { return null; }62}63async function loadFonts() {64 for (const fam of Object.values(FONT)) {65 FN[fam] = {};66 for (const w of [400, 500, 600, 700]) {67 let got = null;68 for (const s of STYLE_CANDIDATES[w]) { got = await tryLoad(fam, s); if (got) break; }69 if (!got && fam !== FONT.body) for (const s of STYLE_CANDIDATES[w]) { got = await tryLoad(FONT.body, s); if (got) break; }70 if (!got) got = await tryLoad("Inter", "Regular");71 FN[fam][w] = got;72 }73 }74}75function fn(famKey, w) { return FN[FONT[famKey]][w] || FN[FONT.body][400]; }7677// ============================================================================78// 3. PRIMITIVES79// ============================================================================80function T(chars, o = {}) {81 const t = figma.createText();82 t.fontName = fn(o.fam || "body", o.w || 400);83 t.characters = String(chars);84 t.fontSize = o.size || 15;85 t.fills = [paint(o.color || C.ink, o.op == null ? 1 : o.op)];86 if (o.ls) t.letterSpacing = { unit: "PERCENT", value: o.ls * 100 };87 if (o.lh) t.lineHeight = { unit: "PIXELS", value: o.lh };88 else t.lineHeight = { unit: "PERCENT", value: o.lhp || (o.fam === "display" ? 110 : 155) };89 if (o.upper) t.textCase = "UPPER";90 if (o.align) t.textAlignHorizontal = o.align;91 if (o.width) { t.textAutoResize = "HEIGHT"; t.resize(o.width, 10); } else t.textAutoResize = "WIDTH_AND_HEIGHT";92 if (o.name) t.name = o.name;93 return t;94}95function RT(segments, o = {}) {96 const t = T(segments.map((s) => s.text).join(""), Object.assign({}, o, { color: segments[0].color || o.color }));97 let i = 0;98 for (const s of segments) {99 const end = i + s.text.length;100 if (s.color) t.setRangeFills(i, end, [paint(s.color, s.op == null ? 1 : s.op)]);101 if (s.w || s.fam) t.setRangeFontName(i, end, fn(s.fam || o.fam || "body", s.w || o.w || 400));102 if (s.size) t.setRangeFontSize(i, end, s.size);103 i = end;104 }105 return t;106}107function box(o = {}) {108 const f = o.component ? figma.createComponent() : figma.createFrame();109 f.name = o.name || "Frame";110 const dir = o.dir || "NONE";111 f.layoutMode = dir === "H" ? "HORIZONTAL" : dir === "V" ? "VERTICAL" : "NONE";112 if (dir !== "NONE") {113 f.itemSpacing = o.gap == null ? 0 : o.gap;114 const p = o.pad == null ? 0 : o.pad; const pa = Array.isArray(p) ? p : [p, p, p, p];115 f.paddingTop = pa[0]; f.paddingRight = pa[1]; f.paddingBottom = pa[2]; f.paddingLeft = pa[3];116 f.primaryAxisAlignItems = o.justify || "MIN";117 f.counterAxisAlignItems = o.align || "MIN";118 const wFixed = o.w != null, hFixed = o.h != null;119 if (dir === "H") { f.primaryAxisSizingMode = wFixed ? "FIXED" : "AUTO"; f.counterAxisSizingMode = hFixed ? "FIXED" : "AUTO"; }120 else { f.primaryAxisSizingMode = hFixed ? "FIXED" : "AUTO"; f.counterAxisSizingMode = wFixed ? "FIXED" : "AUTO"; }121 if (o.wrap) { f.layoutWrap = "WRAP"; f.counterAxisSpacing = o.rowGap == null ? (o.gap || 0) : o.rowGap; }122 }123 f.resize(o.w || 100, o.h || 100);124 f.fills = o.fill ? [typeof o.fill === "string" ? paint(o.fill, o.fillOp == null ? 1 : o.fillOp) : o.fill] : [];125 if (o.stroke) {126 f.strokes = [paint(o.stroke, o.strokeOp == null ? 1 : o.strokeOp)];127 f.strokeWeight = o.sw == null ? 1.5 : o.sw; f.strokeAlign = "INSIDE";128 if (o.strokeSides) { f.strokeTopWeight = o.strokeSides[0]; f.strokeRightWeight = o.strokeSides[1]; f.strokeBottomWeight = o.strokeSides[2]; f.strokeLeftWeight = o.strokeSides[3]; }129 if (o.dash) f.dashPattern = o.dash;130 }131 if (o.radius != null) f.cornerRadius = o.radius;132 if (o.radii) { f.topLeftRadius = o.radii[0]; f.topRightRadius = o.radii[1]; f.bottomRightRadius = o.radii[2]; f.bottomLeftRadius = o.radii[3]; }133 if (o.shadow) f.effects = Array.isArray(o.shadow) ? o.shadow : [o.shadow];134 f.clipsContent = !!o.clip;135 if (o.op != null) f.opacity = o.op;136 return f;137}138function add(parent, child, opts = {}) {139 parent.appendChild(child);140 if (parent.layoutMode && parent.layoutMode !== "NONE") {141 if (opts.fillW) child.layoutSizingHorizontal = "FILL";142 if (opts.fillH) child.layoutSizingVertical = "FILL";143 if (opts.grow) child.layoutGrow = 1;144 }145 if (opts.x != null) child.x = opts.x;146 if (opts.y != null) child.y = opts.y;147 return child;148}149/** Enfant positionné en absolu dans un auto-layout (barres fixes, FAB…) */150function pin(parent, child, x, y) {151 parent.appendChild(child);152 child.layoutPositioning = "ABSOLUTE";153 child.x = x; child.y = y;154 return child;155}156function rect(w, h, fill, o = {}) {157 const r = figma.createRectangle(); r.resize(w, h);158 r.fills = fill ? [typeof fill === "string" ? paint(fill, o.op == null ? 1 : o.op) : fill] : [];159 if (o.radius != null) r.cornerRadius = o.radius;160 if (o.stroke) { r.strokes = [paint(o.stroke, o.strokeOp == null ? 1 : o.strokeOp)]; r.strokeWeight = o.sw == null ? 1.5 : o.sw; r.strokeAlign = "INSIDE"; if (o.dash) r.dashPattern = o.dash; }161 if (o.shadow) r.effects = Array.isArray(o.shadow) ? o.shadow : [o.shadow];162 r.name = o.name || "Rect"; return r;163}164function ellipse(d, fill, o = {}) {165 const e = figma.createEllipse(); e.resize(d, d); e.fills = fill ? [paint(fill, o.op == null ? 1 : o.op)] : [];166 if (o.stroke) { e.strokes = [paint(o.stroke, o.strokeOp == null ? 1 : o.strokeOp)]; e.strokeWeight = o.sw || 1.5; }167 if (o.shadow) e.effects = Array.isArray(o.shadow) ? o.shadow : [o.shadow];168 e.name = o.name || "Dot"; return e;169}170function spacer(w, h) { return box({ w: w || 1, h: h || 1, name: "spacer" }); }171function hr(w, op = LINE, h = 1) { return rect(w, h, C.ink, { op, name: "filet" }); }172function svg(markup, name) { const n = figma.createNodeFromSvg(markup); n.name = name || "svg"; return n; }173function gradient(stops, angleDeg = 90) {174 const a = (angleDeg * Math.PI) / 180; const dx = Math.cos(a) / 2, dy = Math.sin(a) / 2;175 return { type: "GRADIENT_LINEAR", gradientTransform: [[dx * 2 || 0.0001, 0, 0.5 - dx], [0, dy * 2 || 0.0001, 0.5 - dy]], gradientStops: stops.map((s) => ({ position: s[0], color: rgba(s[1], s[2] == null ? 1 : s[2]) })) };176}177const IMG = {};178function imageHash(name) {179 if (IMG[name]) return IMG[name];180 const ref = R.find((r) => r.name === name); if (!ref) return null;181 try { IMG[name] = figma.createImage(figma.base64Decode(ref.b64)).hash; return IMG[name]; } catch (e) { return null; }182}183const PHOTOS = R.filter((r) => r.kind === "photo").map((r) => r.name);184let photoIdx = 0;185function photo(w, h, o = {}) {186 const r = figma.createRectangle(); r.resize(w, h); r.name = o.name || "photo";187 const name = o.photo || (PHOTOS.length ? PHOTOS[photoIdx++ % PHOTOS.length] : null);188 const hash = name ? imageHash(name) : null;189 r.fills = hash ? [{ type: "IMAGE", scaleMode: "FILL", imageHash: hash }] : [gradient([[0, "#e9e4d8"], [1, "#cfc7b6"]], 135)];190 if (o.radius != null) r.cornerRadius = o.radius;191 return r;192}193194// ============================================================================195// 4. ICÔNES + LOGO196// ============================================================================197const ICO = {198 search: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>`,199 camera: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2.2" stroke-linejoin="round"><path d="M4 8h3l2-3h6l2 3h3v11H4z"/><circle cx="12" cy="13" r="3.5"/></svg>`,200 heart: (c, filled) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="${filled ? c : "none"}" stroke="${c}" stroke-width="2" stroke-linejoin="round"><path d="M12 20s-7-4.6-7-10a4 4 0 0 1 7-2.5A4 4 0 0 1 19 10c0 5.4-7 10-7 10z"/></svg>`,201 list: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2.4" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h16"/></svg>`,202 map: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linejoin="round"><path d="M3 6l6-2 6 2 6-2v14l-6 2-6-2-6 2z"/><path d="M9 4v14M15 6v14"/></svg>`,203 chev: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="3" stroke-linecap="round"><path d="M6 9l6 6 6-6"/></svg>`,204 sliders: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15" cy="7" r="2.5"/><circle cx="9" cy="17" r="2.5"/></svg>`,205 home: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linejoin="round"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6h-6v6H4a1 1 0 0 1-1-1z"/></svg>`,206 pin: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linejoin="round"><path d="M12 21s-6-5.5-6-11a6 6 0 0 1 12 0c0 5.5-6 11-6 11z"/><circle cx="12" cy="10" r="2.2"/></svg>`,207 user: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>`,208 doc: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linejoin="round"><path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4M9 13h6M9 17h6"/></svg>`,209 check: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="3" stroke-linecap="round"><path d="M5 12l5 5 9-10"/></svg>`,210 spark: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="${c}"><path d="M12 2l2.2 6.8L21 11l-6.8 2.2L12 20l-2.2-6.8L3 11l6.8-2.2z"/></svg>`,211 arrow: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2.4" stroke-linecap="round"><path d="M7 17L17 7M9 7h8v8"/></svg>`,212 x: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2.6" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>`,213 door: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linejoin="round"><rect x="5" y="3" width="14" height="18" rx="2"/><circle cx="14.5" cy="12" r="1" fill="${c}"/></svg>`,214 bolt: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linejoin="round"><path d="M13 2L4 14h7l-1 8 9-12h-7z"/></svg>`,215 paw: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="${c}"><circle cx="7" cy="9" r="2"/><circle cx="17" cy="9" r="2"/><circle cx="10.5" cy="5" r="2"/><circle cx="13.5" cy="5" r="2"/><path d="M12 11c-3 0-6 3-6 6a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3c0-3-3-6-6-6z"/></svg>`,216 bed: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linejoin="round"><path d="M3 18v-7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v7M3 15h18M6 9V6h5v3"/></svg>`,217 down: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="${c}"><path d="M6 9h12l-6 8z"/></svg>`,218 up: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="${c}"><path d="M6 15h12l-6-8z"/></svg>`,219 approx: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2.4" stroke-linecap="round"><path d="M4 10c3-3 5-3 8 0s5 3 8 0M4 15c3-3 5-3 8 0s5 3 8 0"/></svg>`,220 refresh: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2.2" stroke-linecap="round"><path d="M20 12a8 8 0 1 1-2.5-5.8"/><path d="M20 4v5h-5"/></svg>`,221 download: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v11M7 11l5 5 5-5M5 20h14"/></svg>`,222 chevR: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2.6" stroke-linecap="round"><path d="M9 6l6 6-6 6"/></svg>`,223 tools: (c) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"><path d="M14 6l4 4-9 9-4-4zM4 20l1-1M15 5l3-3 4 4-3 3"/></svg>`,224};225function ico(name, color, size = 16, arg) { const n = svg(ICO[name](color, arg), `ico/${name}`); n.resize(size, size); return n; }226227function logoSvg(dark) {228 const doorTop = dark ? "#ffffff" : "#101a3d", doorBot = dark ? "#e8edf8" : "#0b1330";229 return `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100" fill="none">230 <defs>231 <linearGradient id="fr" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff7a1a"/><stop offset="1" stop-color="#f56000"/></linearGradient>232 <linearGradient id="dr" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${doorTop}"/><stop offset="1" stop-color="${doorBot}"/></linearGradient>233 <linearGradient id="li" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff8533"/><stop offset="1" stop-color="#ff6a00"/></linearGradient>234 </defs>235 <path d="M29 17 L66 22.5 L62 73.5 L29 79 Z" fill="url(#dr)" stroke="url(#dr)" stroke-width="4" stroke-linejoin="round"/>236 <path d="M37 77.4 L63 73.2 L74.8 88.5 Q76.4 92.5 71.6 92.5 L34.5 92.5 Q30.2 92.5 32 88.6 Z" fill="url(#li)"/>237 <rect x="20" y="5" width="60" height="90" rx="19" stroke="url(#fr)" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round"/>238 <path d="M31 6.8 Q50 4.4 69 6.8" stroke="#ffffff" stroke-opacity="0.28" stroke-width="2.6" stroke-linecap="round"/>239</svg>`;240}241function logoIcon(size, dark) { const n = svg(logoSvg(dark), "Logo · icône"); n.resize(size, size); return n; }242/** Wordmark « Lou-Ka » — rendu actuel : « Lou- » encre, « Ka » dans une boîte orange inclinée (−2°). */243function logo(size = 30, o = {}) {244 const ts = o.textSize || Math.round(size * 0.83);245 const f = box({ dir: "H", gap: Math.round(size * 0.4), align: "CENTER", name: "Logo Lou-Ka" });246 add(f, logoIcon(size, o.dark));247 const wm = box({ dir: "H", gap: 2, align: "CENTER", name: "wordmark" });248 add(wm, T("Lou-", { fam: "display", w: 700, size: ts, color: o.dark ? C.paper : C.ink, ls: -0.03, lhp: 100 }));249 const ka = box({ dir: "H", pad: [0, Math.round(ts * 0.24), Math.round(ts * 0.08), Math.round(ts * 0.24)], align: "CENTER", fill: C.accent, radius: Math.round(ts * 0.24), name: "Ka" }); ka.rotation = 2;250 add(ka, T("Ka", { fam: "display", w: 700, size: ts, color: C.white, ls: -0.03, lhp: 100 }));251 add(wm, ka); add(f, wm);252 return f;253}254255// ============================================================================256// 5. COMPOSANTS257// ============================================================================258function kicker(text, o = {}) {259 const f = box({ dir: "H", gap: 10, align: "CENTER", name: "Kicker", w: o.w });260 add(f, rect(22, 2, o.color || C.green, { name: "tiret" }));261 add(f, T(text, { fam: "mono", w: 500, size: 11.5, color: o.color || C.green, ls: 0.12, upper: true, lhp: 140, width: o.w ? o.w - 32 : undefined }));262 return f;263}264function klabel(text, o = {}) { return T(text, Object.assign({ fam: "mono", w: 700, size: 10, color: C.ink3, ls: 0.1, upper: true, lhp: 140, name: "klabel" }, o)); }265266/** Bouton .btn — default | primary | accent | ghost | login */267function button(label, variant = "default", o = {}) {268 const v = {269 default: { bg: C.surface, fg: C.ink, stroke: C.ink },270 primary: { bg: C.ink, fg: C.accent, stroke: C.ink },271 accent: { bg: C.accent, fg: C.onAccent, stroke: C.ink, sh: SH.hard4 },272 ghost: { bg: null, fg: C.ink, stroke: C.ink, strokeOp: LINE_STRONG, sw: 1 },273 login: { bg: C.ink, fg: C.accent, stroke: C.ink },274 }[variant];275 const f = box({ dir: "H", gap: 8, pad: [0, o.px || 18, 0, o.px || 18], h: o.h || 44, w: o.w, align: "CENTER", justify: "CENTER", fill: v.bg, stroke: v.stroke,276 strokeOp: v.strokeOp == null ? 1 : v.strokeOp, sw: v.sw == null ? 1.5 : v.sw, radius: o.pill ? RAD.pill : RAD.ctl, shadow: o.shadow || v.sh || null,277 name: o.name || `Bouton / ${variant}`, component: o.component });278 if (variant === "login") { const k = box({ dir: "H", w: 18, h: 16, align: "CENTER", justify: "CENTER", fill: C.accent, radius: 5, name: "KA" }); add(k, T("KA", { w: 700, size: 9, color: C.white, ls: -0.02, lhp: 100 })); add(f, k); }279 if (o.icon) add(f, ico(o.icon, o.iconColor || v.fg, o.iconSize || 14));280 add(f, T(label, { fam: "display", w: 700, size: o.size || 14, color: v.fg, lhp: 100, name: "label" }));281 if (o.chev) add(f, ico("chev", v.fg, 10));282 return f;283}284/** Chip .chip (rendu actuel : filet fort 1 px, fond transparent, 38 px ; actif = encre + papier) */285function chip(label, state = "default", o = {}) {286 const v = {287 default: { bg: null, fg: C.ink, stroke: C.ink, strokeOp: LINE_STRONG, sw: 1 },288 on: { bg: C.ink, fg: C.paper, stroke: C.ink, strokeOp: 1, sw: 1 },289 accent: { bg: C.accent, fg: C.onAccent, stroke: C.ink, strokeOp: 1, sw: 1.5 },290 hover: { bg: C.accentSoft, fg: C.accentDeep, stroke: C.ink, strokeOp: LINE_STRONG, sw: 1 },291 }[state];292 const f = box({ dir: "H", gap: 7, pad: [0, 15, 0, 15], h: o.h || 38, align: "CENTER", justify: "CENTER", fill: v.bg, stroke: v.stroke, strokeOp: v.strokeOp, sw: v.sw, radius: RAD.pill, name: o.name || `Chip / ${state}`, component: o.component });293 if (o.icon) add(f, ico(o.icon, v.fg, 12));294 add(f, T(label, { fam: "display", w: 700, size: o.size || 12.5, color: v.fg, upper: !!o.upper, ls: o.upper ? 0.04 : 0, lhp: 100, name: "label" }));295 return f;296}297/** Micro-étiquette mono (.st-pill / .chip tokens.css) — kind : observed | included | estimated | unknown | ink */298function stPill(label, kind = "observed") {299 const v = { observed: { fg: C.ink, bg: C.surface }, included: { fg: C.greenOk, bg: C.greenOkSoft }, estimated: { fg: C.accentDeep, bg: C.accentSoft }, unknown: { fg: C.ink3, bg: C.surface2, dash: true }, ink: { fg: C.accent, bg: C.ink } }[kind];300 const f = box({ dir: "H", pad: [2, 7, 2, 7], align: "CENTER", fill: v.bg, stroke: kind === "ink" ? C.ink : v.fg, radius: 3, dash: v.dash ? [3, 2] : undefined, name: `st-pill / ${kind}` });301 add(f, T(label, { fam: "mono", w: 700, size: 9.5, color: v.fg, ls: 0.06, upper: true, lhp: 140 }));302 return f;303}304function badge(text, kind = "type", o = {}) {305 if (kind === "fav") {306 const f = box({ dir: "H", w: 34, h: 34, align: "CENTER", justify: "CENTER", fill: C.white, fillOp: 0.94, radius: RAD.pill, shadow: SH.flat, name: o.name || "Badge / favori", component: o.component });307 add(f, ico("heart", o.on ? C.accent : C.ink, 16, !!o.on)); return f;308 }309 const v = { type: { bg: C.accent, fg: C.white }, photos: { bg: C.white, fg: C.ink }, reco: { bg: C.ink, fg: C.accent } }[kind];310 const f = box({ dir: "H", gap: 5, pad: [4, 12, 4, 12], align: "CENTER", fill: v.bg, fillOp: kind === "photos" ? 0.92 : 1, radius: RAD.pill, name: o.name || `Badge / ${kind}`, component: o.component });311 if (kind === "photos") add(f, ico("camera", v.fg, 12));312 add(f, T(text, { w: 700, size: 11, color: v.fg, ls: 0.04, lhp: 120, name: "label" }));313 return f;314}315/** FairValueBadge — verdict : sous | marche | sur ; pct ex. « +193 % » */316function fvBadge(verdict = "marche", pct, compact = false) {317 const v = { sous: { bg: C.greenSoft, fg: C.green, stroke: C.green, so: 0.35, ic: "down", label: "Sous le marché" }, marche: { bg: C.surface, fg: C.ink2, stroke: C.ink, so: 1, ic: "approx", label: "Dans le marché" }, sur: { bg: C.accentSoft, fg: C.accentDeep, stroke: C.accent, so: 0.4, ic: "up", label: "Au-dessus du marché" } }[verdict];318 const f = box({ dir: "H", gap: compact ? 4 : 6, pad: compact ? [2, 8, 2, 8] : [6, 12, 6, 12], align: "CENTER", fill: v.bg, stroke: v.stroke, strokeOp: v.so, radius: RAD.pill, name: `FairValue / ${verdict}` });319 add(f, ico(v.ic, v.fg, 10));320 const txt = compact ? (pct || v.label) : (pct ? `${pct} vs le secteur · ${v.label}` : v.label);321 add(f, T(txt, { w: 700, size: compact ? 11 : 12.5, color: v.fg, lhp: 120 }));322 return f;323}324function kaTint(s) { return s == null ? C.ink3 : s >= 70 ? C.green : s >= 55 ? "#5c8a2e" : s >= 40 ? C.amber : C.danger; }325function kaBadge(score) {326 const tint = kaTint(score);327 const f = box({ dir: "H", gap: 4, pad: [2, 8, 2, 3], align: "CENTER", fill: C.surface, stroke: tint, sw: 1.5, radius: RAD.pill, name: "KA Score badge" });328 const k = box({ dir: "H", pad: [1, 4, 1, 4], fill: C.ink, radius: 4, align: "CENTER", name: "logo" }); add(k, T("KA", { fam: "mono", w: 700, size: 8.5, color: C.accent, lhp: 120 }));329 add(f, k); add(f, T(String(score), { fam: "display", w: 700, size: 12, color: tint, lhp: 120 }));330 return f;331}332function sourceTag(text) {333 const f = box({ dir: "H", pad: [3, 10, 3, 10], align: "CENTER", fill: C.greenSoft, radius: RAD.pill, name: "Source" });334 add(f, T(text, { w: 700, size: 10, color: C.green, ls: 0.06, upper: true, lhp: 120, name: "label" })); return f;335}336/** Champ de recherche boîte (.f-search — feuille mobile) */337function input(placeholder, o = {}) {338 const f = box({ dir: "H", gap: 9, pad: [0, 16, 0, 16], w: o.w || 320, h: o.h || 52, align: "CENTER", fill: o.focus ? C.surface : C.surface2, stroke: C.ink, radius: RAD.ctl, shadow: o.focus ? SH.accent3 : null, name: o.name || "Champ / recherche", component: o.component });339 add(f, ico("search", o.focus ? C.accentDeep : C.ink2, 18));340 add(f, T(o.value || placeholder, { size: 14.5, color: o.value ? C.ink : C.ink3, lhp: 120, name: "placeholder" }), { grow: true });341 if (o.clear) { const x = box({ dir: "H", w: 22, h: 22, align: "CENTER", justify: "CENTER", fill: C.ink, fillOp: LINE, radius: RAD.pill, name: "effacer" }); add(x, ico("x", C.ink2, 9)); add(f, x); }342 return f;343}344/** Grande recherche soulignée (.q-big) */345function qBig(w, mobile, o = {}) {346 const f = box({ dir: "H", gap: 14, pad: [4, 2, 14, 2], w, align: "CENTER", stroke: o.focus ? C.accent : C.ink, sw: 2, strokeSides: [0, 0, 2, 0], name: "q-big" });347 add(f, ico("search", o.focus ? C.accentDeep : C.ink3, mobile ? 22 : 26));348 add(f, T(o.value || "Où voulez-vous habiter ?", { fam: "display", w: o.value ? 600 : 500, size: mobile ? 19 : 28, color: o.value ? C.ink : C.ink3, ls: -0.02, lhp: 120, name: "q" }), { grow: true });349 return f;350}351/** Critère de la ligne de recherche (.crit) */352function crit(label, value, o = {}) {353 const f = box({ dir: "V", gap: 1, pad: [12, 26, 12, 0], justify: "CENTER", stroke: o.last ? null : C.ink, strokeOp: LINE, sw: 1, strokeSides: [0, 1, 0, 0], name: `crit / ${label}` });354 add(f, T(label, { fam: "mono", w: 700, size: 9.5, color: C.ink3, ls: 0.14, upper: true, lhp: 140 }));355 const r = box({ dir: "H", gap: 10, align: "CENTER" });356 add(r, T(value, { fam: "display", w: 700, size: 15.5, color: o.hover ? C.accentDeep : C.ink, lhp: 120 })); add(r, ico("chev", C.ink3, 9));357 add(f, r); return f;358}359function fctl(label, value, o = {}) {360 const f = box({ dir: "V", gap: 2, pad: [7, 14, 6, 14], h: 52, w: o.w, justify: "CENTER", fill: o.focus ? C.white : C.surface, stroke: C.ink, radius: RAD.ctl, shadow: o.focus ? SH.accent3 : null, name: o.name || "Contrôle / f-ctl", component: o.component });361 add(f, T(label, { fam: "mono", w: 700, size: 9.5, color: C.ink3, ls: 0.1, upper: true, lhp: 120, name: "label" }));362 const row = box({ dir: "H", gap: 8, align: "CENTER", name: "valeur" }); add(row, T(value, { fam: "display", w: 700, size: 14.5, color: C.ink, lhp: 120, name: "value" })); add(row, ico("chev", C.ink, 9)); add(f, row);363 return f;364}365function field(label, value, o = {}) {366 const f = box({ dir: "V", gap: 5, w: o.w || 260, name: o.name || "Champ / natif", component: o.component });367 add(f, klabel(label, { size: 10.5 }));368 const b = box({ dir: "H", pad: [11, 14, 11, 14], h: 44, align: "CENTER", justify: "SPACE_BETWEEN", fill: o.transparent ? null : C.surface2, stroke: C.ink, strokeOp: o.transparent ? LINE_STRONG : 1, sw: o.transparent ? 1 : 1.5, radius: RAD.ctl, name: "boîte" });369 add(b, T(value, { size: 15, color: o.placeholder ? C.ink3 : C.ink, lhp: 120 }));370 if (o.select) add(b, ico("chev", C.ink, 10));371 add(f, b, { fillW: true }); return f;372}373function segments(items, onIdx = 0) {374 const f = box({ dir: "H", gap: -1, name: "Segments" });375 items.forEach((it, i) => {376 const on = i === onIdx; const first = i === 0, last = i === items.length - 1;377 const s = box({ dir: "H", pad: [0, 14, 0, 14], h: 38, align: "CENTER", fill: on ? C.ink : null, stroke: C.ink, strokeOp: on ? 1 : LINE_STRONG, sw: 1, radii: [first ? RAD.ctl : 0, last ? RAD.ctl : 0, last ? RAD.ctl : 0, first ? RAD.ctl : 0], name: `seg / ${it}` });378 add(s, T(it, { fam: "display", w: 600, size: 13, color: on ? C.paper : C.ink2, lhp: 100 })); add(f, s);379 });380 return f;381}382function pill(label, clear) {383 const f = box({ dir: "H", gap: 7, pad: [0, 12, 0, 12], h: 30, align: "CENTER", stroke: clear ? C.danger : C.ink, strokeOp: clear ? 1 : LINE_STRONG, sw: 1, radius: RAD.pill, name: `Pill / ${clear ? "effacer" : "actif"}` });384 add(f, T(label, { w: 600, size: 12, color: clear ? C.danger : C.ink, lhp: 100 })); if (!clear) add(f, ico("x", C.ink, 8)); return f;385}386function gkBadge(dark) {387 const f = box({ dir: "H", gap: 7, pad: [3, 10, 4, 10], h: 30, align: "CENTER", fill: dark ? null : C.surface, stroke: dark ? C.paper : C.ink, strokeOp: dark ? 0.4 : 1, radius: RAD.pill, name: "Badge Groupe KA" });388 add(f, T("Un service", { fam: "mono", w: 700, size: 10, color: dark ? C.paper : C.ink2, op: dark ? 0.75 : 1, ls: 0.08, upper: true, lhp: 120 }));389 const b = box({ dir: "H", gap: 3, align: "CENTER", name: "Groupe KA" });390 add(b, T("Groupe", { fam: "display", w: 700, size: 12, color: dark ? C.paper : C.ink, ls: -0.02, lhp: 120 }));391 const ka = box({ dir: "H", pad: [0, 5, 1, 5], fill: C.ink, radius: 5, name: "ka" }); ka.rotation = 2; add(ka, T("KA", { fam: "display", w: 700, size: 12, color: C.accent, lhp: 120 })); add(b, ka); add(f, b);392 return f;393}394function kvCell(k, v, w) {395 const f = box({ dir: "V", gap: 2, pad: [10, 14, 10, 14], w: w || 200, fill: C.surface2, radius: RAD.ctl, name: `KV / ${k}` });396 add(f, klabel(k, { size: 9.5 })); add(f, T(v, { fam: "display", w: 700, size: 14.5, color: C.ink, lhp: 120 })); return f;397}398/** Titre de bloc (.f-bloc h2 : tiret accent + texte + filet) */399function blocTitle(text, w) {400 const f = box({ dir: "H", gap: 12, align: "CENTER", w, name: "Titre de bloc" });401 add(f, rect(22, 3, C.accent, { radius: 2 }));402 add(f, T(text, { fam: "display", w: 700, size: 21, color: C.ink, ls: -0.02, lhp: 120 }));403 if (w) add(f, hr(20), { grow: true });404 return f;405}406function chipKey(label, icon) {407 const f = box({ dir: "H", gap: 7, pad: [0, 13, 0, 13], h: 34, align: "CENTER", stroke: C.ink, strokeOp: LINE_STRONG, sw: 1, radius: RAD.pill, name: `Chip-clé / ${label}` });408 add(f, ico(icon || "spark", C.accentDeep, 13)); add(f, T(label, { w: 600, size: 12, color: C.ink, lhp: 100 })); return f;409}410function amenityRow(label, confirmed, w) {411 const f = box({ dir: "H", gap: 11, pad: [7, 0, 7, 0], w: w || 300, h: 46, align: "CENTER", stroke: C.ink, strokeOp: LINE, sw: 1, strokeSides: [0, 0, 1, 0], name: `Inclusion / ${label}` });412 const i = box({ dir: "H", w: 32, h: 32, align: "CENTER", justify: "CENTER", fill: C.accentSoft, radius: 8, name: "icône" }); add(i, ico("spark", C.accentDeep, 14));413 add(f, i); add(f, T(label, { size: 13.5, color: C.ink, lhp: 120 }), { grow: true }); if (confirmed) add(f, ico("check", C.green, 12)); return f;414}415function kaCircle(score, nom) {416 const tint = kaTint(score); const c = 2 * Math.PI * 26; const part = score == null ? 0 : Math.min(1, score / 100);417 const s = svg(`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" fill="none"><circle cx="32" cy="32" r="26" stroke="#141814" stroke-opacity="0.12" stroke-width="6"/><circle cx="32" cy="32" r="26" stroke="${tint}" stroke-width="6" stroke-linecap="round" stroke-dasharray="${(c * part).toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 32 32)"/></svg>`, "jauge");418 const f = box({ dir: "V", gap: 6, align: "CENTER", name: `KA / ${nom}` });419 const wrap = box({ w: 64, h: 64, name: "cercle" }); add(wrap, s);420 const val = T(score == null ? "—" : String(score), { fam: "display", w: 700, size: 17, color: C.ink, lhp: 100 }); add(wrap, val); val.x = 32 - val.width / 2; val.y = 32 - val.height / 2;421 add(f, wrap); add(f, klabel(nom, { size: 10.5, color: C.ink2 })); return f;422}423/** Bouton flottant de l'agent conversationnel KA (ka-agent.js) */424function kaAgentFab(size = 64) {425 const f = box({ dir: "H", w: size, h: size, align: "CENTER", justify: "CENTER", fill: C.accent, stroke: C.ink, radius: RAD.pill, shadow: [SH.hard4, SH.fabBlur], name: "KA Agent · bouton flottant" });426 add(f, T("Ka", { fam: "display", w: 700, size: Math.round(size * 0.36), color: C.white, ls: -0.02, lhp: 100 })); return f;427}428/** Sparkline orange (KPI stats) */429function sparkline(w, h, pts, o = {}) {430 const n = pts.length; const step = w / (n - 1);431 const coords = pts.map((p, i) => [i * step, h - p * (h - 2) - 1]);432 const d = coords.map((c, i) => `${i ? "L" : "M"}${c[0].toFixed(1)} ${c[1].toFixed(1)}`).join(" ");433 const area = `${d} L${w} ${h} L0 ${h} Z`;434 return svg(`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" fill="none"><path d="${area}" fill="#ff6a00" fill-opacity="${o.fill == null ? 0.18 : o.fill}"/><path d="${d}" stroke="#ff6a00" stroke-width="${o.sw || 2}" stroke-linejoin="round" stroke-linecap="round"/></svg>`, "sparkline");435}436/** Jauge semi-circulaire (page Stats) */437function gauge(pct, label, sub, w = 220) {438 const r = 92, sw = 14, cx = 110, cy = 104; const ang = Math.PI * Math.min(1, pct / 100);439 const ex = cx + r * Math.cos(Math.PI - ang), ey = cy - r * Math.sin(Math.PI - ang) * 1 + 0; // point d'arrivée440 const large = ang > Math.PI / 2 ? 1 : 0;441 const arc = `M${cx - r} ${cy} A${r} ${r} 0 0 1 ${cx + r} ${cy}`;442 const val = `M${cx - r} ${cy} A${r} ${r} 0 ${large} 1 ${ex.toFixed(1)} ${(cy - r * Math.sin(ang)).toFixed(1)}`;443 const s = svg(`<svg xmlns="http://www.w3.org/2000/svg" width="220" height="112" viewBox="0 0 220 112" fill="none"><path d="${arc}" stroke="#141814" stroke-opacity="0.1" stroke-width="${sw}" stroke-linecap="round"/><path d="${val}" stroke="#ff6a00" stroke-width="${sw}" stroke-linecap="round"/></svg>`, "arc");444 const f = box({ dir: "V", gap: 10, align: "CENTER", w, name: `Jauge / ${label}` });445 const wrap = box({ w: 220, h: 112, name: "jauge" }); add(wrap, s);446 const v = RT([{ text: String(pct).replace(".", ","), color: C.ink }, { text: " %", color: C.ink2, size: 16, w: 500 }], { fam: "display", w: 700, size: 30, ls: -0.03, lhp: 100 }); add(wrap, v); v.x = 110 - v.width / 2; v.y = 70;447 const sb = T(sub, { fam: "mono", w: 500, size: 11, color: C.ink3, ls: 0.04, lhp: 120 }); add(wrap, sb); sb.x = 110 - sb.width / 2; sb.y = 100;448 add(f, wrap); add(f, klabel(label, { size: 10.5, color: C.ink2, align: "CENTER", width: w, ls: 0.12 }));449 return f;450}451452// ---- Carte d'annonce ----------------------------------------------------------453function listingCard(d, o = {}) {454 const w = o.w || 353;455 const card = box({ dir: "V", w, fill: C.surface, stroke: C.ink, radius: RAD.card, shadow: o.hover ? SH.off : SH.flat, clip: true, name: o.name || "Carte d'annonce", component: o.component });456 const imgH = Math.round((w * 10.5) / 16);457 const img = box({ w, h: imgH, fill: C.surface2, clip: true, name: "card-img" });458 add(img, photo(w, imgH, { photo: d.photo, name: "photo" }));459 add(img, badge(d.type || "4½", "type", { name: "type" }), { x: 12, y: 12 });460 if (d.nPhotos) { const b = badge(String(d.nPhotos), "photos", { name: "nphotos" }); add(img, b); b.x = w - 12 - b.width; b.y = 12; }461 if (d.reco) { const b = badge("Recommandé pour vous", "reco", { name: "reco" }); add(img, b); b.x = 12; b.y = imgH - 12 - b.height; }462 const fav = badge("", "fav", { on: d.fav }); add(img, fav); fav.x = w - 10 - 34; fav.y = imgH - 10 - 34;463 add(card, img);464 const body = box({ dir: "V", gap: 6, pad: [16, 18, 16, 18], w, name: "card-body" });465 const pr = box({ dir: "H", gap: 8, align: "CENTER", name: "prix-ligne" });466 add(pr, RT([{ text: d.price, color: C.ink }, { text: " / mois", color: C.ink3, w: 500, size: 11.5 }], { fam: "display", w: 700, size: 21, ls: -0.02, lhp: 110, name: "prix" }));467 if (d.fv) add(pr, fvBadge(d.fv, d.pct, true));468 add(body, pr);469 add(body, T(d.title, { w: 600, size: 14.5, color: C.ink, lhp: 130, width: w - 36, name: "titre" }));470 const meta = box({ dir: "H", gap: 7, align: "CENTER", name: "meta" });471 if (d.sector) { add(meta, T(d.sector, { size: 12.5, color: C.ink3, lhp: 120, name: "secteur" })); add(meta, ellipse(4, C.accent)); }472 add(meta, T(d.city, { size: 12.5, color: C.ink3, lhp: 120, name: "ville" }));473 if (d.ks) add(meta, kaBadge(d.ks));474 add(body, meta);475 const foot = box({ dir: "H", pad: [11, 0, 0, 0], w: w - 36, align: "CENTER", justify: "SPACE_BETWEEN", stroke: C.ink, strokeOp: LINE, sw: 1, strokeSides: [1, 0, 0, 0], name: "card-foot" });476 add(foot, sourceTag(d.source)); add(foot, T(d.avail || "", { w: 500, size: 11.5, color: C.ink2, lhp: 120, align: "RIGHT", name: "dispo" }));477 add(body, foot); add(card, body);478 return card;479}480const SAMPLE_CARDS = [481 { type: "4½", price: "1 450 $", title: "Avenue du Mont-Royal Est — 4½ rénové, balcon", sector: "Plateau-Mont-Royal", city: "Montréal", source: "Cogir", avail: "Dès le 1er octobre", nPhotos: 12, ks: 84, fv: "marche", pct: "−3 %" },482 { type: "3½", price: "1 195 $", title: "Rue Saint-Denis — 3½ lumineux, chauffé", sector: "Rosemont", city: "Montréal", source: "Rentals.ca", avail: "Libre immédiatement", nPhotos: 8, ks: 78, fv: "sous", pct: "−12 %" },483 { type: "5½", price: "2 580 $", title: "719 5e Avenue — 5½ rénové, Verdun", sector: "Île-des-Sœurs", city: "Montréal", source: "Royal LePage", avail: "1er novembre", nPhotos: 33, ks: 71, fv: "sur", pct: "+18 %" },484 { type: "4½", price: "1 320 $", title: "Rue Cartier — 4½, quartier Montcalm", sector: "Montcalm", city: "Québec", source: "Immeubles Roussin", avail: "Dès le 1er octobre", nPhotos: 6, ks: 66, reco: true },485 { type: "Studio", price: "995 $", title: "Boulevard René-Lévesque — studio meublé", sector: "Centre-ville", city: "Montréal", source: "Realstar", avail: "Libre immédiatement", nPhotos: 9, ks: 88, fv: "sur", pct: "+9 %" },486 { type: "3½", price: "1 085 $", title: "Rue Saint-Joseph — 3½ près du traversier", sector: "Vieux-Lévis", city: "Lévis", source: "Rés. Soleil", avail: "1er décembre", nPhotos: 7, ks: 59 },487];488489// ---- Header / ticker / barres ---------------------------------------------------490function header(o = {}) {491 const mobile = !!o.mobile; const w = o.w || 1440; const h = mobile ? 56 : 64;492 const f = box({ dir: "H", w, h, pad: [0, mobile ? 16 : 24, 0, mobile ? 16 : 24], gap: mobile ? 12 : 20, align: "CENTER", fill: C.paper, fillOp: 0.92, stroke: C.ink, sw: 1.5, strokeSides: [0, 0, 1.5, 0], name: mobile ? "Header / mobile" : "Header / desktop", component: o.component });493 const brand = box({ dir: "H", gap: 12, align: "CENTER", name: "brand" });494 add(brand, logo(mobile ? 28 : 30, { textSize: mobile ? 23 : 25 }));495 if (!mobile) add(brand, T("La porte d'entrée vers votre prochain chez-vous.", { w: 500, size: 11, color: C.ink3, ls: 0.02, lhp: 125, width: 90, name: "brand-tag" }));496 add(f, brand);497 if (!mobile) add(f, gkBadge());498 add(f, spacer(1, 1), { grow: true });499 if (!mobile) {500 const nav = box({ dir: "H", gap: 4, align: "CENTER", name: "nav" });501 ["Logements", "Court terme", "Stats", "Sources", "Déménageurs"].forEach((n, i) => {502 const on = i === (o.active == null ? 0 : o.active);503 const a = box({ dir: "H", pad: [0, 16, 0, 16], h: 40, align: "CENTER", fill: on ? C.ink : null, radius: RAD.ctl, name: `nav / ${n}` });504 add(a, T(n, { fam: "display", w: 600, size: 14, color: on ? C.accent : C.ink, lhp: 100 })); add(nav, a);505 });506 add(f, nav);507 }508 add(f, button("Connexion", "login", { h: 38, size: 13.5, px: 16 }));509 if (mobile) { const b = box({ dir: "V", gap: 5, w: 44, h: 44, align: "CENTER", justify: "CENTER", fill: C.surface, stroke: C.ink, radius: RAD.ctl, shadow: SH.flat, name: "menu-btn" }); for (let i = 0; i < 3; i++) add(b, rect(18, 2, C.ink, { radius: 2 })); add(f, b); }510 return f;511}512function ticker(w, mobile) {513 const f = box({ dir: "H", w, h: mobile ? 30 : 32, align: "CENTER", fill: C.ink, stroke: C.ink, sw: 1.5, strokeSides: [0, 0, 1.5, 0], clip: true, name: "Ticker" });514 ["47 425 logements actifs", "Québec 3526", "Lévis 1636", "Grand Montréal 24 127", "Loyer moyen 1824 $", "Kangalou · 7155", "LogisQuébec · 4962", "Kijiji Québec (location) · 3620", "Cogir · 255"].forEach((t) => {515 const s = box({ dir: "H", gap: 26, pad: [0, 13, 0, 13], align: "CENTER", name: "item" });516 add(s, T(t, { fam: "mono", w: 500, size: mobile ? 10.5 : 11.5, color: C.white, op: 0.85, ls: 0.08, upper: true, lhp: 100 }));517 add(s, T("◆", { size: 7, color: C.accent, op: 0.8, lhp: 100 })); add(f, s);518 });519 return f;520}521/** Tabbar mobile flottante (rendu actuel : carte blanche à bord encre, actif = encre + tiret accent) */522function tabbar(w, active = 0) {523 const f = box({ dir: "H", w: w - 24, pad: [8, 6, 8, 6], gap: 0, fill: C.white, fillOp: 0.96, stroke: C.ink, radius: 18, shadow: SH.offSoft, name: "Tabbar / mobile" });524 [["search", "Rechercher"], ["map", "Carte"], ["heart", "Favoris"], ["user", "Profil"]].forEach(([i, l], k) => {525 const on = k === active; const col = on ? C.ink : C.ink3;526 const t = box({ dir: "V", gap: 3, pad: [4, 2, 2, 2], h: 56, align: "CENTER", justify: "CENTER", radius: 12, name: `tab / ${l}` });527 add(t, ico(i, col, 22)); add(t, T(l, { fam: "display", w: 600, size: 10.5, color: col, lhp: 120 }));528 if (on) add(t, rect(16, 2.5, C.accent, { radius: 2 }));529 add(f, t, { grow: true });530 });531 return f;532}533function footer(w, o = {}) {534 const mobile = w < 700;535 const f = box({ dir: "V", w, pad: [44, mobile ? 16 : 24, mobile ? 120 : 44, mobile ? 16 : 24], fill: C.ink, name: "Footer / Groupe KA", component: o.component });536 const iw = Math.min(w - (mobile ? 32 : 48), 1104);537 const inner = box({ dir: "V", w: iw, name: "container" });538 add(inner, RT([{ text: "Groupe ", color: C.paper }, { text: "KA", color: C.accent }], { fam: "display", w: 700, size: 30, ls: -0.04, lhp: 100 }));539 add(inner, spacer(1, 16));540 add(inner, T("Holding québécois d'agrégateurs de produits et services entièrement automatisés. Des centaines de connecteurs lisent les sites à la source, normalisent la donnée et se resynchronisent seuls. Lou·Ka — Tous les logements à louer — est un service Groupe KA.", { size: 13, color: C.paper, op: 0.75, width: Math.min(640, iw), lhp: 155 }));541 add(inner, spacer(1, 16));542 const notice = box({ dir: "H", pad: [0, 0, 0, 16], stroke: C.accent, sw: 2, strokeSides: [0, 0, 0, 2], w: Math.min(640, iw), name: "notice" });543 add(notice, T("Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction.", { w: 700, size: 13, color: C.paper, width: Math.min(640, iw) - 16, lhp: 155 }));544 add(inner, notice); add(inner, spacer(1, 26));545 const sites = box({ dir: "H", gap: 26, rowGap: 10, wrap: true, w: iw, name: "sites" });546 ["Groupe KA", "Trouve·Ka", "Immo·Ka", "Vrai-Prix", "Auto·Ka", "Fabri·Ka", "Food·Ka", "Resto·Ka", "Sorti·Ka", "Créa·Ka", "API·Ka", "Job·Ka", "Ka·Stats", "ValoPlex", "Ka2", "Ka4", "Ka6"].forEach((s) => add(sites, T(s, { fam: "mono", w: 700, size: 11, color: C.paper, op: 0.65, ls: 0.1, upper: true, lhp: 120 })));547 add(inner, sites); add(inner, spacer(1, 30)); add(inner, rect(iw, 1, C.paper, { op: 0.15 })); add(inner, spacer(1, 26));548 const contacts = box({ dir: mobile ? "V" : "H", gap: mobile ? 14 : 32, w: iw, name: "contacts" });549 [["contact@groupe-ka.com", "Projets, partenariats & données"], ["info@groupe-ka.com", "Médias & questions générales"], ["admin@groupe-ka.com", "Légal, vie privée & Loi 25"]].forEach(([e, r]) => {550 const c = box({ dir: "V", gap: 3, name: e }); add(c, T(e, { fam: "mono", w: 700, size: 12, color: C.paper, op: 0.85, lhp: 120 })); add(c, T(r, { size: 11, color: C.paper, op: 0.5, lhp: 120 })); add(contacts, c, mobile ? {} : { grow: true });551 });552 add(inner, contacts); add(inner, spacer(1, 30));553 add(inner, T("© 2026 Groupe KA — Simon-Pierre Boucher · Conditions d'utilisation · Politique de confidentialité · Protection des renseignements personnels (Loi 25) · Transparence des robots d'indexation", { fam: "mono", size: 11, color: C.paper, op: 0.45, width: iw, lhp: 160 }));554 add(f, inner); return f;555}556557// ============================================================================558// 6. BLOCS D'ÉCRAN559// ============================================================================560function innerW(w, mobile) { return Math.min(w - (mobile ? 32 : 48), 1104); }561function section(w, mobile, children, o = {}) {562 const c = box({ dir: "V", w, pad: [o.pt || 0, mobile ? 16 : 24, o.pb || 0, mobile ? 16 : 24], gap: o.gap || 0, name: o.name || "section" });563 children.forEach((ch) => add(c, ch)); return c;564}565/** Trait de pinceau orange sous « chez-vous. » (SVG .brush::after, viewBox 300×26 remis à l'échelle) */566function brush(W, H) {567 const sx = W / 300, sy = H / 26;568 const p = (x, y) => `${(x * sx).toFixed(1)} ${(y * sy).toFixed(1)}`;569 const d = `M${p(5, 17)} C ${p(60, 8)}, ${p(118, 21)}, ${p(168, 13)} S ${p(258, 10)}, ${p(295, 15)}`;570 const n = svg(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" fill="none"><path d="${d}" stroke="#ff6a00" stroke-width="${(11 * sy).toFixed(1)}" stroke-linecap="round" opacity="0.92"/></svg>`, "brush");571 n.rotation = -0.6; return n;572}573function hero(w, mobile) {574 const iw = innerW(w, mobile);575 const f = box({ dir: "V", w: iw, pad: [mobile ? 36 : 64, 0, mobile ? 14 : 26, 0], name: "Héro" });576 add(f, kicker("Location — tout le Québec, un seul endroit", { w: mobile ? iw : undefined }));577 add(f, spacer(1, 18));578 const h1 = mobile ? 42 : 88;579 add(f, T(mobile ? "Trouvez votre\nprochain" : "Trouvez votre prochain", { fam: "display", w: 700, size: h1, color: C.ink, ls: -0.045, lhp: 100 }));580 const l2wrap = box({ name: "chez-vous (brush)" });581 const l2 = T("chez-vous.", { fam: "display", w: 700, size: h1, color: C.ink, ls: -0.045, lhp: 100 });582 const br = brush(l2.width * 1.06, h1 * 0.34);583 add(l2wrap, br); add(l2wrap, l2); l2wrap.resize(l2.width, l2.height); br.x = -l2.width * 0.03; br.y = l2.height - h1 * 0.34 - h1 * 0.03;584 add(f, l2wrap); add(f, spacer(1, 18));585 add(f, T("Tous les logements à louer au Québec, agrégés en continu — photos complètes, juste valeur estimée, lien direct vers l'annonce originale.", { size: mobile ? 16 : 16.5, color: C.ink2, width: Math.min(580, iw), lhp: 155 }));586 add(f, spacer(1, 34));587 const live = box({ dir: "H", align: "CENTER", wrap: !mobile, rowGap: 10, w: iw, clip: mobile, name: "live-line" });588 const flag = box({ dir: "H", gap: 7, pad: [0, 14, 0, 0], align: "CENTER", stroke: C.ink, strokeOp: LINE_STRONG, sw: 1, strokeSides: [0, 1, 0, 0], name: "live-flag" });589 add(flag, ellipse(7, C.accent)); add(flag, T("live", { fam: "mono", w: 700, size: 10.5, color: C.accentDeep, ls: 0.18, upper: true, lhp: 120 })); add(live, flag);590 [["47 425", " logements indexés"], ["301", " sources"], ["", "synchro il y a 6 s"], ["1824 $", " loyer moyen"]].forEach(([b, t], i, arr) => {591 const it = box({ dir: "H", pad: [0, 14, 0, 14], stroke: i < arr.length - 1 ? C.ink : null, strokeOp: LINE, sw: 1, strokeSides: [0, 1, 0, 0], name: "live-item" });592 add(it, RT(b ? [{ text: b, color: C.ink, w: 700 }, { text: t, color: C.ink2 }] : [{ text: t, color: C.ink2 }], { fam: "mono", w: 500, size: 12, ls: 0.02, lhp: 120 })); add(live, it);593 });594 add(f, live); return f;595}596/** Zone de recherche actuelle (.search-zone) : q-big + ligne de critères (desktop) ou résumé (mobile) */597function searchZone(w, mobile, o = {}) {598 const iw = innerW(w, mobile);599 const f = box({ dir: "V", w: iw, name: "Zone de recherche (.search-zone)" });600 add(f, qBig(iw, mobile));601 if (mobile) {602 const cs = box({ dir: "H", gap: 12, pad: [14, 0, 14, 0], w: iw, align: "CENTER", stroke: C.ink, strokeOp: LINE, sw: 1, strokeSides: [0, 0, 1, 0], name: "crit-summary" });603 add(cs, ico("sliders", C.ink, 18)); add(cs, T("Ville, quartier, budget, critères…", { fam: "mono", w: 500, size: 13, color: C.ink2, lhp: 120 }), { grow: true }); add(cs, ico("chev", C.ink, 14)); add(f, cs);604 } else {605 const line = box({ dir: "H", gap: 26, w: iw, align: "CENTER", name: "crit-line" });606 add(line, crit("Ville", "Toutes")); add(line, crit("Quartier", "Tous")); add(line, crit("Loyer", "Min — Max"));607 const more = box({ dir: "H", gap: 9, pad: [12, 0, 12, 0], align: "CENTER", name: "crit-more" }); add(more, T("Tous les critères", { fam: "display", w: 700, size: 14.5, color: C.ink, lhp: 100 })); if (o.n) { const n = box({ dir: "H", pad: [0, 5, 0, 5], h: 19, align: "CENTER", justify: "CENTER", fill: C.accent, radius: RAD.pill }); add(n, T(String(o.n), { w: 700, size: 11, color: C.white, lhp: 100 })); add(more, n); } add(more, ico("chev", C.ink, 12)); add(line, more);608 add(f, line);609 }610 return f;611}612function quickChips(iw, mobile) {613 const f = box({ dir: "H", gap: 8, w: iw, clip: true, pad: [20, 0, 0, 0], name: "chips (filtres rapides)" });614 ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"].forEach((c, i) => add(f, chip(c, i === 3 && !mobile ? "on" : "default", { upper: true, size: 12.5 })));615 if (!mobile) { add(f, hr(1, LINE, 38)).resize(1, 38); add(f, chip("Sous le marché", "default", { icon: "down", upper: true })); add(f, chip("Dispo maintenant", "default", { icon: "bolt", upper: true })); add(f, chip("Animaux OK", "default", { icon: "paw", upper: true })); add(f, chip("Meublé", "default", { icon: "bed", upper: true })); }616 return f;617}618function pillsRow(iw) {619 const f = box({ dir: "H", gap: 8, wrap: true, rowGap: 8, w: iw, pad: [14, 0, 0, 0], name: "filtres actifs (.pills)" });620 ["Montréal", "4½", "≤ 2 500 $", "Animaux acceptés"].forEach((p) => add(f, pill(p))); add(f, pill("Tout effacer", true)); return f;621}622function resultsBar(iw, mobile, o = {}) {623 const f = box({ dir: "H", w: iw, gap: 16, pad: [12, 0, 11, 0], align: "CENTER", stroke: C.ink, strokeOp: LINE_STRONG, sw: 1, strokeSides: [0, 0, 1, 0], name: "Barre de résultats (.results-bar, sticky)" });624 add(f, T(`${o.count || "47 425"} logements`, { fam: "display", w: 700, size: mobile ? 17 : 20, color: C.ink, ls: -0.02, lhp: 120 }));625 add(f, spacer(1, 1), { grow: true });626 const tabs = box({ dir: "H", gap: 18, align: "CENTER", name: "rb-tabs" });627 [["list", "Liste", true], ["map", "Carte", false]].forEach(([i, l, on]) => { const t = box({ dir: "H", gap: 6, pad: [8, 0, 8, 0], align: "CENTER", name: `rb-tab / ${l}` }); add(t, ico(i, on ? C.ink : C.ink3, 14)); add(t, T(l, { fam: "display", w: 700, size: 13.5, color: on ? C.ink : C.ink3, lhp: 100 })); add(tabs, t); });628 add(f, tabs);629 if (mobile) add(f, ico("sliders", C.ink, 18));630 else { const s = box({ dir: "H", gap: 8, align: "CENTER", name: "tri" }); add(s, klabel("Trier")); add(s, T("Plus récents", { fam: "display", w: 700, size: 13.5, color: C.ink, lhp: 100 })); add(s, ico("chev", C.ink, 10)); add(f, s); }631 return f;632}633function cardGrid(iw, mobile, cards) {634 const cols = mobile ? 1 : 3; const gap = mobile ? 16 : 22; const cw = Math.floor((iw - gap * (cols - 1)) / cols);635 const g = box({ dir: "H", gap, rowGap: gap, wrap: true, w: iw, name: "Grille de résultats (.grid)" });636 cards.forEach((d) => add(g, listingCard(d, { w: cw }))); return g;637}638function pager(iw) {639 const f = box({ dir: "H", gap: 6, w: iw, justify: "CENTER", align: "CENTER", name: "Pagination" });640 ["‹", "1", "2", "3", "…", "2 372", "›"].forEach((p) => { const on = p === "1"; const b = box({ dir: "H", pad: [0, 12, 0, 12], h: 40, align: "CENTER", justify: "CENTER", fill: on ? C.ink : null, stroke: C.ink, strokeOp: on ? 1 : LINE_STRONG, sw: 1, radius: RAD.ctl, name: `page ${p}` }); if (b.width < 40) b.resize(40, 40); add(b, T(p, { fam: "display", w: 700, size: 13, color: on ? C.accent : C.ink, lhp: 100 })); add(f, b); });641 return f;642}643644// ---- Fiche logement (annonce réelle capturée : 719 5e Avenue, Montréal) ----------645const FICHE = {646 price: "2 580 $", title: "719 5E Avenue", loc: "Verdun · Île-des-Sœurs · Montréal", pct: "+193 %", verdict: "sur",647 chips: [["5½", "door"], ["3 chambres", "bed"], ["Rénové", "spark"], ["Stationnement", "spark"], ["Dès maintenant", "bolt"]],648 source: "royal_lepage", sourceName: "Royal LePage", nPhotos: 33,649 incl: [["Électricité", 1], ["Internet", 1], ["Électroménagers", 1], ["Rénové", 0], ["Stationnement", 0], ["Cour arrière", 0]],650 kv: [["Gestionnaire", "Royal LePage"], ["Prix affiché", "2 580 $/mois"], ["En ligne depuis", "aujourd'hui"], ["Synchronisé", "il y a 12 min"], ["Superficie", "1 250 pi²"], ["Bail", "12 mois"]],651};652function gallery(w, mobile) {653 const f = box({ dir: "V", gap: 10, w, name: "Galerie (.carousel + .thumbs)" });654 const mh = Math.round(w * 10 / 14.4);655 const main = box({ w, h: mh, stroke: C.ink, radius: RAD.card, shadow: SH.offSoft, clip: true, name: "carousel" });656 add(main, photo(w, mh, { photo: PHOTOS[0] }));657 const cnt = box({ dir: "H", pad: [7, 14, 7, 14], fill: C.ink, fillOp: 0.78, radius: RAD.pill, name: "compteur" }); add(cnt, T(`1/${FICHE.nPhotos}`, { fam: "display", w: 700, size: 14, color: C.white, lhp: 100 })); add(main, cnt); cnt.x = w - 14 - cnt.width; cnt.y = mh - 14 - cnt.height;658 if (!mobile) { const nx = box({ dir: "H", w: 56, h: 56, align: "CENTER", justify: "CENTER", fill: C.white, fillOp: 0.92, radius: RAD.pill, name: "carousel-nav ›" }); add(nx, ico("chevR", C.ink, 18)); add(main, nx); nx.x = w - 14 - 56; nx.y = mh / 2 - 28; }659 add(f, main);660 const cols = mobile ? 4 : 7; const gap = 8; const tw = Math.floor((w - gap * (cols - 1)) / cols);661 const th = box({ dir: "H", gap, rowGap: gap, wrap: !mobile, w, clip: true, name: "thumbs" });662 const n = mobile ? 5 : 14;663 for (let i = 0; i < n; i++) { const t = box({ w: tw, h: Math.round(tw * 3 / 4), stroke: i === 0 ? C.accent : C.ink, strokeOp: i === 0 ? 1 : LINE, sw: 2, radius: 12, clip: true, name: `thumb ${i + 1}` }); add(t, photo(tw, Math.round(tw * 3 / 4), { photo: PHOTOS.length ? PHOTOS[(i + 1) % PHOTOS.length] : null })); add(th, t); }664 add(f, th); return f;665}666function ficheHero(w, mobile, o = {}) {667 const pad = o.flat ? 0 : (mobile ? 20 : 26);668 const f = box({ dir: "V", w, pad, fill: o.flat ? null : C.surface, stroke: o.flat ? null : C.ink, radius: RAD.card, shadow: o.flat ? null : SH.offSoft, name: "Panneau héro (.f-hero : prix, verdict, titre, adresse, chips, ancres, CTA)" });669 const cw = w - pad * 2;670 add(f, RT([{ text: FICHE.price, color: C.ink }, { text: " / mois", color: C.ink2, w: 500, size: mobile ? 30 : 34 }], { fam: "display", w: 500, size: mobile ? 48 : 54, ls: -0.04, lhp: 100 }));671 add(f, spacer(1, 12)); add(f, fvBadge(FICHE.verdict, FICHE.pct));672 add(f, spacer(1, 12)); add(f, T(FICHE.title, { fam: "display", w: 700, size: mobile ? 22 : 26, color: C.ink, ls: -0.03, width: cw, lhp: 120 }));673 add(f, spacer(1, 6)); add(f, T(FICHE.loc, { size: 14, color: C.ink2, width: cw, lhp: 150 }));674 add(f, spacer(1, 14));675 const ch = box({ dir: "H", gap: 8, w: cw, clip: true, name: "chips-scroll" }); FICHE.chips.forEach(([c, i]) => add(ch, chipKey(c, i))); add(f, ch);676 add(f, spacer(1, 8));677 const an = box({ dir: "H", gap: 18, w: cw, stroke: C.ink, strokeOp: LINE, sw: 1, strokeSides: [0, 0, 1, 0], name: "ancres" });678 ["Description", "Prix", "Inclusions", "Carte", "Quartier", "À proximité"].forEach((a, i) => { const t = box({ dir: "V", pad: [10, 2, 10, 2], stroke: i === 0 ? C.accent : null, sw: 2.5, strokeSides: [0, 0, 2.5, 0], name: a }); add(t, T(a, { fam: "display", w: 700, size: 13, color: i === 0 ? C.accentDeep : C.ink3, lhp: 100 })); add(an, t); });679 add(f, an); add(f, spacer(1, 16));680 add(f, button(`Voir l'annonce chez ${FICHE.sourceName}`, "accent", { h: 50, size: 15, icon: "arrow", name: "CTA / voir l'annonce (.cta)" }), { fillW: true });681 add(f, spacer(1, 10));682 add(f, button("Télécharger la fiche (PDF)", "ghost", { icon: "doc" }), { fillW: true });683 add(f, spacer(1, 14));684 add(f, T("Lou-Ka n'est pas partie à la transaction — l'annonce et le contact appartiennent au gestionnaire.", { size: 11.5, color: C.ink3, width: cw, align: "CENTER", lhp: 150 }));685 return f;686}687function ficheBloc(title, w, build) { const f = box({ dir: "V", gap: 14, w, name: `Bloc / ${title}` }); add(f, blocTitle(title, w)); build(f); return f; }688function whiteCard(w, name, pad = 22) { return box({ dir: "V", gap: 14, w, pad, fill: C.surface, stroke: C.ink, radius: RAD.card, shadow: SH.offSoft, name }); }689function crRow(label, pillKind, pillLabel, value, w, o = {}) {690 const r = box({ dir: "H", gap: 10, w, pad: [7, 8, 7, o.total ? 8 : 0], align: "CENTER", fill: o.total ? C.accentSoft : (o.even ? C.surface2 : null), stroke: C.ink, strokeOp: LINE, sw: 1, strokeSides: [0, 0, 1, 0], name: `ligne / ${label}` });691 add(r, T(label, { w: o.total ? 700 : 400, size: 13.5, color: o.total ? C.ink : C.ink2, lhp: 120 }));692 if (pillKind) add(r, stPill(pillLabel, pillKind));693 add(r, spacer(1, 1), { grow: true });694 add(r, T(value, { fam: "mono", w: 700, size: 13.5, color: C.ink, lhp: 120 }));695 return r;696}697function coutReel(w) {698 const c = whiteCard(w, "Coût réel mensuel"); const cw = w - 44;699 add(c, blocTitle("Coût réel mensuel", cw));700 [["Loyer affiché", "observed", "Observé", "2 580 $"], ["Électricité", "included", "Inclus", "0 $"], ["Chauffage", "unknown", "Inconnu", "—"], ["Eau chaude", "unknown", "Inconnu", "—"], ["Internet", "included", "Inclus", "0 $"], ["Stationnement", "unknown", "Inconnu", "—"]].forEach((r, i) => add(c, crRow(r[0], r[1], r[2], r[3], cw, { even: i % 2 === 1 })));701 add(c, crRow("Total estimé", null, null, "≈ 2 580 $ /mois", cw, { total: true }));702 const an = box({ dir: "H", w: cw, pad: [4, 8, 0, 0], justify: "SPACE_BETWEEN" }); add(an, T("soit sur 12 mois", { w: 700, size: 12.5, color: C.ink2, lhp: 120 })); add(an, T("≈ 30 960 $", { fam: "mono", w: 500, size: 12.5, color: C.ink3, lhp: 120 })); add(c, an);703 const note = box({ dir: "H", pad: [12, 16, 12, 16], w: cw, fill: C.accentSoft, stroke: C.accent, sw: 2.5, strokeSides: [0, 0, 0, 2.5], radius: 6, name: "cr-inconnus" });704 add(note, T("Postes non chiffrables avec les données publiées : chauffage, eau chaude, stationnement — le total réel peut être plus élevé.", { size: 13.5, color: C.ink2, width: cw - 32, lhp: 150 })); add(c, note);705 add(c, T("loyer affiché + frais non inclus connus (observés) ou estimés avec source ; les postes inconnus sont listés tels quels, jamais chiffrés arbitrairement", { size: 14, color: C.ink2, width: cw, lhp: 150 }));706 return c;707}708function historique(w) {709 const c = whiteCard(w, "Historique Lou-Ka"); const cw = w - 44;710 add(c, blocTitle("Historique Lou-Ka", cw));711 const g = box({ dir: "H", gap: 20, rowGap: 14, wrap: true, w: cw, name: "faits" }); const gw = (cw - 20) / 2;712 [["Suivie depuis", "4 sept. 2026"], ["En ligne", "0 jour"], ["Modifications observées", "0"], ["Prix initial", "2 580 $"]].forEach(([k, v]) => { const r = box({ dir: "H", w: gw, justify: "SPACE_BETWEEN", align: "CENTER" }); add(r, klabel(k, { width: gw * 0.5 })); add(r, T(v, { fam: "display", w: 700, size: 16, color: C.ink, lhp: 120 })); add(g, r); });713 add(c, g);714 add(c, T("Lou-Ka relit la source à chaque synchronisation : les changements de prix, de disponibilité et de description sont consignés ici.", { size: 12.5, color: C.ink3, width: cw, lhp: 150 }));715 return c;716}717function priceAnalysis(w) {718 const c = whiteCard(w, "Analyse de prix Lou-Ka"); const cw = w - 44;719 add(c, blocTitle("Analyse de prix Lou-Ka", cw));720 add(c, T("Comparé à 84 logements 5½ de Verdun / Île-des-Sœurs indexés ces 90 derniers jours.", { size: 13, color: C.ink2, width: cw, lhp: 150 }));721 const kp = box({ dir: "H", gap: 10, w: cw }); const kw = (cw - 20) / 3; add(kp, kvCell("Médiane secteur", "1 895 $", kw)); add(kp, kvCell("Cette annonce", "2 580 $", kw)); add(kp, kvCell("Écart", "+36 %", kw)); add(c, kp);722 const hist = box({ dir: "H", gap: 3, h: 120, w: cw, align: "MAX", stroke: C.ink, strokeOp: LINE_STRONG, sw: 1, strokeSides: [0, 0, 1, 0], name: "fv-histo" });723 [0.15, 0.3, 0.55, 0.85, 1, 0.8, 0.55, 0.35, 0.22, 0.14, 0.4, 0.08].forEach((v, i) => add(hist, rect(10, Math.max(3, 116 * v), i === 10 ? C.accent : C.green, { radius: 3 }), { grow: true }));724 add(c, hist);725 const ax = box({ dir: "H", w: cw, justify: "SPACE_BETWEEN" }); ["1 200 $", "1 600 $", "2 000 $", "2 400 $", "2 800 $+"].forEach((a) => add(ax, T(a, { fam: "mono", size: 9.5, color: C.ink3, lhp: 120 }))); add(c, ax);726 const lg = box({ dir: "H", gap: 14 }); [[C.green, "Secteur"], [C.accent, "Cette annonce"]].forEach(([col, l]) => { const r = box({ dir: "H", gap: 6, align: "CENTER" }); add(r, rect(10, 10, col, { radius: 2 })); add(r, klabel(l)); add(lg, r); }); add(c, lg);727 return c;728}729function mapBlock(w, h, o = {}) {730 const f = box({ w, h, fill: "#e9efe3", stroke: C.ink, radius: RAD.card, shadow: SH.offSoft, clip: true, name: o.name || "Carte (Mapbox 3D · ka-maps)" });731 const water = rect(w, 54, "#1c5c41", { op: 0.55, name: "eau" }); add(f, water); water.y = h - 74;732 [0.12, 0.38, 0.66, 0.88].forEach((x) => { const r = rect(6, h + 40, "#d9f26b", { name: "rue" }); add(f, r); r.x = w * x; r.y = -20; r.rotation = 8; });733 [0.25, 0.55].forEach((y) => { const r = rect(w + 40, 6, "#ffffff", { name: "rue" }); add(f, r); r.x = -20; r.y = h * y; r.rotation = -6; });734 for (let i = 0; i < 10; i++) { const b = rect(22 + (i % 3) * 12, 16 + (i % 4) * 8, "#f7f5ef", { stroke: C.ink, strokeOp: 0.35, sw: 1, radius: 2, name: "bâtiment" }); add(f, b); b.x = 20 + (i * 97) % (w - 60); b.y = 24 + (i * 61) % (h - 130); }735 const pinW = box({ dir: "V", align: "CENTER", name: "marqueur prix" });736 const p = box({ dir: "H", pad: [6, 12, 6, 12], fill: C.ink, radius: RAD.pill, shadow: SH.offMid }); add(p, T(o.price || FICHE.price, { fam: "display", w: 700, size: 13, color: C.accent, lhp: 100 })); add(pinW, p);737 add(pinW, svg(`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="8" viewBox="0 0 14 8"><path d="M0 0h14L7 8z" fill="#141814"/></svg>`, "pointe"));738 add(f, pinW); pinW.x = w / 2 - pinW.width / 2; pinW.y = h / 2 - pinW.height;739 const ctl = box({ dir: "V", gap: 0, fill: C.surface, stroke: C.ink, radius: RAD.ctl, clip: true, name: "zoom" }); ["+", "−"].forEach((s) => { const b = box({ dir: "H", w: 32, h: 32, align: "CENTER", justify: "CENTER" }); add(b, T(s, { fam: "display", w: 700, size: 16, color: C.ink, lhp: 100 })); add(ctl, b); }); add(f, ctl); ctl.x = w - 12 - 32; ctl.y = 12;740 return f;741}742function kaScoresBlock(w) {743 const c = whiteCard(w, "KA Scores"); const cw = w - 44;744 const head = box({ dir: "H", w: cw, gap: 12, align: "CENTER" }); add(head, blocTitle("KA Scores"), { grow: true }); add(head, kaBadge(71)); add(c, head);745 add(c, T("Indices Lou-Ka de 0 à 100 calculés sur l'environnement du logement (OSM + Plan métropolitain). Méthodologie sur /ka-scores.", { size: 12.5, color: C.ink2, width: cw, lhp: 150 }));746 const row = box({ dir: "H", w: cw, justify: "SPACE_BETWEEN", name: "jauges" }); [[78, "Marche"], [69, "Transport"], [74, "Vélo"], [58, "Calme"], [76, "Services"]].forEach(([s, n]) => add(row, kaCircle(s, n))); add(c, row);747 return c;748}749function quartierBlock(w) {750 const c = whiteCard(w, "Le quartier"); const cw = w - 44;751 add(c, blocTitle("Le quartier — Verdun", cw));752 const g = box({ dir: "H", gap: 10, rowGap: 10, wrap: true, w: cw, name: "stats quartier" }); const kw = (cw - 10) / 2;753 [["Loyer médian 5½", "1 895 $"], ["Registre des loyers", "1 640 $ (2025)"], ["Métro le plus proche", "De l'Église · 9 min"], ["Risque d'inondation", "Modéré (BDZI)"], ["Qualité de l'air", "Bonne (IQA 19)"], ["Crimes / 1 000 hab.", "31 · sous la médiane"]].forEach(([k, v]) => add(g, kvCell(k, v, kw))); add(c, g);754 const pois = box({ dir: "V", gap: 2, w: cw, name: "À proximité" }); add(pois, klabel("À proximité"));755 [["Épicerie", "IGA Verdun", "260 m"], ["Pharmacie", "Pharmaprix", "310 m"], ["Métro", "De l'Église (ligne verte)", "700 m"], ["Parc", "Parc Arthur-Therrien", "480 m"], ["Essence", "Esso (1,56 $/L)", "1,3 km"]].forEach(([t, n, d]) => {756 const r = box({ dir: "H", gap: 9, w: cw, align: "CENTER", pad: [7, 2, 7, 2], stroke: C.ink, strokeOp: LINE, sw: 1, strokeSides: [0, 0, 1, 0] });757 add(r, ico("pin", C.accentDeep, 14)); add(r, klabel(t, { width: 70 })); add(r, T(n, { size: 13, color: C.ink2, lhp: 120 }), { grow: true }); add(r, T(d, { w: 600, size: 11.5, color: C.ink, lhp: 120 })); add(pois, r);758 });759 add(c, pois); return c;760}761function crumbs(iw) {762 const f = box({ dir: "H", gap: 10, align: "CENTER", w: iw, name: "Fil d'Ariane (.crumbs)" });763 ["Logements", "›", "Montréal", "›", "719 5e Avenue"].forEach((c) => add(f, T(c, { fam: "mono", w: 500, size: 11.5, color: C.ink3, ls: 0.06, upper: true, lhp: 120 }))); return f;764}765766// ---- Stats (rendu actuel : grands chiffres + sparklines, période, jauges) -----------767function kpi(value, label, w, o = {}) {768 const f = box({ dir: "V", gap: 12, w, name: `KPI / ${label}` });769 add(f, o.unit ? RT([{ text: value, color: C.ink }, { text: " " + o.unit, color: C.ink, size: 24 }], { fam: "display", w: 700, size: 44, ls: -0.04, lhp: 100 }) : T(value, { fam: "display", w: 700, size: 44, color: C.ink, ls: -0.04, lhp: 100 }));770 add(f, klabel(label, { size: 10.5, ls: 0.12, width: w - 20 }));771 if (o.spark) add(f, sparkline(Math.min(140, w - 40), 40, o.spark));772 return f;773}774function periodChips(iw, mobile) {775 const f = box({ dir: "V", gap: 12, w: iw, name: "Période" });776 const row = box({ dir: "H", gap: 10, rowGap: 10, wrap: true, w: iw });777 ["Aujourd'hui", "7 jours", "30 jours", "3 mois", "6 mois", "12 mois", "Année en cours", "Tout"].forEach((p) => add(row, chip(p, p === "30 jours" ? "accent" : "default", { upper: true, size: 12.5, h: 44 })));778 add(f, row);779 const d = box({ dir: "H", gap: 12, align: "CENTER" });780 [0, 1].forEach((i) => { const b = box({ dir: "H", gap: 12, pad: [0, 18, 0, 18], h: 46, align: "CENTER", fill: C.surface, stroke: C.ink, radius: RAD.ctl }); add(b, T("mm/dd/yyyy", { fam: "mono", w: 500, size: 14, color: C.ink2, lhp: 100 })); add(b, ico("doc", C.ink, 14)); add(d, b); if (i === 0) add(d, klabel("au", { size: 9.5 })); });781 add(f, d); return f;782}783function areaChart(w, h) {784 const pts = [0.42, 0.44, 0.43, 0.47, 0.5, 0.49, 0.53, 0.58, 0.57, 0.62, 0.66, 0.65, 0.7, 0.74, 0.73, 0.78, 0.8, 0.79, 0.84, 0.86, 0.85, 0.88, 0.9, 0.89, 0.92, 0.94, 0.93, 0.96, 0.97, 0.98];785 const f = box({ w, h, name: "Annonces actives par jour" });786 for (let i = 0; i < 5; i++) { const g = hr(w, 0.08); add(f, g); g.y = (h / 4) * i; }787 add(f, sparkline(w, h, pts, { fill: 0.14, sw: 2.5 }));788 return f;789}790791// ============================================================================792// 7. ÉCRANS793// ============================================================================794function screenFrame(name, w) { const s = box({ dir: "V", w, fill: C.paper, clip: true, name }); s.primaryAxisSizingMode = "AUTO"; s.counterAxisSizingMode = "FIXED"; return s; }795function mobileStatus(w) {796 const f = box({ dir: "H", w, h: 47, pad: [14, 28, 0, 28], justify: "SPACE_BETWEEN", align: "CENTER", fill: C.paper, name: "Barre d'état iOS" });797 add(f, T("9:41", { fam: "display", w: 700, size: 15, color: C.ink, lhp: 100 })); add(f, T("●●● ᯤ ▮", { size: 12, color: C.ink, lhp: 100 })); return f;798}799function chrome(s, w, mobile, active) { if (mobile) add(s, mobileStatus(w)); add(s, header({ w, mobile, active })); add(s, ticker(w, mobile)); }800function overlays(s, w, mobile, o = {}) {801 const H = mobile ? 844 : 900;802 if (mobile && o.tabbar !== false) { const tb = tabbar(w, o.tab || 0); pin(s, tb, 12, H - 14 - tb.height); }803 if (o.bottombar) pin(s, o.bottombar, 0, H - o.bottombar.height);804 const fab = kaAgentFab(mobile ? 60 : 64); pin(s, fab, w - (mobile ? 16 : 28) - fab.width, H - (mobile ? (o.bottombar ? 130 : 110) : 28) - fab.height);805 const mark = rect(w, 1.5, C.accent, { name: `repère pli ${H}` }); pin(s, mark, 0, H); mark.opacity = 0.7;806}807function screenAccueil(w, mobile) {808 const s = screenFrame(mobile ? "Accueil · mobile 390" : "Accueil · desktop 1440", w); const iw = innerW(w, mobile);809 chrome(s, w, mobile, 0);810 add(s, section(w, mobile, [hero(w, mobile)]));811 add(s, section(w, mobile, [searchZone(w, mobile, { n: 2 }), quickChips(iw, mobile)].concat(mobile ? [] : [pillsRow(iw)]), { pt: mobile ? 30 : 42 }));812 add(s, section(w, mobile, [resultsBar(iw, mobile), spacer(1, 22), cardGrid(iw, mobile, mobile ? SAMPLE_CARDS.slice(0, 3) : SAMPLE_CARDS), spacer(1, 40), pager(iw)], { pt: 30, pb: 70 }));813 add(s, footer(w));814 overlays(s, w, mobile, { tab: 0 });815 return s;816}817function screenFiche(w, mobile) {818 const s = screenFrame(mobile ? "Fiche logement · mobile 390" : "Fiche logement · desktop 1440", w); const iw = innerW(w, mobile);819 chrome(s, w, mobile, -1);820 const gap = 30; const colL = mobile ? iw : Math.round((iw - gap) * 1.6 / 2.6), colR = mobile ? iw : iw - gap - colL;821 const left = box({ dir: "V", gap: 26, w: colL, name: "f-col · principale (galerie → prix → description → inclusions → pratique)" });822 add(left, gallery(colL, mobile));823 add(left, ficheHero(colL, mobile, { flat: true }));824 add(left, ficheBloc("Description", colL, (b) => {825 const eb = box({ dir: "H", pad: [12, 14, 12, 14], w: colL, fill: C.greenSoft, stroke: C.green, strokeOp: 0.25, sw: 1, radius: RAD.ctl, name: "en bref" });826 add(eb, T("En bref — 5½ entièrement rénové sur deux niveaux à Verdun, électricité et Internet inclus, stationnement, disponible immédiatement.", { size: 13.5, color: C.green, width: colL - 28, lhp: 155 })); add(b, eb);827 add(b, T("Magnifique 5½ situé au 719 5e Avenue, à quelques minutes du métro De l'Église et des berges du fleuve. Cuisine refaite avec électroménagers en acier inoxydable, planchers de bois franc, grandes fenêtres. Trois chambres fermées, salle de bain rénovée avec douche vitrée, sous-sol aménagé. Cour arrière privée.", { size: 15, color: C.ink2, width: colL, lhp: 160 }));828 add(b, T("Voir le texte original de la source ▾", { fam: "mono", w: 700, size: 11, color: C.green, ls: 0.06, upper: true, lhp: 120 }));829 }));830 add(left, ficheBloc("Inclusions et commodités", colL, (b) => {831 const g = box({ dir: "H", gap: 28, wrap: true, rowGap: 0, w: colL, name: "amenity-grid" }); const aw = mobile ? colL : (colL - 28) / 2; FICHE.incl.forEach(([l, c]) => add(g, amenityRow(l, !!c, aw))); add(b, g);832 add(b, T("✓ = confirmé par les données structurées de la source · les autres sont mentionnés dans le texte de l'annonce.", { size: 11.5, color: C.ink3, width: colL, lhp: 150 }));833 }));834 add(left, ficheBloc("Détails pratiques", colL, (b) => {835 const g = box({ dir: "H", gap: 10, rowGap: 10, wrap: true, w: colL, name: "kv" }); const kw = mobile ? (colL - 10) / 2 : (colL - 20) / 3; FICHE.kv.forEach(([k, v]) => add(g, kvCell(k, v, kw))); add(b, g);836 }));837 const right = box({ dir: "V", gap: 26, w: colR, name: "f-col · annexe (coût réel → historique → analyse → carte → scores → quartier)" });838 add(right, coutReel(colR)); add(right, historique(colR)); add(right, priceAnalysis(colR));839 const mb = box({ dir: "V", gap: 14, w: colR, name: "Bloc / Emplacement" }); add(mb, blocTitle("Emplacement", colR)); add(mb, mapBlock(colR, mobile ? 260 : 320)); add(right, mb);840 add(right, kaScoresBlock(colR)); add(right, quartierBlock(colR));841 const fiche = box({ dir: mobile ? "V" : "H", gap: mobile ? 26 : gap, w: iw, name: "fiche (DOM = ordre visuel, identique mobile et desktop)" }); add(fiche, left); add(fiche, right);842 add(s, section(w, mobile, [crumbs(iw), spacer(1, 22), fiche], { pt: mobile ? 22 : 30, pb: 90 }));843 add(s, footer(w));844 let bar = null;845 if (mobile) {846 bar = box({ dir: "H", gap: 12, w, pad: [10, 16, 30, 16], align: "CENTER", fill: C.surface, stroke: C.ink, strokeSides: [1.5, 0, 0, 0], name: "Barre d'action basse (.ka-bottombar)" });847 const pr = box({ dir: "V", gap: 2, name: "prix" }); add(pr, RT([{ text: "2 580 $", color: C.ink }, { text: " /mois", color: C.ink3, size: 11, w: 500 }], { fam: "display", w: 700, size: 22, ls: -0.03, lhp: 100 })); add(bar, pr);848 add(bar, button("Voir chez royal_lepage", "accent", { h: 52, size: 15, icon: "arrow", name: "CTA" }), { grow: true });849 }850 overlays(s, w, mobile, { tabbar: false, bottombar: bar });851 return s;852}853function screenStats(w, mobile) {854 const s = screenFrame(mobile ? "Stats · mobile 390" : "Stats · desktop 1440", w); const iw = innerW(w, mobile);855 chrome(s, w, mobile, 2);856 const head = box({ dir: mobile ? "V" : "H", gap: 24, w: iw, align: "MIN", name: "stats-head" });857 const left = box({ dir: "V", gap: 8, name: "titre" });858 add(left, klabel("Groupe KA · Lou·Ka", { size: 10.5, ls: 0.14 }));859 add(left, T("Statistiques du marché locatif", { fam: "display", w: 700, size: mobile ? 32 : 46, color: C.ink, ls: -0.03, lhp: 106, width: mobile ? iw : 700 }));860 add(left, klabel("Période : 30 jours (2026-08-06 → 2026-09-04)", { size: 10.5, ls: 0.12 }));861 add(head, left, mobile ? {} : { grow: true });862 const tools = box({ dir: "V", gap: 12, align: mobile ? "MIN" : "MAX", name: "stats-tools" });863 const r1 = box({ dir: "H", gap: 12, wrap: mobile, rowGap: 10, w: mobile ? iw : undefined });864 add(r1, button("Rapport PDF complet", "primary", { h: 58, size: 15, icon: "download", iconColor: C.accent, px: 26 })); add(r1, button("Autres rapports", "default", { h: 58, size: 15, chev: true })); add(r1, button("Rapport personnalisé", "default", { h: 58, size: 15, icon: "tools" })); add(tools, r1);865 const r2 = box({ dir: "H", gap: 14, align: "CENTER" }); add(r2, klabel("Mis à jour le 4 sept. 2026, 19 h 10", { size: 10.5 })); add(r2, button("Rafraîchir", "default", { h: 58, size: 15, icon: "refresh" })); add(tools, r2);866 add(head, tools);867 const cols = mobile ? 2 : 6; const kw = Math.floor((iw - 24 * (cols - 1)) / cols);868 const grid = box({ dir: "H", gap: 24, rowGap: 44, wrap: true, w: iw, name: "KPI" });869 const sp1 = [0.2, 0.22, 0.3, 0.55, 0.7, 0.72, 0.9, 0.88, 0.95, 1], sp2 = [0.1, 0.2, 0.15, 0.9, 0.3, 0.2, 0.35, 0.15, 0.3, 0.25], sp3 = [0.2, 0.4, 0.3, 0.7, 0.6, 0.9, 0.5, 0.8, 0.9, 0.6], sp4 = [0.6, 0.65, 0.5, 0.9, 0.55, 0.85, 0.5, 0.95, 0.6, 0.8];870 [["55 368", "Annonces actives", { spark: sp1 }], ["111 288", "Nouvelles annonces (période)", { spark: sp2 }], ["55 920", "Annonces retirées (période)", { spark: sp3 }], ["1 841", "Loyer moyen (actives)", { unit: "$", spark: sp4 }], ["1 720", "Loyer médian (actives)", { unit: "$" }], ["7 945", "Annonces en quarantaine (qualité)", {}], ["10 382", "Annonces sous le marché", {}], ["432", "Connecteurs actifs (période)", {}], ["1 353", "Villes couvertes", {}]].forEach(([v, l, o]) => add(grid, kpi(v, l, kw, o)));871 const gauges = box({ dir: "H", gap: 16, rowGap: 24, wrap: true, w: iw, justify: mobile ? "CENTER" : "SPACE_BETWEEN", name: "Qualité des données (jauges)" });872 const gw = mobile ? (iw - 16) / 2 : Math.floor((iw - 16 * 4) / 5);873 [[95.7, "Annonces géolocalisées", "96 % de 100 %"], [93.3, "Annonces avec loyer affiché", "93 % de 100 %"], [97.5, "Annonces avec photos", "98 % de 100 %"], [85.7, "Annonces publiées (qualité OK)", "86 % de 100 %"], [87.8, "Complétude moyenne des fiches", "88 % de 100 %"]].forEach(([p, l, sub]) => { const g = gauge(p, l, sub, Math.max(gw, 160)); if (mobile) { g.children[0].rescale(0.72); } add(gauges, g); });874 const chartHead = box({ dir: "H", w: iw, align: "CENTER", justify: "SPACE_BETWEEN" }); add(chartHead, T("Annonces actives par jour", { fam: "display", w: 700, size: 19, color: C.ink, ls: -0.01, lhp: 120 })); const lg = box({ dir: "H", gap: 8, align: "CENTER" }); add(lg, rect(26, 2.5, C.accent, { radius: 2 })); add(lg, klabel("Période courante", { size: 10.5, color: C.ink })); add(chartHead, lg);875 const chart = box({ dir: "V", gap: 10, w: iw, name: "Graphique" }); add(chart, areaChart(iw, mobile ? 160 : 240));876 const ax = box({ dir: "H", w: iw, justify: "SPACE_BETWEEN" }); ["6 août", "13 août", "20 août", "27 août", "4 sept."].forEach((a) => add(ax, klabel(a, { size: 9.5, color: C.ink3 }))); add(chart, ax);877 const vizW = mobile ? iw : (iw - 22) / 2;878 const viz = box({ dir: "H", gap: 22, rowGap: 22, wrap: true, w: iw, name: "viz-grid" });879 add(viz, vizCard(vizW, "Loyer médian par ville", "4½ · 30 derniers jours", (c, cw) => hbars(c, cw, [["Montréal", "1 650 $", 1], ["Laval", "1 520 $", 0.92], ["Gatineau", "1 410 $", 0.85], ["Québec", "1 250 $", 0.76], ["Lévis", "1 190 $", 0.72], ["Sherbrooke", "1 080 $", 0.65], ["Trois-Rivières", "940 $", 0.57]])));880 add(viz, vizCard(vizW, "Volume par type de logement", "Annonces actives", (c, cw) => hbars(c, cw, [["4½", "17 860", 1], ["3½", "14 210", 0.8], ["5½", "6 940", 0.39], ["Studio / 1½", "3 120", 0.17], ["Chambre", "2 870", 0.16], ["6½ et +", "1 480", 0.08], ["Maison", "1 026", 0.06]])));881 add(s, section(w, mobile, [head, spacer(1, 44), grid, spacer(1, 48), periodChips(iw, mobile), spacer(1, 56), gauges, spacer(1, 56), chartHead, spacer(1, 16), chart, spacer(1, 48), viz, spacer(1, 24), T("Sources : connecteurs Lou-Ka · dernier cycle complet il y a 12 min · les valeurs excluent les annonces en quarantaine qualité.", { size: 11.5, color: C.ink3, width: iw, lhp: 150 })], { pt: 34, pb: 90 }));882 add(s, footer(w));883 overlays(s, w, mobile, { tabbar: false });884 return s;885}886function vizCard(w, title, sub, build) {887 const c = box({ dir: "V", gap: 4, w, pad: [26, 28, 20, 28], fill: C.surface, stroke: C.ink, radius: RAD.card, shadow: SH.offSoft, name: `Viz / ${title}` });888 add(c, T(title, { fam: "display", w: 700, size: 19, color: C.ink, ls: -0.01, lhp: 120 })); add(c, klabel(sub, { size: 11.5, ls: 0.06 })); add(c, spacer(1, 16)); build(c, w - 56); return c;889}890function barRow(label, value, ratio, w, color) {891 const f = box({ dir: "H", gap: 12, align: "CENTER", w, name: `barre / ${label}` });892 add(f, T(label, { w: 600, size: 13, color: C.ink, width: 130, lhp: 120 }));893 const tw = w - 130 - 60 - 24; const track = box({ w: tw, h: 18, fill: C.surface2, radius: RAD.ctl, clip: true, name: "track" }); add(track, rect(Math.max(2, tw * ratio), 18, color || C.ink, { radius: RAD.ctl, name: "fill" })); add(f, track);894 add(f, T(value, { fam: "mono", w: 700, size: 11.5, color: C.ink, width: 60, align: "RIGHT", lhp: 120 })); return f;895}896function hbars(parent, w, rows) { const f = box({ dir: "V", gap: 7, w, name: "hbars" }); rows.forEach(([l, v, r], i) => add(f, barRow(l, v, r, w, i === 0 ? C.accent : C.ink))); add(parent, f); }897898function screenVilles(w, mobile) {899 const s = screenFrame(mobile ? "Villes · mobile 390" : "Villes · desktop 1440", w); const iw = innerW(w, mobile);900 chrome(s, w, mobile, 0);901 const head = box({ dir: "V", gap: 10, w: iw, name: "en-tête" });902 add(head, kicker("Répertoire — logements par ville"));903 add(head, T("Logements à louer par ville", { fam: "display", w: 700, size: mobile ? 32 : 46, color: C.ink, ls: -0.03, width: iw, lhp: 106 }));904 add(head, T("Chaque ville a sa page : annonces, loyer médian, carte et statistiques locales — indexées auprès des gestionnaires immobiliers de la région.", { size: 15, color: C.ink2, width: Math.min(640, iw), lhp: 155 }));905 const cols = mobile ? 1 : 4; const cw = Math.floor((iw - 16 * (cols - 1)) / cols);906 const g = box({ dir: "H", gap: 16, rowGap: 16, wrap: true, w: iw, name: "villes" });907 [["Montréal", "24 127", "1 650 $"], ["Québec", "3 526", "1 250 $"], ["Laval", "2 140", "1 520 $"], ["Lévis", "1 636", "1 190 $"], ["Gatineau", "1 210", "1 410 $"], ["Longueuil", "1 180", "1 480 $"], ["Sherbrooke", "980", "1 080 $"], ["Trois-Rivières", "760", "940 $"], ["Saguenay", "520", "860 $"], ["Terrebonne", "410", "1 390 $"], ["Saint-Jean-sur-Richelieu", "380", "1 120 $"], ["Drummondville", "340", "980 $"]].slice(0, mobile ? 6 : 12).forEach(([v, n, m], i) => {908 const c = box({ dir: "V", gap: 8, pad: 18, w: cw, fill: C.surface, stroke: C.ink, radius: RAD.card, shadow: i === 0 ? SH.off : SH.flat, name: `Ville / ${v}` });909 const top = box({ dir: "H", w: cw - 36, justify: "SPACE_BETWEEN", align: "CENTER" }); add(top, T(v, { fam: "display", w: 700, size: 19, color: C.ink, ls: -0.02, lhp: 120 })); add(top, ico("arrow", C.accent, 14)); add(c, top);910 add(c, RT([{ text: n, color: C.ink, w: 700 }, { text: " logements", color: C.ink3 }], { fam: "mono", size: 12, lhp: 120 }));911 const row = box({ dir: "H", gap: 8, align: "CENTER" }); add(row, klabel("Loyer médian 4½")); add(row, T(m, { fam: "display", w: 700, size: 14, color: C.ink, lhp: 120 })); add(c, row);912 const types = box({ dir: "H", gap: 6, wrap: true, rowGap: 6, w: cw - 36 }); ["3½", "4½", "5½", "Studio"].forEach((t) => add(types, stPill(t, "observed"))); add(c, types); add(g, c);913 });914 add(s, section(w, mobile, [head, spacer(1, 30), g], { pt: 44, pb: 90 }));915 add(s, footer(w)); overlays(s, w, mobile, { tab: 0 }); return s;916}917function screenMobileStates(w) {918 const out = [];919 const m = screenFrame("Menu mobile ouvert · 390", w); m.resize(w, 844); m.primaryAxisSizingMode = "FIXED";920 add(m, mobileStatus(w)); add(m, header({ w, mobile: true }));921 const panel = box({ dir: "V", w, pad: [20, 16, 24, 16], fill: C.paper, stroke: C.ink, strokeSides: [0, 0, 1.5, 0], name: "mobile-menu" });922 [["home", "Logements", true], ["doc", "Court terme"], ["list", "Stats"], ["arrow", "Sources"], ["map", "Déménageurs"], ["pin", "Villes"], ["heart", "Favoris"], ["user", "Mon compte KA ID"]].forEach(([i, l, on]) => {923 const r = box({ dir: "H", gap: 12, pad: [0, 10, 0, 10], w: w - 32, h: 52, align: "CENTER", stroke: C.ink, strokeOp: LINE, sw: 1, strokeSides: [0, 0, 1, 0], name: `mm-link / ${l}` });924 add(r, ico(i, on ? C.accent : C.ink, 18)); add(r, T(l, { fam: "display", w: 700, size: 19, color: on ? C.accent : C.ink, ls: -0.02, lhp: 100 }), { grow: true }); add(r, ico("arrow", C.accent, 14)).opacity = on ? 1 : 0.25; add(panel, r);925 });926 add(panel, spacer(1, 12)); add(panel, T("Lou-Ka — un service Groupe KA · La porte d'entrée vers votre prochain chez-vous.", { size: 11, color: C.ink3, width: w - 52, lhp: 150 })); add(m, panel);927 const close = box({ dir: "H", w: 44, h: 44, align: "CENTER", justify: "CENTER", fill: C.ink, radius: RAD.pill, name: "mm-close" }); add(close, ico("x", C.paper, 15)); pin(m, close, w - 14 - 44, 47 + 6);928 out.push(m);929 const sh = screenFrame("Feuille de critères · 390", w); sh.resize(w, 844); sh.primaryAxisSizingMode = "FIXED";930 add(sh, mobileStatus(w)); add(sh, header({ w, mobile: true })); add(sh, ticker(w, true)); add(sh, section(w, true, [hero(w, true)]));931 pin(sh, rect(w, 844, C.ink, { op: 0.45, name: "sheet-backdrop" }), 0, 0);932 const sheet = box({ dir: "V", gap: 12, w, pad: [10, 18, 34, 18], align: "CENTER", fill: C.surface, radii: [RAD.sheet, RAD.sheet, 0, 0], shadow: shadow(0, -16, 48, C.ink, 0.28), name: "feuille de critères (.filterbar.open)" });933 add(sheet, rect(44, 5, C.ink, { op: LINE, radius: 999, name: "sheet-handle" }));934 const hd = box({ dir: "H", w: w - 36, pad: [6, 0, 8, 0], justify: "SPACE_BETWEEN", align: "CENTER", stroke: C.ink, strokeOp: LINE, sw: 1, strokeSides: [0, 0, 1, 0], name: "sheet-head" }); add(hd, T("Critères", { fam: "display", w: 700, size: 17, color: C.ink, lhp: 120 })); const cl = box({ dir: "H", w: 36, h: 36, align: "CENTER", justify: "CENTER", stroke: C.ink, radius: 999 }); add(cl, ico("x", C.ink, 12)); add(hd, cl); add(sheet, hd);935 add(sheet, input("Où voulez-vous habiter ?", { w: w - 36, h: 46, value: "Montréal", clear: true }));936 add(sheet, fctl("Ville", "Montréal", { w: w - 36 })); add(sheet, fctl("Quartier", "Tous", { w: w - 36 })); add(sheet, fctl("Loyer", "Min — 2 500 $", { w: w - 36 }));937 const g1 = box({ dir: "V", gap: 7, w: w - 36 }); add(g1, klabel("Animaux", { size: 10.5 })); add(g1, segments(["Indifférent", "Acceptés", "Refusés"], 1)); add(sheet, g1);938 add(sheet, field("KA Score minimum", "70 et +", { w: w - 36, select: true, transparent: true }));939 add(sheet, button("Voir 24 127 logements", "primary", { w: w - 36, h: 50, shadow: shadow(5, 5, 0, C.accent, 1), name: "sheet-apply" }));940 pin(sh, sheet, 0, 844 - sheet.height);941 out.push(sh);942 return out;943}944945// ============================================================================946// 8. PAGES : COUVERTURE, FONDATIONS, COMPOSANTS, RÉFÉRENCES947// ============================================================================948function sectionTitle(t, sub) { const f = box({ dir: "V", gap: 6, name: `§ ${t}` }); add(f, T(t, { fam: "display", w: 700, size: 34, color: C.ink, ls: -0.03, lhp: 110 })); if (sub) add(f, T(sub, { size: 14, color: C.ink2, width: 760, lhp: 155 })); return f; }949function swatch(name, hex, note, op) {950 const f = box({ dir: "V", gap: 8, w: 168, name: `Couleur / ${name}` });951 if (op != null) { const bg = box({ w: 168, h: 96, fill: C.surface, radius: RAD.card, stroke: C.ink, clip: true, name: "sw" }); add(bg, rect(168, 96, hex, { op })); add(f, bg); } else add(f, rect(168, 96, hex, { radius: RAD.card, stroke: C.ink }));952 add(f, T(name, { fam: "display", w: 700, size: 13, color: C.ink, lhp: 120 }));953 add(f, T(op == null ? hex.toUpperCase() : `${hex.toUpperCase()} · ${Math.round(op * 100)} %`, { fam: "mono", w: 500, size: 11, color: C.ink2, lhp: 120 }));954 if (note) add(f, T(note, { size: 11, color: C.ink3, width: 168, lhp: 140 })); return f;955}956const COLOR_TOKENS = [957 ["Papier", "paper", C.paper, "--paper · fond de page"], ["Surface", "surface", C.surface, "--surface · cartes"], ["Surface 2", "surface2", C.surface2, "--surface-2 · champs, lignes paires"],958 ["Encre", "ink", C.ink, "--ink · texte, bordures 1,5 px, états actifs"], ["Encre 2", "ink2", C.ink2, "--ink-2 · texte secondaire"], ["Encre 3", "ink3", C.ink3, "--ink-3 · étiquettes, placeholders"],959 ["Accent · Orange Lou-Ka", "accent", C.accent, "--accent · surcharge Lou-Ka (#FF6A00)"], ["Accent doux", "accentSoft", C.accentSoft, "--accent-soft · totaux, notes"], ["Accent profond", "accentDeep", C.accentDeep, "--accent-deep · survol CTA, ▲ marché"],960 ["Vert profond", "green", C.green, "--green · kicker, tags source, « sous le marché »"], ["Vert très profond", "greenDeep", C.greenDeep, "--green-deep"], ["Vert doux", "greenSoft", C.greenSoft, "--blue-soft (alias) · amenities, en bref"],961 ["Vert « inclus »", "greenOk", C.greenOk, ".st-included"], ["Marine (logo)", "navy", C.navy, "#0B1330 · porte du logo"], ["Ambre", "amber", C.amber, "--amber · KA Score moyen"],962 ["Ambre doux", "amberSoft", C.amberSoft, "--amber-soft"], ["Danger", "danger", C.danger, "--danger · retrait de filtre"], ["Danger doux", "dangerSoft", C.dangerSoft, "--danger-soft"],963];964const TYPE_TOKENS = [965 ["Display / H1 héro", "display", 700, 88, -0.045, 100, "Space Grotesk Bold · clamp(44px, 7.2vw, 88px) · .hero-display"],966 ["Display / H1", "display", 700, 46, -0.03, 106, "Space Grotesk Bold · pages Stats/Villes clamp(30→46)"],967 ["Display / H2", "display", 700, 34, -0.03, 115, "--fs-h2 clamp(23→34)"],968 ["Display / Titre de bloc", "display", 700, 21, -0.02, 120, ".f-bloc h2 (tiret accent)"],969 ["Display / KPI", "display", 700, 44, -0.04, 100, "page Stats, grands chiffres"],970 ["Display / Prix fiche", "display", 500, 54, -0.04, 100, ".f-hero .price clamp(38→54)"],971 ["Display / Prix carte", "display", 700, 21, -0.02, 110, ".card-price"],972 ["Display / Recherche", "display", 500, 28, -0.02, 120, ".q-big input clamp(19→28)"],973 ["Display / Nav", "display", 600, 14, 0, 100, ".nav a"],974 ["Display / Bouton", "display", 700, 14, 0, 100, ".btn / .cta"],975 ["Display / Chip", "display", 700, 12.5, 0.04, 100, ".chip (MAJUSCULES sur les filtres rapides)"],976 ["Body / Lede", "body", 400, 16.5, 0, 155, "Inter · .hero .lede"],977 ["Body / Texte", "body", 400, 15, 0, 155, "--fs-body"],978 ["Body / Titre carte", "body", 600, 14.5, 0, 130, ".card-title"],979 ["Body / Petit", "body", 400, 13, 0, 155, "--fs-small"],980 ["Body / Méta", "body", 400, 12.5, 0, 120, ".card-meta"],981 ["Mono / Ticker", "mono", 500, 11.5, 0.08, 100, ".ticker · MAJUSCULES"],982 ["Mono / Kicker", "mono", 500, 11.5, 0.12, 140, ".kicker · MAJUSCULES"],983 ["Mono / Live", "mono", 500, 12, 0.02, 120, ".live-line"],984 ["Mono / Étiquette", "mono", 700, 10, 0.1, 140, ".klabel / .crit > span / .tile-k · MAJUSCULES"],985 ["Mono / Micro-pilule", "mono", 700, 9.5, 0.06, 140, ".st-pill · MAJUSCULES"],986];987988async function buildStylesAndVariables() {989 for (const [name, , hex, note] of COLOR_TOKENS) { const s = figma.createPaintStyle(); s.name = `Lou-Ka / ${name}`; s.paints = [paint(hex)]; s.description = note; }990 const ln = figma.createPaintStyle(); ln.name = "Lou-Ka / Filet (encre 14 %)"; ln.paints = [paint(C.ink, LINE)]; ln.description = "--line / --hairline";991 const ls = figma.createPaintStyle(); ls.name = "Lou-Ka / Filet fort (encre 85 %)"; ls.paints = [paint(C.ink, LINE_STRONG)]; ls.description = "--line-strong / --hairline-strong";992 for (const [name, fam, w, size, lsp, lh, note] of TYPE_TOKENS) {993 const t = figma.createTextStyle(); t.name = `Lou-Ka / ${name}`; t.fontName = fn(fam, w); t.fontSize = size;994 t.letterSpacing = { unit: "PERCENT", value: lsp * 100 }; t.lineHeight = { unit: "PERCENT", value: lh }; t.description = note;995 if (/MAJUSCULES/.test(note)) t.textCase = "UPPER";996 }997 const effs = [["Ombre décalée · encre (6,6)", SH.off, "--shadow-off · survol carte"], ["Ombre décalée · dure (4,4)", SH.hard4, ".cta / .fab / KA agent"], ["Ombre décalée · douce (8,8 · 8 %)", SH.offSoft, "--shadow-off-soft · cartes, panneaux"], ["Ombre décalée · moyenne (4,4 · 18 %)", SH.offMid, "--shadow-off-mid"], ["Ombre plate", SH.flat, "--shadow-flat"], ["Focus accent (3,3)", SH.accent3, ":focus champs / sheet-apply"]];998 for (const [n, e, d] of effs) { const s = figma.createEffectStyle(); s.name = `Lou-Ka / ${n}`; s.effects = [e]; s.description = d; }999 try {1000 const col = figma.variables.createVariableCollection("Lou-Ka · Couleurs"); const mode = col.modes[0].modeId; col.renameMode(mode, "Lou-Ka");1001 for (const [, key, hex] of COLOR_TOKENS) { const v = figma.variables.createVariable(`couleur/${key}`, col, "COLOR"); v.setValueForMode(mode, rgba(hex, 1)); }1002 const vl = figma.variables.createVariable("couleur/line", col, "COLOR"); vl.setValueForMode(mode, rgba(C.ink, LINE));1003 const vls = figma.variables.createVariable("couleur/line-strong", col, "COLOR"); vls.setValueForMode(mode, rgba(C.ink, LINE_STRONG));1004 const dim = figma.variables.createVariableCollection("Lou-Ka · Dimensions"); const dm = dim.modes[0].modeId; dim.renameMode(dm, "Base");1005 SP.forEach((v, i) => { const x = figma.variables.createVariable(`espacement/sp-${i + 1}`, dim, "FLOAT"); x.setValueForMode(dm, v); });1006 [["rayon/card", 10], ["rayon/ctl", 6], ["rayon/pill", 999], ["rayon/sheet", 24], ["bordure/encre", 1.5], ["bordure/filet", 1], ["bordure/forte", 2], ["cible-tactile", 44], ["conteneur/max", 1152], ["header/desktop", 64], ["header/mobile", 56], ["ticker", 32], ["breakpoint/sm", 360], ["breakpoint/md", 768], ["breakpoint/lg", 1024], ["breakpoint/xl", 1440]].forEach(([n, v]) => { const x = figma.variables.createVariable(n, dim, "FLOAT"); x.setValueForMode(dm, v); });1007 [["typo/display", "Space Grotesk"], ["typo/body", "Inter"], ["typo/mono", "JetBrains Mono"]].forEach(([n, v]) => { const x = figma.variables.createVariable(n, dim, "STRING"); x.setValueForMode(dm, v); });1008 } catch (e) { figma.notify("Variables non créées (limite du forfait) — styles créés.", { timeout: 3000 }); }1009}10101011function pageFondations() {1012 const root = box({ dir: "V", gap: 96, pad: 96, fill: C.paper, name: "① Fondations — Design system Lou-Ka (ka-ui « éditorial sharp » + accent orange)" });1013 const cols = box({ dir: "V", gap: 28, name: "Couleurs" });1014 add(cols, sectionTitle("Couleurs", "Palette fixe Groupe KA (papier / encre / vert profond) + accent Lou-Ka orange #FF6A00, seule surcharge autorisée de tokens.css. Filets = encre à 14 % (normal) et 85 % (fort). Les états actifs sont encre + accent (plus de bleu/marine dans l'interface, le marine ne survit que dans le logo)."));1015 const g = box({ dir: "H", gap: 24, rowGap: 32, wrap: true, w: 168 * 6 + 24 * 5, name: "nuancier" });1016 COLOR_TOKENS.forEach(([n, , h, note]) => add(g, swatch(n, h, note))); add(g, swatch("Filet", C.ink, "--line · --hairline", LINE)); add(g, swatch("Filet fort", C.ink, "--line-strong", LINE_STRONG));1017 add(cols, g); add(root, cols);1018 const ty = box({ dir: "V", gap: 28, name: "Typographie" });1019 add(ty, sectionTitle("Typographie", "Display : Space Grotesk (500/600/700) · Texte : Inter (400–700) · Micro-étiquettes : JetBrains Mono (500/700). Échelle fluide clamp() — valeurs au maximum (1440 px) ; interlettrage négatif sur les titres, positif sur les mono en majuscules."));1020 TYPE_TOKENS.forEach(([name, fam, w, size, lsp, lh, note]) => {1021 const row = box({ dir: "H", gap: 32, align: "CENTER", w: 1128, pad: [14, 0, 14, 0], stroke: C.ink, strokeOp: LINE, sw: 1, strokeSides: [0, 0, 1, 0], name });1022 const meta = box({ dir: "V", gap: 4, w: 260 }); add(meta, T(name, { fam: "display", w: 700, size: 13, color: C.ink, lhp: 120 })); add(meta, T(`${size} px · ${w} · ${lsp ? lsp + " em" : "0"} · lh ${lh} %`, { fam: "mono", size: 10.5, color: C.ink2, lhp: 130 })); add(meta, T(note, { size: 11, color: C.ink3, width: 260, lhp: 140 })); add(row, meta);1023 add(row, T(fam === "mono" ? "Location — tout le Québec, un seul endroit" : "Trouvez votre prochain chez-vous.", { fam, w, size: Math.min(size, 64), color: C.ink, ls: lsp, lhp: lh, upper: /MAJUSCULES/.test(note), width: 800 }));1024 add(ty, row);1025 });1026 add(root, ty);1027 const sp = box({ dir: "V", gap: 28, name: "Espacements, rayons, bordures" });1028 add(sp, sectionTitle("Espacements, rayons, bordures", "Échelle --sp-1…8 (4 → 64 px). Rayons : carte 10 px · contrôles, boutons, nav, CTA 6 px (--r-ctl) · chips/badges/pilules 999 · feuille mobile 24 px. Bordures encre 1,5 px (2 px sous la grande recherche), filets 1 px. Cible tactile ≥ 44 px. Conteneur 1152 px. Breakpoints 360 / 640 / 768 / 900 / 1024 / 1440."));1029 const sprow = box({ dir: "H", gap: 24, align: "MAX" }); SP.forEach((v, i) => { const c = box({ dir: "V", gap: 8, align: "CENTER" }); add(c, rect(v, v, C.accent, { radius: 2 })); add(c, klabel(`sp-${i + 1}`)); add(c, T(`${v} px`, { fam: "mono", size: 10, color: C.ink3, lhp: 120 })); add(sprow, c); }); add(sp, sprow);1030 const rrow = box({ dir: "H", gap: 24, align: "MAX" }); [["card · 10 · 1,5 px", 10, 1.5], ["ctl · 6 · 1,5 px", 6, 1.5], ["ctl · 6 · filet fort 1 px", 6, 1], ["sheet · 24", 24, 0], ["pill · 999 · 1 px", 999, 1]].forEach(([n, r, sw]) => { const c = box({ dir: "V", gap: 8, align: "CENTER" }); add(c, rect(120, 64, C.surface, { radius: r, stroke: sw ? C.ink : null, sw: sw || 1, strokeOp: sw === 1 ? LINE_STRONG : 1, shadow: SH.offSoft })); add(c, klabel(n)); add(rrow, c); }); add(sp, rrow); add(root, sp);1031 const shd = box({ dir: "V", gap: 28, name: "Ombres décalées" });1032 add(shd, sectionTitle("Ombres décalées — la signature du groupe", "Jamais de flou sur les ombres structurelles : décalage dur (x,y) sans rayon. Le flou n'existe que sous le bouton flottant (halo encre 25 %)."));1033 const srow = box({ dir: "H", gap: 40 }); [["flat", SH.flat, "0 1 2 · 5 %", C.surface], ["off-soft", SH.offSoft, "8 8 0 · 8 %", C.surface], ["off-mid", SH.offMid, "4 4 0 · 18 %", C.surface], ["off", SH.off, "6 6 0 · encre", C.surface], ["hard (cta/fab)", SH.hard4, "4 4 0 · encre", C.accent], ["focus accent", SH.accent3, "3 3 0 · orange", C.surface]].forEach(([n, e, d, bg]) => { const c = box({ dir: "V", gap: 12 }); add(c, rect(150, 90, bg, { radius: RAD.card, stroke: C.ink, shadow: e })); add(c, T(n, { fam: "display", w: 700, size: 13, color: C.ink, lhp: 120 })); add(c, T(d, { fam: "mono", size: 10.5, color: C.ink3, lhp: 120 })); add(srow, c); }); add(shd, srow); add(root, shd);1034 const lg = box({ dir: "V", gap: 28, name: "Logo" });1035 add(lg, sectionTitle("Logo — la porte entrouverte", "Cadre squircle 60×90 (rx 19) en dégradé orange #FF7A1A → #F56000, porte marine #101A3D → #0B1330 ouverte ≈ 30°, tapis de lumière #FF8533 → #FF6A00, reflet blanc 28 %. Wordmark actuel : « Lou- » encre + « Ka » blanc dans une boîte orange inclinée (−2°). Baseline : « La porte d'entrée vers votre prochain chez-vous. »"));1036 const lrow = box({ dir: "H", gap: 48, align: "CENTER" }); add(lrow, logoIcon(160, false)); const dk = box({ dir: "H", pad: 32, fill: C.navy, radius: RAD.card }); add(dk, logoIcon(120, true)); add(lrow, dk); add(lrow, logo(48, { textSize: 40 })); const dk2 = box({ dir: "H", pad: 32, fill: C.ink, radius: RAD.card }); add(dk2, logo(40, { dark: true, textSize: 34 })); add(lrow, dk2); add(lrow, gkBadge()); add(lrow, kaAgentFab()); add(lg, lrow); add(root, lg);1037 const rules = box({ dir: "V", gap: 12, w: 1128, name: "Règles d'usage" });1038 add(rules, sectionTitle("Règles d'usage (tokens.css / CLAUDE.md Groupe Ka)"));1039 ["Mobile-first : TAP, jamais :hover, pour ouvrir un menu ; le survol ne sert qu'aux effets décoratifs (@media (hover: hover)).", "Ordre du DOM = ordre visuel, identique mobile et desktop (jamais order:/column-reverse). Galerie + prix + adresse toujours en premier sous le header.", "Zones tactiles ≥ 44 × 44 px ; champs ≥ 16 px au doigt (anti-zoom iOS) ; safe-areas iOS ; barre basse = .ka-bottombar + body.has-bottombar.", "z-index : uniquement l'échelle --z-* (sticky 300 · header 500 · bottombar 600 · dropdown 700 · overlay 800 · modal 900 · toast 950).", "Hauteur de viewport : var(--vh100) (dvh), jamais 100vh en dur.", "États sélectionnés = encre + accent (nav, segments, pagination) ; CTA = aplat orange + bordure encre + ombre 4,4 ; contrôles à rayon 6 px, chips/badges en pilule."].forEach((r) => { const li = box({ dir: "H", gap: 12, w: 1128, align: "MIN" }); const sq = rect(9, 9, C.accent, { stroke: C.ink, shadow: shadow(2, 2, 0, C.ink, 1) }); add(li, sq); sq.y = 6; add(li, T(r, { size: 14, color: C.ink2, width: 1100, lhp: 155 })); add(rules, li); });1040 add(root, rules); return root;1041}10421043function pageComposants() {1044 const root = box({ dir: "V", gap: 80, pad: 96, fill: C.paper, name: "② Composants Lou-Ka" });1045 function group(title, sub, nodes, gap = 24) { const g = box({ dir: "V", gap: 24, name: title }); add(g, sectionTitle(title, sub)); const row = box({ dir: "H", gap, rowGap: gap, wrap: true, align: "MIN", w: 1248, name: "variantes" }); nodes.forEach((n) => add(row, n)); add(g, row); add(root, g); return g; }1046 function variantSet(nodes, name) { const set = figma.combineAsVariants(nodes, root); set.name = name; set.layoutMode = "HORIZONTAL"; set.itemSpacing = 16; set.paddingTop = set.paddingBottom = set.paddingLeft = set.paddingRight = 16; set.primaryAxisSizingMode = "AUTO"; set.counterAxisSizingMode = "AUTO"; set.counterAxisAlignItems = "CENTER"; return set; }1047 const btnSet = variantSet([["default", "Défaut", "Autres rapports"], ["primary", "Primaire", "Rapport PDF complet"], ["accent", "Accent (CTA)", "Voir l'annonce"], ["ghost", "Fantôme", "Télécharger la fiche (PDF)"], ["login", "Connexion", "Connexion"]].map(([v, n, l]) => button(l, v, { component: true, name: `Variante=${n}`, icon: v === "accent" ? "arrow" : v === "ghost" ? "doc" : null })), "Bouton");1048 group("Boutons (.btn / .cta / .login-btn)", "Space Grotesk 700 · 14 px · 44 px · rayon 6 · bordure encre 1,5. Primaire = encre + texte accent. Accent (CTA) = orange + blanc + bordure encre + ombre dure 4,4. Fantôme = filet fort 1 px. Connexion = encre + accent + carré KA orange.", [btnSet, button("Voir 24 127 logements", "primary", { w: 320, h: 50, shadow: shadow(5, 5, 0, C.accent, 1), name: "sheet-apply" }), button("Rafraîchir", "default", { h: 58, size: 15, icon: "refresh" }), kaAgentFab()]);1049 const chipSet = variantSet([["default", "Défaut"], ["hover", "Survol"], ["on", "Actif"], ["accent", "Accent"]].map(([s, n]) => chip("4½", s, { component: true, name: `État=${n}`, upper: true })), "Chip");1050 group("Chips, pilules et micro-étiquettes", "Chip (.chip) : filet fort 1 px, transparent, 38 px, Space Grotesk 700 12,5 px MAJUSCULES ; actif = encre + papier ; accent = orange (période Stats). Pill de filtre actif (survol = rouge « retrait »). Micro-pilules mono .st-pill (observé / inclus / estimé / inconnu). Kicker, klabel, segments.", [chipSet, chip("Sous le marché", "default", { icon: "down", upper: true }), chip("Dispo maintenant", "default", { icon: "bolt", upper: true }), pill("≤ 2 500 $"), pill("Tout effacer", true), stPill("Observé", "observed"), stPill("Inclus", "included"), stPill("Estimé", "estimated"), stPill("Inconnu", "unknown"), stPill("Nouveau", "ink"), kicker("Location — tout le Québec"), klabel("Étiquette klabel"), segments(["Indifférent", "Acceptés", "Refusés"], 1)]);1051 const badgeSet = variantSet([badge("4½", "type", { component: true, name: "Type=Type de logement" }), badge("12", "photos", { component: true, name: "Type=Nombre de photos" }), badge("Recommandé pour vous", "reco", { component: true, name: "Type=Recommandé KA ID" })], "Badge photo");1052 group("Badges, pastilles de valeur et KA Score", "Badges posés sur la photo (.badge) · bouton favori · FairValueBadge (▼ sous / ≈ dans / ▲ au-dessus du marché, + écart %) · pastille « KA 78 » (≥70 vert, ≥55 olive, ≥40 ambre, sinon rouge) · tag source · jauges KA Scores.", [badgeSet, badge("", "fav"), badge("", "fav", { on: true }), fvBadge("sous", "−12 %"), fvBadge("marche", "−3 %"), fvBadge("sur", "+193 %"), fvBadge("sous", "−12 %", true), fvBadge("sur", "+18 %", true), kaBadge(84), kaBadge(62), kaBadge(45), kaBadge(31), sourceTag("Cogir"), kaCircle(91, "Marche"), kaCircle(62, "Calme"), kaCircle(null, "Vélo")]);1053 group("Recherche et champs", "Grande recherche soulignée (.q-big, 2 px encre, focus = accent) · critères de la ligne (.crit : étiquette mono 9,5 + valeur display 15,5 + chevron, filets verticaux) · résumé mobile (.crit-summary) · champ boîte (.f-search) · contrôle .f-ctl · champ natif .field.", [qBig(560, false), qBig(560, false, { focus: true, value: "Plateau-Mont-Royal" }), crit("Ville", "Toutes"), crit("Loyer", "Min — Max"), crit("Quartier", "Rosemont", { hover: true, last: true }), input("Où voulez-vous habiter ?", { component: true }), input("Où voulez-vous habiter ?", { value: "Montréal", focus: true, clear: true, name: "Champ / recherche · focus" }), fctl("Type", "Tous les types", { component: true }), fctl("Loyer", "Min — 2 500 $", { focus: true, name: "Contrôle / f-ctl · focus" }), field("Source", "Toutes les sources", { select: true, component: true }), field("Courriel", "vous@exemple.ca", { placeholder: true, transparent: true })]);1054 group("KPI, tuiles, cellules, titres, jauges", "KPI Stats (Space Grotesk 44 + étiquette mono + sparkline orange) · jauge semi-circulaire · cellule .kv .cell · titre de bloc (tiret accent 22×3 + filet) · ligne d'inclusion (.amenity-it, ✓ = confirmé) · chip-clé de fiche · ligne de coût réel.", [kpi("55 368", "Annonces actives", 200, { spark: [0.2, 0.22, 0.3, 0.55, 0.7, 0.72, 0.9, 0.88, 0.95, 1] }), kpi("1 841", "Loyer moyen (actives)", 200, { unit: "$" }), gauge(95.7, "Annonces géolocalisées", "96 % de 100 %"), kvCell("Gestionnaire", "Royal LePage"), kvCell("En ligne depuis", "aujourd'hui"), blocTitle("Description", 320), amenityRow("Électricité", true), amenityRow("Stationnement", false), chipKey("5½", "door"), chipKey("3 chambres", "bed"), crRow("Électricité", "included", "Inclus", "0 $", 420), crRow("Total estimé", null, null, "≈ 2 580 $ /mois", 420, { total: true })]);1055 const cardComp = listingCard(SAMPLE_CARDS[0], { component: true, name: "Carte d'annonce" });1056 group("Carte d'annonce (.card)", "Photo 16:10,5 + badges (type orange, compteur photos, favori, « Recommandé pour vous ») · prix Space Grotesk 21 + pastille FairValue compacte · titre · secteur • ville + KA Score · pied : tag source + disponibilité. Survol : translateY(−4) + ombre off encre. Grille minmax(290px, 1fr) → 3 colonnes à 1152.", [cardComp, listingCard(SAMPLE_CARDS[1], { hover: true, name: "Carte d'annonce · survol" }), listingCard(SAMPLE_CARDS[3], { name: "Carte d'annonce · recommandée" }), listingCard(SAMPLE_CARDS[2], { w: 358, name: "Carte d'annonce · mobile 358" })]);1057 group("Navigation", "Header desktop 64 px (logo + baseline + badge Groupe KA + nav rayon 6, actif = encre/accent + bouton Connexion) · header mobile 56 px (Connexion + burger 44×44) · ticker mono encre · tabbar mobile flottante (actif = encre + tiret accent) · badge Groupe KA · pagination · fil d'Ariane.", [header({ w: 1280, component: true }), header({ w: 390, mobile: true, component: true }), ticker(1280), tabbar(390, 0), gkBadge(), gkBadge(true), pager(560), crumbs(600)]);1058 group("Zone de recherche et barre de résultats", "Recherche actuelle (.search-zone) : q-big + ligne de critères + « Tous les critères » (compteur) ; chips de filtres rapides ; pastilles actives ; barre de résultats sticky (compteur + Liste/Carte + tri).", [searchZone(1152, false, { n: 2 }), quickChips(1104, false), pillsRow(1104), resultsBar(1104, false), searchZone(390, true), resultsBar(358, true)]);1059 group("Footer Groupe KA (ka-ui/KaFooter)", "Fond encre, wordmark « Groupe KA », description, avis « agrégateur » (liseré accent), 13 sites en mono majuscules, 3 contacts, mentions légales.", [footer(1280, { component: true })]);1060 group("Blocs de fiche logement", "Panneau héro (prix 54 + verdict + titre + adresse + chips-clés + ancres + CTA + PDF) · coût réel mensuel (micro-pilules d'état, total accentué, note) · historique · analyse de prix · carte ka-maps · KA Scores · quartier.", [ficheHero(420, false), coutReel(420), historique(420), priceAnalysis(420), mapBlock(420, 300), kaScoresBlock(420), quartierBlock(420)]);1061 return root;1062}10631064function pageCouverture(page) {1065 const f = box({ w: 1920, h: 1080, fill: C.paper, clip: true, name: "Couverture" });1066 const halo = ellipse(900, C.accent, { op: 0.13 }); add(f, halo); halo.x = 1250; halo.y = -300;1067 const inner = box({ dir: "V", name: "contenu" });1068 add(inner, logo(72, { textSize: 60 })); add(inner, spacer(1, 56));1069 add(inner, kicker("Kit Figma — frontend déployé sur le cluster MacLustr (M4M64a · www.lou-ka.com)")); add(inner, spacer(1, 18));1070 add(inner, T("Lou-Ka", { fam: "display", w: 700, size: 140, color: C.ink, ls: -0.05, lhp: 100 }));1071 const l2 = box({ name: "brush" }); const t2 = T("Design system & écrans", { fam: "display", w: 700, size: 72, color: C.ink, ls: -0.04, lhp: 100 }); const br = brush(t2.width * 1.06, 26); add(l2, br); add(l2, t2); l2.resize(t2.width, t2.height); br.x = -t2.width * 0.03; br.y = t2.height - 26 - 4; add(inner, l2);1072 add(inner, spacer(1, 28));1073 add(inner, T("Tous les logements à louer au Québec — agrégateur Groupe KA. Fichier généré depuis le code source du frontend (React + Vite, tokens ka-ui « éditorial sharp », accent orange #FF6A00) : fondations, composants, écrans desktop 1440 et mobile 390, captures du site en production.", { size: 18, color: C.ink2, width: 860, lhp: 155 }));1074 add(inner, spacer(1, 40));1075 const pages = box({ dir: "H", gap: 12, wrap: true, rowGap: 12, w: 1000 }); ["① Fondations", "② Composants", "③ Écrans · Desktop 1440", "④ Écrans · Mobile 390", "⑤ Références live"].forEach((p, i) => add(pages, chip(p, i === 0 ? "on" : "default", { h: 42 }))); add(inner, pages);1076 add(inner, spacer(1, 56));1077 add(inner, T(`Généré le ${new Date().toISOString().slice(0, 10)} · Simon-Pierre Boucher — contact@spboucher.ai · source : repo lou-ka (spbgit) frontend/src/ka/tokens.css + styles.css · captures www.lou-ka.com`, { fam: "mono", size: 11.5, color: C.ink3, ls: 0.02, lhp: 140 }));1078 add(f, inner); inner.x = 120; inner.y = 110;1079 const gk = gkBadge(); add(f, gk); gk.x = 1920 - 120 - gk.width; gk.y = 130;1080 const fab = kaAgentFab(); add(f, fab); fab.x = 1920 - 120 - 64; fab.y = 1080 - 120 - 64;1081 page.appendChild(f); return f;1082}10831084function pageReferences() {1085 const root = box({ dir: "V", gap: 64, pad: 96, fill: C.paper, name: "⑤ Références — captures du site en production (www.lou-ka.com)" });1086 add(root, sectionTitle("Captures du site déployé", "Au-dessus du pli, desktop 1440×900 et mobile 390×844 (rendu @2x), capturées sur www.lou-ka.com au moment de la génération. Les captures pleine page sont dans references/full/ (à glisser directement dans Figma)."));1087 const shots = R.filter((r) => r.kind === "shot");1088 if (!shots.length) { add(root, T("Aucune capture embarquée (lancer shots.mjs puis build.mjs).", { size: 14, color: C.ink3 })); return root; }1089 const groups = {}; shots.forEach((s) => { const key = s.name.replace(/^(desktop|mobile)-/, ""); (groups[key] = groups[key] || []).push(s); });1090 const order = ["accueil", "logement", "stats", "villes", "court-terme"];1091 const titles = { accueil: "Accueil /", logement: "Fiche logement /logement/:uid", stats: "Statistiques /stats", villes: "Villes /villes", "court-terme": "Court terme /court-terme" };1092 for (const key of order.concat(Object.keys(groups).filter((k) => !order.includes(k)))) {1093 if (!groups[key]) continue;1094 const g = box({ dir: "V", gap: 16, name: key });1095 add(g, T(titles[key] || key, { fam: "display", w: 700, size: 22, color: C.ink, ls: -0.02, lhp: 120 }));1096 const row = box({ dir: "H", gap: 40, align: "MIN" });1097 for (const s of groups[key].sort((a) => (a.name.startsWith("desktop") ? -1 : 1))) {1098 const scale = s.name.startsWith("desktop") ? 1440 / s.w : 390 / s.w;1099 const wrap = box({ dir: "V", gap: 8, name: s.name });1100 const r = figma.createRectangle(); r.resize(Math.round(s.w * scale), Math.round(s.h * scale)); r.cornerRadius = 12; r.strokes = [paint(C.ink)]; r.strokeWeight = 1.5; r.effects = [SH.offSoft];1101 const hash = imageHash(s.name); r.fills = hash ? [{ type: "IMAGE", scaleMode: "FILL", imageHash: hash }] : [paint(C.surface2)]; r.name = s.name;1102 add(wrap, r); add(wrap, T(`${s.name} · ${Math.round(s.w * scale)}×${Math.round(s.h * scale)}`, { fam: "mono", size: 10.5, color: C.ink3, lhp: 120 })); add(row, wrap);1103 }1104 add(g, row); add(root, g);1105 }1106 return root;1107}11081109// ============================================================================1110// 9. ORCHESTRATION1111// ============================================================================1112async function setPage(page) { if (figma.setCurrentPageAsync) await figma.setCurrentPageAsync(page); else figma.currentPage = page; }1113function layoutRow(page, nodes, gap = 160) { let x = 0; for (const n of nodes) { page.appendChild(n); n.x = x; n.y = 0; x += n.width + gap; } }1114async function main() {1115 figma.notify("Lou-Ka · chargement des polices…", { timeout: 2000 });1116 await loadFonts();1117 const pInit = figma.currentPage; pInit.name = "⓪ Couverture";1118 const p1 = figma.createPage(); p1.name = "① Fondations";1119 const p2 = figma.createPage(); p2.name = "② Composants";1120 const p3 = figma.createPage(); p3.name = "③ Écrans · Desktop 1440";1121 const p4 = figma.createPage(); p4.name = "④ Écrans · Mobile 390";1122 const p5 = figma.createPage(); p5.name = "⑤ Références live";1123 await setPage(pInit); pageCouverture(pInit);1124 figma.notify("① Fondations…", { timeout: 1500 }); await setPage(p1); await buildStylesAndVariables(); p1.appendChild(pageFondations());1125 figma.notify("② Composants…", { timeout: 1500 }); await setPage(p2); p2.appendChild(pageComposants());1126 figma.notify("③ Écrans desktop…", { timeout: 1500 }); await setPage(p3); layoutRow(p3, [screenAccueil(1440, false), screenFiche(1440, false), screenStats(1440, false), screenVilles(1440, false)], 200);1127 figma.notify("④ Écrans mobile…", { timeout: 1500 }); await setPage(p4); layoutRow(p4, [screenAccueil(390, true), screenFiche(390, true), screenStats(390, true), screenVilles(390, true)].concat(screenMobileStates(390)), 120);1128 figma.notify("⑤ Références…", { timeout: 1500 }); await setPage(p5); p5.appendChild(pageReferences());1129 await setPage(pInit); figma.viewport.scrollAndZoomIntoView(pInit.children);1130 figma.notify("Fichier Figma Lou-Ka généré ✓ — 6 pages, styles, variables, composants et écrans.", { timeout: 6000 });1131 figma.closePlugin();1132}1133main().catch((e) => { console.error(e); figma.notify("Erreur : " + (e && e.message ? e.message : e), { error: true, timeout: 8000 }); figma.closePlugin(); });1134