Skip to content

Commit bd4daf5

Browse files
authored
Merge pull request #100 from levelcodeai/fix/chat-page-scroll
fix(chat): the composer stays put — the hidden speaker label was stretching the page
2 parents f7f6843 + 6d17de5 commit bd4daf5

3 files changed

Lines changed: 73 additions & 1 deletion

File tree

‎docs/CHAT-TYPOGRAPHY.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,27 @@ transcript collapses into one undifferentiated column, the opposite of the inten
180180
height back in `em`, so it tracks D2's prose size. Net: **20.5px reclaimed per message** while the
181181
turn-boundary-to-continuation ratio *improves* from 1.5× to 2.2×.
182182

183+
**Corrected after shipping: the clipped label needs an anchor.** T4 measured the label's *box* and
184+
never asked *where it was laid out*. The clipping pattern takes it out of flow with
185+
`position: absolute`, and nothing above it was positioned — so every label was laid out against the
186+
page rather than the transcript, at its unscrolled place in the log, out of reach of the log's
187+
scrolling and clipping. A label that started more than a viewport down sat below the fold, the page
188+
grew to reach that one pixel, and the page then scrolled: once the log was at its end, the wheel
189+
moved the whole shell — transcript, composer and status row — up over blank space. It showed on any
190+
conversation past its first long turn.
191+
192+
`#log { position: relative }` makes the log the containing block for everything inside it: this
193+
label, and anything out of flow added later. Measured the same way (Chromium, the shipped
194+
`chat.html`, two turns, 1118px viewport):
195+
196+
| | T4 as shipped | with the anchor |
197+
| --- | --- | --- |
198+
| label's `offsetParent` | `<body>` | **`#log`** |
199+
| the last label, its message scrolled to −22px | at 1962px: pinned to the page | **at −23px: 1px above its message** |
200+
| page height | 1963px (that label, plus its pixel) | **1118px** (the viewport) |
201+
| composer after 4 wheel ticks at the end of the log | 400px higher, blank page beneath | **unmoved** |
202+
| transcript content height | 2919px | 2919px (nothing inside the log moves) |
203+
183204
### D7 — It stays hackable: two settings, no hard-coded values.
184205

185206
`levelcode.ai.chat.proseWidth` (px) and `levelcode.ai.chat.fontSize` (px). Both flow through the CSS

