SPB Git forge

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

48commits 1branches 0releases
5.9 MBsize
maindefault branch
19 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%

Design Groupe KA « éditorial sharp » + SSO KA ID

· Site docs refait sur le design system commun (ka-ui/tokens.css, accent
  indigo #3b5bdb) : header API·Ka + badge « Un service Groupe KA »,
  footer commun .ka-footer alimenté par ecosystem.json, page /contact,
  responsive mobile-first (360/768/1024/1440, tactile ≥ 44 px).
· Package ka-ui vendorisé dans src/api/web/ka/ et servi sur /ka
  (tokens.css, ecosystem.json, ka-shell.js).
· SSO KA ID délégué au hub groupe-ka.com (src/api/routes/auth.py) :
  /api/auth/ka/login → /sso/authorize, callback JWT HS256 (aud=api-ka),
  cookie de session signé apika_session, /api/auth/me, /api/auth/logout,
  bouton « Se connecter avec KA » au header.
· /contact : HTML pour navigateurs, JSON (enveloppe) pour clients API.

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

9 changed files +1,057 −174

modified src/api/main.py +36 −4
@@ -4,13 +4,14 @@
4 4 # Node : m3u96b
5 5 # Author : Simon-Pierre Boucher
6 6 # Contact : contact@spboucher.ai
7 −# Date : 2026-08-16
7 +# Date : 2026-08-17
8 8 # ============================================
9 9 """Application FastAPI d'API-KA — servie par uvicorn sur 127.0.0.1:8000,
10 10 exposée publiquement uniquement via le tunnel ngrok www.api-ka.com."""
11 11
12 12 from __future__ import annotations
13 13
14 +import json
14 15 from collections.abc import AsyncIterator
15 16 from contextlib import asynccontextmanager
16 17 from pathlib import Path
@@ -18,10 +19,11 @@ from typing import Any
18 19
19 20 from fastapi import FastAPI, Request
20 21 from fastapi.responses import FileResponse
22 +from fastapi.staticfiles import StaticFiles
21 23
22 24 from src.api.middleware.logging import RequestLoggingMiddleware
23 25 from src.api.middleware.ratelimit import RateLimitMiddleware
24 −from src.api.routes import envelope, health, runs, services
26 +from src.api.routes import auth, envelope, health, runs, services
25 27 from src.config import SERVICES, get_settings, verify_node
26 28 from src.database.db import init_db
27 29 from src.utils.logger import get_logger
@@ -45,7 +47,8 @@ app = FastAPI(
45 47 title="API-KA",
46 48 description=(
47 49 "Plateforme centrale de l'écosystème KA — données quotidiennes des services "
48 − "lou-ka, immo-ka, food-ka, auto-ka et fabri-ka. Node : m3u96b."
50 + "lou-ka, immo-ka, food-ka, auto-ka et fabri-ka. Node : m3u96b. "
51 + "Un service Groupe KA (www.groupe-ka.com)."
49 52 ),
50 53 version=VERSION,
51 54 lifespan=lifespan,
@@ -58,11 +61,20 @@ app.add_middleware(RequestLoggingMiddleware)
58 61
59 62 # /api/v1/runs doit être enregistré avant les routes génériques /api/v1/{service}.
60 63 app.include_router(health.router)
64 +app.include_router(auth.router)
61 65 app.include_router(runs.router)
62 66 app.include_router(services.router)
63 67
64 68
65 −WEB_INDEX = Path(__file__).resolve().parent / "web" / "index.html"
69 +WEB_DIR = Path(__file__).resolve().parent / "web"
70 +WEB_INDEX = WEB_DIR / "index.html"
71 +WEB_CONTACT = WEB_DIR / "contact.html"
72 +KA_DIR = WEB_DIR / "ka"
73 +
74 +# Package ka-ui vendorisé (tokens.css, ecosystem.json, ka-shell.js) — servi
75 +# statiquement pour le site de documentation. Voir frontend → src/api/web/ka.
76 +if KA_DIR.is_dir():
77 + app.mount("/ka", StaticFiles(directory=KA_DIR), name="ka")
66 78
67 79
68 80 @app.get("/", tags=["root"])
@@ -85,3 +97,23 @@ def root(request: Request) -> Any:
85 97 "docs": "/docs",
86 98 }
87 99 )
100 +
101 +
102 +@app.get("/contact", tags=["root"])
103 +def contact(request: Request) -> Any:
104 + """Page contact Groupe KA — HTML pour les navigateurs, JSON pour les clients API.
105 +
106 + Les coordonnées proviennent d'ecosystem.json (source de vérité ka-ui).
107 + """
108 + accept = request.headers.get("accept", "")
109 + if "text/html" in accept and WEB_CONTACT.is_file():
110 + return FileResponse(WEB_CONTACT, media_type="text/html")
111 + eco_path = KA_DIR / "ecosystem.json"
112 + eco = json.loads(eco_path.read_text(encoding="utf-8")) if eco_path.is_file() else {}
113 + return envelope(
114 + {
115 + "org": eco.get("org", {}).get("name", "Groupe KA"),
116 + "hub": eco.get("hub", {}).get("url", "https://www.groupe-ka.com"),
117 + "contacts": eco.get("contacts", []),
118 + }
119 + )
added src/api/routes/auth.py +267 −0
@@ -0,0 +1,267 @@
1 +# ============================================
2 +# Projet : API-KA
3 +# Fichier : src/api/routes/auth.py
4 +# Node : m3u96b
5 +# Author : Simon-Pierre Boucher
6 +# Contact : contact@spboucher.ai
7 +# Date : 2026-08-17
8 +# ============================================
9 +"""« Se connecter avec KA ID » — connexion déléguée au hub d'identité du
10 +Groupe KA (groupe-ka.com), calquée sur creaka/auth.py (stdlib pur pour la
11 +crypto, httpx pour le profil).
12 +
13 +Flux : GET /api/auth/ka/login → hub {KA_HUB_URL}/sso/authorize (state signé)
14 + GET /api/auth/ka/callback → vérifie le JWT HS256 ``ka_token`` du hub
15 + (aud="api-ka"), pose le cookie de session signé ``apika_session``
16 + GET /api/auth/me · POST /api/auth/logout · GET /api/auth/config
17 +
18 +Session SANS stockage local (aucune table users) : le hub est LA source de
19 +vérité du profil (fetch signé HMAC, cache 60 s).
20 +.env : KA_SSO_SECRET (partagé avec le hub), SESSION_SECRET,
21 + KA_HUB_URL, APIKA_BASE_URL (optionnel — sinon dérivé de NGROK_DOMAIN).
22 +"""
23 +
24 +from __future__ import annotations
25 +
26 +import base64
27 +import hashlib
28 +import hmac
29 +import json
30 +import os
31 +import secrets
32 +import time
33 +from urllib.parse import urlencode
34 +
35 +import httpx
36 +from fastapi import APIRouter, HTTPException, Request
37 +from fastapi.responses import JSONResponse, RedirectResponse
38 +
39 +router = APIRouter(prefix="/api/auth", tags=["auth"])
40 +
41 +CLIENT_ID = "api-ka"
42 +COOKIE = "apika_session"
43 +SESSION_DAYS = 30
44 +
45 +
46 +def _hub_url() -> str:
47 + return os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
48 +
49 +
50 +_profile_cache: dict[str, tuple[float, dict | None]] = {}
51 +_PROFILE_TTL = 60.0
52 +
53 +
54 +def _secret() -> bytes:
55 + """Secret de session local — SESSION_SECRET, sinon dérivé de KA_SSO_SECRET."""
56 + s = os.environ.get("SESSION_SECRET") or os.environ.get("KA_SSO_SECRET")
57 + if not s:
58 + raise HTTPException(503, "SESSION_SECRET / KA_SSO_SECRET manquant (voir .env)")
59 + return s.encode()
60 +
61 +
62 +def _ka_secret() -> bytes | None:
63 + s = os.environ.get("KA_SSO_SECRET")
64 + return s.encode() if s else None
65 +
66 +
67 +def _base_url(request: Request) -> str:
68 + """URL publique de l'app — APIKA_BASE_URL, sinon https://{NGROK_DOMAIN}."""
69 + explicit = os.environ.get("APIKA_BASE_URL")
70 + if explicit:
71 + return explicit.rstrip("/")
72 + domain = os.environ.get("NGROK_DOMAIN")
73 + if domain:
74 + return f"https://{domain}"
75 + return str(request.base_url).rstrip("/")
76 +
77 +
78 +# -- jeton signé maison (payload JSON -> base64url.signature) -------------------
79 +
80 +
81 +def _sign(payload: dict) -> str:
82 + raw = (
83 + base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode())
84 + .decode()
85 + .rstrip("=")
86 + )
87 + sig = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()
88 + return f"{raw}.{sig}"
89 +
90 +
91 +def _verify(token: str) -> dict | None:
92 + try:
93 + raw, sig = token.rsplit(".", 1)
94 + expected = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()
95 + if not hmac.compare_digest(sig, expected):
96 + return None
97 + payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))
98 + if payload.get("exp", 0) < time.time():
99 + return None
100 + return payload
101 + except Exception:
102 + return None
103 +
104 +
105 +def current_user(request: Request) -> dict | None:
106 + """Payload de session ({ka_id, email, name, picture…}) ou None."""
107 + token = request.cookies.get(COOKIE)
108 + return _verify(token) if token else None
109 +
110 +
111 +# -- vérification du JWT HS256 émis par le hub (stdlib seulement) ---------------
112 +
113 +
114 +def _b64url(s: str) -> bytes:
115 + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
116 +
117 +
118 +def _verify_ka_token(token: str) -> dict | None:
119 + secret = _ka_secret()
120 + if not secret:
121 + return None
122 + try:
123 + h_b64, p_b64, s_b64 = token.split(".")
124 + expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(), hashlib.sha256).digest()
125 + if not hmac.compare_digest(_b64url(s_b64), expected):
126 + return None
127 + if json.loads(_b64url(h_b64)).get("alg") != "HS256":
128 + return None
129 + claims = json.loads(_b64url(p_b64))
130 + if claims.get("iss") != _hub_url():
131 + return None
132 + aud = claims.get("aud")
133 + if (aud if isinstance(aud, str) else "") != CLIENT_ID and CLIENT_ID not in (
134 + aud if isinstance(aud, list) else []
135 + ):
136 + return None
137 + if claims.get("exp", 0) < time.time():
138 + return None
139 + return claims
140 + except Exception:
141 + return None
142 +
143 +
144 +# -- profil dicté par le hub (source de vérité, cache 60 s) ---------------------
145 +
146 +
147 +def fetch_hub_profile(ka_id: str) -> dict | None:
148 + """GET signé {hub}/api/sso/profile — sig = HMAC(secret, client.ka_id.ts)."""
149 + secret = _ka_secret()
150 + if not secret or not ka_id:
151 + return None
152 + now = time.time()
153 + hit = _profile_cache.get(ka_id)
154 + if hit and now - hit[0] < _PROFILE_TTL:
155 + return hit[1]
156 + ts = str(int(now))
157 + sig = hmac.new(secret, f"{CLIENT_ID}.{ka_id}.{ts}".encode(), hashlib.sha256).hexdigest()
158 + profile = None
159 + try:
160 + resp = httpx.get(
161 + f"{_hub_url()}/api/sso/profile",
162 + params={"client_id": CLIENT_ID, "ka_id": ka_id, "ts": ts, "sig": sig},
163 + timeout=8,
164 + )
165 + if resp.status_code == 200:
166 + profile = resp.json()
167 + except Exception:
168 + pass
169 + _profile_cache[ka_id] = (now, profile) # cache positif ET négatif
170 + return profile
171 +
172 +
173 +# -- routes ---------------------------------------------------------------------
174 +
175 +
176 +@router.get("/config")
177 +def auth_config():
178 + return {"ka": bool(_ka_secret())}
179 +
180 +
181 +@router.get("/ka/login")
182 +def ka_login(request: Request):
183 + """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com)."""
184 + if not _ka_secret():
185 + raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")
186 + state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600})
187 + params = {
188 + "client_id": CLIENT_ID,
189 + "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback",
190 + "state": state,
191 + }
192 + return RedirectResponse(f"{_hub_url()}/sso/authorize?{urlencode(params)}")
193 +
194 +
195 +@router.get("/ka/callback")
196 +def ka_callback(request: Request, ka_token: str = "", state: str = ""):
197 + """Retour SSO : vérifie le jeton du hub, pose le cookie de session API-KA.
198 +
199 + Aucune table users locale : le KA ID du hub EST l'identité — la session
200 + transporte les claims utiles, le profil riche est relu au hub à la demande.
201 + """
202 + if not ka_token or _verify(state) is None:
203 + raise HTTPException(400, "state invalide ou expiré")
204 + claims = _verify_ka_token(ka_token)
205 + if claims is None:
206 + raise HTTPException(401, "jeton KA invalide ou expiré")
207 + session = _sign(
208 + {
209 + "ka_id": (claims.get("ka_id") or "").strip(),
210 + "sub": str(claims.get("sub") or ""),
211 + "email": (claims.get("email") or "").lower(),
212 + "name": claims.get("name") or "membre",
213 + "picture": claims.get("picture") or "",
214 + "role": claims.get("ka_role") or "",
215 + "exp": time.time() + SESSION_DAYS * 86400,
216 + }
217 + )
218 + resp = RedirectResponse("/")
219 + resp.set_cookie(
220 + COOKIE,
221 + session,
222 + max_age=SESSION_DAYS * 86400,
223 + httponly=True,
224 + secure=_base_url(request).startswith("https"),
225 + samesite="lax",
226 + path="/",
227 + )
228 + return resp
229 +
230 +
231 +@router.get("/me")
232 +def me(request: Request):
233 + user = current_user(request)
234 + if user is None:
235 + raise HTTPException(401, "non connecté")
236 + out = {
237 + "provider": "ka-id",
238 + **{k: user.get(k) for k in ("ka_id", "email", "name", "picture", "role")},
239 + }
240 + profile = fetch_hub_profile(user.get("ka_id") or "")
241 + if profile: # le hub dicte le profil (nom/photo/rôle à jour)
242 + out.update(
243 + {
244 + k: profile.get(k)
245 + for k in (
246 + "name",
247 + "picture",
248 + "role",
249 + "role_label",
250 + "bio",
251 + "city",
252 + "job_title",
253 + "company",
254 + "website",
255 + "public_url",
256 + )
257 + if profile.get(k) not in (None, "")
258 + }
259 + )
260 + return out
261 +
262 +
263 +@router.post("/logout")
264 +def logout():
265 + resp = JSONResponse({"ok": True})
266 + resp.delete_cookie(COOKIE, path="/")
267 + return resp
added src/api/web/contact.html +102 −0
@@ -0,0 +1,102 @@
1 +<!DOCTYPE html>
2 +<!--
3 +============================================
4 +Projet : API-KA
5 +Fichier : src/api/web/contact.html
6 +Node : m3u96b
7 +Author : Simon-Pierre Boucher
8 +Contact : contact@spboucher.ai
9 +Date : 2026-08-17
10 +============================================
11 +Page /contact — courriels officiels Groupe KA (ecosystem.json) + compte KA ID.
12 +-->
13 +<html lang="fr">
14 +<head>
15 +<meta charset="utf-8">
16 +<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
17 +<title>Contact — API-Ka — Un service Groupe KA</title>
18 +<meta name="description" content="Joindre le Groupe KA : contact@groupe-ka.com (projets, partenariats et données), info@groupe-ka.com (médias) et admin@groupe-ka.com (légal, vie privée et Loi 25).">
19 +<meta name="theme-color" content="#f5f3ee">
20 +<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23141814'/%3E%3Crect x='10' y='10' width='44' height='44' rx='8' fill='none' stroke='%233b5bdb' stroke-width='5'/%3E%3Ccircle cx='32' cy='32' r='7' fill='%233b5bdb'/%3E%3C/svg%3E">
21 +<link rel="preconnect" href="https://fonts.googleapis.com">
22 +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
23 +<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
24 +<link rel="stylesheet" href="/ka/tokens.css">
25 +<style>
26 +/* ---- Accent API-Ka : INDIGO ---- */
27 +:root{
28 + --accent:#3b5bdb; --accent-soft:#e4eafb; --accent-deep:#2b44a8; --on-accent:#ffffff;
29 +}
30 +
31 +/* ---- topbar (identique à l'accueil) ---- */
32 +.topbar{position:sticky;top:0;z-index:60;background:rgba(245,243,238,.94);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border-bottom:1.5px solid var(--ink)}
33 +.topbar-in{display:flex;align-items:center;gap:14px;min-height:68px;flex-wrap:wrap;padding-top:8px;padding-bottom:8px}
34 +.brand{font-family:var(--font-display);font-weight:700;font-size:24px;letter-spacing:-.03em;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;min-height:var(--touch);white-space:nowrap}
35 +.brand .ka{display:inline-block;background:var(--ink);color:var(--accent);border-radius:6px;padding:0 7px 2px;margin-left:5px;transform:rotate(-2deg);transition:transform .15s}
36 +.brand:hover .ka{transform:rotate(0)}
37 +.topnav{display:flex;gap:2px;list-style:none;margin:0 0 0 auto;padding:0;overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}
38 +.topnav a{display:inline-flex;align-items:center;min-height:var(--touch);text-decoration:none;font-family:var(--font-mono);font-size:11.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-2);padding:0 12px;border-radius:var(--r-ctl);white-space:nowrap;transition:background .13s,color .13s}
39 +.topnav a:hover{background:var(--accent-soft);color:var(--accent-deep)}
40 +#ka-auth{display:flex;align-items:center;gap:8px}
41 +#ka-auth .btn{font-size:12.5px;padding:8px 14px}
42 +.ka-user{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2)}
43 +.ka-user b{font-family:var(--font-display);font-size:13px;text-transform:none;letter-spacing:-.01em;color:var(--ink)}
44 +.ka-avatar{width:28px;height:28px;border-radius:50%;border:1.5px solid var(--ink);object-fit:cover}
45 +@media(max-width:1023px){.topnav{order:5;flex-basis:100%;margin-left:0}}
46 +
47 +/* ---- page contact ---- */
48 +.hero-c{padding:64px 0 56px;border-bottom:1.5px solid var(--ink);background:linear-gradient(rgba(59,91,219,.05),transparent 60%),var(--paper)}
49 +.hero-c h1{max-width:640px;margin-top:16px}
50 +.lede{margin-top:14px;max-width:60ch;color:var(--ink-2);font-size:16px}
51 +section{padding:64px 0}
52 +.contact-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:18px}
53 +.contact-card{padding:24px 22px;display:flex;flex-direction:column;gap:12px}
54 +.contact-mail{display:inline-flex;align-items:center;min-height:var(--touch);font-family:var(--font-mono);font-size:14.5px;font-weight:700;color:var(--accent-deep);text-decoration:none;word-break:break-all}
55 +.contact-mail:hover{text-decoration:underline;text-underline-offset:4px}
56 +.account{margin-top:56px;background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off-soft);padding:28px;display:flex;flex-wrap:wrap;align-items:center;gap:20px;justify-content:space-between}
57 +.account p{margin:0;max-width:56ch;color:var(--ink-2);font-size:14.5px}
58 +.account b{color:var(--ink)}
59 +</style>
60 +</head>
61 +<body>
62 +
63 +<div class="topbar"><div class="container topbar-in">
64 + <a class="brand" href="/" aria-label="API-Ka — accueil">API<span class="ka">·Ka</span></a>
65 + <a class="gk-badge" href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer"
66 + aria-label="Un service Groupe KA — visiter le portail de l'écosystème">Un service <b>Groupe<span class="ka">KA</span></b></a>
67 + <ul class="topnav">
68 + <li><a href="/#services">Services</a></li>
69 + <li><a href="/#endpoints">Endpoints</a></li>
70 + <li><a href="/#playground">Playground</a></li>
71 + <li><a href="/docs">Swagger</a></li>
72 + <li><a href="/contact">Contact</a></li>
73 + </ul>
74 + <div id="ka-auth"></div>
75 +</div></div>
76 +
77 +<header class="hero-c"><div class="container">
78 + <span class="kicker">Contact · Groupe KA</span>
79 + <h1>Nous <span class="hl">joindre</span></h1>
80 + <p class="lede">API-Ka est un service du Groupe KA. Toutes les demandes — projets, données,
81 + médias, légal — passent par les courriels officiels du groupe.</p>
82 +</div></header>
83 +
84 +<section><div class="container">
85 + <span class="kicker">Courriels officiels</span>
86 + <h2>Trois <span class="hl">adresses</span></h2>
87 + <div class="contact-grid" id="ka-contacts"></div>
88 +
89 + <div class="account">
90 + <p><b>Compte KA ID.</b> La connexion « Se connecter avec KA » est déléguée au hub
91 + groupe-ka.com : un seul compte pour toutes les plateformes de l'écosystème.
92 + La création de compte se fait sur le hub.</p>
93 + <a class="btn btn-accent" id="ka-hub-link" href="https://www.groupe-ka.com/connexion"
94 + target="_blank" rel="noopener noreferrer">Créer un compte KA ID</a>
95 + </div>
96 +</div></section>
97 +
98 +<footer class="ka-footer"><div class="container" id="ka-footer-root"></div></footer>
99 +
100 +<script src="/ka/ka-shell.js" defer></script>
101 +</body>
102 +</html>
modified src/api/web/index.html +161 −170
@@ -6,163 +6,148 @@ Fichier : src/api/web/index.html
6 6 Node : m3u96b
7 7 Author : Simon-Pierre Boucher
8 8 Contact : contact@spboucher.ai
9 −Date : 2026-08-16
9 +Date : 2026-08-17
10 10 ============================================
11 +Design system Groupe KA « éditorial sharp » — tokens.css (ka-ui) importé en
12 +premier, accent indigo propre à API-Ka surchargé en :root ci-dessous.
11 13 -->
12 14 <html lang="fr">
13 15 <head>
14 16 <meta charset="utf-8">
15 17 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
16 −<title>API-KA — La plateforme de données de l'écosystème KA</title>
17 −<meta name="description" content="API publique centralisant chaque jour les données des services lou-ka, immo-ka, food-ka, auto-ka et fabri-ka. Documentation complète et playground interactif.">
18 −<meta name="theme-color" content="#ffffff">
19 −<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230B1330'/%3E%3Cellipse cx='32' cy='18' rx='17' ry='7' fill='none' stroke='%23FF6A00' stroke-width='5'/%3E%3Cpath d='M15 18v28c0 3.9 7.6 7 17 7s17-3.1 17-7V18' fill='none' stroke='%23FF6A00' stroke-width='5'/%3E%3Cpath d='M15 32c0 3.9 7.6 7 17 7s17-3.1 17-7' fill='none' stroke='%23ffffff' stroke-width='4'/%3E%3C/svg%3E">
18 +<title>API-Ka — Un service Groupe KA</title>
19 +<meta name="description" content="API publique centralisant chaque jour les données des services de l'écosystème Groupe KA — logements, propriétés, épicerie, véhicules, produits québécois, restaurants, sorties et créateurs. Documentation complète et playground interactif.">
20 +<meta name="theme-color" content="#f5f3ee">
21 +<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23141814'/%3E%3Crect x='10' y='10' width='44' height='44' rx='8' fill='none' stroke='%233b5bdb' stroke-width='5'/%3E%3Ccircle cx='32' cy='32' r='7' fill='%233b5bdb'/%3E%3C/svg%3E">
20 22 <link rel="preconnect" href="https://fonts.googleapis.com">
21 23 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
22 −<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
24 +<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
25 +<link rel="stylesheet" href="/ka/tokens.css">
23 26 <style>
27 +/* ---- Accent API-Ka : INDIGO (seules variables surchargées de tokens.css) ---- */
24 28 :root{
25 − --navy:#0b1330; --blue:#2258ff; --orange:#ff6a00; --orange-deep:#e55f00;
26 − --orange-soft:#fff1e6; --blue-soft:#e9efff; --navy-soft:#eef0f6; --grey:#f2f2f2;
27 − --white:#ffffff; --surface:var(--white); --surface-2:#f7f7f9;
28 − --ink:var(--navy); --ink-2:#3e4560; --ink-3:#8a90a5;
29 − --line:rgba(11,19,48,.1); --line-strong:rgba(11,19,48,.16); --danger:#d64545;
30 − --r-card:18px; --r-ctl:12px; --r-pill:999px;
31 − --shadow-flat:0 1px 3px rgba(11,19,48,.06);
32 − --shadow-off:0 16px 40px rgba(11,19,48,.14);
33 − --shadow-off-soft:0 8px 24px rgba(11,19,48,.08);
34 − --font:"Inter","SF Pro Display",system-ui,sans-serif;
35 − --mono:"JetBrains Mono",ui-monospace,monospace;
29 + --accent:#3b5bdb; --accent-soft:#e4eafb; --accent-deep:#2b44a8; --on-accent:#ffffff;
36 30 }
37 −*{box-sizing:border-box;margin:0;padding:0}
38 −html{scroll-behavior:smooth;scroll-padding-top:86px}
39 −body{background:var(--white);color:var(--ink);font-family:var(--font);font-size:15.5px;line-height:1.62;-webkit-font-smoothing:antialiased}
40 −a{color:inherit}
41 −.wrap{max-width:1120px;margin:0 auto;padding:0 24px}
31 +html{scroll-padding-top:84px}
32 +
42 33 /* ================= topbar ================= */
43 −.topbar{position:sticky;top:0;z-index:60;background:rgba(255,255,255,.92);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
44 −.topbar .wrap{display:flex;align-items:center;gap:26px;height:68px}
45 −.brand{font-weight:800;font-size:24px;letter-spacing:-.03em;color:var(--navy);text-decoration:none;display:flex;align-items:center;gap:10px;line-height:1}
46 −.brand svg{width:30px;height:30px;flex:none}
47 −.brand .hl{color:var(--orange)}
48 −.brand>span:first-of-type{white-space:nowrap}
49 −.brand-tag{font-size:11px;font-weight:500;color:var(--ink-3);letter-spacing:.02em;margin-top:3px}
50 −.topnav{display:flex;gap:4px;list-style:none;margin-left:auto}
51 −.topnav a{text-decoration:none;font-size:13.5px;font-weight:600;color:var(--ink-2);padding:8px 14px;border-radius:var(--r-pill);transition:all .13s ease}
52 −.topnav a:hover{background:var(--blue-soft);color:var(--blue)}
53 −.live-chip{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:var(--r-pill);padding:8px 16px;background:var(--surface);font-size:12.5px;font-weight:600;color:var(--ink-2);box-shadow:var(--shadow-flat);white-space:nowrap}
34 +.topbar{position:sticky;top:0;z-index:60;background:rgba(245,243,238,.94);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border-bottom:1.5px solid var(--ink)}
35 +.topbar-in{display:flex;align-items:center;gap:14px;min-height:68px;flex-wrap:wrap;padding-top:8px;padding-bottom:8px}
36 +.brand{font-family:var(--font-display);font-weight:700;font-size:24px;letter-spacing:-.03em;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;min-height:var(--touch);white-space:nowrap}
37 +.brand .ka{display:inline-block;background:var(--ink);color:var(--accent);border-radius:6px;padding:0 7px 2px;margin-left:5px;transform:rotate(-2deg);transition:transform .15s}
38 +.brand:hover .ka{transform:rotate(0)}
39 +.topnav{display:flex;gap:2px;list-style:none;margin:0 0 0 auto;padding:0;overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}
40 +.topnav a{display:inline-flex;align-items:center;min-height:var(--touch);text-decoration:none;font-family:var(--font-mono);font-size:11.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-2);padding:0 12px;border-radius:var(--r-ctl);white-space:nowrap;transition:background .13s,color .13s}
41 +.topnav a:hover{background:var(--accent-soft);color:var(--accent-deep)}
42 +.topbar-tools{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
43 +#ka-auth{display:flex;align-items:center;gap:8px}
44 +#ka-auth .btn{font-size:12.5px;padding:8px 14px}
45 +.ka-user{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2)}
46 +.ka-user b{font-family:var(--font-display);font-size:13px;text-transform:none;letter-spacing:-.01em;color:var(--ink)}
47 +.ka-avatar{width:28px;height:28px;border-radius:50%;border:1.5px solid var(--ink);object-fit:cover}
48 +.live-chip{display:inline-flex;align-items:center;gap:8px;border:1.5px solid var(--ink);border-radius:var(--r-pill);padding:6px 14px;min-height:34px;background:var(--surface);font-family:var(--font-mono);font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--ink-2);white-space:nowrap}
54 49 .pulse{width:8px;height:8px;border-radius:50%;background:var(--ink-3)}
55 −.pulse.ok{background:var(--orange);box-shadow:0 0 0 4px var(--orange-soft);animation:pulse 2.4s ease infinite}
56 −.pulse.err{background:var(--danger);box-shadow:0 0 0 4px #fde8e8}
57 −@keyframes pulse{0%,100%{box-shadow:0 0 0 4px var(--orange-soft)}50%{box-shadow:0 0 0 7px #ff6a002e}}
58 −@media(max-width:900px){.topnav{display:none}.brand-tag{display:none}}
50 +.pulse.ok{background:var(--accent);box-shadow:0 0 0 4px var(--accent-soft);animation:pulse 2.4s ease infinite}
51 +.pulse.err{background:var(--danger);box-shadow:0 0 0 4px var(--danger-soft)}
52 +@keyframes pulse{0%,100%{box-shadow:0 0 0 4px var(--accent-soft)}50%{box-shadow:0 0 0 7px rgba(59,91,219,.22)}}
53 +@media(max-width:1023px){.topnav{order:5;flex-basis:100%;margin-left:0}}
54 +@media(max-width:767px){.live-chip{display:none}}
55 +
59 56 /* ================= hero ================= */
60 −.hero{padding:64px 0 56px;background:
61 − radial-gradient(900px 420px at 85% -10%, var(--blue-soft) 0%, transparent 60%),
62 − radial-gradient(700px 380px at -5% 15%, var(--orange-soft) 0%, transparent 55%), var(--white)}
63 −.hero-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:48px;align-items:center}
64 −@media(max-width:960px){.hero-grid{grid-template-columns:1fr}}
65 −.kicker{display:inline-flex;align-items:center;gap:8px;border:1px solid rgba(255,106,0,.35);background:var(--orange-soft);color:var(--orange-deep);border-radius:var(--r-pill);padding:6px 16px;font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase}
66 −h1{font-size:clamp(38px,5.4vw,60px);font-weight:800;line-height:1.05;letter-spacing:-.03em;color:var(--navy);margin:18px 0 0;max-width:640px}
67 −h1 .hl{color:var(--orange)}
68 −.lede{margin-top:20px;max-width:56ch;color:var(--ink-2);font-size:16.5px}
57 +.hero{padding:56px 0 64px;border-bottom:1.5px solid var(--ink);background:
58 + linear-gradient(rgba(59,91,219,.05),transparent 55%),var(--paper)}
59 +.hero-grid{display:grid;grid-template-columns:1.12fr .88fr;gap:48px;align-items:center}
60 +@media(max-width:1023px){.hero-grid{grid-template-columns:1fr;gap:36px}}
61 +.hero h1{max-width:640px;margin-top:16px}
62 +.lede{margin-top:16px;max-width:58ch;color:var(--ink-2);font-size:16px}
69 63 .hero-cta{display:flex;flex-wrap:wrap;gap:12px;margin-top:28px}
70 −.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid transparent;border-radius:var(--r-pill);padding:12px 26px;font-family:var(--font);font-weight:700;font-size:14.5px;cursor:pointer;text-decoration:none;min-height:46px;transition:transform .12s ease,box-shadow .12s ease,background .15s ease}
71 −.btn-primary{background:var(--orange);color:var(--white);box-shadow:0 6px 18px #ff6a0052}
72 −.btn-primary:hover{background:var(--orange-deep);transform:translateY(-1px);box-shadow:0 10px 24px #ff6a0060}
73 −.btn-ghost{background:transparent;color:var(--navy);border-color:var(--line-strong)}
74 −.btn-ghost:hover{background:var(--navy-soft);transform:translateY(-1px)}
75 −.stat-chips{display:flex;flex-wrap:wrap;gap:10px;margin-top:32px}
76 −.stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-pill);padding:9px 18px;font-size:12.5px;font-weight:500;color:var(--ink-2);display:inline-flex;gap:8px;align-items:center;box-shadow:var(--shadow-flat);white-space:nowrap;transition:background .13s ease}
77 −.stat-chip:hover{background:var(--blue-soft)}
78 −.stat-chip b{color:var(--navy);font-weight:700;font-variant-numeric:tabular-nums}
79 −/* terminal navy */
80 −.hero-tile{background:var(--navy);border-radius:var(--r-card);box-shadow:var(--shadow-off);overflow:hidden;transform:rotate(.6deg)}
81 −.tile-bar{display:flex;align-items:center;gap:8px;padding:13px 18px;background:#101a3d;border-bottom:1px solid rgba(255,255,255,.08)}
82 −.tile-bar i{width:11px;height:11px;border-radius:50%;background:#26325c;display:inline-block}
83 −.tile-bar i:first-child{background:var(--orange)}
84 −.tile-bar span{font-family:var(--mono);font-size:11.5px;color:#8a90a5;margin-left:8px}
85 −.tile-body{padding:20px 22px;font-family:var(--mono);font-size:12.3px;line-height:1.75;color:#e9efff;white-space:pre-wrap;word-break:break-word;min-height:280px}
86 −.tile-body .tk{color:#7ea0ff}.tile-body .ts{color:#ffb277}.tile-body .tn{color:#7dd3a7}.tile-body .tc{color:#8a90a5}
64 +.stat-chips{display:flex;flex-wrap:wrap;gap:10px;margin-top:30px}
65 +.stat-chip{display:inline-flex;gap:8px;align-items:center;background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-pill);padding:8px 16px;font-family:var(--font-mono);font-size:11px;font-weight:500;letter-spacing:.02em;color:var(--ink-2);white-space:nowrap;box-shadow:var(--shadow-flat)}
66 +.stat-chip b{color:var(--ink);font-weight:700;font-variant-numeric:tabular-nums}
67 +/* terminal encre */
68 +.hero-tile{background:var(--ink);border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off);overflow:hidden;transform:rotate(.5deg)}
69 +.tile-bar{display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid rgba(245,243,238,.12)}
70 +.tile-bar i{width:10px;height:10px;border-radius:50%;background:rgba(245,243,238,.18);display:inline-block}
71 +.tile-bar i:first-child{background:var(--accent)}
72 +.tile-bar span{font-family:var(--font-mono);font-size:11px;color:rgba(245,243,238,.5);margin-left:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
73 +.tile-body{padding:18px 20px;font-family:var(--font-mono);font-size:12.2px;line-height:1.75;color:var(--paper);white-space:pre-wrap;word-break:break-word;min-height:270px}
74 +.tile-body .tk{color:#9db1f5}.tile-body .ts{color:var(--amber)}.tile-body .tn{color:#7dd3a7}.tile-body .tc{color:rgba(245,243,238,.45)}
75 +
87 76 /* ================= sections ================= */
88 −section{padding:76px 0}
89 −section.alt{background:var(--surface-2);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
90 −.sec-kicker{color:var(--orange);font-size:12px;font-weight:800;letter-spacing:.1em;text-transform:uppercase}
91 −h2{font-size:clamp(28px,3.6vw,40px);font-weight:800;letter-spacing:-.03em;line-height:1.1;color:var(--navy);margin:8px 0 12px}
92 −h2 .hl{color:var(--orange)}
93 −.sec-sub{color:var(--ink-2);max-width:66ch;margin-bottom:36px;font-size:15.5px}
94 −.sec-sub code, .conv code{font-family:var(--mono);font-size:.82em;background:var(--navy-soft);border-radius:6px;padding:2px 7px;color:var(--navy)}
77 +section{padding:72px 0}
78 +section.alt{background:var(--surface-2);border-top:1.5px solid var(--ink);border-bottom:1.5px solid var(--ink)}
79 +.sec-sub{color:var(--ink-2);max-width:66ch;margin:0 0 34px;font-size:15px}
80 +h2{margin-top:10px}
81 +h2 .hl{font-size:.92em}
82 +.sec-sub code,.conv code,.ep-desc code{font-family:var(--font-mono);font-size:.82em;background:var(--accent-soft);border-radius:5px;padding:2px 7px;color:var(--accent-deep)}
83 +
95 84 /* ================= services ================= */
96 −.svc-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px}
97 −.svc{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-card);padding:24px 22px;box-shadow:var(--shadow-flat);transition:transform .16s ease,box-shadow .16s ease;display:flex;flex-direction:column}
98 −.svc:hover{transform:translateY(-3px);box-shadow:var(--shadow-off-soft)}
99 −.svc h3{font-size:18px;font-weight:800;letter-spacing:-.02em;color:var(--navy)}
100 −.svc h3 .hl{color:var(--orange)}
101 −.svc .path{font-family:var(--mono);font-size:11.5px;color:var(--blue);margin:5px 0 12px}
102 −.svc p{font-size:13px;color:var(--ink-2);line-height:1.55;flex:1}
103 −.svc b{display:block;margin-top:16px;font-size:22px;font-weight:800;letter-spacing:-.02em;color:var(--navy);font-variant-numeric:tabular-nums}
104 −.svc small{color:var(--ink-3);font-size:11.5px;font-weight:500}
105 −.svc a{display:inline-flex;margin-top:12px;font-size:12.5px;font-weight:700;color:var(--orange);text-decoration:none}
106 −.svc a:hover{color:var(--orange-deep)}
85 +.svc-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px}
86 +.svc{padding:22px 20px;display:flex;flex-direction:column}
87 +.svc h3{margin-bottom:2px}
88 +.svc .path{font-family:var(--font-mono);font-size:11px;color:var(--accent-deep);margin:4px 0 12px}
89 +.svc p{font-size:13px;color:var(--ink-2);line-height:1.55;flex:1;margin:0}
90 +.svc b{display:block;margin-top:16px;font-family:var(--font-display);font-size:24px;font-weight:700;letter-spacing:-.02em;color:var(--ink);font-variant-numeric:tabular-nums}
91 +.svc small{color:var(--ink-3);font-size:11px;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.06em}
92 +.svc a{display:inline-flex;align-items:center;min-height:32px;margin-top:10px;font-family:var(--font-mono);font-size:11.5px;font-weight:700;color:var(--accent-deep);text-decoration:none}
93 +.svc a:hover{text-decoration:underline;text-underline-offset:4px}
94 +
107 95 /* ================= endpoints ================= */
108 −.ep{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-card);padding:26px 28px;margin-bottom:18px;box-shadow:var(--shadow-flat);transition:transform .16s ease,box-shadow .16s ease}
109 −.ep:hover{transform:translateY(-2px);box-shadow:var(--shadow-off-soft)}
96 +.ep{padding:24px 26px;margin-bottom:18px}
110 97 .ep-top{display:flex;flex-wrap:wrap;align-items:center;gap:12px}
111 −.ep-num{font-family:var(--mono);font-size:11px;font-weight:700;color:var(--ink-3)}
112 −.method{border:1px solid rgba(34,88,255,.3);background:var(--blue-soft);color:var(--blue);border-radius:var(--r-pill);padding:4px 13px;font-size:11px;font-weight:800;letter-spacing:.05em}
113 −.ep-path{font-family:var(--mono);font-size:15px;font-weight:700;color:var(--navy);letter-spacing:-.01em}
114 −.try{margin-left:auto;font-size:12.5px;font-weight:700;color:var(--orange);text-decoration:none;border:1px solid rgba(255,106,0,.35);border-radius:var(--r-pill);padding:6px 16px;background:var(--orange-soft);transition:all .13s ease}
115 −.try:hover{background:var(--orange);color:var(--white)}
98 +.ep-num{font-family:var(--font-mono);font-size:11px;font-weight:700;color:var(--ink-3)}
99 +.method{border:1.5px solid var(--ink);background:var(--accent);color:var(--on-accent);border-radius:var(--r-pill);padding:3px 12px;font-family:var(--font-mono);font-size:10.5px;font-weight:700;letter-spacing:.06em}
100 +.ep-path{font-family:var(--font-mono);font-size:14.5px;font-weight:700;color:var(--ink);letter-spacing:-.01em;word-break:break-all}
101 +.try{margin-left:auto;display:inline-flex;align-items:center;min-height:36px;font-family:var(--font-mono);font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--accent-deep);text-decoration:none;border:1.5px solid var(--ink);border-radius:var(--r-pill);padding:0 15px;background:var(--accent-soft);transition:background .13s,color .13s}
102 +.try:hover{background:var(--accent);color:var(--on-accent)}
116 103 .ep-desc{margin:12px 0 0;color:var(--ink-2);max-width:72ch;font-size:14px}
117 −table.params{width:100%;border-collapse:collapse;margin-top:18px;font-size:13.5px}
118 −table.params th{text-align:left;font-weight:700;font-size:10.5px;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3);padding:8px 14px 8px 0;border-bottom:1px solid var(--line)}
119 −table.params td{padding:10px 14px 10px 0;border-bottom:1px solid rgba(11,19,48,.06);vertical-align:top;color:var(--ink-2)}
120 −table.params code{font-family:var(--mono);font-size:12px;color:var(--navy);font-weight:500}
121 −.req{font-family:var(--mono);font-size:10px;font-weight:700;border-radius:var(--r-pill);padding:2px 9px;background:var(--orange-soft);color:var(--orange-deep);letter-spacing:.04em;text-transform:uppercase}
122 −.opt{font-family:var(--mono);font-size:10px;font-weight:700;border-radius:var(--r-pill);padding:2px 9px;background:var(--navy-soft);color:var(--ink-3);letter-spacing:.04em;text-transform:uppercase}
123 −/* code blocks — terminal navy */
124 −.codeblock{margin-top:18px;border-radius:var(--r-ctl);overflow:hidden;background:var(--navy);box-shadow:var(--shadow-flat)}
125 −.code-tabs{display:flex;align-items:center;background:#101a3d;padding:0 10px;border-bottom:1px solid rgba(255,255,255,.08)}
126 −.code-tabs button{font-family:var(--mono);font-size:11.5px;font-weight:500;background:none;border:none;padding:11px 13px;cursor:pointer;color:#8a90a5;border-bottom:2px solid transparent;transition:color .12s ease}
127 −.code-tabs button:hover{color:#e9efff}
128 −.code-tabs button.on{color:var(--white);border-bottom-color:var(--orange)}
129 −.copy{margin-left:auto;font-family:var(--mono);font-size:10.5px;font-weight:500;background:none;border:1px solid rgba(255,255,255,.18);border-radius:7px;padding:4px 12px;cursor:pointer;color:#8a90a5;transition:all .12s ease}
130 −.copy:hover{color:var(--white);border-color:var(--orange)}
131 −pre{background:var(--navy);color:#e9efff;padding:17px 20px;overflow-x:auto;font-family:var(--mono);font-size:12.3px;line-height:1.7;white-space:pre}
104 +table.params{width:100%;min-width:520px;border-collapse:collapse;margin-top:18px;font-size:13.5px}
105 +table.params th{text-align:left;font-family:var(--font-mono);font-weight:700;font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3);padding:8px 14px 8px 0;border-bottom:1.5px solid var(--ink)}
106 +table.params td{padding:10px 14px 10px 0;border-bottom:1px solid var(--line);vertical-align:top;color:var(--ink-2)}
107 +table.params code{font-family:var(--font-mono);font-size:12px;color:var(--ink);font-weight:500}
108 +.req{font-family:var(--font-mono);font-size:10px;font-weight:700;border-radius:var(--r-pill);padding:2px 9px;background:var(--accent);color:var(--on-accent);letter-spacing:.04em;text-transform:uppercase}
109 +.opt{font-family:var(--font-mono);font-size:10px;font-weight:700;border-radius:var(--r-pill);padding:2px 9px;background:var(--surface-2);border:1px solid var(--line);color:var(--ink-3);letter-spacing:.04em;text-transform:uppercase}
110 +/* code blocks — terminal encre */
111 +.codeblock{margin-top:18px;border:1.5px solid var(--ink);border-radius:var(--r-ctl);overflow:hidden;background:var(--ink);box-shadow:var(--shadow-off-mid)}
112 +.code-tabs{display:flex;align-items:center;padding:0 8px;border-bottom:1px solid rgba(245,243,238,.12);overflow-x:auto}
113 +.code-tabs button{font-family:var(--font-mono);font-size:11.5px;font-weight:500;background:none;border:none;padding:12px 13px;min-height:var(--touch);cursor:pointer;color:rgba(245,243,238,.5);border-bottom:2px solid transparent;transition:color .12s;white-space:nowrap}
114 +.code-tabs button:hover{color:var(--paper)}
115 +.code-tabs button.on{color:var(--paper);border-bottom-color:var(--accent)}
116 +.copy{margin-left:auto;font-family:var(--font-mono);font-size:10.5px;font-weight:500;background:none;border:1px solid rgba(245,243,238,.25);border-radius:6px;padding:6px 12px;min-height:32px;cursor:pointer;color:rgba(245,243,238,.6);transition:color .12s,border-color .12s;white-space:nowrap}
117 +.copy:hover{color:var(--paper);border-color:var(--accent)}
118 +pre{margin:0;background:var(--ink);color:var(--paper);padding:16px 18px;overflow-x:auto;font-family:var(--font-mono);font-size:12.2px;line-height:1.7;white-space:pre}
119 +
132 120 /* ================= playground ================= */
133 −.pg{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-card);padding:32px;box-shadow:var(--shadow-off-soft)}
134 −.pg-grid{display:grid;grid-template-columns:340px 1fr;gap:34px}
135 −@media(max-width:880px){.pg-grid{grid-template-columns:1fr}}
121 +.pg{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);padding:28px;box-shadow:var(--shadow-off-soft)}
122 +@media(max-width:767px){.pg{padding:18px}}
123 +.pg-grid{display:grid;grid-template-columns:330px 1fr;gap:32px}
124 +@media(max-width:1023px){.pg-grid{grid-template-columns:1fr}}
136 125 .field{margin-bottom:16px}
137 −.field label{display:block;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3);margin-bottom:7px}
138 −.field select,.field input{width:100%;font-family:var(--mono);font-size:13px;padding:11px 14px;border:1px solid var(--line-strong);border-radius:var(--r-ctl);background:var(--surface);color:var(--navy);transition:border-color .12s ease,box-shadow .12s ease;appearance:auto}
139 −.field select:focus,.field input:focus{outline:none;border-color:var(--orange);box-shadow:0 0 0 4px var(--orange-soft)}
140 −.send{width:100%;font-family:var(--font);font-weight:700;font-size:15px;background:var(--orange);color:var(--white);border:none;border-radius:var(--r-pill);padding:13px;cursor:pointer;margin-top:6px;min-height:48px;box-shadow:0 6px 18px #ff6a0052;transition:transform .12s ease,background .15s ease,box-shadow .12s ease}
141 −.send:hover{background:var(--orange-deep);transform:translateY(-1px);box-shadow:0 10px 24px #ff6a0060}
142 −.send:disabled{opacity:.55;cursor:wait;transform:none}
143 −.pg-url{font-family:var(--mono);font-size:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);padding:12px 14px;word-break:break-all;margin-bottom:12px;display:flex;gap:12px;align-items:flex-start;color:var(--navy)}
126 +.field label{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3);margin-bottom:7px}
127 +.field select,.field input{width:100%;min-height:var(--touch);font-family:var(--font-mono);font-size:13px;padding:10px 14px;border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--surface);color:var(--ink);appearance:auto}
128 +.field select:focus,.field input:focus{outline:none;box-shadow:3px 3px 0 var(--accent)}
129 +.send{width:100%;font-family:var(--font-display);font-weight:700;font-size:15px;background:var(--ink);color:var(--accent);border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:12px;cursor:pointer;margin-top:6px;min-height:48px;transition:transform .15s,box-shadow .15s,background .15s}
130 +.send:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off-mid);background:var(--accent-deep);color:#fff}
131 +.send:disabled{opacity:.55;cursor:wait;transform:none;box-shadow:none}
132 +.pg-url{font-family:var(--font-mono);font-size:12px;background:var(--surface-2);border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:12px 14px;word-break:break-all;margin-bottom:12px;display:flex;gap:12px;align-items:flex-start;color:var(--ink)}
144 133 .pg-url span{flex:1}
145 134 .pg-url .copy{border-color:var(--line-strong);color:var(--ink-3)}
146 −.pg-url .copy:hover{color:var(--navy);border-color:var(--orange)}
135 +.pg-url .copy:hover{color:var(--ink);border-color:var(--accent)}
147 136 .pg-status{display:flex;gap:10px;margin-bottom:12px;flex-wrap:wrap}
148 −.pg-status span:not(:empty){font-family:var(--mono);font-size:11.5px;font-weight:700;border-radius:var(--r-pill);padding:4px 13px;background:var(--navy-soft);color:var(--ink-2)}
149 −.pg-status .s-ok{background:var(--blue-soft);color:var(--blue)}
150 −.pg-status .s-err{background:#fde8e8;color:var(--danger)}
151 −#pg-out{background:var(--navy);color:#e9efff;border-radius:var(--r-ctl);padding:18px;font-family:var(--mono);font-size:12.3px;line-height:1.6;max-height:500px;overflow:auto;white-space:pre-wrap;word-break:break-word;min-height:220px;box-shadow:var(--shadow-flat)}
137 +.pg-status span:not(:empty){font-family:var(--font-mono);font-size:11px;font-weight:700;border-radius:var(--r-pill);border:1.5px solid var(--ink);padding:4px 13px;background:var(--surface);color:var(--ink-2)}
138 +.pg-status .s-ok{background:var(--accent-soft);color:var(--accent-deep)}
139 +.pg-status .s-err{background:var(--danger-soft);color:var(--danger)}
140 +#pg-out{background:var(--ink);color:var(--paper);border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:16px;font-family:var(--font-mono);font-size:12.2px;line-height:1.6;max-height:500px;overflow:auto;white-space:pre-wrap;word-break:break-word;min-height:220px}
141 +
152 142 /* ================= conventions ================= */
153 143 .conv-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:16px}
154 −.conv{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-card);padding:24px;box-shadow:var(--shadow-flat);transition:transform .16s ease,box-shadow .16s ease}
155 −.conv:hover{transform:translateY(-3px);box-shadow:var(--shadow-off-soft)}
156 −.conv h3{font-size:15.5px;font-weight:800;letter-spacing:-.01em;color:var(--navy);margin-bottom:8px;display:flex;align-items:center;gap:9px}
157 −.conv h3 .ico{width:26px;height:26px;flex:none;border-radius:8px;background:var(--orange-soft);color:var(--orange);display:inline-flex;align-items:center;justify-content:center;font-size:13px}
158 −.conv p,.conv li{font-size:13.5px;color:var(--ink-2)}
159 −.conv ul{padding-left:18px}
160 −/* ================= footer ================= */
161 −footer{background:var(--navy);color:#8a90a5;padding:44px 0;font-size:13.5px}
162 −footer .wrap{display:flex;flex-wrap:wrap;gap:14px;justify-content:space-between;align-items:center}
163 −footer b{color:var(--white);font-weight:700}
164 −footer a{color:var(--orange);text-decoration:none;font-weight:600}
165 −footer a:hover{color:#ffb277}
144 +.conv{padding:22px}
145 +.conv h3{margin-bottom:8px;display:flex;align-items:center;gap:9px}
146 +.conv h3 .ico{width:26px;height:26px;flex:none;border-radius:7px;background:var(--accent);color:var(--on-accent);display:inline-flex;align-items:center;justify-content:center;font-family:var(--font-mono);font-size:12px}
147 +.conv p,.conv li{font-size:13.5px;color:var(--ink-2);margin:0}
148 +.conv ul{padding-left:18px;margin:0}
149 +.conv li+li{margin-top:4px}
150 +
166 151 /* reveal on scroll */
167 152 .reveal{opacity:0;transform:translateY(14px);transition:opacity .5s ease,transform .5s ease}
168 153 .reveal.in{opacity:1;transform:none}
@@ -171,27 +156,30 @@ footer a:hover{color:#ffb277}
171 156 </head>
172 157 <body>
173 158
174 −<div class="topbar"><div class="wrap">
175 − <a class="brand" href="#">
176 − <svg viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#0B1330"/><ellipse cx="32" cy="18" rx="17" ry="7" fill="none" stroke="#FF6A00" stroke-width="5"/><path d="M15 18v28c0 3.9 7.6 7 17 7s17-3.1 17-7V18" fill="none" stroke="#FF6A00" stroke-width="5"/><path d="M15 32c0 3.9 7.6 7 17 7s17-3.1 17-7" fill="none" stroke="#ffffff" stroke-width="4"/></svg>
177 − <span>API-<span class="hl">KA</span></span><span class="brand-tag">Les données de l'écosystème KA</span>
178 − </a>
159 +<div class="topbar"><div class="container topbar-in">
160 + <a class="brand" href="/" aria-label="API-Ka — accueil">API<span class="ka">·Ka</span></a>
161 + <a class="gk-badge" href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer"
162 + aria-label="Un service Groupe KA — visiter le portail de l'écosystème">Un service <b>Groupe<span class="ka">KA</span></b></a>
179 163 <ul class="topnav">
180 164 <li><a href="#services">Services</a></li>
181 165 <li><a href="#endpoints">Endpoints</a></li>
182 166 <li><a href="#playground">Playground</a></li>
183 167 <li><a href="#conventions">Conventions</a></li>
184 168 <li><a href="/docs">Swagger</a></li>
169 + <li><a href="/contact">Contact</a></li>
185 170 </ul>
186 − <span class="live-chip"><span class="pulse" id="live-dot"></span><span id="live-txt">vérification…</span></span>
171 + <div class="topbar-tools">
172 + <span class="live-chip"><span class="pulse" id="live-dot"></span><span id="live-txt">vérification…</span></span>
173 + <div id="ka-auth"></div>
174 + </div>
187 175 </div></div>
188 176
189 −<header class="hero"><div class="wrap"><div class="hero-grid">
177 +<header class="hero"><div class="container"><div class="hero-grid">
190 178 <div>
191 179 <span class="kicker">Plateforme centrale · Node m3u96b</span>
192 − <h1>Toutes les données <span class="hl">KA</span>,<br>centralisées <span class="hl">chaque jour</span>.</h1>
193 − <p class="lede">API-KA collecte, sauvegarde et historise quotidiennement les données des huit
194 − services de l'écosystème KA — logements, propriétés, épicerie, véhicules, produits
180 + <h1>Toutes les données <span class="hl">KA</span>, centralisées chaque jour.</h1>
181 + <p class="lede">API-Ka collecte, sauvegarde et historise quotidiennement les données des huit
182 + services de l'écosystème Groupe KA — logements, propriétés, épicerie, véhicules, produits
195 183 québécois, restaurants, sorties et créateurs — puis les expose via une API publique,
196 184 versionnée et documentée.</p>
197 185 <div class="hero-cta">
@@ -211,16 +199,16 @@ footer a:hover{color:#ffb277}
211 199 </div>
212 200 </div></div></header>
213 201
214 −<section id="services"><div class="wrap">
215 − <span class="sec-kicker">01 — Sources</span>
202 +<section id="services"><div class="container">
203 + <span class="kicker">01 — Sources</span>
216 204 <h2>Les huit <span class="hl">services</span></h2>
217 205 <p class="sec-sub">Chaque service est interrogé tous les jours à 02:00. Les données brutes sont
218 206 validées, dédupliquées par empreinte SHA-256, historisées en mode append-only et sauvegardées.</p>
219 207 <div class="svc-grid" id="svc-grid"></div>
220 208 </div></section>
221 209
222 −<section id="endpoints" class="alt"><div class="wrap">
223 − <span class="sec-kicker">02 — Référence</span>
210 +<section id="endpoints" class="alt"><div class="container">
211 + <span class="kicker">02 — Référence</span>
224 212 <h2>Les <span class="hl">endpoints</span></h2>
225 213 <p class="sec-sub">Base : <code>https://www.api-ka.com</code> — toutes les réponses suivent
226 214 l'enveloppe uniforme <code>{ success, data, meta }</code>.
@@ -228,8 +216,8 @@ footer a:hover{color:#ffb277}
228 216 <div id="ep-list"></div>
229 217 </div></section>
230 218
231 −<section id="playground"><div class="wrap">
232 − <span class="sec-kicker">03 — Essayer</span>
219 +<section id="playground"><div class="container">
220 + <span class="kicker">03 — Essayer</span>
233 221 <h2>Play<span class="hl">ground</span></h2>
234 222 <p class="sec-sub">Composez une requête réelle contre l'API en production et inspectez la
235 223 réponse — statut, latence et corps JSON.</p>
@@ -247,39 +235,42 @@ footer a:hover{color:#ffb277}
247 235 </div></div>
248 236 </div></section>
249 237
250 −<section id="conventions" class="alt"><div class="wrap">
251 − <span class="sec-kicker">04 — Contrat</span>
238 +<section id="conventions" class="alt"><div class="container">
239 + <span class="kicker">04 — Contrat</span>
252 240 <h2>Conven<span class="hl">tions</span></h2>
253 241 <div class="conv-grid">
254 − <div class="conv reveal"><h3><span class="ico">{ }</span>Enveloppe uniforme</h3>
242 + <div class="conv card reveal"><h3><span class="ico">{ }</span>Enveloppe uniforme</h3>
255 243 <p>Toute réponse contient <code>success</code>, <code>data</code> et <code>meta</code>.
256 244 Le <code>meta</code> inclut toujours le node (<code>m3u96b</code>) et, pour les listes,
257 245 <code>page</code>, <code>limit</code> et <code>total</code>.</p></div>
258 − <div class="conv reveal"><h3><span class="ico">⇥</span>Pagination</h3>
246 + <div class="conv card reveal"><h3><span class="ico">⇥</span>Pagination</h3>
259 247 <p>Paramètres <code>?page=</code> (≥ 1) et <code>?limit=</code> (1 à 500, défaut 100).
260 248 Les enregistrements sont triés du plus récent au plus ancien.</p></div>
261 − <div class="conv reveal"><h3><span class="ico">⏱</span>Limites d'usage</h3>
249 + <div class="conv card reveal"><h3><span class="ico">⏱</span>Limites d'usage</h3>
262 250 <p>120 requêtes / minute / IP. Au-delà : <code>429</code> avec un corps JSON explicite.
263 251 <code>/health</code> n'est pas limité.</p></div>
264 − <div class="conv reveal"><h3><span class="ico">!</span>Erreurs</h3>
252 + <div class="conv card reveal"><h3><span class="ico">!</span>Erreurs</h3>
265 253 <ul><li><code>404</code> — service inconnu</li>
266 254 <li><code>422</code> — paramètre invalide (date, limit…)</li>
267 255 <li><code>429</code> — limite de débit dépassée</li></ul></div>
268 − <div class="conv reveal"><h3><span class="ico">#</span>Intégrité des données</h3>
256 + <div class="conv card reveal"><h3><span class="ico">#</span>Intégrité des données</h3>
269 257 <p>Historique append-only : rien n'est jamais supprimé. Chaque enregistrement porte un
270 258 <code>checksum</code> SHA-256 et une <code>date_key</code> logique. Déduplication par
271 259 <code>(source, date_key, checksum)</code>.</p></div>
272 − <div class="conv reveal"><h3><span class="ico">↻</span>Fiabilité</h3>
260 + <div class="conv card reveal"><h3><span class="ico">↻</span>Fiabilité</h3>
273 261 <p>Relance automatique (3 tentatives, backoff 30 s → 2 min → 10 min), rattrapage (backfill)
274 262 des 7 derniers jours, sauvegardes quotidiennes horodatées conservées 90 jours.</p></div>
263 + <div class="conv card reveal"><h3><span class="ico">ID</span>Compte KA ID</h3>
264 + <p>La connexion « Se connecter avec KA » est déléguée au hub
265 + <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">groupe-ka.com</a>.
266 + Pour créer un compte : <a href="https://www.groupe-ka.com/connexion" target="_blank"
267 + rel="noopener noreferrer">groupe-ka.com/connexion</a>.</p></div>
275 268 </div>
276 269 </div></section>
277 270
278 −<footer><div class="wrap">
279 − <span>API-<b>KA</b> — plateforme légendaire de l'écosystème KA · Node <b>m3u96b</b></span>
280 − <span>Créée par <b>Simon-Pierre Boucher</b> · <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a></span>
281 −</div></footer>
271 +<footer class="ka-footer" id="contact"><div class="container" id="ka-footer-root"></div></footer>
282 272
273 +<script src="/ka/ka-shell.js" defer></script>
283 274 <script>
284 275 "use strict";
285 276 const BASE = location.origin;
@@ -287,14 +278,14 @@ const NF = new Intl.NumberFormat("fr-CA");
287 278
288 279 /* ---------- catalogue des services ---------- */
289 280 const SERVICES = [
290 − {id:"louka", nom:"lou-<span class='hl'>ka</span>", site:"https://www.lou-ka.com", desc:"Logements locatifs au Québec — Québec, Lévis, Grand Montréal."},
291 − {id:"immoka", nom:"immo-<span class='hl'>ka</span>", site:"https://www.immo-ka.com", desc:"Propriétés à vendre au Québec, toutes agences confondues."},
292 − {id:"foodka", nom:"food-<span class='hl'>ka</span>", site:"https://www.food-ka.com", desc:"Produits d'épicerie québécois et suivi des prix."},
293 − {id:"autoka", nom:"auto-<span class='hl'>ka</span>", site:"https://www.auto-ka.com", desc:"Véhicules usagés des concessionnaires du Québec."},
294 − {id:"fabrika",nom:"fabri-<span class='hl'>ka</span>", site:"https://www.fabri-ka.com", desc:"Produits des boutiques et fabricants québécois."},
295 − {id:"restoka",nom:"resto-<span class='hl'>ka</span>", site:"https://www.resto-ka.com", desc:"Restaurants du Québec — menus complets et prix réels."},
296 − {id:"sortika",nom:"sorti-<span class='hl'>ka</span>", site:"https://www.sorti-ka.com", desc:"Sorties et événements dans les 17 régions du Québec."},
297 − {id:"creaka", nom:"crea-<span class='hl'>ka</span>", site:"https://www.crea-ka.com", desc:"Annuaire des créateurs de contenu québécois et de leurs comptes."},
281 + {id:"louka", nom:"Lou·Ka", site:"https://www.lou-ka.com", desc:"Logements locatifs au Québec — Québec, Lévis, Grand Montréal."},
282 + {id:"immoka", nom:"Immo·Ka", site:"https://www.immo-ka.com", desc:"Propriétés à vendre au Québec, toutes agences confondues."},
283 + {id:"foodka", nom:"Food·Ka", site:"https://www.food-ka.com", desc:"Produits d'épicerie québécois et suivi des prix."},
284 + {id:"autoka", nom:"Auto·Ka", site:"https://www.auto-ka.com", desc:"Véhicules usagés des concessionnaires du Québec."},
285 + {id:"fabrika",nom:"Fabri·Ka", site:"https://www.fabri-ka.com", desc:"Produits des boutiques et fabricants québécois."},
286 + {id:"restoka",nom:"Resto·Ka", site:"https://www.resto-ka.com", desc:"Restaurants du Québec — menus complets et prix réels."},
287 + {id:"sortika",nom:"Sorti·Ka", site:"https://www.sorti-ka.com", desc:"Sorties et événements dans les 17 régions du Québec."},
288 + {id:"creaka", nom:"Créa·Ka", site:"https://www.crea-ka.com", desc:"Annuaire des créateurs de contenu québécois et de leurs comptes."},
298 289 ];
299 290
300 291 /* ---------- catalogue des endpoints ---------- */
@@ -359,7 +350,7 @@ ENDPOINTS.forEach((ep, i) => {
359 350 const ex = ep.build({service:"louka", date:"2026-08-16", page:1, limit:100});
360 351 const sn = snippets(ex);
361 352 const card = document.createElement("article");
362 − card.className = "ep reveal";
353 + card.className = "ep card reveal";
363 354 card.innerHTML = `
364 355 <div class="ep-top">
365 356 <span class="ep-num">${String(i+1).padStart(2,"0")}</span>
@@ -368,10 +359,10 @@ ENDPOINTS.forEach((ep, i) => {
368 359 <a class="try" href="#playground" data-ep="${ep.id}">Essayer ↓</a>
369 360 </div>
370 361 <p class="ep-desc">${ep.desc}</p>
371 − ${ep.params.length ? `<table class="params"><tr><th>Paramètre</th><th>Type</th><th></th><th>Description</th></tr>
362 + ${ep.params.length ? `<div class="tbl-wrap"><table class="params"><tr><th>Paramètre</th><th>Type</th><th></th><th>Description</th></tr>
372 363 ${ep.params.map(([n,t,d,req])=>`<tr><td><code>${n}</code></td><td><code>${t}</code></td>
373 364 <td>${req?'<span class="req">requis</span>':'<span class="opt">optionnel</span>'}</td><td>${d}</td></tr>`).join("")}
374 − </table>`:""}
365 + </table></div>`:""}
375 366 <div class="codeblock">
376 367 <div class="code-tabs">
377 368 <button class="on" data-lang="curl">curl</button>
@@ -530,7 +521,7 @@ function relTime(iso){
530 521 let grand = 0, done = 0;
531 522 for (const s of SERVICES) {
532 523 const card = document.createElement("div");
533 − card.className = "svc reveal"; card.id = "svc-" + s.id;
524 + card.className = "svc card card-hover reveal"; card.id = "svc-" + s.id;
534 525 card.innerHTML = `<h3>${s.nom}</h3><div class="path">/api/v1/${s.id}</div>
535 526 <p>${s.desc}</p><b>…</b><small>enregistrements historisés</small>
536 527 <a href="${s.site}" target="_blank" rel="noopener">${s.site.replace("https://","")}&nbsp;↗</a>`;
added src/api/web/ka/GroupeKaBadge.tsx +21 −0
@@ -0,0 +1,21 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// ka-ui/react/GroupeKaBadge.tsx — badge « Un service Groupe KA », cliquable
3 +// vers le hub. À placer dans le header de chaque site (à côté du logo de la
4 +// marque). Variante dark pour les surfaces encre. Zone de protection : le
5 +// padding interne du badge suffit ; ne rien coller à moins de 8 px du badge.
6 +export default function GroupeKaBadge({ dark = false }: { dark?: boolean }) {
7 + return (
8 + <a
9 + className={`gk-badge${dark ? " gk-badge--dark" : ""}`}
10 + href="https://www.groupe-ka.com"
11 + target="_blank"
12 + rel="noopener noreferrer"
13 + aria-label="Un service Groupe KA — visiter le portail de l'écosystème"
14 + >
15 + Un service
16 + <b>
17 + Groupe<span className="ka">KA</span>
18 + </b>
19 + </a>
20 + );
21 +}
added src/api/web/ka/KaFooter.tsx +91 −0
@@ -0,0 +1,91 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// ka-ui/react/KaFooter.tsx — footer commun Groupe KA (fond encre), à vendorer
3 +// dans chaque app Vite/React sous frontend/src/ka/. Consomme ecosystem.json :
4 +// toute modification des infos de contact se fait dans ka-ui/ecosystem.json
5 +// puis se resynchronise sur les 12 sites (voir ka-ui/README.md).
6 +import eco from "./ecosystem.json";
7 +
8 +type LocalLink = { label: string; href: string };
9 +
10 +export default function KaFooter({
11 + siteId,
12 + localLegal = [],
13 +}: {
14 + /** id du site courant dans ecosystem.json (ex. "resto-ka") */
15 + siteId: string;
16 + /** pages légales PROPRES au site (ex. /conditions), affichées avant celles du hub */
17 + localLegal?: LocalLink[];
18 +}) {
19 + const year = new Date().getFullYear();
20 + const site = eco.sites.find((s) => s.id === siteId);
21 + const others = eco.sites.filter((s) => s.id !== siteId);
22 + return (
23 + <footer className="ka-footer" id="contact">
24 + <div className="container">
25 + <a
26 + className="wordmark"
27 + href={eco.hub.url}
28 + target={siteId === "groupe-ka" ? undefined : "_blank"}
29 + rel="noopener noreferrer"
30 + aria-label="Groupe KA — le portail de l'écosystème"
31 + >
32 + Groupe <span className="ka">KA</span>
33 + </a>
34 + <p className="desc">
35 + {eco.org.tagline} Des centaines de connecteurs lisent les sites à la
36 + source, normalisent la donnée et se resynchronisent seuls.{" "}
37 + {site ? (
38 + <>
39 + <b style={{ color: "var(--paper)" }}>{site.wordmark}</b> —{" "}
40 + {site.tagline} — est un service Groupe KA.
41 + </>
42 + ) : null}
43 + </p>
44 + <p className="notice">
45 + <b>{eco.org.disclaimer}</b>
46 + </p>
47 + <ul className="sites">
48 + {others.map((s) => (
49 + <li key={s.id}>
50 + <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer">
51 + {s.wordmark}
52 + </a>
53 + </li>
54 + ))}
55 + {eco.extraFooterLinks.map((l) => (
56 + <li key={l.href}>
57 + <a href={l.href} target="_blank" rel="noopener noreferrer">
58 + {l.label}
59 + </a>
60 + </li>
61 + ))}
62 + </ul>
63 + <div className="contacts">
64 + {eco.contacts.map((c) => (
65 + <p key={c.email} style={{ margin: 0 }}>
66 + <a href={`mailto:${c.email}`}>{c.email}</a>
67 + <span>{c.role}</span>
68 + </p>
69 + ))}
70 + </div>
71 + <p className="legal">
72 + © {year} {eco.org.copyrightHolder}
73 + {localLegal.map((l) => (
74 + <span key={l.href}>
75 + {" · "}
76 + <a href={l.href}>{l.label}</a>
77 + </span>
78 + ))}
79 + {eco.legal.map((l) => (
80 + <span key={l.href}>
81 + {" · "}
82 + <a href={l.href} target="_blank" rel="noopener noreferrer">
83 + {l.label}
84 + </a>
85 + </span>
86 + ))}
87 + </p>
88 + </div>
89 + </footer>
90 + );
91 +}
added src/api/web/ka/ecosystem.json +45 −0
@@ -0,0 +1,45 @@
1 +{
2 + "org": {
3 + "name": "Groupe KA",
4 + "legalName": "Groupe KA — Simon-Pierre Boucher",
5 + "tagline": "Holding québécois d'agrégateurs de produits et services entièrement automatisés.",
6 + "disclaimer": "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction.",
7 + "copyrightHolder": "Groupe KA — Simon-Pierre Boucher"
8 + },
9 + "hub": {
10 + "url": "https://www.groupe-ka.com",
11 + "loginPath": "/connexion",
12 + "signupNote": "La création de compte KA ID se fait sur le hub groupe-ka.com ; chaque site délègue sa connexion via /api/auth/ka/login."
13 + },
14 + "contacts": [
15 + { "email": "contact@groupe-ka.com", "role": "Projets, partenariats & données" },
16 + { "email": "info@groupe-ka.com", "role": "Médias & questions générales" },
17 + { "email": "admin@groupe-ka.com", "role": "Légal, vie privée & Loi 25" }
18 + ],
19 + "legal": [
20 + { "label": "Conditions d'utilisation", "href": "https://www.groupe-ka.com/conditions" },
21 + { "label": "Politique de confidentialité", "href": "https://www.groupe-ka.com/confidentialite" },
22 + { "label": "Protection des renseignements personnels (Loi 25)", "href": "https://www.groupe-ka.com/loi-25" },
23 + { "label": "Transparence des robots d'indexation", "href": "https://www.groupe-ka.com/bots" }
24 + ],
25 + "sites": [
26 + { "id": "groupe-ka", "wordmark": "Groupe KA", "domain": "www.groupe-ka.com", "accent": "#d9f26b", "accentSoft": "#f0f9d2", "accentDeep": "#123f2e", "onAccent": "#141814", "tagline": "Le portail de l'écosystème ·Ka" },
27 + { "id": "trouve-ka", "wordmark": "Trouve·Ka", "domain": "www.trouve-ka.com", "accent": "#1c7ed6", "accentSoft": "#e7f2fd", "accentDeep": "#14508f", "onAccent": "#ffffff", "tagline": "Le moteur de recherche du web québécois" },
28 + { "id": "lou-ka", "wordmark": "Lou·Ka", "domain": "www.lou-ka.com", "accent": "#ff6a00", "accentSoft": "#fff1e6", "accentDeep": "#cc5500", "onAccent": "#ffffff", "tagline": "Tous les logements à louer" },
29 + { "id": "immo-ka", "wordmark": "Immo·Ka", "domain": "www.immo-ka.com", "accent": "#e23744", "accentSoft": "#fbe0e2", "accentDeep": "#a8232e", "onAccent": "#ffffff", "tagline": "Toutes les propriétés à vendre" },
30 + { "id": "vrai-prix", "wordmark": "Vrai-Prix", "domain": "www.vrai-prix.com", "accent": "#ff5148", "accentSoft": "#ffe3e0", "accentDeep": "#9e2a25", "onAccent": "#ffffff", "tagline": "La valeur réelle de chaque propriété" },
31 + { "id": "auto-ka", "wordmark": "Auto·Ka", "domain": "www.auto-ka.com", "accent": "#ff5a2a", "accentSoft": "#ffe8de", "accentDeep": "#cc3f16", "onAccent": "#ffffff", "tagline": "Les voitures usagées du Québec" },
32 + { "id": "fabri-ka", "wordmark": "Fabri·Ka", "domain": "www.fabri-ka.com", "accent": "#c4532e", "accentSoft": "#f7e3da", "accentDeep": "#a94525", "onAccent": "#ffffff", "tagline": "Les produits fabriqués au Québec" },
33 + { "id": "food-ka", "wordmark": "Food·Ka", "domain": "www.food-ka.com", "accent": "#1f9d55", "accentSoft": "#e2f5ea", "accentDeep": "#157a40", "onAccent": "#ffffff", "tagline": "Les prix d'épicerie, suivis à la source" },
34 + { "id": "resto-ka", "wordmark": "Resto·Ka", "domain": "www.resto-ka.com", "accent": "#f08c00", "accentSoft": "#fdeed7", "accentDeep": "#b96a00", "onAccent": "#141814", "tagline": "Chaque resto, chaque plat, chaque prix" },
35 + { "id": "sorti-ka", "wordmark": "Sorti·Ka", "domain": "www.sorti-ka.com", "accent": "#d6336c", "accentSoft": "#fbe0eb", "accentDeep": "#a12551", "onAccent": "#ffffff", "tagline": "Toutes les sorties, dans les 17 régions" },
36 + { "id": "crea-ka", "wordmark": "Créa·Ka", "domain": "www.crea-ka.com", "accent": "#7048e8", "accentSoft": "#ece5fc", "accentDeep": "#5433b8", "onAccent": "#ffffff", "tagline": "Les créateurs d'ici, tous leurs liens" },
37 + { "id": "api-ka", "wordmark": "API·Ka", "domain": "www.api-ka.com", "accent": "#3b5bdb", "accentSoft": "#e4eafb", "accentDeep": "#2b44a8", "onAccent": "#ffffff", "tagline": "La donnée de l'écosystème, par API" }
38 + ],
39 + "extraFooterLinks": [
40 + { "label": "ValoPlex", "href": "https://www.valoplex.com" },
41 + { "label": "Ka2", "href": "https://www.ka2.bot" },
42 + { "label": "Ka4", "href": "https://www.ka4.bot" },
43 + { "label": "Ka6", "href": "https://www.ka6.bot" }
44 + ]
45 +}
added src/api/web/ka/ka-shell.js +105 −0
@@ -0,0 +1,105 @@
1 +/* ============================================
2 + Projet : API-KA
3 + Fichier : src/api/web/ka/ka-shell.js
4 + Node : m3u96b
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + Date : 2026-08-17
8 + ============================================
9 + Coquille commune Groupe KA pour les pages statiques d'API-KA :
10 + · footer commun .ka-footer (équivalent vanilla de ka-ui/KaFooter.tsx),
11 + alimenté par /ka/ecosystem.json (source de vérité unique) ;
12 + · cartes contact (page /contact, conteneur #ka-contacts) ;
13 + · état de connexion KA ID dans le header (conteneur #ka-auth). */
14 +"use strict";
15 +
16 +const KA_SITE_ID = "api-ka";
17 +
18 +async function kaEco() {
19 + const r = await fetch("/ka/ecosystem.json");
20 + return r.json();
21 +}
22 +
23 +function el(tag, cls, html) {
24 + const e = document.createElement(tag);
25 + if (cls) e.className = cls;
26 + if (html !== undefined) e.innerHTML = html;
27 + return e;
28 +}
29 +
30 +function esc(s) {
31 + return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
32 +}
33 +
34 +/* ---------- footer commun (structure .ka-footer de tokens.css) ---------- */
35 +function kaRenderFooter(eco) {
36 + const root = document.getElementById("ka-footer-root");
37 + if (!root) return;
38 + const site = eco.sites.find((s) => s.id === KA_SITE_ID);
39 + const others = eco.sites.filter((s) => s.id !== KA_SITE_ID);
40 + const year = new Date().getFullYear();
41 + root.innerHTML = `
42 + <a class="wordmark" href="${eco.hub.url}" target="_blank" rel="noopener noreferrer"
43 + aria-label="Groupe KA — le portail de l'écosystème">Groupe <span class="ka">KA</span></a>
44 + <p class="desc">${esc(eco.org.tagline)} Des centaines de connecteurs lisent les sites à la
45 + source, normalisent la donnée et se resynchronisent seuls.
46 + ${site ? `<b style="color:var(--paper)">${esc(site.wordmark)}</b> — ${esc(site.tagline)} — est un service Groupe KA.` : ""}</p>
47 + <p class="notice"><b>${esc(eco.org.disclaimer)}</b></p>
48 + <ul class="sites">
49 + ${others.map((s) => `<li><a href="https://${s.domain}" target="_blank" rel="noopener noreferrer">${esc(s.wordmark)}</a></li>`).join("")}
50 + ${(eco.extraFooterLinks || []).map((l) => `<li><a href="${l.href}" target="_blank" rel="noopener noreferrer">${esc(l.label)}</a></li>`).join("")}
51 + </ul>
52 + <div class="contacts">
53 + ${eco.contacts.map((c) => `<p style="margin:0"><a href="mailto:${c.email}">${c.email}</a><span>${esc(c.role)}</span></p>`).join("")}
54 + </div>
55 + <p class="legal">© ${year} ${esc(eco.org.copyrightHolder)}
56 + ${eco.legal.map((l) => ` · <a href="${l.href}" target="_blank" rel="noopener noreferrer">${esc(l.label)}</a>`).join("")}
57 + </p>`;
58 +}
59 +
60 +/* ---------- cartes contact (page /contact) ---------- */
61 +function kaRenderContacts(eco) {
62 + const zone = document.getElementById("ka-contacts");
63 + if (!zone) return;
64 + zone.innerHTML = eco.contacts
65 + .map(
66 + (c) => `<div class="card contact-card">
67 + <span class="klabel">${esc(c.role)}</span>
68 + <a class="contact-mail" href="mailto:${c.email}">${c.email}</a>
69 + </div>`
70 + )
71 + .join("");
72 + const hub = document.getElementById("ka-hub-link");
73 + if (hub) hub.href = eco.hub.url + eco.hub.loginPath;
74 +}
75 +
76 +/* ---------- connexion KA ID dans le header (#ka-auth) ---------- */
77 +async function kaRenderAuth() {
78 + const zone = document.getElementById("ka-auth");
79 + if (!zone) return;
80 + try {
81 + const r = await fetch("/api/auth/me", { headers: { Accept: "application/json" } });
82 + if (r.ok) {
83 + const u = await r.json();
84 + zone.innerHTML = `<span class="ka-user" title="${esc(u.email || "")}">
85 + ${u.picture ? `<img src="${u.picture}" alt="" class="ka-avatar">` : ""}
86 + <b>${esc(u.name || "membre")}</b></span>
87 + <button class="btn btn-ghost ka-logout" type="button">Déconnexion</button>`;
88 + zone.querySelector(".ka-logout").addEventListener("click", async () => {
89 + await fetch("/api/auth/logout", { method: "POST" });
90 + location.reload();
91 + });
92 + return;
93 + }
94 + } catch {}
95 + zone.innerHTML = `<a class="btn btn-accent ka-login" href="/api/auth/ka/login">Se connecter avec KA</a>`;
96 +}
97 +
98 +(async () => {
99 + kaRenderAuth();
100 + try {
101 + const eco = await kaEco();
102 + kaRenderFooter(eco);
103 + kaRenderContacts(eco);
104 + } catch {}
105 +})();
added src/api/web/ka/tokens.css +229 −0
@@ -0,0 +1,229 @@
1 +/* -----------------------------------------------------------------------------
2 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 + Fichier : ka-ui/tokens.css — SOURCE CANONIQUE (repo ka-ui sur spbgit)
4 + Desc. : Design system commun GROUPE KA « éditorial sharp » — tokens + socle
5 + de composants partagés par les 12 plateformes. Chaque site importe ce
6 + fichier PUIS surcharge uniquement son accent (voir accents.css).
7 +
8 + · Typo : display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
9 + · Palette: papier #f5f3ee · encre #141814 · vert profond #1c5c41 + ACCENT du site
10 + · Signature : bordures encre 1,5–2 px + ombres décalées dures, grain de film,
11 + surlignés d'accent inclinés (néo-brutalisme raffiné)
12 + · Breakpoints communs : 360 / 768 / 1024 / 1440 px (mobile-first)
13 + · Zones tactiles ≥ 44×44 px, safe-areas iOS, typographie fluide (clamp)
14 +----------------------------------------------------------------------------- */
15 +
16 +:root {
17 + /* ---- Palette fixe Groupe KA (identique sur les 12 sites) ---- */
18 + --paper: #f5f3ee;
19 + --surface: #ffffff;
20 + --surface-2: #faf9f5;
21 + --ink: #141814;
22 + --ink-2: #4d5551;
23 + --ink-3: #8b928c;
24 + --line: rgba(20, 24, 20, 0.14);
25 + --line-strong: rgba(20, 24, 20, 0.85);
26 + --green: #1c5c41;
27 + --green-deep: #123f2e;
28 + --amber: #e8a33d;
29 + --amber-soft: #fdf3e2;
30 + --danger: #b3423a;
31 + --danger-soft: #fbe9e7;
32 +
33 + /* ---- ACCENT du site — SEULES variables surchargées par marque ---- */
34 + --accent: #d9f26b; /* défaut : lime Groupe KA */
35 + --accent-soft: #f0f9d2;
36 + --accent-deep: #123f2e;
37 + --on-accent: var(--ink); /* couleur du texte posé SUR l'accent */
38 +
39 + /* alias rétro-compatibles (les satellites historiques utilisent --lime) */
40 + --lime: var(--accent);
41 + --lime-soft: var(--accent-soft);
42 +
43 + /* ---- Géométrie sharp ---- */
44 + --r-card: 10px;
45 + --r-ctl: 6px;
46 + --r-pill: 999px;
47 +
48 + /* ---- Ombres décalées — la signature du groupe ---- */
49 + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
50 + --shadow-off: 6px 6px 0 var(--ink);
51 + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
52 + --shadow-off-mid: 4px 4px 0 rgba(20, 24, 20, 0.18);
53 +
54 + /* ---- Typographie ---- */
55 + --font-display: "Space Grotesk", system-ui, sans-serif;
56 + --font-body: "Inter", system-ui, sans-serif;
57 + --font-mono: "JetBrains Mono", ui-monospace, monospace;
58 +
59 + /* Échelle fluide (mobile-first, clamp) */
60 + --fs-h1: clamp(30px, 3.2vw + 18px, 54px);
61 + --fs-h2: clamp(23px, 1.8vw + 14px, 34px);
62 + --fs-h3: clamp(17px, 0.9vw + 12px, 22px);
63 + --fs-body: 15px;
64 + --fs-small: 13px;
65 +
66 + /* Espacements */
67 + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
68 + --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px;
69 +
70 + /* Cible tactile minimale */
71 + --touch: 44px;
72 +}
73 +
74 +/* ---- Socle ---- */
75 +* { box-sizing: border-box; }
76 +html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
77 +body {
78 + margin: 0;
79 + background: var(--paper);
80 + color: var(--ink);
81 + font-family: var(--font-body);
82 + font-size: var(--fs-body);
83 + line-height: 1.55;
84 + -webkit-font-smoothing: antialiased;
85 + padding-bottom: env(safe-area-inset-bottom);
86 + overflow-x: clip; /* jamais de débordement horizontal */
87 +}
88 +img, svg, video { max-width: 100%; height: auto; display: block; }
89 +h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0 0 0.5em; }
90 +h1 { font-size: var(--fs-h1); line-height: 1.06; }
91 +h2 { font-size: var(--fs-h2); line-height: 1.15; }
92 +h3 { font-size: var(--fs-h3); line-height: 1.25; }
93 +a { color: inherit; }
94 +::selection { background: var(--accent); color: var(--on-accent); }
95 +:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
96 +
97 +/* Grain de film pleine page — ⚠️ jamais de z-index sur body > * (position:
98 + relative seulement), sinon les utilitaires z-* sont écrasés. */
99 +body::before {
100 + content: "";
101 + position: fixed; inset: 0; pointer-events: none; opacity: 0.35; z-index: 9999;
102 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
103 +}
104 +body > * { position: relative; }
105 +
106 +/* ---- Conteneur commun ---- */
107 +.container { max-width: 1152px; margin: 0 auto; padding: 0 var(--sp-4); }
108 +@media (min-width: 768px) { .container { padding: 0 var(--sp-5); } }
109 +
110 +/* ---- Micro-typographie signature ---- */
111 +.kicker {
112 + display: inline-flex; align-items: center; gap: 10px;
113 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
114 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--green);
115 +}
116 +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
117 +.klabel {
118 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
119 + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);
120 +}
121 +.hl {
122 + display: inline-block; background: var(--accent); color: var(--on-accent);
123 + border-radius: 8px; padding: 0 10px 2px; transform: rotate(-1deg);
124 +}
125 +.outline-txt { color: transparent; -webkit-text-stroke: 2px var(--ink); }
126 +
127 +/* ---- Boutons (cible ≥ 44 px) ---- */
128 +.btn {
129 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
130 + min-height: var(--touch); padding: 10px 18px;
131 + font-family: var(--font-display); font-weight: 700; font-size: 14px;
132 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
133 + background: var(--surface); color: var(--ink);
134 + cursor: pointer; text-decoration: none;
135 + transition: transform 0.15s, box-shadow 0.15s, background 0.15s, color 0.15s;
136 +}
137 +.btn:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); }
138 +.btn:active { transform: translate(0, 0); box-shadow: none; }
139 +.btn:disabled { opacity: 0.45; pointer-events: none; }
140 +.btn-primary { background: var(--ink); color: var(--accent); }
141 +.btn-primary:hover { background: var(--accent-deep); }
142 +.btn-accent { background: var(--accent); color: var(--on-accent); }
143 +.btn-ghost { background: transparent; border-color: var(--line); }
144 +.btn-ghost:hover { border-color: var(--ink); }
145 +
146 +/* ---- Cartes ---- */
147 +.card {
148 + background: var(--surface); border: 1.5px solid var(--ink);
149 + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
150 + overflow: hidden;
151 +}
152 +.card-hover { transition: transform 0.15s, box-shadow 0.15s; }
153 +.card-hover:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); }
154 +
155 +/* ---- Chips / badges ---- */
156 +.chip {
157 + display: inline-flex; align-items: center; gap: 6px;
158 + padding: 4px 11px; font-family: var(--font-mono); font-size: 11px; font-weight: 700;
159 + text-transform: uppercase; letter-spacing: 0.06em;
160 + border: 1.5px solid var(--ink); border-radius: var(--r-pill);
161 + background: var(--surface); color: var(--ink);
162 +}
163 +.chip-accent { background: var(--accent); color: var(--on-accent); }
164 +.chip-soft { background: var(--accent-soft); border-color: var(--line); }
165 +
166 +/* ---- Champs ---- */
167 +.input, .select, .textarea {
168 + width: 100%; min-height: var(--touch); padding: 10px 14px;
169 + font: inherit; color: var(--ink); background: var(--surface);
170 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
171 +}
172 +.input:focus, .select:focus, .textarea:focus {
173 + outline: none; box-shadow: 3px 3px 0 var(--accent); border-color: var(--ink);
174 +}
175 +.input::placeholder { color: var(--ink-3); }
176 +
177 +/* ---- Badge « Un service Groupe KA » (header, cliquable → hub) ---- */
178 +.gk-badge {
179 + display: inline-flex; align-items: center; gap: 7px;
180 + min-height: 30px; padding: 3px 10px 4px;
181 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
182 + letter-spacing: 0.08em; text-transform: uppercase; text-decoration: none;
183 + border: 1.5px solid var(--ink); border-radius: var(--r-pill);
184 + background: var(--surface); color: var(--ink-2); white-space: nowrap;
185 +}
186 +.gk-badge b {
187 + font-family: var(--font-display); font-size: 12px; letter-spacing: -0.02em;
188 + text-transform: none; color: var(--ink);
189 +}
190 +.gk-badge b .ka {
191 + display: inline-block; background: var(--ink); color: var(--accent);
192 + border-radius: 5px; padding: 0 5px 1px; margin-left: 3px; transform: rotate(-2deg);
193 +}
194 +.gk-badge:hover .ka { transform: rotate(0); }
195 +.gk-badge--dark { background: transparent; border-color: rgba(245,243,238,0.4); color: rgba(245,243,238,0.75); }
196 +.gk-badge--dark b { color: var(--paper); }
197 +
198 +/* ---- Footer commun (fond encre — voir react/KaFooter.tsx) ---- */
199 +.ka-footer { margin-top: var(--sp-8); background: var(--ink); padding: 44px 0; font-size: 13px; color: rgba(245,243,238,0.75); }
200 +.ka-footer a { text-decoration: none; }
201 +.ka-footer .wordmark { font-family: var(--font-display); font-weight: 700; font-size: 30px; letter-spacing: -0.04em; color: var(--paper); text-decoration: none; }
202 +.ka-footer .wordmark .ka { color: var(--accent); }
203 +.ka-footer .desc { max-width: 640px; margin-top: var(--sp-4); }
204 +.ka-footer .notice { max-width: 640px; margin-top: var(--sp-4); border-left: 2px solid var(--accent); padding-left: var(--sp-4); }
205 +.ka-footer .notice b { color: var(--paper); }
206 +.ka-footer .sites { list-style: none; display: flex; flex-wrap: wrap; gap: 10px 26px; margin: 26px 0 0; padding: 0; font-family: var(--font-mono); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
207 +.ka-footer .sites a { color: rgba(245,243,238,0.65); }
208 +.ka-footer .sites a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
209 +.ka-footer .contacts { display: grid; gap: 14px 32px; border-top: 1px solid rgba(245,243,238,0.15); margin-top: 30px; padding-top: 26px; }
210 +@media (min-width: 768px) { .ka-footer .contacts { grid-template-columns: repeat(3, 1fr); } }
211 +.ka-footer .contacts a { font-family: var(--font-mono); font-weight: 700; font-size: 12px; color: rgba(245,243,238,0.85); }
212 +.ka-footer .contacts a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
213 +.ka-footer .contacts span { display: block; margin-top: 3px; font-size: 11px; color: rgba(245,243,238,0.5); }
214 +.ka-footer .legal { margin-top: 30px; font-family: var(--font-mono); font-size: 11px; color: rgba(245,243,238,0.45); }
215 +.ka-footer .legal a { color: inherit; }
216 +.ka-footer .legal a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
217 +
218 +/* ---- Tableaux → cartes empilées sous 768 px ---- */
219 +.tbl-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
220 +@media (max-width: 767px) {
221 + .tbl-stack thead { display: none; }
222 + .tbl-stack tr { display: block; border: 1.5px solid var(--ink); border-radius: var(--r-card); margin-bottom: var(--sp-3); background: var(--surface); }
223 + .tbl-stack td { display: flex; justify-content: space-between; gap: var(--sp-3); padding: 8px 12px; border: 0; }
224 + .tbl-stack td::before { content: attr(data-label); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); }
225 +}
226 +
227 +/* ---- Utilitaires responsive ---- */
228 +.only-mobile { display: initial; } .only-desktop { display: none; }
229 +@media (min-width: 768px) { .only-mobile { display: none; } .only-desktop { display: initial; } }
230