@citisen/dsh-font 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,847 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@citisen/dsh-font",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ /**
8
+ * Browser half of `dsh-font` — the source of the prebuilt `lib/client.js`.
9
+ *
10
+ * This file is NOT loaded as an ES module. `scripts/build-client.mjs` wraps it
11
+ * in the DSH client-bundle envelope and writes `lib/client.js`, which is what
12
+ * the Web shell fetches. Keep it dependency-light: the only modules it may
13
+ * `import` are the platform-singleton specifiers the shell seeds into its
14
+ * module table, and the only Node-side API it may use is that table.
15
+ *
16
+ * Presentation strategy
17
+ * ---------------------
18
+ * Families are *not* written as CSS custom properties directly, because
19
+ * `ui-layout`'s theme presenter owns `document.body.style` and deletes any
20
+ * custom property it did not write on every theme change. Instead the families
21
+ * are stacked onto the design system through the theme service
22
+ * (`ctx.theme.overrideTokens`), which folds them into the active token set so
23
+ * the presenter republishes them — and keeps republishing them — itself.
24
+ *
25
+ * Sizes are a stylesheet, because the shipped components hard-code every
26
+ * interface text size in px and the shipped token scale is a fixed ladder
27
+ * (`--dsw-font-xs-13` is 13px, period). The sheet re-derives each hard-coded
28
+ * step from `--dsh-font-ui-scale` and redefines the conversation text tokens
29
+ * as absolute px, so one integer drives the whole surface.
30
+ *
31
+ * @module dsh-font/client
32
+ */
33
+
34
+ let _react = require("react");
35
+ let _deepseek_ai_dsh_client_store = require("@deepseek-ai/dsh-client-store");
36
+ let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
37
+ /** Settings namespace owned by this plugin (mirrors the host half). */
38
+ const FONT_SETTINGS_NAMESPACE = 'ui-font'
39
+ /** Locale namespace owning this feature's settings-row copy. */
40
+ const SETTINGS_LOCALE_NAMESPACE = 'settings.font'
41
+ /** Cosmetic namespace for this plugin's CSS classes and tags. */
42
+ const STYLE_PREFIX = 'dsh-font'
43
+ /**
44
+ * Id of the single stylesheet this plugin owns. The host half's pre-paint
45
+ * bootstrap looks the same element up by this id, so the two must agree;
46
+ * `scripts/verify-client.mjs` asserts that they do.
47
+ */
48
+ const FONT_STYLE_ID = `${STYLE_PREFIX}/variables`
49
+ /**
50
+ * The plugin's identity in the theme registry, substituted with the real
51
+ * package name by `scripts/build-client.mjs`. The theme pins one override layer
52
+ * per source, so this must equal the package the roster mounted.
53
+ */
54
+ const PLUGIN_ID = "@citisen/dsh-font"
55
+
56
+ /** Field names — must match the host schema in `lib/index.js`. */
57
+ const UI_FONT_FAMILY_FIELD = 'uiFontFamily'
58
+ const CODE_FONT_FAMILY_FIELD = 'codeFontFamily'
59
+ const UI_FONT_SCALE_FIELD = 'uiFontScale'
60
+ const CONTENT_FONT_SIZE_FIELD = 'contentFontSize'
61
+ const CODE_FONT_SIZE_FIELD = 'codeFontSize'
62
+
63
+ /** Defaults — must match the host schema in `lib/index.js`. */
64
+ const DEFAULT_UI_FONT_FAMILY =
65
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif'
66
+ const DEFAULT_CODE_FONT_FAMILY =
67
+ '"SF Mono", "JetBrains Mono", "Fira Code", Consolas, "Liberation Mono", Menlo, Courier, "PingFang SC", "Microsoft YaHei"'
68
+
69
+ /** Accepted ranges — must match the host schema in `lib/index.js`. */
70
+ const UI_FONT_SCALE_MIN = 0.75
71
+ const UI_FONT_SCALE_MAX = 1.5
72
+ const UI_FONT_SCALE_STEP = 0.05
73
+ const CONTENT_FONT_SIZE_MIN = 12
74
+ const CONTENT_FONT_SIZE_MAX = 20
75
+ const CODE_FONT_SIZE_MIN = 10
76
+ const CODE_FONT_SIZE_MAX = 20
77
+
78
+ /** The interface text sizes the shipped components hard-code, in px. */
79
+ const UI_TEXT_STEPS = [11, 12, 13, 14, 16, 20, 24]
80
+
81
+ /** Curated family presets, offered as one-click fills for both inputs. */
82
+ const UI_FAMILY_PRESETS = [
83
+ { id: 'system', label: 'System', value: DEFAULT_UI_FONT_FAMILY },
84
+ {
85
+ id: 'inter',
86
+ label: 'Inter',
87
+ value: 'Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif',
88
+ },
89
+ {
90
+ id: 'plex',
91
+ label: 'IBM Plex Sans',
92
+ value: '"IBM Plex Sans", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
93
+ },
94
+ {
95
+ id: 'noto',
96
+ label: 'Noto Sans',
97
+ value: '"Noto Sans", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei", sans-serif',
98
+ },
99
+ ]
100
+
101
+ /** Curated code-family presets. */
102
+ const CODE_FAMILY_PRESETS = [
103
+ { id: 'system', label: 'System mono', value: DEFAULT_CODE_FONT_FAMILY },
104
+ {
105
+ id: 'jetbrains',
106
+ label: 'JetBrains Mono',
107
+ value: '"JetBrains Mono", "Fira Code", Consolas, monospace',
108
+ },
109
+ { id: 'fira', label: 'Fira Code', value: '"Fira Code", Consolas, monospace' },
110
+ {
111
+ id: 'cascadia',
112
+ label: 'Cascadia Code',
113
+ value: '"Cascadia Code", "Cascadia Mono", Consolas, monospace',
114
+ },
115
+ {
116
+ id: 'maple',
117
+ label: 'Maple Mono',
118
+ value: '"Maple Mono", "JetBrains Mono", Consolas, monospace',
119
+ },
120
+ { id: 'mono', label: 'Generic mono', value: 'ui-monospace, SFMono-Regular, Menlo, monospace' },
121
+ ]
122
+
123
+ /** Row copy, keyed by locale. `zh` is the key-set source of truth. */
124
+ const zh = {
125
+ 'font.title': '字体',
126
+ 'font.description': '自定义界面与代码字体、字号,设置会保存到本机',
127
+ 'font.uiFamily': '界面字体',
128
+ 'font.uiFamilyHint': 'CSS font-family 列表,例如 Inter, "PingFang SC", sans-serif',
129
+ 'font.codeFamily': '代码字体',
130
+ 'font.codeFamilyHint': '用于代码块、行内代码与终端等宽文本',
131
+ 'font.uiScale': '界面字号',
132
+ 'font.uiScaleHint': '按比例缩放整个界面的文字大小',
133
+ 'font.contentSize': '会话正文字号',
134
+ 'font.contentSizeHint': '对话正文、标题与表格的字号',
135
+ 'font.codeSize': '代码字号',
136
+ 'font.codeSizeHint': '代码块与行内代码的字号',
137
+ 'font.unit': 'px',
138
+ 'font.reset': '恢复默认',
139
+ 'font.presets': '预设',
140
+ 'font.increase': '增大',
141
+ 'font.decrease': '减小',
142
+ }
143
+
144
+ /** English dictionary, checked complete against the `zh` key set. */
145
+ const en = {
146
+ 'font.title': 'Fonts',
147
+ 'font.description':
148
+ 'Customize the interface and code fonts and sizes; values are saved on this machine',
149
+ 'font.uiFamily': 'Interface font',
150
+ 'font.uiFamilyHint': 'A CSS font-family list, e.g. Inter, "PingFang SC", sans-serif',
151
+ 'font.codeFamily': 'Code font',
152
+ 'font.codeFamilyHint': 'Used for code blocks, inline code, and monospace text',
153
+ 'font.uiScale': 'Interface text size',
154
+ 'font.uiScaleHint': 'Scales every interface text size proportionally',
155
+ 'font.contentSize': 'Conversation text size',
156
+ 'font.contentSizeHint': 'Size of message bodies, headings, and tables',
157
+ 'font.codeSize': 'Code text size',
158
+ 'font.codeSizeHint': 'Size of code blocks and inline code',
159
+ 'font.unit': 'px',
160
+ 'font.reset': 'Reset to defaults',
161
+ 'font.presets': 'Presets',
162
+ 'font.increase': 'Increase',
163
+ 'font.decrease': 'Decrease',
164
+ }
165
+
166
+ /** The stylesheet this plugin owns, keyed by the resolved settings section. */
167
+ function fontStyleSheet(section) {
168
+ const scale = section[UI_FONT_SCALE_FIELD]
169
+ const contentSize = section[CONTENT_FONT_SIZE_FIELD]
170
+ const codeSize = section[CODE_FONT_SIZE_FIELD]
171
+
172
+ const scaleRules = UI_TEXT_STEPS.map(
173
+ (step) => `.dsh-font-size-${String(step)}{font-size:calc(${String(step)}px * var(--dsh-font-ui-scale,1)) !important}`,
174
+ ).join('')
175
+
176
+ return [
177
+ '/* dsh-font: interface scale + conversation text sizes.',
178
+ ' The scale is emitted as one utility class per hard-coded UI text size and',
179
+ ' stamped onto every element by applyFonts(), so it is a deterministic',
180
+ ' override that never inherits into a subtree. */',
181
+ 'html body{',
182
+ `--dsh-font-ui-scale:${String(scale)};`,
183
+ `--dsh-font-code-size:${String(codeSize)}px;`,
184
+ // A CSS-side mirror of the content size. The authoritative declaration is
185
+ // inline on `body` (written by applyFonts), because ui-layout's theme
186
+ // presenter owns that one and an inline value outranks any stylesheet.
187
+ `--dsh-font-conversation-size:${String(contentSize)}px;`,
188
+ '}',
189
+ scaleRules,
190
+ '/* Conversation text sizes: absolute px, replacing the shipped 12..17px',
191
+ ' ladder that ui-layout drives from the `ui-theme` namespace. This is the',
192
+ ' size axis for conversation content; the scale above is the axis for the',
193
+ ' surrounding interface, and the two never compose. */',
194
+ 'html body{',
195
+ `--dsh-font-markdown-base:var(--dsh-font-conversation-size,14px) / calc(${String(contentSize)}px + 10px) var(--dsw-font-family) !important;`,
196
+ `--dsh-font-markdown-h1:700 calc(${String(contentSize)}px + 7px) / calc(${String(contentSize)}px + 16px) var(--dsw-font-family) !important;`,
197
+ `--dsh-font-markdown-h2:700 calc(${String(contentSize)}px + 5px) / calc(${String(contentSize)}px + 14px) var(--dsw-font-family) !important;`,
198
+ `--dsh-font-markdown-h3:700 calc(${String(contentSize)}px + 4px) / calc(${String(contentSize)}px + 12px) var(--dsw-font-family) !important;`,
199
+ `--dsh-font-markdown-h4:600 var(--dsh-font-conversation-size,14px) / calc(${String(contentSize)}px + 10px) var(--dsw-font-family) !important;`,
200
+ `--dsh-font-markdown-table:calc(${String(contentSize)}px - 1px) / calc(${String(contentSize)}px + 9px) var(--dsw-font-family) !important;`,
201
+ `--dsh-font-markdown-table-head:500 calc(${String(contentSize)}px - 1px) / calc(${String(contentSize)}px + 9px) var(--dsw-font-family) !important;`,
202
+ '}',
203
+ '/* Code text sizes, on their own axis. */',
204
+ 'html body{',
205
+ `--dsw-font-markdown-code:var(--dsh-font-code-size,12px) / calc(var(--dsh-font-code-size,12px) + 7px) var(--ds-font-family-code) !important;`,
206
+ `--dsw-font-markdown-code-font-size:var(--dsh-font-code-size,12px) !important;`,
207
+ `--dsw-font-markdown-code-block:var(--dsh-font-code-size,12px) / calc(var(--dsh-font-code-size,12px) + 8px) var(--ds-font-family-code) !important;`,
208
+ `--dsw-font-markdown-code-block-font-size:var(--dsh-font-code-size,12px) !important;`,
209
+ `--dsw-font-markdown-code-block-small:calc(var(--dsh-font-code-size,12px) - 1px) / calc(var(--dsh-font-code-size,12px) + 5px) var(--ds-font-family-code) !important;`,
210
+ `--dsw-font-markdown-code-block-small-font-size:calc(var(--dsh-font-code-size,12px) - 1px) !important;`,
211
+ '}',
212
+ ].join('\n')
213
+ }
214
+
215
+ /** The most recent resolved section; the observer re-stamps against it. */
216
+ let currentSection
217
+
218
+ /**
219
+ * Keep the interface-scale stamps current as the interface mounts new
220
+ * elements. A single coalesced pass per frame is enough: the conversation,
221
+ * tool panels, and dialogs all grow by appending nodes.
222
+ * @param ctx - client context owning the observer's lifetime.
223
+ */
224
+ function observeScale(ctx) {
225
+ if (typeof document === 'undefined' || typeof MutationObserver !== 'function') return
226
+ const target = document.body
227
+ if (target === null || target === undefined) return
228
+ let scheduled = false
229
+ const observer = new MutationObserver(() => {
230
+ if (scheduled || currentSection === undefined) return
231
+ scheduled = true
232
+ const run = () => {
233
+ scheduled = false
234
+ stampScaleClasses(currentSection[UI_FONT_SCALE_FIELD])
235
+ }
236
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
237
+ else setTimeout(run, 16)
238
+ })
239
+ ctx.effect(() => {
240
+ observer.observe(target, { childList: true, subtree: true })
241
+ return () => {
242
+ observer.disconnect()
243
+ }
244
+ }, 'dsh-font: interface scale observer')
245
+ }
246
+
247
+ /**
248
+ * Write the owned stylesheet and the two family tokens onto the document.
249
+ * @param section - resolved settings section.
250
+ */
251
+ function applyFonts(section) {
252
+ if (typeof document === 'undefined') return
253
+ currentSection = section
254
+ const uiFamily = section[UI_FONT_FAMILY_FIELD]
255
+ const codeFamily = section[CODE_FONT_FAMILY_FIELD]
256
+
257
+ let tag = document.getElementById(FONT_STYLE_ID)
258
+ if (tag === null) {
259
+ tag = document.createElement('style')
260
+ tag.id = FONT_STYLE_ID
261
+ tag.dataset.plugin = STYLE_PREFIX
262
+ const parent = document.head || document.documentElement
263
+ parent.appendChild(tag)
264
+ }
265
+ tag.textContent = fontStyleSheet(section)
266
+
267
+ // Pre-paint parity. The theme presenter re-asserts both of these on `body`
268
+ // from the override layer installed by `publishFamilyTokens`.
269
+ const root = document.documentElement.style
270
+ root.setProperty('--dsw-font-family', uiFamily)
271
+ root.setProperty('--ds-font-family-code', codeFamily)
272
+
273
+ // `--dsh-content-font-size` is an INLINE custom property on `body`, written
274
+ // by ui-layout's presenter from the `ui-theme` namespace. Inline declarations
275
+ // outrank a stylesheet regardless of `!important`, so the one authoritative
276
+ // value has to be written here too, in the same place the presenter writes.
277
+ if (document.body !== null && document.body !== undefined) {
278
+ document.body.style.setProperty(
279
+ '--dsh-content-font-size',
280
+ `${String(section[CONTENT_FONT_SIZE_FIELD])}px`,
281
+ )
282
+ }
283
+
284
+ stampScaleClasses(section[UI_FONT_SCALE_FIELD])
285
+ }
286
+
287
+ /** The stamped utility class for one shipped text size. */
288
+ function sizeClass(step) {
289
+ return `dsh-font-size-${String(step)}`
290
+ }
291
+
292
+ /** Selector matching any element already stamped by {@link stampScaleClasses}. */
293
+ const SCALE_STAMPED_SELECTOR = UI_TEXT_STEPS.map((step) => `.${sizeClass(step)}`).join(',')
294
+
295
+ /**
296
+ * The shipped text size measured for each element, so a later pass reuses the
297
+ * measurement instead of calling `getComputedStyle` again. Cleared whenever the
298
+ * stamps are removed, which is what makes a re-measure exact.
299
+ * @type {WeakMap<Element, number>}
300
+ */
301
+ const measuredScaleStep = new WeakMap()
302
+
303
+ /**
304
+ * Stamp the interface-scale utility class onto every element whose shipped
305
+ * stylesheet fixes a font size in px.
306
+ *
307
+ * The shipped design system has no interface size variable — each component
308
+ * hard-codes one of {@link UI_TEXT_STEPS} — so the scale is applied per element
309
+ * rather than through inheritance. That keeps it a deterministic override with
310
+ * no compounding, and it needs no knowledge of any component's class names.
311
+ *
312
+ * An element is measured while it carries no stamp; changing the scale clears
313
+ * every stamp first, which restores the shipped value and re-measures.
314
+ * @param scale - current interface scale; 1 means "shipped sizes" and stamps nothing.
315
+ */
316
+ function stampScaleClasses(scale) {
317
+ if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return
318
+ const root = document.body
319
+ if (root === null || root === undefined) return
320
+ if (typeof root.querySelectorAll !== 'function') return
321
+
322
+ const stamped = [...root.querySelectorAll(SCALE_STAMPED_SELECTOR)]
323
+ if (scale === 1) {
324
+ for (const element of stamped) {
325
+ for (const step of UI_TEXT_STEPS) element.classList.remove(sizeClass(step))
326
+ measuredScaleStep.delete(element)
327
+ }
328
+ return
329
+ }
330
+
331
+ const candidates = [...stamped, ...root.querySelectorAll('*')].filter(
332
+ (element) =>
333
+ element.classList !== undefined &&
334
+ (element.className !== '' ||
335
+ (typeof element.getAttribute === 'function' && element.getAttribute('style') !== null)),
336
+ )
337
+
338
+ for (const element of candidates) {
339
+ let step = measuredScaleStep.get(element)
340
+ if (step === undefined) {
341
+ const measured = getComputedStyle(element).fontSize
342
+ if (typeof measured !== 'string' || !measured.endsWith('px')) continue
343
+ const exact = Number.parseFloat(measured)
344
+ const rounded = Math.round(exact)
345
+ if (!UI_TEXT_STEPS.includes(rounded) || Math.abs(exact - rounded) > 0.01) continue
346
+ step = rounded
347
+ measuredScaleStep.set(element, step)
348
+ }
349
+ // Move the element to the stamp for its measured size, dropping any other.
350
+ for (const candidate of UI_TEXT_STEPS) {
351
+ if (candidate !== step) element.classList.remove(sizeClass(candidate))
352
+ }
353
+ element.classList.add(sizeClass(step))
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Stack the two family tokens onto the design system through the theme
359
+ * service, so the theme presenter keeps them applied across palette changes.
360
+ * @param ctx - client context carrying the theme service.
361
+ * @param section - resolved settings section.
362
+ */
363
+ function publishFamilyTokens(ctx, section) {
364
+ const theme = ctx.get('theme')
365
+ if (theme === undefined) return
366
+ theme.overrideTokens(PLUGIN_ID, {
367
+ '--dsw-font-family': {
368
+ light: section[UI_FONT_FAMILY_FIELD],
369
+ dark: section[UI_FONT_FAMILY_FIELD],
370
+ },
371
+ '--ds-font-family-code': {
372
+ light: section[CODE_FONT_FAMILY_FIELD],
373
+ dark: section[CODE_FONT_FAMILY_FIELD],
374
+ },
375
+ })
376
+ }
377
+
378
+ /** Live-state store behind the General settings row. */
379
+ function createFontRowStore() {
380
+ return _deepseek_ai_dsh_client_store.defineStore({
381
+ init: () => ({
382
+ uiFontFamily: DEFAULT_UI_FONT_FAMILY,
383
+ codeFontFamily: DEFAULT_CODE_FONT_FAMILY,
384
+ uiFontScale: 1,
385
+ contentFontSize: 14,
386
+ codeFontSize: 12,
387
+ revision: -1,
388
+ }),
389
+ actions: {
390
+ sync: (draft, section, revision) => {
391
+ if (revision !== undefined && revision <= draft.revision) return
392
+ draft.uiFontFamily = section[UI_FONT_FAMILY_FIELD]
393
+ draft.codeFontFamily = section[CODE_FONT_FAMILY_FIELD]
394
+ draft.uiFontScale = section[UI_FONT_SCALE_FIELD]
395
+ draft.contentFontSize = section[CONTENT_FONT_SIZE_FIELD]
396
+ draft.codeFontSize = section[CODE_FONT_SIZE_FIELD]
397
+ if (revision !== undefined) draft.revision = revision
398
+ },
399
+ },
400
+ })
401
+ }
402
+
403
+ /** The stylesheet for the row's own chrome. */
404
+ const ROW_CSS = [
405
+ '.dsh-font-row{border-bottom:.5px solid var(--dsw-alias-border-l2);flex-direction:column;gap:16px;padding:16px 0;display:flex}',
406
+ '.dsh-font-head{flex-direction:column;gap:4px;display:flex}',
407
+ '.dsh-font-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}',
408
+ '.dsh-font-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}',
409
+ '.dsh-font-field{flex-direction:column;gap:6px;display:flex}',
410
+ '.dsh-font-labelRow{align-items:center;justify-content:space-between;gap:8px;display:flex}',
411
+ '.dsh-font-label{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:20px}',
412
+ '.dsh-font-value{color:var(--dsw-alias-label-secondary);font-size:12px;font-variant-numeric:tabular-nums;line-height:18px}',
413
+ '.dsh-font-hint{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}',
414
+ '.dsh-font-input{box-sizing:border-box;width:100%;height:32px;padding:0 10px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-module-platform);border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;font-family:inherit;font-size:13px;line-height:20px;outline:none}',
415
+ '.dsh-font-input:focus{border-color:var(--dsw-alias-brand-primary)}',
416
+ '.dsh-font-code{font-family:var(--ds-font-family-code)}',
417
+ '.dsh-font-sliderRow{align-items:center;gap:10px;display:flex}',
418
+ '.dsh-font-slider{flex:1;min-width:0;accent-color:var(--dsw-alias-brand-primary)}',
419
+ '.dsh-font-stepper{background:var(--dsw-alias-bg-module-platform);border-radius:16px;justify-content:center;align-items:center;gap:2px;min-width:96px;height:32px;display:inline-flex;flex:none;padding:0 4px}',
420
+ '.dsh-font-step{border:none;background:0 0;color:var(--dsw-alias-label-primary);cursor:pointer;width:20px;height:20px;border-radius:4px;justify-content:center;align-items:center;padding:0;display:inline-flex}',
421
+ '.dsh-font-step:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
422
+ '.dsh-font-step:disabled{color:var(--dsw-alias-label-caption);cursor:default}',
423
+ '.dsh-font-stepValue{text-align:center;min-width:32px;color:var(--dsw-alias-label-primary);font-size:13px;font-variant-numeric:tabular-nums;line-height:20px}',
424
+ '.dsh-font-presets{flex-wrap:wrap;gap:6px;display:flex}',
425
+ '.dsh-font-chip{border:.5px solid var(--dsw-alias-border-l4);background:0 0;color:var(--dsw-alias-label-secondary);cursor:pointer;border-radius:12px;padding:3px 10px;font-family:inherit;font-size:11px;line-height:16px}',
426
+ '.dsh-font-chip:hover{background:var(--dsw-alias-interactive-bg-hover)}',
427
+ '.dsh-font-chip[data-active="true"]{border-color:var(--dsw-static-neutral-bluish-400);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-module-platform)}',
428
+ '.dsh-font-reset{align-self:flex-start;border:.5px solid var(--dsw-alias-border-l4);background:0 0;color:var(--dsw-alias-label-primary);cursor:pointer;border-radius:10px;padding:5px 12px;font-family:inherit;font-size:12px;line-height:18px}',
429
+ '.dsh-font-reset:hover{background:var(--dsw-alias-interactive-bg-hover)}',
430
+ ].join('')
431
+
432
+ /** Install the row chrome stylesheet for the plugin's lifetime. */
433
+ function installRowStyles(ctx) {
434
+ if (typeof document === 'undefined') return
435
+ ctx.effect(() => {
436
+ const tag = document.createElement('style')
437
+ tag.dataset.plugin = STYLE_PREFIX
438
+ tag.dataset.pluginCss = `${STYLE_PREFIX}/row.css`
439
+ tag.textContent = ROW_CSS
440
+ document.head.appendChild(tag)
441
+ return () => {
442
+ tag.remove()
443
+ }
444
+ }, 'dsh-font: row stylesheet')
445
+ }
446
+
447
+ /**
448
+ * One labelled field wrapper.
449
+ * @param props - _react props.
450
+ * @returns the field element.
451
+ */
452
+ function Field({ label, value, hint, children }) {
453
+ return _react.createElement(
454
+ 'div',
455
+ { className: 'dsh-font-field' },
456
+ _react.createElement(
457
+ 'div',
458
+ { className: 'dsh-font-labelRow' },
459
+ _react.createElement('span', { className: 'dsh-font-label' }, label),
460
+ value === undefined
461
+ ? null
462
+ : _react.createElement('span', { className: 'dsh-font-value' }, value),
463
+ ),
464
+ children,
465
+ hint === undefined
466
+ ? null
467
+ : _react.createElement('div', { className: 'dsh-font-hint' }, hint),
468
+ )
469
+ }
470
+
471
+ /**
472
+ * A commit-on-blur/Enter text input that always reflects the durable value
473
+ * once the user stops editing.
474
+ * @param props - _react props.
475
+ * @returns the input element.
476
+ */
477
+ function FamilyInput({ value, placeholder, onCommit, monospace }) {
478
+ const [draft, setDraft] = _react.useState(value)
479
+ const editing = _react.useRef(false)
480
+
481
+ _react.useEffect(() => {
482
+ if (!editing.current) setDraft(value)
483
+ }, [value])
484
+
485
+ const commit = _react.useCallback(() => {
486
+ editing.current = false
487
+ const next = draft.trim()
488
+ if (next === '') {
489
+ setDraft(value)
490
+ return
491
+ }
492
+ if (next !== value) onCommit(next)
493
+ }, [draft, onCommit, value])
494
+
495
+ return _react.createElement('input', {
496
+ type: 'text',
497
+ spellCheck: false,
498
+ className: monospace === true ? 'dsh-font-input dsh-font-code' : 'dsh-font-input',
499
+ value: draft,
500
+ placeholder,
501
+ onChange: (event) => {
502
+ editing.current = true
503
+ setDraft(event.target.value)
504
+ },
505
+ onBlur: commit,
506
+ onKeyDown: (event) => {
507
+ if (event.key === 'Enter') event.currentTarget.blur()
508
+ if (event.key === 'Escape') {
509
+ editing.current = false
510
+ setDraft(value)
511
+ event.currentTarget.blur()
512
+ }
513
+ },
514
+ })
515
+ }
516
+
517
+ /**
518
+ * A horizontal slider plus an exact stepper.
519
+ * @param props - _react props.
520
+ * @returns the control element.
521
+ */
522
+ function SliderControl({ min, max, step, value, format, onChange, ariaLabel, increase, decrease }) {
523
+ return _react.createElement(
524
+ 'div',
525
+ { className: 'dsh-font-sliderRow' },
526
+ _react.createElement('input', {
527
+ type: 'range',
528
+ className: 'dsh-font-slider',
529
+ min,
530
+ max,
531
+ step,
532
+ value,
533
+ 'aria-label': ariaLabel,
534
+ onChange: (event) => {
535
+ onChange(Number(event.target.value))
536
+ },
537
+ }),
538
+ _react.createElement(
539
+ 'div',
540
+ { className: 'dsh-font-stepper' },
541
+ _react.createElement(
542
+ 'button',
543
+ {
544
+ type: 'button',
545
+ className: 'dsh-font-step',
546
+ 'aria-label': decrease,
547
+ disabled: value <= min,
548
+ onClick: () => {
549
+ onChange(Number((value - step).toFixed(2)))
550
+ },
551
+ },
552
+ _react.createElement(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { size: 9 }),
553
+ ),
554
+ _react.createElement('span', { className: 'dsh-font-stepValue' }, format(value)),
555
+ _react.createElement(
556
+ 'button',
557
+ {
558
+ type: 'button',
559
+ className: 'dsh-font-step',
560
+ 'aria-label': increase,
561
+ disabled: value >= max,
562
+ onClick: () => {
563
+ onChange(Number((value + step).toFixed(2)))
564
+ },
565
+ },
566
+ _react.createElement(_deepseek_ai_dsh_client_ui_primitives.IconChevronUpOutline14, { size: 9 }),
567
+ ),
568
+ ),
569
+ )
570
+ }
571
+
572
+ /**
573
+ * A row of one-click family presets.
574
+ * @param props - _react props.
575
+ * @returns the preset strip.
576
+ */
577
+ function PresetStrip({ presets, current, onPick }) {
578
+ return _react.createElement(
579
+ 'div',
580
+ { className: 'dsh-font-presets' },
581
+ presets.map((preset) =>
582
+ _react.createElement(
583
+ 'button',
584
+ {
585
+ key: preset.id,
586
+ type: 'button',
587
+ className: 'dsh-font-chip',
588
+ 'data-active': preset.value === current ? 'true' : 'false',
589
+ onClick: () => {
590
+ onPick(preset.value)
591
+ },
592
+ },
593
+ preset.label,
594
+ ),
595
+ ),
596
+ )
597
+ }
598
+
599
+ /**
600
+ * The General-settings row: five controls over the `ui-font` namespace.
601
+ * @param props - composed slot props (`t`, `useStore`, and the inject actions).
602
+ * @returns the row element tree.
603
+ */
604
+ function FontRow({ t, useStore, setField, reset }) {
605
+ const uiFontFamily = useStore((s) => s.uiFontFamily)
606
+ const codeFontFamily = useStore((s) => s.codeFontFamily)
607
+ const uiFontScale = useStore((s) => s.uiFontScale)
608
+ const contentFontSize = useStore((s) => s.contentFontSize)
609
+ const codeFontSize = useStore((s) => s.codeFontSize)
610
+
611
+ return _react.createElement(
612
+ 'div',
613
+ { className: 'dsh-font-row' },
614
+ _react.createElement(
615
+ 'div',
616
+ { className: 'dsh-font-head' },
617
+ _react.createElement('div', { className: 'dsh-font-title' }, t('font.title')),
618
+ _react.createElement('div', { className: 'dsh-font-desc' }, t('font.description')),
619
+ ),
620
+ _react.createElement(
621
+ Field,
622
+ { label: t('font.uiFamily'), hint: t('font.uiFamilyHint') },
623
+ _react.createElement(FamilyInput, {
624
+ value: uiFontFamily,
625
+ placeholder: DEFAULT_UI_FONT_FAMILY,
626
+ onCommit: (value) => {
627
+ setField(UI_FONT_FAMILY_FIELD, value)
628
+ },
629
+ }),
630
+ _react.createElement(PresetStrip, {
631
+ presets: UI_FAMILY_PRESETS,
632
+ current: uiFontFamily,
633
+ onPick: (value) => {
634
+ setField(UI_FONT_FAMILY_FIELD, value)
635
+ },
636
+ }),
637
+ ),
638
+ _react.createElement(
639
+ Field,
640
+ { label: t('font.codeFamily'), hint: t('font.codeFamilyHint') },
641
+ _react.createElement(FamilyInput, {
642
+ value: codeFontFamily,
643
+ placeholder: DEFAULT_CODE_FONT_FAMILY,
644
+ monospace: true,
645
+ onCommit: (value) => {
646
+ setField(CODE_FONT_FAMILY_FIELD, value)
647
+ },
648
+ }),
649
+ _react.createElement(PresetStrip, {
650
+ presets: CODE_FAMILY_PRESETS,
651
+ current: codeFontFamily,
652
+ onPick: (value) => {
653
+ setField(CODE_FONT_FAMILY_FIELD, value)
654
+ },
655
+ }),
656
+ ),
657
+ _react.createElement(
658
+ Field,
659
+ {
660
+ label: t('font.uiScale'),
661
+ value: `${String(Math.round(uiFontScale * 100))}%`,
662
+ hint: t('font.uiScaleHint'),
663
+ },
664
+ _react.createElement(SliderControl, {
665
+ min: UI_FONT_SCALE_MIN,
666
+ max: UI_FONT_SCALE_MAX,
667
+ step: UI_FONT_SCALE_STEP,
668
+ value: uiFontScale,
669
+ format: (value) => `${String(Math.round(value * 100))}%`,
670
+ ariaLabel: t('font.uiScale'),
671
+ increase: t('font.increase'),
672
+ decrease: t('font.decrease'),
673
+ onChange: (value) => {
674
+ setField(UI_FONT_SCALE_FIELD, value)
675
+ },
676
+ }),
677
+ ),
678
+ _react.createElement(
679
+ Field,
680
+ {
681
+ label: t('font.contentSize'),
682
+ value: `${String(contentFontSize)} ${t('font.unit')}`,
683
+ hint: t('font.contentSizeHint'),
684
+ },
685
+ _react.createElement(SliderControl, {
686
+ min: CONTENT_FONT_SIZE_MIN,
687
+ max: CONTENT_FONT_SIZE_MAX,
688
+ step: 1,
689
+ value: contentFontSize,
690
+ format: (value) => `${String(value)} ${t('font.unit')}`,
691
+ ariaLabel: t('font.contentSize'),
692
+ increase: t('font.increase'),
693
+ decrease: t('font.decrease'),
694
+ onChange: (value) => {
695
+ setField(CONTENT_FONT_SIZE_FIELD, value)
696
+ },
697
+ }),
698
+ ),
699
+ _react.createElement(
700
+ Field,
701
+ {
702
+ label: t('font.codeSize'),
703
+ value: `${String(codeFontSize)} ${t('font.unit')}`,
704
+ hint: t('font.codeSizeHint'),
705
+ },
706
+ _react.createElement(SliderControl, {
707
+ min: CODE_FONT_SIZE_MIN,
708
+ max: CODE_FONT_SIZE_MAX,
709
+ step: 1,
710
+ value: codeFontSize,
711
+ format: (value) => `${String(value)} ${t('font.unit')}`,
712
+ ariaLabel: t('font.codeSize'),
713
+ increase: t('font.increase'),
714
+ decrease: t('font.decrease'),
715
+ onChange: (value) => {
716
+ setField(CODE_FONT_SIZE_FIELD, value)
717
+ },
718
+ }),
719
+ ),
720
+ _react.createElement(
721
+ 'button',
722
+ {
723
+ type: 'button',
724
+ className: 'dsh-font-reset',
725
+ onClick: () => {
726
+ reset()
727
+ },
728
+ },
729
+ t('font.reset'),
730
+ ),
731
+ )
732
+ }
733
+
734
+ /**
735
+ * The services this plugin waits for: the settings transport plus slots/locale
736
+ * for the row. The theme service is read through `ctx.get` (optional) so a
737
+ * composition without `ui-theme` still gets families from the stylesheet path.
738
+ */
739
+ const inject = ['slots', 'locale', 'settingsScope']
740
+
741
+ /**
742
+ * Client plugin body: resolve the durable section, paint it, and register the
743
+ * feature-owned Fonts row into the General section's item slot.
744
+ * @param ctx - client cordis context.
745
+ */
746
+ function apply(ctx) {
747
+ installRowStyles(ctx)
748
+ observeScale(ctx)
749
+
750
+ const scope = ctx.settingsScope.bind({
751
+ namespace: FONT_SETTINGS_NAMESPACE,
752
+ decode: (section) => {
753
+ if (section === null || typeof section !== 'object') return undefined
754
+ const raw = section
755
+ const text = (key, fallback) =>
756
+ typeof raw[key] === 'string' && raw[key] !== '' ? raw[key] : fallback
757
+ const number = (key, fallback, min, max) => {
758
+ const value = Number(raw[key])
759
+ return Number.isFinite(value) && value >= min && value <= max ? value : fallback
760
+ }
761
+ return {
762
+ [UI_FONT_FAMILY_FIELD]: text(UI_FONT_FAMILY_FIELD, DEFAULT_UI_FONT_FAMILY),
763
+ [CODE_FONT_FAMILY_FIELD]: text(CODE_FONT_FAMILY_FIELD, DEFAULT_CODE_FONT_FAMILY),
764
+ [UI_FONT_SCALE_FIELD]: number(UI_FONT_SCALE_FIELD, 1, UI_FONT_SCALE_MIN, UI_FONT_SCALE_MAX),
765
+ [CONTENT_FONT_SIZE_FIELD]: Math.round(
766
+ number(CONTENT_FONT_SIZE_FIELD, 14, CONTENT_FONT_SIZE_MIN, CONTENT_FONT_SIZE_MAX),
767
+ ),
768
+ [CODE_FONT_SIZE_FIELD]: Math.round(
769
+ number(CODE_FONT_SIZE_FIELD, 12, CODE_FONT_SIZE_MIN, CODE_FONT_SIZE_MAX),
770
+ ),
771
+ }
772
+ },
773
+ })
774
+
775
+ const store = createFontRowStore()
776
+
777
+ ctx.effect(
778
+ () =>
779
+ ctx.locale.register(SETTINGS_LOCALE_NAMESPACE, {
780
+ zh,
781
+ en,
782
+ }),
783
+ 'dsh-font: settings row dictionaries',
784
+ )
785
+
786
+ const defaults = {
787
+ [UI_FONT_FAMILY_FIELD]: DEFAULT_UI_FONT_FAMILY,
788
+ [CODE_FONT_FAMILY_FIELD]: DEFAULT_CODE_FONT_FAMILY,
789
+ [UI_FONT_SCALE_FIELD]: 1,
790
+ [CONTENT_FONT_SIZE_FIELD]: 14,
791
+ [CODE_FONT_SIZE_FIELD]: 12,
792
+ }
793
+
794
+ let actions
795
+
796
+ const paint = (section, revision) => {
797
+ actions?.sync(section, revision)
798
+ applyFonts(section)
799
+ publishFamilyTokens(ctx, section)
800
+ }
801
+
802
+ ctx.effect(
803
+ () =>
804
+ scope.subscribe(() => {
805
+ const snapshot = scope.getSnapshot()
806
+ if (snapshot.value === undefined) return
807
+ paint(snapshot.value, snapshot.revision)
808
+ }),
809
+ 'dsh-font: settings adoption',
810
+ )
811
+
812
+ const initial = scope.getSnapshot()
813
+ paint(initial.value ?? defaults, initial.revision)
814
+
815
+ ctx.slots.inject('settings.general.item', () =>
816
+ ctx.slots.register(
817
+ {
818
+ name: 'settings.general.item',
819
+ id: 'font',
820
+ order: 12,
821
+ store,
822
+ locale: SETTINGS_LOCALE_NAMESPACE,
823
+ inject: (bound) => {
824
+ actions = bound
825
+ const snapshot = scope.getSnapshot()
826
+ paint(snapshot.value ?? defaults, snapshot.revision)
827
+ return {
828
+ setField: (field, value) => {
829
+ scope.set(field, value)
830
+ },
831
+ reset: () => {
832
+ for (const field of Object.keys(defaults)) scope.unset(field)
833
+ },
834
+ }
835
+ },
836
+ },
837
+ FontRow,
838
+ ),
839
+ )
840
+ }
841
+ exports.apply = apply;
842
+ exports.inject = inject;
843
+ exports.fontStyleSheet = fontStyleSheet;
844
+ exports.applyFonts = applyFonts;
845
+ return module.exports;
846
+ }
847
+ });