diff --git a/README.md b/README.md
index baabf8c..6cfe154 100644
--- a/README.md
+++ b/README.md
@@ -177,7 +177,7 @@ Ajoutées par ce fork :
lui promettre des yeux qu'il n'a pas — il peut toujours prendre la capture et
la montrer, sans prétendre la décrire. L'image relayée au moteur reste
éphémère : la persister gonflait le contexte jusqu'à le faire déborder.
-- **Panneau Fichiers** (bouton dossier du pied de carte) : les fichiers de la
+- **Panneau Fichiers** (bouton dossier de la barre de saisie) : les fichiers de la
discussion ouverte — dépôts, captures, ce que l'agent y a écrit — avec
navigation dans les sous-dossiers, téléchargement et suppression. Un dossier
affiche la taille de **tout** son contenu, c'est ce qu'on libère en le
@@ -186,6 +186,15 @@ Ajoutées par ce fork :
reste du disque ni les autres discussions ne sont atteignables. Les fichiers
d'avant ce rangement que la migration n'a pas su rattacher restent joignables
par le bouton **hors discussion**, qui disparaît une fois le ménage fait.
+- **Interface « Sober Tech »** : ardoise et sauge, typographie Inter (interface)
+ et JetBrains Mono (code, chiffres, chemins) — embarquées dans le binaire, donc
+ aucune requête vers un service de polices. Deux variantes : claire par défaut,
+ **Deep Dark** (fond `#0F172A`, cartes `#1E293B`) d'un clic depuis l'en-tête.
+ L'en-tête porte le titre de la discussion et le **sélecteur de modèle** (le
+ changement de preset ne demande plus d'ouvrir les réglages) ; la barre
+ latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y
+ cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent
+ visibles en pied de colonne.
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
dans le fil.
- **Paramètres** : les réglages d'application (identité, apparence, accès
diff --git a/internal/loki/ui/fonts/bricolage.woff2 b/internal/loki/ui/fonts/bricolage.woff2
deleted file mode 100644
index fcc4eb1..0000000
Binary files a/internal/loki/ui/fonts/bricolage.woff2 and /dev/null differ
diff --git a/internal/loki/ui/fonts/inter.woff2 b/internal/loki/ui/fonts/inter.woff2
new file mode 100644
index 0000000..d15208d
Binary files /dev/null and b/internal/loki/ui/fonts/inter.woff2 differ
diff --git a/internal/loki/ui/fonts/jetbrainsmono.woff2 b/internal/loki/ui/fonts/jetbrainsmono.woff2
new file mode 100644
index 0000000..4d09cda
Binary files /dev/null and b/internal/loki/ui/fonts/jetbrainsmono.woff2 differ
diff --git a/internal/loki/ui/fonts/plexmono-400.woff2 b/internal/loki/ui/fonts/plexmono-400.woff2
deleted file mode 100644
index 0804aaf..0000000
Binary files a/internal/loki/ui/fonts/plexmono-400.woff2 and /dev/null differ
diff --git a/internal/loki/ui/fonts/plexmono-500.woff2 b/internal/loki/ui/fonts/plexmono-500.woff2
deleted file mode 100644
index 090f82f..0000000
Binary files a/internal/loki/ui/fonts/plexmono-500.woff2 and /dev/null differ
diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html
index b2603a2..90672cf 100644
--- a/internal/loki/ui/index.html
+++ b/internal/loki/ui/index.html
@@ -22,47 +22,62 @@
garde `display-mode` en second pour les PWA Android/desktop à manifeste. -->
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+ Nouvelle discussion
+
+
+
+
+
@@ -1803,18 +2065,6 @@ html[data-files="1"] #files-btn{color:var(--accent)}
0 / 0
compacter
-
-
-
-
-
-
-
-
-
Entrée pour envoyer · Maj+Entrée = nouvelle ligne
@@ -1838,7 +2107,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
- Fichiers de la discussion
+
+ Fichiers
↻
×
@@ -2355,11 +2626,14 @@ function initTheme(){
// Quatre interrupteurs indépendants. Chacun pose un attribut sur ; le CSS
// fait le reste, sauf « replier les bulles » qui est un comportement (voir
// 08-chat-render).
+//
+// « barre latérale escamotable » a disparu d'ici : la refonte l'escamote pour de
+// bon (bouton de l'en-tête, data-side — voir 18-shell.js), au lieu de la
+// transformer en tiroir qui recouvre la conversation.
const VIEW_OPTS=[
{id:'hide-reasoning', label:'masquer le raisonnement'},
{id:'hide-tools', label:"masquer les appels d'outils"},
{id:'fold-tools', label:'garder les bulles repliées'},
- {id:'hide-side', label:'barre latérale escamotable'},
{id:'hide-stats', label:'masquer la vitesse de génération'},
];
function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; }
@@ -2367,12 +2641,6 @@ function applyView(id, on){
document.documentElement.setAttribute('data-'+id, on?'1':'0');
try{ localStorage.setItem('loki-'+id, on?'1':'0'); }catch(e){}
const box=document.getElementById('view-'+id); if(box) box.checked=!!on;
- // La barre latérale peut rester ouverte quand on repasse en mode fixe.
- if(id==='hide-side' && !on){
- document.getElementById('side').classList.remove('open');
- document.getElementById('backdrop').classList.remove('open');
- document.body.classList.remove('drawer-open');
- }
}
function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); }
function initView(){
@@ -2599,7 +2867,9 @@ async function loadStatus(){
function toggleSvcLog(){
const box=document.getElementById('svc-log-box');
if(!box) return;
- box.open = !box.open;
+ // openDetails déplie aussi « Réglages » : sans lui, la section s'ouvrait à
+ // l'intérieur d'un repli fermé — donc nulle part, du point de vue de l'écran.
+ if(box.open) box.open = false; else openDetails(box);
}
async function loadSvcLog(){
const el=document.getElementById('svc-log');
@@ -2684,30 +2954,63 @@ function updateCtxMeter(){
// puisse compacter à la demande avant que l'auto-compaction (75%) ne s'en charge.
document.getElementById('ctx-compact').style.display = (pct>=50 && CTX_USED>0) ? 'inline-block' : 'none';
}
+// ─── Moniteur machine (pied de barre latérale) ───────────────────────────────
+// Une jauge par ressource : nom, pourcentage, barre, puis le détail chiffré en
+// dessous. C'est la hiérarchie de la maquette — on lit la charge d'un coup
+// d'œil, et le détail (Gio, température) reste là pour qui le cherche.
+//
+// Construit EN DOM et non en innerHTML : les noms de GPU viennent du pilote,
+// donc d'ailleurs. Même règle que pour les noms de fichiers et les titres de
+// discussions.
+function perfRow(label, pct, sub, title){
+ pct = Math.max(0, Math.min(100, Math.round(pct||0)));
+ const row=document.createElement('div'); row.className='perf-row';
+ if(title) row.title=title;
+ const l=document.createElement('div'); l.className='perf-l';
+ const b=document.createElement('b'); b.textContent=label;
+ const v=document.createElement('span'); v.className='perf-pct'; v.textContent=pct+'%';
+ l.append(b,v);
+ const track=document.createElement('div'); track.className='perf-track';
+ const fill=document.createElement('div');
+ // Sauge jusqu'à 75 %, ambre au-delà, rouille à 90 : la couleur ne sert à rien
+ // tant que la machine respire, elle doit sauter aux yeux quand elle sature.
+ fill.className='perf-fill'+(pct>=90?' max':pct>=75?' hot':'');
+ fill.style.width=pct+'%';
+ track.appendChild(fill);
+ row.append(l,track);
+ if(sub){ const s=document.createElement('div'); s.className='perf-sub'; s.textContent=sub; row.appendChild(s); }
+ return row;
+}
async function loadVram(){
const gpus=await jget('/api/vram');
- // Bloc de statistique : intitulé + valeur sur une ligne, jauge, détail dessous.
- // Même gabarit que la RAM (voir .stat dans le CSS) — le HTML libre d'avant
- // collait aux bords de la carte.
- document.getElementById('vram').innerHTML = (gpus||[]).map(g=>{
- const pct=Math.round(g.used*100/g.total);
- return ''+g.name+' '+
- ''+(g.used/1024).toFixed(1)+' / '+(g.total/1024).toFixed(1)+' GiB
'+
- '
'+
- '
GPU '+g.util+' % · '+g.temp+' °C
';
- }).join('') || '(pas de GPU)
';
+ const box=document.getElementById('vram');
+ if(!box) return;
+ box.textContent='';
+ if(!gpus || !gpus.length){
+ const d=document.createElement('div'); d.className='perf-sub'; d.textContent='(pas de GPU détecté)';
+ box.appendChild(d);
+ return;
+ }
+ gpus.forEach((g,i)=>{
+ // Le pourcentage affiché est la CHARGE du processeur graphique (ce que
+ // montre la maquette) ; la VRAM, elle, passe en détail sous la barre — deux
+ // mesures distinctes qu'une seule barre ne peut pas porter.
+ const vram=(g.used/1024).toFixed(1)+' / '+(g.total/1024).toFixed(1)+' Gio';
+ box.appendChild(perfRow(
+ 'GPU '+i+(gpus.length>1?'':'')+' — charge', g.util,
+ vram+' · '+g.temp+' °C', g.name));
+ });
}
async function loadRam(){
const m=await jget('/api/ram');
const box=document.getElementById('ram-details');
- if(!m || !m.total){ if(box) box.style.display='none'; return; }
- if(box) box.style.display='';
- const pct=Math.round(m.used*100/m.total);
- document.getElementById('ram').innerHTML =
- 'Mémoire vive '+
- ''+(m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' GiB
'+
- '
'+
- '
'+pct+' % utilisée
';
+ if(!box) return;
+ if(!m || !m.total){ box.style.display='none'; return; }
+ box.style.display='';
+ const host=document.getElementById('ram');
+ host.textContent='';
+ host.appendChild(perfRow('Mémoire vive', m.used*100/m.total,
+ (m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' Gio'));
}
async function loadCfg(){
// /api/llamacpp en parallèle : il indique si le BIN de la config correspond au
@@ -2782,6 +3085,8 @@ async function loadPresets(){
const sp = document.getElementById('status-preset');
sp.textContent = act ? act.name : '';
sp.title = act ? 'preset actif' : '';
+ // Sélecteur de modèle de l'en-tête : même donnée, autre point d'entrée.
+ if(typeof renderModelSwitch === 'function') renderModelSwitch(p, act);
const cont=document.getElementById('presets');
cont.innerHTML='';
if(!p.length){ cont.innerHTML='(aucun) '; return; }
@@ -5177,13 +5482,13 @@ async function loadLlamacpp(){
// Job en cours (page rechargée pendant une install) → on raccroche l'affichage.
if(s.job && s.job.exists && s.job.running && !lcPoll){
- document.getElementById('lc-details').open = true;
+ openDetails('lc-details');
lcStartPolling();
}
// Job terminé/interrompu qu'on n'a pas encore montré (rechargement APRÈS coup,
// typiquement quand le service a redémarré) : on l'affiche sans polling.
else if(s.job && s.job.exists && !s.job.running && !lcPoll && s.job.error && !lcEndShown){
- document.getElementById('lc-details').open = true;
+ openDetails('lc-details');
document.getElementById('lc-job').style.display = '';
lcLogNext = 0;
document.getElementById('lc-log').textContent = '';
@@ -5222,7 +5527,7 @@ function lcChipOpen(){
const side = document.getElementById('side');
if(!side.classList.contains('open')) toggleSide();
const det = document.getElementById('lc-details');
- det.open = true;
+ openDetails(det); // déplie aussi « Réglages », qui contient la section
det.scrollIntoView({block:'center'});
if(!document.getElementById('lc-chip').classList.contains('failed')) return;
lcSeenEnd = true;
@@ -5384,7 +5689,7 @@ async function lcInstallCustom(){
const r = await jpost('/api/llamacpp/install-custom', {repo:url, name});
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
closeCustomBackends();
- document.getElementById('lc-details').open = true;
+ openDetails('lc-details');
lcStartPolling();
}
@@ -6196,31 +6501,55 @@ function fmtWhen(sec){
// Les titres viennent du premier message de l'utilisateur : ils sont construits
// en DOM (jamais en innerHTML) pour qu'un titre contenant < > & " ne casse ni le
// balisage ni les gestionnaires d'événement.
+//
+// La liste chargée est GARDÉE en mémoire (CONVS) pour que la recherche filtre
+// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant,
+// titre, dates, nombre d'échanges — jamais les messages.
+
+let CONVS = [], CONV_ACTIVE = '';
async function loadConversations(){
let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; }
+ CONVS = r.conversations || [];
+ CONV_ACTIVE = r.active || '';
+ renderConversations();
+}
+
+function renderConversations(){
const cont = document.getElementById('conv-list');
if(!cont) return;
- cont.innerHTML = '';
- const list = r.conversations || [];
- if(!list.length){ cont.innerHTML = '(aucune) '; return; }
+ const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
+ cont.textContent = '';
+ const list = q
+ ? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
+ : CONVS;
+ if(!list.length){
+ const e = document.createElement('div');
+ e.className = 'conv-empty';
+ e.textContent = q ? 'aucune discussion ne correspond' : '(aucune)';
+ cont.appendChild(e);
+ syncTopbarTitle();
+ return;
+ }
for(const c of list){
const row = document.createElement('div');
- row.className = 'preset' + (c.id === r.active ? ' active' : '');
+ row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
row.onclick = () => convSwitch(c.id);
const info = document.createElement('div'); info.className = 'preset-info';
const nm = document.createElement('div'); nm.className = 'preset-name';
- if(c.id === r.active){ const d = document.createElement('i'); d.className = 'preset-dot'; nm.appendChild(d); }
const title = c.title || 'Nouvelle discussion';
nm.appendChild(document.createTextNode(title));
- nm.title = title;
- const meta = document.createElement('div'); meta.className = 'preset-meta';
- const when = document.createElement('span'); when.className = 'muted';
- when.textContent = convWhen(c.updated) + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
- meta.appendChild(when);
- info.appendChild(nm); info.appendChild(meta);
+ nm.title = title + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
+ info.appendChild(nm);
+
+ // Heure à droite du titre, sur la même ligne (maquette). Elle s'efface au
+ // survol : renommer et supprimer prennent sa place, la ligne ne s'allonge
+ // donc jamais et rien ne se chevauche.
+ const when = document.createElement('span');
+ when.className = 'conv-when';
+ when.textContent = convWhen(c.updated);
// Renommer / supprimer : stopPropagation, sinon le clic bascule aussi.
const acts = document.createElement('div'); acts.className = 'conv-acts';
@@ -6230,9 +6559,21 @@ async function loadConversations(){
del.title = 'supprimer'; del.onclick = e => { e.stopPropagation(); convDelete(c.id, title); };
acts.appendChild(ren); acts.appendChild(del);
- row.appendChild(info); row.appendChild(acts);
+ row.append(info, when, acts);
cont.appendChild(row);
}
+ syncTopbarTitle();
+}
+
+// Titre de l'en-tête : celui de la discussion ouverte. Il dit de quoi on parle
+// quand la barre latérale est escamotée — c'est-à-dire quand on en a besoin.
+function syncTopbarTitle(){
+ const el = document.getElementById('topbar-title');
+ if(!el) return;
+ const cur = CONVS.find(c => c.id === CONV_ACTIVE);
+ const t = (cur && cur.title) || 'Nouvelle discussion';
+ el.textContent = t;
+ el.title = t;
}
// Date courte : l'heure pour aujourd'hui, la date sinon — une liste de
@@ -6250,6 +6591,8 @@ async function convNew(){
try{
await jpost('/api/conversations/new', {});
toast('nouvelle discussion');
+ const s = document.getElementById('conv-search');
+ if(s) s.value = ''; // sinon la discussion toute neuve est masquée par le filtre
loadConversations();
}catch(_){ toast('erreur réseau'); }
}
@@ -6362,4 +6705,109 @@ function saveIdentity(){
try{ await jpost('/api/prefs', {user_name: IDENT.name}); }catch(_){ toast('erreur réseau'); }
}, 500);
}
+// ─── Coque de l'interface ────────────────────────────────────────────────────
+// Les commandes de la refonte : escamoter la barre latérale, replier le
+// moniteur machine, basculer le thème depuis l'en-tête, changer de modèle sans
+// ouvrir les réglages.
+//
+// Chaque état vit sur (data-side, data-perf, data-theme) et dans
+// localStorage, et il est REPOSÉ AVANT LE RENDU par le script du : sans
+// ça, la barre latérale s'afficherait puis se refermerait sous les yeux à chaque
+// chargement de page.
+
+// Escamotage de la barre latérale (grand écran). Sur téléphone elle est déjà un
+// tiroir — c'est toggleSide() qui s'en charge, et le bouton ☰ qui l'ouvre.
+function toggleSideCollapse(){
+ if(window.innerWidth <= 720){ toggleSide(); return; }
+ const on = document.documentElement.getAttribute('data-side') !== '0';
+ document.documentElement.setAttribute('data-side', on ? '0' : '1');
+ try{ localStorage.setItem('loki-side', on ? '0' : '1'); }catch(_){}
+ // La largeur du fil change sans qu'aucun `resize` ne soit émis : la gouttière
+ // de barre de défilement mesurée dans --sbw resterait celle d'avant, et la
+ // carte de saisie serait décalée du fil.
+ if(typeof syncGutter === 'function') requestAnimationFrame(syncGutter);
+}
+
+// Moniteur machine replié : le pied de barre latérale ne garde que son titre.
+function togglePerf(){
+ const on = document.documentElement.getAttribute('data-perf') !== '0';
+ document.documentElement.setAttribute('data-perf', on ? '0' : '1');
+ try{ localStorage.setItem('loki-perf', on ? '0' : '1'); }catch(_){}
+ const b = document.querySelector('#side-perf .perf-toggle');
+ if(b){
+ b.textContent = on ? '+' : '–';
+ b.title = on ? 'déplier le moniteur' : 'replier le moniteur';
+ }
+}
+
+// Bascule clair/sombre depuis l'en-tête. Elle passe par applyTheme + savePrefs
+// (01-theme.js) pour rester alignée avec l'interrupteur des réglages et avec le
+// serveur — deux chemins vers le même réglage, une seule source de vérité.
+function toggleThemeQuick(){
+ const dark = document.documentElement.getAttribute('data-theme') === 'dark';
+ applyTheme(dark ? 'light' : 'dark');
+ if(typeof savePrefs === 'function') savePrefs();
+}
+
+// Déplie un ET tous ceux qui le contiennent. Depuis que les réglages
+// vivent sous un repli unique, ouvrir une section par programme (journal du
+// moteur, installation llama.cpp) ne suffit plus : elle s'ouvrait à l'intérieur
+// d'un parent fermé, donc invisible.
+function openDetails(el){
+ if(typeof el === 'string') el = document.getElementById(el);
+ while(el){
+ if(el.tagName === 'DETAILS') el.open = true;
+ el = el.parentElement;
+ }
+}
+
+// Sélecteur de modèle de l'en-tête. Il liste les presets ; en choisir un revient
+// à cliquer la ligne correspondante dans les réglages (switchTo demande
+// confirmation puis relance le moteur).
+function onModelSwitch(sel){
+ const n = parseInt(sel.value, 10);
+ if(!n) return;
+ const name = sel.options[sel.selectedIndex].textContent;
+ // La liste est repeinte par loadPresets() une fois la bascule faite (ou
+ // annulée) : on ne touche pas à la sélection ici, sinon l'affichage mentirait
+ // pendant les quelques secondes du redémarrage.
+ switchTo(n, name);
+}
+
+// Remplit le sélecteur à partir de la liste de presets déjà chargée par
+// loadPresets() — aucun appel réseau supplémentaire.
+function renderModelSwitch(presets, active){
+ const sel = document.getElementById('model-switch');
+ if(!sel) return;
+ sel.textContent = '';
+ if(!presets || !presets.length){
+ const o = document.createElement('option');
+ o.textContent = 'aucun preset'; o.value = '';
+ sel.appendChild(o); sel.disabled = true;
+ return;
+ }
+ sel.disabled = false;
+ presets.forEach((p, i) => {
+ const o = document.createElement('option');
+ o.value = String(i + 1);
+ o.textContent = p.name;
+ if(active && p.id === active.id) o.selected = true;
+ sel.appendChild(o);
+ });
+ // Aucun preset actif (configuration modifiée à la main) : on le dit, plutôt
+ // que de laisser le premier de la liste passer pour le modèle chargé.
+ if(!active){
+ const o = document.createElement('option');
+ o.value = ''; o.textContent = 'configuration hors preset'; o.selected = true;
+ sel.insertBefore(o, sel.firstChild);
+ }
+}
+
+// État initial des libellés (le CSS, lui, est déjà appliqué par le ).
+document.addEventListener('DOMContentLoaded', () => {
+ if(document.documentElement.getAttribute('data-perf') === '0'){
+ const b = document.querySelector('#side-perf .perf-toggle');
+ if(b){ b.textContent = '+'; b.title = 'déplier le moniteur'; }
+ }
+});
diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html
index c030043..557df10 100644
--- a/internal/loki/ui/src/index.tmpl.html
+++ b/internal/loki/ui/src/index.tmpl.html
@@ -22,7 +22,12 @@
garde `display-mode` en second pour les PWA Android/desktop à manifeste. -->
-
+
@@ -33,19 +38,22 @@
+
+
+
+
+
+
+
+
+
+
+
+ Nouvelle discussion
+
+
+
+
+
@@ -361,18 +394,6 @@
0 / 0
compacter
-
-
-
-
-
-
-
-
-
Entrée pour envoyer · Maj+Entrée = nouvelle ligne
@@ -396,7 +436,9 @@
- Fichiers de la discussion
+
+ Fichiers
↻
×
diff --git a/internal/loki/ui/src/js/01-theme.js b/internal/loki/ui/src/js/01-theme.js
index ce29184..cb21df3 100644
--- a/internal/loki/ui/src/js/01-theme.js
+++ b/internal/loki/ui/src/js/01-theme.js
@@ -24,11 +24,14 @@ function initTheme(){
// Quatre interrupteurs indépendants. Chacun pose un attribut sur ; le CSS
// fait le reste, sauf « replier les bulles » qui est un comportement (voir
// 08-chat-render).
+//
+// « barre latérale escamotable » a disparu d'ici : la refonte l'escamote pour de
+// bon (bouton de l'en-tête, data-side — voir 18-shell.js), au lieu de la
+// transformer en tiroir qui recouvre la conversation.
const VIEW_OPTS=[
{id:'hide-reasoning', label:'masquer le raisonnement'},
{id:'hide-tools', label:"masquer les appels d'outils"},
{id:'fold-tools', label:'garder les bulles repliées'},
- {id:'hide-side', label:'barre latérale escamotable'},
{id:'hide-stats', label:'masquer la vitesse de génération'},
];
function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; }
@@ -36,12 +39,6 @@ function applyView(id, on){
document.documentElement.setAttribute('data-'+id, on?'1':'0');
try{ localStorage.setItem('loki-'+id, on?'1':'0'); }catch(e){}
const box=document.getElementById('view-'+id); if(box) box.checked=!!on;
- // La barre latérale peut rester ouverte quand on repasse en mode fixe.
- if(id==='hide-side' && !on){
- document.getElementById('side').classList.remove('open');
- document.getElementById('backdrop').classList.remove('open');
- document.body.classList.remove('drawer-open');
- }
}
function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); }
function initView(){
diff --git a/internal/loki/ui/src/js/05-status.js b/internal/loki/ui/src/js/05-status.js
index 01b986f..8ac15a5 100644
--- a/internal/loki/ui/src/js/05-status.js
+++ b/internal/loki/ui/src/js/05-status.js
@@ -42,7 +42,9 @@ async function loadStatus(){
function toggleSvcLog(){
const box=document.getElementById('svc-log-box');
if(!box) return;
- box.open = !box.open;
+ // openDetails déplie aussi « Réglages » : sans lui, la section s'ouvrait à
+ // l'intérieur d'un repli fermé — donc nulle part, du point de vue de l'écran.
+ if(box.open) box.open = false; else openDetails(box);
}
async function loadSvcLog(){
const el=document.getElementById('svc-log');
@@ -127,30 +129,63 @@ function updateCtxMeter(){
// puisse compacter à la demande avant que l'auto-compaction (75%) ne s'en charge.
document.getElementById('ctx-compact').style.display = (pct>=50 && CTX_USED>0) ? 'inline-block' : 'none';
}
+// ─── Moniteur machine (pied de barre latérale) ───────────────────────────────
+// Une jauge par ressource : nom, pourcentage, barre, puis le détail chiffré en
+// dessous. C'est la hiérarchie de la maquette — on lit la charge d'un coup
+// d'œil, et le détail (Gio, température) reste là pour qui le cherche.
+//
+// Construit EN DOM et non en innerHTML : les noms de GPU viennent du pilote,
+// donc d'ailleurs. Même règle que pour les noms de fichiers et les titres de
+// discussions.
+function perfRow(label, pct, sub, title){
+ pct = Math.max(0, Math.min(100, Math.round(pct||0)));
+ const row=document.createElement('div'); row.className='perf-row';
+ if(title) row.title=title;
+ const l=document.createElement('div'); l.className='perf-l';
+ const b=document.createElement('b'); b.textContent=label;
+ const v=document.createElement('span'); v.className='perf-pct'; v.textContent=pct+'%';
+ l.append(b,v);
+ const track=document.createElement('div'); track.className='perf-track';
+ const fill=document.createElement('div');
+ // Sauge jusqu'à 75 %, ambre au-delà, rouille à 90 : la couleur ne sert à rien
+ // tant que la machine respire, elle doit sauter aux yeux quand elle sature.
+ fill.className='perf-fill'+(pct>=90?' max':pct>=75?' hot':'');
+ fill.style.width=pct+'%';
+ track.appendChild(fill);
+ row.append(l,track);
+ if(sub){ const s=document.createElement('div'); s.className='perf-sub'; s.textContent=sub; row.appendChild(s); }
+ return row;
+}
async function loadVram(){
const gpus=await jget('/api/vram');
- // Bloc de statistique : intitulé + valeur sur une ligne, jauge, détail dessous.
- // Même gabarit que la RAM (voir .stat dans le CSS) — le HTML libre d'avant
- // collait aux bords de la carte.
- document.getElementById('vram').innerHTML = (gpus||[]).map(g=>{
- const pct=Math.round(g.used*100/g.total);
- return ''+g.name+' '+
- ''+(g.used/1024).toFixed(1)+' / '+(g.total/1024).toFixed(1)+' GiB
'+
- '
'+
- '
GPU '+g.util+' % · '+g.temp+' °C
';
- }).join('') || '(pas de GPU)
';
+ const box=document.getElementById('vram');
+ if(!box) return;
+ box.textContent='';
+ if(!gpus || !gpus.length){
+ const d=document.createElement('div'); d.className='perf-sub'; d.textContent='(pas de GPU détecté)';
+ box.appendChild(d);
+ return;
+ }
+ gpus.forEach((g,i)=>{
+ // Le pourcentage affiché est la CHARGE du processeur graphique (ce que
+ // montre la maquette) ; la VRAM, elle, passe en détail sous la barre — deux
+ // mesures distinctes qu'une seule barre ne peut pas porter.
+ const vram=(g.used/1024).toFixed(1)+' / '+(g.total/1024).toFixed(1)+' Gio';
+ box.appendChild(perfRow(
+ 'GPU '+i+(gpus.length>1?'':'')+' — charge', g.util,
+ vram+' · '+g.temp+' °C', g.name));
+ });
}
async function loadRam(){
const m=await jget('/api/ram');
const box=document.getElementById('ram-details');
- if(!m || !m.total){ if(box) box.style.display='none'; return; }
- if(box) box.style.display='';
- const pct=Math.round(m.used*100/m.total);
- document.getElementById('ram').innerHTML =
- 'Mémoire vive '+
- ''+(m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' GiB
'+
- '
'+
- '
'+pct+' % utilisée
';
+ if(!box) return;
+ if(!m || !m.total){ box.style.display='none'; return; }
+ box.style.display='';
+ const host=document.getElementById('ram');
+ host.textContent='';
+ host.appendChild(perfRow('Mémoire vive', m.used*100/m.total,
+ (m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' Gio'));
}
async function loadCfg(){
// /api/llamacpp en parallèle : il indique si le BIN de la config correspond au
diff --git a/internal/loki/ui/src/js/06-settings.js b/internal/loki/ui/src/js/06-settings.js
index 99156bf..92297d6 100644
--- a/internal/loki/ui/src/js/06-settings.js
+++ b/internal/loki/ui/src/js/06-settings.js
@@ -44,6 +44,8 @@ async function loadPresets(){
const sp = document.getElementById('status-preset');
sp.textContent = act ? act.name : '';
sp.title = act ? 'preset actif' : '';
+ // Sélecteur de modèle de l'en-tête : même donnée, autre point d'entrée.
+ if(typeof renderModelSwitch === 'function') renderModelSwitch(p, act);
const cont=document.getElementById('presets');
cont.innerHTML='';
if(!p.length){ cont.innerHTML='(aucun) '; return; }
diff --git a/internal/loki/ui/src/js/10-llamacpp.js b/internal/loki/ui/src/js/10-llamacpp.js
index ae466d0..7a0a371 100644
--- a/internal/loki/ui/src/js/10-llamacpp.js
+++ b/internal/loki/ui/src/js/10-llamacpp.js
@@ -28,13 +28,13 @@ async function loadLlamacpp(){
// Job en cours (page rechargée pendant une install) → on raccroche l'affichage.
if(s.job && s.job.exists && s.job.running && !lcPoll){
- document.getElementById('lc-details').open = true;
+ openDetails('lc-details');
lcStartPolling();
}
// Job terminé/interrompu qu'on n'a pas encore montré (rechargement APRÈS coup,
// typiquement quand le service a redémarré) : on l'affiche sans polling.
else if(s.job && s.job.exists && !s.job.running && !lcPoll && s.job.error && !lcEndShown){
- document.getElementById('lc-details').open = true;
+ openDetails('lc-details');
document.getElementById('lc-job').style.display = '';
lcLogNext = 0;
document.getElementById('lc-log').textContent = '';
@@ -73,7 +73,7 @@ function lcChipOpen(){
const side = document.getElementById('side');
if(!side.classList.contains('open')) toggleSide();
const det = document.getElementById('lc-details');
- det.open = true;
+ openDetails(det); // déplie aussi « Réglages », qui contient la section
det.scrollIntoView({block:'center'});
if(!document.getElementById('lc-chip').classList.contains('failed')) return;
lcSeenEnd = true;
@@ -235,7 +235,7 @@ async function lcInstallCustom(){
const r = await jpost('/api/llamacpp/install-custom', {repo:url, name});
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
closeCustomBackends();
- document.getElementById('lc-details').open = true;
+ openDetails('lc-details');
lcStartPolling();
}
diff --git a/internal/loki/ui/src/js/16-conversations.js b/internal/loki/ui/src/js/16-conversations.js
index df4a22c..3cbe9af 100644
--- a/internal/loki/ui/src/js/16-conversations.js
+++ b/internal/loki/ui/src/js/16-conversations.js
@@ -8,31 +8,55 @@
// Les titres viennent du premier message de l'utilisateur : ils sont construits
// en DOM (jamais en innerHTML) pour qu'un titre contenant < > & " ne casse ni le
// balisage ni les gestionnaires d'événement.
+//
+// La liste chargée est GARDÉE en mémoire (CONVS) pour que la recherche filtre
+// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant,
+// titre, dates, nombre d'échanges — jamais les messages.
+
+let CONVS = [], CONV_ACTIVE = '';
async function loadConversations(){
let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; }
+ CONVS = r.conversations || [];
+ CONV_ACTIVE = r.active || '';
+ renderConversations();
+}
+
+function renderConversations(){
const cont = document.getElementById('conv-list');
if(!cont) return;
- cont.innerHTML = '';
- const list = r.conversations || [];
- if(!list.length){ cont.innerHTML = '(aucune) '; return; }
+ const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
+ cont.textContent = '';
+ const list = q
+ ? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
+ : CONVS;
+ if(!list.length){
+ const e = document.createElement('div');
+ e.className = 'conv-empty';
+ e.textContent = q ? 'aucune discussion ne correspond' : '(aucune)';
+ cont.appendChild(e);
+ syncTopbarTitle();
+ return;
+ }
for(const c of list){
const row = document.createElement('div');
- row.className = 'preset' + (c.id === r.active ? ' active' : '');
+ row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
row.onclick = () => convSwitch(c.id);
const info = document.createElement('div'); info.className = 'preset-info';
const nm = document.createElement('div'); nm.className = 'preset-name';
- if(c.id === r.active){ const d = document.createElement('i'); d.className = 'preset-dot'; nm.appendChild(d); }
const title = c.title || 'Nouvelle discussion';
nm.appendChild(document.createTextNode(title));
- nm.title = title;
- const meta = document.createElement('div'); meta.className = 'preset-meta';
- const when = document.createElement('span'); when.className = 'muted';
- when.textContent = convWhen(c.updated) + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
- meta.appendChild(when);
- info.appendChild(nm); info.appendChild(meta);
+ nm.title = title + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
+ info.appendChild(nm);
+
+ // Heure à droite du titre, sur la même ligne (maquette). Elle s'efface au
+ // survol : renommer et supprimer prennent sa place, la ligne ne s'allonge
+ // donc jamais et rien ne se chevauche.
+ const when = document.createElement('span');
+ when.className = 'conv-when';
+ when.textContent = convWhen(c.updated);
// Renommer / supprimer : stopPropagation, sinon le clic bascule aussi.
const acts = document.createElement('div'); acts.className = 'conv-acts';
@@ -42,9 +66,21 @@ async function loadConversations(){
del.title = 'supprimer'; del.onclick = e => { e.stopPropagation(); convDelete(c.id, title); };
acts.appendChild(ren); acts.appendChild(del);
- row.appendChild(info); row.appendChild(acts);
+ row.append(info, when, acts);
cont.appendChild(row);
}
+ syncTopbarTitle();
+}
+
+// Titre de l'en-tête : celui de la discussion ouverte. Il dit de quoi on parle
+// quand la barre latérale est escamotée — c'est-à-dire quand on en a besoin.
+function syncTopbarTitle(){
+ const el = document.getElementById('topbar-title');
+ if(!el) return;
+ const cur = CONVS.find(c => c.id === CONV_ACTIVE);
+ const t = (cur && cur.title) || 'Nouvelle discussion';
+ el.textContent = t;
+ el.title = t;
}
// Date courte : l'heure pour aujourd'hui, la date sinon — une liste de
@@ -62,6 +98,8 @@ async function convNew(){
try{
await jpost('/api/conversations/new', {});
toast('nouvelle discussion');
+ const s = document.getElementById('conv-search');
+ if(s) s.value = ''; // sinon la discussion toute neuve est masquée par le filtre
loadConversations();
}catch(_){ toast('erreur réseau'); }
}
diff --git a/internal/loki/ui/src/js/18-shell.js b/internal/loki/ui/src/js/18-shell.js
new file mode 100644
index 0000000..bd1a322
--- /dev/null
+++ b/internal/loki/ui/src/js/18-shell.js
@@ -0,0 +1,105 @@
+// ─── Coque de l'interface ────────────────────────────────────────────────────
+// Les commandes de la refonte : escamoter la barre latérale, replier le
+// moniteur machine, basculer le thème depuis l'en-tête, changer de modèle sans
+// ouvrir les réglages.
+//
+// Chaque état vit sur (data-side, data-perf, data-theme) et dans
+// localStorage, et il est REPOSÉ AVANT LE RENDU par le script du : sans
+// ça, la barre latérale s'afficherait puis se refermerait sous les yeux à chaque
+// chargement de page.
+
+// Escamotage de la barre latérale (grand écran). Sur téléphone elle est déjà un
+// tiroir — c'est toggleSide() qui s'en charge, et le bouton ☰ qui l'ouvre.
+function toggleSideCollapse(){
+ if(window.innerWidth <= 720){ toggleSide(); return; }
+ const on = document.documentElement.getAttribute('data-side') !== '0';
+ document.documentElement.setAttribute('data-side', on ? '0' : '1');
+ try{ localStorage.setItem('loki-side', on ? '0' : '1'); }catch(_){}
+ // La largeur du fil change sans qu'aucun `resize` ne soit émis : la gouttière
+ // de barre de défilement mesurée dans --sbw resterait celle d'avant, et la
+ // carte de saisie serait décalée du fil.
+ if(typeof syncGutter === 'function') requestAnimationFrame(syncGutter);
+}
+
+// Moniteur machine replié : le pied de barre latérale ne garde que son titre.
+function togglePerf(){
+ const on = document.documentElement.getAttribute('data-perf') !== '0';
+ document.documentElement.setAttribute('data-perf', on ? '0' : '1');
+ try{ localStorage.setItem('loki-perf', on ? '0' : '1'); }catch(_){}
+ const b = document.querySelector('#side-perf .perf-toggle');
+ if(b){
+ b.textContent = on ? '+' : '–';
+ b.title = on ? 'déplier le moniteur' : 'replier le moniteur';
+ }
+}
+
+// Bascule clair/sombre depuis l'en-tête. Elle passe par applyTheme + savePrefs
+// (01-theme.js) pour rester alignée avec l'interrupteur des réglages et avec le
+// serveur — deux chemins vers le même réglage, une seule source de vérité.
+function toggleThemeQuick(){
+ const dark = document.documentElement.getAttribute('data-theme') === 'dark';
+ applyTheme(dark ? 'light' : 'dark');
+ if(typeof savePrefs === 'function') savePrefs();
+}
+
+// Déplie un ET tous ceux qui le contiennent. Depuis que les réglages
+// vivent sous un repli unique, ouvrir une section par programme (journal du
+// moteur, installation llama.cpp) ne suffit plus : elle s'ouvrait à l'intérieur
+// d'un parent fermé, donc invisible.
+function openDetails(el){
+ if(typeof el === 'string') el = document.getElementById(el);
+ while(el){
+ if(el.tagName === 'DETAILS') el.open = true;
+ el = el.parentElement;
+ }
+}
+
+// Sélecteur de modèle de l'en-tête. Il liste les presets ; en choisir un revient
+// à cliquer la ligne correspondante dans les réglages (switchTo demande
+// confirmation puis relance le moteur).
+function onModelSwitch(sel){
+ const n = parseInt(sel.value, 10);
+ if(!n) return;
+ const name = sel.options[sel.selectedIndex].textContent;
+ // La liste est repeinte par loadPresets() une fois la bascule faite (ou
+ // annulée) : on ne touche pas à la sélection ici, sinon l'affichage mentirait
+ // pendant les quelques secondes du redémarrage.
+ switchTo(n, name);
+}
+
+// Remplit le sélecteur à partir de la liste de presets déjà chargée par
+// loadPresets() — aucun appel réseau supplémentaire.
+function renderModelSwitch(presets, active){
+ const sel = document.getElementById('model-switch');
+ if(!sel) return;
+ sel.textContent = '';
+ if(!presets || !presets.length){
+ const o = document.createElement('option');
+ o.textContent = 'aucun preset'; o.value = '';
+ sel.appendChild(o); sel.disabled = true;
+ return;
+ }
+ sel.disabled = false;
+ presets.forEach((p, i) => {
+ const o = document.createElement('option');
+ o.value = String(i + 1);
+ o.textContent = p.name;
+ if(active && p.id === active.id) o.selected = true;
+ sel.appendChild(o);
+ });
+ // Aucun preset actif (configuration modifiée à la main) : on le dit, plutôt
+ // que de laisser le premier de la liste passer pour le modèle chargé.
+ if(!active){
+ const o = document.createElement('option');
+ o.value = ''; o.textContent = 'configuration hors preset'; o.selected = true;
+ sel.insertBefore(o, sel.firstChild);
+ }
+}
+
+// État initial des libellés (le CSS, lui, est déjà appliqué par le ).
+document.addEventListener('DOMContentLoaded', () => {
+ if(document.documentElement.getAttribute('data-perf') === '0'){
+ const b = document.querySelector('#side-perf .perf-toggle');
+ if(b){ b.textContent = '+'; b.title = 'déplier le moniteur'; }
+ }
+});
diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css
index 721f3b2..c932e3c 100644
--- a/internal/loki/ui/src/styles.css
+++ b/internal/loki/ui/src/styles.css
@@ -1,42 +1,52 @@
/* ===== Polices =====================================================
- Bricolage Grotesque (titres et texte) + IBM Plex Mono (chiffres, chemins,
- code). Servies par Loki (/fonts/, embarquées dans le binaire) et NON par
- Google Fonts : une instance locale ou coupée d'internet doit s'afficher
- correctement, sans qu'un tiers apprenne qui consulte l'interface.
+ Inter (interface) + JetBrains Mono (chiffres, chemins, code). Servies par Loki
+ (/fonts/, embarquées dans le binaire) et NON par Google Fonts : une instance
+ locale ou coupée d'internet doit s'afficher correctement, sans qu'un tiers
+ apprenne qui consulte l'interface.
+ Sous-ensemble LATIN uniquement (~48 + ~31 Ko) : il couvre le français, y
+ compris œ et les guillemets. Les symboles de l'UI (↻ ⌫ ☰ 🗑) n'ont jamais été
+ dessinés par la police d'interface — ils viennent du système, ici comme avant.
swap : le texte s'affiche tout de suite dans la police de repli, puis bascule
— jamais de blanc au chargement. */
-@font-face{font-family:'Bricolage Grotesque';src:url('/fonts/bricolage.woff2') format('woff2');font-weight:400 700;font-display:swap;font-style:normal}
-@font-face{font-family:'IBM Plex Mono';src:url('/fonts/plexmono-400.woff2') format('woff2');font-weight:400;font-display:swap;font-style:normal}
-@font-face{font-family:'IBM Plex Mono';src:url('/fonts/plexmono-500.woff2') format('woff2');font-weight:500;font-display:swap;font-style:normal}
+@font-face{font-family:'Inter';src:url('/fonts/inter.woff2') format('woff2');font-weight:100 900;font-display:swap;font-style:normal}
+@font-face{font-family:'JetBrains Mono';src:url('/fonts/jetbrainsmono.woff2') format('woff2');font-weight:100 800;font-display:swap;font-style:normal}
-/* ===== Palette « Terre » ===========================================
- Bruns chauds, vert sauge et argile. Deux variantes — claire par défaut,
- sombre via data-theme="dark" sur . Toutes les couleurs de l'UI passent
- par ces variables ; rien d'autre ne code une couleur en dur.
+/* ===== Palette « Sober Tech » ======================================
+ Ardoise et sauge. Deux variantes — claire par défaut (celle de la maquette),
+ sombre « Deep Dark » via data-theme="dark" sur . Toutes les couleurs de
+ l'UI passent par ces variables ; rien d'autre ne code une couleur en dur.
- Rôles : --accent = sauge (sélection, marche, liens) · --warn = argile
- (chiffres, mesures, avertissements) · --err = terre cuite (arrêt, danger).
- --row-bg / --code-bg sont les deux fonds encastrés d'un panneau. */
+ Rôles : --accent = sauge (sélection, marche, liens, bulle utilisateur) ·
+ --warn = ambre (chiffres, mesures, avertissements) · --err = rouille (arrêt,
+ danger). --row-bg / --code-bg sont les deux fonds encastrés d'un panneau.
+
+ --on-accent : ce qui s'écrit SUR un aplat de sauge. Il change avec la
+ variante — blanc sur la sauge foncée du thème clair, ardoise sur la sauge
+ claire du thème sombre — sinon un des deux thèmes écrit du texte à 2:1 de
+ contraste sur la bulle utilisateur et le bouton d'envoi. */
:root{color-scheme:light;
- --font:'Bricolage Grotesque',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;
- --mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
- --bg:#f3efe8;--panel:#ffffff;--bubble:#efe9df;--border:rgba(44,38,34,.12);
- --text:#2c2622;--dim:#7a7069;
- --accent:#5f7f52;--accent-bg:rgba(95,127,82,.12);
- --row-bg:#f7f4ee;--row-bg-on:#f4f1ea;
- --ok:#5f7f52;--warn:#a9662f;--err:#c05a3f;--mag:#a9662f;
- --code-bg:#f7f4ee;--danger-bg:#f8e9e4;--warn-bg:#f6efe4;
- --add:#5f7f52;--add-bg:rgba(95,127,82,.12);--del:#c05a3f;--del-bg:rgba(192,90,63,.12);
- --shadow:0 1px 2px rgba(44,38,34,.06)}
+ --font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;
+ --mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
+ --bg:#eceff0;--panel:#ffffff;--bubble:#f6f8f7;--border:rgba(30,41,59,.11);
+ --text:#1e293b;--dim:#64748b;
+ --accent:#5e7f5a;--accent-bg:rgba(94,127,90,.11);--on-accent:#ffffff;--on-err:#ffffff;
+ --accent-soft:#8fab8b;
+ --side:#fafbfa;--row-bg:#f5f7f6;--row-bg-on:#eef2f0;
+ --ok:#5e7f5a;--warn:#a86a2a;--err:#c0503f;--mag:#a86a2a;
+ --code-bg:#f4f6f5;--danger-bg:#faeae7;--warn-bg:#f7f0e5;
+ --add:#5e7f5a;--add-bg:rgba(94,127,90,.11);--del:#c0503f;--del-bg:rgba(192,80,63,.12);
+ --shadow:0 1px 2px rgba(15,23,42,.05);--shadow-lift:0 4px 16px rgba(15,23,42,.08)}
+/* « Deep Dark » : fond #0F172A, cartes #1E293B. */
[data-theme="dark"]{color-scheme:dark;
- --bg:#211d1a;--panel:#2b2622;--bubble:#38312a;--border:rgba(236,229,221,.12);
- --text:#ece5dd;--dim:#a99d92;
- --accent:#9ab68c;--accent-bg:rgba(154,182,140,.15);
- --row-bg:#332d28;--row-bg-on:#38312a;
- --ok:#9ab68c;--warn:#d2a07a;--err:#c9705c;--mag:#d2a07a;
- --code-bg:#211d1a;--danger-bg:#3a2621;--warn-bg:#332b23;
- --add:#9ab68c;--add-bg:rgba(154,182,140,.15);--del:#c9705c;--del-bg:rgba(201,112,92,.15);
- --shadow:none}
+ --bg:#0f172a;--panel:#1e293b;--bubble:#1e293b;--border:rgba(148,163,184,.17);
+ --text:#e2e8f0;--dim:#94a3b8;
+ --accent:#8fb08c;--accent-bg:rgba(143,176,140,.16);--on-accent:#0f172a;--on-err:#0f172a;
+ --accent-soft:#6d8f6a;
+ --side:#0d1424;--row-bg:#172033;--row-bg-on:#243349;
+ --ok:#8fb08c;--warn:#d9a55f;--err:#e0705c;--mag:#d9a55f;
+ --code-bg:#131c2f;--danger-bg:#3a1f1c;--warn-bg:#33291b;
+ --add:#8fb08c;--add-bg:rgba(143,176,140,.16);--del:#e0705c;--del-bg:rgba(224,112,92,.16);
+ --shadow:none;--shadow-lift:0 4px 16px rgba(0,0,0,.35)}
/* Safari iOS peint un rectangle gris/noir sur TOUT élément touché (bouton, lien,
résumé de section, bulle repliable…). C'est un retour tactile par défaut, hors
de notre charte et impossible à styler : on le coupe. Nos états :active/:focus
@@ -465,7 +475,6 @@ button:disabled{opacity:.5;cursor:not-allowed}
@media (max-width:720px){
.side{padding:calc(10px + env(safe-area-inset-top)) 12px calc(10px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))}
}
-[data-hide-side="1"] .side{padding:calc(10px + env(safe-area-inset-top)) 12px calc(10px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))}
.sidehead{margin-bottom:10px}
/* --- En-tête de section --- */
@@ -601,24 +610,10 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
data-hide-reasoning : masque les bulles de raisonnement
data-hide-tools : masque les bulles d'appel d'outil
data-fold-tools : les garde repliées (dépliables au clic)
- data-hide-side : barre latérale en tiroir, même sur ordinateur
Quand raisonnement et outils sont masqués, l'activité reste signalée par
l'indicateur de frappe (.msg.typing) maintenu pendant tout le tour. */
[data-hide-reasoning="1"] #chat .msg.reasoning{display:none}
[data-hide-tools="1"] #chat .msg.tool{display:none}
-/* Barre latérale en tiroir sur TOUTES les tailles d'écran : mêmes règles que
- la version mobile, activées par l'attribut au lieu de la largeur. */
-[data-hide-side="1"] .side{position:fixed;inset:0 auto 0 0;width:80%;max-width:320px;z-index:15;transform:translateX(-100%);transition:transform .2s;background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));padding-left:calc(16px + env(safe-area-inset-left));border-right:1px solid var(--border)}
-@media (min-width:721px){[data-hide-side="1"] .side{width:380px;max-width:380px}}
-/* Le bouton ☰ est posé au-dessus du fil : on dégage le haut de la
- conversation pour qu'il ne recouvre aucun message. */
-@media (min-width:721px){html[data-hide-side="1"] #chat{padding-top:60px}}
-[data-hide-side="1"] .side.open{transform:translateX(0)}
-[data-hide-side="1"] #menubtn{display:block}
-[data-hide-side="1"] #lc-chip:not([hidden]){display:block}
-[data-hide-side="1"] #backdrop.open{display:block}
-[data-hide-side="1"] body.drawer-open #menubtn,[data-hide-side="1"] body.drawer-open #lc-chip{display:none}
-[data-hide-side="1"] .main{width:100%}
/* Hauteur animée en JS (mesure scrollHeight → 0) plutôt que grid 1fr→0fr qui
peut « dépasser ». overflow:hidden clippe le contenu pendant tout le repli.
width revient à auto SANS délai à l'ouverture (pas de saut), et passe à 0
@@ -1290,10 +1285,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
/* Action principale en sauge : c'est la couleur d'action de la charte, et elle
se distingue enfin du texte (le bouton reprenait --text, donc un brun sombre
indistinct du reste). L'arrêt, lui, reste en terre cuite. */
-#send{background:var(--accent);border-color:var(--accent);color:#fff}
-#send:hover:not(:disabled){background:var(--accent);border-color:var(--accent);color:#fff;opacity:.88}
-#stop{background:var(--err);border-color:var(--err);color:#fff}
-#stop:hover{background:var(--err);border-color:var(--err);color:#fff;opacity:.88}
+#send{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
+#send:hover:not(:disabled){background:var(--accent);border-color:var(--accent);color:var(--on-accent);opacity:.88}
+#stop{background:var(--err);border-color:var(--err);color:var(--on-err)}
+#stop:hover{background:var(--err);border-color:var(--err);color:var(--on-err);opacity:.88}
/* --- Terre : largeur de lecture --------------------------------------
La mesure était plafonnée à 760 px et centrée sur la fenêtre : sur un écran
@@ -1441,3 +1436,237 @@ html[data-files="1"] #files-btn{color:var(--accent)}
#files{background:var(--panel);border-left-color:transparent;border-radius:20px;
box-shadow:var(--shadow);margin:0}
}
+
+/* =====================================================================
+ ===== Refonte « Sober Tech » ========================================
+ =====================================================================
+ Dernière section du fichier, donc dernier mot de la cascade : elle porte la
+ coque de la refonte (barre d'en-tête, zones de la barre latérale, moniteur
+ machine) et redresse les quelques règles historiques qu'elle contredit.
+
+ Trois principes, tirés de la maquette :
+ 1. Le fil est la seule chose qui compte : tout le reste est atténué, sans
+ cadre superflu, et la barre latérale s'escamote d'un clic.
+ 2. Les données machine sont TOUJOURS visibles mais jamais au premier plan —
+ un pied de barre latérale, trois jauges, pas un tableau.
+ 3. Aucun contour permanent sur les boutons : l'affordance vient du survol
+ (fond qui apparaît, léger enfoncement au clic).
+ ===================================================================== */
+
+/* --- Coque ---------------------------------------------------------- */
+.side{background:var(--side);display:flex;flex-direction:column;gap:0;padding:0;overflow:hidden}
+/* Les trois zones de la barre : en-tête fixe, contenu qui défile, moniteur
+ posé en pied. Sans ça, la liste des discussions poussait les jauges hors de
+ l'écran dès la dixième discussion. */
+.sidehead{flex:none;display:flex;align-items:center;gap:8px;margin:0;
+ padding:14px 14px 10px;padding-top:calc(14px + env(safe-area-inset-top))}
+#side-scroll{flex:1;min-height:0;overflow-y:auto;padding:0 12px 12px;
+ scrollbar-gutter:stable;display:flex;flex-direction:column;gap:2px}
+#side-perf{flex:none;border-top:1px solid var(--border);padding:12px 14px;
+ padding-bottom:calc(12px + env(safe-area-inset-bottom))}
+@media (max-width:720px){
+ .side{padding:0}
+ #side-scroll{padding-left:calc(12px + env(safe-area-inset-left))}
+ .sidehead{padding-left:calc(14px + env(safe-area-inset-left))}
+}
+/* Escamotage sur grand écran : la barre disparaît, le fil reprend la place.
+ L'état est persisté (localStorage) et posé sur avant le rendu, comme
+ le thème — sinon la barre apparaît puis se referme sous les yeux. */
+@media (min-width:721px){
+ html[data-side="0"] .side{display:none}
+ html[data-side="0"] #sidetoggle svg{transform:none}
+}
+#sidetoggle svg{transform:scaleX(-1)}
+
+/* --- Barre d'en-tête du fil ----------------------------------------- */
+/* Elle porte ce qui décrit la conversation en cours : de quoi on parle, et avec
+ QUEL modèle. Le choix du modèle vivait au fond de la barre latérale, dans une
+ liste de presets — trois clics et un défilement pour une action qu'on fait
+ plusieurs fois par session. */
+#topbar{flex:none;display:flex;align-items:center;gap:10px;padding:10px 16px;
+ padding-top:calc(10px + env(safe-area-inset-top));
+ border-bottom:1px solid var(--border);background:var(--bg);z-index:5}
+#topbar-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
+ font-size:13.5px;font-weight:500;color:var(--text)}
+#topbar .iconbtn{width:30px;height:30px;flex:none;display:inline-flex;align-items:center;
+ justify-content:center;border:0;border-radius:9px;background:transparent;color:var(--dim);
+ cursor:pointer;margin:0;padding:0;transition:background .15s,color .15s,transform .1s}
+#topbar .iconbtn:hover{background:var(--row-bg-on);color:var(--text)}
+#topbar .iconbtn:active{transform:scale(.92)}
+#topbar .iconbtn svg{display:block}
+/* Sélecteur de modèle : le libellé n'est PAS une étiquette flottante mais le nom
+ du preset actif, lisible d'un coup d'œil. */
+#model-switch{max-width:230px;font:inherit;font-size:12.5px;color:var(--text);
+ background:var(--panel);border:1px solid var(--border);border-radius:999px;
+ padding:6px 30px 6px 12px;cursor:pointer;appearance:none;
+ background-image:linear-gradient(45deg,transparent 50%,var(--dim) 50%),linear-gradient(135deg,var(--dim) 50%,transparent 50%);
+ background-position:calc(100% - 15px) 52%,calc(100% - 11px) 52%;
+ background-size:4px 4px,4px 4px;background-repeat:no-repeat}
+#model-switch:hover{border-color:var(--accent)}
+#model-switch:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
+@media (max-width:720px){
+ #topbar{padding-left:56px} /* la place du bouton ☰, en position fixe */
+ #model-switch{max-width:130px}
+}
+
+/* --- Discussions ----------------------------------------------------- */
+/* La liste EST la barre latérale : titre à gauche, heure à droite, une ligne.
+ Le reste (réglages) descend sous un unique repli. */
+#conv-search{width:100%;font:inherit;font-size:12.5px;color:var(--text);
+ background:var(--panel);border:1px solid var(--border);border-radius:10px;
+ padding:7px 11px;margin:0 0 6px}
+#conv-search::placeholder{color:var(--dim)}
+#conv-search:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)}
+.side .preset{padding:8px 11px;margin:1px 0;gap:10px}
+.side .preset .preset-name{font-size:13px;font-weight:450;white-space:nowrap;overflow:hidden;
+ text-overflow:ellipsis;word-break:normal;overflow-wrap:normal}
+.side .preset.active .preset-name{font-weight:550}
+/* Heure de la dernière activité : à DROITE du titre, sur la même ligne, en
+ monospace pour que les colonnes s'alignent d'une ligne à l'autre.
+ ⚠️ Sélecteur en TROIS classes : `.preset>span` (plus haut) pose flex:1 sur
+ tout span enfant d'une ligne — l'heure prenait donc la MOITIÉ de la ligne et
+ le titre était tronqué au premier mot. */
+.side .preset .conv-when{flex:none;font-family:var(--mono);font-size:10.5px;
+ color:var(--dim);letter-spacing:-.01em;overflow:visible}
+.side .preset:hover .conv-when{opacity:0}
+.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
+/* Repli des réglages : tout ce qui n'est pas la conversation tient sous cette
+ seule ligne, pour que la barre latérale de la maquette (discussions +
+ machine) reste la vue par défaut. */
+#side-settings{margin-top:8px;border-top:1px solid var(--border)}
+#side-settings>summary{height:38px}
+#side-settings>.sidegroup{margin-top:2px}
+
+/* --- Moniteur machine (pied de barre latérale) ----------------------- */
+.perf-head{display:flex;align-items:center;gap:8px;margin-bottom:9px;
+ font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--dim)}
+.perf-head .perf-toggle{margin-left:auto;border:0;background:transparent;color:var(--dim);
+ padding:0 2px;margin:0;font-size:11px;cursor:pointer;line-height:1}
+.perf-row{margin:0 0 9px}
+.perf-row:last-child{margin-bottom:0}
+.perf-l{display:flex;align-items:baseline;gap:8px;font-size:11.5px;color:var(--dim);margin-bottom:4px}
+.perf-l b{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
+ font-weight:450;color:var(--text)}
+.perf-l .perf-pct{font-family:var(--mono);font-size:11px;color:var(--text);font-variant-numeric:tabular-nums}
+/* La jauge : 4 px, coins ronds, remplissage sauge. Elle passe à l'ambre puis au
+ rouille quand la machine sature — c'est là que l'information devient utile. */
+.perf-track{height:4px;border-radius:999px;background:var(--row-bg-on);overflow:hidden}
+.perf-fill{height:100%;width:0;border-radius:999px;background:var(--accent-soft);
+ transition:width .4s ease,background .4s ease}
+.perf-fill.hot{background:var(--warn)}
+.perf-fill.max{background:var(--err)}
+.perf-sub{margin-top:3px;font-family:var(--mono);font-size:9.5px;color:var(--dim);opacity:.85}
+html[data-perf="0"] .perf-body{display:none}
+
+/* --- Boutons : sans contour, micro-interaction au survol ------------- */
+button{border-color:transparent;background:var(--row-bg);border-radius:9px;
+ transition:background .15s ease,color .15s ease,transform .08s ease}
+button:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
+button:active:not(:disabled){transform:translateY(1px)}
+.side .preset,.slist,#conv-search{transition:background .15s ease,border-color .15s ease}
+
+/* --- Fil : la réponse redevient une carte --------------------------- */
+/* La maquette encadre la réponse de l'IA : sur un fond de fil coloré, du texte
+ nu flotte sans ancrage, et la frontière entre deux réponses se perd. */
+#chat{padding-top:22px}
+#chat .msg.assistant{background:var(--bubble);border:1px solid var(--border);
+ border-radius:16px;padding:14px 18px;max-width:100%;box-shadow:var(--shadow)}
+#chat .msg.assistant.errmsg{background:var(--danger-bg);border-color:color-mix(in srgb,var(--err) 45%,transparent)}
+/* Question : aplat de sauge, coin bas-droit rentré. --on-accent garantit le
+ contraste dans les DEUX variantes. */
+#chat .msg.user{background:var(--accent);color:var(--on-accent);border-radius:18px 18px 6px 18px;
+ padding:11px 16px;max-width:75%}
+#chat .msg.user .label{color:var(--on-accent);opacity:.75}
+#chat .msg.user .body a{color:var(--on-accent);text-decoration:underline}
+#chat .msg.user code{background:rgba(255,255,255,.18);color:inherit}
+[data-theme="dark"] #chat .msg.user code{background:rgba(15,23,42,.22)}
+/* Les pastilles de fichier d'une question sont posées SUR l'aplat de sauge :
+ sans fond propre, elles s'y fondaient. */
+#chat .msg.user .msg-files .chip-file{background:var(--panel);color:var(--text);border-color:transparent}
+
+/* --- Saisie : la pilule de la maquette ------------------------------ */
+/* Le bouton d'envoi devient une pastille ronde à flèche : c'est la forme de la
+ maquette, et elle laisse toute la largeur au texte. Le mot « Envoyer » reste
+ porté par aria-label et par l'infobulle — l'action n'est pas moins nommée
+ pour un lecteur d'écran, elle l'est moins à l'écran. */
+#send,#stop{width:34px;height:34px;min-height:34px;padding:0;border-radius:999px;
+ display:inline-flex;align-items:center;justify-content:center;flex:none}
+#send svg,#stop svg{display:block}
+#send:hover:not(:disabled){opacity:1;transform:none;background:color-mix(in srgb,var(--accent) 85%,var(--text))}
+#send:active:not(:disabled){transform:scale(.92)}
+#stop:hover{opacity:1;background:color-mix(in srgb,var(--err) 85%,var(--text))}
+/* Les outils de la saisie (joindre, fichiers, envoyer) montent DANS la pilule,
+ à droite du texte : c'est là qu'on les cherche, et le pied de carte redevient
+ ce qu'il doit être — une jauge de contexte, rien d'autre. */
+#inputbar{align-items:flex-end;padding:8px 8px 8px 18px;gap:4px;box-shadow:var(--shadow)}
+#composer:focus-within #inputbar,#composer:focus-within #ctxmeta{border-color:var(--accent-soft)}
+#inputbar #attach,#inputbar #files-btn{width:34px;height:34px;min-height:34px;border-radius:999px;
+ align-self:center}
+#inputbar #attach svg,#inputbar #files-btn svg{width:16px;height:16px}
+#ctxmeta{padding-bottom:12px}
+#ctxtrack{border-radius:999px}
+
+/* --- Coque à plat ---------------------------------------------------
+ La direction précédente posait deux CARTES flottantes séparées par une
+ gouttière ; la maquette est bord à bord : barre latérale collée au bord,
+ simple filet de séparation, fil qui occupe tout le reste. On neutralise donc
+ ici les règles de coque de la section « Terre » plutôt que de les réécrire
+ plus haut — la cascade fait le tri, et l'historique reste lisible. */
+@media (min-width:721px){
+ body{padding:0;gap:0}
+ html[data-pwa="1"] body{padding-top:env(safe-area-inset-top)}
+ .side{width:300px;border-right:1px solid var(--border);border-radius:0;
+ box-shadow:none;background:var(--side);overflow:hidden}
+ /* Dans la barre, plus de cartes : des lignes posées sur le fond. Deux niveaux
+ de cadre imbriqués (carte de section + carte de liste) pour une liste de
+ titres, c'était deux de trop. */
+ #side-scroll>details,#side-scroll>.sidegroup{background:transparent;box-shadow:none;
+ border-radius:0;padding:0;margin:0}
+ #side-scroll>details>summary{height:34px}
+ .side .slist{background:transparent;border-color:transparent;border-radius:12px}
+ .side #side-settings .slist{background:var(--row-bg);border-radius:12px}
+ /* Le fil n'est plus une carte : il EST la page. */
+ .main{background:transparent;border-radius:0;box-shadow:none;overflow:visible}
+ #composer{background:linear-gradient(to bottom,transparent 0,var(--bg) var(--fade))}
+ #inputbar,#ctxmeta{background:var(--panel);border-color:var(--border)}
+ #input{background:transparent}
+}
+/* Ligne de discussion : les actions (renommer, supprimer) sont posées EN
+ SURIMPRESSION à droite plutôt qu'en flux. En flux, elles réservaient leur
+ largeur en permanence — invisibles mais présentes — et le titre était tronqué
+ au tiers de la place disponible. */
+.side .preset{position:relative;border-radius:12px}
+.side .preset .conv-acts{position:absolute;right:6px;top:50%;transform:translateY(-50%);
+ margin:0;background:var(--row-bg-on);border-radius:9px;padding:1px}
+.side .preset.active .conv-acts{background:var(--accent-bg)}
+.side .preset .preset-info{overflow:hidden}
+/* La sélection est un aplat, pas un cadre : un liseré autour d'une ligne de
+ liste alourdit là où un fond suffit à dire « c'est celle-ci ». */
+.side .preset.active{background:var(--accent-bg);border-color:transparent}
+.side .preset.active .preset-name{color:var(--text)}
+
+/* --- Téléphone : un seul bouton de barre latérale -------------------- */
+/* Le ☰ flottant ouvre déjà le tiroir ; garder EN PLUS l'icône d'escamotage de
+ l'en-tête donnait deux boutons voisins pour la même action, et grignotait la
+ place du titre sur un écran qui n'en a pas. */
+@media (max-width:720px){
+ /* Sélecteur en DEUX id : `#topbar .iconbtn` (plus haut) pose déjà un display,
+ et il l'emporterait sur un simple `#sidetoggle`. */
+ #topbar #sidetoggle{display:none}
+ #topbar{padding-left:52px;padding-right:10px;gap:6px}
+ #topbar-title{font-size:13px}
+}
+/* Le ☰ suit la charte des boutons : pas de contour, un fond qui apparaît au
+ survol. Il flotte au-dessus de l'en-tête, il doit donc en reprendre le fond
+ pour ne pas laisser voir le fil défiler dessous. */
+#menubtn{background:var(--bg);border:0;color:var(--dim);border-radius:10px;
+ width:34px;height:34px;font-size:16px;top:calc(11px + env(safe-area-inset-top));left:9px}
+#menubtn:hover{background:var(--row-bg-on);color:var(--text)}
+#lc-chip{border-radius:10px;height:34px;top:calc(11px + env(safe-area-inset-top));left:calc(50px + env(safe-area-inset-left))}
+/* Jauges : la piste doit se VOIR, sinon une barre presque vide ressemble à un
+ trait souligné sous l'intitulé. */
+.perf-track{background:color-mix(in srgb,var(--text) 9%,transparent)}
+/* Une liste déroulante posée directement dans une ligne de modale n'a pas de
+ garde-fou de largeur (contrairement à .pe-row-c) : une valeur très longue —
+ un chemin de destination, par exemple — débordait sous l'intitulé. */
+.pe-row>.pe-selc{max-width:62%}
diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go
index 75ae8f0..9516e30 100644
--- a/internal/loki/web_server.go
+++ b/internal/loki/web_server.go
@@ -119,10 +119,10 @@ func newWebMux() *http.ServeMux {
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(b)
})
- // Polices embarquées (Bricolage Grotesque + IBM Plex Mono, sous-ensemble
- // latin). Servies par Loki et non par Google Fonts : une instance locale ou
- // derrière un réseau fermé doit s'afficher correctement sans appeler un tiers,
- // et sans lui signaler qui consulte l'interface.
+ // Polices embarquées (Inter + JetBrains Mono, sous-ensemble latin). Servies
+ // par Loki et non par Google Fonts : une instance locale ou derrière un
+ // réseau fermé doit s'afficher correctement sans appeler un tiers, et sans
+ // lui signaler qui consulte l'interface.
mux.HandleFunc("/fonts/", func(w http.ResponseWriter, r *http.Request) {
name := path.Base(r.URL.Path)
if !strings.HasSuffix(name, ".woff2") {