‎extensions/levelcode-ai/media/chat.html‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,17 @@
143143
status row resolve the same values the transcript does. `--shell-x` is the log's horizontal padding
144144
AND the shell column's inset, which is what keeps the two edges on the same line. */
145145
#log { flex: 1; overflow-y: auto; padding: 12px var(--shell-x); display: flex; flex-direction: column; gap: 12px; }
146+
/* THE LOG CONTAINS WHAT IS IN IT. The page is a 100vh shell and this is the only thing in it that
147+
scrolls, so what sits below it — the composer, the status row — never moves. That holds only while
148+
nothing in the transcript is laid out against the PAGE, and an out-of-flow box with no positioned
149+
ancestor is: it sits at its unscrolled place in the transcript, where neither this element's
150+
scrolling nor its clipping reaches it. `.msg .role` (below) is such a box. Every labelled message
151+
that starts more than a viewport down the transcript — in practice, every turn after a long first
152+
one — put its label below the fold; the page grew to reach that one pixel, and the wheel, once the
153+
log was at its end, scrolled the whole shell away: transcript, composer and status row riding up
154+
over a blank page. Positioned, the log is the containing block for every descendant: the label,
155+
and whatever is added later. webviewCss.test.js fails if this goes. */
156+
#log { position: relative; }
146157
/* Log is a flex column → children default to flex-shrink:1. Any child with overflow:hidden (e.g. the
147158
approval/edit cards) then gets an auto min-size of 0 and the flex algorithm crushes it to an invisible
148159
line once the log overflows. Pin every child to its natural height; the log itself scrolls instead. */
@@ -241,7 +252,11 @@
241252
wall of text. So the element stays in the accessibility tree and leaves the visual layer. This is
242253
why the rule below must never be "simplified" to `display: none` or `visibility: hidden` — both
243254
take it out of the a11y tree too, which is the whole thing we are preserving. webviewCss.test.js
244-
fails on either. */
255+
fails on either.
256+
257+
ABSOLUTE NEEDS AN ANCHOR. Taking the label out of flow is what stops it reserving a line — and it
258+
also means the label is laid out against its nearest positioned ancestor. That is `#log` (see
259+
`#log { position: relative }` above). Without one it is the page, and the page scrolls. */
245260
.msg .role {
246261
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
247262
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;

‎extensions/levelcode-ai/test/webviewCss.test.js‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,42 @@ test('TRANSCRIPT: the speaker label leaves the screen but NOT the accessibility
516516
assert.match(add, /cont \? '' : '<div class="role">/, 'a continuation must still omit the label element entirely');
517517
});
518518

519+
test('SHELL: the transcript is its own containing block — nothing in it is laid out against the page', () => {
520+
// The page is a 100vh flex column and #log is the only thing in it that scrolls: the composer and
521+
// the status row sit under it and never move. A box taken out of flow (position: absolute) is laid
522+
// out against its nearest POSITIONED ancestor — and with none, against the page, at its unscrolled
523+
// place in the transcript, where neither #log's scrolling nor its clipping reaches it.
524+
//
525+
// The speaker label is such a box; the test above requires it to be. With nothing positioned above
526+
// it, the label of every message that started more than a viewport down the transcript lay below
527+
// the fold. The page grew to reach that one pixel, and once the log was at its end the wheel
528+
// scrolled the PAGE: transcript, composer and status row riding up together over blank space, on
529+
// every conversation past its first long turn. Measured in Chromium against the shipped file, two
530+
// turns in a 1118px viewport: the page was 1963px tall, the labels' offsetParent was <body>, and
531+
// four wheel ticks moved the composer up by 400px. Positioned: 1118px, #log, 0px.
532+
//
533+
// No DOM test can see any of that — the fake DOM lays nothing out — so the rule is pinned here.
534+
const logRules = [...cssBlocks.matchAll(/(?:^|[}\n])\s*#log\s*\{([^}]*)\}/g)].map((m) => m[1]);
535+
assert.ok(logRules.length, '#log no longer has a rule of its own');
536+
537+
// The same selector each time, so the last declaration is the one in force.
538+
const declared = logRules.map((b) => (/(?:^|[;\s])position\s*:\s*([a-z-]+)/.exec(b) || [])[1]).filter(Boolean);
539+
const position = declared[declared.length - 1];
540+
assert.ok(position && position !== 'static',
541+
'#log must be positioned (position: relative). It is the containing block for the clipped speaker\n'
542+
+ 'label; without it every label is laid out against the page, the page grows past the viewport, and\n'
543+
+ 'the whole chat — composer included — scrolls away. Got: ' + (position || 'no position at all'));
544+
545+
// The premise. If the shell is ever rebuilt, this is the test to rethink rather than delete.
546+
const shell = blockAt(cssBlocks, cssBlocks.search(/(?:^|\n)\s*body\s*\{/));
547+
assert.match(shell, /(?:^|[;\s])height:\s*100vh/, 'premise: the page is exactly one viewport tall (height, not min-height)');
548+
assert.ok(logRules.some((b) => /overflow-y\s*:\s*auto/.test(b)), 'premise: #log is the element that scrolls');
549+
550+
// And the label is still what needs it: out of flow, with no offsets of its own to pin it anywhere.
551+
const label = /\.msg \.role \{([^}]*)\}/.exec(cssBlocks);
552+
assert.ok(label && /position:\s*absolute/.test(label[1]), 'the label is no longer out of flow — see the test above');
553+
});
554+
519555
test('TRANSCRIPT: dropping the label does not collapse the gap between speakers', () => {
520556
// The label was doing spacing work nobody had accounted for: ~19px above every turn. Remove it and
521557
// a new turn is separated from a continuation by 12px versus 7px — not a difference you can see, so

0 commit comments

Comments
 (0)