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))}
}