Skip to content

Commit a75414a

Browse files
authored
Merge pull request #72 from levelcodeai/feat/chat-typography-t2
feat(chat): prose gets its own type scale — T2 (+ T5's escape hatch)
2 parents 213ab91 + 2ac2110 commit a75414a

5 files changed

Lines changed: 224 additions & 12 deletions

File tree

docs/CHAT-TYPOGRAPHY.md

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,15 @@ This is the real trade-off in the whole document, so it gets stated rather than
7878
paragraphs of explanation at that size, at `line-height: 1.5`, is why the panel feels cramped next to
7979
the reference.
8080

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.
81+
**Decision:** message bodies get their own size and leading (~1.65), expressed through a single custom
82+
property. Everything else — the composer, buttons, session cards, the status row, approval chips —
83+
keeps inheriting the workbench size, so the panel still belongs to the editor.
84+
85+
The size is an **offset**, `calc(var(--vscode-font-size) + 1px)`, not a flat 14px. Review caught the
86+
reason: a flat value silently inverts the decision for anyone who has raised the editor's UI font for
87+
accessibility — an 18px workbench would read 14px prose inside 18px chrome, which is the divergence
88+
this decision argues for, pointing the wrong way. At the default 13px it resolves to the same 14px, so
89+
the change is invisible to everyone who has not touched it.
8490

8591
**The cost, honestly:** the chat will no longer match workbench chrome exactly. That is a real
8692
inconsistency, and it is the deliberate price of the panel being a place you *read* rather than a
@@ -117,10 +123,22 @@ for exactly this case).
117123

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

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.
126+
`levelcode.ai.chat.proseWidth` (px) and `levelcode.ai.chat.fontSize` (px). Both flow through the CSS
127+
custom properties above, set on the container, so the defaults are a starting point rather than a
128+
verdict — consistent with the editor's whole posture, and the honest answer to anyone who preferred
129+
the old density.
130+
131+
**`0` means "leave the stylesheet alone" for both**, and nothing more. The first draft of this line
132+
claimed `0` = *unconstrained* for the width and `0` = *follow the workbench* for the size; neither was
133+
what the code did, and review caught both. The width's default is a 680px measure, not the absence of
134+
one — the way to widen it is a large number. The size's default now does track the workbench, but by
135+
the D2 offset, which is a property of the stylesheet rather than of the sentinel.
136+
137+
Both are **clamped at the host boundary** (`clampSetting`, 8–24 and 320–2000). `minimum`/`maximum` in
138+
the contribution schema only drive the settings *editor*; a hand-edited `settings.json` reaches
139+
`getConfiguration()` unchecked, and these values land directly in CSS. `proseWidth: 1` is a one-pixel
140+
transcript — a panel with nothing left on screen to open settings with, whose only exit is finding the
141+
JSON file again. `webviewCss.test.js` pins the clamp to the schema so the two cannot drift.
124142

125143
---
126144

@@ -142,7 +160,10 @@ selection, and every non-prose control still matches workbench chrome.
142160

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

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

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

extensions/levelcode-ai/extension.js

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2100,17 +2100,45 @@ async function browseProviderModels(cfg, providerId) {
21002100
sendConfigToWebview();
21012101
}
21022102

