SPB Git

spb/admin-ka

Public

admin-ka v2 — vrai Claude Code piloté, sessions détachées, Écosystème live, identité Ka

- Modèles par session: Fable 5 (défaut), Opus 5, Sonnet 5, Haiku (--model explicite)
- Modes de permission: normal (Autoriser/Refuser/Toujours), acceptEdits, plan, bypass
- Sessions détachées du WebSocket + replay du transcript à la reconnexion + notifications
- Monitoring Écosystème: 13 sites, checks 45 s, SSL, incidents, sweep nœuds 5 min, SQLite, push WS
- UI Groupe KA: crème/orange néo-brutaliste, bottom nav 4 onglets, graphiques SVG validés
- /clear /compact /cost /resume, coût cumulé, tokens de contexte

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 20, 2026) parent c9e420b Browse files

6 changed files +1,529 −524

added CLAUDE.md +48 −0
@@ -0,0 +1,48 @@
1+# CLAUDE.md — administration-ka.com (Admin-Ka v2)
2+
3+Console d'administration du Groupe KA : app chat mobile-first qui pilote le **vrai Claude Code CLI** sur le cluster MacLustr, plus un monitoring temps réel de tous les sites du groupe.
4+
5+## Exigences non négociables
6+
7+1. **Vrai Claude Code, jamais l'API Messages.** Le backend lance le binaire `claude` (headless : `claude -p --output-format stream-json --verbose --include-partial-messages`), avec `cwd` = le repo de l'app choisie. Chaque app KA s'exécute **sur son nœud de déploiement** (via SSH pour les nœuds distants) — le repo de prod est la seule source de vérité, aucun clone. Tous les événements du stream (texte, thinking, tool_use, tool_result, result) sont relayés et rendus comme dans le terminal (diffs colorés, blocs outils repliables, coût/tokens).
8+2. **Options Claude Code dans l'UI** : modèle par session (Opus/Sonnet/Haiku, `--model`), mode de permissions (`default` avec cartes Autoriser/Refuser/Toujours, `acceptEdits`, `plan`, bypass derrière confirmation), choix du projet/cwd, `/clear` `/compact` `/cost` `/resume`, affichage live du modèle actif, session ID, coût cumulé, tokens de contexte.
9+3. **Sessions détachées** : le process claude ne dépend JAMAIS du WebSocket. Transcript JSONL persisté par conversation ; à la reconnexion l'app rejoue l'historique manqué puis reprend le live. États : en cours / en attente de permission / inactive. Reconnexion auto avec backoff + indicateur. Notifications in-app quand une tâche se termine ou attend une permission.
10+4. **Écosystème** : health checks HTTPS toutes les 45 s des 13 sites KA depuis M3U96a (latence, code, SSL), sweep des nœuds toutes les 5 min (charge/RAM/disque + PM2 cpu/mem/status + dernier commit), historique SQLite (`data/eco.sqlite`, rétention 35 j), incidents (2 échecs consécutifs → alerte, rétablissement → alerte verte), uptime 24h/7j/30j, moy/p95, sparklines + graphique 24 h + barres d'uptime 14 j, push temps réel via le même WebSocket.
11+5. **Identité Groupe KA** : crème `#F2F1EC`, orange `#F97316` (traits de graphe `#e05f00`), encre `#1a1611`, cartes à bords noirs 2 px + ombres décalées, pills, typo ronde, pastille « Ka ». Header fixe + bottom nav (Chat / Sessions / Écosystème / Réglages) avec safe-areas. Statuts JAMAIS en couleur seule (● ▲ ■ + libellé).
12+6. **Rien de simulé** : tout est branché sur le vrai CLI et les vrais sites.
13+
14+## Architecture
15+
16+```
17+iPhone ⇄ wss://www.administration-ka.com (ngrok) ⇄ backend :3300 (M3U96a)
18+ backend ⇄ claude CLI (local, apps M3U96a)
19+ backend ⇄ ssh <nœud> claude CLI (apps distantes, repo de prod)
20+ └ tunnel SSH inverse -R : cartes de permission (perm-mcp MCP)
21+ backend ⇄ monitor.js : health checks + sweep nœuds + SQLite + push WS
22+```
23+
24+- `server/server.js` — HTTP + WS + orchestration claude + états de session.
25+- `server/monitor.js` — collecteur Écosystème (node:sqlite intégré, aucune dépendance native).
26+- `server/perm-mcp.js` — serveur MCP stdio pour `--permission-prompt-tool mcp__adminka__approve` ; déployé aussi sur M3U96b/M4M64a/M4M64b/M2M32 dans `~/.adminka/`.
27+- `public/` — frontend une page, 4 onglets, aucun framework.
28+
29+## Pièges connus (ne pas re-découvrir)
30+
31+- **Tailscale inter-nœuds bloqué (ACL)** : depuis M3U96a, joindre les nœuds via les noms Bonjour `.local` (config `~/.ssh/config` en place). M2U64 = sous-réseau 192.168.0.x.
32+- **IPv4 LAN non routée entre nœuds** (EHOSTUNREACH) : les permissions distantes passent par le tunnel SSH inverse, pas par le LAN.
33+- `ControlPath` ssh avec hostnames .local trop long → `%C`.
34+- Sans `--model`, les nœuds retombent sur leur défaut local (Sonnet) → toujours passer `--model` explicitement.
35+- Auth headless par `ANTHROPIC_API_KEY` de `~/.claude/.env` (pas de login OAuth sur les nœuds).
36+- Chaque run reçoit `--append-system-prompt` : après toute modif → rebuild + pm2 restart + healthcheck + commit/push **spbgit** (origin, pas GitHub).
37+
38+## Exploitation
39+
40+```bash
41+# sur M3U96a
42+launchctl kickstart -k gui/501/io.adminka.backend # redémarrer backend
43+launchctl kickstart -k gui/501/io.adminka.ngrok # redémarrer tunnel
44+tail -f ~/apps/admin-ka/data/backend.err.log
45+node server/set-password.js '<nouveau>' # changer le mot de passe
46+```
47+
48+Registre des déploiements : `~/Desktop/cluster-skill/cluster-deployments.json` (laptop, source de vérité). Repo : spbgit `~/srv/git/admin-ka.git`.
modified public/app.js +542 −105
@@ -1,4 +1,4 @@
1−/* admin-ka — client chat mobile pour Claude Code */ 1+/* Admin-Ka v2 — client : chat Claude Code + sessions détachées + écosystème */
2 (() => { 2 (() => {
3 const $ = (id) => document.getElementById(id); 3 const $ = (id) => document.getElementById(id);
4 const messagesEl = $('messages'); 4 const messagesEl = $('messages');
@@ -6,34 +6,82 @@
6 const sendBtn = $('sendBtn'); 6 const sendBtn = $('sendBtn');
7 const stopBtn = $('stopBtn'); 7 const stopBtn = $('stopBtn');
8 const projectSel = $('projectSel'); 8 const projectSel = $('projectSel');
9− const modeBtn = $('modeBtn');
10− const connDot = $('connDot');
11− const statusText = $('statusText');
12 9
13 marked.setOptions({ breaks: true, mangle: false, headerIds: false }); 10 marked.setOptions({ breaks: true, mangle: false, headerIds: false });
14 11
15− let ws = null; 12+ // ---------- état ----------
16− let wsRetry = 1000; 13+ const store = JSON.parse(localStorage.getItem('adminka') || '{}');
17− let currentChat = null; // {id, title, project, mode} 14+ const settings = {
18− let mode = 'safe'; 15+ model: store.model || 'fable',
16+ permMode: store.permMode || 'default',
17+ projectId: store.projectId || null,
18+ };
19+ function saveStore() { localStorage.setItem('adminka', JSON.stringify(settings)); }
20+
21+ let ws = null, wsRetry = 1000, wsWanted = true;
22+ let currentChat = null; // publicChat du serveur
19 let isRunning = false; 23 let isRunning = false;
20− let live = null; // bloc de streaming en cours {el, textEl, text} 24+ let live = null, liveThink = null;
21− let liveThink = null; 25+ let curView = 'chat';
22− const toolCards = new Map(); // tool_use_id -> {card, state, bodyEl} 26+ let ecoData = null;
23− const permCards = new Map(); // requestId -> card 27+ const toolCards = new Map();
28+ const permCards = new Map();
29+
30+ const MODEL_LABEL = { fable: 'Fable 5', opus: 'Opus 5', sonnet: 'Sonnet 5', haiku: 'Haiku' };
31+ const PERM_LABEL = { default: 'normal', acceptEdits: 'acceptEdits', plan: 'plan', bypass: '⚠ bypass' };
24 32
25− // ---------- helpers ---------- 33+ // ---------- utilitaires ----------
26 function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); } 34 function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
27− function nearBottom() { return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 120; }
28− function scrollBottom(force) {
29− if (force || nearBottom()) messagesEl.scrollTop = messagesEl.scrollHeight;
30− }
31 function el(tag, cls, html) { 35 function el(tag, cls, html) {
32 const e = document.createElement(tag); 36 const e = document.createElement(tag);
33 if (cls) e.className = cls; 37 if (cls) e.className = cls;
34 if (html !== undefined) e.innerHTML = html; 38 if (html !== undefined) e.innerHTML = html;
35 return e; 39 return e;
36 } 40 }
41+ function fmt$(v) { return (v || 0) < 0.005 ? '<0,01 $' : (v).toFixed(2).replace('.', ',') + ' $'; }
42+ function fmtTok(v) { return v > 1000 ? Math.round(v / 1000) + 'k' : String(v || 0); }
43+ function timeAgo(ts) {
44+ if (!ts) return '—';
45+ const s = (Date.now() - ts) / 1000;
46+ if (s < 90) return 'à l’instant';
47+ if (s < 3600) return Math.round(s / 60) + ' min';
48+ if (s < 86400) return Math.round(s / 3600) + ' h';
49+ return Math.round(s / 86400) + ' j';
50+ }
51+ function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; }
52+
53+ // ---------- navigation ----------
54+ const views = ['chat', 'sessions', 'eco', 'settings'];
55+ function switchView(name) {
56+ curView = name;
57+ for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name);
58+ document.querySelectorAll('.nav-btn').forEach((b) => {
59+ b.classList.toggle('active', b.dataset.view === name);
60+ if (b.dataset.view === name) b.querySelector('.badge').classList.add('hidden');
61+ });
62+ if (name === 'sessions') loadSessions();
63+ if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); }
64+ if (name === 'settings') refreshSettingsView();
65+ }
66+ document.querySelectorAll('.nav-btn').forEach((b) => { b.onclick = () => switchView(b.dataset.view); });
67+ function navBadge(view) {
68+ if (curView === view) return;
69+ const b = document.querySelector(`.nav-btn[data-view="${view}"] .badge`);
70+ if (b) b.classList.remove('hidden');
71+ }
72+
73+ // ---------- bannières ----------
74+ function banner(kind, html, onclick, ttl = 6000) {
75+ const b = el('div', 'banner ' + kind, html);
76+ if (onclick) b.onclick = () => { onclick(); b.remove(); };
77+ $('banners').appendChild(b);
78+ if (navigator.vibrate) navigator.vibrate(60);
79+ setTimeout(() => b.remove(), ttl);
80+ }
81+
82+ // ---------- scroll / messages ----------
83+ function nearBottom() { return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 130; }
84+ function scrollBottom(force) { if (force || nearBottom()) messagesEl.scrollTop = messagesEl.scrollHeight; }
37 function addMsg(node) { 85 function addMsg(node) {
38 $('welcome').classList.add('hidden'); 86 $('welcome').classList.add('hidden');
39 const wasNear = nearBottom(); 87 const wasNear = nearBottom();
@@ -56,7 +104,6 @@
56 return wrap; 104 return wrap;
57 } 105 }
58 106
59− // ---------- indicateur "travaille" ----------
60 function workingEl() { return messagesEl.querySelector('.working'); } 107 function workingEl() { return messagesEl.querySelector('.working'); }
61 function setRunning(r) { 108 function setRunning(r) {
62 isRunning = r; 109 isRunning = r;
@@ -65,12 +112,10 @@
65 let w = workingEl(); 112 let w = workingEl();
66 if (r && !w) { 113 if (r && !w) {
67 $('welcome').classList.add('hidden'); 114 $('welcome').classList.add('hidden');
68− w = el('div', 'working', '<div class="dots"><i></i><i></i><i></i></div><span>Claude travaille…</span>'); 115+ w = el('div', 'working', '<div class="dots"><i></i><i></i><i></i></div><span>Claude Code travaille…</span>');
69 messagesEl.appendChild(w); 116 messagesEl.appendChild(w);
70 scrollBottom(true); 117 scrollBottom(true);
71− } else if (!r && w) { 118+ } else if (!r && w) w.remove();
72− w.remove();
73− }
74 if (!r) { finalizeLive(); finalizeThink(); } 119 if (!r) { finalizeLive(); finalizeThink(); }
75 } 120 }
76 121
@@ -94,17 +139,15 @@
94 } 139 }
95 function finalizeThink() { 140 function finalizeThink() {
96 if (!liveThink) return; 141 if (!liveThink) return;
97− const txt = liveThink.text; 142+ const d = el('details', 'thinking-box', '<summary>réflexion</summary>');
98− const d = el('details', 'thinking-box'); 143+ const inner = el('div', 'thinking'); inner.textContent = liveThink.text;
99− d.innerHTML = '<summary>réflexion</summary>';
100− const inner = el('div', 'thinking'); inner.textContent = txt;
101 d.appendChild(inner); 144 d.appendChild(inner);
102 liveThink.el.replaceWith(d); 145 liveThink.el.replaceWith(d);
103 liveThink = null; 146 liveThink = null;
104 } 147 }
105 148
106 // ---------- cartes outils ---------- 149 // ---------- cartes outils ----------
107− const TOOL_ICONS = { Bash: '❯', Read: '📄', Edit: '✏️', MultiEdit: '✏️', Write: '📝', Glob: '🔍', Grep: '🔍', LS: '📁', Task: '🤖', WebFetch: '🌐', WebSearch: '🌐', TodoWrite: '☑️', NotebookRead: '📓', NotebookEdit: '📓' }; 150+ const TOOL_ICONS = { Bash: '❯', Read: '📄', Edit: '✏️', MultiEdit: '✏️', Write: '📝', Glob: '🔍', Grep: '🔍', LS: '📁', Task: '🤖', WebFetch: '🌐', WebSearch: '🌐', TodoWrite: '☑️', NotebookRead: '📓', NotebookEdit: '📓', ExitPlanMode: '📋' };
108 const TOOL_TINT = { Bash: 'bash', Edit: 'edit', MultiEdit: 'edit', Write: 'edit', NotebookEdit: 'edit', Read: 'read', Glob: 'read', Grep: 'read', LS: 'read', NotebookRead: 'read', WebFetch: 'web', WebSearch: 'web' }; 151 const TOOL_TINT = { Bash: 'bash', Edit: 'edit', MultiEdit: 'edit', Write: 'edit', NotebookEdit: 'edit', Read: 'read', Glob: 'read', Grep: 'read', LS: 'read', NotebookRead: 'read', WebFetch: 'web', WebSearch: 'web' };
109 function toolSubtitle(name, input) { 152 function toolSubtitle(name, input) {
110 input = input || {}; 153 input = input || {};
@@ -144,12 +187,13 @@
144 const ul = el('ul', 'todos'); 187 const ul = el('ul', 'todos');
145 for (const t of input.todos || []) { 188 for (const t of input.todos || []) {
146 const icon = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐'; 189 const icon = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐';
147− const li = el('li', t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'doing' : '', esc(icon + ' ' + t.content)); 190+ ul.appendChild(el('li', t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'doing' : '', esc(icon + ' ' + t.content)));
148− ul.appendChild(li);
149 } 191 }
150 body.appendChild(ul); 192 body.appendChild(ul);
151 } else if (name === 'Task') { 193 } else if (name === 'Task') {
152 body.appendChild(truncPre(input.prompt || JSON.stringify(input, null, 2))); 194 body.appendChild(truncPre(input.prompt || JSON.stringify(input, null, 2)));
195+ } else if (name === 'ExitPlanMode') {
196+ const md = el('div', 'md'); renderMd(md, input.plan || ''); body.appendChild(md);
153 } else { 197 } else {
154 body.appendChild(truncPre(JSON.stringify(input, null, 2), 1500)); 198 body.appendChild(truncPre(JSON.stringify(input, null, 2), 1500));
155 } 199 }
@@ -176,8 +220,7 @@
176 if (typeof block.content === 'string') text = block.content; 220 if (typeof block.content === 'string') text = block.content;
177 else if (Array.isArray(block.content)) text = block.content.map((c) => c.type === 'text' ? c.text : '[' + c.type + ']').join('\n'); 221 else if (Array.isArray(block.content)) text = block.content.map((c) => c.type === 'text' ? c.text : '[' + c.type + ']').join('\n');
178 if (text.trim()) { 222 if (text.trim()) {
179− const lbl = el('div', 'label', block.is_error ? 'erreur' : 'résultat'); 223+ t.bodyEl.appendChild(el('div', 'label', block.is_error ? 'erreur' : 'résultat'));
180− t.bodyEl.appendChild(lbl);
181 t.bodyEl.appendChild(truncPre(text)); 224 t.bodyEl.appendChild(truncPre(text));
182 } 225 }
183 } 226 }
@@ -186,26 +229,37 @@
186 function addPermCard(ev) { 229 function addPermCard(ev) {
187 finalizeLive(); finalizeThink(); 230 finalizeLive(); finalizeThink();
188 const card = el('div', 'perm'); 231 const card = el('div', 'perm');
189− card.innerHTML = `<div class="perm-title">🔐 Permission demandée — ${esc(ev.tool)}</div>`; 232+ const isPlan = ev.tool === 'ExitPlanMode';
190− const pre = el('pre'); pre.textContent = JSON.stringify(ev.input, null, 2); card.appendChild(pre); 233+ card.innerHTML = `<div class="perm-title">${isPlan ? '📋 Plan proposé' : '🔐 Permission — ' + esc(ev.tool)}</div>`;
234+ if (isPlan && ev.input?.plan) {
235+ const md = el('div', 'md'); renderMd(md, ev.input.plan); card.appendChild(md);
236+ } else {
237+ const pre = el('pre'); pre.textContent = JSON.stringify(ev.input, null, 2); card.appendChild(pre);
238+ }
191 const actions = el('div', 'perm-actions'); 239 const actions = el('div', 'perm-actions');
192 const deny = el('button', 'perm-deny', 'Refuser'); 240 const deny = el('button', 'perm-deny', 'Refuser');
193− const allow = el('button', 'perm-allow', 'Autoriser'); 241+ const allow = el('button', 'perm-allow', isPlan ? 'Approuver' : 'Autoriser');
194 actions.append(deny, allow); 242 actions.append(deny, allow);
243+ if (!isPlan) {
244+ const always = el('button', 'perm-always', 'Toujours');
245+ always.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true, always: true });
246+ actions.insertBefore(always, allow);
247+ }
195 card.appendChild(actions); 248 card.appendChild(actions);
196 deny.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: false }); 249 deny.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: false });
197 allow.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true }); 250 allow.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true });
198 addMsg(card); 251 addMsg(card);
199 permCards.set(ev.requestId, card); 252 permCards.set(ev.requestId, card);
200 scrollBottom(true); 253 scrollBottom(true);
201− if (navigator.vibrate) navigator.vibrate(80); 254+ if (navigator.vibrate) navigator.vibrate([60, 60, 60]);
202 } 255 }
203 function resolvePermCard(ev) { 256 function resolvePermCard(ev) {
204 const card = permCards.get(ev.requestId); 257 const card = permCards.get(ev.requestId);
205 if (!card) return; 258 if (!card) return;
206 const actions = card.querySelector('.perm-actions'); 259 const actions = card.querySelector('.perm-actions');
207 if (actions) actions.remove(); 260 if (actions) actions.remove();
208− card.appendChild(el('div', 'perm-resolved', ev.allow ? '✓ Autorisé' : '✗ Refusé' + (ev.reason === 'timeout' ? ' (timeout)' : ''))); 261+ card.appendChild(el('div', 'perm-resolved',
262+ ev.allow ? ('✓ Autorisé' + (ev.always ? ' (toujours)' : '')) : ('✗ Refusé' + (ev.reason === 'timeout' ? ' (délai dépassé)' : ''))));
209 } 263 }
210 264
211 // ---------- rendu des événements ---------- 265 // ---------- rendu des événements ----------
@@ -217,14 +271,16 @@
217 } 271 }
218 if (ev.type === 'status') { setRunning(ev.state === 'running'); return; } 272 if (ev.type === 'status') { setRunning(ev.state === 'running'); return; }
219 if (ev.type === 'error') { addMsg(el('div', 'error-box', esc(ev.message))); return; } 273 if (ev.type === 'error') { addMsg(el('div', 'error-box', esc(ev.message))); return; }
274+ if (ev.type === 'notice') { addMsg(el('div', 'notice-line', esc(ev.text))); return; }
220 if (ev.type === 'permission_request') { addPermCard(ev); return; } 275 if (ev.type === 'permission_request') { addPermCard(ev); return; }
221 if (ev.type === 'permission_resolved') { resolvePermCard(ev); return; } 276 if (ev.type === 'permission_resolved') { resolvePermCard(ev); return; }
277+ if (ev.type === 'permission_auto') { addMsg(el('div', 'meta-line', esc('🔓 auto-autorisé : ' + ev.key))); return; }
222 if (ev.type === 'claude') { renderClaude(ev.event); return; } 278 if (ev.type === 'claude') { renderClaude(ev.event); return; }
223 } 279 }
224 280
225 function renderClaude(ev) { 281 function renderClaude(ev) {
226 if (ev.type === 'system' && ev.subtype === 'init') { 282 if (ev.type === 'system' && ev.subtype === 'init') {
227− addMsg(el('div', 'meta-line', esc(`session ${String(ev.session_id || '').slice(0, 8)} · ${ev.model || ''} · ${(ev.cwd || '').replace(/^\/Users\/[^/]+\//, '~/')}`))); 283+ addMsg(el('div', 'meta-line', esc(`⌁ ${ev.model || ''} · session ${String(ev.session_id || '').slice(0, 8)} · ${(ev.cwd || '').replace(/^\/Users\/[^/]+\//, '~/')}`)));
228 return; 284 return;
229 } 285 }
230 if (ev.type === 'stream_event' && ev.event) { 286 if (ev.type === 'stream_event' && ev.event) {
@@ -256,20 +312,17 @@
256 } else if (block.type === 'tool_use') { 312 } else if (block.type === 'tool_use') {
257 addToolCard(block); 313 addToolCard(block);
258 } else if (block.type === 'thinking' && block.thinking) { 314 } else if (block.type === 'thinking' && block.thinking) {
259− finalizeThink(); 315+ if (liveThink) { liveThink.el.remove(); liveThink = null; }
260 const d = el('details', 'thinking-box', '<summary>réflexion</summary>'); 316 const d = el('details', 'thinking-box', '<summary>réflexion</summary>');
261 const inner = el('div', 'thinking'); inner.textContent = block.thinking; 317 const inner = el('div', 'thinking'); inner.textContent = block.thinking;
262 d.appendChild(inner); 318 d.appendChild(inner);
263 addMsg(d); 319 addMsg(d);
264− if (liveThink) { liveThink.el.remove(); liveThink = null; }
265 } 320 }
266 } 321 }
267 return; 322 return;
268 } 323 }
269 if (ev.type === 'user' && ev.message && Array.isArray(ev.message.content)) { 324 if (ev.type === 'user' && ev.message && Array.isArray(ev.message.content)) {
270− for (const block of ev.message.content) { 325+ for (const block of ev.message.content) if (block.type === 'tool_result') attachToolResult(block);
271− if (block.type === 'tool_result') attachToolResult(block);
272− }
273 return; 326 return;
274 } 327 }
275 if (ev.type === 'result') { 328 if (ev.type === 'result') {
@@ -283,67 +336,135 @@
283 } 336 }
284 } 337 }
285 338
339+ // ---------- méta de session (chips + bandeau) ----------
340+ function refreshChips() {
341+ const model = currentChat ? currentChat.model : settings.model;
342+ const perm = currentChat ? currentChat.permMode : settings.permMode;
343+ $('chipModel').textContent = MODEL_LABEL[model] || model;
344+ $('chipPerm').textContent = PERM_LABEL[perm] || perm;
345+ $('chipPerm').classList.toggle('warn', perm === 'bypass');
346+ $('chipModel').classList.add('orange');
347+ const meta = $('sessMeta');
348+ if (currentChat && (currentChat.claudeSessionId || currentChat.totalCost)) {
349+ meta.classList.remove('hidden');
350+ $('metaModel').textContent = currentChat.lastModel || MODEL_LABEL[model];
351+ $('metaSid').textContent = currentChat.claudeSessionId ? 'sess ' + currentChat.claudeSessionId.slice(0, 8) : '';
352+ $('metaCost').textContent = fmt$(currentChat.totalCost);
353+ $('metaCtx').textContent = currentChat.contextTokens ? 'ctx ' + fmtTok(currentChat.contextTokens) + ' tok' : '';
354+ } else meta.classList.add('hidden');
355+ }
356+
286 // ---------- WebSocket ---------- 357 // ---------- WebSocket ----------
287 function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); } 358 function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
359+ function setConn(state) {
360+ const pill = $('connPill');
361+ pill.className = 'conn-pill ' + state;
362+ $('connText').textContent = state === 'on' ? 'connecté' : state === 'mid' ? 'reconnexion…' : 'hors ligne';
363+ }
288 function connect() { 364 function connect() {
365+ if (!wsWanted) return;
366+ setConn('mid');
289 const proto = location.protocol === 'https:' ? 'wss' : 'ws'; 367 const proto = location.protocol === 'https:' ? 'wss' : 'ws';
290 ws = new WebSocket(`${proto}://${location.host}/ws`); 368 ws = new WebSocket(`${proto}://${location.host}/ws`);
291 ws.onopen = () => { 369 ws.onopen = () => {
292− connDot.classList.add('on'); 370+ setConn('on');
293 wsRetry = 1000; 371 wsRetry = 1000;
294 if (currentChat) send({ type: 'open', chatId: currentChat.id }); 372 if (currentChat) send({ type: 'open', chatId: currentChat.id });
373+ if (curView === 'eco') send({ type: 'eco_sub' });
295 }; 374 };
296 ws.onclose = () => { 375 ws.onclose = () => {
297− connDot.classList.remove('on'); 376+ setConn('off');
298 setTimeout(connect, wsRetry); 377 setTimeout(connect, wsRetry);
299 wsRetry = Math.min(wsRetry * 1.6, 20000); 378 wsRetry = Math.min(wsRetry * 1.6, 20000);
300 }; 379 };
301 ws.onmessage = (e) => { 380 ws.onmessage = (e) => {
302 let msg; 381 let msg;
303 try { msg = JSON.parse(e.data); } catch { return; } 382 try { msg = JSON.parse(e.data); } catch { return; }
304− if (msg.type === 'chat_created') { 383+ handleServer(msg);
305− currentChat = msg.chat;
306− return;
307− }
308− if (msg.type === 'history') {
309− currentChat = msg.chat;
310− clearConversation();
311− for (const ev of msg.events) { try { renderEvent(ev); } catch {} }
312− setRunning(msg.running);
313− for (const o of projectSel.options) if (o.value === msg.chat.projectId) projectSel.value = o.value;
314− scrollBottom(true);
315− return;
316− }
317− renderEvent(msg);
318 }; 384 };
319 } 385 }
320− function clearConversation() { 386+ function handleServer(msg) {
387+ if (msg.type === 'chat_created') { currentChat = msg.chat; refreshChips(); return; }
388+ if (msg.type === 'chat_meta') {
389+ if (currentChat && msg.chat.id === currentChat.id) { currentChat = msg.chat; refreshChips(); }
390+ return;
391+ }
392+ if (msg.type === 'history') {
393+ currentChat = msg.chat;
394+ clearConversation(true);
395+ for (const ev of msg.events) { try { renderEvent(ev); } catch {} }
396+ setRunning(msg.running);
397+ for (const o of projectSel.options) if (o.value === msg.chat.projectId) projectSel.value = o.value;
398+ refreshChips();
399+ scrollBottom(true);
400+ return;
401+ }
402+ if (msg.type === 'notify') {
403+ if (msg.kind === 'done') {
404+ if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) {
405+ banner('good', `✓ Tâche terminée — ${esc(msg.title || '')}`, () => openChat(msg.chatId));
406+ navBadge('chat'); navBadge('sessions');
407+ }
408+ } else if (msg.kind === 'perm') {
409+ if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) {
410+ banner('warn', `🔐 Permission attendue (${esc(msg.tool || '')}) — ${esc(msg.title || '')}`, () => openChat(msg.chatId), 12000);
411+ navBadge('chat');
412+ }
413+ }
414+ return;
415+ }
416+ if (msg.type === 'eco_alert') {
417+ if (msg.kind === 'down') banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000);
418+ else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco'));
419+ navBadge('eco');
420+ if (curView === 'eco') loadEco();
421+ return;
422+ }
423+ if (msg.type === 'eco') {
424+ if (msg.kind === 'checks' && ecoData && curView === 'eco') applyChecks(msg.items);
425+ return;
426+ }
427+ renderEvent(msg);
428+ }
429+
430+ function clearConversation(keepChat) {
321 messagesEl.innerHTML = ''; 431 messagesEl.innerHTML = '';
322 toolCards.clear(); permCards.clear(); 432 toolCards.clear(); permCards.clear();
323 live = null; liveThink = null; 433 live = null; liveThink = null;
324− if (!currentChat) $('welcome').classList.remove('hidden'); 434+ if (!keepChat && !currentChat) $('welcome').classList.remove('hidden');
435+ }
436+ function openChat(chatId) {
437+ switchView('chat');
438+ currentChat = { id: chatId };
439+ clearConversation(true);
440+ send({ type: 'open', chatId });
325 } 441 }
326− // suggestions de l'écran d'accueil → composer
327− document.querySelectorAll('.hint').forEach((h) => {
328− h.onclick = () => { inputEl.value = h.textContent; autoGrow(); inputEl.focus(); };
329− });
330 442
331− // ---------- actions ---------- 443+ // ---------- composer ----------
332 function sendPrompt() { 444 function sendPrompt() {
333 const text = inputEl.value.trim(); 445 const text = inputEl.value.trim();
334 if (!text || isRunning) return; 446 if (!text || isRunning) return;
447+ // commandes slash locales
448+ if (text === '/clear') { if (currentChat) send({ type: 'clear', chatId: currentChat.id }); inputEl.value = ''; autoGrow(); return; }
449+ if (text === '/cost') {
450+ if (currentChat) addMsg(el('div', 'meta-line', esc(`coût cumulé ${fmt$(currentChat.totalCost)} · ${currentChat.turns || 0} tours · ctx ${fmtTok(currentChat.contextTokens)} tok`)));
451+ inputEl.value = ''; autoGrow(); return;
452+ }
453+ if (text === '/resume') { switchView('sessions'); inputEl.value = ''; autoGrow(); return; }
335 inputEl.value = ''; 454 inputEl.value = '';
336 autoGrow(); 455 autoGrow();
456+ hideSlash();
337 send({ 457 send({
338 type: 'start', 458 type: 'start',
339 chatId: currentChat ? currentChat.id : null, 459 chatId: currentChat ? currentChat.id : null,
340 project: projectSel.value, 460 project: projectSel.value,
341− mode, 461+ model: currentChat ? currentChat.model : settings.model,
462+ permMode: currentChat ? currentChat.permMode : settings.permMode,
342 prompt: text, 463 prompt: text,
343 }); 464 });
344 } 465 }
345 sendBtn.onclick = sendPrompt; 466 sendBtn.onclick = sendPrompt;
346− stopBtn.onclick = () => send({ type: 'interrupt' }); 467+ stopBtn.onclick = () => { if (currentChat) send({ type: 'interrupt', chatId: currentChat.id }); };
347 inputEl.addEventListener('keydown', (e) => { 468 inputEl.addEventListener('keydown', (e) => {
348 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendPrompt(); } 469 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendPrompt(); }
349 }); 470 });
@@ -351,57 +472,367 @@
351 inputEl.style.height = 'auto'; 472 inputEl.style.height = 'auto';
352 inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px'; 473 inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px';
353 } 474 }
354− inputEl.addEventListener('input', autoGrow); 475+ inputEl.addEventListener('input', () => {
476+ autoGrow();
477+ if (inputEl.value.startsWith('/')) showSlash(); else hideSlash();
478+ });
355 inputEl.addEventListener('focus', () => setTimeout(() => scrollBottom(true), 300)); 479 inputEl.addEventListener('focus', () => setTimeout(() => scrollBottom(true), 300));
356 480
357− $('newBtn').onclick = () => { currentChat = null; clearConversation(); setRunning(false); }; 481+ function showSlash() {
482+ const h = $('slashHint');
483+ h.classList.remove('hidden');
484+ if (!h.childElementCount) {
485+ for (const [cmd, tip] of [['/clear', 'effacer le contexte'], ['/compact', 'compacter la session'], ['/cost', 'coût de la session'], ['/resume', 'liste des sessions']]) {
486+ const b = el('button', '', `${cmd} <span style="color:var(--muted);font-weight:600">${tip}</span>`);
487+ b.onclick = () => { inputEl.value = cmd; hideSlash(); sendPrompt(); };
488+ h.appendChild(b);
489+ }
490+ }
491+ }
492+ function hideSlash() { $('slashHint').classList.add('hidden'); }
358 493
359− modeBtn.onclick = () => { 494+ document.querySelectorAll('.hint').forEach((h) => {
360− if (mode === 'safe') { 495+ h.onclick = () => { inputEl.value = h.textContent; autoGrow(); inputEl.focus(); };
361− if (!confirm('Mode AUTO : Claude exécutera toutes les actions sans demander de permission. Continuer ?')) return; 496+ });
362− mode = 'auto'; 497+
363− } else { 498+ // ---------- chips ----------
364− mode = 'safe'; 499+ $('chipModel').onclick = () => {
500+ const order = ['fable', 'opus', 'sonnet', 'haiku'];
501+ const cur = currentChat ? currentChat.model : settings.model;
502+ const next = order[(order.indexOf(cur) + 1) % order.length];
503+ applyOpts({ model: next });
504+ };
505+ $('chipPerm').onclick = () => {
506+ const order = ['default', 'acceptEdits', 'plan', 'bypass'];
507+ const cur = currentChat ? currentChat.permMode : settings.permMode;
508+ const next = order[(order.indexOf(cur) + 1) % 4];
509+ if (next === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return;
510+ applyOpts({ permMode: next });
511+ };
512+ function applyOpts(opts) {
513+ if (currentChat) {
514+ Object.assign(currentChat, opts);
515+ send({ type: 'set_opts', chatId: currentChat.id, model: currentChat.model, permMode: currentChat.permMode });
365 } 516 }
366− modeBtn.textContent = mode === 'auto' ? 'Auto' : 'Sûr'; 517+ if (opts.model) settings.model = opts.model;
367− modeBtn.classList.toggle('auto', mode === 'auto'); 518+ if (opts.permMode) settings.permMode = opts.permMode;
519+ saveStore();
520+ refreshChips();
521+ refreshSettingsView();
522+ }
523+ $('chipMore').onclick = () => {
524+ const c = currentChat;
525+ openSheet(`
526+ <div class="sheet-h"><h3>Session</h3></div>
527+ <div class="sheet-sec">
528+ <div class="kv"><span>Projet</span><b>${esc(c ? c.projectName : projectSel.selectedOptions[0]?.textContent || '—')}</b></div>
529+ <div class="kv"><span>Session Claude Code</span><b>${c?.claudeSessionId ? c.claudeSessionId.slice(0, 12) + '…' : '—'}</b></div>
530+ <div class="kv"><span>Modèle du dernier tour</span><b>${esc(c?.lastModel || '—')}</b></div>
531+ <div class="kv"><span>Coût cumulé</span><b>${fmt$(c?.totalCost)}</b></div>
532+ <div class="kv"><span>Contexte</span><b>${c?.contextTokens ? fmtTok(c.contextTokens) + ' tokens' : '—'}</b></div>
533+ </div>
534+ <div class="sheet-sec">
535+ <div class="set-actions">
536+ <button class="btn-ghost" id="shClear">/clear — nouveau contexte</button>
537+ <button class="btn-ghost" id="shCompact">/compact</button>
538+ <button class="btn-ghost" id="shNew">+ Nouvelle conversation</button>
539+ </div>
540+ </div>`);
541+ $('shClear').onclick = () => { if (c) send({ type: 'clear', chatId: c.id }); closeSheet(); };
542+ $('shCompact').onclick = () => { closeSheet(); inputEl.value = '/compact'; sendPrompt(); };
543+ $('shNew').onclick = () => { closeSheet(); newChat(); };
368 }; 544 };
545+ function newChat() {
546+ currentChat = null;
547+ clearConversation();
548+ setRunning(false);
549+ refreshChips();
550+ switchView('chat');
551+ }
369 552
370− // ---------- drawer ---------- 553+ // ---------- sessions ----------
371− const drawer = $('drawer'); 554+ async function loadSessions() {
372− $('menuBtn').onclick = async () => {
373− drawer.classList.remove('hidden');
374 const r = await fetch('/api/chats'); 555 const r = await fetch('/api/chats');
375 if (!r.ok) return; 556 if (!r.ok) return;
376 const { chats } = await r.json(); 557 const { chats } = await r.json();
377− const list = $('chatList'); 558+ const list = $('sessList');
378 list.innerHTML = ''; 559 list.innerHTML = '';
379− if (!chats.length) list.appendChild(el('div', 'chat-empty', 'Aucune conversation')); 560+ if (!chats.length) { list.appendChild(el('div', 'sess-empty', 'Aucune session. Lance une conversation dans l’onglet Chat.')); return; }
561+ const stLabel = { running: '● en cours', waiting_perm: '🔐 permission', idle: '✓ terminée' };
380 for (const c of chats) { 562 for (const c of chats) {
381− const item = el('div', 'chat-item' + (currentChat && currentChat.id === c.id ? ' active' : '')); 563+ const card = el('div', 'sess-card');
382− const when = new Date(c.updated).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); 564+ card.innerHTML = `
383− item.innerHTML = `<div class="ci-main"><span class="ci-title">${esc(c.title)}</span><span class="ci-meta">${esc(c.projectName || '')} · ${when}${c.running ? ' · <span class="ci-running">● en cours</span>' : ''}</span></div>`; 565+ <div class="sess-top">
384− const delBtn = el('button', 'ci-del', '✕'); 566+ <span class="sess-title">${esc(c.title)}</span>
385− delBtn.onclick = async (e) => { 567+ <span class="sess-badge ${c.state}">${stLabel[c.state] || c.state}</span>
386− e.stopPropagation(); 568+ </div>
387− if (!confirm('Supprimer cette conversation ?')) return; 569+ <div class="sess-info">
388− await fetch('/api/chats/' + c.id, { method: 'DELETE' }); 570+ <span>${esc(c.projectName || '')}</span>
389− item.remove(); 571+ <span>${MODEL_LABEL[c.model] || c.model}</span>
390− if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); } 572+ <span>${fmt$(c.totalCost)}</span>
391− }; 573+ <span>${timeAgo(c.updated)}</span>
392− if (!c.running) item.appendChild(delBtn); 574+ </div>`;
393− item.onclick = () => { 575+ const actions = el('div', 'sess-actions');
394− drawer.classList.add('hidden'); 576+ const open = el('button', '', c.state === 'idle' ? 'Reprendre' : 'Ouvrir');
395− currentChat = { id: c.id }; 577+ open.onclick = (e) => { e.stopPropagation(); openChat(c.id); };
396− clearConversation(); 578+ actions.appendChild(open);
397− send({ type: 'open', chatId: c.id }); 579+ if (c.state === 'idle') {
398− }; 580+ const del = el('button', 'del', 'Supprimer');
399− list.appendChild(item); 581+ del.onclick = async (e) => {
582+ e.stopPropagation();
583+ if (!confirm('Supprimer cette session ?')) return;
584+ await fetch('/api/chats/' + c.id, { method: 'DELETE' });
585+ card.remove();
586+ if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); }
587+ };
588+ actions.appendChild(del);
589+ }
590+ card.appendChild(actions);
591+ card.onclick = () => openChat(c.id);
592+ list.appendChild(card);
400 } 593 }
594+ }
595+ $('newSessBtn').onclick = newChat;
596+
597+ // ---------- écosystème ----------
598+ function siteStatus(s) {
599+ if (s.incident || (s.last && !s.last.ok)) return 'down';
600+ if (s.last && s.last.ms > 4000) return 'slow';
601+ return s.last ? 'up' : 'unknown';
602+ }
603+ const ST = {
604+ up: { cls: 'up', ico: '●', lbl: 'EN LIGNE' },
605+ slow: { cls: 'slow', ico: '▲', lbl: 'LENT' },
606+ down: { cls: 'down', ico: '■', lbl: 'HORS LIGNE' },
607+ unknown: { cls: 'slow', ico: '▲', lbl: '…' },
401 }; 608 };
402− $('drawerClose').onclick = () => drawer.classList.add('hidden'); 609+ function sparkline(spark, w = 120, h = 30) {
403− $('drawerBack').onclick = () => drawer.classList.add('hidden'); 610+ if (!spark || spark.length < 2) return '<svg class="spark"></svg>';
611+ const ms = spark.map(p => p.ok ? p.ms : null);
612+ const max = Math.max(...ms.filter(v => v !== null), 100) * 1.15;
613+ const step = w / (spark.length - 1);
614+ let d = '', pen = false;
615+ spark.forEach((p, i) => {
616+ if (!p.ok) { pen = false; return; }
617+ const x = i * step, y = h - 3 - (p.ms / max) * (h - 8);
618+ d += (pen ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1);
619+ pen = true;
620+ });
621+ const fails = spark.map((p, i) => !p.ok ? `<rect x="${(i * step - 1.5).toFixed(1)}" y="${h - 7}" width="3" height="5" fill="var(--bad)"/>` : '').join('');
622+ return `<svg class="spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none"><path d="${d}" fill="none" stroke="var(--orange-deep)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>${fails}</svg>`;
623+ }
624+ async function loadEco() {
625+ const r = await fetch('/api/eco/summary');
626+ if (!r.ok) return;
627+ ecoData = await r.json();
628+ renderEco();
629+ }
630+ function applyChecks(items) {
631+ if (!ecoData) return;
632+ for (const it of items) {
633+ const s = ecoData.sites.find(x => x.app === it.site);
634+ if (!s) continue;
635+ s.last = { ts: it.ts, ms: it.ms, code: it.code, ok: it.ok, err: it.err };
636+ s.spark.push({ ts: it.ts, ms: it.ms, ok: it.ok });
637+ if (s.spark.length > 40) s.spark.shift();
638+ }
639+ renderEco();
640+ }
641+ function renderEco() {
642+ const sites = ecoData.sites;
643+ const up = sites.filter(s => siteStatus(s) === 'up').length;
644+ const down = sites.filter(s => siteStatus(s) === 'down').length;
645+ const lat = sites.filter(s => s.last?.ok).map(s => s.last.ms);
646+ const avg = lat.length ? Math.round(lat.reduce((a, b) => a + b, 0) / lat.length) : 0;
647+ $('ecoStamp').textContent = 'maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
648+ $('ecoOverview').innerHTML = `
649+ <div class="eco-stat ok"><div class="v">${up}/${sites.length}</div><div class="l">en ligne</div></div>
650+ <div class="eco-stat ${down ? 'bad' : ''}"><div class="v">${down}</div><div class="l">hors ligne</div></div>
651+ <div class="eco-stat"><div class="v">${avg}<small style="font-size:12px"> ms</small></div><div class="l">latence moy.</div></div>`;
652+ const grid = $('ecoGrid');
653+ grid.innerHTML = '';
654+ const order = { down: 0, slow: 1, unknown: 2, up: 3 };
655+ for (const s of [...sites].sort((a, b) => order[siteStatus(a)] - order[siteStatus(b)])) {
656+ const st = ST[siteStatus(s)];
657+ const card = el('div', 'site-card' + (siteStatus(s) === 'down' ? ' down' : ''));
658+ card.innerHTML = `
659+ <div class="site-top">
660+ <span class="site-name">${esc(s.label)}</span>
661+ <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>
662+ </div>
663+ <div class="site-mid">
664+ <div class="site-ms">${s.last?.ok ? s.last.ms : '—'}<small> ms</small></div>
665+ ${sparkline(s.spark)}
666+ </div>
667+ <div class="site-foot">
668+ <span>up 24h <b>${s.uptime24 ?? '—'}%</b></span>
669+ <span>p95 <b>${s.p95_24 ?? '—'} ms</b></span>
670+ <span>${esc(s.node)}</span>
671+ ${s.certExpires ? `<span>SSL <b>${Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000))} j</b></span>` : ''}
672+ </div>`;
673+ card.onclick = () => openSiteSheet(s.app);
674+ grid.appendChild(card);
675+ }
676+ }
677+
678+ async function openSiteSheet(app) {
679+ const r = await fetch('/api/eco/site/' + app);
680+ if (!r.ok) return;
681+ const d = await r.json();
682+ const s = ecoData.sites.find(x => x.app === app) || {};
683+ const st = ST[siteStatus(s)];
684+ const certDays = s.certExpires ? Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000)) : null;
685+ openSheet(`
686+ <div class="sheet-h">
687+ <h3>${esc(d.label)}</h3>
688+ <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>
689+ </div>
690+ <div class="sheet-sec">
691+ <h4>Disponibilité</h4>
692+ <div class="tile-row">
693+ <div class="tile"><div class="v">${s.uptime24 ?? '—'}%</div><div class="l">24 h</div></div>
694+ <div class="tile"><div class="v">${s.uptime7 ?? '—'}%</div><div class="l">7 j</div></div>
695+ <div class="tile"><div class="v">${s.uptime30 ?? '—'}%</div><div class="l">30 j</div></div>
696+ </div>
697+ <div style="margin-top:10px">${uptimeDays(d.days)}</div>
698+ </div>
699+ <div class="sheet-sec">
700+ <h4>Latence — 24 h</h4>
701+ ${latencyChart(d.series)}
702+ <div class="tile-row" style="margin-top:10px">
703+ <div class="tile"><div class="v">${s.avg24 ?? '—'}</div><div class="l">moy ms</div></div>
704+ <div class="tile"><div class="v">${s.p95_24 ?? '—'}</div><div class="l">p95 ms</div></div>
705+ <div class="tile"><div class="v">${s.last?.code || '—'}</div><div class="l">HTTP</div></div>
706+ </div>
707+ </div>
708+ <div class="sheet-sec">
709+ <h4>Service</h4>
710+ <div class="kv"><span>Domaine</span><b>${esc(d.domain)}</b></div>
711+ <div class="kv"><span>Nœud</span><b>${esc(d.node)}</b></div>
712+ <div class="kv"><span>PM2</span><b>${esc(s.proc?.status || '—')}${s.proc?.restarts ? ' · ' + s.proc.restarts + ' restarts' : ''}</b></div>
713+ <div class="kv"><span>CPU / RAM process</span><b>${s.proc ? s.proc.cpu + '% / ' + fmtMB(s.proc.mem) : '—'}</b></div>
714+ <div class="kv"><span>Charge nœud</span><b>${s.nodeStat ? s.nodeStat.load1 + ' · RAM ' + Math.round(s.nodeStat.mem_used / s.nodeStat.mem_total * 100) + '% · disque ' + s.nodeStat.disk_pct + '%' : '—'}</b></div>
715+ <div class="kv"><span>Dernier commit</span><b>${s.proc?.last_commit ? timeAgo(s.proc.last_commit) : '—'}</b></div>
716+ <div class="kv"><span>Certificat SSL</span><b>${certDays !== null ? 'expire dans ' + certDays + ' j' : '—'}</b></div>
717+ </div>
718+ ${d.incidents.length ? `<div class="sheet-sec"><h4>Incidents — 30 j</h4>${d.incidents.map(i => `
719+ <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span>
720+ <span>${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
721+ ${i.ended ? ' → ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — EN COURS'} · ${esc(i.reason || '')}</span></div>`).join('')}</div>` : ''}
722+ ${d.errors.length ? `<div class="sheet-sec"><h4>Erreurs récentes</h4>${d.errors.slice(0, 8).map(e => `
723+ <div class="inc-item"><span class="inc-dot">✗</span><span>${new Date(e.ts).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} · ${e.code ? 'HTTP ' + e.code : esc(e.err || '')}</span></div>`).join('')}</div>` : ''}
724+ <button class="btn-cta" style="width:100%" id="shChat">Ouvrir dans le chat →</button>
725+ `);
726+ $('shChat').onclick = () => {
727+ closeSheet();
728+ newChat();
729+ for (const o of projectSel.options) if (o.value.startsWith(d.app + '@')) projectSel.value = o.value;
730+ };
731+ attachChartTips();
732+ }
733+
734+ function uptimeDays(days) {
735+ if (!days) return '';
736+ const cells = days.map(dd => {
737+ const u = dd.uptime;
738+ const c = u === null ? 'var(--line)' : u >= 99.5 ? 'var(--good)' : u >= 95 ? 'var(--warn)' : 'var(--bad)';
739+ return `<div title="${new Date(dd.day).toLocaleDateString('fr-CA')} — ${u === null ? 'aucune donnée' : u + '%'}" style="flex:1;height:22px;border-radius:5px;background:${c};border:1.5px solid var(--line)"></div>`;
740+ }).join('');
741+ return `<div style="display:flex;gap:4px">${cells}</div><div style="display:flex;justify-content:space-between;font-size:10px;color:var(--muted);font-family:var(--mono);margin-top:3px"><span>il y a 14 j</span><span>aujourd'hui</span></div>`;
742+ }
743+
744+ function latencyChart(series) {
745+ const W = 340, H = 130, PL = 38, PB = 18, PT = 8;
746+ if (!series || series.length < 2) return '<div style="color:var(--muted);font-size:13px">Pas encore assez de données.</div>';
747+ const ok = series.filter(p => p.ok);
748+ const max = Math.max(...ok.map(p => p.ms), 100) * 1.15;
749+ const x = (t) => PL + ((t - series[0].t) / (series[series.length - 1].t - series[0].t)) * (W - PL - 6);
750+ const y = (ms) => PT + (1 - ms / max) * (H - PT - PB);
751+ let d = '', pen = false;
752+ const pts = [];
753+ for (const p of series) {
754+ if (!p.ok) { pen = false; continue; }
755+ const px = x(p.t), py = y(p.ms);
756+ d += (pen ? 'L' : 'M') + px.toFixed(1) + ' ' + py.toFixed(1);
757+ pts.push({ x: px, y: py, ms: Math.round(p.ms), t: p.t });
758+ pen = true;
759+ }
760+ const failMarks = series.filter(p => !p.ok).map(p =>
761+ `<rect x="${(x(p.t) - 2).toFixed(1)}" y="${H - PB - 6}" width="4" height="6" fill="var(--bad)"/>`).join('');
762+ const gridLines = [0.25, 0.5, 0.75, 1].map(f => {
763+ const gy = y(max * f / 1.15);
764+ return `<line x1="${PL}" y1="${gy}" x2="${W - 6}" y2="${gy}" stroke="var(--grid)" stroke-width="1"/>
765+ <text x="${PL - 5}" y="${gy + 3.5}" text-anchor="end" font-size="9" fill="var(--muted)" font-family="var(--mono)">${Math.round(max * f / 1.15)}</text>`;
766+ }).join('');
767+ const lastPt = pts[pts.length - 1];
768+ const lastLbl = lastPt ? `<circle cx="${lastPt.x}" cy="${lastPt.y}" r="3.5" fill="var(--orange-deep)" stroke="var(--card)" stroke-width="2"/>
769+ <text x="${Math.min(lastPt.x, W - 40)}" y="${Math.max(lastPt.y - 8, 10)}" font-size="10" font-weight="800" fill="var(--ink)" font-family="var(--mono)">${lastPt.ms} ms</text>` : '';
770+ const t0 = new Date(series[0].t), t1 = new Date(series[series.length - 1].t);
771+ const fmtH = (dt) => dt.getHours().toString().padStart(2, '0') + 'h';
772+ return `<div style="position:relative">
773+ <svg class="chart lat-chart" viewBox="0 0 ${W} ${H}" data-pts='${JSON.stringify(pts.map(p => ({ x: +p.x.toFixed(1), y: +p.y.toFixed(1), ms: p.ms, t: p.t })))}'>
774+ ${gridLines}
775+ <path d="${d}" fill="none" stroke="var(--orange-deep)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
776+ ${failMarks}${lastLbl}
777+ <text x="${PL}" y="${H - 4}" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t0)}</text>
778+ <text x="${W - 6}" y="${H - 4}" text-anchor="end" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t1)}</text>
779+ </svg></div>`;
780+ }
781+ function attachChartTips() {
782+ document.querySelectorAll('.lat-chart').forEach((svg) => {
783+ const pts = JSON.parse(svg.dataset.pts || '[]');
784+ if (!pts.length) return;
785+ const wrap = svg.parentElement;
786+ let tip = null;
787+ const show = (clientX) => {
788+ const rect = svg.getBoundingClientRect();
789+ const vx = (clientX - rect.left) / rect.width * 340;
790+ let best = pts[0];
791+ for (const p of pts) if (Math.abs(p.x - vx) < Math.abs(best.x - vx)) best = p;
792+ if (!tip) { tip = el('div', 'chart-tip'); wrap.appendChild(tip); }
793+ tip.textContent = `${new Date(best.t).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })} · ${best.ms} ms`;
794+ tip.style.left = (best.x / 340 * rect.width) + 'px';
795+ tip.style.top = (best.y / 130 * rect.height) + 'px';
796+ };
797+ svg.addEventListener('pointerdown', (e) => show(e.clientX));
798+ svg.addEventListener('pointermove', (e) => { if (e.buttons || e.pointerType === 'mouse') show(e.clientX); });
799+ svg.addEventListener('pointerleave', () => { if (tip) { tip.remove(); tip = null; } });
800+ });
801+ }
802+
803+ // rafraîchissement périodique de l'onglet éco (en plus du push)
804+ setInterval(() => { if (curView === 'eco' && !document.hidden) loadEco(); }, 60000);
805+ document.addEventListener('visibilitychange', () => {
806+ if (!document.hidden && curView === 'eco') loadEco();
807+ });
808+
809+ // ---------- feuille ----------
810+ function openSheet(html) {
811+ $('sheetBody').innerHTML = html;
812+ $('sheet').classList.remove('hidden');
813+ }
814+ function closeSheet() { $('sheet').classList.add('hidden'); }
815+ $('sheetBack').onclick = closeSheet;
816+
817+ // ---------- réglages ----------
818+ function refreshSettingsView() {
819+ document.querySelectorAll('#segModel button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.model));
820+ document.querySelectorAll('#segPerm button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.permMode));
821+ $('setSid').textContent = currentChat?.claudeSessionId ? currentChat.claudeSessionId.slice(0, 12) + '…' : '—';
822+ $('setCost').textContent = currentChat ? fmt$(currentChat.totalCost) : '—';
823+ $('setCtx').textContent = currentChat?.contextTokens ? fmtTok(currentChat.contextTokens) + ' tokens' : '—';
824+ }
825+ document.querySelectorAll('#segModel button').forEach(b => { b.onclick = () => applyOpts({ model: b.dataset.v }); });
826+ document.querySelectorAll('#segPerm button').forEach(b => {
827+ b.onclick = () => {
828+ if (b.dataset.v === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return;
829+ applyOpts({ permMode: b.dataset.v });
830+ };
831+ });
832+ $('btnClear').onclick = () => { if (currentChat) { send({ type: 'clear', chatId: currentChat.id }); switchView('chat'); } };
833+ $('btnCompact').onclick = () => { switchView('chat'); inputEl.value = '/compact'; sendPrompt(); };
404 $('logoutBtn').onclick = async () => { 834 $('logoutBtn').onclick = async () => {
835+ wsWanted = false;
405 await fetch('/api/logout', { method: 'POST' }); 836 await fetch('/api/logout', { method: 'POST' });
406 location.reload(); 837 location.reload();
407 }; 838 };
@@ -414,6 +845,8 @@
414 $('app').classList.add('hidden'); 845 $('app').classList.add('hidden');
415 return; 846 return;
416 } 847 }
848+ const info = await me.json();
849+ $('setHost').textContent = (info.node || 'M3U96a') + ':3300';
417 $('login').classList.add('hidden'); 850 $('login').classList.add('hidden');
418 $('app').classList.remove('hidden'); 851 $('app').classList.remove('hidden');
419 const pr = await fetch('/api/projects'); 852 const pr = await fetch('/api/projects');
@@ -421,9 +854,13 @@
421 projectSel.innerHTML = ''; 854 projectSel.innerHTML = '';
422 for (const p of projects) { 855 for (const p of projects) {
423 const o = document.createElement('option'); 856 const o = document.createElement('option');
424− o.value = p.id; o.textContent = p.name; 857+ o.value = p.id; o.textContent = p.name + ' · ' + p.node;
425 projectSel.appendChild(o); 858 projectSel.appendChild(o);
426 } 859 }
860+ if (settings.projectId) projectSel.value = settings.projectId;
861+ projectSel.onchange = () => { settings.projectId = projectSel.value; saveStore(); };
862+ refreshChips();
863+ refreshSettingsView();
427 connect(); 864 connect();
428 } 865 }
429 $('loginBtn').onclick = doLogin; 866 $('loginBtn').onclick = doLogin;
modified public/index.html +127 −53
@@ -3,86 +3,160 @@
3 <head> 3 <head>
4 <meta charset="utf-8"> 4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content"> 5 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
6−<meta name="theme-color" content="#ffffff"> 6+<meta name="theme-color" content="#F2F1EC">
7 <meta name="apple-mobile-web-app-capable" content="yes"> 7 <meta name="apple-mobile-web-app-capable" content="yes">
8 <meta name="apple-mobile-web-app-status-bar-style" content="default"> 8 <meta name="apple-mobile-web-app-status-bar-style" content="default">
9−<meta name="apple-mobile-web-app-title" content="Admin·KA"> 9+<meta name="apple-mobile-web-app-title" content="Admin-Ka">
10−<title>Administration·KA</title> 10+<title>Admin-Ka — Console Groupe KA</title>
11−<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%233556f0'/%3E%3Ctext x='16' y='21.5' font-size='13' font-family='-apple-system,sans-serif' fill='white' text-anchor='middle' font-weight='700'%3EKA%3C/text%3E%3C/svg%3E"> 11+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='9' fill='%23F97316'/%3E%3Ctext x='16' y='22' font-size='14' font-family='-apple-system,sans-serif' fill='%231a1611' text-anchor='middle' font-weight='800'%3EKa%3C/text%3E%3C/svg%3E">
12−<link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%233556f0'/%3E%3Ctext x='16' y='21.5' font-size='13' font-family='sans-serif' fill='white' text-anchor='middle' font-weight='700'%3EKA%3C/text%3E%3C/svg%3E"> 12+<link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23F97316'/%3E%3Ctext x='16' y='22' font-size='14' font-family='sans-serif' fill='%231a1611' text-anchor='middle' font-weight='800'%3EKa%3C/text%3E%3C/svg%3E">
13 <link rel="stylesheet" href="/vendor/hljs-github.min.css"> 13 <link rel="stylesheet" href="/vendor/hljs-github.min.css">
14 <link rel="stylesheet" href="/style.css"> 14 <link rel="stylesheet" href="/style.css">
15 </head> 15 </head>
16 <body> 16 <body>
17 17
18−<!-- Écran de connexion --> 18+<!-- ============ Connexion ============ -->
19 <div id="login" class="login hidden"> 19 <div id="login" class="login hidden">
20 <div class="login-box"> 20 <div class="login-box">
21− <div class="login-mark">KA</div> 21+ <div class="ka-mark big">Ka</div>
22− <h1 class="login-title">Administration·KA</h1> 22+ <h1 class="login-title">Admin<span class="tiret">-</span>Ka</h1>
23 <p class="login-sub">Console Claude Code · Groupe KA</p> 23 <p class="login-sub">Console Claude Code · Groupe KA</p>
24 <input id="password" type="password" placeholder="Mot de passe" autocomplete="current-password"> 24 <input id="password" type="password" placeholder="Mot de passe" autocomplete="current-password">
25− <button id="loginBtn">Se connecter</button> 25+ <button id="loginBtn" class="btn-cta">Se connecter</button>
26 <div id="loginError" class="login-error"></div> 26 <div id="loginError" class="login-error"></div>
27 </div> 27 </div>
28 <div class="login-foot">M3U96a · MacLustr</div> 28 <div class="login-foot">M3U96a · MacLustr</div>
29 </div> 29 </div>
30 30
31−<!-- App --> 31+<!-- ============ App ============ -->
32 <div id="app" class="app hidden"> 32 <div id="app" class="app hidden">
33+
33 <header class="header"> 34 <header class="header">
34− <button id="menuBtn" class="icon-btn" title="Conversations" aria-label="Conversations"> 35+ <div class="brand">
35− <svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> 36+ <span class="ka-mark">Ka</span>
36− </button> 37+ <span class="brand-name">Admin<span class="tiret">-</span>Ka</span>
37− <div class="header-mid">
38− <div class="project-row">
39− <span id="connDot" class="dot" title="Connexion"></span>
40− <select id="projectSel" class="project-sel" title="Projet actif"></select>
41− </div>
42 </div> 38 </div>
43− <button id="modeBtn" class="mode-btn" title="Mode permissions">Sûr</button> 39+ <div id="connPill" class="conn-pill off"><span class="conn-dot"></span><span id="connText">hors ligne</span></div>
44− <button id="newBtn" class="icon-btn accent" title="Nouvelle conversation" aria-label="Nouvelle conversation">
45− <svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>
46− </button>
47 </header> 40 </header>
48 41
49− <main id="messages" class="messages"></main> 42+ <!-- Bannière notifications / alertes -->
43+ <div id="banners" class="banners"></div>
50 44
51− <div id="welcome" class="welcome"> 45+ <!-- ======== Vue CHAT ======== -->
52− <div class="welcome-mark">KA</div> 46+ <section id="view-chat" class="view">
53− <h2>Administration·KA</h2> 47+ <div class="chatbar">
54− <p>Parle à Claude Code, directement sur le nœud de chaque app du Groupe KA.</p> 48+ <select id="projectSel" class="pill-select" title="Projet (repo + nœud)"></select>
55− <div class="hints"> 49+ <button id="chipModel" class="chip" title="Modèle">opus</button>
56− <button class="hint">Quel est l'état de l'app ?</button> 50+ <button id="chipPerm" class="chip" title="Permissions">normal</button>
57− <button class="hint">Montre les derniers commits</button> 51+ <button id="chipMore" class="chip ghost" title="Options de session">⋯</button>
58− <button class="hint">Vérifie les erreurs PM2</button>
59 </div> 52 </div>
60− </div> 53+ <div id="sessMeta" class="sess-meta hidden">
54+ <span id="metaModel"></span><span id="metaSid"></span><span id="metaCost"></span><span id="metaCtx"></span>
55+ </div>
56+
57+ <main id="messages" class="messages"></main>
58+
59+ <div id="welcome" class="welcome">
60+ <div class="ka-mark big">Ka</div>
61+ <h2>Console Claude Code</h2>
62+ <p>Le vrai Claude Code, lancé directement sur le nœud de chaque app du Groupe KA.</p>
63+ <div class="hints">
64+ <button class="hint">Quel modèle es-tu et dans quel répertoire travailles-tu ?</button>
65+ <button class="hint">Montre les 5 derniers commits</button>
66+ <button class="hint">État PM2 de l'app</button>
67+ </div>
68+ </div>
69+
70+ <footer class="composer">
71+ <div class="composer-inner">
72+ <textarea id="input" rows="1" placeholder="Demander à Claude Code…" enterkeyhint="send"></textarea>
73+ <button id="stopBtn" class="send-btn stop hidden" title="Arrêter"><svg viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></svg></button>
74+ <button id="sendBtn" class="send-btn" title="Envoyer"><svg viewBox="0 0 24 24"><path d="M12 19V6M6 11l6-6 6 6" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg></button>
75+ </div>
76+ <div id="slashHint" class="slash-hint hidden"></div>
77+ </footer>
78+ </section>
79+
80+ <!-- ======== Vue SESSIONS ======== -->
81+ <section id="view-sessions" class="view hidden">
82+ <div class="view-head">
83+ <h2>Sessions</h2>
84+ <button id="newSessBtn" class="btn-cta small">+ Nouvelle</button>
85+ </div>
86+ <div id="sessList" class="sess-list"></div>
87+ </section>
61 88
62− <footer class="composer"> 89+ <!-- ======== Vue ÉCOSYSTÈME ======== -->
63− <div class="composer-inner"> 90+ <section id="view-eco" class="view hidden">
64− <textarea id="input" rows="1" placeholder="Demander à Claude…" enterkeyhint="send"></textarea> 91+ <div class="view-head">
65− <button id="stopBtn" class="send-btn stop hidden" title="Arrêter" aria-label="Arrêter"> 92+ <h2>Écosystème</h2>
66− <svg viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></svg> 93+ <span id="ecoStamp" class="eco-stamp"></span>
67− </button>
68− <button id="sendBtn" class="send-btn" title="Envoyer" aria-label="Envoyer">
69− <svg viewBox="0 0 24 24"><path d="M12 19V6M6 11l6-6 6 6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
70− </button>
71 </div> 94 </div>
72− </footer> 95+ <div id="ecoOverview" class="eco-overview"></div>
96+ <div id="ecoGrid" class="eco-grid"><div class="eco-loading">Chargement…</div></div>
97+ </section>
73 98
74− <!-- Tiroir conversations --> 99+ <!-- ======== Vue RÉGLAGES ======== -->
75− <div id="drawer" class="drawer hidden"> 100+ <section id="view-settings" class="view hidden">
76− <div id="drawerBack" class="drawer-back"></div> 101+ <div class="view-head"><h2>Réglages</h2></div>
77− <div class="drawer-panel"> 102+ <div class="set-card">
78− <div class="drawer-head"> 103+ <div class="set-label">Modèle par défaut</div>
79− <span>Conversations</span> 104+ <div class="seg" id="segModel">
80− <button id="drawerClose" class="icon-btn" aria-label="Fermer"> 105+ <button data-v="fable">Fable 5</button><button data-v="opus">Opus 5</button><button data-v="sonnet">Sonnet 5</button><button data-v="haiku">Haiku</button>
81− <svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> 106+ </div>
82− </button> 107+ </div>
108+ <div class="set-card">
109+ <div class="set-label">Permissions par défaut</div>
110+ <div class="seg vert" id="segPerm">
111+ <button data-v="default">Normal — demande dans le chat</button>
112+ <button data-v="acceptEdits">acceptEdits — éditions auto</button>
113+ <button data-v="plan">Plan — proposer avant d'agir</button>
114+ <button data-v="bypass">⚠ Tout autoriser (bypass)</button>
83 </div> 115 </div>
84− <div id="chatList" class="chat-list"></div> 116+ </div>
85− <button id="logoutBtn" class="logout-btn">Se déconnecter</button> 117+ <div class="set-card">
118+ <div class="set-label">Conversation active</div>
119+ <div class="set-row"><span>Session Claude Code</span><span id="setSid" class="mono">—</span></div>
120+ <div class="set-row"><span>Coût cumulé</span><span id="setCost" class="mono">—</span></div>
121+ <div class="set-row"><span>Contexte utilisé</span><span id="setCtx" class="mono">—</span></div>
122+ <div class="set-actions">
123+ <button id="btnClear" class="btn-ghost">/clear — effacer le contexte</button>
124+ <button id="btnCompact" class="btn-ghost">/compact — compacter</button>
125+ </div>
126+ </div>
127+ <div class="set-card">
128+ <div class="set-row"><span>Backend</span><span id="setHost" class="mono">M3U96a:3300</span></div>
129+ <div class="set-row"><span>Version</span><span class="mono">2.0.0</span></div>
130+ </div>
131+ <button id="logoutBtn" class="btn-danger">Se déconnecter</button>
132+ </section>
133+
134+ <!-- ======== Bottom nav ======== -->
135+ <nav class="bottomnav">
136+ <button data-view="chat" class="nav-btn active">
137+ <svg viewBox="0 0 24 24"><path d="M4 6a3 3 0 013-3h10a3 3 0 013 3v8a3 3 0 01-3 3H9l-4 4v-4a3 3 0 01-1-2.2z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/></svg>
138+ <span>Chat</span><i class="badge hidden"></i>
139+ </button>
140+ <button data-view="sessions" class="nav-btn">
141+ <svg viewBox="0 0 24 24"><path d="M5 5h14M5 12h14M5 19h9" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
142+ <span>Sessions</span><i class="badge hidden"></i>
143+ </button>
144+ <button data-view="eco" class="nav-btn">
145+ <svg viewBox="0 0 24 24"><path d="M4 17l4-6 4 3 4-8 4 5" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 21h18" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
146+ <span>Écosystème</span><i class="badge hidden"></i>
147+ </button>
148+ <button data-view="settings" class="nav-btn">
149+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
150+ <span>Réglages</span><i class="badge hidden"></i>
151+ </button>
152+ </nav>
153+
154+ <!-- ======== Feuille détail (éco / options) ======== -->
155+ <div id="sheet" class="sheet hidden">
156+ <div id="sheetBack" class="sheet-back"></div>
157+ <div class="sheet-panel">
158+ <div class="sheet-grip"></div>
159+ <div id="sheetBody" class="sheet-body"></div>
86 </div> 160 </div>
87 </div> 161 </div>
88 </div> 162 </div>
modified public/style.css +392 −285
@@ -1,413 +1,520 @@
1+/* Admin-Ka — identité Groupe KA : crème + orange, néo-brutalisme doux */
1 :root { 2 :root {
2− --bg: #f2f4f8; 3+ --bg: #F2F1EC;
3− --surface: #ffffff; 4+ --card: #FCFBF8;
4− --surface2: #f6f8fb; 5+ --card2: #F7F5EF;
5− --border: #e4e8ef; 6+ --ink: #1a1611;
6− --border-strong: #d4dae3; 7+ --muted: #6d675c;
7− --text: #17203a; 8+ --line: #e3dfd4;
8− --muted: #67708a; 9+ --orange: #F97316;
9− --accent: #3556f0; 10+ --orange-deep: #e05f00; /* traits de graphique (contraste validé) */
10− --accent-soft: #eef1fe; 11+ --orange-soft: #fee9d8;
11− --accent-grad: linear-gradient(135deg, #3556f0, #5a71f5); 12+ --good: #178a4c;
12− --green: #17924a; 13+ --good-soft: #e2f3e8;
13− --green-soft: #e7f7ee; 14+ --warn: #946300;
14− --red: #d5303e; 15+ --warn-soft: #f7edd4;
15− --red-soft: #fdeeef; 16+ --bad: #d5303e;
16− --amber: #b26a00; 17+ --bad-soft: #fbe6e7;
17− --amber-soft: #fff6e6; 18+ --grid: #e5e1d8;
18− --shadow-sm: 0 1px 2px rgba(23, 32, 58, 0.06), 0 1px 3px rgba(23, 32, 58, 0.08); 19+ --r-xl: 26px;
19− --shadow-md: 0 4px 16px rgba(23, 32, 58, 0.10); 20+ --r-lg: 20px;
20− --shadow-lg: 0 12px 40px rgba(23, 32, 58, 0.16); 21+ --r-md: 14px;
21− --r-lg: 18px; 22+ --r-sm: 10px;
22− --r-md: 13px; 23+ --bshadow: 3px 3px 0 var(--ink);
23− --r-sm: 9px; 24+ --bshadow-sm: 2px 2px 0 var(--ink);
25+ --z-banner: 140;
24 --z-header: 100; 26 --z-header: 100;
25− --z-composer: 100; 27+ --z-nav: 110;
26− --z-welcome: 50; 28+ --z-composer: 105;
27− --z-drawer: 200; 29+ --z-sheet: 200;
28 --z-login: 300; 30 --z-login: 300;
29 --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace; 31 --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace;
32+ --round: ui-rounded, -apple-system, BlinkMacSystemFont, "SF Pro Rounded", "Segoe UI", Roboto, sans-serif;
30 } 33 }
31 * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } 34 * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
32 html, body { 35 html, body {
33 margin: 0; padding: 0; 36 margin: 0; padding: 0;
34− background: var(--bg); color: var(--text); 37+ background: var(--bg); color: var(--ink);
35− font: 15.5px/1.55 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif; 38+ font: 15.5px/1.55 var(--round);
36 height: 100%; 39 height: 100%;
37 overscroll-behavior: none; 40 overscroll-behavior: none;
38− text-rendering: optimizeLegibility;
39 } 41 }
40 .hidden { display: none !important; } 42 .hidden { display: none !important; }
41−button { font-family: inherit; } 43+button { font-family: inherit; color: inherit; }
44+.mono { font-family: var(--mono); font-size: 0.92em; }
45+
46+.ka-mark {
47+ display: inline-flex; align-items: center; justify-content: center;
48+ width: 34px; height: 34px; border-radius: 11px;
49+ background: var(--orange); color: var(--ink);
50+ border: 2px solid var(--ink); box-shadow: var(--bshadow-sm);
51+ font-weight: 800; font-size: 15px; letter-spacing: -0.2px;
52+ flex-shrink: 0;
53+}
54+.ka-mark.big { width: 68px; height: 68px; border-radius: 21px; font-size: 28px; box-shadow: var(--bshadow); margin: 0 auto; }
55+.tiret { color: var(--orange); font-weight: 800; }
56+
57+.btn-cta {
58+ background: var(--orange); color: var(--ink);
59+ border: 2px solid var(--ink); border-radius: 999px;
60+ box-shadow: var(--bshadow);
61+ font-weight: 800; font-size: 16px; padding: 13px 22px; cursor: pointer;
62+ transition: transform 0.08s, box-shadow 0.08s;
63+}
64+.btn-cta:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--ink); }
65+.btn-cta.small { font-size: 13.5px; padding: 8px 16px; box-shadow: var(--bshadow-sm); }
66+.btn-ghost {
67+ background: var(--card); border: 1.5px solid var(--ink); border-radius: 999px;
68+ font-weight: 700; font-size: 13.5px; padding: 10px 16px; cursor: pointer;
69+}
70+.btn-danger {
71+ display: block; margin: 18px auto calc(20px);
72+ background: var(--card); border: 2px solid var(--bad); color: var(--bad);
73+ border-radius: 999px; font-weight: 800; font-size: 14.5px; padding: 12px 26px; cursor: pointer;
74+}
42 75
43 /* ================= Login ================= */ 76 /* ================= Login ================= */
44 .login { 77 .login {
45 position: fixed; inset: 0; z-index: var(--z-login); 78 position: fixed; inset: 0; z-index: var(--z-login);
46− background: 79+ background: var(--bg);
47− radial-gradient(1200px 600px at 50% -200px, #e3e9ff 0%, transparent 60%),
48− var(--bg);
49 display: flex; flex-direction: column; align-items: center; justify-content: center; 80 display: flex; flex-direction: column; align-items: center; justify-content: center;
50− padding: 24px; 81+ padding: 24px; gap: 18px;
51 } 82 }
52 .login-box { 83 .login-box {
53 width: 100%; max-width: 360px; 84 width: 100%; max-width: 360px;
54− background: var(--surface); 85+ background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-xl);
55− border: 1px solid var(--border); 86+ box-shadow: 6px 6px 0 var(--ink);
56− border-radius: 24px; 87+ padding: 34px 26px 26px;
57− box-shadow: var(--shadow-lg); 88+ display: flex; flex-direction: column; gap: 13px; text-align: center;
58− padding: 36px 28px 28px;
59− display: flex; flex-direction: column; gap: 12px;
60− text-align: center;
61−}
62−.login-mark {
63− width: 64px; height: 64px; margin: 0 auto 4px;
64− background: var(--accent-grad); color: #fff;
65− border-radius: 20px;
66− display: flex; align-items: center; justify-content: center;
67− font-size: 22px; font-weight: 800; letter-spacing: 0.5px;
68− box-shadow: 0 8px 24px rgba(53, 86, 240, 0.35);
69 } 89 }
70−.login-title { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -0.3px; } 90+.login-title { margin: 6px 0 0; font-size: 27px; font-weight: 800; letter-spacing: -0.5px; }
71−.login-sub { margin: 0 0 10px; color: var(--muted); font-size: 13.5px; } 91+.login-sub { margin: 0 0 8px; color: var(--muted); font-size: 13.5px; font-weight: 600; }
72 .login input { 92 .login input {
73− background: var(--surface2); border: 1.5px solid var(--border); color: var(--text); 93+ background: var(--bg); border: 2px solid var(--ink); color: var(--ink);
74 border-radius: var(--r-md); padding: 14px 16px; font-size: 16px; outline: none; 94 border-radius: var(--r-md); padding: 14px 16px; font-size: 16px; outline: none;
75− transition: border-color 0.15s, box-shadow 0.15s; 95+ font-family: inherit;
76−} 96+}
77−.login input:focus { border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); } 97+.login input:focus { border-color: var(--orange); }
78−.login button { 98+.login-error { color: var(--bad); font-size: 13px; min-height: 18px; font-weight: 600; }
79− background: var(--accent-grad); color: #fff; font-weight: 600; font-size: 16px; 99+.login-foot { color: var(--muted); font-size: 12px; font-family: var(--mono); }
80− border: none; border-radius: var(--r-md); padding: 14px; cursor: pointer;
81− box-shadow: 0 4px 14px rgba(53, 86, 240, 0.3);
82− transition: transform 0.1s;
83−}
84−.login button:active { transform: scale(0.98); }
85−.login-error { color: var(--red); font-size: 13px; min-height: 18px; }
86−.login-foot { margin-top: 20px; color: var(--muted); font-size: 12px; font-family: var(--mono); }
87 100
88−/* ================= Layout ================= */ 101+/* ================= Coquille ================= */
89−.app { display: flex; flex-direction: column; height: 100dvh; } 102+.app { display: flex; flex-direction: column; height: 100dvh; position: relative; }
90 103
91 .header { 104 .header {
92 position: sticky; top: 0; z-index: var(--z-header); 105 position: sticky; top: 0; z-index: var(--z-header);
93− display: flex; align-items: center; gap: 8px; 106+ display: flex; align-items: center; justify-content: space-between; gap: 10px;
94− background: rgba(255, 255, 255, 0.92); 107+ background: var(--bg);
95− -webkit-backdrop-filter: saturate(180%) blur(14px); 108+ border-bottom: 2px solid var(--ink);
96− backdrop-filter: saturate(180%) blur(14px); 109+ padding: 8px 14px;
97− border-bottom: 1px solid var(--border);
98− padding: 8px 10px;
99 padding-top: calc(8px + env(safe-area-inset-top)); 110 padding-top: calc(8px + env(safe-area-inset-top));
100 } 111 }
101−.header-mid { flex: 1; min-width: 0; } 112+.brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 19px; letter-spacing: -0.4px; }
102−.project-row { 113+.conn-pill {
103− display: flex; align-items: center; gap: 8px; 114+ display: flex; align-items: center; gap: 6px;
104− background: var(--surface2); 115+ border: 1.5px solid var(--ink); border-radius: 999px;
105− border: 1px solid var(--border); 116+ background: var(--card); font-size: 11.5px; font-weight: 700;
106− border-radius: 999px; 117+ padding: 4px 10px;
107− padding: 6px 12px 6px 12px; 118+}
108−} 119+.conn-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--bad); }
109−.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--red); flex-shrink: 0; transition: background 0.3s; } 120+.conn-pill.on .conn-dot { background: var(--good); }
110−.dot.on { background: var(--green); box-shadow: 0 0 0 3px var(--green-soft); } 121+.conn-pill.mid .conn-dot { background: var(--warn); animation: pulse 1s infinite; }
111−.project-sel { 122+@keyframes pulse { 50% { opacity: 0.35; } }
112− flex: 1; min-width: 0;
113− background: none; color: var(--text); border: none;
114− font-size: 15px; font-weight: 600; outline: none;
115− appearance: none; -webkit-appearance: none;
116− text-overflow: ellipsis;
117− padding-right: 14px;
118− background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 9l6 6 6-6' stroke='%2367708a' stroke-width='2.4' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
119− background-repeat: no-repeat; background-position: right center; background-size: 13px;
120−}
121−.icon-btn {
122− background: none; border: none; color: var(--text);
123− width: 40px; height: 40px; border-radius: 12px; cursor: pointer; flex-shrink: 0;
124− display: flex; align-items: center; justify-content: center;
125− transition: background 0.15s;
126−}
127−.icon-btn svg { width: 21px; height: 21px; }
128−.icon-btn:active { background: var(--surface2); }
129−.icon-btn.accent { color: var(--accent); background: var(--accent-soft); }
130−.mode-btn {
131− border: 1px solid var(--border); background: var(--green-soft); color: var(--green);
132− font-size: 12.5px; font-weight: 700; border-radius: 999px; padding: 7px 13px;
133− cursor: pointer; flex-shrink: 0; letter-spacing: 0.2px;
134−}
135−.mode-btn.auto { color: var(--amber); background: var(--amber-soft); }
136 123
137−/* ================= Welcome ================= */ 124+/* bannières notification / alerte */
138−.welcome { 125+.banners { position: fixed; top: calc(52px + env(safe-area-inset-top)); left: 0; right: 0; z-index: var(--z-banner); display: flex; flex-direction: column; gap: 8px; padding: 8px 14px; pointer-events: none; }
139− position: absolute; inset: 0; z-index: var(--z-welcome); 126+.banner {
140− display: flex; flex-direction: column; align-items: center; justify-content: center; 127+ pointer-events: auto; cursor: pointer;
141− gap: 8px; padding: 32px; text-align: center; pointer-events: none; 128+ border: 2px solid var(--ink); border-radius: var(--r-md);
129+ box-shadow: var(--bshadow); padding: 11px 14px;
130+ font-weight: 700; font-size: 13.5px;
131+ display: flex; gap: 10px; align-items: center;
132+ animation: drop-in 0.25s ease-out;
133+}
134+@keyframes drop-in { from { transform: translateY(-14px); opacity: 0; } to { transform: none; opacity: 1; } }
135+.banner.info { background: var(--orange-soft); }
136+.banner.good { background: var(--good-soft); }
137+.banner.bad { background: var(--bad-soft); }
138+.banner.warn { background: var(--warn-soft); }
139+
140+/* vues */
141+.view { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
142+#view-sessions, #view-eco, #view-settings { overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 14px 14px calc(86px + env(safe-area-inset-bottom)); }
143+.view-head { display: flex; align-items: center; justify-content: space-between; margin: 4px 2px 14px; }
144+.view-head h2 { margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.5px; }
145+
146+/* ================= Bottom nav ================= */
147+.bottomnav {
148+ position: fixed; bottom: 0; left: 0; right: 0; z-index: var(--z-nav);
149+ display: flex;
150+ background: var(--card);
151+ border-top: 2px solid var(--ink);
152+ padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
153+}
154+.nav-btn {
155+ position: relative; flex: 1;
156+ background: none; border: none; cursor: pointer;
157+ display: flex; flex-direction: column; align-items: center; gap: 2px;
158+ color: var(--muted); font-size: 10.5px; font-weight: 700;
159+ padding: 6px 2px; border-radius: var(--r-md);
142 } 160 }
143−.welcome > * { pointer-events: auto; } 161+.nav-btn svg { width: 23px; height: 23px; }
144−.welcome-mark { 162+.nav-btn.active { color: var(--ink); }
145− width: 72px; height: 72px; 163+.nav-btn.active svg { color: var(--orange-deep); }
146− background: var(--accent-grad); color: #fff; 164+.nav-btn .badge {
147− border-radius: 22px; 165+ position: absolute; top: 3px; right: calc(50% - 17px);
148− display: flex; align-items: center; justify-content: center; 166+ width: 9px; height: 9px; border-radius: 50%;
149− font-size: 25px; font-weight: 800; 167+ background: var(--orange); border: 1.5px solid var(--ink);
150− box-shadow: 0 10px 30px rgba(53, 86, 240, 0.3);
151− margin-bottom: 8px;
152 } 168 }
153−.welcome h2 { margin: 0; font-size: 21px; letter-spacing: -0.3px; } 169+
154−.welcome p { margin: 0; color: var(--muted); font-size: 14.5px; max-width: 300px; } 170+/* ================= Vue chat ================= */
155−.hints { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 16px; } 171+.chatbar {
156−.hint { 172+ display: flex; gap: 7px; align-items: center;
157− background: var(--surface); border: 1px solid var(--border); 173+ padding: 9px 12px; overflow-x: auto; scrollbar-width: none;
158− color: var(--text); font-size: 13.5px; 174+ border-bottom: 1.5px solid var(--line);
159− border-radius: 999px; padding: 9px 15px; cursor: pointer; 175+ background: var(--bg);
160− box-shadow: var(--shadow-sm); 176+}
177+.chatbar::-webkit-scrollbar { display: none; }
178+.pill-select {
179+ appearance: none; -webkit-appearance: none;
180+ background: var(--card); color: var(--ink);
181+ border: 2px solid var(--ink); border-radius: 999px;
182+ font-weight: 800; font-size: 13.5px; font-family: inherit;
183+ padding: 7px 26px 7px 13px;
184+ box-shadow: var(--bshadow-sm);
185+ max-width: 46vw; text-overflow: ellipsis;
186+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 9l6 6 6-6' stroke='%231a1611' stroke-width='3' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
187+ background-repeat: no-repeat; background-position: right 9px center; background-size: 12px;
188+}
189+.chip {
190+ border: 1.5px solid var(--ink); border-radius: 999px;
191+ background: var(--card2); font-weight: 700; font-size: 12.5px;
192+ padding: 7px 12px; cursor: pointer; white-space: nowrap; flex-shrink: 0;
193+}
194+.chip.orange { background: var(--orange-soft); }
195+.chip.warn { background: var(--warn-soft); color: var(--warn); }
196+.chip.ghost { border-style: dashed; color: var(--muted); }
197+.sess-meta {
198+ display: flex; gap: 12px; flex-wrap: wrap;
199+ padding: 6px 14px; font-size: 11px; font-family: var(--mono); color: var(--muted);
200+ border-bottom: 1.5px solid var(--line); background: var(--card2);
161 } 201 }
162−.hint:active { background: var(--surface2); }
163 202
164−/* ================= Messages ================= */
165−.app { position: relative; }
166 .messages { 203 .messages {
167 flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; 204 flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch;
168− padding: 16px 14px 24px; 205+ padding: 16px 13px calc(20px);
169 display: flex; flex-direction: column; gap: 12px; 206 display: flex; flex-direction: column; gap: 12px;
170 max-width: 780px; width: 100%; margin: 0 auto; 207 max-width: 780px; width: 100%; margin: 0 auto;
171 } 208 }
172 @keyframes msg-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } 209 @keyframes msg-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
173−.messages > * { animation: msg-in 0.22s ease-out; } 210+.messages > * { animation: msg-in 0.2s ease-out; }
174 211
175 .msg-user { 212 .msg-user {
176− align-self: flex-end; max-width: 85%; 213+ align-self: flex-end; max-width: 86%;
177− background: var(--accent-grad); color: #fff; 214+ background: var(--orange); color: var(--ink);
178− border-radius: 20px 20px 6px 20px; 215+ border: 2px solid var(--ink); border-radius: var(--r-lg) var(--r-lg) 6px var(--r-lg);
179− padding: 11px 16px; 216+ box-shadow: var(--bshadow-sm);
217+ padding: 10px 15px; font-weight: 600;
180 white-space: pre-wrap; word-break: break-word; 218 white-space: pre-wrap; word-break: break-word;
181− box-shadow: 0 3px 10px rgba(53, 86, 240, 0.25);
182− font-size: 15.5px;
183 } 219 }
184 .msg-claude { 220 .msg-claude {
185 align-self: stretch; 221 align-self: stretch;
186− background: var(--surface); 222+ background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg);
187− border: 1px solid var(--border); 223+ box-shadow: var(--bshadow-sm);
188− border-radius: var(--r-lg); 224+ padding: 13px 16px; word-break: break-word;
189− padding: 13px 16px;
190− box-shadow: var(--shadow-sm);
191− word-break: break-word;
192 } 225 }
193 .msg-claude .md > :first-child { margin-top: 0; } 226 .msg-claude .md > :first-child { margin-top: 0; }
194 .msg-claude .md > :last-child { margin-bottom: 0; } 227 .msg-claude .md > :last-child { margin-bottom: 0; }
195 .md p { margin: 0.55em 0; } 228 .md p { margin: 0.55em 0; }
196 .md pre { 229 .md pre {
197− background: #f6f8fa; border: 1px solid var(--border); border-radius: var(--r-sm); 230+ background: #f2efe7; border: 1.5px solid var(--line); border-radius: var(--r-sm);
198 padding: 11px; overflow-x: auto; font-size: 12.5px; line-height: 1.5; 231 padding: 11px; overflow-x: auto; font-size: 12.5px; line-height: 1.5;
199 } 232 }
200 .md code { font-family: var(--mono); font-size: 0.88em; } 233 .md code { font-family: var(--mono); font-size: 0.88em; }
201−.md :not(pre) > code { background: var(--accent-soft); color: #2c46c8; padding: 1px 6px; border-radius: 6px; } 234+.md :not(pre) > code { background: var(--orange-soft); padding: 1px 6px; border-radius: 6px; }
202−.md h1, .md h2, .md h3, .md h4 { font-size: 1.02em; margin: 0.9em 0 0.35em; } 235+.md h1, .md h2, .md h3, .md h4 { font-size: 1.03em; margin: 0.9em 0 0.35em; }
203 .md table { border-collapse: collapse; display: block; overflow-x: auto; font-size: 13px; } 236 .md table { border-collapse: collapse; display: block; overflow-x: auto; font-size: 13px; }
204−.md th, .md td { border: 1px solid var(--border); padding: 5px 9px; } 237+.md th, .md td { border: 1.5px solid var(--line); padding: 5px 9px; }
205−.md th { background: var(--surface2); } 238+.md th { background: var(--card2); }
206−.md a { color: var(--accent); } 239+.md a { color: var(--orange-deep); font-weight: 700; }
207 .md ul, .md ol { padding-left: 1.35em; margin: 0.5em 0; } 240 .md ul, .md ol { padding-left: 1.35em; margin: 0.5em 0; }
208−.md blockquote { margin: 0.6em 0; padding: 2px 12px; border-left: 3px solid var(--border-strong); color: var(--muted); } 241+.md blockquote { margin: 0.6em 0; padding: 2px 12px; border-left: 3px solid var(--orange); color: var(--muted); }
209−.md hr { border: none; border-top: 1px solid var(--border); }
210 242
211 .thinking { 243 .thinking {
212 color: var(--muted); font-style: italic; font-size: 13px; 244 color: var(--muted); font-style: italic; font-size: 13px;
213− border-left: 2.5px solid var(--border-strong); padding-left: 12px; 245+ border-left: 3px solid var(--line); padding-left: 12px;
214 white-space: pre-wrap; word-break: break-word; 246 white-space: pre-wrap; word-break: break-word;
215 } 247 }
216−details.thinking-box { padding: 2px 4px; } 248+details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: pointer; font-weight: 600; }
217−details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: pointer; }
218−
219 .meta-line { 249 .meta-line {
220 align-self: center; text-align: center; 250 align-self: center; text-align: center;
221 color: var(--muted); font-size: 11px; font-family: var(--mono); 251 color: var(--muted); font-size: 11px; font-family: var(--mono);
222− background: var(--surface2); border-radius: 999px; padding: 3px 12px; 252+ background: var(--card2); border: 1.5px solid var(--line); border-radius: 999px; padding: 3px 12px;
223 max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 253 max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
224 } 254 }
255+.notice-line { align-self: center; color: var(--warn); font-size: 12px; font-weight: 700; }
225 .error-box { 256 .error-box {
226− background: var(--red-soft); border: 1px solid #f3c2c6; 257+ background: var(--bad-soft); border: 2px solid var(--bad);
227− color: var(--red); border-radius: var(--r-md); padding: 11px 14px; 258+ color: var(--bad); border-radius: var(--r-md); padding: 11px 14px;
228− font-size: 13px; white-space: pre-wrap; word-break: break-word; 259+ font-size: 13px; white-space: pre-wrap; word-break: break-word; font-weight: 600;
229 } 260 }
230 261
231−/* ================= Cartes outils ================= */ 262+/* --- cartes outils --- */
232 .tool { 263 .tool {
233− background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); 264+ background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-md);
234− overflow: hidden; font-size: 13px; 265+ overflow: hidden; font-size: 13px; box-shadow: var(--bshadow-sm);
235− box-shadow: var(--shadow-sm);
236 } 266 }
237 .tool summary { 267 .tool summary {
238 display: flex; align-items: center; gap: 10px; 268 display: flex; align-items: center; gap: 10px;
239− padding: 9px 13px; cursor: pointer; list-style: none; user-select: none; 269+ padding: 9px 12px; cursor: pointer; list-style: none; user-select: none; min-height: 44px;
240− min-height: 44px;
241 } 270 }
242 .tool summary::-webkit-details-marker { display: none; } 271 .tool summary::-webkit-details-marker { display: none; }
243 .tool .t-icon { 272 .tool .t-icon {
244− flex-shrink: 0; width: 28px; height: 28px; border-radius: 8px; 273+ flex-shrink: 0; width: 28px; height: 28px; border-radius: 9px;
274+ border: 1.5px solid var(--ink);
245 display: flex; align-items: center; justify-content: center; 275 display: flex; align-items: center; justify-content: center;
246− font-size: 14px; background: var(--surface2); 276+ font-size: 13px; background: var(--card2);
247 } 277 }
248−.tool .t-icon.bash { background: #eceff5; } 278+.tool .t-icon.bash { background: var(--ink); color: var(--orange); }
249−.tool .t-icon.edit { background: var(--amber-soft); } 279+.tool .t-icon.edit { background: var(--orange-soft); }
250−.tool .t-icon.read { background: #e8f3fd; } 280+.tool .t-icon.read { background: var(--card2); }
251−.tool .t-icon.web { background: #e9f6f2; } 281+.tool .t-icon.web { background: var(--good-soft); }
252−.tool .t-name { font-weight: 700; font-family: var(--mono); font-size: 12.5px; flex-shrink: 0; color: var(--text); } 282+.tool .t-name { font-weight: 800; font-family: var(--mono); font-size: 12.5px; flex-shrink: 0; }
253 .tool .t-sub { 283 .tool .t-sub {
254 color: var(--muted); font-family: var(--mono); font-size: 12px; 284 color: var(--muted); font-family: var(--mono); font-size: 12px;
255 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; 285 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1;
256 } 286 }
257−.tool .t-state { flex-shrink: 0; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 3px 8px; } 287+.tool .t-state { flex-shrink: 0; font-size: 11px; font-weight: 800; border-radius: 999px; padding: 3px 9px; border: 1.5px solid transparent; }
258−.tool .t-state.pending { color: var(--amber); background: var(--amber-soft); } 288+.tool .t-state.pending { color: var(--warn); background: var(--warn-soft); }
259−.tool .t-state.ok { color: var(--green); background: var(--green-soft); } 289+.tool .t-state.ok { color: var(--good); background: var(--good-soft); }
260−.tool .t-state.err { color: var(--red); background: var(--red-soft); } 290+.tool .t-state.err { color: var(--bad); background: var(--bad-soft); }
261−.tool-body { border-top: 1px solid var(--border); padding: 9px 13px 11px; background: var(--surface2); } 291+.tool-body { border-top: 2px solid var(--ink); padding: 9px 13px 11px; background: var(--card2); }
262 .tool-body pre { 292 .tool-body pre {
263− margin: 6px 0; background: #ffffff; border: 1px solid var(--border); 293+ margin: 6px 0; background: var(--card); border: 1.5px solid var(--line);
264 border-radius: var(--r-sm); padding: 9px; 294 border-radius: var(--r-sm); padding: 9px;
265 overflow-x: auto; font-size: 12px; font-family: var(--mono); 295 overflow-x: auto; font-size: 12px; font-family: var(--mono);
266 white-space: pre-wrap; word-break: break-word; 296 white-space: pre-wrap; word-break: break-word;
267 } 297 }
268−.tool-body .label { 298+.tool-body .label { color: var(--muted); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; margin-top: 6px; }
269− color: var(--muted); font-size: 10.5px; font-weight: 700; 299+.see-more { background: none; border: none; color: var(--orange-deep); font-size: 12.5px; font-weight: 800; cursor: pointer; padding: 4px 0; }
270− text-transform: uppercase; letter-spacing: 0.7px; margin-top: 6px;
271−}
272−.see-more { background: none; border: none; color: var(--accent); font-size: 12.5px; font-weight: 600; cursor: pointer; padding: 4px 0; }
273 300
274−/* diffs — style GitHub clair */ 301+.diff { font-family: var(--mono); font-size: 12px; margin: 6px 0; border: 1.5px solid var(--line); border-radius: var(--r-sm); overflow: hidden; background: var(--card); }
275−.diff {
276− font-family: var(--mono); font-size: 12px; margin: 6px 0;
277− border: 1px solid var(--border); border-radius: var(--r-sm);
278− overflow: hidden; background: #fff;
279−}
280 .diff .dl { padding: 2px 10px; white-space: pre-wrap; word-break: break-word; } 302 .diff .dl { padding: 2px 10px; white-space: pre-wrap; word-break: break-word; }
281−.diff .del { background: #ffebe9; color: #82071e; } 303+.diff .del { background: #ffe4e1; color: #8c1622; }
282−.diff .add { background: #e6ffec; color: #055d20; } 304+.diff .add { background: #ddf3e4; color: #0c5326; }
283 305
284−/* todos */
285 .todos { margin: 4px 0; padding: 0; list-style: none; font-size: 13.5px; } 306 .todos { margin: 4px 0; padding: 0; list-style: none; font-size: 13.5px; }
286 .todos li { padding: 3px 0; } 307 .todos li { padding: 3px 0; }
287 .todos .done { color: var(--muted); text-decoration: line-through; } 308 .todos .done { color: var(--muted); text-decoration: line-through; }
288−.todos .doing { color: var(--accent); font-weight: 600; } 309+.todos .doing { color: var(--orange-deep); font-weight: 800; }
289 310
290−/* ================= Permission ================= */ 311+/* --- permission --- */
291 .perm { 312 .perm {
292− background: var(--amber-soft); border: 1.5px solid #f0c36b; 313+ background: var(--warn-soft); border: 2px solid var(--ink);
293 border-radius: var(--r-lg); padding: 14px; font-size: 13.5px; 314 border-radius: var(--r-lg); padding: 14px; font-size: 13.5px;
294− box-shadow: var(--shadow-md); 315+ box-shadow: 4px 4px 0 var(--ink);
295 } 316 }
296−.perm-title { font-weight: 700; color: var(--amber); margin-bottom: 8px; font-size: 14px; } 317+.perm-title { font-weight: 800; margin-bottom: 8px; font-size: 14.5px; }
297 .perm pre { 318 .perm pre {
298− background: #fff; border: 1px solid #eddcb8; border-radius: var(--r-sm); padding: 9px; 319+ background: var(--card); border: 1.5px solid var(--line); border-radius: var(--r-sm); padding: 9px;
299 overflow-x: auto; font-size: 12px; font-family: var(--mono); 320 overflow-x: auto; font-size: 12px; font-family: var(--mono);
300 white-space: pre-wrap; word-break: break-word; 321 white-space: pre-wrap; word-break: break-word;
301 max-height: 200px; overflow-y: auto; margin: 0; 322 max-height: 200px; overflow-y: auto; margin: 0;
302 } 323 }
303−.perm-actions { display: flex; gap: 10px; margin-top: 12px; } 324+.perm-actions { display: flex; gap: 8px; margin-top: 12px; }
304 .perm-actions button { 325 .perm-actions button {
305− flex: 1; padding: 12px; border-radius: var(--r-md); border: none; 326+ flex: 1; padding: 11px 6px; border-radius: 999px;
306− font-size: 15px; font-weight: 700; cursor: pointer; min-height: 46px; 327+ font-size: 13.5px; font-weight: 800; cursor: pointer; min-height: 46px;
307−} 328+ border: 2px solid var(--ink);
308−.perm-allow { background: var(--green); color: #fff; box-shadow: 0 3px 10px rgba(23, 146, 74, 0.3); } 329+}
309−.perm-deny { background: #fff; color: var(--red); border: 1.5px solid #f3c2c6 !important; } 330+.perm-allow { background: var(--good); color: #fff; box-shadow: var(--bshadow-sm); }
310−.perm-resolved { color: var(--muted); font-size: 12.5px; margin-top: 10px; font-weight: 600; } 331+.perm-always { background: var(--good-soft); color: var(--good); }
332+.perm-deny { background: var(--card); color: var(--bad); }
333+.perm-resolved { color: var(--muted); font-size: 12.5px; margin-top: 10px; font-weight: 700; }
311 334
312−/* ================= Indicateur travail ================= */
313 .working { 335 .working {
314 align-self: flex-start; 336 align-self: flex-start;
315 display: flex; align-items: center; gap: 10px; 337 display: flex; align-items: center; gap: 10px;
316− color: var(--muted); font-size: 13.5px; 338+ font-size: 13.5px; font-weight: 700;
317− background: var(--surface); border: 1px solid var(--border); 339+ background: var(--card); border: 2px solid var(--ink);
318− border-radius: 999px; padding: 8px 16px; 340+ border-radius: 999px; padding: 8px 16px; box-shadow: var(--bshadow-sm);
319− box-shadow: var(--shadow-sm);
320 } 341 }
321 .dots { display: flex; gap: 4px; } 342 .dots { display: flex; gap: 4px; }
322−.dots i { 343+.dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--orange); border: 1px solid var(--ink); animation: bounce 1.1s ease-in-out infinite; }
323− width: 6px; height: 6px; border-radius: 50%; background: var(--accent); 344+.dots i:nth-child(2) { animation-delay: 0.14s; }
324− animation: pulse 1.2s ease-in-out infinite; 345+.dots i:nth-child(3) { animation-delay: 0.28s; }
346+@keyframes bounce { 0%, 100% { transform: translateY(0); opacity: 0.5; } 40% { transform: translateY(-4px); opacity: 1; } }
347+
348+/* --- accueil --- */
349+.welcome {
350+ position: absolute; inset: 130px 0 140px; z-index: 5;
351+ display: flex; flex-direction: column; align-items: center; justify-content: center;
352+ gap: 9px; padding: 28px; text-align: center; pointer-events: none;
353+}
354+.welcome > * { pointer-events: auto; }
355+.welcome h2 { margin: 6px 0 0; font-size: 22px; font-weight: 800; letter-spacing: -0.4px; }
356+.welcome p { margin: 0; color: var(--muted); font-size: 14px; max-width: 300px; font-weight: 600; }
357+.hints { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 14px; }
358+.hint {
359+ background: var(--card); border: 1.5px solid var(--ink);
360+ font-size: 13px; font-weight: 700;
361+ border-radius: 999px; padding: 9px 14px; cursor: pointer;
362+ box-shadow: var(--bshadow-sm);
325 } 363 }
326−.dots i:nth-child(2) { animation-delay: 0.15s; }
327−.dots i:nth-child(3) { animation-delay: 0.3s; }
328−@keyframes pulse { 0%, 100% { opacity: 0.25; transform: scale(0.85); } 40% { opacity: 1; transform: scale(1.1); } }
329 364
330−/* ================= Composer ================= */ 365+/* --- composer --- */
331 .composer { 366 .composer {
332 position: sticky; bottom: 0; z-index: var(--z-composer); 367 position: sticky; bottom: 0; z-index: var(--z-composer);
333− background: linear-gradient(to top, var(--bg) 65%, transparent); 368+ background: var(--bg);
334− padding: 10px 12px calc(10px + env(safe-area-inset-bottom)); 369+ padding: 8px 12px calc(72px + env(safe-area-inset-bottom));
335 } 370 }
336 .composer-inner { 371 .composer-inner {
337 display: flex; align-items: flex-end; gap: 8px; 372 display: flex; align-items: flex-end; gap: 8px;
338 max-width: 780px; margin: 0 auto; 373 max-width: 780px; margin: 0 auto;
339− background: var(--surface); 374+ background: var(--card);
340− border: 1px solid var(--border); 375+ border: 2px solid var(--ink); border-radius: var(--r-xl);
341− border-radius: 26px; 376+ box-shadow: var(--bshadow);
342− padding: 7px 7px 7px 18px; 377+ padding: 6px 6px 6px 17px;
343− box-shadow: var(--shadow-md);
344 } 378 }
345 #input { 379 #input {
346− flex: 1; background: none; color: var(--text); 380+ flex: 1; background: none; color: var(--ink);
347− border: none; 381+ border: none; padding: 10px 0; font-size: 16px; line-height: 1.45;
348− padding: 9px 0; font-size: 16px; line-height: 1.45; 382+ resize: none; outline: none; max-height: 140px; font-family: inherit; font-weight: 600;
349− resize: none; outline: none; max-height: 140px;
350− font-family: inherit;
351 } 383 }
352−#input::placeholder { color: var(--muted); } 384+#input::placeholder { color: var(--muted); font-weight: 600; }
353 .send-btn { 385 .send-btn {
354− width: 40px; height: 40px; border-radius: 50%; border: none; 386+ width: 42px; height: 42px; border-radius: 50%;
355− background: var(--accent-grad); color: #fff; 387+ border: 2px solid var(--ink);
388+ background: var(--orange); color: var(--ink);
356 cursor: pointer; flex-shrink: 0; 389 cursor: pointer; flex-shrink: 0;
357 display: flex; align-items: center; justify-content: center; 390 display: flex; align-items: center; justify-content: center;
358− box-shadow: 0 3px 10px rgba(53, 86, 240, 0.35); 391+ box-shadow: var(--bshadow-sm);
359− transition: transform 0.1s, opacity 0.15s; 392+ transition: transform 0.08s;
360 } 393 }
361 .send-btn svg { width: 19px; height: 19px; } 394 .send-btn svg { width: 19px; height: 19px; }
362−.send-btn:active { transform: scale(0.92); } 395+.send-btn:active { transform: translate(1px, 1px); box-shadow: none; }
363 .send-btn:disabled { opacity: 0.35; } 396 .send-btn:disabled { opacity: 0.35; }
364−.send-btn.stop { background: var(--red); box-shadow: 0 3px 10px rgba(213, 48, 62, 0.35); } 397+.send-btn.stop { background: var(--bad); color: #fff; }
365− 398+.slash-hint {
366−/* ================= Drawer ================= */ 399+ max-width: 780px; margin: 6px auto 0; display: flex; gap: 6px; flex-wrap: wrap;
367−.drawer { position: fixed; inset: 0; z-index: var(--z-drawer); }
368−.drawer-back { position: absolute; inset: 0; background: rgba(23, 32, 58, 0.35); -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); }
369−@keyframes slide-in { from { transform: translateX(-100%); } to { transform: none; } }
370−.drawer-panel {
371− position: absolute; top: 0; left: 0; bottom: 0; width: min(330px, 88vw);
372− background: var(--surface);
373− box-shadow: var(--shadow-lg);
374− display: flex; flex-direction: column;
375− padding-top: env(safe-area-inset-top);
376− animation: slide-in 0.22s ease-out;
377− border-radius: 0 20px 20px 0;
378−}
379−.drawer-head {
380− display: flex; align-items: center; justify-content: space-between;
381− padding: 14px 10px 12px 20px; border-bottom: 1px solid var(--border);
382− font-weight: 700; font-size: 17px;
383−}
384−.chat-list { flex: 1; overflow-y: auto; padding: 8px; }
385−.chat-item {
386− padding: 12px 12px; border-radius: var(--r-md); cursor: pointer;
387− display: flex; align-items: center; gap: 8px;
388− margin-bottom: 2px;
389−}
390−.chat-item:active { background: var(--surface2); }
391−.chat-item.active { background: var(--accent-soft); }
392−.chat-item .ci-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
393−.chat-item .ci-title { font-size: 14.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
394−.chat-item .ci-meta { font-size: 11.5px; color: var(--muted); }
395−.chat-item .ci-running { color: var(--green); font-weight: 700; }
396−.ci-del {
397− background: none; border: none; color: var(--muted); cursor: pointer;
398− width: 34px; height: 34px; border-radius: 9px; flex-shrink: 0;
399− display: flex; align-items: center; justify-content: center; font-size: 15px;
400−}
401−.ci-del:active { background: var(--red-soft); color: var(--red); }
402−.chat-empty { color: var(--muted); text-align: center; padding: 32px 16px; font-size: 14px; }
403−.logout-btn {
404− margin: 10px 16px calc(14px + env(safe-area-inset-bottom));
405− background: var(--surface2); border: 1px solid var(--border); color: var(--red);
406− border-radius: var(--r-md); padding: 13px; font-size: 14.5px; font-weight: 600; cursor: pointer;
407 } 400 }
408− 401+.slash-hint button {
409−/* écrans plus larges */ 402+ border: 1.5px solid var(--ink); background: var(--card2); border-radius: 999px;
410−@media (min-width: 700px) { 403+ font-size: 12px; font-weight: 700; padding: 5px 11px; cursor: pointer;
411− .header { padding-left: 16px; padding-right: 16px; }
412− .messages { padding: 24px 20px 32px; }
413 } 404 }
405+
406+/* ================= Sessions ================= */
407+.sess-list { display: flex; flex-direction: column; gap: 12px; max-width: 780px; margin: 0 auto; width: 100%; }
408+.sess-card {
409+ background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg);
410+ box-shadow: var(--bshadow-sm); padding: 13px 15px; cursor: pointer;
411+ display: flex; flex-direction: column; gap: 6px;
412+}
413+.sess-top { display: flex; align-items: center; gap: 8px; }
414+.sess-title { flex: 1; min-width: 0; font-weight: 800; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
415+.sess-badge { font-size: 11px; font-weight: 800; border-radius: 999px; padding: 3px 10px; border: 1.5px solid var(--ink); flex-shrink: 0; }
416+.sess-badge.running { background: var(--orange); animation: pulse 1.4s infinite; }
417+.sess-badge.waiting_perm { background: var(--warn-soft); color: var(--warn); }
418+.sess-badge.idle { background: var(--card2); color: var(--muted); }
419+.sess-info { display: flex; gap: 10px; flex-wrap: wrap; font-size: 11.5px; color: var(--muted); font-family: var(--mono); }
420+.sess-actions { display: flex; gap: 8px; margin-top: 4px; }
421+.sess-actions button {
422+ border: 1.5px solid var(--ink); border-radius: 999px; background: var(--card2);
423+ font-size: 12px; font-weight: 800; padding: 6px 13px; cursor: pointer;
424+}
425+.sess-actions .del { color: var(--bad); }
426+.sess-empty { color: var(--muted); text-align: center; padding: 40px 16px; font-weight: 600; }
427+
428+/* ================= Écosystème ================= */
429+.eco-stamp { font-size: 11px; color: var(--muted); font-family: var(--mono); }
430+.eco-overview {
431+ display: flex; gap: 10px; margin-bottom: 14px;
432+ max-width: 780px; margin-left: auto; margin-right: auto;
433+}
434+.eco-stat {
435+ flex: 1; background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg);
436+ box-shadow: var(--bshadow-sm); padding: 10px 12px; text-align: center;
437+}
438+.eco-stat .v { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }
439+.eco-stat .l { font-size: 10.5px; color: var(--muted); font-weight: 800; text-transform: uppercase; letter-spacing: 0.6px; }
440+.eco-stat.ok .v { color: var(--good); }
441+.eco-stat.bad .v { color: var(--bad); }
442+.eco-grid {
443+ display: grid; grid-template-columns: 1fr; gap: 12px;
444+ max-width: 780px; margin: 0 auto; width: 100%;
445+}
446+@media (min-width: 560px) { .eco-grid { grid-template-columns: 1fr 1fr; } }
447+.eco-loading { color: var(--muted); text-align: center; padding: 40px; font-weight: 700; }
448+.site-card {
449+ background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg);
450+ box-shadow: var(--bshadow-sm); padding: 12px 14px; cursor: pointer;
451+ display: flex; flex-direction: column; gap: 8px;
452+ transition: transform 0.08s;
453+}
454+.site-card:active { transform: translate(2px, 2px); box-shadow: none; }
455+.site-card.down { border-color: var(--bad); box-shadow: 3px 3px 0 var(--bad); }
456+.site-top { display: flex; align-items: center; gap: 8px; }
457+.site-name { font-weight: 800; font-size: 16px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
458+.st-pill { font-size: 10.5px; font-weight: 800; border-radius: 999px; padding: 3px 10px; border: 1.5px solid var(--ink); flex-shrink: 0; display: flex; gap: 4px; align-items: center; }
459+.st-pill.up { background: var(--good-soft); color: var(--good); }
460+.st-pill.slow { background: var(--warn-soft); color: var(--warn); }
461+.st-pill.down { background: var(--bad); color: #fff; }
462+.site-mid { display: flex; align-items: flex-end; gap: 10px; }
463+.site-ms { font-size: 24px; font-weight: 800; letter-spacing: -0.5px; line-height: 1; }
464+.site-ms small { font-size: 11px; color: var(--muted); font-weight: 700; }
465+.spark { flex: 1; height: 30px; }
466+.site-foot { display: flex; gap: 9px; flex-wrap: wrap; font-size: 10.5px; color: var(--muted); font-family: var(--mono); }
467+.site-foot b { color: var(--ink); }
468+
469+/* feuille détail */
470+.sheet { position: fixed; inset: 0; z-index: var(--z-sheet); }
471+.sheet-back { position: absolute; inset: 0; background: rgba(26, 22, 17, 0.45); }
472+@keyframes sheet-up { from { transform: translateY(60%); } to { transform: none; } }
473+.sheet-panel {
474+ position: absolute; left: 0; right: 0; bottom: 0;
475+ max-height: 88dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
476+ background: var(--bg); border: 2px solid var(--ink); border-bottom: none;
477+ border-radius: var(--r-xl) var(--r-xl) 0 0;
478+ padding: 8px 16px calc(20px + env(safe-area-inset-bottom));
479+ animation: sheet-up 0.22s ease-out;
480+}
481+.sheet-grip { width: 42px; height: 5px; border-radius: 3px; background: var(--line); margin: 6px auto 12px; }
482+.sheet-h { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
483+.sheet-h h3 { margin: 0; font-size: 21px; font-weight: 800; flex: 1; }
484+.sheet-sec { background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); box-shadow: var(--bshadow-sm); padding: 13px 15px; margin-bottom: 12px; }
485+.sheet-sec h4 { margin: 0 0 9px; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); }
486+.kv { display: flex; justify-content: space-between; gap: 10px; padding: 4px 0; font-size: 13.5px; }
487+.kv b { font-family: var(--mono); font-weight: 700; }
488+.tile-row { display: flex; gap: 9px; }
489+.tile { flex: 1; background: var(--card2); border: 1.5px solid var(--line); border-radius: var(--r-md); padding: 8px 6px; text-align: center; }
490+.tile .v { font-size: 17px; font-weight: 800; }
491+.tile .l { font-size: 10px; color: var(--muted); font-weight: 800; text-transform: uppercase; }
492+.chart { width: 100%; }
493+.chart-tip {
494+ position: absolute; pointer-events: none; transform: translate(-50%, -110%);
495+ background: var(--ink); color: var(--bg); border-radius: 8px;
496+ font-size: 11px; font-family: var(--mono); padding: 4px 8px; white-space: nowrap;
497+}
498+.inc-item { display: flex; gap: 8px; padding: 7px 0; border-bottom: 1.5px solid var(--line); font-size: 12.5px; align-items: baseline; }
499+.inc-item:last-child { border-bottom: none; }
500+.inc-dot { color: var(--bad); font-weight: 800; }
501+.inc-ok { color: var(--good); font-weight: 800; }
502+
503+/* ================= Réglages ================= */
504+.set-card {
505+ max-width: 780px; margin: 0 auto 12px; width: 100%;
506+ background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg);
507+ box-shadow: var(--bshadow-sm); padding: 14px 16px;
508+}
509+.set-label { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); margin-bottom: 10px; }
510+.seg { display: flex; gap: 7px; }
511+.seg.vert { flex-direction: column; }
512+.seg button {
513+ flex: 1; border: 1.5px solid var(--ink); border-radius: 999px;
514+ background: var(--card2); font-weight: 700; font-size: 13.5px;
515+ padding: 10px 12px; cursor: pointer; text-align: center;
516+}
517+.seg.vert button { text-align: left; padding-left: 16px; }
518+.seg button.on { background: var(--orange); font-weight: 800; box-shadow: var(--bshadow-sm); }
519+.set-row { display: flex; justify-content: space-between; gap: 10px; padding: 6px 0; font-size: 14px; font-weight: 600; }
520+.set-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
added server/monitor.js +240 −0
Large diff (240 lines) is not loaded by default to keep the page fast.Load diff
modified server/server.js +180 −81
Large diff (480 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme