From 8d4ab9a07f44b0a0a8845af057f83b051a708636 Mon Sep 17 00:00:00 2001 From: alf Date: Tue, 30 Jun 2026 17:02:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20r=C3=A9=C3=A9criture=20front=20?= =?UTF-8?q?=C2=AB=20clinique-apais=C3=A9=20=C2=BB=20=E2=80=94=20shell=20d'?= =?UTF-8?q?app=20(phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refonte UX complète (page blanche assumée sur l'UI uniquement) : - web/styles.css : design system clair (tokens couleurs/espacements, cartes, tables, badges, boutons, toasts, spinner, écran de déverrouillage). - web/index.html : shell minimal (charge css + js). - web/app.js : shell d'application — top-bar (contexte tablette + état coffre), nav latérale par sections (Flotte · Voix · Mises à jour · Audit + stubs Profils/ Conversations/Config/RAG « à venir »), écran de déverrouillage propre, toasts + états de chargement, sélecteur de tablette global. - Sections Flotte (vue parc + détail device), Voix (catalogue global + chargées/ chargeables + déploiement + ré-enrôlement + autosync), Updates (OTA parc), Audit. - Sans build (vanilla JS servi par StaticFiles), wrappable pywebview. Co-Authored-By: Claude Opus 4.8 (1M context) --- kazeia_central/web/app.js | 224 +++++++++++++++++++ kazeia_central/web/index.html | 403 +--------------------------------- kazeia_central/web/styles.css | 147 +++++++++++++ 3 files changed, 376 insertions(+), 398 deletions(-) create mode 100644 kazeia_central/web/app.js create mode 100644 kazeia_central/web/styles.css diff --git a/kazeia_central/web/app.js b/kazeia_central/web/app.js new file mode 100644 index 0000000..d95e852 --- /dev/null +++ b/kazeia_central/web/app.js @@ -0,0 +1,224 @@ +"use strict"; +/* Kazeia-central — shell d'application (clinique-apaisé, sans build). */ + +const S = { initialized: false, unlocked: false, devices: [], selected: null, section: "flotte" }; + +// ---- helpers -------------------------------------------------------------- +const $ = (s, r = document) => r.querySelector(s); +function elFrom(html) { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstChild; } +async function api(path, opts) { + const r = await fetch("/api" + path, opts); + if (!r.ok) { let d = r.status; try { d = (await r.json()).detail || d; } catch {} const e = new Error(d); e.status = r.status; throw e; } + return r.status === 204 ? null : r.json(); +} +function toast(msg, kind = "info", ms = 3800) { + const t = elFrom(`
${msg}
`); $("#toasts").appendChild(t); + setTimeout(() => t.remove(), ms); +} +const esc = (s) => (s == null ? "" : String(s)).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); +const badge = (txt, cls = "") => `${esc(txt)}`; +const dot = (cls) => ``; +const fmtTs = (ms) => ms ? new Date(ms).toLocaleString("fr-FR") : "—"; +const scopeBadge = (s) => badge(s || "?", { exclusive: "warn", global: "ok", pending: "" }[s] || ""); +function deviceLabel(serial) { const d = S.devices.find(x => x.serial === serial); return d && d.label ? d.label : serial; } +async function withBusy(btn, fn) { const o = btn.textContent; btn.disabled = true; btn.innerHTML = ``; try { return await fn(); } finally { btn.disabled = false; btn.textContent = o; } } + +// ---- sections ------------------------------------------------------------- +const SECTIONS = [ + { id: "flotte", ic: "🛰", label: "Flotte", render: renderFlotte }, + { id: "voix", ic: "🎙", label: "Voix", render: renderVoix }, + { id: "updates", ic: "⬆", label: "Mises à jour", render: renderUpdates }, + { id: "audit", ic: "📋", label: "Audit", render: renderAudit }, + { sep: "Parité admin (à venir)" }, + { id: "profils", ic: "👤", label: "Profils", render: () => stub("Profils & fiche patient", "Fiche clinique chiffrée côté Kazeia-central (identité, contact, clinique) + langue par patient. La fiche est constructible maintenant ; la propagation de la langue passe par l'app patiente (PROFILE_LANGUAGE_SPEC livrée).") }, + { id: "conversations", ic: "💬", label: "Conversations", render: () => stub("Conversations cliniques", "Rapatriement chiffré des conversations + export PDF/JSON + purge + collecteur de fond. Dépend de conversations_export côté app patiente.") }, + { id: "config", ic: "⚙", label: "Config & presets", render: () => stub("Config & presets", "Édition de la config sampling + presets et push. Dépend des méthodes call() base64-JSON côté app patiente (PROVIDER_RPC_SPEC).") }, + { id: "rag", ic: "📚", label: "RAG", render: () => stub("Corpus RAG", "Authoring du corpus + test retrieval + publication OTA. Dépend de rag_upsert_json côté app patiente.") }, +]; + +// ---- boot + shell --------------------------------------------------------- +boot(); +async function boot() { + try { const st = await api("/lock-status"); S.initialized = st.initialized; S.unlocked = st.unlocked; } + catch (e) { $("#app").innerHTML = `

