From 1fa8452e44b4e59e11c9eec4f1a1eb3031cf9c50 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 09:09:38 +0000 Subject: [PATCH] Le fil ne passe plus sous la carte de saisie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur écran large, la fin de la conversation était recouverte par la carte de saisie et aucun défilement ne la ramenait : le fil réservait 8 px sous lui là où le composeur en occupe ~160. styles.css réservait bien la hauteur (#chat{padding-bottom:170px}), mais deux règles POSTÉRIEURES dans la feuille — ajoutées par le redesign « Terre » puis par le correctif de largeur de lecture — réécrivaient `padding` en raccourci sous @media (min-width:721px), ce qui remet padding-bottom à la valeur du raccourci. Sous 721 px la règle en raccourci est déclarée AVANT, d'où un bug invisible sur mobile. Ces règles passent en longhands (padding-top + padding-inline), et un commentaire dit pourquoi le raccourci est proscrit ici. La réserve devient mesurée au lieu d'être devinée : syncComposer() publie --composer-h (hauteur réelle du composeur) via un ResizeObserver, à côté de syncGutter() qui fait déjà ce travail pour --sbw. Une constante redevenait fausse dès que la saisie grandit ou qu'une pièce jointe s'affiche. Le scrollbtn suit la même variable. env(safe-area-inset-bottom) disparaît du calcul : il est déjà dans la hauteur mesurée, le compter deux fois creusait un trou en PWA iOS. Le voile de fondu au-dessus de la carte s'exprimait en % de la hauteur TOTALE du composeur : l'opacité pleine tombait sous le bord haut de la carte, et le texte restait lisible au ras du bord — l'impression de « passer derrière ». Une variable --fade pilote désormais la bande réservée ET la course du dégradé, qui devient donc opaque exactement au bord de la carte. Mesuré au navigateur, du 380 px au 2000 px, au repos / saisie 10 lignes / pièce jointe en attente : écart de 46 à 52 px partout. En réinjectant l'ancienne règle : -119 px, bug reproduit. La largeur de lecture est inchangée (1080 px de contenu à 2000 px de fenêtre). Barre latérale : la discussion sélectionnée porte un cadre sauge complet et fin (1 px) au lieu d'un liseré de 3 px sur le seul bord gauche. La bordure transparente est portée par tous les états, la sélection ne décale donc pas le contenu. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VueWA9xcYadaYq65tBisix --- internal/loki/ui/index.html | 63 ++++++++++++++++++----- internal/loki/ui/src/js/08-chat-render.js | 18 +++++++ internal/loki/ui/src/styles.css | 45 +++++++++++----- 3 files changed, 100 insertions(+), 26 deletions(-) diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 4b2dba1..4a6ad23 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -1031,8 +1031,13 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em} /* Barre latérale : plus sombre/atténuée que la zone de conversation. */ .side{background:var(--panel);border-right-color:transparent} @media (max-width:720px){.side{background:var(--panel)}} -/* Colonne de lecture centrée (comme le fil ChatGPT), pleine largeur sur mobile. */ -#chat{padding:28px max(16px,calc(50% - 380px)) 8px;gap:22px;scrollbar-gutter:stable both-edges} +/* Colonne de lecture centrée (comme le fil ChatGPT), pleine largeur sur mobile. + ⚠️ JAMAIS le raccourci `padding` ici : le rembourrage BAS réserve la hauteur + de la carte de saisie (voir #chat plus bas) et il est réglé ailleurs. Une + règle en raccourci le remet silencieusement à sa valeur du raccourci — c'est + exactement ce qui, jusqu'à ce correctif, laissait la fin du fil coincée sous + la carte au-delà de 721 px de large. Longhands uniquement. */ +#chat{padding-top:28px;padding-inline:max(16px,calc(50% - 380px));gap:22px;scrollbar-gutter:stable both-edges} /* Réponses de l'assistant : pas de cadre, pas de fond — juste du texte. */ #chat .msg.assistant{background:transparent;border:none;padding:0;max-width:100%;line-height:1.7} /* Question : pastille arrondie à droite, texte lisible sur fond clair OU sombre. */ @@ -1078,9 +1083,21 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none} /* Le padding BAS dégage l'indicateur d'accueil de l'iPhone en PWA : la carte flotte au-dessus de lui, son arrondi bas reste donc bien visible. Hors PWA l'inset vaut 0 → strictement les 8 px d'origine. */ -#composer{position:absolute;left:0;right:0;bottom:0;z-index:4;border-top:none;display:flex;flex-direction:column;padding:34px 0 calc(8px + env(safe-area-inset-bottom));--col:calc(max(16px,calc(50% - 380px)) + var(--sbw,0px));background:linear-gradient(to bottom,transparent 0,var(--bg) 34%)} -#chat{padding-bottom:calc(170px + env(safe-area-inset-bottom))} -#scrollbtn{bottom:calc(180px + env(safe-area-inset-bottom));border-radius:999px} +/* --fade = la bande transparente au-dessus de la carte, celle où le fil doit + s'effacer. Le dégradé s'exprime dans la MÊME unité (et non en % de la hauteur + totale du composeur, qui varie) : l'opacité est donc pleine exactement au bord + haut de la carte. Avec un %, le fondu finissait SOUS ce bord et le texte + restait lisible au ras de la carte — l'impression de « passer derrière ». */ +#composer{position:absolute;left:0;right:0;bottom:0;z-index:4;border-top:none;display:flex;flex-direction:column;--fade:34px;padding:var(--fade) 0 calc(8px + env(safe-area-inset-bottom));--col:calc(max(16px,calc(50% - 380px)) + var(--sbw,0px));background:linear-gradient(to bottom,transparent 0,var(--bg) var(--fade))} +/* Réserve sous le fil = hauteur RÉELLE du composeur, mesurée et publiée par + syncComposer() (08-chat-render.js). Une constante redevient fausse dès que la + saisie grandit ou qu'une pièce jointe s'affiche, et la fin du fil repasse hors + d'atteinte. 170px n'est que le repli du tout premier rendu, avant mesure. + Pas de env(safe-area-inset-bottom) ici : il est déjà DANS le padding bas du + composeur, donc dans sa hauteur mesurée — le compter deux fois creusait un + trou en PWA iOS. */ +#chat{padding-bottom:calc(var(--composer-h,170px) + 12px)} +#scrollbtn{bottom:calc(var(--composer-h,170px) + 10px);border-radius:999px} #inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:26px 26px 0 0} #input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:36px;line-height:18px;max-height:160px} /* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même @@ -1147,7 +1164,7 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none} overflow-x:auto;scrollbar-width:none} #attach-list::-webkit-scrollbar{display:none} #attach-list.show{display:flex} -#composer:has(#attach-list.show){padding-top:6px} +#composer:has(#attach-list.show){--fade:6px} /* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un cadre, sinon le texte de la conversation transparaît derrière. */ #attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border); @@ -1296,10 +1313,10 @@ button:hover{border-color:var(--dim);color:var(--text)} /* Colonne principale : une seule grande carte qui contient fil et saisie. */ .main{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);overflow:hidden} - #chat{padding:26px max(20px,calc(50% - 360px)) 8px} + #chat{padding-top:26px;padding-inline:max(20px,calc(50% - 360px))} /* Le dégradé qui fait disparaître le fil sous la carte de saisie doit fondre vers le PANNEAU, pas vers le fond de la coque. */ - #composer{background:linear-gradient(to bottom,transparent 0,var(--panel) 34%)} + #composer{background:linear-gradient(to bottom,transparent 0,var(--panel) var(--fade))} /* Carte de saisie : posée SUR le panneau, donc un ton au-dessus, et sans bordure — c'est l'élévation qui la détache. */ #inputbar,#ctxmeta{background:var(--row-bg-on);border-color:transparent} @@ -1313,10 +1330,12 @@ button:hover{border-color:var(--dim);color:var(--text)} .side{gap:8px} .side>details>summary{height:38px} - /* Discussion active : un LISERÉ sauge à gauche, pas un cadre complet — c'est - la marque de sélection de la charte, et elle se lit sans alourdir. */ - .side .preset{border:0;border-left:3px solid transparent;border-radius:14px;background:transparent} - .side .preset.active{background:var(--row-bg-on);border-left-color:var(--accent)} + /* Discussion active : un cadre sauge COMPLET, mais fin (1 px) — le liseré de + 3 px sur le seul bord gauche se lisait mal et déséquilibrait la ligne. La + bordure transparente est portée par tous les états : la sélection ne décale + donc pas le contenu d'un pixel. */ + .side .preset{border:1px solid transparent;border-radius:14px;background:transparent} + .side .preset.active{background:var(--row-bg-on);border-color:var(--accent)} .side .preset:hover{background:var(--row-bg)} /* Bulle de question : coin bas-droit rentré, comme une bulle qui pointe vers @@ -1341,7 +1360,7 @@ button:hover{border-color:var(--dim);color:var(--text)} le début de la suivante). 1080 px ≈ 110 caractères, large mais tenable. */ @media (min-width:721px){ :root{--measure:min(1080px,100%)} - #chat{padding:26px max(20px,calc(50% - var(--measure)/2)) 8px} + #chat{padding-top:26px;padding-inline:max(20px,calc(50% - var(--measure)/2))} #composer{--col:calc(max(20px,calc(50% - var(--measure)/2)) + var(--sbw,0px))} } @@ -4177,8 +4196,26 @@ function syncGutter(){ const g=Math.max(0,(chat.offsetWidth-chat.clientWidth)/2); document.documentElement.style.setProperty('--sbw', g+'px'); } +// La carte de saisie flotte AU-DESSUS du fil (position:absolute) : c'est au fil +// de réserver sa hauteur en rembourrage bas, sinon ses derniers messages passent +// dessous et deviennent hors d'atteinte — aucun défilement ne les ramène. +// Cette hauteur n'est pas constante : la saisie grandit avec le texte (autoGrow), +// et une pièce jointe en attente ajoute une rangée de pastilles. On la mesure +// donc au lieu de la deviner, comme on le fait déjà pour la gouttière. +// Pas de boucle d'observation : le composeur est en position:absolute, changer le +// rembourrage de #chat ne modifie pas sa hauteur. +function syncComposer(){ + const c=document.getElementById('composer'); if(!c) return; + document.documentElement.style.setProperty('--composer-h', c.offsetHeight+'px'); +} addEventListener('resize', syncGutter); addEventListener('DOMContentLoaded', syncGutter); +addEventListener('resize', syncComposer); +addEventListener('DOMContentLoaded', ()=>{ + syncComposer(); + const c=document.getElementById('composer'); + if(c && window.ResizeObserver) new ResizeObserver(syncComposer).observe(c); +}); function scrollMaybe(){ // Pendant le replay initial on NE force AUCUN reflow : lire scrollHeight à chaque diff --git a/internal/loki/ui/src/js/08-chat-render.js b/internal/loki/ui/src/js/08-chat-render.js index 16a91d9..db66e75 100644 --- a/internal/loki/ui/src/js/08-chat-render.js +++ b/internal/loki/ui/src/js/08-chat-render.js @@ -14,8 +14,26 @@ function syncGutter(){ const g=Math.max(0,(chat.offsetWidth-chat.clientWidth)/2); document.documentElement.style.setProperty('--sbw', g+'px'); } +// La carte de saisie flotte AU-DESSUS du fil (position:absolute) : c'est au fil +// de réserver sa hauteur en rembourrage bas, sinon ses derniers messages passent +// dessous et deviennent hors d'atteinte — aucun défilement ne les ramène. +// Cette hauteur n'est pas constante : la saisie grandit avec le texte (autoGrow), +// et une pièce jointe en attente ajoute une rangée de pastilles. On la mesure +// donc au lieu de la deviner, comme on le fait déjà pour la gouttière. +// Pas de boucle d'observation : le composeur est en position:absolute, changer le +// rembourrage de #chat ne modifie pas sa hauteur. +function syncComposer(){ + const c=document.getElementById('composer'); if(!c) return; + document.documentElement.style.setProperty('--composer-h', c.offsetHeight+'px'); +} addEventListener('resize', syncGutter); addEventListener('DOMContentLoaded', syncGutter); +addEventListener('resize', syncComposer); +addEventListener('DOMContentLoaded', ()=>{ + syncComposer(); + const c=document.getElementById('composer'); + if(c && window.ResizeObserver) new ResizeObserver(syncComposer).observe(c); +}); function scrollMaybe(){ // Pendant le replay initial on NE force AUCUN reflow : lire scrollHeight à chaque diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index 400a67e..3ee84d5 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -1005,8 +1005,13 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em} /* Barre latérale : plus sombre/atténuée que la zone de conversation. */ .side{background:var(--panel);border-right-color:transparent} @media (max-width:720px){.side{background:var(--panel)}} -/* Colonne de lecture centrée (comme le fil ChatGPT), pleine largeur sur mobile. */ -#chat{padding:28px max(16px,calc(50% - 380px)) 8px;gap:22px;scrollbar-gutter:stable both-edges} +/* Colonne de lecture centrée (comme le fil ChatGPT), pleine largeur sur mobile. + ⚠️ JAMAIS le raccourci `padding` ici : le rembourrage BAS réserve la hauteur + de la carte de saisie (voir #chat plus bas) et il est réglé ailleurs. Une + règle en raccourci le remet silencieusement à sa valeur du raccourci — c'est + exactement ce qui, jusqu'à ce correctif, laissait la fin du fil coincée sous + la carte au-delà de 721 px de large. Longhands uniquement. */ +#chat{padding-top:28px;padding-inline:max(16px,calc(50% - 380px));gap:22px;scrollbar-gutter:stable both-edges} /* Réponses de l'assistant : pas de cadre, pas de fond — juste du texte. */ #chat .msg.assistant{background:transparent;border:none;padding:0;max-width:100%;line-height:1.7} /* Question : pastille arrondie à droite, texte lisible sur fond clair OU sombre. */ @@ -1052,9 +1057,21 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none} /* Le padding BAS dégage l'indicateur d'accueil de l'iPhone en PWA : la carte flotte au-dessus de lui, son arrondi bas reste donc bien visible. Hors PWA l'inset vaut 0 → strictement les 8 px d'origine. */ -#composer{position:absolute;left:0;right:0;bottom:0;z-index:4;border-top:none;display:flex;flex-direction:column;padding:34px 0 calc(8px + env(safe-area-inset-bottom));--col:calc(max(16px,calc(50% - 380px)) + var(--sbw,0px));background:linear-gradient(to bottom,transparent 0,var(--bg) 34%)} -#chat{padding-bottom:calc(170px + env(safe-area-inset-bottom))} -#scrollbtn{bottom:calc(180px + env(safe-area-inset-bottom));border-radius:999px} +/* --fade = la bande transparente au-dessus de la carte, celle où le fil doit + s'effacer. Le dégradé s'exprime dans la MÊME unité (et non en % de la hauteur + totale du composeur, qui varie) : l'opacité est donc pleine exactement au bord + haut de la carte. Avec un %, le fondu finissait SOUS ce bord et le texte + restait lisible au ras de la carte — l'impression de « passer derrière ». */ +#composer{position:absolute;left:0;right:0;bottom:0;z-index:4;border-top:none;display:flex;flex-direction:column;--fade:34px;padding:var(--fade) 0 calc(8px + env(safe-area-inset-bottom));--col:calc(max(16px,calc(50% - 380px)) + var(--sbw,0px));background:linear-gradient(to bottom,transparent 0,var(--bg) var(--fade))} +/* Réserve sous le fil = hauteur RÉELLE du composeur, mesurée et publiée par + syncComposer() (08-chat-render.js). Une constante redevient fausse dès que la + saisie grandit ou qu'une pièce jointe s'affiche, et la fin du fil repasse hors + d'atteinte. 170px n'est que le repli du tout premier rendu, avant mesure. + Pas de env(safe-area-inset-bottom) ici : il est déjà DANS le padding bas du + composeur, donc dans sa hauteur mesurée — le compter deux fois creusait un + trou en PWA iOS. */ +#chat{padding-bottom:calc(var(--composer-h,170px) + 12px)} +#scrollbtn{bottom:calc(var(--composer-h,170px) + 10px);border-radius:999px} #inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:26px 26px 0 0} #input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:36px;line-height:18px;max-height:160px} /* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même @@ -1121,7 +1138,7 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none} overflow-x:auto;scrollbar-width:none} #attach-list::-webkit-scrollbar{display:none} #attach-list.show{display:flex} -#composer:has(#attach-list.show){padding-top:6px} +#composer:has(#attach-list.show){--fade:6px} /* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un cadre, sinon le texte de la conversation transparaît derrière. */ #attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border); @@ -1270,10 +1287,10 @@ button:hover{border-color:var(--dim);color:var(--text)} /* Colonne principale : une seule grande carte qui contient fil et saisie. */ .main{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);overflow:hidden} - #chat{padding:26px max(20px,calc(50% - 360px)) 8px} + #chat{padding-top:26px;padding-inline:max(20px,calc(50% - 360px))} /* Le dégradé qui fait disparaître le fil sous la carte de saisie doit fondre vers le PANNEAU, pas vers le fond de la coque. */ - #composer{background:linear-gradient(to bottom,transparent 0,var(--panel) 34%)} + #composer{background:linear-gradient(to bottom,transparent 0,var(--panel) var(--fade))} /* Carte de saisie : posée SUR le panneau, donc un ton au-dessus, et sans bordure — c'est l'élévation qui la détache. */ #inputbar,#ctxmeta{background:var(--row-bg-on);border-color:transparent} @@ -1287,10 +1304,12 @@ button:hover{border-color:var(--dim);color:var(--text)} .side{gap:8px} .side>details>summary{height:38px} - /* Discussion active : un LISERÉ sauge à gauche, pas un cadre complet — c'est - la marque de sélection de la charte, et elle se lit sans alourdir. */ - .side .preset{border:0;border-left:3px solid transparent;border-radius:14px;background:transparent} - .side .preset.active{background:var(--row-bg-on);border-left-color:var(--accent)} + /* Discussion active : un cadre sauge COMPLET, mais fin (1 px) — le liseré de + 3 px sur le seul bord gauche se lisait mal et déséquilibrait la ligne. La + bordure transparente est portée par tous les états : la sélection ne décale + donc pas le contenu d'un pixel. */ + .side .preset{border:1px solid transparent;border-radius:14px;background:transparent} + .side .preset.active{background:var(--row-bg-on);border-color:var(--accent)} .side .preset:hover{background:var(--row-bg)} /* Bulle de question : coin bas-droit rentré, comme une bulle qui pointe vers @@ -1315,7 +1334,7 @@ button:hover{border-color:var(--dim);color:var(--text)} le début de la suivante). 1080 px ≈ 110 caractères, large mais tenable. */ @media (min-width:721px){ :root{--measure:min(1080px,100%)} - #chat{padding:26px max(20px,calc(50% - var(--measure)/2)) 8px} + #chat{padding-top:26px;padding-inline:max(20px,calc(50% - var(--measure)/2))} #composer{--col:calc(max(20px,calc(50% - var(--measure)/2)) + var(--sbw,0px))} }