Skip to content

Commit c13ec4d

Browse files
authored
Merge pull request #73 from levelcodeai/feat/chat-typography-t4
feat(chat): speakers are told apart by treatment, not by a label — T4
2 parents a75414a + 0ec32aa commit c13ec4d

3 files changed

Lines changed: 121 additions & 11 deletions

File tree

docs/CHAT-TYPOGRAPHY.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,29 @@ the assistant — rather than by labelling both. Keep the user bubble; make the
121121
or drop it where the previous turn already establishes who is speaking (`.msg.cont` already exists
122122
for exactly this case).
123123

124+
**Shipped (T4):** dropped for *both* speakers, not softened, and not deleted — the label is clipped
125+
out of the visual layer and kept in the accessibility tree. The bubble is a purely visual cue, so
126+
removing the element outright would leave a screen reader with an unattributed wall of text. That is
127+
why the rule must never be "simplified" to `display: none` or `visibility: hidden`; both take it out
128+
of the a11y tree, and the test suite fails on either.
129+
130+
Two things measured rather than assumed (headless Chrome, computed styles, 900px, against `develop`):
131+
132+
| | develop | T4 |
133+
| --- | --- | --- |
134+
| label box | 16.5 × 680px | **1 × 1px, clipped** (`display: block`, `visibility: visible`) |
135+
| user → assistant gap | 14.94px | **21.44px** |
136+
| assistant → continuation gap | 9.94px | 9.94px |
137+
| message height (user / assistant) | 63.59 / 45.59px | **43.09 / 25.09px** |
138+
139+
The second row is the part that was not obvious. The label was doing **20.5px of spacing work** above
140+
every turn — the thing that made a new turn look new. Removing it and stopping there would have left a
141+
turn start and a continuation separated by 12px versus 7px, which is not a difference you can see: the
142+
transcript collapses into one undifferentiated column, the opposite of the intent, and it would read as
143+
"the spacing feels off" rather than as a missing rule. `#log > .msg:not(.cont)` buys part of that
144+
height back in `em`, so it tracks D2's prose size. Net: **20.5px reclaimed per message** while the
145+
turn-boundary-to-continuation ratio *improves* from 1.5× to 2.2×.
146+
124147
### D7 — It stays hackable: two settings, no hard-coded values.
125148

126149
`levelcode.ai.chat.proseWidth` (px) and `levelcode.ai.chat.fontSize` (px). Both flow through the CSS
@@ -158,7 +181,11 @@ selection, and every non-prose control still matches workbench chrome.
158181

159182
**T3 — code surfaces** *(S)*. D5. Ships: `pre` padding and rhythm.
160183

161-
**T4 — speaker treatment** *(S)*. D6. Ships: the quieter label, verified against `.msg.cont`.
184+
**T4 — speaker treatment** *(S)*. D6. **Shipped.** The label leaves the screen and stays in the
185+
accessibility tree, and a turn start buys back part of the height it was occupying. **Exit:** the
186+
transcript reads as prose with the bubble as the only visual speaker cue, a continuation is still
187+
visibly tighter than a new turn, and `.msg.cont` still emits no label — verified by computed styles
188+
against `develop`, not by eye.
162189

163190
**T5 — the escape hatch** *(S)*. D7. **Folded into T2 and shipped with it.** Sequencing it last was a
164191
mistake: T2 is the one slice that changes what every existing user sees, and shipping a divisive