2103+
// A settings number on its way into a CSS custom property.
2104+
//
2105+
// `minimum`/`maximum` in the contribution schema only drive the settings *editor*: it draws a squiggle
2106+
// and saves the value anyway, and a hand-edited settings.json, a synced profile or a bad merge never
2107+
// passes through that UI at all. Whatever is in the file is what `getConfiguration()` returns, and here
2108+
// it lands directly in CSS — where `chat.proseWidth: 1` is a one-pixel transcript and
2109+
// `chat.fontSize: 0.5` is a blank panel. Neither leaves anything on screen to open settings with, so
2110+
// the way out is to find the JSON file again; clamping at the boundary is cheaper than that.
2111+
//
2112+
// Out-of-range is pulled INTO the range rather than rejected — someone who asks for a 200px measure
2113+
// wants it narrow, so give them the narrowest readable one instead of silently ignoring them. Only 0,
2114+
// a negative, or a non-number means "leave the stylesheet's own value alone".
2115+
function clampSetting(raw, lo, hi) {
2116+
const n = Number(raw);
2117+
if (!Number.isFinite(n) || n <= 0) { return 0; }
2118+
return Math.min(Math.max(n, lo), hi);
2119+
}
2120+
21032121
function sendConfigToWebview() {
21042122
const cfg = aiConfig();
21052123
// Gateway mode (signed in — the gateway only routes when authenticated): the footer reflects the
21062124
// LevelCode Cloud plan model (free → gpt-oss, paid → Kimi), not the BYOK provider — with a `paid` flag
21072125
// so the webview can show the free-tier Upgrade CTA.
21082126
// Calm transcript: whether the webview folds consecutive agent actions into one collapsible group.
21092127
const groupActivity = cfg.get('chat.groupActivity', true) !== false;
2128+
// T2/T5 (docs/CHAT-TYPOGRAPHY.md D2, D7). Prose gets its own size because `--vscode-font-size` is the
2129+
// size of menu labels and tree rows — right for chrome, wrong for reading three paragraphs. Both are
2130+
// escape hatches by design, so anyone who preferred the old density has a one-setting way back
2131+
// rather than an argument.
2132+
//
2133+
// 0 means "leave the stylesheet alone" for both — the size then tracks the workbench
2134+
// (`--vscode-font-size` + 1px) and the measure stays at its 680px default. It does NOT mean
2135+
// "unconstrained": the way to widen the measure is a large number, not 0.
2136+
const proseSize = clampSetting(cfg.get('chat.fontSize', 0), 8, 24);
2137+
const proseWidth = clampSetting(cfg.get('chat.proseWidth', 0), 320, 2000);
21102138
if (providerMode() === 'gateway' && cloudSignedIn) {
21112139
const model = gatewayModel();
21122140
post({
2113-
type: 'config', provider: 'gateway', model: gatewayModelLabel(model), modelId: model,
2141+
type: 'config', provider: 'gateway', proseSize, proseWidth, model: gatewayModelLabel(model), modelId: model,
21142142
providerLabel: 'LevelCode Cloud', contextLimit: contextLimitFor('openai', capsModel(model)),
21152143
gateway: true, plan: cloudPlanName() || 'Free', paid: isPaidCloudPlan(cloudPlanName()),
21162144
groupActivity: groupActivity
@@ -2120,7 +2148,7 @@ function sendConfigToWebview() {
21202148
const providerId = currentProviderId();
21212149
const p = providers.getProvider(providerId) || providers.getProvider('claude');
21222150
// Carry the model's context window so the footer meter updates the moment the model changes.
2123-
post({ type: 'config', provider: providerId, model: activeModel(cfg, providerId), providerLabel: p.label, contextLimit: currentContextLimit(), groupActivity: groupActivity });
2151+
post({ type: 'config', provider: providerId, proseSize, proseWidth, model: activeModel(cfg, providerId), providerLabel: p.label, contextLimit: currentContextLimit(), groupActivity: groupActivity });
21242152
}
21252153

21262154
class ChatViewProvider {

extensions/levelcode-ai/media/chat.html

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,18 @@
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+
An OFFSET, not a flat 14px, so prose TRACKS the workbench instead of pinning against it. A flat
37+
value quietly inverts for anyone who raises the editor's UI font for accessibility: at a 18px
38+
workbench they would read 14px prose inside 18px chrome — the divergence D2 argues for, pointing
39+
the wrong way. At the default 13px this still resolves to 14px, so nothing moves for anyone who
40+
has not changed it. */
41+
--prose-size: calc(var(--vscode-font-size, 13px) + 1px);
42+
--prose-leading: 1.65; }
3243
/* Log is a flex column → children default to flex-shrink:1. Any child with overflow:hidden (e.g. the
3344
approval/edit cards) then gets an auto min-size of 0 and the flex algorithm crushes it to an invisible
3445
line once the log overflows. Pin every child to its natural height; the log itself scrolls instead. */
@@ -110,6 +121,10 @@
110121
#jumpLatest.show { display: inline-flex; }
111122
#jumpLatest:hover { opacity: 1; background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.25)); }
112123
.msg { white-space: normal; word-wrap: break-word; line-height: 1.5; }
124+
/* Scoped to .body, NOT .msg: the role label, the copy button and the checkpoint control are chrome
125+
and stay at the workbench size. This is also what makes T1's em-based rhythm scale — those margins
126+
resolve against THIS size, so raising the type opens the spacing with it. */
127+
.msg .body { font-size: var(--prose-size); line-height: var(--prose-leading); }
113128
.msg .role { font-size: 11px; opacity: .55; margin-bottom: 4px; text-transform: uppercase; letter-spacing: .05em; }
114129
/* a quiet remind-me line: a soft accent rule, small label, the words in a calm serif italic */
115130
.rme { margin: 0 4px; padding: 10px 0 10px 14px; border-left: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); }
@@ -125,7 +140,9 @@
125140
.msg .body li { margin: 2px 0; }
126141
.msg .body li > ul, .msg .body li > ol { margin: 2px 0; }
127142
.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; }
143+
/* 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
144+
hierarchy were indistinguishable without selecting the text. */
145+
.msg .body h1 { font-size: 1.45em; } .msg .body h2 { font-size: 1.25em; } .msg .body h3 { font-size: 1.1em; }
129146

130147
/* T1 (D3) — vertical rhythm, in `em` so it scales when T2 raises the prose size, and gated to the
131148
width where the chat is actually being READ. The sidebar keeps today's density on purpose: this
@@ -3506,6 +3523,13 @@
35063523
window.addEventListener('message', ev => {
35073524
const m = ev.data;
35083525
if (m.type === 'config'){
3526+
// D7 — the escape hatch. 0 means "follow the default": clearing the property lets the stylesheet
3527+
// value win again, rather than pinning it to whatever the default happened to be at the time.
3528+
const rootLog = document.getElementById('log');
3529+
if (rootLog){
3530+
rootLog.style.setProperty('--prose-size', m.proseSize ? m.proseSize + 'px' : '');
3531+
rootLog.style.setProperty('--prose-max', m.proseWidth ? m.proseWidth + 'px' : '');
3532+
}
35093533
const label = m.provider === 'claude' ? prettyModel(m.model) : m.model;
35103534
const mEl = document.getElementById('model');
35113535
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
@@ -375,6 +375,20 @@
375375
"default": false,
376376
"description": "Include a list of all project file paths with each chat message, so the AI knows the repo structure. Uses more tokens."
377377
},
378+
"levelcode.ai.chat.fontSize": {
379+
"type": "number",
380+
"default": 0,
381+
"minimum": 0,
382+
"maximum": 24,
383+
"markdownDescription": "Font size for chat **prose** (message bodies), in pixels. `0` tracks the editor's UI font size, one step larger for reading — a 13px workbench gives 14px prose.\n\nThe workbench size is tuned for menu labels and tree rows; message bodies read a step above it so a long answer is comfortable. Controls, cards and the composer always match the workbench exactly. Values are clamped to 8–24."
384+
},
385+
"levelcode.ai.chat.proseWidth": {
386+
"type": "number",
387+
"default": 0,
388+
"minimum": 0,
389+
"maximum": 2000,
390+
"markdownDescription": "Maximum width of the chat transcript, in pixels. `0` uses the default reading measure (680px) — it does **not** mean unconstrained.\n\nOnly has an effect when the panel is wider than the measure: in a narrow sidebar the container already bounds the line length. Raise it to let the transcript spread across a wide editor tab. Values are clamped to 320–2000."
391+
},
378392
"levelcode.ai.chat.groupActivity": {
379393
"type": "boolean",
380394
"default": true,

0 commit comments

Comments
 (0)