feat(wordlens): trim hints to first sense + suppress known derivations (#4635)

* refactor(wordlens): rename ww-gloss CSS class to wl-gloss

Completes the Word Wise → Word Lens rename (#4633) for the gloss ruby class —
the 'ww' shorthand was missed. Renamed consistently across the apply site
(GLOSS_CLASS), the CSS rules in style.ts, the tap hit-test in
iframeEventHandlers, and the browser tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(wordlens): trim hints to first sense + suppress known derivations

Runtime, best-effort gloss-quality pass over the shipped en-zh pack (no
regeneration):

- cleanGloss: strip leading POS tags (now incl. 6-letter `interj.`) and keep
  only the first sense, so hints stay short — "Ahem" shows 呃哼, not
  "interj. 呃哼"; multi-sense entries collapse to their first sense.
- Derivational reduction (English source only): a would-be-glossed word inherits
  a known base form's lower rank when the base exists in the pack AND their
  glosses share meaning, so lazily/shyly/sorrowful/downwards/inwards stop being
  hinted once lazy/shy/sorrow/… are known. Drifted forms keep their own rank
  because their gloss doesn't overlap the base (hardly≠hard, lately≠late).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(wordlens): note hint-quality layer + wl-gloss in agent memory

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-18 01:46:54 +08:00
committed by GitHub
parent c2ac207945
commit 8bcb9f9b2a
10 changed files with 201 additions and 13 deletions
@@ -11,13 +11,13 @@ Kindle-style **Word Lens**: a short native-language gloss above difficult words
**Files:** `src/services/wordlens/{types,difficulty,glossIndex,planner}.ts` (pure pipeline), `src/app/reader/utils/{wordlensRuby,wordlensSection}.ts` (DOM inject/clear + orchestration), `src/components/settings/WordLensPanel.tsx`, `scripts/build-wordlens-data.mjs`, `public/wordlens/{en-zh,zh-en}.json` + `ATTRIBUTION.md`.
**CFI-safe DOM mutation (the key technique).** Glosses render as `<ruby class="ww-gloss" cfi-skip>word<rt cfi-inert>gloss</rt></ruby>` injected into the live iframe doc. foliate `epubcfi.js` `rawChildNodes` HOISTS a `cfi-skip` wrapper's children and REMOVES a `cfi-inert` subtree, then `indexChildNodes` re-merges the now-adjacent text nodes into one chunk → CFI is byte-identical to plain text in BOTH `fromRange` and `toRange`. So highlights/bookmarks/progress are unaffected. `xcfi.ts` honors the same attrs → KOSync safe too. Gate test: `epubcfi-ruby-inline.test.ts` (the pre-existing `epubcfi-{inert,skip}.test.ts` only cover block wrappers, NOT mid-text inline ruby with text on both sides). NOTE: an early research subagent wrongly claimed `cfi-skip`/`cfi-inert` only affect tree-walk not offset summation — that's FALSE; they fully neutralize offsets. This retired the need for an overlay-rendering fallback.
**CFI-safe DOM mutation (the key technique).** Glosses render as `<ruby class="wl-gloss" cfi-skip>word<rt cfi-inert>gloss</rt></ruby>` injected into the live iframe doc. foliate `epubcfi.js` `rawChildNodes` HOISTS a `cfi-skip` wrapper's children and REMOVES a `cfi-inert` subtree, then `indexChildNodes` re-merges the now-adjacent text nodes into one chunk → CFI is byte-identical to plain text in BOTH `fromRange` and `toRange`. So highlights/bookmarks/progress are unaffected. `xcfi.ts` honors the same attrs → KOSync safe too. Gate test: `epubcfi-ruby-inline.test.ts` (the pre-existing `epubcfi-{inert,skip}.test.ts` only cover block wrappers, NOT mid-text inline ruby with text on both sides). NOTE: an early research subagent wrongly claimed `cfi-skip`/`cfi-inert` only affect tree-walk not offset summation — that's FALSE; they fully neutralize offsets. This retired the need for an overlay-rendering fallback.
**TTS/search/annotation isolation.** Gloss text must stay out of spoken text, find-in-book, and Edge-TTS word offsets. (1) foliate TTS reject filter already has `tags:['rt']` (`TTSController.ts`) → spoken text excludes gloss automatically. (2) `globalAnnotations` findTextRanges already rejects `rt`/`rp`. (3) `wordHighlight.ts` getTextSubRange walked raw `SHOW_TEXT` → added `isInertText` skip + exported `rangeTextExcludingInert` (used in `TTSController.prepareSpeakWords` instead of `range.toString()`), else Edge word offsets drift vs the gloss-free boundary words — this also fixes a latent furigana bug. (4) search `SearchBar.tsx` only rejected `rt` for ja → added `attributes:['cfi-inert']`.
**Gloss data caveat (IMPORTANT).** The committed `public/wordlens/*.json` are small CURATED STARTER sets (52 EN→中文, 40 中文→EN) so the feature works out-of-the-box. The FULL frequency-trimmed assets are a maintainer step: `node scripts/build-wordlens-data.mjs en-zh <ecdict.csv> [topN]` (ECDICT, MIT, ~160MB full; difficulty = COCA `frq` rank; EN inflections parsed from the `exchange` field) and `zh-en <cedict.txt> <hsk.json> [topN]` (CC-CEDICT CC-BY-SA + HSK; zh tokenized in-app via jieba `cutZh`). Index is lazy-fetched once from `/wordlens/<pair>.json` into an in-memory `GlossIndex`.
**Gotchas fixed in review:** slider-drag fires repeated refreshes → `refreshSectionGlosses` uses a `WeakMap<Document,number>` generation guard (latest wins on a clean doc); a `pendingWordLensDictRef` could stick `true` past early-returns and hijack the next selection → read-and-reset pattern; skip pre-paginated/fixed-layout books (ruby overflows fixed boxes); adding a `SettingsPanelType` requires updating the exhaustive `panelIcons` map in `commandRegistry.ts` AND its `react-icons/pi` test mock. Tap-to-dict = `iframeEventHandlers` hit-tests `.ww-gloss``wordlens-dictionary` event → `Annotator` synthesizes a selection over the base word. **STALE-CLOSURE bug (fixed post-PR):** `handleWordLensDictionary` is registered in a `useEffect(…, [bookKey])`, so it closes over the render-time `view` const — which is still `null` when the effect first runs (view mounts after bookKey). At tap time the closure's `view` stays null → `view.renderer.getContents()` undefined → content-not-found early-return → no popup, silent. Fix = read `const view = getView(bookKey)` FRESH inside the handler (mirrors the other handlers at lines ~616/709/748 that already do this). Diagnosed live via `[wwtap]` console-trace instrumentation through the whole path (gloss-hit → dispatch → handler → effect); the `content? true index N` log confirmed the fix. General rule for this file: any foliate event handler bound once with `[bookKey]` deps must call `getView(bookKey)` rather than use the closure `view`.
**Gotchas fixed in review:** slider-drag fires repeated refreshes → `refreshSectionGlosses` uses a `WeakMap<Document,number>` generation guard (latest wins on a clean doc); a `pendingWordLensDictRef` could stick `true` past early-returns and hijack the next selection → read-and-reset pattern; skip pre-paginated/fixed-layout books (ruby overflows fixed boxes); adding a `SettingsPanelType` requires updating the exhaustive `panelIcons` map in `commandRegistry.ts` AND its `react-icons/pi` test mock. Tap-to-dict = `iframeEventHandlers` hit-tests `.wl-gloss``wordlens-dictionary` event → `Annotator` synthesizes a selection over the base word. **STALE-CLOSURE bug (fixed post-PR):** `handleWordLensDictionary` is registered in a `useEffect(…, [bookKey])`, so it closes over the render-time `view` const — which is still `null` when the effect first runs (view mounts after bookKey). At tap time the closure's `view` stays null → `view.renderer.getContents()` undefined → content-not-found early-return → no popup, silent. Fix = read `const view = getView(bookKey)` FRESH inside the handler (mirrors the other handlers at lines ~616/709/748 that already do this). Diagnosed live via `[wwtap]` console-trace instrumentation through the whole path (gloss-hit → dispatch → handler → effect); the `content? true index N` log confirmed the fix. General rule for this file: any foliate event handler bound once with `[bookKey]` deps must call `getView(bookKey)` rather than use the closure `view`.
**R2 runtime delivery (no bundled packs).** Packs are NOT shipped in the installer: source of truth is `apps/readest-app/data/wordlens/` (committed, outside `public/`+`src/` so unbundled) holding `<pair>.json` + `manifest.json` (`{pair,source,target,file,bytes,sha256,entries}`); `scripts/sync-wordlens-r2.mjs` (`pnpm wordlens:sync`, env `WORDLENS_R2_BUCKET`) uploads to the `cdn.readest.com` R2 bucket (`WORDLENS_CDN_BASE`). Runtime: `src/services/wordlens/glossPacks.ts` = `fetchManifest``resolvePack(source,hint)``ensurePack`(sha256 verify, single-flight via in-flight Map, `allowDownload` flag, `?v=<sha8>` cache-bust on pack URL only)→`loadGlossIndex` (memo does NOT cache null → retries after download), stored in appService `'Data'` (IndexedDB on web). `getPackStatus`/`deletePack`/`listAvailableTargets` drive the UI.
@@ -27,4 +27,6 @@ Kindle-style **Word Lens**: a short native-language gloss above difficult words
**UI in Settings → Language** (not a top-level tab): `WordLensSubPanel` opened via a `NavigationRow` in `LangPanel`, mirroring the `CustomDictionaries` sub-page swap (`useKeyDownActions` back/Esc + early-return render). Hint-language `SettingsSelect` = `getLangOptions(TRANSLATED_LANGS)` base-code-filtered against manifest targets (targets are base codes `zh`; TRANSLATED_LANGS has `zh-CN`/`pt-BR` → match on base); value `wordLensHintLang` ('' = auto = app UI lang via `getLocale()`). Global `ReadSettings.wordLensAutoDownload` (default true) gates reader auto-download; `isMetered()` (`src/utils/network.ts`, Network Information API, absent on iOS/Tauri → unmetered). Generalized: `WordLensSourceLang=string`; `difficulty.ts` FREQUENCY cutoffs for all non-zh + HSK-scale (`level×3000`) for zh; `canTokenizeSource` blocks ja/ko/th (tier 3, need a segmenter). Spec: `docs/superpowers/specs/2026-06-14-wordlens-r2-delivery.md`.
**Hint quality (runtime, best-effort over shipped packs — no regen).** `src/services/wordlens/gloss.ts`: `cleanGloss` strips a leading POS tag (`/^\s*(?:[a-zA-Z]{1,6}\.\s*)+/` — the `{1,6}` is so 6-letter `interj.` is caught, e.g. "Ahem" → 呃哼 not "interj. 呃哼") and keeps ONLY the first sense (split on `[,,、;/]`), so en-zh hints stay short; applied in `planGlosses` when building each occurrence. **Derivational reduction (English source only):** a would-be-glossed word inherits a known base form's LOWER rank when the base exists in the pack AND their glosses overlap, so transparent derivations drop below the cutoff and aren't hinted (lazily⇐lazy, shyly⇐shy via shared 怯, sorrowful⇐sorrow, downwards⇐downward, inwards⇐inward). `baseFormCandidates` over-generates via reverse-suffix rules (-ly/-ily→y/-ful/-ward(s)/-ness/-less); `glossesShareMeaning` validates (shared Han char after stripping 的/地 particles; Latin = shared word≥3). Drifted forms keep their own rank because their gloss does NOT overlap the base (hardly≠hard, lately≠late, barely≠bare) — and those are sub-B1-common anyway so unaffected at higher levels. Done in the PLANNER (which knows `sourceLang`), GlossIndex stays pure. The matters-only-when-would-be-glossed gate (compute reduction only after the direct rank passes `isDifficult`) keeps it cheap. **NOT in the rename PR #4633** — landed as a follow-up; the `wl-gloss` class rename (was `ww-gloss`, the 'ww' shorthand #4633 missed) rode along.
Related: [[edge-tts-word-highlighting-4017]], [[footnote-aside-namespace-order-4438]], [[empty-start-cfi-sync]], [[android-nativefile-remotefile-io]].
@@ -34,14 +34,14 @@ describe('wordlensRuby', () => {
const model = buildSectionTextModel(document);
applyGlosses(document, model, [{ start: 4, end: 9, word: 'quick', gloss: '敏捷' }]);
const ruby = document.querySelector('ruby.ww-gloss')!;
const ruby = document.querySelector('ruby.wl-gloss')!;
expect(ruby.getAttribute('cfi-skip')).toBe('');
expect(ruby.querySelector('rt')!.getAttribute('cfi-inert')).toBe('');
expect(ruby.querySelector('rt')!.textContent).toBe('敏捷');
expect(document.getElementById('p')!.textContent).toBe('The quick敏捷 fox');
clearGlosses(document);
expect(document.querySelector('ruby.ww-gloss')).toBeNull();
expect(document.querySelector('ruby.wl-gloss')).toBeNull();
expect(document.getElementById('p')!.textContent).toBe('The quick fox');
});
@@ -17,12 +17,12 @@ describe('Word Lens rendering (browser)', () => {
{ start, end: start + 'cryptic'.length, word: 'cryptic', gloss: '晦涩的' },
]);
const rt = document.querySelector('ruby.ww-gloss > rt')!;
const rt = document.querySelector('ruby.wl-gloss > rt')!;
expect(rt.textContent).toBe('晦涩的');
expect(root.getBoundingClientRect().height).toBeGreaterThan(before);
clearGlosses(document);
expect(document.querySelector('ruby.ww-gloss')).toBeNull();
expect(document.querySelector('ruby.wl-gloss')).toBeNull();
expect(root.textContent).toBe('A cryptic note appears.');
});
});
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { cleanGloss, baseFormCandidates, glossesShareMeaning } from '@/services/wordlens/gloss';
describe('cleanGloss', () => {
it('strips a leading interj. POS tag (6 letters)', () => {
expect(cleanGloss('interj. 呃哼')).toBe('呃哼');
});
it('strips short leading POS tags', () => {
expect(cleanGloss('a. 神秘的')).toBe('神秘的');
expect(cleanGloss('vt. 做;vi. 看')).toBe('做');
});
it('keeps only the first sense (comma- or semicolon-separated)', () => {
expect(cleanGloss('内心的, 向内的, 本来的;向内的')).toBe('内心的');
expect(cleanGloss('to run, to operate, to manage')).toBe('to run');
});
it('leaves a single short sense unchanged, including a trailing 的', () => {
expect(cleanGloss('向下')).toBe('向下');
expect(cleanGloss('晦涩的')).toBe('晦涩的');
});
it('returns empty string for empty input', () => {
expect(cleanGloss('')).toBe('');
});
});
describe('baseFormCandidates', () => {
it('over-generates base forms for transparent derivations', () => {
expect(baseFormCandidates('lazily')).toContain('lazy');
expect(baseFormCandidates('shyly')).toContain('shy');
expect(baseFormCandidates('sorrowful')).toContain('sorrow');
expect(baseFormCandidates('downwards')).toContain('downward');
expect(baseFormCandidates('inwards')).toContain('inward');
});
it('returns no candidates for a non-derived word', () => {
expect(baseFormCandidates('cryptic')).toEqual([]);
});
});
describe('glossesShareMeaning', () => {
it('is true when two CJK glosses share a content character', () => {
expect(glossesShareMeaning('懒洋洋地', '懒惰的, 怠惰的')).toBe(true);
expect(glossesShareMeaning('向下', '向下的')).toBe(true);
});
it('is false when a derived gloss has drifted from its base', () => {
expect(glossesShareMeaning('几乎不', '坚硬的, 硬的')).toBe(false); // hardly vs hard
expect(glossesShareMeaning('近来', '迟的, 晚的')).toBe(false); // lately vs late
});
});
@@ -52,6 +52,39 @@ describe('planGlosses (Chinese)', () => {
});
});
describe('planGlosses derivational reduction (English source)', () => {
// A derived word inherits its base's (lower) rank when the base exists and
// their glosses share meaning — so it drops below the cutoff and isn't hinted.
const derv: GlossSource = {
lookup(word) {
const table: Record<string, GlossEntry> = {
lazily: { rank: 16150, gloss: '懒洋洋地' },
lazy: { rank: 6238, gloss: '懒惰的, 怠惰的' },
// hardly is artificially rare here to prove the gloss-overlap gate (not rank) keeps it.
hardly: { rank: 16000, gloss: '几乎不' },
hard: { rank: 438, gloss: '坚硬的, 硬的' },
ahem: { rank: 24471, gloss: 'interj. 呃哼' },
};
return table[word.toLowerCase()] ?? null;
},
};
const cutoff = 14000; // ~C1
it('suppresses a transparent derivation whose base is known (lazily ⇐ lazy)', () => {
expect(planGlosses('lazily', derv, { sourceLang: 'en', rankCutoff: cutoff })).toEqual([]);
});
it('keeps a drifted derivation even when its base is common (hardly ≠ hard)', () => {
const occ = planGlosses('hardly', derv, { sourceLang: 'en', rankCutoff: cutoff });
expect(occ.map((o) => o.word)).toEqual(['hardly']);
});
it('cleans the displayed gloss (strips interj., keeps first sense)', () => {
const occ = planGlosses('ahem', derv, { sourceLang: 'en', rankCutoff: cutoff });
expect(occ[0]?.gloss).toBe('呃哼');
});
});
describe('planGlosses against a zh-en fixture', () => {
// Decoupled from the shipping data/wordlens/zh-en.json: the committed pack is
// (re)generated from real corpora, so the test owns its ranks via a fixture.
@@ -252,7 +252,7 @@ export const handleClick = (
// after the drag/long-hold guards so only a clean single tap triggers it.
const glossWord = findGlossWord(element);
if (glossWord) {
const ruby = element?.closest('ruby.ww-gloss') ?? null;
const ruby = element?.closest('ruby.wl-gloss') ?? null;
eventDispatcher.dispatch('wordlens-dictionary', { bookKey, element: ruby, word: glossWord });
return;
}
@@ -1,6 +1,6 @@
import type { GlossOccurrence } from '@/services/wordlens/types';
const GLOSS_CLASS = 'ww-gloss';
const GLOSS_CLASS = 'wl-gloss';
interface Segment {
node: Text;
@@ -77,7 +77,7 @@ const occurrenceRange = (
return range;
};
/** Wrap each occurrence as <ruby class=ww-gloss cfi-skip>word<rt cfi-inert>gloss</rt></ruby>. */
/** Wrap each occurrence as <ruby class=wl-gloss cfi-skip>word<rt cfi-inert>gloss</rt></ruby>. */
export const applyGlosses = (
doc: Document,
model: SectionTextModel,
@@ -0,0 +1,79 @@
// Runtime gloss normalization + English derivational reduction for Word Lens.
//
// These run against the shipped (already-trimmed) gloss packs — no regeneration.
// `cleanGloss` shortens what a reader sees; `baseFormCandidates` +
// `glossesShareMeaning` let a transparent derivation (lazily ⇐ lazy) inherit its
// base's difficulty so it isn't hinted when the base is already known.
// Leading dictionary POS tags: "a." "vt." "interj." (≤6 letters so interj. is covered).
const LEADING_POS = /^\s*(?:[a-zA-Z]{1,6}\.\s*)+/;
// Sense separators in both the zh packs (",;、") and the en-target packs (",;/").
const SENSE_SEPARATORS = /[,,、;/]/;
const MAX_GLOSS_LEN = 24;
/** First sense only, POS/bracket/classifier noise stripped, length-capped. */
export function cleanGloss(gloss: string): string {
if (!gloss) return '';
const firstSense = gloss.split(SENSE_SEPARATORS)[0] ?? '';
return firstSense
.replace(LEADING_POS, '')
.replace(/\[[^\]]*\]/g, '') // [ge4] pinyin / [医] domain tags
.replace(/\bCL:.*$/, '') // CC-CEDICT classifier clause
.replace(/\s+/g, ' ')
.trim()
.slice(0, MAX_GLOSS_LEN);
}
// Reverse-derivation rules. Over-generate candidates ("all possibilities"); the
// caller validates each against the index + gloss overlap, so wrong guesses are
// harmless. English-only morphology — gated to English-source packs by the caller.
export function baseFormCandidates(word: string): string[] {
const w = word.toLowerCase();
const out = new Set<string>();
const add = (x: string) => {
if (x.length >= 2) out.add(x);
};
if (w.endsWith('ily') && w.length >= 5) add(w.slice(0, -3) + 'y'); // lazily → lazy
if (w.endsWith('ly') && w.length >= 4) {
add(w.slice(0, -2)); // shyly → shy
add(w.slice(0, -2) + 'e'); // nicely → nice
}
if (w.endsWith('ful') && w.length >= 5) {
add(w.slice(0, -3)); // sorrowful → sorrow
add(w.slice(0, -4) + 'y'); // beautiful → beauty
}
if (w.endsWith('wards') && w.length >= 6) {
add(w.slice(0, -1)); // downwards → downward
add(w.slice(0, -5)); // downwards → down
} else if (w.endsWith('ward') && w.length >= 5) {
add(w.slice(0, -4)); // inward → in
}
if (w.endsWith('ness') && w.length >= 5) {
add(w.slice(0, -4)); // kindness → kind
add(w.slice(0, -5) + 'y'); // happiness → happy
}
if (w.endsWith('less') && w.length >= 5) add(w.slice(0, -4)); // harmless → harm
out.delete(w);
return [...out];
}
// Particles that carry no lexical content; dropped before the overlap test so a
// shared 的/地 can't fake a match.
const PARTICLES = /[的地得了着之吗呢啊,,;;、。.\s/]/g;
const HAN = /\p{Script=Han}/u;
/**
* Do two glosses share core meaning? Used to confirm a derivation is transparent
* (knowing the base really does mean knowing the derived form). CJK glosses match
* on a shared Han character; Latin-script glosses match on a shared word (≥3 letters).
*/
export function glossesShareMeaning(a: string, b: string): boolean {
const an = (a || '').replace(PARTICLES, '');
const bn = (b || '').replace(PARTICLES, '');
if (!an || !bn) return false;
const hanChars = [...an].filter((ch) => HAN.test(ch));
if (hanChars.length) return hanChars.some((ch) => bn.includes(ch));
const wordsA = a.toLowerCase().match(/[a-z]{3,}/g) ?? [];
const wordsB = new Set(b.toLowerCase().match(/[a-z]{3,}/g) ?? []);
return wordsA.some((w) => wordsB.has(w));
}
@@ -1,5 +1,6 @@
import type { GlossOccurrence, GlossSource, WordLensSourceLang } from './types';
import type { GlossEntry, GlossOccurrence, GlossSource, WordLensSourceLang } from './types';
import { isDifficult } from './difficulty';
import { baseFormCandidates, cleanGloss, glossesShareMeaning } from './gloss';
export interface PlanOptions {
sourceLang: WordLensSourceLang;
@@ -67,7 +68,15 @@ export const planGlosses = (
for (const t of tokens) {
const entry = source.lookup(t.word);
if (!entry || !isDifficult(entry.rank, opts.rankCutoff)) continue;
occurrences.push({ start: t.start, end: t.end, word: t.word, gloss: entry.gloss });
// English derivations inherit a known base's lower rank (lazily ⇐ lazy), so
// they drop below the cutoff and aren't hinted. Gated to English source.
if (
opts.sourceLang === 'en' &&
!isDifficult(effectiveRank(t.word, entry, source), opts.rankCutoff)
) {
continue;
}
occurrences.push({ start: t.start, end: t.end, word: t.word, gloss: cleanGloss(entry.gloss) });
if (occurrences.length >= cap) {
console.warn(`[wordlens] occurrence cap (${cap}) hit; some hints omitted`);
break;
@@ -75,3 +84,15 @@ export const planGlosses = (
}
return occurrences;
};
// Lowest rank among the word itself and any base form that exists in the index
// AND shares meaning with it (transparent derivation). A drifted form like
// `hardly` finds `hard` but their glosses don't overlap, so it keeps its own rank.
const effectiveRank = (word: string, entry: GlossEntry, source: GlossSource): number => {
let rank = entry.rank;
for (const base of baseFormCandidates(word)) {
const b = source.lookup(base);
if (b && glossesShareMeaning(entry.gloss, b.gloss)) rank = Math.min(rank, b.rank);
}
return rank;
};
+2 -2
View File
@@ -760,10 +760,10 @@ const getRubyStyles = () => `
rp {
display: none !important;
}
ruby.ww-gloss {
ruby.wl-gloss {
cursor: help;
}
ruby.ww-gloss > rt {
ruby.wl-gloss > rt {
font-size: 0.5em;
line-height: 1.1;
opacity: 0.7;