serveur injoignable : ${esc(e.message)}

`; return; } + if (!S.unlocked) return renderGate(); + await renderShell(); +} + +function renderGate() { + const first = !S.initialized; + $("#app").innerHTML = `
+ +

${first ? "Choisis un mot de passe opérateur (première fois)" : "Déverrouille le coffre opérateur"}

+ + +
+
`; + const go = async () => { + try { await api("/unlock", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ password: $("#pw").value }) }); S.unlocked = true; await renderShell(); } + catch (e) { $("#gerr").textContent = e.message; } + }; + $("#gobtn").onclick = go; + $("#pw").onkeydown = (e) => { if (e.key === "Enter") go(); }; +} + +async function renderShell() { + $("#app").innerHTML = ` +
+ Kazeia·central + Tablette : + + 🔓 coffre ouvert + +
+ +
`; + $("#sidebar").innerHTML = SECTIONS.map(s => s.sep + ? `` + : ``).join(""); + $("#sidebar").querySelectorAll(".nav-item").forEach(n => n.onclick = () => { S.section = n.dataset.id; renderSection(); }); + await loadDevices(); + renderSection(); +} + +async function loadDevices() { + try { S.devices = await api("/devices"); } catch (e) { S.devices = []; toast("erreur adb : " + e.message, "err"); } + if (S.devices.length && !S.selected) S.selected = S.devices[0].serial; + const sel = $("#devsel"); + if (sel) { + sel.innerHTML = `` + S.devices.map(d => + ``).join(""); + sel.onchange = () => { S.selected = sel.value || null; renderSection(); }; + } +} + +function renderSection() { + document.querySelectorAll(".nav-item").forEach(n => n.classList.toggle("active", n.dataset.id === S.section)); + const sec = SECTIONS.find(s => s.id === S.section) || SECTIONS[0]; + const c = $("#content"); c.innerHTML = `
`; + Promise.resolve(sec.render(c)).catch(e => { c.innerHTML = `
${esc(e.message)}
`; }); +} + +function pageHead(title, sub, actions = "") { + return `

${title}

${sub ? `
${sub}
` : ""}
${actions}
`; +} +function stub(title, desc) { + return `${pageHead(title)}
🚧

${esc(desc)}

Section prévue par l'architecture — non encore implémentée.

`; +} + +// ---- FLOTTE --------------------------------------------------------------- +async function renderFlotte(c) { + const rep = await api("/fleet/overview").catch(e => ({ error: e.message })); + const rows = Array.isArray(rep) ? rep : []; + const head = pageHead("Flotte", `${S.devices.length} tablette(s)`, + ``); + const table = rep.error ? `
${esc(rep.error)}
` : rows.length ? ` + + ${rows.map(r => { const o = r.result || {}; const sel = r.serial === S.selected; + return ` + + ${r.ok === false ? `` : ` + + + + + + `}`; }).join("")} +
tabletteétatRAMMAJRAGprofilssessionscrashes
${esc(r.label || r.serial)}
${esc(r.serial)}
${esc(r.error)}${dot(o.online ? "ok" : "off")} ${o.online ? "en ligne" : "hors-ligne"}${o.mem_available_mb ?? "—"} Mo${esc(o.update_phase || "—")}${o.app_update_available ? " ⬆" : ""}${dot(o.rag_ready ? "ok" : "idle")} ${o.rag_docs ?? 0}${o.profiles ?? 0}${o.sessions ?? 0}${o.crashes ? `${o.crashes}` : 0}
` : `
aucune tablette branchée
`; + c.innerHTML = head + `
${table}
`; + if (S.selected) renderDeviceDetail($("#devdetail"), S.selected); +} + +function selectDevice(serial) { S.selected = serial; const sel = $("#devsel"); if (sel) sel.value = serial; renderSection(); } + +async function renderDeviceDetail(host, serial) { + host.innerHTML = `

