返回 DeepSeek-Reasonix
typography-overflow-contract.test.ts
根目录 / desktop / frontend / src / __tests__ / typography-overflow-contract.test.ts
1 // Run: tsx src/__tests__/typography-overflow-contract.test.ts
2
3 import { readFileSync } from "node:fs";
4 import { dirname, resolve } from "node:path";
5 import { fileURLToPath } from "node:url";
6 import { TEXT_SIZES } from "../lib/textSize";
7
8 const testDir = dirname(fileURLToPath(import.meta.url));
9 const styles = [
10 readFileSync(resolve(testDir, "../components/ProviderCatalogPicker.css"), "utf8"),
11 readFileSync(resolve(testDir, "../styles.css"), "utf8"),
12 readFileSync(resolve(testDir, "../components/CompactRatioSettings.css"), "utf8"),
13 readFileSync(resolve(testDir, "../components/ChatTranscript.css"), "utf8"),
14 ].join("\n").replace(/\/\*[\s\S]*?\*\//g, "");
15
16 let passed = 0;
17 let failed = 0;
18
19 function ok(value: unknown, label: string) {
20 if (value) {
21 process.stdout.write(` PASS ${label}\n`);
22 passed += 1;
23 } else {
24 process.stdout.write(` FAIL ${label}\n`);
25 failed += 1;
26 }
27 }
28
29 function eq(a: unknown, b: unknown, label: string) {
30 if (a === b) {
31 process.stdout.write(` PASS ${label}\n`);
32 passed += 1;
33 } else {
34 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
35 failed += 1;
36 }
37 }
38
39 function matchingBlocks(selector: string): string[] {
40 const blocks: string[] = [];
41 const rule = /([^{}]+)\{([^{}]*)\}/g;
42 let match: RegExpExecArray | null;
43 while ((match = rule.exec(styles)) !== null) {
44 const selectors = match[1].split(",").map((part) => part.trim());
45 if (selectors.includes(selector)) blocks.push(match[2]);
46 }
47 return blocks;
48 }
49
50 function finalDeclaration(selector: string, property: string): string | undefined {
51 let value: string | undefined;
52 for (const block of matchingBlocks(selector)) {
53 const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g");
54 let match: RegExpExecArray | null;
55 while ((match = declaration.exec(block)) !== null) {
56 value = match[1].trim();
57 }
58 }
59 return value;
60 }
61
62 function hasDeclaration(selector: string, property: string, expected: string): boolean {
63 return matchingBlocks(selector).some((block) => {
64 const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g");
65 let match: RegExpExecArray | null;
66 while ((match = declaration.exec(block)) !== null) {
67 if (match[1].trim() === expected) return true;
68 }
69 return false;
70 });
71 }
72
73 function clipsSingleLine(selector: string) {
74 eq(finalDeclaration(selector, "overflow"), "hidden", `${selector} clips long text`);
75 eq(finalDeclaration(selector, "text-overflow"), "ellipsis", `${selector} uses ellipsis`);
76 eq(finalDeclaration(selector, "white-space"), "nowrap", `${selector} stays on one line`);
77 }
78
79 console.log("\ntypography overflow contract");
80
81 eq(finalDeclaration(".transcript-navigation-content", "min-width"), "0", "chat content can shrink beside the dock launcher");
82 eq(finalDeclaration(".md", "overflow-wrap"), "anywhere", "completed markdown wraps the same long tokens as the stream tail");
83 eq(finalDeclaration(".reasoning__body", "overflow-wrap"), "anywhere", "reasoning wraps long tokens");
84 eq(finalDeclaration(".turn-collapse__inline-reasoning", "overflow-wrap"), "anywhere", "inline reasoning wraps long tokens");
85 for (const selector of [".md pre", ".md pre code", ".md table", ".md .katex"]) {
86 eq(finalDeclaration(selector, "overflow-wrap"), "normal", selector + " retains its own wrapping contract");
87 }
88 eq(finalDeclaration(".tool__command .code", "white-space"), "pre-wrap", "only complete commands opt into code wrapping");
89 eq(finalDeclaration(".tool__command .code", "overflow-wrap"), "anywhere", "commands wrap continuous tokens");
90 eq(finalDeclaration(".tool__command .code", "max-height"), "240px", "command loading and loaded views share the height cap");
91
92 eq(
93 JSON.stringify(TEXT_SIZES),
94 JSON.stringify(["small", "default", "large", "xlarge", "xxlarge"]),
95 "text-size presets include the large accessibility step",
96 );
97 eq(finalDeclaration(":root", "--sans"), "var(--font-ui)", "legacy sans alias stays synced with UI font");
98 eq(finalDeclaration(':root[data-text-size="xxlarge"]', "--font-scale"), "1.32", "xxlarge has a real scale bump");
99 ok(
100 (finalDeclaration(":root", "--statusbar-dock-height") ?? "").includes("var(--font-scale)"),
101 "status bar dock height scales with interface text size",
102 );
103 ok(
104 hasDeclaration(".layout", "--statusbar-height", "var(--statusbar-dock-height)"),
105 "layout reserves scaled status bar height",
106 );
107 eq(
108 finalDeclaration(".app", "height"),
109 "var(--app-viewport-height, 100%)",
110 "app height follows the live viewport height variable",
111 );
112 eq(finalDeclaration(".transcript--empty", "overflow-y"), "auto", "empty transcript can scroll instead of clipping");
113 eq(finalDeclaration(".welcome", "overflow"), "visible", "welcome empty state is not clipped by its own box");
114 ok(
115 /\.md\s*>\s*:where\([^)]*p[^)]*ul[^)]*ol[^)]*\)\s*\{[^}]*content-visibility:\s*auto;[^}]*contain-intrinsic-size:\s*auto 72px;/.test(styles),
116 "non-transcript markdown still culls offscreen blocks with a 72px placeholder",
117 );
118 ok(hasDeclaration(".chat-transcript .md > *", "content-visibility", "visible"), "natural chat uses real Markdown geometry");
119 eq(finalDeclaration(".chat-transcript .chat-column", "max-width"), "800px", "chat has one readable column");
120 eq(finalDeclaration(".chat-transcript .chat-surface > .chat-flow-scroll.transcript", "overflow-y"), "auto", "chat uses native vertical scrolling");
121 ok(hasDeclaration(".chat-transcript .chat-surface > .chat-flow-scroll.transcript", "padding", "24px"), "normal chat inset is 24px");
122 ok(hasDeclaration(".chat-transcript .chat-surface > .chat-flow-scroll.transcript", "padding", "24px 16px 16px"), "narrow chat inset is 16px");
123 eq(finalDeclaration(".chat-details", "width"), "100%", "narrow details cover the chat surface");
124 ok(
125 hasDeclaration(".transcript--empty > .welcome", "margin-block", "auto"),
126 "empty-state auto margins apply only to the welcome content",
127 );
128 ok(
129 finalDeclaration(".transcript--empty > *", "margin-block") === undefined,
130 "empty-state generic children do not receive auto margins",
131 );
132 eq(
133 finalDeclaration(":root[data-theme-style] .statusbar", "height"),
134 "var(--statusbar-dock-height)",
135 "fixed status bar height follows the scaled dock token",
136 );
137 eq(
138 finalDeclaration(":root[data-theme-style] .statusbar", "min-height"),
139 "var(--statusbar-dock-height)",
140 "status bar min-height follows the scaled dock token",
141 );
142 eq(finalDeclaration(".provider-catalog__brands", "overflow-y"), "auto", "brand list scrolls independently");
143 eq(finalDeclaration(".provider-catalog__detail", "min-width"), "0", "provider detail can shrink within its column");
144 eq(finalDeclaration(".provider-catalog__models", "overflow-wrap"), "anywhere", "long model IDs wrap within the detail");
145
146 eq(finalDeclaration(".provider-model-draft__list", "grid-auto-rows"), "min-content", "provider model rows grow with their content");
147 eq(finalDeclaration(".provider-model-draft__option", "min-height"), undefined, "provider model cards do not force undersized rows");
148 eq(finalDeclaration(".provider-model-draft__option", "overflow"), "hidden", "provider model cards contain overflowing controls");
149 eq(finalDeclaration(".compact-ratio-choice-list", "width"), "100%", "compaction choices fit the settings control width");
150 eq(finalDeclaration(".compact-ratio-choice__row", "display"), "grid", "compaction rows align radio, label, percentage and explanation");
151 eq(finalDeclaration(".compact-ratio-choice__row", "min-height"), "40px", "compaction rows retain a stable click target");
152 eq(finalDeclaration(".compact-ratio-choice__effect", "min-width"), "0", "compaction explanations can shrink and wrap");
153 eq(finalDeclaration(".compact-ratio-choice__inline-input > span", "flex"), "0 0 auto", "percentage suffix remains visible beside the editable value");
154
155 eq(finalDeclaration(".statusbar", "white-space"), "nowrap", "status bar keeps metrics on one row");
156 eq(finalDeclaration(".statusbar", "overflow-y"), "hidden", "status bar hides vertical overflow");
157 clipsSingleLine(".statusbar__model");
158
159 for (const selector of [
160 ".sidebar-im__summary-label",
161 ".sidebar-im__summary-status",
162 ".workbench-dock__tab-label",
163 ".workspace-files__scope-title",
164 ".workspace-files__scope-meta",
165 ".context-panel__section-head span",
166 ".context-panel__metric span",
167 ".context-panel__metric strong",
168 ".app--creation .context-panel__mini-stat-label",
169 ".app--creation .context-panel__mini-stat strong",
170 ".topbar__model",
171 ".composer-modebar__item span",
172 ".composer-more-menu__item span",
173 ]) {
174 clipsSingleLine(selector);
175 }
176
177 eq(
178 finalDeclaration(".app--creation .layout.layout--workspace-open", "transition"),
179 "grid-template-columns 0s, min-width 0s",
180 "creation dock skips zero-width grid interpolation on open",
181 );
182 eq(
183 finalDeclaration(".app--creation .context-panel__usage", "animation"),
184 "none",
185 "creation overview usage card disables inherited entrance animation",
186 );
187 ok(
188 finalDeclaration(".app--creation .context-panel__mini-stat", "justify-content") !== "space-between",
189 "creation overview rows avoid edge-pinned value alignment",
190 );
191 ok(
192 finalDeclaration(".app--creation .context-panel__mini-stat", "grid-template-columns") !== "minmax(0, 1fr) auto",
193 "creation overview rows avoid the spacer grid that pushes values to the edge",
194 );
195 ok(
196 finalDeclaration(".app--creation .context-panel__mini-stat strong", "max-width") !== "14ch",
197 "creation overview values are not capped to a fixed 14ch width",
198 );
199 eq(finalDeclaration(".context-panel__mini-stat-head", "display"), "flex", "session metric labels and rate badges share a compact header row");
200 eq(finalDeclaration(".context-panel__rate-band", "flex"), "0 0 auto", "rate badges remain visible without truncating the amount row");
201 eq(finalDeclaration(".context-panel__rate-band", "white-space"), "nowrap", "rate badge labels stay intact");
202
203 eq(finalDeclaration(".composer-modebar", "overflow"), "hidden", "chat mode switcher contains enlarged labels");
204 eq(finalDeclaration(".composer-meta__control--intent", "max-width"), "72px", "task method selector keeps its current state visible at narrow widths");
205 eq(finalDeclaration(".composer-task-mode-trigger__value", "text-overflow"), "ellipsis", "task method selector truncates its value only when constrained");
206 eq(finalDeclaration(".composer-meta .modelsw__trigger", "font-weight"), "var(--composer-control-font-weight)", "model selector uses the shared control weight");
207 eq(finalDeclaration(".composer-meta__divider", "height"), "18px", "execution policy and model settings have a compact visual divider");
208 ok(
209 /@container \(max-width: 560px\)\s*\{[\s\S]*?\.composer-meta__control--more\s*\{[\s\S]*?flex-basis:\s*38px;/.test(styles),
210 "composer enters icon-only mode before model and effort controls overlap",
211 );
212 ok(
213 /@container \(max-width: 760px\)\s*\{[\s\S]*?\.composer-meta__control--approval \.composer-modebar--approval\s*\{[^}]*flex:\s*1 1 auto;[^}]*width:\s*100%;[^}]*min-width:\s*0;[^}]*max-width:\s*100%;/.test(styles),
214 "approval mode switcher shrinks with its compact composer container",
215 );
216 eq(finalDeclaration(".composer-modebar--approval", "--composer-modebar-active-bg"), "var(--mode-auto-bg)", "ask approval restores the solid semantic fill");
217 eq(finalDeclaration('.composer-modebar--approval[data-mode="auto"]', "--composer-modebar-active-fg"), "#fff", "auto approval keeps high-contrast text on its solid fill");
218 eq(finalDeclaration('.composer-modebar--approval[data-mode="yolo"]', "--composer-modebar-active-bg"), "var(--mode-yolo-bg)", "yolo approval restores the solid warning fill");
219 eq(finalDeclaration(".composer-intent-menu", "width"), "min(284px, calc(100vw - 16px))", "task method menu uses the shared menu width");
220 eq(finalDeclaration(".composer-access-menu__desc", "white-space"), "normal", "menu descriptions can wrap onto a second line");
221 eq(finalDeclaration(".composer-access-menu__desc", "text-overflow"), "clip", "menu descriptions no longer use single-line ellipsis");
222 eq(finalDeclaration(".composer-task-mode-trigger:focus-visible", "box-shadow"), "var(--focus-ring)", "task method selector uses the shared keyboard focus ring");
223 eq(finalDeclaration(".composer-meta .modelsw__trigger:focus-visible", "box-shadow"), "var(--focus-ring)", "model and effort selectors use the shared keyboard focus ring");
224 eq(finalDeclaration(":root[data-theme-style] .composer-modebar__item--active:focus-visible", "box-shadow"), "var(--focus-ring)", "active permission options retain keyboard focus feedback");
225 eq(
226 finalDeclaration(".app--creation .msg--assistant:not(.chat-transcript .msg--assistant) .msg__body", "font-size"),
227 "var(--font-content)",
228 "creation assistant body text follows the conversation text size",
229 );
230 eq(
231 finalDeclaration(":root[data-theme-style] .msg--assistant:not(.chat-transcript .msg--assistant) .msg__body", "font-size"),
232 "var(--font-content)",
233 "themed assistant body text follows the conversation text size",
234 );
235 eq(
236 finalDeclaration(".app--creation .msg--assistant:not(.chat-transcript .msg--assistant) .msg__body", "font-family"),
237 "var(--font-content-family)",
238 "creation assistant body follows the conversation font family",
239 );
240 eq(
241 finalDeclaration(".app--creation .md:not(.chat-transcript .md)", "font-family"),
242 "var(--font-content-family)",
243 "creation markdown follows the conversation font family",
244 );
245 eq(
246 finalDeclaration(".app--creation .composer__input", "font-size"),
247 "var(--font-content)",
248 "creation composer input follows the composer text size",
249 );
250 eq(
251 finalDeclaration("body", "--text-base"),
252 "var(--typography-interface-size, calc(14px * var(--font-scale)))",
253 "interface text resolves its own exact regional size",
254 );
255 eq(
256 finalDeclaration(".transcript", "--text-base"),
257 "var(--typography-conversation-size, calc(14px * var(--font-scale)))",
258 "conversation text resolves its own exact regional size",
259 );
260 eq(
261 finalDeclaration(".composer-wrap", "--text-base"),
262 "var(--typography-composer-size, calc(14px * var(--font-scale)))",
263 "composer text resolves its own exact regional size",
264 );
265 eq(
266 finalDeclaration(".code", "--font-code"),
267 "var(--typography-code-size, calc(12px * var(--font-scale)))",
268 "code text resolves its own exact regional size",
269 );
270 eq(finalDeclaration(".code", "font-family"), "var(--font-code-family)", "code blocks keep the regional code font");
271 eq(finalDeclaration(".md-code", "font-family"), "var(--font-code-family)", "inline code keeps the regional code font");
272 eq(finalDeclaration(".code code", "font-family"), "inherit", "nested code text inherits the regional code font");
273 eq(finalDeclaration(".code-line-text", "font-family"), "inherit", "line-numbered code text inherits its viewer font");
274 eq(
275 finalDeclaration(".code-lines-wrap", "font-family"),
276 "var(--typography-code-font, var(--font-mono))",
277 "line-numbered code viewers use the regional code font",
278 );
279 eq(
280 finalDeclaration(".diff", "font-size"),
281 "var(--typography-code-size, calc(12.5px * var(--global-font-scale)))",
282 "diff text follows the global scale until the code region is customized",
283 );
284 eq(finalDeclaration(".msg-meta", "font-size"), "var(--font-status)", "message metadata keeps its regional size");
285 eq(
286 finalDeclaration(".composer-meta", "font-family"),
287 "var(--font-metadata-family)",
288 "composer metadata keeps the regional font",
289 );
290 eq(finalDeclaration(".statusbar", "font-family"), "var(--font-metadata-family)", "status bar keeps the regional font");
291 eq(
292 finalDeclaration(".typography-settings__preview", "--preview-size"),
293 "var(--typography-conversation-size, calc(14px * var(--global-font-scale)))",
294 "conversation preview uses the exact conversation size",
295 );
296 eq(
297 finalDeclaration(".typography-settings__preview--interface", "--preview-size"),
298 "var(--typography-interface-size, calc(14px * var(--global-font-scale)))",
299 "interface preview uses the exact interface size",
300 );
301 eq(
302 finalDeclaration(".typography-settings__preview--composer", "--preview-size"),
303 "var(--typography-composer-size, calc(14px * var(--global-font-scale)))",
304 "composer preview uses the exact composer size",
305 );
306 eq(
307 finalDeclaration(".typography-settings__preview--code", "--preview-size"),
308 "var(--typography-code-size, calc(12px * var(--global-font-scale)))",
309 "code preview uses the exact code size",
310 );
311 eq(
312 finalDeclaration(".typography-settings__preview--metadata", "--preview-size"),
313 "var(--typography-metadata-size, calc(12px * var(--global-font-scale)))",
314 "metadata preview uses the exact supporting-text size",
315 );
316 eq(
317 finalDeclaration(".typography-settings__preview-body", "font-size"),
318 "var(--preview-size)",
319 "live preview renders the selected region's exact size",
320 );
321 eq(
322 finalDeclaration(".app--creation .reasoning__body", "font-family"),
323 "var(--font-content-family)",
324 "creation reasoning keeps the conversation font",
325 );
326 eq(
327 finalDeclaration(".app--creation .tool__name", "font-family"),
328 "var(--font-code-family)",
329 "creation tool names keep the code font",
330 );
331 ok(
332 !/\.app--creation[^{]*\{[^}]*font-size:\s*[0-9.]+px\s*(?:!important\s*)?;/.test(styles),
333 "creation rules do not hardcode bare px font sizes (except font-size:0)",
334 );
335 eq(
336 finalDeclaration(".context-ring-popover__title", "font-size"),
337 "calc(14px * var(--font-scale))",
338 "creation context-ring popover (portaled to body) follows interface text size",
339 );
340 ok(
341 !/\.context-ring-popover[^{]*\{[^}]*font-size:\s*[0-9.]+px\s*(?:!important\s*)?;/.test(styles),
342 "context-ring popover rules do not hardcode bare px font sizes (except font-size:0)",
343 );
344 eq(
345 finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "height"),
346 "0 !important",
347 "collapsed creation tool bodies keep mounted content clipped",
348 );
349 eq(
350 finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "visibility"),
351 "hidden",
352 "collapsed creation tool bodies do not paint hidden tool text",
353 );
354 ok(
355 /@container\s*\(max-width:\s*760px\)[\s\S]*?\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(240px,\s*42vw\)[\s\S]*?\.composer-meta__control--intent\s*\{[\s\S]*?max-width\s*:\s*128px[\s\S]*?\.composer-meta__control--effort\s*\{[\s\S]*?display\s*:\s*none[\s\S]*?\.composer-meta__control--more\s*\{[\s\S]*?display\s*:\s*inline-flex/.test(styles),
356 "composer compact controls activate at the capped theme width",
357 );
358 eq(finalDeclaration(".md-table-scroll", "overflow-x"), "auto", "markdown table wrapper scrolls horizontally");
359 eq(finalDeclaration(".md-table-scroll", "overflow-y"), "hidden", "markdown table wrapper does not nest vertical scroll");
360 eq(finalDeclaration(".md table", "overflow"), "visible", "markdown tables stay in document flow for trackpad Y");
361 eq(finalDeclaration(".chat-stat-dialog", "font"), "13px/1.5 var(--font-ui)", "portaled turn statistics use a root-owned font token");
362 eq(finalDeclaration(".code", "overflow-x"), "auto", "code blocks scroll horizontally instead of widening the layout");
363 eq(finalDeclaration(".code", "overflow-y"), "hidden", "code blocks do not nest vertical scroll by default");
364 ok(
365 /@media\s*\(max-width:\s*900px\)[\s\S]*?\.settings-center\s*\{[\s\S]*?grid-template-columns\s*:\s*1fr/.test(styles),
366 "settings center stacks navigation before the modal is too narrow",
367 );
368 ok(
369 /@media\s*\(max-width:\s*900px\)[\s\S]*?\.settings-field\s*\{[\s\S]*?grid-template-columns\s*:\s*1fr/.test(styles),
370 "settings fields collapse to one column at the mid-width breakpoint",
371 );
372 ok(
373 /@media\s*\(max-width:\s*760px\)[\s\S]*?\.settings-modal\s*\{[\s\S]*?width\s*:\s*100vw[\s\S]*?height\s*:\s*100vh/.test(styles),
374 "settings modal only becomes fullscreen at the narrow breakpoint",
375 );
376 ok(
377 /@media\s*\(max-width:\s*820px\)[\s\S]*?\.app\s+\.layout[\s\S]*?grid-template-columns\s*:\s*minmax\(0,\s*1fr\)\s*!important[\s\S]*?\.app\s+\.sidebar[\s\S]*?display\s*:\s*none\s*!important[\s\S]*?\.app\s+\.chat-pane[\s\S]*?grid-column\s*:\s*1\s*!important/.test(styles),
378 "narrow workbench layout hides side panels and keeps chat single-column",
379 );
380
381 for (const selector of [
382 ".reasoning__head",
383 ".turn-collapse__reasoning-head",
384 ".process-card__head",
385 ".tool__difflabel",
386 ".msg-memory-citations",
387 ".msg-memory-citations__source",
388 ".msg-memory-citations__note",
389 ".msg-attachment__name",
390 ".msg-attachment__meta",
391 ".msg-pasted-head",
392 ".msg-pasted-expanded",
393 ".msg__send-failed",
394 ":root[data-theme-style] .process-card__kind",
395 ':root[data-theme-style] .msg--assistant:not(.chat-transcript .msg--assistant) > .process-card[data-tone="violet"] .process-card__name',
396 ]) {
397 const size = finalDeclaration(selector, "font-size");
398 ok(size !== undefined && !/^[0-9.]+px$/.test(size), `${selector} font size follows the text-size scale`);
399 }
400
401 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
402 if (failed > 0) process.exit(1);
403
403 lines TYPESCRIPT