diff --git a/apps/readest-app/src/__tests__/utils/a11y.browser.test.ts b/apps/readest-app/src/__tests__/utils/a11y.browser.test.ts
new file mode 100644
index 00000000..cc060593
--- /dev/null
+++ b/apps/readest-app/src/__tests__/utils/a11y.browser.test.ts
@@ -0,0 +1,62 @@
+import { describe, test, expect, afterEach } from 'vitest';
+import type { FoliateView } from '@/types/view';
+import { handleA11yNavigation } from '@/utils/a11y';
+
+// The exact paragraph-layout selector emitted by getParagraphLayoutStyles() in
+// src/utils/style.ts. Its
clause only matches paragraph-like divs whose
+// descendants are all inline formatting tags — so nesting any other element
+// (e.g. the next-section skip link) inside such a paragraph drops the match.
+// This needs the real :has() engine, so it runs as a browser test.
+const PARAGRAPH_SELECTOR = 'p, blockquote, dd, div:not(:has(*:not(b, a, em, i, strong, u, span)))';
+
+const NEXT_SECTION_ID = 'readest-skip-link-next-section';
+
+const makeOptions = () => ({
+ skipToLastPosCallback: () => {},
+ skipToLastPosLabel: 'last',
+ skipToNextSectionCallback: () => {},
+ skipToNextSectionLabel: 'next',
+});
+
+const iframes: HTMLIFrameElement[] = [];
+
+// Render an isolated, laid-out document in an iframe (mirrors how foliate
+// renders each EPUB section) so getComputedStyle and :has() use the real engine.
+const renderSection = (bodyHtml: string) => {
+ const iframe = document.createElement('iframe');
+ document.body.appendChild(iframe);
+ iframes.push(iframe);
+ const doc = iframe.contentDocument!;
+ const style = doc.createElement('style');
+ style.textContent =
+ `body { font-size: 16px; }` + `${PARAGRAPH_SELECTOR} { line-height: 3 !important; }`;
+ doc.head.appendChild(style);
+ doc.body.innerHTML = bodyHtml;
+ return { doc, win: iframe.contentWindow! };
+};
+
+describe('handleA11yNavigation paragraph-layout interaction', () => {
+ afterEach(() => {
+ while (iframes.length) iframes.pop()!.remove();
+ });
+
+ test('keeps the last
paragraph styled by the paragraph-layout rule', () => {
+ const { doc, win } = renderSection(
+ `
First paragraph.
` +
+ `
Last paragraph with an inline span.
`,
+ );
+ const lastPara = doc.body.lastElementChild as HTMLElement;
+ // 3 × 16px — the overridden line spacing, applied before any injection.
+ expect(win.getComputedStyle(lastPara).lineHeight).toBe('48px');
+
+ handleA11yNavigation({} as FoliateView, doc, makeOptions());
+
+ // The skip link is still injected at the section end (accessibility intact)...
+ const skipLink = doc.getElementById(NEXT_SECTION_ID);
+ expect(skipLink).not.toBeNull();
+ expect(lastPara.contains(skipLink)).toBe(true);
+ // ...and the last paragraph still receives the overridden line spacing
+ // instead of reverting to the book default (the bug: #last-paragraph).
+ expect(win.getComputedStyle(lastPara).lineHeight).toBe('48px');
+ });
+});
diff --git a/apps/readest-app/src/utils/a11y.ts b/apps/readest-app/src/utils/a11y.ts
index fac0284b..66921f85 100644
--- a/apps/readest-app/src/utils/a11y.ts
+++ b/apps/readest-app/src/utils/a11y.ts
@@ -81,7 +81,15 @@ export const handleA11yNavigation = (
}
const skipNextSectionLinkId = 'readest-skip-link-next-section';
if (document.body && !document.getElementById(skipNextSectionLinkId)) {
- const skipLink = document.createElement('div');
+ // Use a
, not a : this link is nested inside the section's last
+ // content element (see findSectionEndHost below). The paragraph-layout rule
+ // in getParagraphLayoutStyles() targets `div:not(:has(*:not(b,a,em,i,strong,
+ // u,span)))`, so a nested
would make the enclosing paragraph fail the
+ // `:has()` test and silently lose its line-spacing/indent overrides.
+ // is in that allow-list, so the paragraph keeps matching. position:absolute
+ // still makes the inline span an out-of-flow 1×1px box, so layout/focus are
+ // unchanged.
+ const skipLink = document.createElement('span');
skipLink.id = skipNextSectionLinkId;
skipLink.setAttribute('cfi-inert', '');
skipLink.setAttribute('tabindex', '0');