Détail · ${esc(deviceLabel(serial))}

`; + const get = (p, fn) => api(`/devices/${serial}/${p}`).then(fn).catch(e => `

${p}

${esc(e.message)}
`); + const cards = await Promise.all([ + get("state", s => card("État runtime", kv("pid", s.pid) + kv("uptime (s)", s.uptime_seconds) + kv("PSS (Mo)", s.pss_mb) + kv("RAM dispo (Mo)", s.mem_available_mb))), + get("updates", u => card("Mises à jour", kv("phase", u.phase) + kv("MAJ dispo", u.app_update_available ? "oui" : "non") + kv("version", u.app_version_name || "—"))), + get("rag/status", r => card("RAG", kv("état", (r.ready ? "prêt" : "non prêt")) + kv("modèle", r.model || "—") + kv("docs", r.doc_count ?? 0))), + get("profiles", ps => card("Profils (" + ps.length + ")", ps.length ? `${ps.map(p => ``).join("")}
nomvoixPINtours
${esc(p.display_name || p.id)}${esc(p.voice_id || "—")}${p.has_pin ? "🔒" : "—"}${p.turn_count ?? 0}
` : `aucun`, "full")), + get("conversations", cs => card("Conversations (" + cs.length + ")", cs.length ? `${cs.map(x => ``).join("")}
profilsessiontoursdébut
${esc(x.profile_id)}${esc(x.session_id)}${x.turn_count ?? 0}${fmtTs(x.started_at)}
` : `aucune session`, "full")), + get("crashes", cr => card("Crashes (" + cr.length + ")", cr.length ? cr.map(x => kv(x.component || "?", x.message || "")).join("") : `aucun`)), + ]); + $("#ddgrid").innerHTML = cards.join(""); +} +const card = (title, body, cls = "") => `

${title}

