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