diff --git a/apps/readest-app/src/__tests__/services/dictionaries/dict-popup-font-size.browser.test.ts b/apps/readest-app/src/__tests__/services/dictionaries/dict-popup-font-size.browser.test.ts new file mode 100644 index 00000000..095d7cec --- /dev/null +++ b/apps/readest-app/src/__tests__/services/dictionaries/dict-popup-font-size.browser.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +// Real-browser regression test for readest#4443 (adjustable dictionary popup +// font size). The feature hangs entirely on CSS behaviour jsdom cannot model: +// 1. Tailwind `text-*` utilities re-based to `em` so they ride a scaled +// `font-size` root on `[data-dict-content]`. +// 2. `::part(dict-content)` reaching into the MDict shadow root, with the +// `--dict-font-scale` custom property inheriting across the boundary. +// Both need a layout engine + a real Shadow DOM + Custom Highlight-free part +// styling, so this lives in the browser lane. +// +// The rules below mirror the `[data-dict-content]` block in +// `src/styles/globals.css`; keep them byte-identical to what ships. +const POPUP_FONT_RULES = ` +[data-dict-content] { + font-size: calc(var(--dict-font-scale, 1) * 1em); +} +[data-dict-content] .text-xs { font-size: 0.75em; } +[data-dict-content] .text-sm { font-size: 0.875em; } +[data-dict-content] .text-base { font-size: 1em; } +[data-dict-content] .text-lg { font-size: 1.125em; } +[data-dict-content] .dict-shadow-host::part(dict-content) { + font-size: calc(var(--dict-font-scale, 1) * 0.875rem); +} +`; + +let style: HTMLStyleElement | null = null; +let root: HTMLElement | null = null; + +const px = (el: Element): number => parseFloat(getComputedStyle(el).fontSize); + +/** + * Build the exact DOM the popup produces for a card: a `[data-dict-content]` + * content root holding a light-DOM headword + body (e.g. the DICT provider) + * and an MDict shadow host whose shadow body carries `part="dict-content"`. + */ +const mountCard = (scale: number) => { + root = document.createElement('div'); + // The popup's surrounding chrome inherits the document base; force a known + // 16px base so `rem`/`em` math is deterministic regardless of UA defaults. + root.style.fontSize = '16px'; + root.innerHTML = ` +
a definition+ +
def
`, + }), + }) as unknown as FakeMDX; + + try { + const provider = createMdictProvider({ dict: buildDict(false), fs: makeFs() }); + const container = document.createElement('div'); + await provider.lookup('hello', { + signal: new AbortController().signal, + container, + }); + + const shadow = getMdictShadow(container); + // The content root carries BOTH the data-dict-kind hook and the + // shadow `part` so the popup's `::part(dict-content)` font-size rule + // can reach across the shadow boundary (it otherwise can't). + const body = shadow.querySelector('[data-dict-kind="mdict"]'); + expect(body).not.toBeNull(); + expect(body!.getAttribute('part')).toBe('dict-content'); + // The host must be selectable from the outer tree for the `::part()` + // rule's host selector to match. + const host = container.lastElementChild as HTMLElement; + expect(host.classList.contains('dict-shadow-host')).toBe(true); + } finally { + jsmdict.MDX.create = origMDXCreate; + } + }); + it('keeps the auto-prepended headword when the dict body has a different h1 text', async () => { const jsmdict = await import('js-mdict'); const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX); diff --git a/apps/readest-app/src/__tests__/services/sync/adapters/settings.test.ts b/apps/readest-app/src/__tests__/services/sync/adapters/settings.test.ts index f9676ead..6b0d673a 100644 --- a/apps/readest-app/src/__tests__/services/sync/adapters/settings.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/adapters/settings.test.ts @@ -141,6 +141,8 @@ describe('SETTINGS_WHITELIST', () => { expect(SETTINGS_WHITELIST).toContain('dictionarySettings.providerOrder'); expect(SETTINGS_WHITELIST).toContain('dictionarySettings.providerEnabled'); expect(SETTINGS_WHITELIST).toContain('dictionarySettings.webSearches'); + // Dictionary popup font size (#4443) follows the user across devices. + expect(SETTINGS_WHITELIST).toContain('dictionarySettings.fontScale'); }); test('does NOT sync dictionarySettings.defaultProviderId (per-device last-used tab)', () => { diff --git a/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts b/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts index 38975846..86a767b1 100644 --- a/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts +++ b/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts @@ -695,3 +695,74 @@ describe('customDictionaryStore — loadCustomDictionaries reconciliation', () = expect('imp-tombstoned' in after.providerEnabled).toBe(false); }); }); + +describe('customDictionaryStore — fontScale (dictionary popup font size, #4443)', () => { + beforeEach(() => { + vi.clearAllMocks(); + useCustomDictionaryStore.setState({ + dictionaries: [], + settings: { + providerOrder: ['local-x'], + providerEnabled: { 'local-x': true }, + webSearches: [], + }, + }); + }); + + it('setFontScale updates the in-memory setting', () => { + const { setFontScale } = useCustomDictionaryStore.getState(); + setFontScale(1.3); + expect(useCustomDictionaryStore.getState().settings.fontScale).toBe(1.3); + }); + + it('applyRemoteDictionarySettings overlays a remote fontScale patch', () => { + const { applyRemoteDictionarySettings } = useCustomDictionaryStore.getState(); + applyRemoteDictionarySettings({ fontScale: 1.5 }); + expect(useCustomDictionaryStore.getState().settings.fontScale).toBe(1.5); + }); + + it('loadCustomDictionaries defaults fontScale to 1 when the persisted settings omit it', async () => { + type SettingsState = ReturnType