@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.
@@ -0,0 +1,410 @@
1
+ /**
2
+ * Load an emitted DSH client bundle in Node, assert its envelope, and then run
3
+ * `apply(ctx)` against stub services to prove the presentation path works.
4
+ *
5
+ * A broken client bundle otherwise fails only in the browser, where the
6
+ * diagnostic is a console error inside the boot audit. This check makes the
7
+ * cheap-to-catch failure modes — a bundle that registers nothing, one whose
8
+ * factory throws, one that paints nothing, one that registers no Settings row —
9
+ * fail on the command line instead.
10
+ *
11
+ * Usage:
12
+ * node scripts/verify-client.mjs [path/to/lib/client.js]
13
+ */
14
+
15
+ import assert from 'node:assert/strict'
16
+ import { readFileSync } from 'node:fs'
17
+ import { dirname, join, resolve } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+
20
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
21
+ const bundlePath = resolve(process.argv[2] ?? join(root, 'lib', 'client.js'))
22
+ const source = readFileSync(bundlePath, 'utf8')
23
+
24
+ /** The package name, which the bundle id must equal. */
25
+ const PACKAGE_NAME = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name
26
+
27
+ /** Minimal React stub: enough for the row component to build a tree. */
28
+ const react = {
29
+ createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }),
30
+ useCallback: (fn) => fn,
31
+ useEffect: () => undefined,
32
+ useRef: (value) => ({ current: value }),
33
+ useState: (value) => [value, () => undefined],
34
+ }
35
+
36
+ /** A tiny observable store, matching the `@deepseek-ai/dsh-client-store` face. */
37
+ const stores = []
38
+ const storeModule = {
39
+ defineStore: (spec) => {
40
+ const state = spec.init()
41
+ const listeners = new Set()
42
+ const handle = {
43
+ spec,
44
+ state,
45
+ create: () =>
46
+ Object.fromEntries(
47
+ Object.entries(spec.actions).map(([name, action]) => [
48
+ name,
49
+ (...args) => {
50
+ action(state, ...args)
51
+ for (const listener of listeners) listener()
52
+ },
53
+ ]),
54
+ ),
55
+ getSnapshot: () => state,
56
+ subscribe: (listener) => {
57
+ listeners.add(listener)
58
+ return () => listeners.delete(listener)
59
+ },
60
+ }
61
+ stores.push(handle)
62
+ return handle
63
+ },
64
+ }
65
+
66
+ const stubs = {
67
+ react,
68
+ 'react/jsx-runtime': { jsx: react.createElement, jsxs: react.createElement },
69
+ 'react-dom': {},
70
+ 'react-dom/client': {},
71
+ '@deepseek-ai/cordis': {},
72
+ '@deepseek-ai/dsh-client-store': storeModule,
73
+ '@deepseek-ai/dsh-client-ui-slots': {},
74
+ '@deepseek-ai/dsh-client-ui-primitives': new Proxy(
75
+ {},
76
+ { get: (_target, key) => (key === 'then' ? undefined : () => null) },
77
+ ),
78
+ '@deepseek-ai/dsh-client-ui-dockkit': {},
79
+ }
80
+
81
+ const registrations = []
82
+ globalThis.window = {
83
+ __ModuleLoader__: {
84
+ load(registration) {
85
+ registrations.push(registration)
86
+ },
87
+ },
88
+ }
89
+
90
+ const requested = []
91
+ const requireStub = (specifier) => {
92
+ requested.push(specifier)
93
+ if (!(specifier in stubs)) throw new Error(`unknown platform module "${specifier}"`)
94
+ return stubs[specifier]
95
+ }
96
+
97
+ // eslint-disable-next-line no-eval -- the bundle is a classic script by contract
98
+ ;(0, eval)(source)
99
+
100
+ assert.equal(registrations.length, 1, 'the bundle must register exactly one factory')
101
+ const [registration] = registrations
102
+ assert.equal(registration.id, PACKAGE_NAME)
103
+ assert.equal(typeof registration.factory, 'function')
104
+
105
+ const plugin = registration.factory(requireStub)
106
+ assert.equal(typeof plugin.apply, 'function', 'bundle must export apply()')
107
+ assert.ok(Array.isArray(plugin.inject), 'bundle must export inject as an array')
108
+ assert.deepEqual(plugin.inject, ['slots', 'locale', 'settingsScope'])
109
+ assert.equal(typeof plugin.fontStyleSheet, 'function')
110
+ assert.equal(typeof plugin.applyFonts, 'function')
111
+
112
+ // The build must have substituted the template's identity placeholder, or the
113
+ // bundle would register the placeholder instead of the real package name.
114
+ assert.ok(
115
+ !source.includes('dsh:plugin-id'),
116
+ 'the identity placeholder must be substituted at build time',
117
+ )
118
+
119
+ // ── the stylesheet builder ──────────────────────────────────────────────────
120
+ const section = {
121
+ uiFontFamily: 'Inter, sans-serif',
122
+ codeFontFamily: '"JetBrains Mono", monospace',
123
+ uiFontScale: 1.25,
124
+ contentFontSize: 16,
125
+ codeFontSize: 13,
126
+ }
127
+ const sheet = plugin.fontStyleSheet(section)
128
+ assert.match(sheet, /--dsh-font-ui-scale:1\.25;/)
129
+ assert.match(sheet, /--dsh-font-code-size:13px;/)
130
+ // The content size is an INLINE custom property on `body` (ui-layout's theme
131
+ // presenter owns that declaration), so the sheet must not declare it — an
132
+ // inline value would win and nothing here could override it.
133
+ assert.ok(
134
+ !sheet.includes('--dsh-content-font-size:'),
135
+ 'the sheet must not declare the inline-owned content size',
136
+ )
137
+ assert.match(sheet, /\.dsh-font-size-14\{font-size:calc\(14px \* var\(--dsh-font-ui-scale,1\)\) !important\}/)
138
+ for (const step of [11, 12, 13, 14, 16, 20, 24]) {
139
+ assert.ok(sheet.includes(`.dsh-font-size-${String(step)}{`), `missing scale class for ${String(step)}px`)
140
+ }
141
+ // The scale is stamped per element, never inherited from a universal rule.
142
+ assert.ok(
143
+ !sheet.includes('html body,html body *'),
144
+ 'the scale must not be a universal rule (it would compound with the content size)',
145
+ )
146
+
147
+ // The conversation ladder must be absolute px derived from the content slider.
148
+ assert.match(sheet, /--dsh-font-markdown-h1:700 calc\(16px \+ 7px\) \/ calc\(16px \+ 16px\)/)
149
+ assert.match(sheet, /--dsh-font-markdown-base:var\(--dsh-font-conversation-size,14px\) \/ calc\(16px \+ 10px\)/)
150
+ assert.match(sheet, /--dsw-font-markdown-code-block-font-size:var\(--dsh-font-code-size,12px\) !important;/)
151
+
152
+ // A different content size must move the ladder, not just the base variable.
153
+ const bigger = plugin.fontStyleSheet({ ...section, contentFontSize: 20 })
154
+ assert.match(bigger, /--dsh-font-markdown-h1:700 calc\(20px \+ 7px\) \/ calc\(20px \+ 16px\)/)
155
+ assert.notEqual(bigger, sheet)
156
+
157
+ // ── applyFonts against a DOM stub ───────────────────────────────────────────
158
+ const appended = []
159
+ const styleTags = []
160
+ const rootProperties = new Map()
161
+ const bodyProperties = new Map()
162
+ const makeNode = (tagName) => ({
163
+ tagName,
164
+ id: '',
165
+ textContent: '',
166
+ dataset: {},
167
+ className: '',
168
+ appendChild: (child) => appended.push(child),
169
+ querySelectorAll: () => [],
170
+ classList: { add: () => undefined, remove: () => undefined },
171
+ getAttribute: () => null,
172
+ })
173
+ globalThis.document = {
174
+ head: makeNode('head'),
175
+ body: { ...makeNode('body'), style: { setProperty: (name, value) => bodyProperties.set(name, value) } },
176
+ documentElement: { style: { setProperty: (name, value) => rootProperties.set(name, value) } },
177
+ getElementById: (id) => styleTags.find((tag) => tag.id === id) ?? null,
178
+ createElement: (tagName) => {
179
+ const node = makeNode(tagName)
180
+ styleTags.push(node)
181
+ return node
182
+ },
183
+ }
184
+ globalThis.console = console
185
+
186
+ plugin.applyFonts(section)
187
+ assert.equal(styleTags.length, 1, 'applyFonts must create one stylesheet')
188
+ assert.equal(styleTags[0].tagName, 'style')
189
+ assert.equal(styleTags[0].id, 'dsh-font/variables')
190
+ assert.equal(styleTags[0].dataset.plugin, 'dsh-font')
191
+
192
+ // The host half looks this element up by the same id when it repaints the
193
+ // pre-paint row; a drift between the two halves would silently stack two
194
+ // stylesheets with the later one winning.
195
+ const hostSource = readFileSync(join(root, 'lib', 'index.js'), 'utf8')
196
+ const hostStyleId = /const FONT_STYLE_ID = '([^']+)'/.exec(hostSource)?.[1]
197
+ assert.equal(
198
+ hostStyleId,
199
+ styleTags[0].id,
200
+ 'lib/index.js FONT_STYLE_ID must equal the client half stylesheet id',
201
+ )
202
+
203
+ assert.match(styleTags[0].textContent, /\.dsh-font-size-14\{/)
204
+ assert.equal(rootProperties.get('--dsw-font-family'), 'Inter, sans-serif')
205
+ assert.equal(rootProperties.get('--ds-font-family-code'), '"JetBrains Mono", monospace')
206
+ // The content size must be written where ui-layout writes it, or the inline
207
+ // presenter value would win over the stylesheet.
208
+ assert.equal(bodyProperties.get('--dsh-content-font-size'), '16px')
209
+
210
+ // Re-applying must rewrite the same tag, not accumulate stylesheets.
211
+ plugin.applyFonts({ ...section, contentFontSize: 18, uiFontScale: 1 })
212
+ assert.equal(styleTags.length, 1, 'applyFonts must reuse its own stylesheet tag')
213
+ assert.match(styleTags[0].textContent, /--dsh-font-ui-scale:1;/)
214
+ assert.equal(bodyProperties.get('--dsh-content-font-size'), '18px')
215
+
216
+ // ── the interface-scale stamping pass ───────────────────────────────────────
217
+ // A fake tree whose computed sizes are the shipped ones.
218
+ const sizes = new Map()
219
+ const fakeElement = (size, extras = {}) => {
220
+ const classes = new Set()
221
+ const element = {
222
+ className: 'x',
223
+ classList: {
224
+ contains: (name) => classes.has(name),
225
+ add: (name) => classes.add(name),
226
+ remove: (name) => classes.delete(name),
227
+ },
228
+ getAttribute: () => null,
229
+ classes,
230
+ ...extras,
231
+ }
232
+ sizes.set(element, `${String(size)}px`)
233
+ return element
234
+ }
235
+ const shipped14 = fakeElement(14)
236
+ const shipped12 = fakeElement(12)
237
+ const shipped9 = fakeElement(9)
238
+ const shipped17 = fakeElement(17)
239
+ const inlineStyled = fakeElement(16)
240
+ inlineStyled.className = ''
241
+ inlineStyled.getAttribute = (name) => (name === 'style' ? 'color:red' : null)
242
+
243
+ const allElements = [shipped14, shipped12, shipped9, shipped17, inlineStyled]
244
+ const stampedSelector = /\.dsh-font-size-(\d+)(?:,|$)/
245
+ globalThis.document = {
246
+ head: makeNode('head'),
247
+ body: {
248
+ ...makeNode('body'),
249
+ style: { setProperty: () => undefined },
250
+ querySelectorAll: (selector) =>
251
+ selector.startsWith('.dsh-font-size-')
252
+ ? allElements.filter((element) => [...element.classes].some((name) => name.startsWith('dsh-font-size-')))
253
+ : allElements,
254
+ },
255
+ documentElement: { style: { setProperty: () => undefined } },
256
+ getElementById: () => null,
257
+ createElement: (tagName) => makeNode(tagName),
258
+ }
259
+ globalThis.getComputedStyle = (element) => ({ fontSize: sizes.get(element) ?? '' })
260
+
261
+ plugin.applyFonts({ ...section, uiFontScale: 1.25 })
262
+ assert.ok(shipped14.classes.has('dsh-font-size-14'), '14px must be stamped')
263
+ assert.ok(shipped12.classes.has('dsh-font-size-12'), '12px must be stamped')
264
+ assert.ok(inlineStyled.classes.has('dsh-font-size-16'), 'inline-styled 16px must be stamped')
265
+ assert.equal(shipped9.classes.size, 0, 'an unlisted size must not be stamped')
266
+ assert.equal(shipped17.classes.size, 0, 'an unlisted size must not be stamped')
267
+
268
+ // A repeat pass reuses the measurement and does not duplicate stamps.
269
+ plugin.applyFonts({ ...section, uiFontScale: 1.5 })
270
+ assert.equal(shipped14.classes.size, 1, 'a repeat pass must not accumulate stamps')
271
+
272
+ // Returning to 1 must clear every stamp.
273
+ plugin.applyFonts({ ...section, uiFontScale: 1 })
274
+ assert.equal(shipped14.classes.size, 0, 'scale 1 must clear the stamps')
275
+ assert.equal(inlineStyled.classes.size, 0, 'scale 1 must clear the stamps')
276
+ void stampedSelector
277
+
278
+ // Restore a recording DOM for the remaining checks.
279
+ rootProperties.clear()
280
+ bodyProperties.clear()
281
+ globalThis.document = {
282
+ head: makeNode('head'),
283
+ body: { ...makeNode('body'), style: { setProperty: (name, value) => bodyProperties.set(name, value) } },
284
+ documentElement: { style: { setProperty: (name, value) => rootProperties.set(name, value) } },
285
+ getElementById: (id) => styleTags.find((tag) => tag.id === id) ?? null,
286
+ createElement: (tagName) => {
287
+ const node = makeNode(tagName)
288
+ styleTags.push(node)
289
+ return node
290
+ },
291
+ }
292
+
293
+ // ── apply(ctx) end to end ───────────────────────────────────────────────────
294
+ const themeOverrides = []
295
+ const registeredSlots = []
296
+ const dictionaries = []
297
+ const locale = {
298
+ register: (namespace, dict) => {
299
+ dictionaries.push({ namespace, dict })
300
+ return () => undefined
301
+ },
302
+ }
303
+
304
+ let scopeListener
305
+ const scope = {
306
+ getSnapshot: () => ({
307
+ status: 'ready',
308
+ value: section,
309
+ revision: 7,
310
+ writable: true,
311
+ mode: 'host',
312
+ }),
313
+ subscribe: (listener) => {
314
+ scopeListener = listener
315
+ return () => undefined
316
+ },
317
+ set: () => Promise.resolve(),
318
+ unset: () => Promise.resolve(),
319
+ }
320
+
321
+ const ctx = {
322
+ effect: (execute) => {
323
+ execute()
324
+ return { dispose: () => undefined }
325
+ },
326
+ on: () => undefined,
327
+ get: (name) => (name === 'theme' ? { overrideTokens: (source, tokens) => themeOverrides.push({ source, tokens }) } : undefined),
328
+ locale,
329
+ settingsScope: { bind: (spec) => (assert.equal(spec.namespace, 'ui-font'), scope) },
330
+ slots: {
331
+ inject: (name, callback) => {
332
+ assert.equal(name, 'settings.general.item')
333
+ callback()
334
+ },
335
+ register: (options, component) => {
336
+ registeredSlots.push({ options, component })
337
+ return () => undefined
338
+ },
339
+ },
340
+ }
341
+
342
+ plugin.apply(ctx)
343
+
344
+ assert.equal(themeOverrides.length, 1, 'the families must be stacked onto the theme')
345
+ assert.equal(themeOverrides[0].source, PACKAGE_NAME)
346
+ assert.deepEqual(Object.keys(themeOverrides[0].tokens).sort(), ['--ds-font-family-code', '--dsw-font-family'])
347
+ assert.equal(themeOverrides[0].tokens['--dsw-font-family'].light, section.uiFontFamily)
348
+ assert.equal(themeOverrides[0].tokens['--dsw-font-family'].dark, section.uiFontFamily)
349
+
350
+ assert.equal(dictionaries.length, 1)
351
+ assert.deepEqual(Object.keys(dictionaries[0].dict.zh).sort(), Object.keys(dictionaries[0].dict.en).sort())
352
+
353
+ assert.equal(registeredSlots.length, 1)
354
+ const [{ options, component }] = registeredSlots
355
+ assert.equal(options.id, 'font')
356
+ assert.equal(options.name, 'settings.general.item')
357
+ assert.equal(options.locale, 'settings.font')
358
+ assert.ok(stores.length >= 1, 'the row must register a store')
359
+ assert.equal(typeof component, 'function')
360
+
361
+ // The row's inject face must expose the two write paths.
362
+ const actions = options.inject(options.store.create())
363
+ assert.equal(typeof actions.setField, 'function')
364
+ assert.equal(typeof actions.reset, 'function')
365
+
366
+ // The component must render a tree containing the localized labels.
367
+ const rendered = component({
368
+ t: (key) => key,
369
+ useStore: (selector) => selector(options.store.getSnapshot()),
370
+ ...actions,
371
+ })
372
+ const labels = []
373
+ const collect = (node) => {
374
+ if (node === null || node === undefined) return
375
+ if (typeof node === 'string' || typeof node === 'number') {
376
+ labels.push(String(node))
377
+ return
378
+ }
379
+ if (typeof node !== 'object') return
380
+ const children = Array.isArray(node.children) ? node.children : [node.children]
381
+ for (const child of children) collect(child)
382
+ // `Field` and `SliderControl` carry their copy in props, because the stubs
383
+ // above do not render function components.
384
+ collect(node.props?.children)
385
+ for (const key of ['label', 'value', 'hint', 'ariaLabel', 'placeholder']) {
386
+ collect(node.props?.[key])
387
+ }
388
+ }
389
+ collect(rendered)
390
+ for (const key of [
391
+ 'font.title',
392
+ 'font.uiFamily',
393
+ 'font.codeFamily',
394
+ 'font.uiScale',
395
+ 'font.contentSize',
396
+ 'font.codeSize',
397
+ 'font.reset',
398
+ ]) {
399
+ assert.ok(labels.includes(key), `rendered row is missing ${key}`)
400
+ }
401
+
402
+ // A pushed settings change must repaint.
403
+ rootProperties.clear()
404
+ scopeListener()
405
+ assert.equal(rootProperties.get('--dsw-font-family'), section.uiFontFamily)
406
+
407
+ delete globalThis.document
408
+
409
+ console.log('verify-client: OK — envelope, stylesheet, theme stacking, and settings row all verified')
410
+ console.log(`verify-client: factory required ${requested.join(', ')}`)
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Verify the host half without a running DSH: import `lib/index.js`, exercise
3
+ * the pure builders against the real `@deepseek-ai/schemastery`, execute the
4
+ * emitted pre-paint bootstrap against a DOM stub, and drive `apply(ctx)` with a
5
+ * stub context to prove the namespace registers and the index-injection row
6
+ * reaches the webserver table.
7
+ *
8
+ * Usage:
9
+ * node scripts/verify-host.mjs
10
+ */
11
+
12
+ import assert from 'node:assert/strict'
13
+ import { existsSync } from 'node:fs'
14
+ import { createRequire } from 'node:module'
15
+ import { dirname, join } from 'node:path'
16
+ import { pathToFileURL } from 'node:url'
17
+ import { fileURLToPath } from 'node:url'
18
+
19
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
20
+
21
+ /**
22
+ * Import `lib/index.js` through the installed profile when one exists.
23
+ *
24
+ * The host half imports `@deepseek-ai/schemastery`, which dsh supplies to a
25
+ * plugin from the installation's module fallback rather than from the plugin's
26
+ * own tree. Importing through the profile link reproduces that resolution, so
27
+ * this check also proves the plugin's runtime dependencies actually resolve at
28
+ * the place dsh will load it from. Falling back to the direct path keeps the
29
+ * check usable in a bare checkout.
30
+ */
31
+ async function importHost() {
32
+ const dshHome = process.env.DSH_HOME
33
+ const profile = process.env.DSH_PROFILE ?? 'web'
34
+ if (dshHome !== undefined) {
35
+ const anchor = join(dshHome, 'profiles', profile, 'package.json')
36
+ if (existsSync(anchor)) {
37
+ try {
38
+ // Resolve the bare specifier: `exports` maps "." to lib/index.js, and a
39
+ // direct `dsh-font/lib/index.js` path is deliberately not exported.
40
+ const resolved = createRequire(anchor).resolve('dsh-font')
41
+ return { host: await import(pathToFileURL(resolved).href), via: resolved }
42
+ } catch {
43
+ /* not installed in that profile; fall through to the direct import */
44
+ }
45
+ }
46
+ }
47
+ const direct = join(root, 'lib', 'index.js')
48
+ return { host: await import(pathToFileURL(direct).href), via: direct }
49
+ }
50
+
51
+ const { host, via } = await importHost()
52
+ console.log(`verify-host: loaded ${via}`)
53
+
54
+ const {
55
+ FONT_SETTINGS_NAMESPACE,
56
+ FontSettingsSchema,
57
+ DEFAULT_UI_FONT_FAMILY,
58
+ DEFAULT_CODE_FONT_FAMILY,
59
+ FONT_STYLE_ID,
60
+ fontStyleSheet,
61
+ fontBootstrapScript,
62
+ fontInjection,
63
+ apply,
64
+ } = host
65
+
66
+ assert.equal(FONT_SETTINGS_NAMESPACE, 'ui-font')
67
+
68
+ // The schema resolves a complete default section — what an absent settings
69
+ // document must produce for the pre-paint bootstrap to be a no-op.
70
+ const defaults = FontSettingsSchema({})
71
+ assert.equal(defaults.uiFontFamily, DEFAULT_UI_FONT_FAMILY)
72
+ assert.equal(defaults.codeFontFamily, DEFAULT_CODE_FONT_FAMILY)
73
+ assert.equal(defaults.uiFontScale, 1)
74
+ assert.equal(defaults.contentFontSize, 14)
75
+ assert.equal(defaults.codeFontSize, 12)
76
+
77
+ // Out-of-range values must be rejected at the wire boundary.
78
+ assert.throws(() => FontSettingsSchema({ contentFontSize: 99 }))
79
+ assert.throws(() => FontSettingsSchema({ uiFontScale: 0.1 }))
80
+
81
+ // The pre-paint sheet carries the scale and both size axes, one rule per
82
+ // hard-coded UI text step.
83
+ const sheet = fontStyleSheet(defaults)
84
+ assert.match(sheet, /--dsh-font-ui-scale:1;/)
85
+ assert.match(sheet, /--dsh-font-content-size:14px/)
86
+ assert.match(sheet, /--dsh-font-code-size:12px/)
87
+ assert.match(sheet, /font-size:calc\(14px \* var\(--dsh-font-ui-scale,1\)\) !important/)
88
+ for (const step of [11, 12, 13, 14, 16, 20, 24]) {
89
+ assert.ok(sheet.includes(`calc(${String(step)}px * `), `missing scale rule for ${String(step)}px`)
90
+ }
91
+
92
+ const script = fontBootstrapScript(defaults)
93
+ assert.ok(script.includes(JSON.stringify(FONT_STYLE_ID)), 'bootstrap must key on the stylesheet id')
94
+ assert.ok(!script.includes('</script'), 'bootstrap must not close its own script element')
95
+
96
+ // Execute the bootstrap against a DOM stub and read back what it wrote.
97
+ const appended = []
98
+ const created = []
99
+ const element = () => ({
100
+ id: '',
101
+ textContent: '',
102
+ dataset: {},
103
+ appendChild: (child) => appended.push(child),
104
+ })
105
+ const rootStyle = new Map()
106
+ globalThis.document = {
107
+ head: element(),
108
+ documentElement: { style: { setProperty: (name, value) => rootStyle.set(name, value) } },
109
+ getElementById: () => null,
110
+ createElement: (tag) => {
111
+ const node = { ...element(), tagName: tag }
112
+ created.push(node)
113
+ return node
114
+ },
115
+ }
116
+ globalThis.console = console
117
+ // eslint-disable-next-line no-eval -- the emitted pre-paint script is a classic script by contract
118
+ ;(0, eval)(script)
119
+
120
+ assert.equal(created.length, 1)
121
+ assert.equal(created[0].tagName, 'style')
122
+ assert.equal(created[0].id, FONT_STYLE_ID)
123
+ assert.match(created[0].textContent, /--dsh-font-ui-scale:1;/)
124
+ assert.equal(appended.length, 1)
125
+ assert.equal(rootStyle.get('--dsw-font-family'), DEFAULT_UI_FONT_FAMILY)
126
+ assert.equal(rootStyle.get('--ds-font-family-code'), DEFAULT_CODE_FONT_FAMILY)
127
+ delete globalThis.document
128
+
129
+ // A non-default section must flow through both the sheet and the script.
130
+ const custom = { ...defaults, uiFontScale: 1.25, contentFontSize: 16, codeFontSize: 13 }
131
+ assert.match(fontStyleSheet(custom), /--dsh-font-content-size:16px/)
132
+ assert.match(fontStyleSheet(custom), /font-size:calc\(16px \* var\(--dsh-font-ui-scale,1\)\)/)
133
+
134
+ const injection = fontInjection(defaults)
135
+ assert.equal(injection.kind, 'script')
136
+ assert.equal(injection.placement, 'head')
137
+
138
+ // Drive apply(ctx) with a stub that records the namespace and the injection.
139
+ const registered = []
140
+ const listeners = new Map()
141
+ let injectedSettings
142
+ const ctx = {
143
+ inject(deps, callback) {
144
+ assert.deepEqual(deps, ['settings'])
145
+ injectedSettings = {
146
+ settings: {
147
+ register(namespace, schema) {
148
+ registered.push({ namespace, schema })
149
+ },
150
+ },
151
+ }
152
+ callback(injectedSettings)
153
+ },
154
+ on(event, listener) {
155
+ listeners.set(event, listener)
156
+ },
157
+ get(name) {
158
+ return name === 'settings' ? injectedSettings.settings : undefined
159
+ },
160
+ }
161
+
162
+ apply(ctx)
163
+
164
+ assert.equal(registered.length, 1)
165
+ assert.equal(registered[0].namespace, 'ui-font')
166
+ assert.ok(registered[0].schema !== undefined)
167
+
168
+ const table = []
169
+ listeners.get('webserver/index-inject')(table)
170
+ assert.equal(table.length, 1)
171
+ assert.equal(table[0].kind, 'script')
172
+
173
+ // With no provider at all, apply() must still answer the injection table with
174
+ // the schema defaults rather than throwing.
175
+ const bareListeners = new Map()
176
+ apply({
177
+ inject: () => undefined,
178
+ on: (event, listener) => bareListeners.set(event, listener),
179
+ get: () => undefined,
180
+ })
181
+ const bareTable = []
182
+ bareListeners.get('webserver/index-inject')(bareTable)
183
+ assert.equal(bareTable.length, 1)
184
+ assert.match(bareTable[0].text, /--dsh-font-ui-scale:1;/)
185
+
186
+ console.log('verify-host: OK — namespace registered, pre-paint script executed, injection row emitted')
187
+ console.log(`verify-host: stylesheet ${String(sheet.length)} chars, script ${String(script.length)} chars`)