Skip to content

refactor(reader): vocab highlights via CSS Custom Highlight API#153

Merged
mrviduus merged 10 commits into
mainfrom
refactor/reader-vocab-highlights
Apr 23, 2026
Merged

refactor(reader): vocab highlights via CSS Custom Highlight API#153
mrviduus merged 10 commits into
mainfrom
refactor/reader-vocab-highlights

Conversation

@mrviduus
Copy link
Copy Markdown
Owner

Summary

Fixes vocab-highlight underlines disappearing after scrolling past the chapter-eviction window. Replaces imperative <mark> wrapping with CSS Custom Highlight API + Range-based engine. Zero DOM mutation in chapter text → survives dangerouslySetInnerHTML reload, font-size change, resize.

Defense-in-depth (priority #1 per user: "надежно стабильно безотказно"):

  • Feature flag: VITE_READER_CUSTOM_HIGHLIGHTS (default on)
  • Runtime killswitch: window.__textstackDisableCustomHighlights = true → legacy path
  • Feature detect: CSS.highlights unsupported → legacy path
  • ErrorBoundary around new layer → legacy path on throw
  • Oracle shadow mode: VITE_READER_HIGHLIGHTS_ORACLE=true runs engine alongside legacy and logs oracle.diff on divergence
  • Legacy VocabWordLayer preserved as fallback until Slice 10 (4 weeks clean prod)

Scope: web + mobile parity. Mobile WebView gets the same dispatcher inline in readerHtml.ts. Exterior API unchanged (markVocabWords/addVocabWord/setShowInlineTranslations) so all reader call sites unmodified.

Slices landed

  1. Pure engine + custom-highlight registry + telemetry (100% branch coverage)
  2. Container MutationObserver hook + ErrorBoundary wrapper
  3. VocabHighlightLayer + VocabTranslationOverlay + CSS rules
  4. Feature-flag dispatcher + oracle shadow-mode
  5. Web E2E: scroll-past-eviction persistence, killswitch fallback, unsupported fallback
  6. Mobile WebView port — same dispatcher pattern inline

Remaining (post-merge, time-based gates)

  • Slice 7: staging oracle soak (1 week, 0 divergences required)
  • Slice 8: canary user rollout
  • Slice 9: global rollout
  • Slice 10: delete legacy after 4 weeks clean prod metrics

Test plan

  • 176 unit tests pass (82 new), TS clean on web + mobile, vite build clean
  • Playwright spec lists cleanly (4 tests)
  • Run E2E against dev server locally
  • Manual QA: scroll past 4 chapters → back → underlines persist
  • Manual QA: killswitch in DevTools → legacy <mark> path activates
  • Staging deploy with oracle on → 1 week, 0 divergences

Rollback

Flip VITE_READER_CUSTOM_HIGHLIGHTS=false → redeploy. Legacy takes over with no user-visible change.

🤖 Generated with Claude Code

mrviduus and others added 10 commits April 22, 2026 21:55
…erlay)

core bug: inline translations disappear on scroll due to chapter eviction
wiping imperatively-inserted <mark> nodes. Full refactor to declarative
CSS Custom Highlight API + React overlay, with defense-in-depth rollout
(feature flag, killswitch, error boundary, oracle shadow-mode, legacy
fallback) to guarantee zero user-visible regression.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- vocabHighlightEngine: Range[] from TreeWalker, zero DOM mutation.
- customHighlightRegistry: CSS.highlights wrapper, feature-detect, silent fail.
- vocabHighlightTelemetry: counter API, never throws, console default.
- 40 unit tests green, jsdom mocks for CSS.highlights absence.

Foundations only — not wired to reader yet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- useContainerMutationObserver: RAF-debounced, swallows callback errors,
  disconnects on unmount. Core recovery signal for innerHTML wipes.
- VocabHighlightErrorBoundary: renders legacy fallback on throw, emits
  error.boundary telemetry, supports resetKey for retry across chapters.
- 14 unit tests green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- VocabHighlightLayer: declarative replacement for imperative VocabWordLayer.
  Zero DOM mutation; registers Range[] via CSS Custom Highlight API.
  MutationObserver auto-recovers after innerHTML wipes.
- VocabTranslationOverlay: absolute-positioned overlay, RAF-throttled
  reflow on scroll/resize, viewport coords via CSS var transforms.
- vocab-highlights.css: ::highlight(vocab-*) pseudos mirror legacy colors;
  .vocab-translation-overlay styles.
- 16 unit tests green. Not wired to reader yet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- features.ts: VITE_READER_CUSTOM_HIGHLIGHTS (default on),
  VITE_READER_HIGHLIGHTS_ORACLE (default off). Runtime killswitch via
  window.__textstackDisableCustomHighlights.
- VocabHighlightDispatcher: boot-time decision (flag + support + killswitch).
  Wraps new layer in ErrorBoundary with legacy as fallback.
- VocabHighlightOracle: shadow-mode observer. Diffs engine matches vs
  legacy <mark> count, logs via oracle.diff telemetry.
- ReaderHighlights now mounts the dispatcher. App.tsx imports vocab CSS.
- 12 new tests; 176 total green. tsc + vite build clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Covers: new-path render, eviction+return scroll, killswitch → legacy,
CSS.highlights delete → legacy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…lback

Same dispatcher pattern as web: feature-detect CSS.highlights +
window.__textstackDisableCustomHighlights killswitch → new path; else
legacy <mark> path. MutationObserver reapplies after appendChapter so
vocab marks survive chapter scroll. Exterior API
(markVocabWords/addVocabWord/removeVocabMarks/setShowInlineTranslations)
unchanged → reader pages need no edits.

Translation overlay: absolute-positioned spans on a body-child overlay
container (data-vocab-overlay), repositioned via RAF on scroll/resize.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
E2E: hard-coded words weren't in chapter 1 "welcome" of test book →
totalHighlightSize stayed 0. Now pick a 5-8 letter word from rendered
content at test time.

Mobile: MutationObserver watched document.body, so overlay span appends
(inside [data-vocab-overlay]) and legacy <mark> wraps re-triggered
markVocabWords each RAF forever. Filter out mutations that originate in
our own overlay container / vocab marks before re-applying.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace isOwnMutation filter (missed text-node additions from legacy
unwrap) with disconnect/reconnect around markVocabWords body. Simpler,
immune to edge cases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Test-scoped request fixture has no cookies — beforeAll's testLogin only
auths the worker context. Switch to page.request which shares storage
state with authedPage. Drop beforeAll/afterAll, clean per-test instead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@mrviduus mrviduus merged commit d0be957 into main Apr 23, 2026
5 checks passed
@mrviduus mrviduus deleted the refactor/reader-vocab-highlights branch April 23, 2026 04:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant