Skip to content

Commit 407512d

Browse files
committed
feat(chat): bound the reading measure — T1 of the typography plan
Nothing constrained the transcript's line length. The twelve max-width rules in chat.html are all cards, dialogs and the empty state; the message column had none. That went unnoticed for as long as the chat only lived in a ~380px sidebar, where the container did the bounding — opening it as an editor tab (#70) put the same CSS at 900px and produced ~154-character lines. Now: every direct child of #log shares one 680px column, centred, behind a --prose-max custom property so T5 can hand it to a setting. Applied to every child rather than just .msg, or the cards and the activity timeline drift wider than the prose they sit between. MEASURED, not estimated — and the measurement corrected the plan. The doc's first draft said "~72ch, near 640-700px". Both halves were wrong: "0" (the ch unit) 8.13px real average prose char 5.86px `ch` is 39% wider than actual text, so a 72ch cap yields ~100 characters, not 72; and 72 REAL characters is a 422px column, narrower than the sidebar. The print range of 45-75 does not transfer to a chat carrying identifiers and file paths. 680px is ~116 characters, down from 154, and still wide enough that a fenced block does not wrap constantly. The doc now records the correction; a test asserts the cap stays an absolute length so nobody reaches for `ch` again. Rhythm (D3) is em-based and GATED to >=760px. T1's exit criterion is that a narrow panel renders exactly as before, and that is verified rather than assumed: computed styles at 520px against develop are identical across padding, gap, paragraph and heading margins, line-height and font-size. Rendered and measured at three widths: 520px column 496 · 81 chars · padding 12px (identical to develop) 900px column 680 · 115 chars · padding 24px 1400px column 680 · 115 chars · does not grow Two guards added to webviewCss, because this regression is invisible in a sidebar: whoever refactors the log container will not see it break, a user with the chat in an editor tab will. Verified non-vacuous five ways — remove the cap, scope it to .msg only, switch to ch, drop the width gate, drop the centring: 14-15/17 each. 33 suites green.
1 parent 0810a4c commit 407512d

3 files changed

Lines changed: 233 additions & 1 deletion

File tree

