Skip to content

Commit 8f7e883

Browse files
committed
feat(chat): prose gets its own type scale — T2 (+ T5's escape hatch)
The chat inherited `--vscode-font-size`: the size of menu labels and tree rows. Right for a sidebar widget, wrong for reading three paragraphs, and the reason the panel felt cramped beside the reference. Message bodies now have their own size and leading (14px / 1.65) behind custom properties. Scoped to `.msg .body`, NOT `.msg` — on `.msg` it would drag the role label, the copy button and the checkpoint control up with it and the panel would stop matching the editor. A test pins that scoping, because it is the whole safety property of this slice. The heading scale widens from 1.3/1.18/1.07 to 1.45/1.25/1.1. The old scale put 0.11em between h2 and h3 — 1.4px at 13px — so three levels of hierarchy were indistinguishable without selecting the text. Measured after: 20.3 / 17.5 / 15.4 against 14px body, ~3px steps instead of 1.4px. T1's em-based rhythm now scales with it, which was the point of making it em in the first place: the paragraph gap resolves against the prose size, so it reads 8px in the sidebar and 14px at reading width without a second rule. The measure holds at ~107 characters (was ~115 at 13px, 154 before T1) — the cap tightening as the type grows, as predicted. **T5 is folded in rather than sequenced after.** Ordering it last was a mistake: T2 is the one slice that changes what every existing user sees, and shipping a divisive change with no way back is worse than not shipping it. `levelcode.ai.chat.fontSize` and `chat.proseWidth`, both defaulting to 0 meaning "follow the default" — and 0 CLEARS the custom property so the stylesheet wins again, rather than pinning whatever today's default happens to be. The doc records the reordering and why. Four guards, verified non-vacuous: scope the size to .msg (16/20), revert the heading scale (17/20), make the role label relative (16/20), hard-code the cleared fallback (18/20), drop one of the two config payloads (18/20). 33 suites green.
1 parent 2d4964d commit 8f7e883

5 files changed

Lines changed: 102 additions & 5 deletions

File tree

docs/CHAT-TYPOGRAPHY.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,10 @@ selection, and every non-prose control still matches workbench chrome.
142142

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

145-
**T5 — the escape hatch** *(S)*. D7. Ships: the two settings and their plumbing.
145+
**T5 — the escape hatch** *(S)*. D7. **Folded into T2 and shipped with it.** Sequencing it last was a
146+
mistake: T2 is the one slice that changes what every existing user sees, and shipping a divisive
147+
change with no way back is worse than not shipping it. The plumbing is also shared — once one custom
148+
property reaches the webview from settings, the second is a line — so splitting them bought nothing.
146149

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

extensions/levelcode-ai/extension.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2090,10 +2090,16 @@ function sendConfigToWebview() {
20902090
// so the webview can show the free-tier Upgrade CTA.
20912091
// Calm transcript: whether the webview folds consecutive agent actions into one collapsible group.
20922092
const groupActivity = cfg.get('chat.groupActivity', true) !== false;
2093+
// T2/T5 (docs/CHAT-TYPOGRAPHY.md D2, D7). Prose gets its own size because `--vscode-font-size` is the
2094+
// size of menu labels and tree rows — right for chrome, wrong for reading three paragraphs. Both are
2095+
// escape hatches by design: 0 means "follow the workbench" / "do not constrain", so anyone who
2096+
// preferred the old density has a one-setting way back rather than an argument.
2097+
const proseSize = Number(cfg.get('chat.fontSize', 0)) || 0;
2098+
const proseWidth = Number(cfg.get('chat.proseWidth', 0)) || 0;
20932099
if (providerMode() === 'gateway' && cloudSignedIn) {
20942100
const model = gatewayModel();
20952101
post({
2096-
type: 'config', provider: 'gateway', model: gatewayModelLabel(model), modelId: model,
2102+
type: 'config', provider: 'gateway', proseSize, proseWidth, model: gatewayModelLabel(model), modelId: model,
20972103
providerLabel: 'LevelCode Cloud', contextLimit: contextLimitFor('openai', capsModel(model)),
20982104
gateway: true, plan: cloudPlanName() || 'Free', paid: isPaidCloudPlan(cloudPlanName()),
20992105
groupActivity: groupActivity
@@ -2103,7 +2109,7 @@ function sendConfigToWebview() {
21032109
const providerId = currentProviderId();
21042110
const p = providers.getProvider(providerId) || providers.getProvider('claude');
21052111
// Carry the model's context window so the footer meter updates the moment the model changes.
2106-
post({ type: 'config', provider: providerId, model: activeModel(cfg, providerId), providerLabel: p.label, contextLimit: currentContextLimit(), groupActivity: groupActivity });
2112+
post({ type: 'config', provider: providerId, proseSize, proseWidth, model: activeModel(cfg, providerId), providerLabel: p.label, contextLimit: currentContextLimit(), groupActivity: groupActivity });
21072113
}
21082114

21092115
class ChatViewProvider {

extensions/levelcode-ai/media/chat.html

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@
2828
#log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px;
2929
/* T1 (docs/CHAT-TYPOGRAPHY.md D1) — the reading measure. A custom property so T5 can hand it to a
3030
setting later without touching the rules below. */
31-
--prose-max: 680px; }
31+
--prose-max: 680px;
32+
/* T2 (D2) — the READING type. `--vscode-font-size` is the size of menu labels and tree rows: right
33+
for chrome, wrong for three paragraphs of explanation. Message bodies alone get their own size
34+
and leading; every control around them keeps inheriting the workbench, so the panel still
35+
belongs to the editor. Both are overridable at runtime by the settings (D7). */
36+
--prose-size: 14px;
37+
--prose-leading: 1.65; }
3238
/* Log is a flex column → children default to flex-shrink:1. Any child with overflow:hidden (e.g. the
3339
approval/edit cards) then gets an auto min-size of 0 and the flex algorithm crushes it to an invisible
3440
line once the log overflows. Pin every child to its natural height; the log itself scrolls instead. */
@@ -110,6 +116,10 @@
110116
#jumpLatest.show { display: inline-flex; }
111117
#jumpLatest:hover { opacity: 1; background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.25)); }
112118
.msg { white-space: normal; word-wrap: break-word; line-height: 1.5; }
119+
/* Scoped to .body, NOT .msg: the role label, the copy button and the checkpoint control are chrome
120+
and stay at the workbench size. This is also what makes T1's em-based rhythm scale — those margins
121+
resolve against THIS size, so raising the type opens the spacing with it. */
122+
.msg .body { font-size: var(--prose-size); line-height: var(--prose-leading); }
113123
.msg .role { font-size: 11px; opacity: .55; margin-bottom: 4px; text-transform: uppercase; letter-spacing: .05em; }
114124
/* a quiet remind-me line: a soft accent rule, small label, the words in a calm serif italic */
115125
.rme { margin: 0 4px; padding: 10px 0 10px 14px; border-left: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); }
@@ -125,7 +135,9 @@
125135
.msg .body li { margin: 2px 0; }
126136
.msg .body li > ul, .msg .body li > ol { margin: 2px 0; }
127137
.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; }
128-
.msg .body h1 { font-size: 1.3em; } .msg .body h2 { font-size: 1.18em; } .msg .body h3 { font-size: 1.07em; }
138+
/* D4 — the old 1.3/1.18/1.07 put 0.11em between h2 and h3: at 13px that is 1.4px, so three levels of
139+
hierarchy were indistinguishable without selecting the text. */
140+
.msg .body h1 { font-size: 1.45em; } .msg .body h2 { font-size: 1.25em; } .msg .body h3 { font-size: 1.1em; }
129141

130142
/* T1 (D3) — vertical rhythm, in `em` so it scales when T2 raises the prose size, and gated to the
131143
width where the chat is actually being READ. The sidebar keeps today's density on purpose: this
@@ -3502,6 +3514,13 @@
35023514
window.addEventListener('message', ev => {
35033515
const m = ev.data;
35043516
if (m.type === 'config'){
3517+
// D7 — the escape hatch. 0 means "follow the default": clearing the property lets the stylesheet
3518+
// value win again, rather than pinning it to whatever the default happened to be at the time.
3519+
const rootLog = document.getElementById('log');
3520+
if (rootLog){
3521+
rootLog.style.setProperty('--prose-size', m.proseSize ? m.proseSize + 'px' : '');
3522+
rootLog.style.setProperty('--prose-max', m.proseWidth ? m.proseWidth + 'px' : '');
3523+
}
35053524
const label = m.provider === 'claude' ? prettyModel(m.model) : m.model;
35063525
const mEl = document.getElementById('model');
35073526
mEl.innerHTML = esc(label) + ' <span class="caret">▾</span>';

extensions/levelcode-ai/package.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,20 @@
364364
"default": false,
365365
"description": "Include a list of all project file paths with each chat message, so the AI knows the repo structure. Uses more tokens."
366366
},
367+
"levelcode.ai.chat.fontSize": {
368+
"type": "number",
369+
"default": 0,
370+
"minimum": 0,
371+
"maximum": 24,
372+
"markdownDescription": "Font size for chat **prose** (message bodies), in pixels. `0` follows the editor's UI font size.\n\nThe workbench size is tuned for menu labels and tree rows; message bodies default to a slightly larger size so a long answer reads comfortably. Controls, cards and the composer always follow the workbench."
373+
},
374+
"levelcode.ai.chat.proseWidth": {
375+
"type": "number",
376+
"default": 0,
377+
"minimum": 0,
378+
"maximum": 2000,
379+
"markdownDescription": "Maximum width of the chat transcript, in pixels. `0` uses the default reading measure (680px).\n\nOnly has an effect when the panel is wider than the measure — in a narrow sidebar the container already bounds the line length. Set a large value to let the transcript fill the full width."
380+
},
367381
"levelcode.ai.chat.groupActivity": {
368382
"type": "boolean",
369383
"default": true,

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,61 @@ test('TRANSCRIPT: the looser rhythm is gated to reading width, so the sidebar is
297297
'headings need more space above than below, or they float between sections');
298298
});
299299

300+
test('TRANSCRIPT: prose has its own type, and chrome does not follow it', () => {
301+
// docs/CHAT-TYPOGRAPHY.md D2 — the deliberate divergence, and the whole risk of T2. The workbench
302+
// size is tuned for menu labels; message bodies get their own. The scoping is the entire safety
303+
// property: applied to `.msg` instead of `.msg .body` it would drag the role label, the copy
304+
// button and the checkpoint control up with it, and the panel would stop matching the editor.
305+
assert.match(css, /#log\s*\{[^}]*--prose-size:\s*\d+px/, 'the prose size is no longer a custom property');
306+
assert.match(css, /#log\s*\{[^}]*--prose-leading:\s*[\d.]+/, 'the prose leading is no longer a custom property');
307+
assert.match(css, /\.msg \.body \{[^}]*font-size:\s*var\(--prose-size\)[^}]*line-height:\s*var\(--prose-leading\)/,
308+
'prose type must be set on .msg .body');
309+
310+
const scoped = /\.msg \.body \{[^}]*font-size:\s*var\(--prose-size\)/.test(css);
311+
const leaked = /\.msg \{[^}]*font-size:\s*var\(--prose-size\)/.test(css);
312+
assert.ok(scoped && !leaked, 'the prose size leaked onto .msg — chrome inside a turn would scale with it');
313+
assert.match(css, /\.msg \.role \{[^}]*font-size:\s*11px/,
314+
'the turn label must keep an absolute size, or it grows with the prose it is labelling');
315+
});
316+
317+
test('TRANSCRIPT: the heading scale has steps you can actually see', () => {
318+
// The old 1.3/1.18/1.07 put 0.11em between h2 and h3 — 1.4px at 13px, i.e. three levels of
319+
// hierarchy that were indistinguishable without selecting the text.
320+
const sizes = ['h1', 'h2', 'h3'].map((h) => {
321+
const m = new RegExp('\\.msg \\.body ' + h + ' \\{ font-size: ([\\d.]+)em').exec(css);
322+
assert.ok(m, 'no font-size for ' + h);
323+
return Number(m[1]);
324+
});
325+
assert.ok(sizes[0] > sizes[1] && sizes[1] > sizes[2], 'the scale must descend: ' + sizes.join(' > '));
326+
for (let i = 0; i < 2; i++) {
327+
assert.ok(sizes[i] - sizes[i + 1] >= 0.13,
328+
'h' + (i + 1) + '→h' + (i + 2) + ' differ by ' + (sizes[i] - sizes[i + 1]).toFixed(2)
329+
+ 'em; below ~0.13em the levels are indistinguishable at this type size');
330+
}
331+
});
332+
333+
test('TRANSCRIPT: both type settings exist and reach the stylesheet (D7)', () => {
334+
// Shipping a divisive change with no way back is worse than not shipping it. T5's escape hatch is
335+
// folded into T2 for exactly that reason — see the PR.
336+
const pkg2 = require('../package.json');
337+
const props = pkg2.contributes.configuration.properties;
338+
for (const key of ['levelcode.ai.chat.fontSize', 'levelcode.ai.chat.proseWidth']) {
339+
assert.ok(props[key], key + ' is not declared — the change would be irreversible for a user');
340+
assert.strictEqual(props[key].default, 0, key + ' must default to 0, meaning "follow the default"');
341+
}
342+
const ext2 = fs.readFileSync(path.join(__dirname, '..', 'extension.js'), 'utf8');
343+
assert.match(ext2, /cfg\.get\('chat\.fontSize', 0\)/, 'the host never reads chat.fontSize');
344+
assert.match(ext2, /cfg\.get\('chat\.proseWidth', 0\)/, 'the host never reads chat.proseWidth');
345+
assert.strictEqual((ext2.match(/proseSize, proseWidth,/g) || []).length, 2,
346+
'both config payloads must carry them, or the setting works in one provider mode and not the other');
347+
348+
// 0 must CLEAR the property so the stylesheet wins again, rather than pinning today's default.
349+
assert.match(html, /setProperty\('--prose-size', m\.proseSize \? m\.proseSize \+ 'px' : ''\)/,
350+
'0 must clear --prose-size, not write a hard-coded fallback');
351+
assert.match(html, /setProperty\('--prose-max', m\.proseWidth \? m\.proseWidth \+ 'px' : ''\)/,
352+
'0 must clear --prose-max, not write a hard-coded fallback');
353+
});
354+
300355
test('SESSION CARD: every action button keeps a label for pointers and screen readers', () => {
301356
// The collapse above hides `.sesslbl` VISUALLY. If the buttons had no title/aria-label, an
302357
// icon-only row in a narrow pane would be unusable rather than merely compact.

0 commit comments

Comments
 (0)