From 88e1b385825adb9402ddc53f4fb90ca7f081fd28 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 17 Aug 2026 17:35:54 +0200 Subject: [PATCH] =?UTF-8?q?Images=20du=20fil=20:=20miniatures=20des=20deux?= =?UTF-8?q?=20c=C3=B4t=C3=A9s,=20loupe=20au=20clic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/loki/ui/index.html | 160 +++++++++++++++++++++- internal/loki/ui/src/js/08-chat-render.js | 7 + internal/loki/ui/src/js/14-attach.js | 54 +++++++- internal/loki/ui/src/js/19-lightbox.js | 58 ++++++++ internal/loki/ui/src/styles.css | 41 +++++- 5 files changed, 310 insertions(+), 10 deletions(-) create mode 100644 internal/loki/ui/src/js/19-lightbox.js diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 1a1db97..daff7c6 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -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 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 +// 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='' + +'
'; + // 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||'')); +} diff --git a/internal/loki/ui/src/js/08-chat-render.js b/internal/loki/ui/src/js/08-chat-render.js index f594664..981db7b 100644 --- a/internal/loki/ui/src/js/08-chat-render.js +++ b/internal/loki/ui/src/js/08-chat-render.js @@ -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{ diff --git a/internal/loki/ui/src/js/14-attach.js b/internal/loki/ui/src/js/14-attach.js index cf4c665..a2f84a1 100644 --- a/internal/loki/ui/src/js/14-attach.js +++ b/internal/loki/ui/src/js/14-attach.js @@ -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 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(); } diff --git a/internal/loki/ui/src/js/19-lightbox.js b/internal/loki/ui/src/js/19-lightbox.js new file mode 100644 index 0000000..c0229cd --- /dev/null +++ b/internal/loki/ui/src/js/19-lightbox.js @@ -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 +// 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='' + +'
'; + // 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||'')); +} diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index 420ea23..0c87e05 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -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}