Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/CHAT-TYPOGRAPHY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +192 to +193
`chat.html`, two turns, 1118px viewport):

| | T4 as shipped | with the anchor |
| --- | --- | --- |
| label's `offsetParent` | `<body>` | **`#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
Expand Down
17 changes: 16 additions & 1 deletion extensions/levelcode-ai/media/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Comment on lines +154 to +155
#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. */
Expand Down Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions extensions/levelcode-ai/test/webviewCss.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,42 @@ test('TRANSCRIPT: the speaker label leaves the screen but NOT the accessibility
assert.match(add, /cont \? '' : '<div class="role">/, '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 <body>, 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'));
Comment on lines +540 to +543

// 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
Expand Down
Loading