Images du fil : miniatures des deux côtés, loupe au clic

Une pièce jointe n'affichait que son nom de fichier, et une image posée
par l'IA sortait à sa taille NATIVE — .chatimg portait la classe mais
aucune règle CSS, donc une capture remplissait la colonne et poussait le
texte hors de vue.

- pièce jointe image : vignette dans le composeur (objectURL local, rien
  n'est encore parti au serveur) et tuile 200x160 dans le message envoyé,
  hydratée comme les captures (fetch authentifié + blob:) ;
- images de l'IA plafonnées à 280px de haut ;
- clic sur n'importe quelle image = loupe plein écran (Échap ou clic sur
  le fond pour fermer). La source est relue AU CLIC, l'hydratation
  remplaçant le src par un blob: après coup.

align-items:flex-start sur .msg-files au passage : une pastille texte
voisine d'une vignette s'étirait à sa hauteur et, arrondie à 999px,
devenait un gros ovale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-17 17:35:54 +02:00
1 parent 781520afd8
commit 88e1b38582
5 files changed
+310 -10

No files matched your search

+155 -5
View File
@@ -1232,9 +1232,48 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
alignée à droite comme elle. Rangée et bulle restent deux objets distincts,
séparés par l'espacement normal du fil — les rapprocher les faisait lire comme
une seule bulle difforme qui déborde vers le haut. */
/* align-items:flex-start : depuis que la rangée peut contenir une VIGNETTE, une
pastille texte voisine s'étirait à sa hauteur (étirement par défaut du flex) —
et, arrondie à 999px, se retrouvait en gros ovale. Chaque objet garde sa
hauteur propre. */
#chat .msg-files{align-self:flex-end;display:flex;flex-wrap:wrap;justify-content:flex-end;
gap:6px;max-width:75%}
align-items:flex-start;gap:6px;max-width:75%}
#chat .msg-files .chip-file{background:var(--bubble);max-width:100%}
/* Vignette dans une pastille (composeur) : assez grande pour reconnaître la
capture qu'on vient de coller, assez petite pour que la rangée reste une
rangée de pastilles et pas une planche-contact. */
.chip-file .cf-thumb{flex:none;width:26px;height:26px;object-fit:cover;border-radius:3px;
background:var(--panel)}
.chip-file.has-thumb{padding-left:4px}
/* Pièce jointe IMAGE d'un message envoyé : la vignette remplace la pastille.
Plafonnée pour ne pas déséquilibrer le fil ; cliquable pour la voir en grand. */
#chat .msg-files .msg-img{display:flex;max-width:100%;border-radius:4px;overflow:hidden;
border:1px solid var(--border);background:var(--panel)}
/* margin/border remis à zéro : .chatimg (posée par hydrateImages) s'applique
aussi ici, or le cadre est déjà porté par la tuile. */
#chat .msg-files .msg-img img{display:block;max-width:200px;max-height:160px;
width:auto;height:auto;object-fit:contain;cursor:zoom-in;margin:0;border:none;border-radius:0}
/* Images posées par l'IA dans sa réponse (captures, /api/chat/image). Sans ce
plafond elles sortaient à leur taille NATIVE : une capture d'écran remplissait
la colonne entière et poussait le texte hors de vue. */
#chat .chatimg{display:block;max-width:100%;max-height:280px;width:auto;height:auto;
margin:6px 0;border:1px solid var(--border);border-radius:4px;cursor:zoom-in}
/* Loupe : l'image reprend toute la fenêtre, moins une marge. Le fond ferme au
clic (curseur zoom-out), l'image non — on veut pouvoir la regarder de près. */
#lightbox{position:fixed;inset:0;z-index:80;display:none;align-items:center;
justify-content:center;padding:28px;background:rgba(0,0,0,.72);cursor:zoom-out;
opacity:0;transition:opacity .16s ease}
#lightbox.show{opacity:1}
#lightbox img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;
border-radius:4px;box-shadow:0 16px 50px rgba(0,0,0,.45);cursor:default}
#lightbox .lb-x{position:absolute;top:10px;right:14px;margin:0;padding:0;
width:36px;height:36px;display:flex;align-items:center;justify-content:center;
font-size:22px;line-height:1;border:none;border-radius:50%;
background:rgba(0,0,0,.35);color:#fff}
#lightbox .lb-x:hover{background:rgba(0,0,0,.6)}
#lightbox .lb-name{position:absolute;left:0;right:0;bottom:10px;text-align:center;
color:#fff;opacity:.72;font-size:12px;pointer-events:none}
@media (prefers-reduced-motion:reduce){#lightbox{transition:none}}
/* Lien de téléchargement dans une réponse : un lien normal, mais la flèche dit
qu'il ramène un fichier plutôt qu'il n'ouvre une page. */
#chat .msg .filelink{cursor:pointer}
@@ -5198,6 +5237,13 @@ function hydrateImages(root){
img.dataset.hydrated = '1';
const src = img.getAttribute('src');
img.classList.add('chatimg');
// Nom lisible pour la loupe : la fin du chemin visé, pas l'URL d'API.
let name = img.getAttribute('alt') || '';
if(!name){
const m = /[?&]path=([^&]*)/.exec(src);
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
}
bindZoom(img, name);
const cached = IMG_CACHE.get(src);
if(cached){ img.src = cached; return; }
try{
@@ -6529,12 +6575,28 @@ function fmtSize(n){
if(n >= 1024) return Math.round(n/1024)+' Ko';
return n+' o';
}
// Un nom de fichier ne dit pas ce qu'on a joint : pour une image, on montre
// l'image. Reconnaissance à l'extension — le serveur, lui, renifle le CONTENU
// (handleChatImage) et répond 415 si ça n'en est pas une, donc au pire on affiche
// une vignette cassée qui retombe sur son texte de repli.
const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp|svg|avif|ico|heic|heif|tiff?)$/i;
function isImageName(name){ return IMG_EXT.test(String(name||'')); }
// Pastille de fichier, partagée par le composeur et les bulles du fil : même
// objet visuel des deux côtés, seul le contexte (CSS) change.
//
// opts.thumb = URL d'aperçu : la pastille porte alors une vignette à gauche du
// nom. Le composeur s'en sert avec un objectURL local (rien n'est encore parti
// vers le serveur à ce stade).
function fileChip(name, size, opts){
opts=opts||{};
const chip=document.createElement('div');
chip.className='chip-file'+(opts.cls?' '+opts.cls:'');
if(opts.thumb){
const t=document.createElement('img');
t.className='cf-thumb'; t.src=opts.thumb; t.alt='';
chip.appendChild(t);
chip.classList.add('has-thumb');
}
const n=document.createElement('span');
n.className='cf-name'; n.textContent=name; n.title=opts.title||name;
const s=document.createElement('span');
@@ -6552,12 +6614,31 @@ function fileChip(name, size, opts){
// Fichiers joints à un message DÉJÀ envoyé : posés AU-DESSUS de la bulle, dans
// leur propre rangée. Les mettre dedans étirait la bulle vers le haut et donnait
// un bloc bâtard, moitié texte moitié pastilles.
// Vignette d'une image DÉJÀ déposée : on la redemande au serveur par son chemin
// de travail. hydrateImages fait le fetch authentifié et pose le blob: — une
// balise <img> ne peut pas porter la clé de pilotage.
function msgImgTile(f){
const box=document.createElement('div');
box.className='msg-img';
const img=document.createElement('img');
img.src='/api/chat/image?path='+encodeURIComponent(f.path);
img.alt=f.name||''; img.title=f.name||'';
box.appendChild(img);
return box;
}
function addMsgFiles(el, files){
if(!el || !files || !files.length) return;
const box=document.createElement('div');
box.className='msg-files';
for(const f of files) box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
for(const f of files){
// Image : la vignette REMPLACE la pastille texte. Deux objets pour un seul
// fichier (une vignette + son nom en pastille) doublerait la rangée sans rien
// apprendre — le nom vit dans l'infobulle et dans la loupe.
if(f.path && isImageName(f.name)) box.appendChild(msgImgTile(f));
else box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
}
el.parentNode.insertBefore(box, el);
hydrateImages(box);
// Envoi sans un mot : la bulle serait un rectangle vide sous les pastilles.
markEmptyMsg(el);
return box;
@@ -6705,11 +6786,18 @@ function renderAttach(){
sizeText: a.state==='err' ? 'échec'
: (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %'
: fmtSize(a.size),
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); }
thumb: a.thumb,
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); dropThumb(a); renderAttach(); }
}));
}
}
function clearAttach(){ ATTACH=[]; renderAttach(); }
// L'aperçu du composeur vit sur un objectURL local : il meurt avec sa pastille,
// sinon chaque fichier retiré laisse son image en mémoire jusqu'au rechargement.
// (La bulle envoyée, elle, repart du serveur — elle ne dépend pas de cet URL.)
function dropThumb(a){
if(a && a.thumb){ URL.revokeObjectURL(a.thumb); a.thumb=null; }
}
function clearAttach(){ for(const a of ATTACH) dropThumb(a); ATTACH=[]; renderAttach(); }
// Ce qui part avec le message, pour l'afficher dans sa bulle.
function attachSent(){ return ATTACH.filter(a=>a.state==='ok').map(a=>({name:a.name,size:a.size,path:a.path})); }
@@ -6780,7 +6868,11 @@ function addFiles(files){
for(const f of files||[]){
if(f.size>ATTACH_MAX){ toast('« '+f.name+' » fait '+fmtSize(f.size)+' — maximum '+fmtSize(ATTACH_MAX)); continue; }
if(!f.size){ toast('« '+f.name+' » est vide'); continue; }
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued'});
// Aperçu tout de suite, depuis le fichier local : rien n'est encore parti
// vers le serveur (le dépôt n'a lieu qu'à l'envoi), donc c'est la seule
// source d'image disponible à ce stade.
const thumb = isImageName(f.name) ? URL.createObjectURL(f) : null;
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued', thumb:thumb});
}
renderAttach();
}
@@ -7380,4 +7472,62 @@ function paintModelSwitch(){
// Une bascule en cours ne doit pas voir sa sélection sauter sous la souris.
if(prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
}
// ===== Loupe d'image =======================================================
// Les images du fil sont PLAFONNÉES (CSS .chatimg / .msg-img) : sans plafond,
// une capture sortait à sa taille native et mangeait tout l'écran, ce qui rendait
// la conversation illisible. Le plafond ne se paie donc pas en information : un
// clic rouvre l'image ici, aussi grande que la fenêtre le permet.
//
// On réutilise la source DÉJÀ chargée par la vignette (blob: hydraté par
// hydrateImages, ou objectURL local d'une pièce jointe en attente) : rien n'est
// re-téléchargé, et ça marche derrière la clé de pilotage — qu'une balise <img>
// ne sait pas porter, d'où le blob: en premier lieu.
function lightboxEl(){
let el=document.getElementById('lightbox');
if(el) return el;
el=document.createElement('div');
el.id='lightbox';
el.innerHTML='<button type="button" class="lb-x" title="fermer" aria-label="fermer">×</button>'
+'<img alt=""><div class="lb-name"></div>';
// Clic sur le FOND (ou sur le ×) = fermer ; clic sur l'image = ne rien faire,
// sinon on referme dès qu'on veut la regarder de près.
el.onclick=e=>{ if(e.target===el || e.target.classList.contains('lb-x')) closeLightbox(); };
document.body.appendChild(el);
return el;
}
// Capture (3e argument à true) : sans elle, Échap serait d'abord vu par les
// écouteurs des modales déjà en place et la loupe ne se fermerait pas.
function _lbKey(e){
if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); closeLightbox(); }
}
function openLightbox(src, name){
if(!src) return;
const el=lightboxEl(), img=el.querySelector('img');
img.src=src;
img.alt=name||'';
el.querySelector('.lb-name').textContent=name||'';
showModal(el);
document.addEventListener('keydown', _lbKey, true);
}
function closeLightbox(){
const el=document.getElementById('lightbox'); if(!el) return;
document.removeEventListener('keydown', _lbKey, true);
hideModal(el);
// La source n'est lâchée qu'APRÈS la transition : la vider tout de suite
// laissait un cadre vide clignoter pendant la fermeture.
setTimeout(()=>{
if(!el.classList.contains('show')) el.querySelector('img').removeAttribute('src');
}, 200);
}
// Rend une image cliquable (idempotent : renderBody reconstruit le DOM à chaque
// delta du streaming, et hydrateImages nous rappelle sur les mêmes éléments).
//
// On lit currentSrc/src AU CLIC et non à la liaison : l'hydratation remplace la
// source par un blob: après coup, et figer l'URL /api/chat/image ici rouvrirait
// une image que le navigateur n'a pas le droit de charger seul.
function bindZoom(img, name){
if(!img || img.dataset.zoom) return;
img.dataset.zoom='1';
img.addEventListener('click', ()=>openLightbox(img.currentSrc||img.src, name||img.alt||''));
}
</script></body></html>
@@ -191,6 +191,13 @@ function hydrateImages(root){
img.dataset.hydrated = '1';
const src = img.getAttribute('src');
img.classList.add('chatimg');
// Nom lisible pour la loupe : la fin du chemin visé, pas l'URL d'API.
let name = img.getAttribute('alt') || '';
if(!name){
const m = /[?&]path=([^&]*)/.exec(src);
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
}
bindZoom(img, name);
const cached = IMG_CACHE.get(src);
if(cached){ img.src = cached; return; }
try{
+50 -4
View File
@@ -26,12 +26,28 @@ function fmtSize(n){
if(n >= 1024) return Math.round(n/1024)+' Ko';
return n+' o';
}
// Un nom de fichier ne dit pas ce qu'on a joint : pour une image, on montre
// l'image. Reconnaissance à l'extension — le serveur, lui, renifle le CONTENU
// (handleChatImage) et répond 415 si ça n'en est pas une, donc au pire on affiche
// une vignette cassée qui retombe sur son texte de repli.
const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp|svg|avif|ico|heic|heif|tiff?)$/i;
function isImageName(name){ return IMG_EXT.test(String(name||'')); }
// Pastille de fichier, partagée par le composeur et les bulles du fil : même
// objet visuel des deux côtés, seul le contexte (CSS) change.
//
// opts.thumb = URL d'aperçu : la pastille porte alors une vignette à gauche du
// nom. Le composeur s'en sert avec un objectURL local (rien n'est encore parti
// vers le serveur à ce stade).
function fileChip(name, size, opts){
opts=opts||{};
const chip=document.createElement('div');
chip.className='chip-file'+(opts.cls?' '+opts.cls:'');
if(opts.thumb){
const t=document.createElement('img');
t.className='cf-thumb'; t.src=opts.thumb; t.alt='';
chip.appendChild(t);
chip.classList.add('has-thumb');
}
const n=document.createElement('span');
n.className='cf-name'; n.textContent=name; n.title=opts.title||name;
const s=document.createElement('span');
@@ -49,12 +65,31 @@ function fileChip(name, size, opts){
// Fichiers joints à un message DÉJÀ envoyé : posés AU-DESSUS de la bulle, dans
// leur propre rangée. Les mettre dedans étirait la bulle vers le haut et donnait
// un bloc bâtard, moitié texte moitié pastilles.
// Vignette d'une image DÉJÀ déposée : on la redemande au serveur par son chemin
// de travail. hydrateImages fait le fetch authentifié et pose le blob: — une
// balise <img> ne peut pas porter la clé de pilotage.
function msgImgTile(f){
const box=document.createElement('div');
box.className='msg-img';
const img=document.createElement('img');
img.src='/api/chat/image?path='+encodeURIComponent(f.path);
img.alt=f.name||''; img.title=f.name||'';
box.appendChild(img);
return box;
}
function addMsgFiles(el, files){
if(!el || !files || !files.length) return;
const box=document.createElement('div');
box.className='msg-files';
for(const f of files) box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
for(const f of files){
// Image : la vignette REMPLACE la pastille texte. Deux objets pour un seul
// fichier (une vignette + son nom en pastille) doublerait la rangée sans rien
// apprendre — le nom vit dans l'infobulle et dans la loupe.
if(f.path && isImageName(f.name)) box.appendChild(msgImgTile(f));
else box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
}
el.parentNode.insertBefore(box, el);
hydrateImages(box);
// Envoi sans un mot : la bulle serait un rectangle vide sous les pastilles.
markEmptyMsg(el);
return box;
@@ -202,11 +237,18 @@ function renderAttach(){
sizeText: a.state==='err' ? 'échec'
: (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %'
: fmtSize(a.size),
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); }
thumb: a.thumb,
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); dropThumb(a); renderAttach(); }
}));
}
}
function clearAttach(){ ATTACH=[]; renderAttach(); }
// L'aperçu du composeur vit sur un objectURL local : il meurt avec sa pastille,
// sinon chaque fichier retiré laisse son image en mémoire jusqu'au rechargement.
// (La bulle envoyée, elle, repart du serveur — elle ne dépend pas de cet URL.)
function dropThumb(a){
if(a && a.thumb){ URL.revokeObjectURL(a.thumb); a.thumb=null; }
}
function clearAttach(){ for(const a of ATTACH) dropThumb(a); ATTACH=[]; renderAttach(); }
// Ce qui part avec le message, pour l'afficher dans sa bulle.
function attachSent(){ return ATTACH.filter(a=>a.state==='ok').map(a=>({name:a.name,size:a.size,path:a.path})); }
@@ -277,7 +319,11 @@ function addFiles(files){
for(const f of files||[]){
if(f.size>ATTACH_MAX){ toast('« '+f.name+' » fait '+fmtSize(f.size)+' — maximum '+fmtSize(ATTACH_MAX)); continue; }
if(!f.size){ toast('« '+f.name+' » est vide'); continue; }
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued'});
// Aperçu tout de suite, depuis le fichier local : rien n'est encore parti
// vers le serveur (le dépôt n'a lieu qu'à l'envoi), donc c'est la seule
// source d'image disponible à ce stade.
const thumb = isImageName(f.name) ? URL.createObjectURL(f) : null;
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued', thumb:thumb});
}
renderAttach();
}
+58
View File
@@ -0,0 +1,58 @@
// ===== Loupe d'image =======================================================
// Les images du fil sont PLAFONNÉES (CSS .chatimg / .msg-img) : sans plafond,
// une capture sortait à sa taille native et mangeait tout l'écran, ce qui rendait
// la conversation illisible. Le plafond ne se paie donc pas en information : un
// clic rouvre l'image ici, aussi grande que la fenêtre le permet.
//
// On réutilise la source DÉJÀ chargée par la vignette (blob: hydraté par
// hydrateImages, ou objectURL local d'une pièce jointe en attente) : rien n'est
// re-téléchargé, et ça marche derrière la clé de pilotage — qu'une balise <img>
// ne sait pas porter, d'où le blob: en premier lieu.
function lightboxEl(){
let el=document.getElementById('lightbox');
if(el) return el;
el=document.createElement('div');
el.id='lightbox';
el.innerHTML='<button type="button" class="lb-x" title="fermer" aria-label="fermer">×</button>'
+'<img alt=""><div class="lb-name"></div>';
// Clic sur le FOND (ou sur le ×) = fermer ; clic sur l'image = ne rien faire,
// sinon on referme dès qu'on veut la regarder de près.
el.onclick=e=>{ if(e.target===el || e.target.classList.contains('lb-x')) closeLightbox(); };
document.body.appendChild(el);
return el;
}
// Capture (3e argument à true) : sans elle, Échap serait d'abord vu par les
// écouteurs des modales déjà en place et la loupe ne se fermerait pas.
function _lbKey(e){
if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); closeLightbox(); }
}
function openLightbox(src, name){
if(!src) return;
const el=lightboxEl(), img=el.querySelector('img');
img.src=src;
img.alt=name||'';
el.querySelector('.lb-name').textContent=name||'';
showModal(el);
document.addEventListener('keydown', _lbKey, true);
}
function closeLightbox(){
const el=document.getElementById('lightbox'); if(!el) return;
document.removeEventListener('keydown', _lbKey, true);
hideModal(el);
// La source n'est lâchée qu'APRÈS la transition : la vider tout de suite
// laissait un cadre vide clignoter pendant la fermeture.
setTimeout(()=>{
if(!el.classList.contains('show')) el.querySelector('img').removeAttribute('src');
}, 200);
}
// Rend une image cliquable (idempotent : renderBody reconstruit le DOM à chaque
// delta du streaming, et hydrateImages nous rappelle sur les mêmes éléments).
//
// On lit currentSrc/src AU CLIC et non à la liaison : l'hydratation remplace la
// source par un blob: après coup, et figer l'URL /api/chat/image ici rouvrirait
// une image que le navigateur n'a pas le droit de charger seul.
function bindZoom(img, name){
if(!img || img.dataset.zoom) return;
img.dataset.zoom='1';
img.addEventListener('click', ()=>openLightbox(img.currentSrc||img.src, name||img.alt||''));
}
+40 -1
View File
@@ -1201,9 +1201,48 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
alignée à droite comme elle. Rangée et bulle restent deux objets distincts,
séparés par l'espacement normal du fil — les rapprocher les faisait lire comme
une seule bulle difforme qui déborde vers le haut. */
/* align-items:flex-start : depuis que la rangée peut contenir une VIGNETTE, une
pastille texte voisine s'étirait à sa hauteur (étirement par défaut du flex) —
et, arrondie à 999px, se retrouvait en gros ovale. Chaque objet garde sa
hauteur propre. */
#chat .msg-files{align-self:flex-end;display:flex;flex-wrap:wrap;justify-content:flex-end;
gap:6px;max-width:75%}
align-items:flex-start;gap:6px;max-width:75%}
#chat .msg-files .chip-file{background:var(--bubble);max-width:100%}
/* Vignette dans une pastille (composeur) : assez grande pour reconnaître la
capture qu'on vient de coller, assez petite pour que la rangée reste une
rangée de pastilles et pas une planche-contact. */
.chip-file .cf-thumb{flex:none;width:26px;height:26px;object-fit:cover;border-radius:3px;
background:var(--panel)}
.chip-file.has-thumb{padding-left:4px}
/* Pièce jointe IMAGE d'un message envoyé : la vignette remplace la pastille.
Plafonnée pour ne pas déséquilibrer le fil ; cliquable pour la voir en grand. */
#chat .msg-files .msg-img{display:flex;max-width:100%;border-radius:4px;overflow:hidden;
border:1px solid var(--border);background:var(--panel)}
/* margin/border remis à zéro : .chatimg (posée par hydrateImages) s'applique
aussi ici, or le cadre est déjà porté par la tuile. */
#chat .msg-files .msg-img img{display:block;max-width:200px;max-height:160px;
width:auto;height:auto;object-fit:contain;cursor:zoom-in;margin:0;border:none;border-radius:0}
/* Images posées par l'IA dans sa réponse (captures, /api/chat/image). Sans ce
plafond elles sortaient à leur taille NATIVE : une capture d'écran remplissait
la colonne entière et poussait le texte hors de vue. */
#chat .chatimg{display:block;max-width:100%;max-height:280px;width:auto;height:auto;
margin:6px 0;border:1px solid var(--border);border-radius:4px;cursor:zoom-in}
/* Loupe : l'image reprend toute la fenêtre, moins une marge. Le fond ferme au
clic (curseur zoom-out), l'image non — on veut pouvoir la regarder de près. */
#lightbox{position:fixed;inset:0;z-index:80;display:none;align-items:center;
justify-content:center;padding:28px;background:rgba(0,0,0,.72);cursor:zoom-out;
opacity:0;transition:opacity .16s ease}
#lightbox.show{opacity:1}
#lightbox img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;
border-radius:4px;box-shadow:0 16px 50px rgba(0,0,0,.45);cursor:default}
#lightbox .lb-x{position:absolute;top:10px;right:14px;margin:0;padding:0;
width:36px;height:36px;display:flex;align-items:center;justify-content:center;
font-size:22px;line-height:1;border:none;border-radius:50%;
background:rgba(0,0,0,.35);color:#fff}
#lightbox .lb-x:hover{background:rgba(0,0,0,.6)}
#lightbox .lb-name{position:absolute;left:0;right:0;bottom:10px;text-align:center;
color:#fff;opacity:.72;font-size:12px;pointer-events:none}
@media (prefers-reduced-motion:reduce){#lightbox{transition:none}}
/* Lien de téléchargement dans une réponse : un lien normal, mais la flèche dit
qu'il ramène un fichier plutôt qu'il n'ouvre une page. */
#chat .msg .filelink{cursor:pointer}