diff --git a/docs/CHAT-TYPOGRAPHY.md b/docs/CHAT-TYPOGRAPHY.md index aea80b8..81f62d3 100644 --- a/docs/CHAT-TYPOGRAPHY.md +++ b/docs/CHAT-TYPOGRAPHY.md @@ -180,6 +180,27 @@ transcript collapses into one undifferentiated column, the opposite of the inten height back in `em`, so it tracks D2's prose size. Net: **20.5px reclaimed per message** while the turn-boundary-to-continuation ratio *improves* from 1.5× to 2.2×. +**Corrected after shipping: the clipped label needs an anchor.** T4 measured the label's *box* and +never asked *where it was laid out*. The clipping pattern takes it out of flow with +`position: absolute`, and nothing above it was positioned — so every label was laid out against the +page rather than the transcript, at its unscrolled place in the log, out of reach of the log's +scrolling and clipping. A label that started more than a viewport down sat below the fold, the page +grew to reach that one pixel, and the page then scrolled: once the log was at its end, the wheel +moved the whole shell — transcript, composer and status row — up over blank space. It showed on any +conversation past its first long turn. + +`#log { position: relative }` makes the log the containing block for everything inside it: this +label, and anything out of flow added later. Measured the same way (Chromium, the shipped +`chat.html`, two turns, 1118px viewport): + +| | T4 as shipped | with the anchor | +| --- | --- | --- | +| label's `offsetParent` | `` | **`#log`** | +| the last label, its message scrolled to −22px | at 1962px: pinned to the page | **at −23px: 1px above its message** | +| page height | 1963px (that label, plus its pixel) | **1118px** (the viewport) | +| composer after 4 wheel ticks at the end of the log | 400px higher, blank page beneath | **unmoved** | +| transcript content height | 2919px | 2919px (nothing inside the log moves) | + ### D7 — It stays hackable: two settings, no hard-coded values. `levelcode.ai.chat.proseWidth` (px) and `levelcode.ai.chat.fontSize` (px). Both flow through the CSS diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index 122716d..4a125c1 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -143,6 +143,17 @@ status row resolve the same values the transcript does. `--shell-x` is the log's horizontal padding AND the shell column's inset, which is what keeps the two edges on the same line. */ #log { flex: 1; overflow-y: auto; padding: 12px var(--shell-x); display: flex; flex-direction: column; gap: 12px; } + /* THE LOG CONTAINS WHAT IS IN IT. The page is a 100vh shell and this is the only thing in it that + scrolls, so what sits below it — the composer, the status row — never moves. That holds only while + nothing in the transcript is laid out against the PAGE, and an out-of-flow box with no positioned + ancestor is: it sits at its unscrolled place in the transcript, where neither this element's + scrolling nor its clipping reaches it. `.msg .role` (below) is such a box. Every labelled message + that starts more than a viewport down the transcript — in practice, every turn after a long first + one — put its label below the fold; the page grew to reach that one pixel, and the wheel, once the + log was at its end, scrolled the whole shell away: transcript, composer and status row riding up + over a blank page. Positioned, the log is the containing block for every descendant: the label, + and whatever is added later. webviewCss.test.js fails if this goes. */ + #log { position: relative; } /* Log is a flex column → children default to flex-shrink:1. Any child with overflow:hidden (e.g. the approval/edit cards) then gets an auto min-size of 0 and the flex algorithm crushes it to an invisible line once the log overflows. Pin every child to its natural height; the log itself scrolls instead. */ @@ -241,7 +252,11 @@ wall of text. So the element stays in the accessibility tree and leaves the visual layer. This is why the rule below must never be "simplified" to `display: none` or `visibility: hidden` — both take it out of the a11y tree too, which is the whole thing we are preserving. webviewCss.test.js - fails on either. */ + fails on either. + + ABSOLUTE NEEDS AN ANCHOR. Taking the label out of flow is what stops it reserving a line — and it + also means the label is laid out against its nearest positioned ancestor. That is `#log` (see + `#log { position: relative }` above). Without one it is the page, and the page scrolls. */ .msg .role { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; diff --git a/extensions/levelcode-ai/test/webviewCss.test.js b/extensions/levelcode-ai/test/webviewCss.test.js index 5c4056a..4a1eef6 100644 --- a/extensions/levelcode-ai/test/webviewCss.test.js +++ b/extensions/levelcode-ai/test/webviewCss.test.js @@ -516,6 +516,42 @@ test('TRANSCRIPT: the speaker label leaves the screen but NOT the accessibility assert.match(add, /cont \? '' : '
/, 'a continuation must still omit the label element entirely'); }); +test('SHELL: the transcript is its own containing block — nothing in it is laid out against the page', () => { + // The page is a 100vh flex column and #log is the only thing in it that scrolls: the composer and + // the status row sit under it and never move. A box taken out of flow (position: absolute) is laid + // out against its nearest POSITIONED ancestor — and with none, against the page, at its unscrolled + // place in the transcript, where neither #log's scrolling nor its clipping reaches it. + // + // The speaker label is such a box; the test above requires it to be. With nothing positioned above + // it, the label of every message that started more than a viewport down the transcript lay below + // the fold. The page grew to reach that one pixel, and once the log was at its end the wheel + // scrolled the PAGE: transcript, composer and status row riding up together over blank space, on + // every conversation past its first long turn. Measured in Chromium against the shipped file, two + // turns in a 1118px viewport: the page was 1963px tall, the labels' offsetParent was , and + // four wheel ticks moved the composer up by 400px. Positioned: 1118px, #log, 0px. + // + // No DOM test can see any of that — the fake DOM lays nothing out — so the rule is pinned here. + const logRules = [...cssBlocks.matchAll(/(?:^|[}\n])\s*#log\s*\{([^}]*)\}/g)].map((m) => m[1]); + assert.ok(logRules.length, '#log no longer has a rule of its own'); + + // The same selector each time, so the last declaration is the one in force. + const declared = logRules.map((b) => (/(?:^|[;\s])position\s*:\s*([a-z-]+)/.exec(b) || [])[1]).filter(Boolean); + const position = declared[declared.length - 1]; + assert.ok(position && position !== 'static', + '#log must be positioned (position: relative). It is the containing block for the clipped speaker\n' + + 'label; without it every label is laid out against the page, the page grows past the viewport, and\n' + + 'the whole chat — composer included — scrolls away. Got: ' + (position || 'no position at all')); + + // The premise. If the shell is ever rebuilt, this is the test to rethink rather than delete. + const shell = blockAt(cssBlocks, cssBlocks.search(/(?:^|\n)\s*body\s*\{/)); + assert.match(shell, /(?:^|[;\s])height:\s*100vh/, 'premise: the page is exactly one viewport tall (height, not min-height)'); + assert.ok(logRules.some((b) => /overflow-y\s*:\s*auto/.test(b)), 'premise: #log is the element that scrolls'); + + // And the label is still what needs it: out of flow, with no offsets of its own to pin it anywhere. + const label = /\.msg \.role \{([^}]*)\}/.exec(cssBlocks); + assert.ok(label && /position:\s*absolute/.test(label[1]), 'the label is no longer out of flow — see the test above'); +}); + test('TRANSCRIPT: dropping the label does not collapse the gap between speakers', () => { // The label was doing spacing work nobody had accounted for: ~19px above every turn. Remove it and // a new turn is separated from a continuation by 12px versus 7px — not a difference you can see, so