${body}
`; +const kv = (k, v) => `
${k}${v ?? "—"}
`; + +// ---- MISES À JOUR --------------------------------------------------------- +async function renderUpdates(c) { + const rep = await api("/fleet/overview").catch(e => ({ error: e.message })); + const rows = Array.isArray(rep) ? rep : []; + c.innerHTML = pageHead("Mises à jour", "OTA piloté par tablette", + ` `) + + `
${rep.error ? `
${esc(rep.error)}
` : ` + ${rows.map(r => { const o = r.result || {}; return ``; }).join("")}
tablettephaseMAJ dispoversion
${esc(r.label || r.serial)}${esc(o.update_phase || "—")}${o.app_update_available ? badge("disponible", "warn") : "—"}${esc(o.app_version_name || "—")}
`}
`; + $("#chk").onclick = (e) => withBusy(e.target, async () => { const r = await api("/fleet/update-check", { method: "POST" }); toast(`${r.filter(x => x.ok).length}/${r.length} ont accepté la vérification`, "ok"); }); + $("#inst").onclick = (e) => { if (!confirm("Lancer l'installation OTA sur tout le parc ?")) return; withBusy(e.target, async () => { const r = await api("/fleet/update-install", { method: "POST" }); toast(`installation lancée sur ${r.filter(x => x.ok).length}/${r.length}`, "ok"); }); }; +} + +// ---- AUDIT ---------------------------------------------------------------- +async function renderAudit(c) { + const log = await api("/audit").catch(e => ({ error: e.message })); + const rows = Array.isArray(log) ? log : []; + c.innerHTML = pageHead("Audit", "piste d'accès opérateur (§8)") + `
${log.error ? `
${esc(log.error)}
` : rows.length ? ` + ${rows.map(a => ``).join("")}
dateacteuractioncible
${fmtTs(a.ts)}${esc(a.actor || "—")}${badge(a.action, "primary")}${esc(a.target || "—")}
` : `
aucune entrée
`}
`; +} + +// ---- VOIX ----------------------------------------------------------------- +async function renderVoix(c) { + const serial = S.selected; + const reqs = [api("/voices/catalog").catch(e => ({ error: e.message })), api("/voices/autosync").catch(e => ({ error: e.message }))]; + if (serial) reqs.push(api(`/voices/${serial}`).catch(e => ({ error: e.message })), api(`/voices/${serial}/admin`).catch(e => ({ error: e.message }))); + const [cat, auto, tablet, admin] = await Promise.all(reqs); + + const cv = Array.isArray(cat) ? cat : []; + const catCard = card(`Catalogue voix — flotte (${cv.length})`, cat.error ? `
${esc(cat.error)}
` : + cv.length ? ` + ${cv.map(v => ` + + + + `).join("")} +
voixportéeverroudéployée sur
${esc(v.name || v.voice_id)}
${esc(v.voice_id)}
${scopeBadge(v.scope)}${v.locked_profile_id ? "🔒 " + esc(v.owner_name || v.locked_profile_id) : `généraliste`}${(v.deployed_serials || []).map(deviceLabel).map(esc).join(", ") || "—"}${v.locked_profile_id ? `` : ``} + ${v.has_wav ? `` : ""}
` : `
catalogue vide — enregistre des voix via l'app admin puis synchronise
`, "full"); + + const autoCard = card("Déclencheur automatique", auto.error ? `
${esc(auto.error)}
` : + `
état${dot(auto.enabled ? "ok" : "idle")} ${auto.enabled ? "armé" : "désarmé"}
+
+ +
`); + + let cards = catCard + autoCard; + if (!serial) { + cards += card("Par tablette", `
Choisis une tablette (en haut) pour voir ce qui y est chargé et les voix chargeables dessus.
`, "full"); + } else { + const av = Array.isArray(admin) ? admin : []; + const adminCard = card(`Voix admin à enrôler — ${esc(deviceLabel(serial))} (${av.length})`, admin.error ? `
${esc(admin.error)}
` : + (av.length ? `${av.map(v => ``).join("")}
nomportéepropriétairestatut
${esc(v.name || v.voice_id)}${scopeBadge(v.scope)}${esc(v.owner_name || v.owner_profile_id || "—")}${esc(v.status)}
` : `
aucune voix admin en attente
`) + + `
`, "full"); + + const loaded = (tablet && tablet.deployed) || [], loadable = (tablet && tablet.deployable) || []; + const tabCard = card(`${esc(deviceLabel(serial))} — chargées (${loaded.length}) · chargeables (${loadable.filter(v => v.deployable).length})`, tablet && tablet.error ? `
${esc(tablet.error)}
` : + ` + ${loaded.map(v => ``).join("")} + ${loadable.map(v => ``).join("")} +
voixportéeétat
${dot("ok")} ${esc(v.name || v.voice_id)}${v.in_catalog === false ? ` (hors catalogue)` : ""}${v.scope ? scopeBadge(v.scope) : "—"}chargée${v.in_catalog !== false ? `` : ""}
${esc(v.name || v.voice_id)}${scopeBadge(v.scope)}${v.deployable ? "chargeable" : esc(v.reason || "—")}${v.deployable ? `` : ""}
`, "full"); + cards += adminCard + tabCard; + } + c.innerHTML = pageHead("Voix", "catalogue de flotte · enrôlement OmniVoice") + `
${cards}
`; +} + +// ---- actions voix --------------------------------------------------------- +async function toggleAutosync(on) { try { await api("/voices/autosync", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: on, delete_source: $("#auto_del") && $("#auto_del").checked || false }) }); toast(on ? "auto-enrôlement armé" : "désarmé", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } } +async function syncAdmin(btn, serial) { const del = $("#sync_del") && $("#sync_del").checked || false; await withBusy(btn, async () => { try { const r = await api(`/voices/${serial}/admin/sync`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ delete_source: del }) }); toast(`${r.filter(x => x.ok && x.deployed).length}/${r.length} voix déployée(s)`, "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); } +async function deployVoice(btn, serial, vid) { await withBusy(btn, async () => { try { const r = await api(`/voices/${serial}/deploy`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ voice_id: vid }) }); r.deployed ? toast("voix chargée", "ok") : toast("non chargée : " + (r.reason || ""), "err"); renderSection(); } catch (e) { toast(e.message, "err"); } }); } +async function undeployVoice(btn, serial, vid) { await withBusy(btn, async () => { try { await api(`/voices/${serial}/undeploy`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ voice_id: vid }) }); toast("voix retirée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); } +async function reenrollVoice(btn, vid) { if (!confirm(`Ré-enrôler « ${vid} » (fix §7) et re-pousser sur ses tablettes ?`)) return; await withBusy(btn, async () => { try { const r = await api(`/voices/catalog/${vid}/reenroll`, { method: "POST" }); toast("ré-enrôlé · re-déployé : " + ((r.redeployed || []).join(", ") || "—"), "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); } +async function lockVoicePrompt(vid) { const p = prompt(`Verrouiller « ${vid} » sur quel profil patient ? (profile_id)`); if (!p) return; try { await api(`/voices/catalog/${vid}/lock`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ profile_id: p }) }); toast("voix verrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } } +async function unlockVoice(vid) { try { await api(`/voices/catalog/${vid}/unlock`, { method: "POST" }); toast("voix déverrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } } + +// ---- store ---------------------------------------------------------------- +async function lockStore() { try { await api("/lock", { method: "POST" }); S.unlocked = false; renderGate(); } catch (e) { toast(e.message, "err"); } } diff --git a/kazeia_central/web/index.html b/kazeia_central/web/index.html index 3a09feb..ef4201b 100644 --- a/kazeia_central/web/index.html +++ b/kazeia_central/web/index.html @@ -3,405 +3,12 @@ -Kazeia-central — console de flotte - +Kazeia-central + -
-

Kazeia-central

- console de flotte -
- - - - -
-
-
- -
← sélectionne une tablette
-
- - +
+
+ diff --git a/kazeia_central/web/styles.css b/kazeia_central/web/styles.css new file mode 100644 index 0000000..1dc9f4f --- /dev/null +++ b/kazeia_central/web/styles.css @@ -0,0 +1,147 @@ +/* Kazeia-central — design system « clinique-apaisé » (clair, sans build). */ +:root { + --bg: #eef2f6; /* fond app, gris-bleu très clair */ + --surface: #ffffff; + --surface-2: #f4f7fa; + --line: #e2e8f0; + --line-strong: #cdd7e2; + --txt: #25323f; + --muted: #64748b; + --primary: #3b7dd8; /* bleu calme */ + --primary-d: #2f68bb; + --primary-soft: #e8f0fb; + --ok: #2e9e6b; + --ok-soft: #e4f4ec; + --warn: #c98a1b; + --warn-soft: #fbf1dc; + --danger: #d2544f; + --danger-soft: #fbe9e8; + --radius: 12px; + --radius-sm: 8px; + --shadow: 0 1px 2px rgba(20,40,70,.06), 0 4px 16px rgba(20,40,70,.06); + --shadow-sm: 0 1px 2px rgba(20,40,70,.08); + --side: 230px; + --top: 58px; +} +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; background: var(--bg); color: var(--txt); + font: 14px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; +} +a { color: var(--primary); text-decoration: none; } +h1,h2,h3 { margin: 0; font-weight: 650; } +.muted { color: var(--muted); } +.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; } + +/* ---- shell ---- */ +.topbar { + position: fixed; top: 0; left: 0; right: 0; height: var(--top); z-index: 20; + display: flex; align-items: center; gap: 14px; padding: 0 18px; + background: var(--surface); border-bottom: 1px solid var(--line); +} +.brand { font-weight: 700; font-size: 15px; letter-spacing: .2px; } +.brand .dot { color: var(--primary); } +.topbar .ctx { color: var(--muted); font-size: 13px; display: flex; align-items: center; gap: 8px; } +.topbar .spacer { flex: 1; } +.lockpill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; + padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--surface-2); } +.lockpill.on { color: var(--ok); border-color: var(--ok); background: var(--ok-soft); } +.lockpill.off { color: var(--warn); border-color: var(--warn); background: var(--warn-soft); } + +.sidebar { + position: fixed; top: var(--top); left: 0; bottom: 0; width: var(--side); + background: var(--surface); border-right: 1px solid var(--line); + padding: 14px 12px; overflow: auto; +} +.nav-item { + display: flex; align-items: center; gap: 11px; padding: 9px 12px; border-radius: var(--radius-sm); + color: var(--txt); cursor: pointer; font-weight: 500; margin-bottom: 2px; user-select: none; +} +.nav-item .ic { width: 18px; text-align: center; opacity: .85; } +.nav-item:hover { background: var(--surface-2); } +.nav-item.active { background: var(--primary-soft); color: var(--primary-d); font-weight: 650; } +.nav-sep { height: 1px; background: var(--line); margin: 10px 6px; } +.nav-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); + padding: 6px 12px 4px; } + +main { margin: var(--top) 0 0 var(--side); padding: 22px 26px; min-height: calc(100vh - var(--top)); } +.page-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; } +.page-head h1 { font-size: 19px; } +.page-head .sub { color: var(--muted); font-size: 13px; } +.page-head .actions { margin-left: auto; display: flex; gap: 8px; } + +/* ---- composants ---- */ +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; } +.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); + box-shadow: var(--shadow); padding: 16px 18px; } +.card.full { grid-column: 1 / -1; } +.card > h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); + margin-bottom: 12px; font-weight: 650; } +.kv { display: flex; justify-content: space-between; gap: 12px; padding: 5px 0; border-bottom: 1px solid var(--surface-2); } +.kv:last-child { border-bottom: 0; } +.kv .k { color: var(--muted); } .kv .v { font-variant-numeric: tabular-nums; } + +button, .btn { font: inherit; cursor: pointer; border-radius: var(--radius-sm); padding: 7px 13px; + border: 1px solid var(--line-strong); background: var(--surface); color: var(--txt); + box-shadow: var(--shadow-sm); transition: .12s; } +button:hover, .btn:hover { border-color: var(--primary); color: var(--primary-d); } +.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; } +.btn-primary:hover { background: var(--primary-d); color: #fff; } +.btn-sm { padding: 4px 9px; font-size: 12.5px; } +.btn-ghost { background: transparent; border-color: transparent; box-shadow: none; } +button:disabled { opacity: .5; cursor: default; } +input, select { font: inherit; padding: 8px 11px; border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); background: var(--surface); color: var(--txt); } +input:focus, select:focus { outline: 2px solid var(--primary-soft); border-color: var(--primary); } +label.chk { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 13px; } + +table { width: 100%; border-collapse: collapse; font-size: 13.5px; } +th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); vertical-align: middle; } +th { color: var(--muted); font-weight: 600; font-size: 12px; } +tr:last-child td { border-bottom: 0; } +tbody tr:hover { background: var(--surface-2); } + +.badge { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 11.5px; font-weight: 600; + border: 1px solid var(--line-strong); color: var(--muted); background: var(--surface-2); } +.badge.ok { color: var(--ok); border-color: var(--ok); background: var(--ok-soft); } +.badge.warn { color: var(--warn); border-color: var(--warn); background: var(--warn-soft); } +.badge.danger { color: var(--danger); border-color: var(--danger); background: var(--danger-soft); } +.badge.primary { color: var(--primary-d); border-color: var(--primary); background: var(--primary-soft); } + +.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } +.dot.ok { background: var(--ok); } .dot.off { background: var(--danger); } .dot.idle { background: var(--muted); } + +.empty { text-align: center; color: var(--muted); padding: 34px 16px; } +.empty .big { font-size: 30px; opacity: .5; } +.err { color: var(--danger); font-size: 13px; } + +.list-tablet { display: flex; flex-direction: column; gap: 8px; } +.tablet-row { display: flex; align-items: center; gap: 10px; padding: 11px 13px; border: 1px solid var(--line); + border-radius: var(--radius-sm); background: var(--surface); cursor: pointer; box-shadow: var(--shadow-sm); } +.tablet-row:hover { border-color: var(--primary); } +.tablet-row.sel { border-color: var(--primary); background: var(--primary-soft); } +.tablet-row .name { font-weight: 600; } .tablet-row .meta { color: var(--muted); font-size: 12px; } + +.spinner { width: 16px; height: 16px; border: 2px solid var(--line-strong); border-top-color: var(--primary); + border-radius: 50%; display: inline-block; animation: spin .7s linear infinite; vertical-align: -3px; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ---- toasts ---- */ +#toasts { position: fixed; right: 18px; bottom: 18px; z-index: 50; display: flex; flex-direction: column; gap: 8px; } +.toast { background: var(--surface); border: 1px solid var(--line-strong); border-left: 4px solid var(--primary); + border-radius: var(--radius-sm); box-shadow: var(--shadow); padding: 11px 15px; max-width: 360px; font-size: 13.5px; + animation: slideup .18s ease; } +.toast.ok { border-left-color: var(--ok); } .toast.err { border-left-color: var(--danger); } +@keyframes slideup { from { transform: translateY(8px); opacity: 0; } } + +/* ---- écran de déverrouillage ---- */ +.gate { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: var(--bg); } +.gate .panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); + box-shadow: var(--shadow); padding: 30px 32px; width: 360px; text-align: center; } +.gate .panel .logo { font-size: 22px; font-weight: 700; margin-bottom: 4px; } +.gate .panel .logo .dot { color: var(--primary); } +.gate .panel p { color: var(--muted); font-size: 13px; margin: 4px 0 18px; } +.gate .panel input { width: 100%; margin-bottom: 10px; } +.gate .panel .btn-primary { width: 100%; }