feat(dictionary): add adjustable dictionary popup font size (#4443) (#4734)

Expose `::part(dict-content)` on the MDict shadow content and add a
dictionary popup font-size setting (Settings → Language → Dictionaries),
independent of the main reading view.

- mdictProvider: tag the in-shadow body with `part="dict-content"` and a
  stable `dict-shadow-host` class so the popup's `::part()` rule can reach
  across the shadow boundary — MDict is the only provider that renders into
  a shadow root, so ordinary popup CSS can't touch it.
- DictionarySettings.fontScale (default 1) with setFontScale + load-merge;
  synced cross-device via the `dictionarySettings.fontScale` whitelist entry.
- DictionaryResultsView drives `--dict-font-scale` + `data-dict-content` on
  each per-tab container. globals.css re-bases the light-DOM Tailwind text
  utilities to `em` within that scope and sizes the MDict shadow body via
  `::part(dict-content)`, so every provider scales from one lever.
- SettingsSelect control (85–175%) in the Dictionaries panel.

Tests: jsdom unit tests (part attribute, store fontScale, sync whitelist)
plus a real-Chromium browser test for the em-rebasing + `::part` + custom-
property-inheritance CSS contract jsdom cannot model.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-22 23:51:23 +08:00
committed by GitHub
parent 082edc204b
commit 140b71ee30
11 changed files with 339 additions and 2 deletions
@@ -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 = `
<div data-dict-content style="--dict-font-scale: ${scale}">
<h1 class="text-lg" data-testid="headword">word</h1>
<pre class="text-sm" data-testid="light-body">a definition</pre>
<div class="dict-shadow-host mt-2 text-sm" data-testid="host"></div>
</div>`;
document.body.appendChild(root);
const host = root.querySelector('[data-testid="host"]') as HTMLElement;
const shadow = host.attachShadow({ mode: 'open' });
const body = document.createElement('div');
body.dataset['dictKind'] = 'mdict';
body.setAttribute('part', 'dict-content');
body.textContent = 'shadow definition';
shadow.appendChild(body);
return {
headword: root.querySelector('[data-testid="headword"]') as HTMLElement,
lightBody: root.querySelector('[data-testid="light-body"]') as HTMLElement,
shadowBody: body,
};
};
describe('dictionary popup font size (#4443)', () => {
afterEach(() => {
root?.remove();
root = null;
style?.remove();
style = null;
});
it('renders default sizes at scale 1 and scales every region linearly', () => {
style = document.createElement('style');
style.textContent = POPUP_FONT_RULES;
document.head.appendChild(style);
const base = mountCard(1);
// Defaults match the pre-feature look exactly: text-lg=18, text-sm=14,
// and the MDict shadow body keeps its 0.875rem (14px) base.
expect(px(base.headword)).toBeCloseTo(18, 1);
expect(px(base.lightBody)).toBeCloseTo(14, 1);
expect(px(base.shadowBody)).toBeCloseTo(14, 1);
root!.remove();
const big = mountCard(1.5);
// Light-DOM utilities ride the scaled `[data-dict-content]` root...
expect(px(big.headword)).toBeCloseTo(27, 1); // 18 * 1.5
expect(px(big.lightBody)).toBeCloseTo(21, 1); // 14 * 1.5
// ...and `::part()` carries the same factor across the shadow boundary,
// proving --dict-font-scale inherits into the shadow tree.
expect(px(big.shadowBody)).toBeCloseTo(21, 1); // 14 * 1.5
});
it('leaves the shadow body at its default when the part hook is absent', () => {
// Without the part attribute the rule cannot reach the shadow content —
// this guards against a future refactor dropping the hook silently.
style = document.createElement('style');
style.textContent = POPUP_FONT_RULES;
document.head.appendChild(style);
root = document.createElement('div');
root.style.fontSize = '16px';
root.innerHTML = `<div data-dict-content style="--dict-font-scale: 1.5">
<div class="dict-shadow-host" data-testid="host"></div>
</div>`;
document.body.appendChild(root);
const host = root.querySelector('[data-testid="host"]') as HTMLElement;
const shadow = host.attachShadow({ mode: 'open' });
const body = document.createElement('div');
body.style.fontSize = '0.875rem'; // its own author style, no part exposed
body.textContent = 'unreachable';
shadow.appendChild(body);
expect(px(body)).toBeCloseTo(14, 1); // unscaled — the ::part rule can't bind
});
});
@@ -846,6 +846,44 @@ describe('mdictProvider', () => {
}
});
it('exposes part="dict-content" on the in-shadow body so outer CSS can size it via ::part()', async () => {
const jsmdict = await import('js-mdict');
const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX);
type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never;
jsmdict.MDX.create = async () =>
({
meta: { encrypt: 0 },
header: {},
lookup: async (word: string) => ({
keyText: word,
definition: `<p>def</p>`,
}),
}) 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);
@@ -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)', () => {
@@ -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<typeof useSettingsStore.getState>;
useSettingsStore.setState({
settings: {
customDictionaries: [],
dictionarySettings: {
providerOrder: ['builtin:wikipedia'],
providerEnabled: { 'builtin:wikipedia': true },
webSearches: [],
},
} as unknown as SettingsState['settings'],
} as unknown as SettingsState);
const fakeAppService = { exists: vi.fn().mockResolvedValue(false) };
const fakeEnv = {
getAppService: () => Promise.resolve(fakeAppService),
} as unknown as EnvConfigType;
await useCustomDictionaryStore.getState().loadCustomDictionaries(fakeEnv);
expect(useCustomDictionaryStore.getState().settings.fontScale).toBe(1);
});
it('loadCustomDictionaries preserves a persisted fontScale', async () => {
type SettingsState = ReturnType<typeof useSettingsStore.getState>;
useSettingsStore.setState({
settings: {
customDictionaries: [],
dictionarySettings: {
providerOrder: ['builtin:wikipedia'],
providerEnabled: { 'builtin:wikipedia': true },
webSearches: [],
fontScale: 1.15,
},
} as unknown as SettingsState['settings'],
} as unknown as SettingsState);
const fakeAppService = { exists: vi.fn().mockResolvedValue(false) };
const fakeEnv = {
getAppService: () => Promise.resolve(fakeAppService),
} as unknown as EnvConfigType;
await useCustomDictionaryStore.getState().loadCustomDictionaries(fakeEnv);
expect(useCustomDictionaryStore.getState().settings.fontScale).toBe(1.15);
});
});
@@ -49,6 +49,8 @@ export interface DictionaryResultsState {
resolveWebSearchUrl: (id: string) => string | undefined;
onWebSearchClickTauri: (e: React.MouseEvent<HTMLAnchorElement>, id: string) => void;
noProvidersAtAll: boolean;
/** Dictionary popup font-size multiplier (#4443); `1` = default sizes. */
fontScale: number;
}
/**
@@ -312,6 +314,7 @@ export function useDictionaryResults({
resolveWebSearchUrl,
onWebSearchClickTauri,
noProvidersAtAll,
fontScale: settings.fontScale ?? 1,
};
}
@@ -377,6 +380,7 @@ export const DictionaryResultsBody: React.FC<DictionaryResultsBodyProps> = ({
resolveWebSearchUrl,
onWebSearchClickTauri,
noProvidersAtAll,
fontScale,
}) => {
const _ = useTranslation();
return (
@@ -440,6 +444,12 @@ export const DictionaryResultsBody: React.FC<DictionaryResultsBodyProps> = ({
<div
ref={setContainerRef(p.id)}
onClick={handleContainerClick}
// `data-dict-content` + `--dict-font-scale` drive the
// popup font-size rules in globals.css (#4443): the
// light-DOM `font-size` cascade and the MDict shadow
// `::part(dict-content)` rule both read this scope.
data-dict-content=''
style={{ '--dict-font-scale': fontScale } as React.CSSProperties}
className={clsx(
'font-sans',
isLoading && 'hidden',
@@ -44,7 +44,17 @@ import {
isValidUrlTemplate,
} from '@/services/dictionaries/webSearchTemplates';
import SubPageHeader from './SubPageHeader';
import { Tips } from './primitives';
import { BoxedList, SettingsRow, SettingsSelect, Tips } from './primitives';
/** Dictionary popup font-size multipliers, surfaced as percentages (#4443). */
const FONT_SCALE_OPTIONS = [
{ value: '0.85', label: '85%' },
{ value: '1', label: '100%' },
{ value: '1.15', label: '115%' },
{ value: '1.3', label: '130%' },
{ value: '1.5', label: '150%' },
{ value: '1.75', label: '175%' },
];
interface CustomDictionariesProps {
onBack: () => void;
@@ -254,6 +264,7 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
updateDictionary,
reorder,
setEnabled,
setFontScale,
addWebSearch,
updateWebSearch,
removeWebSearch,
@@ -284,6 +295,11 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
cancelled = true;
};
}, [appService]);
const handleFontScaleChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
setFontScale(Number(e.target.value));
await saveCustomDictionaries(envConfig);
};
const handleResetLookupApp = async () => {
await clearRememberedLookupApp();
setRememberedLookupApp(null);
@@ -769,6 +785,23 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
</div>
</div>
<BoxedList
className='mt-4'
title={_('Appearance')}
description={_(
'Sets the text size of dictionary results, independent of the reading view.',
)}
>
<SettingsRow label={_('Font Size')}>
<SettingsSelect
value={String(settings.fontScale ?? 1)}
onChange={handleFontScaleChange}
options={FONT_SCALE_OPTIONS}
ariaLabel={_('Font Size')}
/>
</SettingsRow>
</BoxedList>
<div className='mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2'>
<button
type='button'
@@ -579,6 +579,12 @@ export const createMdictProvider = ({
// surrounding chrome.
const body = document.createElement('div');
body.dataset['dictKind'] = dict.kind;
// Expose the content root as a shadow `part` (#4443). The MDict body
// lives inside a shadow root, so the popup's font-size rule can't
// reach it with an ordinary descendant selector — `::part(dict-content)`
// is the only hook that crosses the boundary. The matching host class
// (`dict-shadow-host`, set below) is the selector subject for that rule.
body.setAttribute('part', 'dict-content');
body.innerHTML = result.definition;
await resolveImageResources(body, mdds, ctx.signal, trackedUrls);
@@ -605,7 +611,9 @@ export const createMdictProvider = ({
// into the app's layout. Click events still bubble naturally so the
// `sound://` / `entry://` / external-link delegation keeps working.
const shadowHost = document.createElement('div');
shadowHost.className = 'mt-2 text-sm';
// `dict-shadow-host` is the stable host selector the popup's
// `::part(dict-content)` font-size rule targets (#4443).
shadowHost.className = 'dict-shadow-host mt-2 text-sm';
ctx.container.appendChild(shadowHost);
const shadow = shadowHost.attachShadow({ mode: 'open' });
// Baseline app-level styles first (theme-aware blend rules for
@@ -170,6 +170,14 @@ export interface DictionarySettings {
* Merriam-Webster) are hardcoded in the registry and not stored here.
*/
webSearches?: WebSearchEntry[];
/**
* Font-size multiplier for the dictionary popup content (independent of the
* main reading view, #4443). `1` = the default sizes; larger values scale
* every provider's rendered definition up. Drives the `--dict-font-scale`
* CSS variable on the popup content root, which feeds the light-DOM
* `font-size` rules and the MDict shadow `::part(dict-content)` rule alike.
*/
fontScale?: number;
}
/** Stable ids for the built-in providers. */
@@ -49,6 +49,7 @@ export const SETTINGS_WHITELIST = [
'dictionarySettings.providerOrder',
'dictionarySettings.providerEnabled',
'dictionarySettings.webSearches',
'dictionarySettings.fontScale',
// External integrations. Server URL + identifiers sync as plaintext;
// the credential fields are listed in `encryptedFields` below so the
// publish/pull middleware wraps them in cipher envelopes.
@@ -53,6 +53,7 @@ const DEFAULT_DICTIONARY_SETTINGS: DictionarySettings = {
[BUILTIN_WEB_SEARCH_IDS.goodreads]: false,
},
webSearches: [],
fontScale: 1,
};
interface DictionaryStoreState {
@@ -114,6 +115,8 @@ interface DictionaryStoreState {
setEnabled(id: string, enabled: boolean): void;
/** Persist the last-used tab id so the popup re-opens on it. */
setDefaultProviderId(id: string | undefined): void;
/** Set the dictionary popup font-size multiplier (#4443). */
setFontScale(scale: number): void;
/** Add a custom web search (id is generated). Appended + enabled by default. */
addWebSearch(name: string, urlTemplate: string): WebSearchEntry;
@@ -435,6 +438,12 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
}));
},
setFontScale: (scale) => {
set((state) => ({
settings: { ...state.settings, fontScale: scale },
}));
},
addWebSearch: (name, urlTemplate) => {
const trimmedName = name.trim();
const trimmedUrl = urlTemplate.trim();
@@ -582,6 +591,7 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
},
defaultProviderId: persistedSettings.defaultProviderId,
webSearches: persistedSettings.webSearches ?? [],
fontScale: persistedSettings.fontScale ?? DEFAULT_DICTIONARY_SETTINGS.fontScale,
};
set({ dictionaries, settings: settingsMerged });
} catch (error) {
+39
View File
@@ -665,6 +665,45 @@ input[type='range'].slider-input {
--tglbg: theme('colors.base-100') !important;
}
/* Dictionary popup font size (#4443).
*
* `--dict-font-scale` is set per content card by DictionaryResultsView; `1`
* = default sizes. It scales every provider's rendered definition while
* leaving the popup chrome (header, source label, web-search rows — all
* outside this scope) untouched.
*
* `[data-dict-content]` is the per-tab content root. Light-DOM providers
* size text with Tailwind `text-*` utilities (root-relative `rem`), which a
* container `font-size` alone can't move — so those utilities are re-based to
* `em` WITHIN THIS SCOPE ONLY, letting them ride the scaled root.
*
* MDict renders into a shadow root, so its body is unreachable by ordinary
* selectors; `::part(dict-content)` (exposed on the body in mdictProvider) is
* the only hook that crosses the boundary. */
[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);
}
.no-transitions * {
transition: none !important;
}