docs/CHAT-TYPOGRAPHY.md

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# LevelCode — Chat typography and layout — scope & plan
2+
3+
The goal, in one sentence: make the chat read like a **document** rather than a log — a constrained
4+
measure, real vertical rhythm, and a type scale with actual hierarchy — so a long answer is as
5+
comfortable to read in LevelCode as it is in the Claude Code console.
6+
7+
Reference behavior: the Claude Code console transcript (side-by-side screenshots, 2026-08-15).
8+
`CALM-TRANSCRIPT.md` already took the *structure* from the same reference — voice and grouped
9+
activity. This doc takes the *visual layer*, which that one deliberately left alone.
10+
11+
This became urgent the moment the chat could open as an editor tab (#70). In a 380px sidebar the
12+
line length is bounded by the container, so nothing looks badly wrong. At 900px it is unbounded,
13+
and the same CSS produces ~130-character lines — roughly twice the readable measure.
14+
15+
---
16+
17+
## 1. Verified facts about our own code (read 2026-08-15, not recalled)
18+
19+
All from `extensions/levelcode-ai/media/chat.html`:
20+
21+
| | current | note |
22+
| --- | --- | --- |
23+
| Prose column width | **unconstrained** | 12 `max-width` rules exist; every one is a card, dialog or the empty state. **None applies to the message column.** |
24+
| `#log` | `padding: 12px; gap: 12px` | the entire page margin |
25+
| `body` | `font-family: var(--vscode-font-family)`<br>`font-size: var(--vscode-font-size)` | the workbench **UI** font — sized for chrome, not for reading |
26+
| `.msg` | `line-height: 1.5` | |
27+
| Paragraph | `margin: 0 0 8px` | |
28+
| Headings | `1.3em / 1.18em / 1.07em`, `margin: 12px 0 6px` | h2→h3 differ by **0.11em**; at 13px that is 1.4px |
29+
| `.msg.user .body` | `background: var(--field-bg)`, `1px` border, `radius 10px`, `padding: 9px 11px` | |
30+
| `.msg.assistant .body` | `padding: 1px 2px` | effectively none |
31+
| `.msg .role` | `11px`, `opacity .55`, uppercase, `letter-spacing .05em` | a label above every turn |
32+
| Inline code | `background: var(--vscode-textCodeBlock-background…)`, `padding: 1px 5px`, `.92em` | **no `color` is set** — the red/orange in dark themes comes from the theme or webview defaults, not from us |
33+
| `pre` | `padding: 9px 11px`, `radius 8px`, `margin: 8px 0` | |
34+
35+
Two observations worth stating plainly, because they explain almost everything:
36+
37+
1. **Nothing constrains the measure.** Every other property is defensible; this one is simply absent.
38+
2. **The chat inherits the workbench UI font size.** That is a reasonable default for a sidebar
39+
widget and the wrong one for sustained reading. UI type is tuned for scanning labels at density;
40+
prose wants a larger size and looser leading.
41+
42+
---
43+
44+
## 2. Decisions (with the reasoning, so they can be re-litigated)
45+
46+
### D1 — Constrain the measure. This is the single biggest lever.
47+
48+
`max-width` on the prose column, centred, with the container still full-bleed so cards, code blocks
49+
and the composer keep their current behaviour.
50+
51+
**Target 680px** — measured, not estimated. An earlier draft of this section said "~72ch, which lands
52+
near 640–700px". Both halves were wrong, and the correction is worth keeping because the mistake is
53+
easy to repeat:
54+
55+
| measured in the shipped font at 13px | |
56+
| --- | --- |
57+
| `ch` (the width of `0`) | **8.13px** |
58+
| real average prose character | **5.86px** |
59+
60+
`ch` is **39% wider than actual text**, so a `72ch` cap yields ~100 characters, not 72 — and 72 real
61+
characters would be a 422px column, narrower than the sidebar. The print-typography range of 45–75
62+
characters does not transfer to a technical chat: it assumes prose without identifiers, file paths or
63+
code, and a 422px column would wrap every code block constantly.
64+
65+
680px is chosen against the measurement: **~116 characters at 13px**, down from 154 at editor width,
66+
while staying wide enough that a fenced block is still readable. When T2 raises the prose size the
67+
same cap tightens to ~108 characters, which is the right direction.
68+
69+
The elegant part: **in the sidebar this is a no-op.** The container is already narrower than the cap,
70+
so nothing moves for existing users. It only takes effect in the editor tab, which is exactly the
71+
surface that needs it.
72+
73+
### D2 — Prose diverges from the workbench UI font size. Chrome does not.
74+
75+
This is the real trade-off in the whole document, so it gets stated rather than smuggled in.
76+
77+
`--vscode-font-size` (typically 13px) is the size of menu labels and tree rows. Reading three
78+
paragraphs of explanation at that size, at `line-height: 1.5`, is why the panel feels cramped next to
79+
the reference.
80+
81+
**Decision:** message bodies get their own size (~14px) and leading (~1.65), expressed relative to a
82+
single custom property. Everything else — the composer, buttons, session cards, the status row,
83+
approval chips — keeps inheriting the workbench size, so the panel still belongs to the editor.
84+
85+
**The cost, honestly:** the chat will no longer match workbench chrome exactly. That is a real
86+
inconsistency, and it is the deliberate price of the panel being a place you *read* rather than a
87+
place you *operate*. D7 gives it an escape hatch.
88+
89+
### D3 — Vertical rhythm scales with the type, not with pixels.
90+
91+
Spacing is currently absolute (`8px`, `12px`), so raising the font size makes the page *tighter*
92+
rather than proportionally airier. Every prose gap moves to `em`, anchored to the prose size:
93+
paragraph `0.85em`, block gap `1.15em`, `#log` padding to ~`20px 24px` at editor width.
94+
95+
### D4 — Widen the heading scale so hierarchy survives.
96+
97+
`1.3 / 1.18 / 1.07` compresses three levels into a quarter of an em. Move to roughly
98+
`1.45 / 1.25 / 1.1`, with more space *above* a heading than below it — the standard trick that makes
99+
a heading belong to the section it introduces rather than float between two.
100+
101+
### D5 — Code surfaces get room, and stay theme-driven.
102+
103+
`pre` padding `9px 11px`~`12px 14px`, with the block's vertical margin tied to D3's rhythm.
104+
Inline code keeps its neutral background; we do **not** start setting `color` (see §1 — we never
105+
did, and hard-coding it would fight every theme).
106+
107+
Deliberately **not** in scope: a header row on code blocks (language label, copy button). That is a
108+
component, not typography, and it belongs in its own slice.
109+
110+
### D6 — Soften the turn label; keep the user bubble.
111+
112+
The uppercase `LEVELCODE AI` label above every assistant turn adds a line of chrome to every message.
113+
The reference distinguishes speakers by *treatment* — a tinted bubble for you, unadorned prose for
114+
the assistant — rather than by labelling both. Keep the user bubble; make the assistant label quieter
115+
or drop it where the previous turn already establishes who is speaking (`.msg.cont` already exists
116+
for exactly this case).
117+
118+
### D7 — It stays hackable: two settings, no hard-coded values.
119+
120+
`levelcode.ai.chat.proseWidth` (px, `0` = unconstrained) and `levelcode.ai.chat.fontSize`
121+
(`0` = follow the workbench). Both flow through CSS custom properties set on the container, so the
122+
defaults are a starting point rather than a verdict — consistent with the editor's whole posture, and
123+
the honest answer to anyone who preferred the old density.
124+
125+
---
126+
127+
## 3. Slices
128+
129+
Each ships independently and is visible on its own.
130+
131+
**T1 — measure + rhythm** *(S)*. D1 and D3. The largest perceptual change for the least code, and the
132+
one that fixes the editor tab. Ships: a wrapper max-width, `em`-based prose spacing, wider `#log`
133+
padding at editor width. **Exit:** a long answer in the editor tab holds ~72 characters per line, and
134+
the sidebar renders byte-identically to today.
135+
136+
**T2 — the reading type scale** *(S)*. D2 and D4. Ships: the prose size/leading custom properties and
137+
the widened heading scale. **Exit:** h1/h2/h3 are distinguishable at a glance in a screenshot with no
138+
selection, and every non-prose control still matches workbench chrome.
139+
140+
**T3 — code surfaces** *(S)*. D5. Ships: `pre` padding and rhythm.
141+
142+
**T4 — speaker treatment** *(S)*. D6. Ships: the quieter label, verified against `.msg.cont`.
143+
144+
**T5 — the escape hatch** *(S)*. D7. Ships: the two settings and their plumbing.
145+
146+
Sequencing: T1 first and alone — it may turn out to be most of the perceived fix, and shipping it
147+
by itself is the cheapest way to find out before spending effort on T2–T4.
148+
149+
---
150+
151+
## 4. Risks, honestly
152+
153+
- **Divergence from workbench chrome (D2).** The panel will read as slightly its own thing. Mitigated
154+
by scoping the change to message bodies only, and by T5.
155+
- **Sidebar users who liked the density.** At 380px the measure is a no-op, but the type-size change
156+
is not. T5 is the answer, and T1 shipping alone gives us a read on whether T2 is even wanted.
157+
- **Theme variance.** Inline-code colour already comes from the theme rather than from us (§1), so
158+
any judgement about "busy" colour must be checked across the light, dark and high-contrast themes
159+
the `webviewCss` suite already reasons about — not just the default.
160+
- **No test currently guards the measure.** `webviewCss.test.js` pins hidden-attribute defeats and the
161+
session-card overflow; it should gain a guard that the prose column is bounded, or T1 will regress
162+
silently the first time someone refactors the log container.
163+
- **Screenshots are not measurements.** Everything here is derived from our own CSS plus a
164+
side-by-side comparison. The specific numbers (72ch, 14px, 1.65) are considered starting points to
165+
be tuned against the real thing at real widths, not values copied from the reference — we cannot
166+
read the reference's stylesheet.

extensions/levelcode-ai/media/chat.html

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,26 @@
2525
}
2626

2727
/* ---- conversation log ---- */
28-
#log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px; }
28+
#log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px;
29+
/* T1 (docs/CHAT-TYPOGRAPHY.md D1) — the reading measure. A custom property so T5 can hand it to a
30+
setting later without touching the rules below. */
31+
--prose-max: 680px; }
2932
/* Log is a flex column → children default to flex-shrink:1. Any child with overflow:hidden (e.g. the
3033
approval/edit cards) then gets an auto min-size of 0 and the flex algorithm crushes it to an invisible
3134
line once the log overflows. Pin every child to its natural height; the log itself scrolls instead. */
3235
#log > * { flex-shrink: 0; }
36+
/* THE MEASURE. Nothing constrained the transcript's line length before this: the 12 max-width rules
37+
in this file are all cards, dialogs and the empty state. In a 380px sidebar the container bounded
38+
it, so it never looked wrong — but the chat can now open as an editor tab (AI: Open Chat in
39+
Editor), and at 900px the same CSS produced ~154-character lines.
40+
Measured in the shipped font at 13px: the average prose character is 5.86px, so 680px is ~116
41+
characters — a real improvement while staying wide enough that a fenced code block does not wrap
42+
constantly. NOT expressed in `ch`: `0` is 8.13px here, 39% wider than average text, so a `ch` cap
43+
silently overshoots by about a third.
44+
Applied to EVERY direct child, not just .msg, so messages, cards and the activity timeline share
45+
one column instead of drifting apart at width. Below 680px this is inert — which is why the
46+
sidebar is untouched. */
47+
#log > * { width: 100%; max-width: var(--prose-max); margin-inline: auto; box-sizing: border-box; }
3348
/* ---- per-response copy button (hover-reveal under a completed assistant message) ---- */
3449
.msgactions { margin-top: 5px; display: flex; gap: 6px; }
3550
.msgcopy {
@@ -111,6 +126,22 @@
111126
.msg .body li > ul, .msg .body li > ol { margin: 2px 0; }
112127
.msg .body h1, .msg .body h2, .msg .body h3, .msg .body h4, .msg .body h5, .msg .body h6 { margin: 12px 0 6px; line-height: 1.3; font-weight: 600; }
113128
.msg .body h1 { font-size: 1.3em; } .msg .body h2 { font-size: 1.18em; } .msg .body h3 { font-size: 1.07em; }
129+
130+
/* T1 (D3) — vertical rhythm, in `em` so it scales when T2 raises the prose size, and gated to the
131+
width where the chat is actually being READ. The sidebar keeps today's density on purpose: this
132+
slice's exit criterion is that a narrow panel renders unchanged, so a user who upgrades and never
133+
opens the editor tab sees nothing move. `min-width` here is the WEBVIEW's own width — the iframe
134+
is sized to the panel, which is why the existing #empty/dialog rules already use vw/vh. */
135+
@media (min-width: 760px) {
136+
#log { padding: 20px 24px; gap: 1.15em; }
137+
.msg .body p { margin-bottom: 1em; }
138+
.msg .body ul, .msg .body ol { margin-bottom: 1em; }
139+
/* More space ABOVE a heading than below it, so it binds to the section it introduces rather than
140+
floating between two. The old 12px/6px was already this shape; this keeps it and scales it. */
141+
.msg .body h1, .msg .body h2, .msg .body h3,
142+
.msg .body h4, .msg .body h5, .msg .body h6 { margin: 1.6em 0 .55em; }
143+
.msg .body > :first-child { margin-top: 0; }
144+
}
114145
.msg .body h4, .msg .body h5, .msg .body h6 { font-size: 1em; }
115146
.msg .body blockquote { margin: 0 0 8px; padding: 2px 0 2px 12px; border-left: 3px solid var(--border); color: var(--muted); }
116147
.msg .body hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,41 @@ test('SESSION CARD: the label-collapse threshold is above the width the labels a
246246
}
247247
});
248248

249+
test('TRANSCRIPT: the prose column is bounded, and every child shares the one measure', () => {
250+
// docs/CHAT-TYPOGRAPHY.md T1. Nothing constrained line length before this — the other max-width
251+
// rules in the file are cards, dialogs and the empty state. It went unnoticed for as long as the
252+
// chat only ever lived in a ~380px sidebar, where the container did the bounding; opening it as an
253+
// editor tab (#70) put the same CSS at 900px and produced ~154-character lines.
254+
//
255+
// This guard exists because the regression is INVISIBLE in a sidebar. Whoever refactors the log
256+
// container will not see it break; a user with the chat open in an editor tab will.
257+
assert.match(css, /#log\s*\{[^}]*--prose-max:\s*\d+px/,
258+
'the measure is no longer a custom property — T5 hands this to a setting');
259+
assert.match(css, /#log > \*\s*\{[^}]*max-width:\s*var\(--prose-max\)/,
260+
'the cap must apply to EVERY direct child, or cards and the timeline drift wider than the prose');
261+
assert.match(css, /#log > \*\s*\{[^}]*margin-inline:\s*auto/,
262+
'an uncentred capped column pins the transcript to the left edge at width');
263+
264+
// `ch` is a trap here and the reason the first draft of the doc was wrong: `0` measures 8.13px in
265+
// this font against a 5.86px average prose character, so a ch-based cap overshoots by ~39%.
266+
const capRule = /#log\s*\{[^}]*--prose-max:\s*([^;]+);/.exec(css);
267+
assert.ok(capRule && /px$/.test(capRule[1].trim()),
268+
'the measure should be an absolute length, not `ch` — see CHAT-TYPOGRAPHY.md D1');
269+
});
270+
271+
test('TRANSCRIPT: the looser rhythm is gated to reading width, so the sidebar is untouched', () => {
272+
// T1's exit criterion is that a narrow panel renders exactly as before — a user who upgrades and
273+
// never opens the editor tab should see nothing move. Verified against develop's computed styles
274+
// at 520px: padding, gap, paragraph and heading margins, line-height and font-size all identical.
275+
const at = css.indexOf('@media (min-width: 760px)');
276+
assert.ok(at > 0, 'the width gate is gone — the rhythm change would now hit the sidebar too');
277+
const block = css.slice(at, css.indexOf('\n }', at));
278+
assert.match(block, /#log \{[^}]*padding:/, 'the wider page margin belongs inside the gate');
279+
assert.match(block, /margin-bottom:\s*1em/, 'prose spacing must be em-based so T2 scales it');
280+
assert.match(block, /h1[\s\S]*margin:\s*1\.6em 0 \.55em/,
281+
'headings need more space above than below, or they float between sections');
282+
});
283+
249284
test('SESSION CARD: every action button keeps a label for pointers and screen readers', () => {
250285
// The collapse above hides `.sesslbl` VISUALLY. If the buttons had no title/aria-label, an
251286
// icon-only row in a narrow pane would be unusable rather than merely compact.

0 commit comments

Comments
 (0)