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
169 changes: 169 additions & 0 deletions docs/CHAT-TYPOGRAPHY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# LevelCode — Chat typography and layout — scope & plan

The goal, in one sentence: make the chat read like a **document** rather than a log — a constrained
measure, real vertical rhythm, and a type scale with actual hierarchy — so a long answer is as
comfortable to read in LevelCode as it is in the Claude Code console.

Reference behavior: the Claude Code console transcript (side-by-side screenshots, 2026-08-15).
`CALM-TRANSCRIPT.md` already took the *structure* from the same reference — voice and grouped
activity. This doc takes the *visual layer*, which that one deliberately left alone.

This became urgent the moment the chat could open as an editor tab (#70). In a 380px sidebar the
line length is bounded by the container, so nothing looks badly wrong. At 900px it is unbounded,
and the same CSS produced **~154-character lines** — measured, at a 900px viewport.

---

## 1. Verified facts about our own code (read 2026-08-15, not recalled)

All from `extensions/levelcode-ai/media/chat.html`:

| | current | note |
| --- | --- | --- |
| 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.** |
| `#log` | `padding: 12px; gap: 12px` | the entire page margin |
| `body` | `font-family: var(--vscode-font-family)`<br>`font-size: var(--vscode-font-size)` | the workbench **UI** font — sized for chrome, not for reading |
| `.msg` | `line-height: 1.5` | |
| Paragraph | `margin: 0 0 8px` | |
| Headings | `1.3em / 1.18em / 1.07em`, `margin: 12px 0 6px` | h2→h3 differ by **0.11em**; at 13px that is 1.4px |
| `.msg.user .body` | `background: var(--field-bg)`, `1px` border, `radius 10px`, `padding: 9px 11px` | |
| `.msg.assistant .body` | `padding: 1px 2px` | effectively none |
| `.msg .role` | `11px`, `opacity .55`, uppercase, `letter-spacing .05em` | a label above every turn |
| 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 |
| `pre` | `padding: 9px 11px`, `radius 8px`, `margin: 8px 0` | |

Two observations worth stating plainly, because they explain almost everything:

1. **Nothing constrains the measure.** Every other property is defensible; this one is simply absent.
2. **The chat inherits the workbench UI font size.** That is a reasonable default for a sidebar
widget and the wrong one for sustained reading. UI type is tuned for scanning labels at density;
prose wants a larger size and looser leading.

---

## 2. Decisions (with the reasoning, so they can be re-litigated)

### D1 — Constrain the measure. This is the single biggest lever.

`max-width` on the prose column, centred, with the container still full-bleed so cards, code blocks
and the composer keep their current behaviour.

**Target 680px** — measured, not estimated. An earlier draft of this section said "~72ch, which lands
near 640–700px". Both halves were wrong, and the correction is worth keeping because the mistake is
easy to repeat:

| measured in the shipped font at 13px | |
| --- | --- |
| `ch` (the width of `0`) | **8.13px** |
| real average prose character | **5.86px** |

`ch` is **39% wider than actual text**, so a `72ch` cap yields ~100 characters, not 72 — and 72 real
characters would be a 422px column, narrower than the sidebar. The print-typography range of 45–75
characters does not transfer to a technical chat: it assumes prose without identifiers, file paths or
code, and a 422px column would wrap every code block constantly.

680px is chosen against the measurement: **~116 characters at 13px**, down from 154 at editor width,
while staying wide enough that a fenced block is still readable. When T2 raises the prose size the
same cap tightens to ~108 characters, which is the right direction.

The elegant part: **in the sidebar this is a no-op.** The container is already narrower than the cap,
so nothing moves for existing users. It only takes effect in the editor tab, which is exactly the
surface that needs it.

### D2 — Prose diverges from the workbench UI font size. Chrome does not.

This is the real trade-off in the whole document, so it gets stated rather than smuggled in.

`--vscode-font-size` (typically 13px) is the size of menu labels and tree rows. Reading three
paragraphs of explanation at that size, at `line-height: 1.5`, is why the panel feels cramped next to
the reference.

**Decision:** message bodies get their own size (~14px) and leading (~1.65), expressed relative to a
single custom property. Everything else — the composer, buttons, session cards, the status row,
approval chips — keeps inheriting the workbench size, so the panel still belongs to the editor.

**The cost, honestly:** the chat will no longer match workbench chrome exactly. That is a real
inconsistency, and it is the deliberate price of the panel being a place you *read* rather than a
place you *operate*. D7 gives it an escape hatch.

### D3 — Vertical rhythm scales with the type, not with pixels.

Spacing is currently absolute (`8px`, `12px`), so raising the font size makes the page *tighter*
rather than proportionally airier. Every prose gap moves to `em`, anchored to the prose size:
paragraph `0.85em`, block gap `1.15em`, `#log` padding to ~`20px 24px` at editor width.

### D4 — Widen the heading scale so hierarchy survives.

`1.3 / 1.18 / 1.07` compresses three levels into a quarter of an em. Move to roughly
`1.45 / 1.25 / 1.1`, with more space *above* a heading than below it — the standard trick that makes
a heading belong to the section it introduces rather than float between two.

### D5 — Code surfaces get room, and stay theme-driven.

`pre` padding `9px 11px` → ~`12px 14px`, with the block's vertical margin tied to D3's rhythm.
Inline code keeps its neutral background; we do **not** start setting `color` (see §1 — we never
did, and hard-coding it would fight every theme).

Deliberately **not** in scope: a header row on code blocks (language label, copy button). That is a
component, not typography, and it belongs in its own slice.

### D6 — Soften the turn label; keep the user bubble.

The uppercase `LEVELCODE AI` label above every assistant turn adds a line of chrome to every message.
The reference distinguishes speakers by *treatment* — a tinted bubble for you, unadorned prose for
the assistant — rather than by labelling both. Keep the user bubble; make the assistant label quieter
or drop it where the previous turn already establishes who is speaking (`.msg.cont` already exists
for exactly this case).

### D7 — It stays hackable: two settings, no hard-coded values.

`levelcode.ai.chat.proseWidth` (px, `0` = unconstrained) and `levelcode.ai.chat.fontSize`
(`0` = follow the workbench). Both flow through CSS custom properties set on the container, so the
defaults are a starting point rather than a verdict — consistent with the editor's whole posture, and
the honest answer to anyone who preferred the old density.

---

## 3. Slices

Each ships independently and is visible on its own.

**T1 — measure + rhythm** *(S)*. D1 and D3. The largest perceptual change for the least code, and the
one that fixes the editor tab. Ships: a wrapper max-width, `em`-based prose spacing, wider `#log`
padding at editor width. **Exit:** a long answer in the editor tab holds **~116 characters** per line
(the 680px cap of D1, down from ~154), and the sidebar renders byte-identically to today — verified by
comparing computed styles against `develop` at 520px, not by eye.

**T2 — the reading type scale** *(S)*. D2 and D4. Ships: the prose size/leading custom properties and
the widened heading scale. **Exit:** h1/h2/h3 are distinguishable at a glance in a screenshot with no
selection, and every non-prose control still matches workbench chrome.

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

**T4 — speaker treatment** *(S)*. D6. Ships: the quieter label, verified against `.msg.cont`.

**T5 — the escape hatch** *(S)*. D7. Ships: the two settings and their plumbing.

Sequencing: T1 first and alone — it may turn out to be most of the perceived fix, and shipping it
by itself is the cheapest way to find out before spending effort on T2–T4.

---

## 4. Risks, honestly

- **Divergence from workbench chrome (D2).** The panel will read as slightly its own thing. Mitigated
by scoping the change to message bodies only, and by T5.
- **Sidebar users who liked the density.** At 380px the measure is a no-op, but the type-size change
is not. T5 is the answer, and T1 shipping alone gives us a read on whether T2 is even wanted.
- **Theme variance.** Inline-code colour already comes from the theme rather than from us (§1), so
any judgement about "busy" colour must be checked across the light, dark and high-contrast themes
the `webviewCss` suite already reasons about — not just the default.
- **~~No test currently guards the measure.~~ Closed by T1.** `webviewCss.test.js` now asserts the
column is bounded, that the cap covers every child of the log rather than just `.msg`, that it stays
an absolute length (not `ch`), and that the rhythm change stays width-gated. The risk was real: this
regression is invisible in a sidebar, so whoever refactors the log container would not see it break —
a user with the chat in an editor tab would.
- **Screenshots are not measurements.** Everything here is derived from our own CSS plus a
side-by-side comparison. The specific numbers (72ch, 14px, 1.65) are considered starting points to
be tuned against the real thing at real widths, not values copied from the reference — we cannot
read the reference's stylesheet.
33 changes: 32 additions & 1 deletion extensions/levelcode-ai/media/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,26 @@
}

/* ---- conversation log ---- */
#log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px; }
#log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px;
/* T1 (docs/CHAT-TYPOGRAPHY.md D1) — the reading measure. A custom property so T5 can hand it to a
setting later without touching the rules below. */
--prose-max: 680px; }
/* 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. */
#log > * { flex-shrink: 0; }
/* THE MEASURE. Nothing constrained the transcript's line length before this: the 12 max-width rules
in this file are all cards, dialogs and the empty state. In a 380px sidebar the container bounded
it, so it never looked wrong — but the chat can now open as an editor tab (AI: Open Chat in
Editor), and at 900px the same CSS produced ~154-character lines.
Measured in the shipped font at 13px: the average prose character is 5.86px, so 680px is ~116
characters — a real improvement while staying wide enough that a fenced code block does not wrap
constantly. NOT expressed in `ch`: `0` is 8.13px here, 39% wider than average text, so a `ch` cap
silently overshoots by about a third.
Applied to EVERY direct child, not just .msg, so messages, cards and the activity timeline share
one column instead of drifting apart at width. Below 680px this is inert — which is why the
sidebar is untouched. */
#log > * { width: 100%; max-width: var(--prose-max); margin-inline: auto; box-sizing: border-box; }
/* ---- per-response copy button (hover-reveal under a completed assistant message) ---- */
.msgactions { margin-top: 5px; display: flex; gap: 6px; }
.msgcopy {
Expand Down Expand Up @@ -111,6 +126,22 @@
.msg .body li > ul, .msg .body li > ol { margin: 2px 0; }
.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; }
.msg .body h1 { font-size: 1.3em; } .msg .body h2 { font-size: 1.18em; } .msg .body h3 { font-size: 1.07em; }

/* T1 (D3) — vertical rhythm, in `em` so it scales when T2 raises the prose size, and gated to the
width where the chat is actually being READ. The sidebar keeps today's density on purpose: this
slice's exit criterion is that a narrow panel renders unchanged, so a user who upgrades and never
opens the editor tab sees nothing move. `min-width` here is the WEBVIEW's own width — the iframe
is sized to the panel, which is why the existing #empty/dialog rules already use vw/vh. */
@media (min-width: 760px) {
#log { padding: 20px 24px; gap: 1.15em; }
.msg .body p { margin-bottom: 1em; }
.msg .body ul, .msg .body ol { margin-bottom: 1em; }
/* More space ABOVE a heading than below it, so it binds to the section it introduces rather than
floating between two. The old 12px/6px was already this shape; this keeps it and scales it. */
.msg .body h1, .msg .body h2, .msg .body h3,
.msg .body h4, .msg .body h5, .msg .body h6 { margin: 1.6em 0 .55em; }
.msg .body > :first-child { margin-top: 0; }
}
.msg .body h4, .msg .body h5, .msg .body h6 { font-size: 1em; }
.msg .body blockquote { margin: 0 0 8px; padding: 2px 0 2px 12px; border-left: 3px solid var(--border); color: var(--muted); }
.msg .body hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
Expand Down
51 changes: 51 additions & 0 deletions extensions/levelcode-ai/test/webviewCss.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,57 @@ test('SESSION CARD: the label-collapse threshold is above the width the labels a
}
});

test('TRANSCRIPT: the prose column is bounded, and every child shares the one measure', () => {
// docs/CHAT-TYPOGRAPHY.md T1. Nothing constrained line length before this — the other max-width
// rules in the file are cards, dialogs and the empty state. It went unnoticed for as long as the
// chat only ever 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.
//
// This guard exists because the regression is INVISIBLE in a sidebar. Whoever refactors the log
// container will not see it break; a user with the chat open in an editor tab will.
assert.match(css, /#log\s*\{[^}]*--prose-max:\s*\d+px/,
'the measure is no longer a custom property — T5 hands this to a setting');
assert.match(css, /#log > \*\s*\{[^}]*max-width:\s*var\(--prose-max\)/,
'the cap must apply to EVERY direct child, or cards and the timeline drift wider than the prose');
assert.match(css, /#log > \*\s*\{[^}]*margin-inline:\s*auto/,
'an uncentred capped column pins the transcript to the left edge at width');

// `ch` is a trap here and the reason the first draft of the doc was wrong: `0` measures 8.13px in
// this font against a 5.86px average prose character, so a ch-based cap overshoots by ~39%.
const capRule = /#log\s*\{[^}]*--prose-max:\s*([^;]+);/.exec(css);
assert.ok(capRule && /px$/.test(capRule[1].trim()),
'the measure should be an absolute length, not `ch` — see CHAT-TYPOGRAPHY.md D1');
});

test('TRANSCRIPT: the design doc quotes the SAME measure the stylesheet ships', () => {
// Review found the doc contradicting itself in three places: the cap was corrected in D1 after
// measuring, and the intro, the exit criterion and the risks list kept the pre-measurement numbers.
// Prose drifts from code silently, so the one number that matters is pinned to the code instead of
// to a proofread.
const doc = fs.readFileSync(path.join(__dirname, '..', '..', '..', 'docs', 'CHAT-TYPOGRAPHY.md'), 'utf8');
const shipped = /--prose-max:\s*(\d+)px/.exec(css);
assert.ok(shipped, 'the stylesheet no longer declares --prose-max');

assert.ok(doc.includes('**Target ' + shipped[1] + 'px**'),
'CHAT-TYPOGRAPHY.md D1 does not name the ' + shipped[1] + 'px cap the stylesheet actually ships');
// And no stray reference to the estimate that measuring disproved.
assert.ok(!/~72 characters per line/.test(doc),
'the doc still quotes the pre-measurement 72-character target somewhere');
});

test('TRANSCRIPT: the looser rhythm is gated to reading width, so the sidebar is untouched', () => {
// T1's exit criterion is that a narrow panel renders exactly as before — a user who upgrades and
// never opens the editor tab should see nothing move. Verified against develop's computed styles
// at 520px: padding, gap, paragraph and heading margins, line-height and font-size all identical.
const at = css.indexOf('@media (min-width: 760px)');
assert.ok(at > 0, 'the width gate is gone — the rhythm change would now hit the sidebar too');
const block = css.slice(at, css.indexOf('\n }', at));
assert.match(block, /#log \{[^}]*padding:/, 'the wider page margin belongs inside the gate');
assert.match(block, /margin-bottom:\s*1em/, 'prose spacing must be em-based so T2 scales it');
assert.match(block, /h1[\s\S]*margin:\s*1\.6em 0 \.55em/,
'headings need more space above than below, or they float between sections');
});

test('SESSION CARD: every action button keeps a label for pointers and screen readers', () => {
// The collapse above hides `.sesslbl` VISUALLY. If the buttons had no title/aria-label, an
// icon-only row in a narrow pane would be unusable rather than merely compact.
Expand Down