SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
3 days agolast push
HTML 98.9% Python 0.6%
110.6 KB · 1,134 lines javascript
Raw Blame History
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