fix(client): stack mobile font-family settings (#7356)

* fix(client): stack mobile font-family settings

* fix(client): cover font layout and touch sizing

* test(bench): interleave scaling samples
This commit is contained in:
EricMoin authored and GitHub committed 2026-10-26 19:53:18 +08:00
1 parent 1ba07e14ec
commit 3b432c3f40
11 files changed
+200 -62

No files matched your search

@@ -21,11 +21,11 @@
en: f07bb6fbbdd01fe4
zh: 9313a0ef2a22af12
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate:
en: fcc52be9892b2bfc
zh: ac259aba407efcbc
en: 37faaffb4662fd54
zh: df0a4bf016393be9
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered:
en: 168bda1a4e9e0325
zh: af9cf7b804db84b7
en: 559a8037caa9e3aa
zh: 4f3719ed09445cc2
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences:
en: 8f9d25b72d3e7660
zh: e5c6ae0194905876
@@ -43,7 +43,7 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md)
| Item | Content |
|---|---|
| Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time |
| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo |
| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Scaling inputs share one group: every warm-up and measured round evaluates both sizes adjacently and alternates which size goes first, limiting drift between sizes. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo |
| Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval |
| Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character |
| Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report |
@@ -54,6 +54,7 @@ The reference constants are calibrated for minima of five single evaluations. Mi
## Alternatives considered
- **Finishing one scaling size before measuring the other**: separate phases compare different load and V8 tiering conditions. Identical code measured JWT doubling at 1.91× in one CI run and 2.70× in another; a local Node 24 run also exceeded the unchanged 2.5× limit on a different input. Adjacent, alternating size rounds retain the existing sample counts, minimum-of-five aggregation, GC inclusion, workloads, and limits. A controlled extra evaluation cost on only the 128 KiB input must still fail the doubling assertion.
- **Equal evaluation counts at both scaling sizes**: the larger input allocates twice as much per sample, so its fastest sample can include a collection while the smaller input's fastest sample includes none. Equal total input per sample makes the per-evaluation means compare similar allocation volumes without excluding GC or changing the doubling limit.
- **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state.
- **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure.
@@ -43,7 +43,7 @@ Status: implemented
| 项 | 内容 |
|---|---|
| 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 |
| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 |
| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。翻倍比较的两个输入属于同一组:每轮预热和测量相邻求值两个尺寸,并交替先测的尺寸,减少尺寸之间的漂移。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 |
| 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC |
| 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 |
| 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 |
@@ -54,6 +54,7 @@ Status: implemented
## 考虑过的替代方案
- **测完一个翻倍尺寸后再测另一个**:两个阶段比较的负载和 V8 编译层级状态不同。同一份代码的 JWT 翻倍比在两轮 CI 中分别为 1.91 倍和 2.70 倍;本地 Node 24 也在另一个输入上超过既有 2.5 倍上限。相邻、交替的尺寸轮次保留既有样本数、五组取最小值的汇总方式、GC 计入规则、负载与上限。仅给 128 KiB 输入增加受控求值开销时,翻倍断言仍须失败。
- **两种大小使用相同求值次数**:大输入每个样本的分配量翻倍,因而其最快样本可能包含一次垃圾回收,而小输入的最快样本没有。让每个样本的输入总量相等,使单次求值均值比较相近的分配量,无需排除垃圾回收或修改翻倍上限。
- **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。
- **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。
+104 -1
View File
@@ -1,4 +1,4 @@
/** Built settings controls share radius and card materials across account, model, and plugin pages. */
/** Built settings controls share materials and adapt their geometry to the viewport and pointer. */
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { chromium, type Locator } from 'playwright'
@@ -23,6 +23,109 @@ function appearance(element: Locator) {
})
}
function rowGeometry(control: Locator) {
return control.evaluate((node) => {
const row = node.parentElement!
const text = row.firstElementChild!
const description = text.lastElementChild!.getBoundingClientRect()
const rowBox = row.getBoundingClientRect()
const textBox = text.getBoundingClientRect()
const controlBox = node.getBoundingClientRect()
return {
widthDifference: rowBox.width - controlBox.width,
leftDifference: controlBox.left - textBox.left,
rightGap: rowBox.right - controlBox.right,
afterText: controlBox.left - textBox.right,
belowDescription: controlBox.top - description.bottom,
centerDifference: (controlBox.top + controlBox.bottom - textBox.top - textBox.bottom) / 2,
height: controlBox.height,
}
})
}
it('stacks font fields at narrow widths while keeping font-size controls on the right', async () => {
const scaffold = await launchWebScaffold({ developerTools: false })
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
onTestFinished(() => browser.close())
for (const platform of ['web', 'darwin', 'win32'] as const) {
const context = await browser.newContext({
viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, hasTouch: platform === 'web',
})
try {
const page = await context.newPage()
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await openSettings(page, 'zh')
const dialog = page.getByRole('dialog', { name: '设置', exact: true })
await dialog.getByRole('button', { name: '更多字体设置', exact: true }).click()
await page.evaluate((platform) => {
document.documentElement.toggleAttribute('data-windows-titlebar', platform === 'win32')
if (platform === 'darwin') document.documentElement.setAttribute('data-platform', platform)
}, platform)
for (const width of platform === 'web' ? [390, 767, 768] : [520, 767, 768]) {
await page.setViewportSize({ width, height: 844 })
for (const name of ['正文字体', '代码字体', '终端字体']) {
const geometry = await rowGeometry(dialog.getByRole('textbox', { name, exact: true }).locator('..'))
if (width <= 767) {
expect(Math.abs(geometry.widthDifference), `${platform} ${width}px ${name} width`).toBeLessThan(1)
expect(Math.abs(geometry.leftDifference), `${platform} ${width}px ${name} alignment`).toBeLessThan(1)
expect(geometry.belowDescription, `${platform} ${width}px ${name} placement`).toBeGreaterThanOrEqual(0)
expect(geometry.height, `${platform} ${width}px ${name} height`).toBeLessThan(60)
} else {
expect(geometry.afterText, `${platform} ${width}px ${name} placement`).toBeGreaterThanOrEqual(0)
}
}
for (const name of ['增大字号', '增大代码字号', '增大终端字号']) {
const geometry = await rowGeometry(dialog.getByRole('button', { name, exact: true }).locator('../../..'))
expect(geometry.afterText, `${platform} ${width}px ${name} placement`).toBeGreaterThanOrEqual(0)
expect(Math.abs(geometry.rightGap), `${platform} ${width}px ${name} alignment`).toBeLessThan(1)
expect(Math.abs(geometry.centerDifference), `${platform} ${width}px ${name} centering`).toBeLessThan(1)
}
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width)
}
const field = dialog.getByRole('textbox', { name: '正文字体', exact: true })
await field.fill('Uncommitted font draft')
const original = await field.elementHandle()
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 844 })
expect(await original!.evaluate(input => input === document.activeElement && input.isConnected)).toBe(true)
expect(await field.inputValue()).toBe('Uncommitted font draft')
}
} finally {
await context.close()
}
}
})
it('uses 16px text for coarse-pointer font fields and preserves compact fine-pointer fields', async () => {
const scaffold = await launchWebScaffold({ developerTools: false })
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
onTestFinished(() => browser.close())
for (const hasTouch of [true, false]) {
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, hasTouch })
try {
const page = await context.newPage()
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await openSettings(page, 'zh')
const dialog = page.getByRole('dialog', { name: '设置', exact: true })
await dialog.getByRole('button', { name: '更多字体设置', exact: true }).click()
expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(hasTouch)
for (const name of ['正文字体', '代码字体', '终端字体']) {
const fontSize = await dialog.getByRole('textbox', { name, exact: true })
.evaluate(input => getComputedStyle(input).fontSize)
expect(fontSize, name).toBe(hasTouch ? '16px' : '14px')
}
} finally {
await context.close()
}
}
})
it('shares settings card materials and control sizes in both palettes', async () => {
const scaffold = await launchWebScaffold({})
onTestFinished(() => scaffold.close())
+57 -45
View File
@@ -164,20 +164,22 @@ interface Workload {
readonly evaluationsPerSample: number
}
/** Named fixed workloads; adversarial inputs appear at two sizes so their scaling is observable. */
function workloads(): readonly Workload[] {
/** Scaling sizes share adjacent rounds; standalone workloads occupy one group. */
function workloadGroups(): readonly (readonly Workload[])[] {
return [
{ id: 'typical-messages', group: 'unmatched', texts: messages(5_000), evaluationsPerSample: 1 },
[{ id: 'typical-messages', group: 'unmatched', texts: messages(5_000), evaluationsPerSample: 1 }],
...Object.entries({ prose: PROSE, code: CODE, base64: BASE64, markdown: MARKDOWN, log: LOG, json: JSON_LINES, shell: SHELL })
.map(([name, unit]): Workload => ({
.map(([name, unit]): readonly Workload[] => [{
id: `${name}-256k`, group: 'unmatched', texts: [fill(unit, 256 * KIB)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS,
})),
...Object.entries(CREDENTIALS).map(([name, credential]): Workload => ({
}]),
...Object.entries(CREDENTIALS).map(([name, credential]): readonly Workload[] => [{
id: `messages-with-${name}`, group: 'matched', texts: credentialMessages(5_000, credential), evaluationsPerSample: 1,
})),
{ id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)], evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS },
{ id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS },
...Object.entries(ADVERSARIAL_UNITS).flatMap(([name, unit]): Workload[] => [
}]),
[
{ id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)], evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS },
{ id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS },
],
...Object.entries(ADVERSARIAL_UNITS).map(([name, unit]): readonly Workload[] => [
{
id: `adversarial-${name}-64k`, group: 'adversarial', texts: [fill(unit, 64 * KIB)],
evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS,
@@ -256,25 +258,34 @@ async function evaluatePair(texts: readonly string[], index: number, evaluations
return { base: await sample(withoutSecret, texts, evaluations), secret }
}
async function measure(texts: readonly string[], attempts: number, evaluations: number): Promise<SamplePair & {
/** Alternate scaling sizes within every warm-up and measured round to limit drift between them. */
async function measure(workloads: readonly Workload[], attempts: number): Promise<readonly (SamplePair & {
readonly workload: Workload
readonly samples: readonly UserTextTimingPair[]
}> {
for (let warm = 0; warm < WARMUP_PAIRS; warm++) await evaluatePair(texts, warm, evaluations)
const pairs: SamplePair[] = []
for (let attempt = 0; attempt < attempts; attempt++) {
pairs.push(await evaluatePair(texts, attempt, evaluations))
}
const best = pairs.reduce((min, pair) => ({
base: pair.base.ms < min.base.ms ? pair.base : min.base,
secret: pair.secret.ms < min.secret.ms ? pair.secret : min.secret,
}))
return {
...best,
samples: pairs.map(pair => ({
baseMs: pair.base.ms, baseCpuMs: pair.base.cpuMs,
withSecretMs: pair.secret.ms, withSecretCpuMs: pair.secret.cpuMs,
})),
})[]> {
const measured = workloads.map(workload => ({ workload, pairs: new Array<SamplePair>() }))
for (let round = 0; round < WARMUP_PAIRS + attempts; round++) {
for (let position = 0; position < measured.length; position++) {
const index = round % 2 === 0 ? position : measured.length - 1 - position
const { workload, pairs } = measured[index]!
const pair = await evaluatePair(workload.texts, round, workload.evaluationsPerSample)
if (round >= WARMUP_PAIRS) pairs.push(pair)
}
}
return measured.map(({ workload, pairs }) => {
const best = pairs.reduce((min, pair) => ({
base: pair.base.ms < min.base.ms ? pair.base : min.base,
secret: pair.secret.ms < min.secret.ms ? pair.secret : min.secret,
}))
return {
workload,
...best,
samples: pairs.map(pair => ({
baseMs: pair.base.ms, baseCpuMs: pair.base.cpuMs,
withSecretMs: pair.secret.ms, withSecretCpuMs: pair.secret.cpuMs,
})),
}
})
}
declare const gc: (() => void) | undefined
@@ -366,7 +377,7 @@ async function allocations(): Promise<UserTextAllocationReport> {
if (gc === undefined) throw new Error('user-text allocation mode needs --expose-gc')
const collect = gc
const results: UserTextWorkloadAllocation[] = []
for (const { id, texts } of workloads()) {
for (const { id, texts } of workloadGroups().flat()) {
results.push({ id, ruleAllocatedBytes: await allocatedBytes(texts, collect), ...returned(texts) })
}
return { workloads: results }
@@ -374,23 +385,24 @@ async function allocations(): Promise<UserTextAllocationReport> {
async function timings(attempts: number): Promise<UserTextTimeReport> {
const results: UserTextWorkloadTiming[] = []
for (const { id, group, texts, evaluationsPerSample } of workloads()) {
const { base, secret, samples } = await measure(texts, attempts, evaluationsPerSample)
results.push({
id,
group,
documents: texts.length,
characters: texts.reduce((sum, text) => sum + text.length, 0),
evaluationsPerSample,
secrets: secret.secrets,
maskedCharacters: secret.maskedCharacters,
baseMs: base.ms,
baseCpuMs: base.cpuMs,
withSecretMs: secret.ms,
withSecretCpuMs: secret.cpuMs,
ratio: secret.ms / base.ms,
samples,
})
for (const workloads of workloadGroups()) {
for (const { workload: { id, group, texts, evaluationsPerSample }, base, secret, samples } of await measure(workloads, attempts)) {
results.push({
id,
group,
documents: texts.length,
characters: texts.reduce((sum, text) => sum + text.length, 0),
evaluationsPerSample,
secrets: secret.secrets,
maskedCharacters: secret.maskedCharacters,
baseMs: base.ms,
baseCpuMs: base.cpuMs,
withSecretMs: secret.ms,
withSecretCpuMs: secret.cpuMs,
ratio: secret.ms / base.ms,
samples,
})
}
}
return { warmupPairs: WARMUP_PAIRS, attempts, workloads: results }
}
+2 -2
View File
@@ -18,8 +18,8 @@
en: 5a279269e31713c1
zh: dcd5c31fbdfd63e2
/deepseek-ai-dsh-client-ui-theme/use-this-package/appearance-and-font-size:
en: 7dc280d0e36a3e7d
zh: f7a75164db34cf0a
en: 7dff01929847efd1
zh: ec04b4c4485aba96
/deepseek-ai-dsh-client-ui-theme/use-this-package/fonts:
en: b45d5b48ca799c3a
zh: af65514b3759204f
+1 -1
View File
@@ -29,7 +29,7 @@ Users switch the color scheme, font sizes, and fonts from rows in Settings (Gene
### Appearance and font size
In narrow browser settings, the three Appearance choices share one row and font-size labels use a smaller gutter. Windows and macOS desktop windows retain wrapping choices and standard spacing.
In narrow browser settings, the three Appearance choices share one row and font-size labels use a smaller gutter. At viewport widths up to 767 CSS px, including narrow desktop windows, font-family rows stack each title, description, and full-width field vertically. Font-size steppers stay to the right of their labels. Wider viewports retain horizontal font-family rows. Windows and macOS desktop windows retain wrapping Appearance choices and standard font-size label spacing. Font-family fields use 16 px text on devices with a coarse primary pointer.
The plugin registers Appearance preference cubes and the text font-size stepper in the General section. A More font settings button after the stepper expands a region below it, collapsed on every settings open, with a font row for each of text, code, and the terminal, plus the code and terminal font-size steppers. The text stepper accepts integer values from 10 through 22 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; flow-row titles, summaries, and tables follow one step under the body size, while small text keeps a fixed size. The code stepper (10–16 px, default 11) sets the code-block size; inline code stays 1 px larger, and the small tool-output variant uses the same size. The terminal stepper (10–20 px, default 13) sets the sidebar terminal cell font size; changing it keeps the shell and output and refits the grid. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values.
+1 -1
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
### 外观与字号
窄屏浏览器设置中,三个外观选项同排显示,字号标签与控件的间距缩小;Windows 和 macOS 桌面窗口保留可换行选项和标准间距。
窄屏浏览器设置中,三个外观选项同排显示,字号标签使用较小的间距。视口宽度不超过 767 CSS px 时(包括窄桌面窗口),字体输入行按标题、说明、输入框纵向排列,输入框占满可用宽度;字号步进器保持在标签右侧。更宽的视口保留字体输入行的横向排列。Windows 和 macOS 桌面窗口的外观选项保留换行,字号标签保留标准间距。主指针精度较低的设备上,字体输入框使用 16 px 文字。
插件在「通用」分区注册外观偏好方块与正文字号步进器。步进器后的「更多字体设置」按钮在其下方展开一个区域,每次打开设置时均为折叠状态,其中包含正文、代码与终端各自的字体行,以及代码与终端的字号步进器。正文步进器接受 10 至 22 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;流内行的标题、摘要与表格跟随比正文低一档的字号,小号文本保持固定字号。代码步进器(10–16 px,默认 11)设置代码块字号;行内代码保持大 1 px,工具输出的小号变体使用相同字号。终端步进器(10–20 px,默认 13)设置侧栏终端的单元格字号;修改后保留 shell 与输出,并重新适配网格。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。
@@ -1,7 +1,18 @@
/* Font-family rows reuse the font-size row cell (FontSizeRow.module.css);
* the fields share one basis so the three rows align, and shrink with the row. */
/* Font-family fields; shared row text and separators live in FontSizeRow.module.css. */
.field {
flex: 0 1 240px;
min-width: 0;
}
@media (max-width: 767px) {
.field {
flex-basis: auto;
}
}
@media (pointer: coarse) {
.field input {
font-size: 16px;
}
}
@@ -5,6 +5,7 @@
* an empty field restores the built-in stack. The field shows the persisted
* list and resets whenever that list changes.
*/
import clsx from 'clsx'
import { Input } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { normalizeFontFamily, type FontRole } from '../theme-settings.ts'
@@ -39,7 +40,7 @@ export function FontFamilyRow({ t, role, setFontFamily, useStore }: FontFamilyRo
setFontFamily(input.value)
}
return (
<div className={rowCss.row}>
<div className={clsx(rowCss.row, rowCss.stacked)}>
<div className={rowCss.rowText}>
<div className={rowCss.title}>{title}</div>
<div className={rowCss.desc}>{t(`fontFamily.${role}.description`)}</div>
@@ -19,12 +19,21 @@
padding-right: 48px;
}
/* Narrow settings panel: the 48px gutter separates the label from its control
across a wide row and only squeezes the label here. */
@media (max-width: 767px) {
:global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .rowText {
/* The wide-row gutter squeezes labels beside steppers in narrow browser panels. */
:global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .row:not(.stacked) .rowText {
padding-right: 16px;
}
.stacked {
flex-direction: column;
align-items: stretch;
}
.stacked .rowText {
flex: none;
padding-right: 0;
}
}
.title {