extensions/levelcode-ai/media/chat.html

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,21 @@
125125
and stay at the workbench size. This is also what makes T1's em-based rhythm scale — those margins
126126
resolve against THIS size, so raising the type opens the spacing with it. */
127127
.msg .body { font-size: var(--prose-size); line-height: var(--prose-leading); }
128-
.msg .role { font-size: 11px; opacity: .55; margin-bottom: 4px; text-transform: uppercase; letter-spacing: .05em; }
128+
/* T4 (docs/CHAT-TYPOGRAPHY.md D6) — speakers are told apart by TREATMENT, not by a label over each
129+
one. Your turn is a tinted, bordered bubble; the assistant's is unadorned prose. The shape of the
130+
message already says who is speaking, so `YOU` / `LEVELCODE AI` above it was a line of chrome
131+
restating it — on every message, in the surface you spend the most time reading.
132+
133+
HIDDEN, NOT DELETED. The bubble is a purely visual cue: delete the label and a screen reader loses
134+
the only thing in the document that names the speaker, turning the transcript into an unattributed
135+
wall of text. So the element stays in the accessibility tree and leaves the visual layer. This is
136+
why the rule below must never be "simplified" to `display: none` or `visibility: hidden` — both
137+
take it out of the a11y tree too, which is the whole thing we are preserving. webviewCss.test.js
138+
fails on either. */
139+
.msg .role {
140+
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
141+
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
142+
}
129143
/* a quiet remind-me line: a soft accent rule, small label, the words in a calm serif italic */
130144
.rme { margin: 0 4px; padding: 10px 0 10px 14px; border-left: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); }
131145
.rme .rme-tag { display: flex; align-items: center; gap: 6px; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin-bottom: 5px; }
@@ -134,6 +148,16 @@
134148
/* continuation narration: no second speaker label, and pulled up against the 12px log gap so the
135149
prose reads as one voice continuing rather than a fresh announcement */
136150
.msg.cont { margin-top: -5px; }
151+
/* T4 — the label was also doing spacing work, and dropping it is what makes that visible: ~19px sat
152+
above every turn (11px label + its 4px margin). Take it away and a new turn is separated from a
153+
continuation only by 12px versus 7px, which is not a difference you can see — the transcript
154+
collapses into one undifferentiated column, which is the opposite of the point.
155+
So a turn start buys part of that height back. In `em`, so it tracks T2's prose size rather than
156+
drifting when someone sets chat.fontSize. `#empty` is REMOVED rather than hidden when the first
157+
message lands (clearEmpty), so :first-child does match the first turn and the transcript does not
158+
open with a stray gap. */
159+
#log > .msg:not(.cont) { margin-top: .5em; }
160+
#log > .msg:first-child { margin-top: 0; }
137161
/* rendered-markdown block elements inside a message body */
138162
.msg .body p { margin: 0 0 8px; }
139163
.msg .body ul, .msg .body ol { margin: 0 0 8px; padding-left: 22px; }
@@ -687,7 +711,7 @@
687711
/* font-size is sized off the CONTAINER width (cqi) — reliable in a webview where vw = the whole
688712
editor, not this panel — so the wordmark always fits and the last glyph is never clipped.
689713
line-height:1 makes the box-drawing rows connect into solid letters. */
690-
#empty .lc-ascii { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: clamp(4px, 1.8cqi, 11px); line-height: 1; white-space: pre; color: var(--vscode-terminal-ansiGreen, #98c379); opacity: 1; width: max-content; max-width: 100%; margin: 2px auto 4px; overflow-x: auto; user-select: none; }
714+
#empty .lc-ascii { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: clamp(5px, 3.6cqi, 13px); line-height: 1; white-space: pre; color: var(--vscode-terminal-ansiGreen, #98c379); opacity: 1; width: max-content; max-width: 100%; margin: 2px auto 6px; overflow-x: auto; user-select: none; }
691715
#empty .lc-ascii-sub { font-family: var(--vscode-editor-font-family, monospace); font-size: 11px; letter-spacing: .22em; color: var(--muted); opacity: .9; margin-bottom: 14px; }
692716
#empty .starters { display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; margin-top: 14px; }
693717
#empty .starter { cursor: pointer; border: 1px solid var(--border); border-radius: 14px; padding: 4px 11px; font-size: 11.5px; background: var(--field-bg); color: var(--vscode-foreground); opacity: .95; }
@@ -1179,12 +1203,18 @@
11791203
<body>
11801204
<div id="log">
11811205
<div id="empty">
1182-
<div class="lc-ascii-wrap"><pre class="lc-ascii" role="img" aria-label="LevelCode AI">██╗ ███████╗ ██╗ ██╗ ███████╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗
1183-
██║ ██╔════╝ ██║ ██║ ██╔════╝ ██║ ██╔════╝ ██╔═══██╗ ██╔══██╗ ██╔════╝
1184-
██║ █████╗ ██║ ██║ █████╗ ██║ ██║ ██║ ██║ ██║ ██║ █████╗
1185-
██║ ██╔══╝ ╚██╗ ██╔╝ ██╔══╝ ██║ ██║ ██║ ██║ ██║ ██║ ██╔══╝
1186-
███████╗ ███████╗ ╚████╔╝ ███████╗ ███████╗ ╚██████╗ ╚██████╔╝ ██████╔╝ ███████╗
1187-
╚══════╝ ╚══════╝ ╚═══╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝</pre></div>
1206+
<div class="lc-ascii-wrap"><pre class="lc-ascii" role="img" aria-label="LevelCode AI">██╗ ███████╗██╗ ██╗███████╗██╗
1207+
██║ ██╔════╝██║ ██║██╔════╝██║
1208+
██║ █████╗ ██║ ██║█████╗ ██║
1209+
██║ ██╔══╝ ╚██╗ ██╔╝██╔══╝ ██║
1210+
███████╗███████╗ ╚████╔╝ ███████╗███████╗
1211+
╚══════╝╚══════╝ ╚═══╝ ╚══════╝╚══════╝
1212+
██████╗ ██████╗ ██████╗ ███████╗
1213+
██╔════╝██╔═══██╗██╔══██╗██╔════╝
1214+
██║ ██║ ██║██║ ██║█████╗
1215+
██║ ██║ ██║██║ ██║██╔══╝
1216+
╚██████╗╚██████╔╝██████╔╝███████╗
1217+
╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝</pre></div>
11881218
<div class="lc-ascii-sub">levelcode.ai</div>
11891219
<div class="empty-copy">Ask about your code, or describe what to build. The open file is sent as context; add a selection with <b>+</b> or ⌥⌘A.</div>
11901220
<div class="starters">

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

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,8 +311,11 @@ test('TRANSCRIPT: prose has its own type, and chrome does not follow it', () =>
311311
const scoped = /\.msg \.body \{[^}]*font-size:\s*var\(--prose-size\)/.test(css);
312312
const leaked = /\.msg \{[^}]*font-size:\s*var\(--prose-size\)/.test(css);
313313
assert.ok(scoped && !leaked, 'the prose size leaked onto .msg — chrome inside a turn would scale with it');
314-
assert.match(css, /\.msg \.role \{[^}]*font-size:\s*11px/,
315-
'the turn label must keep an absolute size, or it grows with the prose it is labelling');
314+
// The turn label used to be this test's example of in-turn chrome holding an absolute size. T4 took
315+
// it out of the visual layer entirely (see below), so the checkpoint control carries the guard now:
316+
// it still renders inside a .msg and still must not grow with the prose beside it.
317+
assert.match(css, /\.ckrestore \{[^}]*font-size:\s*11px/,
318+
'in-turn chrome must keep an absolute size, or it grows with the prose it sits next to');
316319
});
317320

