diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md
index 526ecbb0..ad55ee70 100644
--- a/apps/readest-app/.claude/memory/MEMORY.md
+++ b/apps/readest-app/.claude/memory/MEMORY.md
@@ -63,6 +63,7 @@
- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls
## Reader UI Fixes
+- [Overlayer splitRange by text nodes](overlayer-splitrange-textnodes.md) — highlight SVG missed bullet-list text when range also touched a `
`: `#splitRangeByParagraph`'s `'p,h1-h4'` selector dropped `li` (3rd whack-a-mole after f087826/920676b); fix = walk text nodes + `img,svg` in overlayer.js, never block-tag selectors; jsdom test stubs `Range.prototype.getClientRects`
- [Android image callout freeze](android-image-callout-freeze.md) — long-press `
` fires WebView native callout that collides with app touch handlers → whole-app freeze; `-webkit-touch-callout: none` doesn't inherit so put `.no-context-menu` on an ancestor of the image (`.no-context-menu img` rule in globals.css); seen on book covers (#4345) + image preview/zoom (#4420, `ImageViewer.tsx`)
- [ProgressBar focus-ring line (#4397)](progressbar-focus-ring-4397.md) — decorative `.progressinfo` footer was `tabIndex={-1}` → Android long-press focused it → stray content-width focus-ring line at the bottom every page; fix = drop tabIndex (role='presentation' must not be focusable); ffmpeg-the-video debugging + live-browser `:focus-visible` confirmation
- [Table dark-mode tint regression (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` color-mix tint in `getColorStyles` must stay gated on `overrideColor` (gate added #2377, removed #4055, re-broke → #4419); safe now that #4392 light-bg rewriters handle #4028 zebra legibility; SAME rule paints vertical-TOC `.space`/▉ spacer cells (▉ U+2589 = blank glyph, contours=0) → "spacing changes" symptom; both fixed by the gate
diff --git a/apps/readest-app/.claude/memory/overlayer-splitrange-textnodes.md b/apps/readest-app/.claude/memory/overlayer-splitrange-textnodes.md
new file mode 100644
index 00000000..fb535371
--- /dev/null
+++ b/apps/readest-app/.claude/memory/overlayer-splitrange-textnodes.md
@@ -0,0 +1,16 @@
+---
+name: overlayer-splitrange-textnodes
+description: "Overlayer highlight rects — split range by TEXT NODES, not a block-tag selector; li/blockquote text was silently dropped from SVG when highlight also touched a
"
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: 58d4b1a1-8823-4474-9966-c96727692e3f
+---
+
+Bug class (3rd occurrence): `Overlayer` in `packages/foliate-js/overlayer.js` splits a range into sub-ranges before `getClientRects()` so fully-contained block elements don't contribute border boxes (over-highlighting blank space — fork commit f087826 #10). The split was `querySelectorAll('p, h1, h2, h3, h4')`; 920676b added headings after the same hole; June 2026 the hole reappeared for `
`: a highlight spanning paragraphs + a bullet list drew NO rects over the list (li text fell into no sub-range), while a highlight entirely inside the list worked via the `splitRanges.length === 0 → [range]` fallback.
+
+**Fix:** `#splitRange` walks TEXT NODES (TreeWalker, `FILTER_REJECT` on non-intersecting elements prunes subtrees) plus replaced elements (`img, svg`), clipping first/last text nodes to the range boundaries. Text-node line rects never include block border boxes, cover every block type, and can't double-paint (`li > p` nesting). Shared `#getRects(range)` used by both `add()` and `redraw()`.
+
+**Why:** any hard-coded block-tag selector is whack-a-mole (li, blockquote, dd, td, div-paragraphs…) and adding container tags double-paints nested matched tags.
+
+**How to apply:** when highlight/overlay rects miss some content or over-paint blank space, check `#splitRange` in overlayer.js first. Test at `src/__tests__/document/overlayer-highlight-blocks.test.ts` — jsdom has no `Range.getClientRects`; stub the prototype to record `range.toString()` per call and assert covered text. Dev-web picked up the symlinked foliate-js edit on page reload (no server restart needed for next dev, unlike [[paginator-gutter-bleed-asymmetry-4394]]'s note).
diff --git a/apps/readest-app/src/__tests__/document/overlayer-highlight-blocks.test.ts b/apps/readest-app/src/__tests__/document/overlayer-highlight-blocks.test.ts
new file mode 100644
index 00000000..8f9e713c
--- /dev/null
+++ b/apps/readest-app/src/__tests__/document/overlayer-highlight-blocks.test.ts
@@ -0,0 +1,211 @@
+// Regression test for: highlights spanning a bullet list are not drawn over
+// the list items.
+//
+// Overlayer splits a range into sub-ranges before collecting client rects so
+// that fully-contained block elements don't contribute their border boxes
+// (which over-highlight blank space between lines/paragraphs). The split was
+// keyed on a hard-coded selector ('p, h1, h2, h3, h4'), so any text living in
+// other block containers — li, blockquote, dd, td, ... — fell into no
+// sub-range and was silently dropped from the drawn SVG rects whenever the
+// range also touched a matching paragraph/heading.
+//
+// The fix splits by text nodes (plus replaced elements like img/svg), which
+// covers every block type and still never yields block border boxes.
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { Overlayer } from 'foliate-js/overlayer.js';
+
+interface RectLike {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+ width: number;
+ height: number;
+}
+
+// Records what content each getClientRects() call covered, so assertions can
+// check which parts of the document contribute rects to the overlay.
+let covered: string[] = [];
+
+const describeRange = (range: Range): string => {
+ const text = range.toString();
+ const frag = range.cloneContents();
+ return frag.querySelector('img, svg') ? `${text}` : text;
+};
+
+function stubGetClientRects(this: Range): DOMRectList {
+ covered.push(describeRange(this));
+ const i = covered.length;
+ const rect: RectLike = {
+ left: 0,
+ top: i * 20,
+ right: 100,
+ bottom: i * 20 + 10,
+ width: 100,
+ height: 10,
+ };
+ return [rect] as unknown as DOMRectList;
+}
+
+const realGetClientRects = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects');
+
+const drawSpy = () => {
+ const drawn: RectLike[] = [];
+ const draw = (rects: RectLike[]) => {
+ drawn.push(...rects);
+ return document.createElementNS('http://www.w3.org/2000/svg', 'g');
+ };
+ return { drawn, draw };
+};
+
+const byId = (id: string) => {
+ const el = document.getElementById(id);
+ if (!el) throw new Error(`missing #${id}`);
+ return el;
+};
+
+const textNode = (id: string) => {
+ const node = byId(id).firstChild;
+ if (!node || node.nodeType !== Node.TEXT_NODE) throw new Error(`#${id} has no text node`);
+ return node as Text;
+};
+
+describe('Overlayer rect splitting across block types', () => {
+ beforeEach(() => {
+ covered = [];
+ Object.defineProperty(Range.prototype, 'getClientRects', {
+ value: stubGetClientRects,
+ configurable: true,
+ writable: true,
+ });
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ if (realGetClientRects) {
+ Object.defineProperty(Range.prototype, 'getClientRects', realGetClientRects);
+ } else {
+ delete (Range.prototype as { getClientRects?: unknown }).getClientRects;
+ }
+ });
+
+ it('draws rects for list items when a highlight spans paragraphs and a bullet list', () => {
+ document.body.innerHTML = `
+
+ The final role of taxes is to encourage certain behaviors.
+ To summarize, taxes have four functions:
+
+ - Make people accept the currency.
+ - Redistribute income.
+ - Reduce consumption to fight inflation.
+ - Deter undesirable behaviors.
+
+ `;
+ const range = document.createRange();
+ range.setStart(textNode('p1'), 0);
+ range.setEnd(textNode('li4'), textNode('li4').length);
+
+ const { drawn, draw } = drawSpy();
+ const overlayer = new Overlayer(document);
+ overlayer.add('annotation', range, draw);
+
+ const text = covered.join('');
+ expect(text).toContain('To summarize, taxes have four functions:');
+ expect(text).toContain('Make people accept the currency.');
+ expect(text).toContain('Redistribute income.');
+ expect(text).toContain('Reduce consumption to fight inflation.');
+ expect(text).toContain('Deter undesirable behaviors.');
+ expect(drawn.length).toBeGreaterThan(0);
+
+ // The range must stay split per block: a single getClientRects call over
+ // the whole range would include block border boxes and over-highlight the
+ // blank space between paragraphs (the reason the split exists).
+ for (const chunk of covered) {
+ expect(chunk.includes('To summarize') && chunk.includes('Make people')).toBe(false);
+ }
+ });
+
+ it('does not double-draw text of paragraphs nested inside list items', () => {
+ document.body.innerHTML = `
+ `;
+ const range = document.createRange();
+ range.setStart(textNode('p1'), 0);
+ range.setEnd(textNode('nested'), textNode('nested').length);
+
+ const { draw } = drawSpy();
+ const overlayer = new Overlayer(document);
+ overlayer.add('annotation', range, draw);
+
+ const hits = covered.filter((t) => t.includes('Paragraph inside a list item.'));
+ expect(hits).toHaveLength(1);
+ });
+
+ it('covers other text blocks such as blockquotes', () => {
+ document.body.innerHTML = `
+
+ Before the quote.
+ Quoted text in a blockquote.
+ After the quote.
+ `;
+ const range = document.createRange();
+ range.setStart(textNode('p1'), 0);
+ range.setEnd(textNode('p2'), textNode('p2').length);
+
+ const { draw } = drawSpy();
+ const overlayer = new Overlayer(document);
+ overlayer.add('annotation', range, draw);
+
+ const text = covered.join('');
+ expect(text).toContain('Before the quote.');
+ expect(text).toContain('Quoted text in a blockquote.');
+ expect(text).toContain('After the quote.');
+ });
+
+ it('clips the first and last blocks to the range boundaries', () => {
+ document.body.innerHTML = `
+
+ To summarize, taxes have four functions:
+
+ - Make people accept the currency.
+
+ `;
+ const start = textNode('p1');
+ const end = textNode('li1');
+ const range = document.createRange();
+ range.setStart(start, 'To summarize, taxes have '.length);
+ range.setEnd(end, 'Make people'.length);
+
+ const { draw } = drawSpy();
+ const overlayer = new Overlayer(document);
+ overlayer.add('annotation', range, draw);
+
+ const chunks = covered.filter((t) => t.trim() !== '');
+ expect(chunks[0]).toBe('four functions:');
+ expect(chunks[chunks.length - 1]).toBe('Make people');
+ });
+
+ it('still draws a rect for replaced elements like images inside the range', () => {
+ document.body.innerHTML = `
+
+ Before the image.
After the image.
+ `;
+ const p = byId('p1');
+ const range = document.createRange();
+ range.selectNodeContents(p);
+
+ const { draw } = drawSpy();
+ const overlayer = new Overlayer(document);
+ overlayer.add('annotation', range, draw);
+
+ expect(covered.some((t) => t.startsWith(''))).toBe(true);
+ const text = covered.join('');
+ expect(text).toContain('Before the image.');
+ expect(text).toContain('After the image.');
+ });
+});
diff --git a/packages/foliate-js b/packages/foliate-js
index a281b5db..1309db7e 160000
--- a/packages/foliate-js
+++ b/packages/foliate-js
@@ -1 +1 @@
-Subproject commit a281b5dbcbe55d73383efc77b7ebb003fbc21ca5
+Subproject commit 1309db7e8ce35d89f23e9da741b2faf3aa59b19c