// ----------------------------------------------------------------------------- // Lou-Ka — Générateur de fichier Figma (plugin de développement) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Source de vérité : frontend/src/ka/tokens.css + frontend/src/styles.css du // repo lou-ka (déployé sur M4M64a:~/apps/lou-ka → www.lou-ka.com), relu le // 2026-09-04, et captures du site en production embarquées (REFS). // Le plugin construit dans le fichier Figma courant : // ⓪ Couverture ① Fondations (couleurs, typo, espacements, ombres, logo ; // styles + variables) ② Composants (jeux de variantes + blocs) // ③ Écrans desktop 1440 ④ Écrans mobile 390 ⑤ Références live // JavaScript pur, sans dépendance. `REFS` (captures + photos base64) est // préfixé par build.mjs → plugin/code.js. // ----------------------------------------------------------------------------- /* global figma, REFS */ const R = typeof REFS !== "undefined" ? REFS : []; // ============================================================================ // 1. JETONS (tokens.css + surcharge Lou-Ka dans styles.css) // ============================================================================ const C = { paper: "#f5f3ee", surface: "#ffffff", surface2: "#faf9f5", ink: "#141814", ink2: "#4d5551", ink3: "#8b928c", green: "#1c5c41", greenDeep: "#123f2e", greenSoft: "#e8f0ea", greenOk: "#2e7d4a", greenOkSoft: "#eef7f0", amber: "#e8a33d", amberSoft: "#fdf3e2", danger: "#b3423a", dangerSoft: "#fbe9e7", accent: "#ff6a00", accentSoft: "#fff1e6", accentDeep: "#cc5500", onAccent: "#ffffff", navy: "#0b1330", navy2: "#101a3d", white: "#ffffff", }; const LINE = 0.14, LINE_STRONG = 0.85; const RAD = { card: 10, ctl: 6, pill: 999, bar: 16, sheet: 24 }; const SP = [4, 8, 12, 16, 24, 32, 48, 64]; const FONT = { display: "Space Grotesk", body: "Inter", mono: "JetBrains Mono" }; const STYLE_CANDIDATES = { 400: ["Regular"], 500: ["Medium"], 600: ["SemiBold", "Semi Bold"], 700: ["Bold"] }; const FN = {}; function rgb(hex) { const h = hex.replace("#", ""); 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 }; } function paint(hex, opacity = 1) { return { type: "SOLID", color: rgb(hex), opacity }; } function rgba(hex, a) { return Object.assign({}, rgb(hex), { a }); } function shadow(x, y, blur, hex, a, spread = 0) { return { type: "DROP_SHADOW", color: rgba(hex, a), offset: { x, y }, radius: blur, spread, visible: true, blendMode: "NORMAL" }; } const SH = { flat: shadow(0, 1, 2, C.ink, 0.05), off: shadow(6, 6, 0, C.ink, 1), offSoft: shadow(8, 8, 0, C.ink, 0.08), offMid: shadow(4, 4, 0, C.ink, 0.18), hard4: shadow(4, 4, 0, C.ink, 1), accent3: shadow(3, 3, 0, C.accent, 1), fabBlur: shadow(0, 10, 28, C.ink, 0.25), }; // ============================================================================ // 2. POLICES // ============================================================================ async function tryLoad(family, style) { try { await figma.loadFontAsync({ family, style }); return { family, style }; } catch (e) { return null; } } async function loadFonts() { for (const fam of Object.values(FONT)) { FN[fam] = {}; for (const w of [400, 500, 600, 700]) { let got = null; for (const s of STYLE_CANDIDATES[w]) { got = await tryLoad(fam, s); if (got) break; } if (!got && fam !== FONT.body) for (const s of STYLE_CANDIDATES[w]) { got = await tryLoad(FONT.body, s); if (got) break; } if (!got) got = await tryLoad("Inter", "Regular"); FN[fam][w] = got; } } } function fn(famKey, w) { return FN[FONT[famKey]][w] || FN[FONT.body][400]; } // ============================================================================ // 3. PRIMITIVES // ============================================================================ function T(chars, o = {}) { const t = figma.createText(); t.fontName = fn(o.fam || "body", o.w || 400); t.characters = String(chars); t.fontSize = o.size || 15; t.fills = [paint(o.color || C.ink, o.op == null ? 1 : o.op)]; if (o.ls) t.letterSpacing = { unit: "PERCENT", value: o.ls * 100 }; if (o.lh) t.lineHeight = { unit: "PIXELS", value: o.lh }; else t.lineHeight = { unit: "PERCENT", value: o.lhp || (o.fam === "display" ? 110 : 155) }; if (o.upper) t.textCase = "UPPER"; if (o.align) t.textAlignHorizontal = o.align; if (o.width) { t.textAutoResize = "HEIGHT"; t.resize(o.width, 10); } else t.textAutoResize = "WIDTH_AND_HEIGHT"; if (o.name) t.name = o.name; return t; } function RT(segments, o = {}) { const t = T(segments.map((s) => s.text).join(""), Object.assign({}, o, { color: segments[0].color || o.color })); let i = 0; for (const s of segments) { const end = i + s.text.length; if (s.color) t.setRangeFills(i, end, [paint(s.color, s.op == null ? 1 : s.op)]); if (s.w || s.fam) t.setRangeFontName(i, end, fn(s.fam || o.fam || "body", s.w || o.w || 400)); if (s.size) t.setRangeFontSize(i, end, s.size); i = end; } return t; } function box(o = {}) { const f = o.component ? figma.createComponent() : figma.createFrame(); f.name = o.name || "Frame"; const dir = o.dir || "NONE"; f.layoutMode = dir === "H" ? "HORIZONTAL" : dir === "V" ? "VERTICAL" : "NONE"; if (dir !== "NONE") { f.itemSpacing = o.gap == null ? 0 : o.gap; const p = o.pad == null ? 0 : o.pad; const pa = Array.isArray(p) ? p : [p, p, p, p]; f.paddingTop = pa[0]; f.paddingRight = pa[1]; f.paddingBottom = pa[2]; f.paddingLeft = pa[3]; f.primaryAxisAlignItems = o.justify || "MIN"; f.counterAxisAlignItems = o.align || "MIN"; const wFixed = o.w != null, hFixed = o.h != null; if (dir === "H") { f.primaryAxisSizingMode = wFixed ? "FIXED" : "AUTO"; f.counterAxisSizingMode = hFixed ? "FIXED" : "AUTO"; } else { f.primaryAxisSizingMode = hFixed ? "FIXED" : "AUTO"; f.counterAxisSizingMode = wFixed ? "FIXED" : "AUTO"; } if (o.wrap) { f.layoutWrap = "WRAP"; f.counterAxisSpacing = o.rowGap == null ? (o.gap || 0) : o.rowGap; } } f.resize(o.w || 100, o.h || 100); f.fills = o.fill ? [typeof o.fill === "string" ? paint(o.fill, o.fillOp == null ? 1 : o.fillOp) : o.fill] : []; if (o.stroke) { f.strokes = [paint(o.stroke, o.strokeOp == null ? 1 : o.strokeOp)]; f.strokeWeight = o.sw == null ? 1.5 : o.sw; f.strokeAlign = "INSIDE"; if (o.strokeSides) { f.strokeTopWeight = o.strokeSides[0]; f.strokeRightWeight = o.strokeSides[1]; f.strokeBottomWeight = o.strokeSides[2]; f.strokeLeftWeight = o.strokeSides[3]; } if (o.dash) f.dashPattern = o.dash; } if (o.radius != null) f.cornerRadius = o.radius; if (o.radii) { f.topLeftRadius = o.radii[0]; f.topRightRadius = o.radii[1]; f.bottomRightRadius = o.radii[2]; f.bottomLeftRadius = o.radii[3]; } if (o.shadow) f.effects = Array.isArray(o.shadow) ? o.shadow : [o.shadow]; f.clipsContent = !!o.clip; if (o.op != null) f.opacity = o.op; return f; } function add(parent, child, opts = {}) { parent.appendChild(child); if (parent.layoutMode && parent.layoutMode !== "NONE") { if (opts.fillW) child.layoutSizingHorizontal = "FILL"; if (opts.fillH) child.layoutSizingVertical = "FILL"; if (opts.grow) child.layoutGrow = 1; } if (opts.x != null) child.x = opts.x; if (opts.y != null) child.y = opts.y; return child; } /** Enfant positionné en absolu dans un auto-layout (barres fixes, FAB…) */ function pin(parent, child, x, y) { parent.appendChild(child); child.layoutPositioning = "ABSOLUTE"; child.x = x; child.y = y; return child; } function rect(w, h, fill, o = {}) { const r = figma.createRectangle(); r.resize(w, h); r.fills = fill ? [typeof fill === "string" ? paint(fill, o.op == null ? 1 : o.op) : fill] : []; if (o.radius != null) r.cornerRadius = o.radius; 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; } if (o.shadow) r.effects = Array.isArray(o.shadow) ? o.shadow : [o.shadow]; r.name = o.name || "Rect"; return r; } function ellipse(d, fill, o = {}) { const e = figma.createEllipse(); e.resize(d, d); e.fills = fill ? [paint(fill, o.op == null ? 1 : o.op)] : []; if (o.stroke) { e.strokes = [paint(o.stroke, o.strokeOp == null ? 1 : o.strokeOp)]; e.strokeWeight = o.sw || 1.5; } if (o.shadow) e.effects = Array.isArray(o.shadow) ? o.shadow : [o.shadow]; e.name = o.name || "Dot"; return e; } function spacer(w, h) { return box({ w: w || 1, h: h || 1, name: "spacer" }); } function hr(w, op = LINE, h = 1) { return rect(w, h, C.ink, { op, name: "filet" }); } function svg(markup, name) { const n = figma.createNodeFromSvg(markup); n.name = name || "svg"; return n; } function gradient(stops, angleDeg = 90) { const a = (angleDeg * Math.PI) / 180; const dx = Math.cos(a) / 2, dy = Math.sin(a) / 2; 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]) })) }; } const IMG = {}; function imageHash(name) { if (IMG[name]) return IMG[name]; const ref = R.find((r) => r.name === name); if (!ref) return null; try { IMG[name] = figma.createImage(figma.base64Decode(ref.b64)).hash; return IMG[name]; } catch (e) { return null; } } const PHOTOS = R.filter((r) => r.kind === "photo").map((r) => r.name); let photoIdx = 0; function photo(w, h, o = {}) { const r = figma.createRectangle(); r.resize(w, h); r.name = o.name || "photo"; const name = o.photo || (PHOTOS.length ? PHOTOS[photoIdx++ % PHOTOS.length] : null); const hash = name ? imageHash(name) : null; r.fills = hash ? [{ type: "IMAGE", scaleMode: "FILL", imageHash: hash }] : [gradient([[0, "#e9e4d8"], [1, "#cfc7b6"]], 135)]; if (o.radius != null) r.cornerRadius = o.radius; return r; } // ============================================================================ // 4. ICÔNES + LOGO // ============================================================================ const ICO = { search: (c) => ``, camera: (c) => ``, heart: (c, filled) => ``, list: (c) => ``, map: (c) => ``, chev: (c) => ``, sliders: (c) => ``, home: (c) => ``, pin: (c) => ``, user: (c) => ``, doc: (c) => ``, check: (c) => ``, spark: (c) => ``, arrow: (c) => ``, x: (c) => ``, door: (c) => ``, bolt: (c) => ``, paw: (c) => ``, bed: (c) => ``, down: (c) => ``, up: (c) => ``, approx: (c) => ``, refresh: (c) => ``, download: (c) => ``, chevR: (c) => ``, tools: (c) => ``, }; function ico(name, color, size = 16, arg) { const n = svg(ICO[name](color, arg), `ico/${name}`); n.resize(size, size); return n; } function logoSvg(dark) { const doorTop = dark ? "#ffffff" : "#101a3d", doorBot = dark ? "#e8edf8" : "#0b1330"; return ` `; } function logoIcon(size, dark) { const n = svg(logoSvg(dark), "Logo · icône"); n.resize(size, size); return n; } /** Wordmark « Lou-Ka » — rendu actuel : « Lou- » encre, « Ka » dans une boîte orange inclinée (−2°). */ function logo(size = 30, o = {}) { const ts = o.textSize || Math.round(size * 0.83); const f = box({ dir: "H", gap: Math.round(size * 0.4), align: "CENTER", name: "Logo Lou-Ka" }); add(f, logoIcon(size, o.dark)); const wm = box({ dir: "H", gap: 2, align: "CENTER", name: "wordmark" }); add(wm, T("Lou-", { fam: "display", w: 700, size: ts, color: o.dark ? C.paper : C.ink, ls: -0.03, lhp: 100 })); 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; add(ka, T("Ka", { fam: "display", w: 700, size: ts, color: C.white, ls: -0.03, lhp: 100 })); add(wm, ka); add(f, wm); return f; } // ============================================================================ // 5. COMPOSANTS // ============================================================================ function kicker(text, o = {}) { const f = box({ dir: "H", gap: 10, align: "CENTER", name: "Kicker", w: o.w }); add(f, rect(22, 2, o.color || C.green, { name: "tiret" })); 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 })); return f; } function 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)); } /** Bouton .btn — default | primary | accent | ghost | login */ function button(label, variant = "default", o = {}) { const v = { default: { bg: C.surface, fg: C.ink, stroke: C.ink }, primary: { bg: C.ink, fg: C.accent, stroke: C.ink }, accent: { bg: C.accent, fg: C.onAccent, stroke: C.ink, sh: SH.hard4 }, ghost: { bg: null, fg: C.ink, stroke: C.ink, strokeOp: LINE_STRONG, sw: 1 }, login: { bg: C.ink, fg: C.accent, stroke: C.ink }, }[variant]; 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, 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, name: o.name || `Bouton / ${variant}`, component: o.component }); 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); } if (o.icon) add(f, ico(o.icon, o.iconColor || v.fg, o.iconSize || 14)); add(f, T(label, { fam: "display", w: 700, size: o.size || 14, color: v.fg, lhp: 100, name: "label" })); if (o.chev) add(f, ico("chev", v.fg, 10)); return f; } /** Chip .chip (rendu actuel : filet fort 1 px, fond transparent, 38 px ; actif = encre + papier) */ function chip(label, state = "default", o = {}) { const v = { default: { bg: null, fg: C.ink, stroke: C.ink, strokeOp: LINE_STRONG, sw: 1 }, on: { bg: C.ink, fg: C.paper, stroke: C.ink, strokeOp: 1, sw: 1 }, accent: { bg: C.accent, fg: C.onAccent, stroke: C.ink, strokeOp: 1, sw: 1.5 }, hover: { bg: C.accentSoft, fg: C.accentDeep, stroke: C.ink, strokeOp: LINE_STRONG, sw: 1 }, }[state]; 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 }); if (o.icon) add(f, ico(o.icon, v.fg, 12)); 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" })); return f; } /** Micro-étiquette mono (.st-pill / .chip tokens.css) — kind : observed | included | estimated | unknown | ink */ function stPill(label, kind = "observed") { 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]; 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}` }); add(f, T(label, { fam: "mono", w: 700, size: 9.5, color: v.fg, ls: 0.06, upper: true, lhp: 140 })); return f; } function badge(text, kind = "type", o = {}) { if (kind === "fav") { 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 }); add(f, ico("heart", o.on ? C.accent : C.ink, 16, !!o.on)); return f; } const v = { type: { bg: C.accent, fg: C.white }, photos: { bg: C.white, fg: C.ink }, reco: { bg: C.ink, fg: C.accent } }[kind]; 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 }); if (kind === "photos") add(f, ico("camera", v.fg, 12)); add(f, T(text, { w: 700, size: 11, color: v.fg, ls: 0.04, lhp: 120, name: "label" })); return f; } /** FairValueBadge — verdict : sous | marche | sur ; pct ex. « +193 % » */ function fvBadge(verdict = "marche", pct, compact = false) { 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]; 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}` }); add(f, ico(v.ic, v.fg, 10)); const txt = compact ? (pct || v.label) : (pct ? `${pct} vs le secteur · ${v.label}` : v.label); add(f, T(txt, { w: 700, size: compact ? 11 : 12.5, color: v.fg, lhp: 120 })); return f; } function kaTint(s) { return s == null ? C.ink3 : s >= 70 ? C.green : s >= 55 ? "#5c8a2e" : s >= 40 ? C.amber : C.danger; } function kaBadge(score) { const tint = kaTint(score); 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" }); 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 })); add(f, k); add(f, T(String(score), { fam: "display", w: 700, size: 12, color: tint, lhp: 120 })); return f; } function sourceTag(text) { const f = box({ dir: "H", pad: [3, 10, 3, 10], align: "CENTER", fill: C.greenSoft, radius: RAD.pill, name: "Source" }); add(f, T(text, { w: 700, size: 10, color: C.green, ls: 0.06, upper: true, lhp: 120, name: "label" })); return f; } /** Champ de recherche boîte (.f-search — feuille mobile) */ function input(placeholder, o = {}) { 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 }); add(f, ico("search", o.focus ? C.accentDeep : C.ink2, 18)); add(f, T(o.value || placeholder, { size: 14.5, color: o.value ? C.ink : C.ink3, lhp: 120, name: "placeholder" }), { grow: true }); 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); } return f; } /** Grande recherche soulignée (.q-big) */ function qBig(w, mobile, o = {}) { 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" }); add(f, ico("search", o.focus ? C.accentDeep : C.ink3, mobile ? 22 : 26)); 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 }); return f; } /** Critère de la ligne de recherche (.crit) */ function crit(label, value, o = {}) { 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}` }); add(f, T(label, { fam: "mono", w: 700, size: 9.5, color: C.ink3, ls: 0.14, upper: true, lhp: 140 })); const r = box({ dir: "H", gap: 10, align: "CENTER" }); 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)); add(f, r); return f; } function fctl(label, value, o = {}) { 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 }); add(f, T(label, { fam: "mono", w: 700, size: 9.5, color: C.ink3, ls: 0.1, upper: true, lhp: 120, name: "label" })); 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); return f; } function field(label, value, o = {}) { const f = box({ dir: "V", gap: 5, w: o.w || 260, name: o.name || "Champ / natif", component: o.component }); add(f, klabel(label, { size: 10.5 })); 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" }); add(b, T(value, { size: 15, color: o.placeholder ? C.ink3 : C.ink, lhp: 120 })); if (o.select) add(b, ico("chev", C.ink, 10)); add(f, b, { fillW: true }); return f; } function segments(items, onIdx = 0) { const f = box({ dir: "H", gap: -1, name: "Segments" }); items.forEach((it, i) => { const on = i === onIdx; const first = i === 0, last = i === items.length - 1; 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}` }); add(s, T(it, { fam: "display", w: 600, size: 13, color: on ? C.paper : C.ink2, lhp: 100 })); add(f, s); }); return f; } function pill(label, clear) { 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"}` }); 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; } function gkBadge(dark) { 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" }); 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 })); const b = box({ dir: "H", gap: 3, align: "CENTER", name: "Groupe KA" }); add(b, T("Groupe", { fam: "display", w: 700, size: 12, color: dark ? C.paper : C.ink, ls: -0.02, lhp: 120 })); 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); return f; } function kvCell(k, v, w) { const f = box({ dir: "V", gap: 2, pad: [10, 14, 10, 14], w: w || 200, fill: C.surface2, radius: RAD.ctl, name: `KV / ${k}` }); 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; } /** Titre de bloc (.f-bloc h2 : tiret accent + texte + filet) */ function blocTitle(text, w) { const f = box({ dir: "H", gap: 12, align: "CENTER", w, name: "Titre de bloc" }); add(f, rect(22, 3, C.accent, { radius: 2 })); add(f, T(text, { fam: "display", w: 700, size: 21, color: C.ink, ls: -0.02, lhp: 120 })); if (w) add(f, hr(20), { grow: true }); return f; } function chipKey(label, icon) { 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}` }); add(f, ico(icon || "spark", C.accentDeep, 13)); add(f, T(label, { w: 600, size: 12, color: C.ink, lhp: 100 })); return f; } function amenityRow(label, confirmed, w) { 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}` }); 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)); 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; } function kaCircle(score, nom) { const tint = kaTint(score); const c = 2 * Math.PI * 26; const part = score == null ? 0 : Math.min(1, score / 100); const s = svg(``, "jauge"); const f = box({ dir: "V", gap: 6, align: "CENTER", name: `KA / ${nom}` }); const wrap = box({ w: 64, h: 64, name: "cercle" }); add(wrap, s); 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; add(f, wrap); add(f, klabel(nom, { size: 10.5, color: C.ink2 })); return f; } /** Bouton flottant de l'agent conversationnel KA (ka-agent.js) */ function kaAgentFab(size = 64) { 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" }); add(f, T("Ka", { fam: "display", w: 700, size: Math.round(size * 0.36), color: C.white, ls: -0.02, lhp: 100 })); return f; } /** Sparkline orange (KPI stats) */ function sparkline(w, h, pts, o = {}) { const n = pts.length; const step = w / (n - 1); const coords = pts.map((p, i) => [i * step, h - p * (h - 2) - 1]); const d = coords.map((c, i) => `${i ? "L" : "M"}${c[0].toFixed(1)} ${c[1].toFixed(1)}`).join(" "); const area = `${d} L${w} ${h} L0 ${h} Z`; return svg(``, "sparkline"); } /** Jauge semi-circulaire (page Stats) */ function gauge(pct, label, sub, w = 220) { const r = 92, sw = 14, cx = 110, cy = 104; const ang = Math.PI * Math.min(1, pct / 100); const ex = cx + r * Math.cos(Math.PI - ang), ey = cy - r * Math.sin(Math.PI - ang) * 1 + 0; // point d'arrivée const large = ang > Math.PI / 2 ? 1 : 0; const arc = `M${cx - r} ${cy} A${r} ${r} 0 0 1 ${cx + r} ${cy}`; const val = `M${cx - r} ${cy} A${r} ${r} 0 ${large} 1 ${ex.toFixed(1)} ${(cy - r * Math.sin(ang)).toFixed(1)}`; const s = svg(``, "arc"); const f = box({ dir: "V", gap: 10, align: "CENTER", w, name: `Jauge / ${label}` }); const wrap = box({ w: 220, h: 112, name: "jauge" }); add(wrap, s); 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; 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; add(f, wrap); add(f, klabel(label, { size: 10.5, color: C.ink2, align: "CENTER", width: w, ls: 0.12 })); return f; } // ---- Carte d'annonce ---------------------------------------------------------- function listingCard(d, o = {}) { const w = o.w || 353; 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 }); const imgH = Math.round((w * 10.5) / 16); const img = box({ w, h: imgH, fill: C.surface2, clip: true, name: "card-img" }); add(img, photo(w, imgH, { photo: d.photo, name: "photo" })); add(img, badge(d.type || "4½", "type", { name: "type" }), { x: 12, y: 12 }); if (d.nPhotos) { const b = badge(String(d.nPhotos), "photos", { name: "nphotos" }); add(img, b); b.x = w - 12 - b.width; b.y = 12; } if (d.reco) { const b = badge("Recommandé pour vous", "reco", { name: "reco" }); add(img, b); b.x = 12; b.y = imgH - 12 - b.height; } const fav = badge("", "fav", { on: d.fav }); add(img, fav); fav.x = w - 10 - 34; fav.y = imgH - 10 - 34; add(card, img); const body = box({ dir: "V", gap: 6, pad: [16, 18, 16, 18], w, name: "card-body" }); const pr = box({ dir: "H", gap: 8, align: "CENTER", name: "prix-ligne" }); 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" })); if (d.fv) add(pr, fvBadge(d.fv, d.pct, true)); add(body, pr); add(body, T(d.title, { w: 600, size: 14.5, color: C.ink, lhp: 130, width: w - 36, name: "titre" })); const meta = box({ dir: "H", gap: 7, align: "CENTER", name: "meta" }); if (d.sector) { add(meta, T(d.sector, { size: 12.5, color: C.ink3, lhp: 120, name: "secteur" })); add(meta, ellipse(4, C.accent)); } add(meta, T(d.city, { size: 12.5, color: C.ink3, lhp: 120, name: "ville" })); if (d.ks) add(meta, kaBadge(d.ks)); add(body, meta); 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" }); add(foot, sourceTag(d.source)); add(foot, T(d.avail || "", { w: 500, size: 11.5, color: C.ink2, lhp: 120, align: "RIGHT", name: "dispo" })); add(body, foot); add(card, body); return card; } const SAMPLE_CARDS = [ { 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 %" }, { 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 %" }, { 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 %" }, { 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 }, { 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 %" }, { 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 }, ]; // ---- Header / ticker / barres --------------------------------------------------- function header(o = {}) { const mobile = !!o.mobile; const w = o.w || 1440; const h = mobile ? 56 : 64; 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 }); const brand = box({ dir: "H", gap: 12, align: "CENTER", name: "brand" }); add(brand, logo(mobile ? 28 : 30, { textSize: mobile ? 23 : 25 })); 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" })); add(f, brand); if (!mobile) add(f, gkBadge()); add(f, spacer(1, 1), { grow: true }); if (!mobile) { const nav = box({ dir: "H", gap: 4, align: "CENTER", name: "nav" }); ["Logements", "Court terme", "Stats", "Sources", "Déménageurs"].forEach((n, i) => { const on = i === (o.active == null ? 0 : o.active); 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}` }); add(a, T(n, { fam: "display", w: 600, size: 14, color: on ? C.accent : C.ink, lhp: 100 })); add(nav, a); }); add(f, nav); } add(f, button("Connexion", "login", { h: 38, size: 13.5, px: 16 })); 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); } return f; } function ticker(w, mobile) { 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" }); ["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) => { const s = box({ dir: "H", gap: 26, pad: [0, 13, 0, 13], align: "CENTER", name: "item" }); 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 })); add(s, T("◆", { size: 7, color: C.accent, op: 0.8, lhp: 100 })); add(f, s); }); return f; } /** Tabbar mobile flottante (rendu actuel : carte blanche à bord encre, actif = encre + tiret accent) */ function tabbar(w, active = 0) { 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" }); [["search", "Rechercher"], ["map", "Carte"], ["heart", "Favoris"], ["user", "Profil"]].forEach(([i, l], k) => { const on = k === active; const col = on ? C.ink : C.ink3; const t = box({ dir: "V", gap: 3, pad: [4, 2, 2, 2], h: 56, align: "CENTER", justify: "CENTER", radius: 12, name: `tab / ${l}` }); add(t, ico(i, col, 22)); add(t, T(l, { fam: "display", w: 600, size: 10.5, color: col, lhp: 120 })); if (on) add(t, rect(16, 2.5, C.accent, { radius: 2 })); add(f, t, { grow: true }); }); return f; } function footer(w, o = {}) { const mobile = w < 700; 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 }); const iw = Math.min(w - (mobile ? 32 : 48), 1104); const inner = box({ dir: "V", w: iw, name: "container" }); add(inner, RT([{ text: "Groupe ", color: C.paper }, { text: "KA", color: C.accent }], { fam: "display", w: 700, size: 30, ls: -0.04, lhp: 100 })); add(inner, spacer(1, 16)); 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 })); add(inner, spacer(1, 16)); 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" }); 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 })); add(inner, notice); add(inner, spacer(1, 26)); const sites = box({ dir: "H", gap: 26, rowGap: 10, wrap: true, w: iw, name: "sites" }); ["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 }))); add(inner, sites); add(inner, spacer(1, 30)); add(inner, rect(iw, 1, C.paper, { op: 0.15 })); add(inner, spacer(1, 26)); const contacts = box({ dir: mobile ? "V" : "H", gap: mobile ? 14 : 32, w: iw, name: "contacts" }); [["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]) => { 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 }); }); add(inner, contacts); add(inner, spacer(1, 30)); 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 })); add(f, inner); return f; } // ============================================================================ // 6. BLOCS D'ÉCRAN // ============================================================================ function innerW(w, mobile) { return Math.min(w - (mobile ? 32 : 48), 1104); } function section(w, mobile, children, o = {}) { 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" }); children.forEach((ch) => add(c, ch)); return c; } /** Trait de pinceau orange sous « chez-vous. » (SVG .brush::after, viewBox 300×26 remis à l'échelle) */ function brush(W, H) { const sx = W / 300, sy = H / 26; const p = (x, y) => `${(x * sx).toFixed(1)} ${(y * sy).toFixed(1)}`; const d = `M${p(5, 17)} C ${p(60, 8)}, ${p(118, 21)}, ${p(168, 13)} S ${p(258, 10)}, ${p(295, 15)}`; const n = svg(``, "brush"); n.rotation = -0.6; return n; } function hero(w, mobile) { const iw = innerW(w, mobile); const f = box({ dir: "V", w: iw, pad: [mobile ? 36 : 64, 0, mobile ? 14 : 26, 0], name: "Héro" }); add(f, kicker("Location — tout le Québec, un seul endroit", { w: mobile ? iw : undefined })); add(f, spacer(1, 18)); const h1 = mobile ? 42 : 88; add(f, T(mobile ? "Trouvez votre\nprochain" : "Trouvez votre prochain", { fam: "display", w: 700, size: h1, color: C.ink, ls: -0.045, lhp: 100 })); const l2wrap = box({ name: "chez-vous (brush)" }); const l2 = T("chez-vous.", { fam: "display", w: 700, size: h1, color: C.ink, ls: -0.045, lhp: 100 }); const br = brush(l2.width * 1.06, h1 * 0.34); 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; add(f, l2wrap); add(f, spacer(1, 18)); 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 })); add(f, spacer(1, 34)); const live = box({ dir: "H", align: "CENTER", wrap: !mobile, rowGap: 10, w: iw, clip: mobile, name: "live-line" }); 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" }); 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); [["47 425", " logements indexés"], ["301", " sources"], ["", "synchro il y a 6 s"], ["1824 $", " loyer moyen"]].forEach(([b, t], i, arr) => { 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" }); 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); }); add(f, live); return f; } /** Zone de recherche actuelle (.search-zone) : q-big + ligne de critères (desktop) ou résumé (mobile) */ function searchZone(w, mobile, o = {}) { const iw = innerW(w, mobile); const f = box({ dir: "V", w: iw, name: "Zone de recherche (.search-zone)" }); add(f, qBig(iw, mobile)); if (mobile) { 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" }); 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); } else { const line = box({ dir: "H", gap: 26, w: iw, align: "CENTER", name: "crit-line" }); add(line, crit("Ville", "Toutes")); add(line, crit("Quartier", "Tous")); add(line, crit("Loyer", "Min — Max")); 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); add(f, line); } return f; } function quickChips(iw, mobile) { const f = box({ dir: "H", gap: 8, w: iw, clip: true, pad: [20, 0, 0, 0], name: "chips (filtres rapides)" }); ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"].forEach((c, i) => add(f, chip(c, i === 3 && !mobile ? "on" : "default", { upper: true, size: 12.5 }))); 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 })); } return f; } function pillsRow(iw) { const f = box({ dir: "H", gap: 8, wrap: true, rowGap: 8, w: iw, pad: [14, 0, 0, 0], name: "filtres actifs (.pills)" }); ["Montréal", "4½", "≤ 2 500 $", "Animaux acceptés"].forEach((p) => add(f, pill(p))); add(f, pill("Tout effacer", true)); return f; } function resultsBar(iw, mobile, o = {}) { 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)" }); add(f, T(`${o.count || "47 425"} logements`, { fam: "display", w: 700, size: mobile ? 17 : 20, color: C.ink, ls: -0.02, lhp: 120 })); add(f, spacer(1, 1), { grow: true }); const tabs = box({ dir: "H", gap: 18, align: "CENTER", name: "rb-tabs" }); [["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); }); add(f, tabs); if (mobile) add(f, ico("sliders", C.ink, 18)); 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); } return f; } function cardGrid(iw, mobile, cards) { const cols = mobile ? 1 : 3; const gap = mobile ? 16 : 22; const cw = Math.floor((iw - gap * (cols - 1)) / cols); const g = box({ dir: "H", gap, rowGap: gap, wrap: true, w: iw, name: "Grille de résultats (.grid)" }); cards.forEach((d) => add(g, listingCard(d, { w: cw }))); return g; } function pager(iw) { const f = box({ dir: "H", gap: 6, w: iw, justify: "CENTER", align: "CENTER", name: "Pagination" }); ["‹", "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); }); return f; } // ---- Fiche logement (annonce réelle capturée : 719 5e Avenue, Montréal) ---------- const FICHE = { price: "2 580 $", title: "719 5E Avenue", loc: "Verdun · Île-des-Sœurs · Montréal", pct: "+193 %", verdict: "sur", chips: [["5½", "door"], ["3 chambres", "bed"], ["Rénové", "spark"], ["Stationnement", "spark"], ["Dès maintenant", "bolt"]], source: "royal_lepage", sourceName: "Royal LePage", nPhotos: 33, incl: [["Électricité", 1], ["Internet", 1], ["Électroménagers", 1], ["Rénové", 0], ["Stationnement", 0], ["Cour arrière", 0]], 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"]], }; function gallery(w, mobile) { const f = box({ dir: "V", gap: 10, w, name: "Galerie (.carousel + .thumbs)" }); const mh = Math.round(w * 10 / 14.4); const main = box({ w, h: mh, stroke: C.ink, radius: RAD.card, shadow: SH.offSoft, clip: true, name: "carousel" }); add(main, photo(w, mh, { photo: PHOTOS[0] })); 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; 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; } add(f, main); const cols = mobile ? 4 : 7; const gap = 8; const tw = Math.floor((w - gap * (cols - 1)) / cols); const th = box({ dir: "H", gap, rowGap: gap, wrap: !mobile, w, clip: true, name: "thumbs" }); const n = mobile ? 5 : 14; 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); } add(f, th); return f; } function ficheHero(w, mobile, o = {}) { const pad = o.flat ? 0 : (mobile ? 20 : 26); 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)" }); const cw = w - pad * 2; 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 })); add(f, spacer(1, 12)); add(f, fvBadge(FICHE.verdict, FICHE.pct)); 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 })); add(f, spacer(1, 6)); add(f, T(FICHE.loc, { size: 14, color: C.ink2, width: cw, lhp: 150 })); add(f, spacer(1, 14)); 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); add(f, spacer(1, 8)); const an = box({ dir: "H", gap: 18, w: cw, stroke: C.ink, strokeOp: LINE, sw: 1, strokeSides: [0, 0, 1, 0], name: "ancres" }); ["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); }); add(f, an); add(f, spacer(1, 16)); add(f, button(`Voir l'annonce chez ${FICHE.sourceName}`, "accent", { h: 50, size: 15, icon: "arrow", name: "CTA / voir l'annonce (.cta)" }), { fillW: true }); add(f, spacer(1, 10)); add(f, button("Télécharger la fiche (PDF)", "ghost", { icon: "doc" }), { fillW: true }); add(f, spacer(1, 14)); 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 })); return f; } function ficheBloc(title, w, build) { const f = box({ dir: "V", gap: 14, w, name: `Bloc / ${title}` }); add(f, blocTitle(title, w)); build(f); return f; } function 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 }); } function crRow(label, pillKind, pillLabel, value, w, o = {}) { 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}` }); add(r, T(label, { w: o.total ? 700 : 400, size: 13.5, color: o.total ? C.ink : C.ink2, lhp: 120 })); if (pillKind) add(r, stPill(pillLabel, pillKind)); add(r, spacer(1, 1), { grow: true }); add(r, T(value, { fam: "mono", w: 700, size: 13.5, color: C.ink, lhp: 120 })); return r; } function coutReel(w) { const c = whiteCard(w, "Coût réel mensuel"); const cw = w - 44; add(c, blocTitle("Coût réel mensuel", cw)); [["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 }))); add(c, crRow("Total estimé", null, null, "≈ 2 580 $ /mois", cw, { total: true })); 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); 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" }); 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); 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 })); return c; } function historique(w) { const c = whiteCard(w, "Historique Lou-Ka"); const cw = w - 44; add(c, blocTitle("Historique Lou-Ka", cw)); const g = box({ dir: "H", gap: 20, rowGap: 14, wrap: true, w: cw, name: "faits" }); const gw = (cw - 20) / 2; [["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); }); add(c, g); 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 })); return c; } function priceAnalysis(w) { const c = whiteCard(w, "Analyse de prix Lou-Ka"); const cw = w - 44; add(c, blocTitle("Analyse de prix Lou-Ka", cw)); 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 })); 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); 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" }); [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 })); add(c, hist); 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); 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); return c; } function mapBlock(w, h, o = {}) { 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)" }); const water = rect(w, 54, "#1c5c41", { op: 0.55, name: "eau" }); add(f, water); water.y = h - 74; [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; }); [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; }); 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); } const pinW = box({ dir: "V", align: "CENTER", name: "marqueur prix" }); 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); add(pinW, svg(``, "pointe")); add(f, pinW); pinW.x = w / 2 - pinW.width / 2; pinW.y = h / 2 - pinW.height; 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; return f; } function kaScoresBlock(w) { const c = whiteCard(w, "KA Scores"); const cw = w - 44; 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); 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 })); 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); return c; } function quartierBlock(w) { const c = whiteCard(w, "Le quartier"); const cw = w - 44; add(c, blocTitle("Le quartier — Verdun", cw)); const g = box({ dir: "H", gap: 10, rowGap: 10, wrap: true, w: cw, name: "stats quartier" }); const kw = (cw - 10) / 2; [["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); const pois = box({ dir: "V", gap: 2, w: cw, name: "À proximité" }); add(pois, klabel("À proximité")); [["É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]) => { 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] }); 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); }); add(c, pois); return c; } function crumbs(iw) { const f = box({ dir: "H", gap: 10, align: "CENTER", w: iw, name: "Fil d'Ariane (.crumbs)" }); ["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; } // ---- Stats (rendu actuel : grands chiffres + sparklines, période, jauges) ----------- function kpi(value, label, w, o = {}) { const f = box({ dir: "V", gap: 12, w, name: `KPI / ${label}` }); 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 })); add(f, klabel(label, { size: 10.5, ls: 0.12, width: w - 20 })); if (o.spark) add(f, sparkline(Math.min(140, w - 40), 40, o.spark)); return f; } function periodChips(iw, mobile) { const f = box({ dir: "V", gap: 12, w: iw, name: "Période" }); const row = box({ dir: "H", gap: 10, rowGap: 10, wrap: true, w: iw }); ["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 }))); add(f, row); const d = box({ dir: "H", gap: 12, align: "CENTER" }); [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 })); }); add(f, d); return f; } function areaChart(w, h) { 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]; const f = box({ w, h, name: "Annonces actives par jour" }); for (let i = 0; i < 5; i++) { const g = hr(w, 0.08); add(f, g); g.y = (h / 4) * i; } add(f, sparkline(w, h, pts, { fill: 0.14, sw: 2.5 })); return f; } // ============================================================================ // 7. ÉCRANS // ============================================================================ function screenFrame(name, w) { const s = box({ dir: "V", w, fill: C.paper, clip: true, name }); s.primaryAxisSizingMode = "AUTO"; s.counterAxisSizingMode = "FIXED"; return s; } function mobileStatus(w) { 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" }); 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; } function chrome(s, w, mobile, active) { if (mobile) add(s, mobileStatus(w)); add(s, header({ w, mobile, active })); add(s, ticker(w, mobile)); } function overlays(s, w, mobile, o = {}) { const H = mobile ? 844 : 900; if (mobile && o.tabbar !== false) { const tb = tabbar(w, o.tab || 0); pin(s, tb, 12, H - 14 - tb.height); } if (o.bottombar) pin(s, o.bottombar, 0, H - o.bottombar.height); const fab = kaAgentFab(mobile ? 60 : 64); pin(s, fab, w - (mobile ? 16 : 28) - fab.width, H - (mobile ? (o.bottombar ? 130 : 110) : 28) - fab.height); const mark = rect(w, 1.5, C.accent, { name: `repère pli ${H}` }); pin(s, mark, 0, H); mark.opacity = 0.7; } function screenAccueil(w, mobile) { const s = screenFrame(mobile ? "Accueil · mobile 390" : "Accueil · desktop 1440", w); const iw = innerW(w, mobile); chrome(s, w, mobile, 0); add(s, section(w, mobile, [hero(w, mobile)])); add(s, section(w, mobile, [searchZone(w, mobile, { n: 2 }), quickChips(iw, mobile)].concat(mobile ? [] : [pillsRow(iw)]), { pt: mobile ? 30 : 42 })); 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 })); add(s, footer(w)); overlays(s, w, mobile, { tab: 0 }); return s; } function screenFiche(w, mobile) { const s = screenFrame(mobile ? "Fiche logement · mobile 390" : "Fiche logement · desktop 1440", w); const iw = innerW(w, mobile); chrome(s, w, mobile, -1); const gap = 30; const colL = mobile ? iw : Math.round((iw - gap) * 1.6 / 2.6), colR = mobile ? iw : iw - gap - colL; const left = box({ dir: "V", gap: 26, w: colL, name: "f-col · principale (galerie → prix → description → inclusions → pratique)" }); add(left, gallery(colL, mobile)); add(left, ficheHero(colL, mobile, { flat: true })); add(left, ficheBloc("Description", colL, (b) => { 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" }); 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); 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 })); 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 })); })); add(left, ficheBloc("Inclusions et commodités", colL, (b) => { 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); 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 })); })); add(left, ficheBloc("Détails pratiques", colL, (b) => { 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); })); const right = box({ dir: "V", gap: 26, w: colR, name: "f-col · annexe (coût réel → historique → analyse → carte → scores → quartier)" }); add(right, coutReel(colR)); add(right, historique(colR)); add(right, priceAnalysis(colR)); 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); add(right, kaScoresBlock(colR)); add(right, quartierBlock(colR)); 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); add(s, section(w, mobile, [crumbs(iw), spacer(1, 22), fiche], { pt: mobile ? 22 : 30, pb: 90 })); add(s, footer(w)); let bar = null; if (mobile) { 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)" }); 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); add(bar, button("Voir chez royal_lepage", "accent", { h: 52, size: 15, icon: "arrow", name: "CTA" }), { grow: true }); } overlays(s, w, mobile, { tabbar: false, bottombar: bar }); return s; } function screenStats(w, mobile) { const s = screenFrame(mobile ? "Stats · mobile 390" : "Stats · desktop 1440", w); const iw = innerW(w, mobile); chrome(s, w, mobile, 2); const head = box({ dir: mobile ? "V" : "H", gap: 24, w: iw, align: "MIN", name: "stats-head" }); const left = box({ dir: "V", gap: 8, name: "titre" }); add(left, klabel("Groupe KA · Lou·Ka", { size: 10.5, ls: 0.14 })); 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 })); add(left, klabel("Période : 30 jours (2026-08-06 → 2026-09-04)", { size: 10.5, ls: 0.12 })); add(head, left, mobile ? {} : { grow: true }); const tools = box({ dir: "V", gap: 12, align: mobile ? "MIN" : "MAX", name: "stats-tools" }); const r1 = box({ dir: "H", gap: 12, wrap: mobile, rowGap: 10, w: mobile ? iw : undefined }); 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); 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); add(head, tools); const cols = mobile ? 2 : 6; const kw = Math.floor((iw - 24 * (cols - 1)) / cols); const grid = box({ dir: "H", gap: 24, rowGap: 44, wrap: true, w: iw, name: "KPI" }); 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]; [["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))); const gauges = box({ dir: "H", gap: 16, rowGap: 24, wrap: true, w: iw, justify: mobile ? "CENTER" : "SPACE_BETWEEN", name: "Qualité des données (jauges)" }); const gw = mobile ? (iw - 16) / 2 : Math.floor((iw - 16 * 4) / 5); [[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); }); 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); const chart = box({ dir: "V", gap: 10, w: iw, name: "Graphique" }); add(chart, areaChart(iw, mobile ? 160 : 240)); 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); const vizW = mobile ? iw : (iw - 22) / 2; const viz = box({ dir: "H", gap: 22, rowGap: 22, wrap: true, w: iw, name: "viz-grid" }); 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]]))); 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]]))); 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 })); add(s, footer(w)); overlays(s, w, mobile, { tabbar: false }); return s; } function vizCard(w, title, sub, build) { 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}` }); 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; } function barRow(label, value, ratio, w, color) { const f = box({ dir: "H", gap: 12, align: "CENTER", w, name: `barre / ${label}` }); add(f, T(label, { w: 600, size: 13, color: C.ink, width: 130, lhp: 120 })); 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); add(f, T(value, { fam: "mono", w: 700, size: 11.5, color: C.ink, width: 60, align: "RIGHT", lhp: 120 })); return f; } function 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); } function screenVilles(w, mobile) { const s = screenFrame(mobile ? "Villes · mobile 390" : "Villes · desktop 1440", w); const iw = innerW(w, mobile); chrome(s, w, mobile, 0); const head = box({ dir: "V", gap: 10, w: iw, name: "en-tête" }); add(head, kicker("Répertoire — logements par ville")); 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 })); 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 })); const cols = mobile ? 1 : 4; const cw = Math.floor((iw - 16 * (cols - 1)) / cols); const g = box({ dir: "H", gap: 16, rowGap: 16, wrap: true, w: iw, name: "villes" }); [["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) => { 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}` }); 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); add(c, RT([{ text: n, color: C.ink, w: 700 }, { text: " logements", color: C.ink3 }], { fam: "mono", size: 12, lhp: 120 })); 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); 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); }); add(s, section(w, mobile, [head, spacer(1, 30), g], { pt: 44, pb: 90 })); add(s, footer(w)); overlays(s, w, mobile, { tab: 0 }); return s; } function screenMobileStates(w) { const out = []; const m = screenFrame("Menu mobile ouvert · 390", w); m.resize(w, 844); m.primaryAxisSizingMode = "FIXED"; add(m, mobileStatus(w)); add(m, header({ w, mobile: true })); 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" }); [["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]) => { 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}` }); 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); }); 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); 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); out.push(m); const sh = screenFrame("Feuille de critères · 390", w); sh.resize(w, 844); sh.primaryAxisSizingMode = "FIXED"; add(sh, mobileStatus(w)); add(sh, header({ w, mobile: true })); add(sh, ticker(w, true)); add(sh, section(w, true, [hero(w, true)])); pin(sh, rect(w, 844, C.ink, { op: 0.45, name: "sheet-backdrop" }), 0, 0); 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)" }); add(sheet, rect(44, 5, C.ink, { op: LINE, radius: 999, name: "sheet-handle" })); 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); add(sheet, input("Où voulez-vous habiter ?", { w: w - 36, h: 46, value: "Montréal", clear: true })); 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 })); 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); add(sheet, field("KA Score minimum", "70 et +", { w: w - 36, select: true, transparent: true })); add(sheet, button("Voir 24 127 logements", "primary", { w: w - 36, h: 50, shadow: shadow(5, 5, 0, C.accent, 1), name: "sheet-apply" })); pin(sh, sheet, 0, 844 - sheet.height); out.push(sh); return out; } // ============================================================================ // 8. PAGES : COUVERTURE, FONDATIONS, COMPOSANTS, RÉFÉRENCES // ============================================================================ function 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; } function swatch(name, hex, note, op) { const f = box({ dir: "V", gap: 8, w: 168, name: `Couleur / ${name}` }); 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 })); add(f, T(name, { fam: "display", w: 700, size: 13, color: C.ink, lhp: 120 })); add(f, T(op == null ? hex.toUpperCase() : `${hex.toUpperCase()} · ${Math.round(op * 100)} %`, { fam: "mono", w: 500, size: 11, color: C.ink2, lhp: 120 })); if (note) add(f, T(note, { size: 11, color: C.ink3, width: 168, lhp: 140 })); return f; } const COLOR_TOKENS = [ ["Papier", "paper", C.paper, "--paper · fond de page"], ["Surface", "surface", C.surface, "--surface · cartes"], ["Surface 2", "surface2", C.surface2, "--surface-2 · champs, lignes paires"], ["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"], ["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é"], ["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"], ["Vert « inclus »", "greenOk", C.greenOk, ".st-included"], ["Marine (logo)", "navy", C.navy, "#0B1330 · porte du logo"], ["Ambre", "amber", C.amber, "--amber · KA Score moyen"], ["Ambre doux", "amberSoft", C.amberSoft, "--amber-soft"], ["Danger", "danger", C.danger, "--danger · retrait de filtre"], ["Danger doux", "dangerSoft", C.dangerSoft, "--danger-soft"], ]; const TYPE_TOKENS = [ ["Display / H1 héro", "display", 700, 88, -0.045, 100, "Space Grotesk Bold · clamp(44px, 7.2vw, 88px) · .hero-display"], ["Display / H1", "display", 700, 46, -0.03, 106, "Space Grotesk Bold · pages Stats/Villes clamp(30→46)"], ["Display / H2", "display", 700, 34, -0.03, 115, "--fs-h2 clamp(23→34)"], ["Display / Titre de bloc", "display", 700, 21, -0.02, 120, ".f-bloc h2 (tiret accent)"], ["Display / KPI", "display", 700, 44, -0.04, 100, "page Stats, grands chiffres"], ["Display / Prix fiche", "display", 500, 54, -0.04, 100, ".f-hero .price clamp(38→54)"], ["Display / Prix carte", "display", 700, 21, -0.02, 110, ".card-price"], ["Display / Recherche", "display", 500, 28, -0.02, 120, ".q-big input clamp(19→28)"], ["Display / Nav", "display", 600, 14, 0, 100, ".nav a"], ["Display / Bouton", "display", 700, 14, 0, 100, ".btn / .cta"], ["Display / Chip", "display", 700, 12.5, 0.04, 100, ".chip (MAJUSCULES sur les filtres rapides)"], ["Body / Lede", "body", 400, 16.5, 0, 155, "Inter · .hero .lede"], ["Body / Texte", "body", 400, 15, 0, 155, "--fs-body"], ["Body / Titre carte", "body", 600, 14.5, 0, 130, ".card-title"], ["Body / Petit", "body", 400, 13, 0, 155, "--fs-small"], ["Body / Méta", "body", 400, 12.5, 0, 120, ".card-meta"], ["Mono / Ticker", "mono", 500, 11.5, 0.08, 100, ".ticker · MAJUSCULES"], ["Mono / Kicker", "mono", 500, 11.5, 0.12, 140, ".kicker · MAJUSCULES"], ["Mono / Live", "mono", 500, 12, 0.02, 120, ".live-line"], ["Mono / Étiquette", "mono", 700, 10, 0.1, 140, ".klabel / .crit > span / .tile-k · MAJUSCULES"], ["Mono / Micro-pilule", "mono", 700, 9.5, 0.06, 140, ".st-pill · MAJUSCULES"], ]; async function buildStylesAndVariables() { for (const [name, , hex, note] of COLOR_TOKENS) { const s = figma.createPaintStyle(); s.name = `Lou-Ka / ${name}`; s.paints = [paint(hex)]; s.description = note; } const ln = figma.createPaintStyle(); ln.name = "Lou-Ka / Filet (encre 14 %)"; ln.paints = [paint(C.ink, LINE)]; ln.description = "--line / --hairline"; 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"; for (const [name, fam, w, size, lsp, lh, note] of TYPE_TOKENS) { const t = figma.createTextStyle(); t.name = `Lou-Ka / ${name}`; t.fontName = fn(fam, w); t.fontSize = size; t.letterSpacing = { unit: "PERCENT", value: lsp * 100 }; t.lineHeight = { unit: "PERCENT", value: lh }; t.description = note; if (/MAJUSCULES/.test(note)) t.textCase = "UPPER"; } 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"]]; for (const [n, e, d] of effs) { const s = figma.createEffectStyle(); s.name = `Lou-Ka / ${n}`; s.effects = [e]; s.description = d; } try { const col = figma.variables.createVariableCollection("Lou-Ka · Couleurs"); const mode = col.modes[0].modeId; col.renameMode(mode, "Lou-Ka"); for (const [, key, hex] of COLOR_TOKENS) { const v = figma.variables.createVariable(`couleur/${key}`, col, "COLOR"); v.setValueForMode(mode, rgba(hex, 1)); } const vl = figma.variables.createVariable("couleur/line", col, "COLOR"); vl.setValueForMode(mode, rgba(C.ink, LINE)); const vls = figma.variables.createVariable("couleur/line-strong", col, "COLOR"); vls.setValueForMode(mode, rgba(C.ink, LINE_STRONG)); const dim = figma.variables.createVariableCollection("Lou-Ka · Dimensions"); const dm = dim.modes[0].modeId; dim.renameMode(dm, "Base"); SP.forEach((v, i) => { const x = figma.variables.createVariable(`espacement/sp-${i + 1}`, dim, "FLOAT"); x.setValueForMode(dm, v); }); [["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); }); [["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); }); } catch (e) { figma.notify("Variables non créées (limite du forfait) — styles créés.", { timeout: 3000 }); } } function pageFondations() { const root = box({ dir: "V", gap: 96, pad: 96, fill: C.paper, name: "① Fondations — Design system Lou-Ka (ka-ui « éditorial sharp » + accent orange)" }); const cols = box({ dir: "V", gap: 28, name: "Couleurs" }); 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).")); const g = box({ dir: "H", gap: 24, rowGap: 32, wrap: true, w: 168 * 6 + 24 * 5, name: "nuancier" }); 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)); add(cols, g); add(root, cols); const ty = box({ dir: "V", gap: 28, name: "Typographie" }); 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.")); TYPE_TOKENS.forEach(([name, fam, w, size, lsp, lh, note]) => { 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 }); 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); 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 })); add(ty, row); }); add(root, ty); const sp = box({ dir: "V", gap: 28, name: "Espacements, rayons, bordures" }); 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.")); 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); 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); const shd = box({ dir: "V", gap: 28, name: "Ombres décalées" }); 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 %).")); 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); const lg = box({ dir: "V", gap: 28, name: "Logo" }); 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. »")); 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); const rules = box({ dir: "V", gap: 12, w: 1128, name: "Règles d'usage" }); add(rules, sectionTitle("Règles d'usage (tokens.css / CLAUDE.md Groupe Ka)")); ["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); }); add(root, rules); return root; } function pageComposants() { const root = box({ dir: "V", gap: 80, pad: 96, fill: C.paper, name: "② Composants Lou-Ka" }); 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; } 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; } 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"); 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()]); 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"); 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)]); 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"); 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")]); 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 })]); 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 })]); const cardComp = listingCard(SAMPLE_CARDS[0], { component: true, name: "Carte d'annonce" }); 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" })]); 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)]); 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)]); 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 })]); 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)]); return root; } function pageCouverture(page) { const f = box({ w: 1920, h: 1080, fill: C.paper, clip: true, name: "Couverture" }); const halo = ellipse(900, C.accent, { op: 0.13 }); add(f, halo); halo.x = 1250; halo.y = -300; const inner = box({ dir: "V", name: "contenu" }); add(inner, logo(72, { textSize: 60 })); add(inner, spacer(1, 56)); add(inner, kicker("Kit Figma — frontend déployé sur le cluster MacLustr (M4M64a · www.lou-ka.com)")); add(inner, spacer(1, 18)); add(inner, T("Lou-Ka", { fam: "display", w: 700, size: 140, color: C.ink, ls: -0.05, lhp: 100 })); 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); add(inner, spacer(1, 28)); 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 })); add(inner, spacer(1, 40)); 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); add(inner, spacer(1, 56)); 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 })); add(f, inner); inner.x = 120; inner.y = 110; const gk = gkBadge(); add(f, gk); gk.x = 1920 - 120 - gk.width; gk.y = 130; const fab = kaAgentFab(); add(f, fab); fab.x = 1920 - 120 - 64; fab.y = 1080 - 120 - 64; page.appendChild(f); return f; } function pageReferences() { 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)" }); 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).")); const shots = R.filter((r) => r.kind === "shot"); if (!shots.length) { add(root, T("Aucune capture embarquée (lancer shots.mjs puis build.mjs).", { size: 14, color: C.ink3 })); return root; } const groups = {}; shots.forEach((s) => { const key = s.name.replace(/^(desktop|mobile)-/, ""); (groups[key] = groups[key] || []).push(s); }); const order = ["accueil", "logement", "stats", "villes", "court-terme"]; const titles = { accueil: "Accueil /", logement: "Fiche logement /logement/:uid", stats: "Statistiques /stats", villes: "Villes /villes", "court-terme": "Court terme /court-terme" }; for (const key of order.concat(Object.keys(groups).filter((k) => !order.includes(k)))) { if (!groups[key]) continue; const g = box({ dir: "V", gap: 16, name: key }); add(g, T(titles[key] || key, { fam: "display", w: 700, size: 22, color: C.ink, ls: -0.02, lhp: 120 })); const row = box({ dir: "H", gap: 40, align: "MIN" }); for (const s of groups[key].sort((a) => (a.name.startsWith("desktop") ? -1 : 1))) { const scale = s.name.startsWith("desktop") ? 1440 / s.w : 390 / s.w; const wrap = box({ dir: "V", gap: 8, name: s.name }); 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]; const hash = imageHash(s.name); r.fills = hash ? [{ type: "IMAGE", scaleMode: "FILL", imageHash: hash }] : [paint(C.surface2)]; r.name = s.name; 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); } add(g, row); add(root, g); } return root; } // ============================================================================ // 9. ORCHESTRATION // ============================================================================ async function setPage(page) { if (figma.setCurrentPageAsync) await figma.setCurrentPageAsync(page); else figma.currentPage = page; } function 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; } } async function main() { figma.notify("Lou-Ka · chargement des polices…", { timeout: 2000 }); await loadFonts(); const pInit = figma.currentPage; pInit.name = "⓪ Couverture"; const p1 = figma.createPage(); p1.name = "① Fondations"; const p2 = figma.createPage(); p2.name = "② Composants"; const p3 = figma.createPage(); p3.name = "③ Écrans · Desktop 1440"; const p4 = figma.createPage(); p4.name = "④ Écrans · Mobile 390"; const p5 = figma.createPage(); p5.name = "⑤ Références live"; await setPage(pInit); pageCouverture(pInit); figma.notify("① Fondations…", { timeout: 1500 }); await setPage(p1); await buildStylesAndVariables(); p1.appendChild(pageFondations()); figma.notify("② Composants…", { timeout: 1500 }); await setPage(p2); p2.appendChild(pageComposants()); figma.notify("③ Écrans desktop…", { timeout: 1500 }); await setPage(p3); layoutRow(p3, [screenAccueil(1440, false), screenFiche(1440, false), screenStats(1440, false), screenVilles(1440, false)], 200); 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); figma.notify("⑤ Références…", { timeout: 1500 }); await setPage(p5); p5.appendChild(pageReferences()); await setPage(pInit); figma.viewport.scrollAndZoomIntoView(pInit.children); figma.notify("Fichier Figma Lou-Ka généré ✓ — 6 pages, styles, variables, composants et écrans.", { timeout: 6000 }); figma.closePlugin(); } main().catch((e) => { console.error(e); figma.notify("Erreur : " + (e && e.message ? e.message : e), { error: true, timeout: 8000 }); figma.closePlugin(); });