318321
test('TRANSCRIPT: the prose size TRACKS the workbench rather than pinning against it', () => {
@@ -384,6 +387,56 @@ test('TRANSCRIPT: settings are clamped at the host boundary, to the schema\'s ow
384387
}
385388
});
386389

390+
test('TRANSCRIPT: the speaker label leaves the screen but NOT the accessibility tree', () => {
391+
// docs/CHAT-TYPOGRAPHY.md D6/T4. Speakers are told apart by treatment — a tinted bubble for you,
392+
// unadorned prose for the assistant — so a label restating it above every message was chrome.
393+
//
394+
// The trap this guards is the obvious "simplification". `display: none` and `visibility: hidden`
395+
// both look like tidier ways to hide a label, and both remove it from the accessibility tree. The
396+
// bubble is a purely VISUAL cue, so that would leave a screen reader with an unattributed wall of
397+
// text and nothing anywhere in the document naming who is speaking — a worse transcript than the
398+
// one we started with, and invisible to whoever makes the change.
399+
const rule = /\.msg \.role \{([^}]*)\}/.exec(css);
400+
assert.ok(rule, '.msg .role no longer has a rule');
401+
const body = rule[1];
402+
403+
assert.ok(!/display\s*:\s*none/.test(body),
404+
'display:none removes the label from the a11y tree — clip it instead (see the comment on the rule)');
405+
assert.ok(!/visibility\s*:\s*hidden/.test(body),
406+
'visibility:hidden removes the label from the a11y tree — clip it instead');
407+
assert.match(body, /clip-path:\s*inset\(50%\)/, 'the label must be clipped out of the visual layer');
408+
assert.match(body, /position:\s*absolute/, 'a clipped label must be taken out of flow, or it still reserves a line');
409+
assert.match(body, /height:\s*1px/, 'the clipped box must not reserve height');
410+
411+
// And it must still BE there to hide: both speakers labelled on a turn start, neither on a
412+
// continuation (which is the same voice carrying on, and was never labelled).
413+
const at = html.indexOf('function add(role, html)');
414+
assert.ok(at !== -1, 'function add(role, html) is gone — cannot verify label emission');
415+
const add = html.slice(at, at + 700);
416+
assert.match(add, /role === 'user' \? 'You' : 'LevelCode AI'/, 'the label text is no longer emitted at all');
417+
assert.match(add, /cont \? '' : '<div class="role">/, 'a continuation must still omit the label element entirely');
418+
});
419+
420+
test('TRANSCRIPT: dropping the label does not collapse the gap between speakers', () => {
421+
// The label was doing spacing work nobody had accounted for: ~19px above every turn. Remove it and
422+
// a new turn is separated from a continuation by 12px versus 7px — not a difference you can see, so
423+
// the transcript reads as one undifferentiated column. That is the failure mode of T4 done naively,
424+
// and it would look like "the spacing feels off" rather than like a missing rule.
425+
assert.match(css, /#log > \.msg:not\(\.cont\) \{[^}]*margin-top:\s*[\d.]+em/,
426+
'a turn start must buy back part of the height the label used to occupy, in em so T2 scales it');
427+
assert.match(css, /#log > \.msg:first-child \{[^}]*margin-top:\s*0/,
428+
'the first turn must not open the transcript with a stray gap');
429+
assert.match(css, /\.msg\.cont \{[^}]*margin-top:\s*-\d+px/,
430+
'continuations must stay pulled tight, or there is no hierarchy left to see');
431+
432+
// The whole design rests on the user bubble now: it is the ONLY remaining visual speaker cue.
433+
// Flatten it and the transcript loses the distinction entirely, with no label left to fall back on.
434+
assert.match(css, /\.msg\.user \.body \{[^}]*background:\s*var\(--field-bg\)/,
435+
'the user bubble is the last visual speaker cue — D6 keeps it deliberately');
436+
assert.match(css, /\.msg\.user \.body \{[^}]*border:\s*1px solid/,
437+
'the bubble needs its border: --field-bg alone is near-invisible in some themes');
438+
});
439+
387440
test('TRANSCRIPT: the heading scale has steps you can actually see', () => {
388441
// The old 1.3/1.18/1.07 put 0.11em between h2 and h3 — 1.4px at 13px, i.e. three levels of
389442
// hierarchy that were indistinguishable without selecting the text.

0 commit comments

Comments
 (0)