diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 43b01a6a8..88b1a700e 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -2093,12 +2093,50 @@ const ps = Math.round(currentParagraphSpacing * layoutScale); // When useBookFonts is enabled (default), do not force the reader - // font family onto every element so the book's own fonts (e.g. - // embedded @font-face families) render where the book specifies them. - const bodyStarFontOverride = currentUseBookFonts - ? '' - : `:root:not(.vrtl):not(.vltr) body *:not(svg):not(svg *):not(math):not(math *):not(pre):not(pre *):not(code):not(code *):not(kbd):not(kbd *):not(samp):not(samp *) { + // font family onto html/body with !important: the book's own + // font-family (on html, body, or any element) must win where specified, + // so embedded @font-face families render. The reader font stays as a + // zero-specificity fallback via :where(html). Target only html (not + // body): body then INHERITS html's font-family, so when the book sets + // one on html it propagates to body text instead of body being pinned + // to the reader font by a direct rule. When disabled, force the reader + // font on html/body and every descendant. + // + // The monospace rule swaps sides with the same toggle: with book fonts + // honored it is zero-specificity (a book's own `pre { font-family: ... }` + // — its embedded code font — wins), and when overriding it needs BOTH + // !important AND higher specificity, because specificity still breaks + // ties between important author declarations: a plain `pre, code, kbd` + // at (0,0,1) loses to an authored `body pre { ... !important }` at + // (0,0,2). The guarded `:root ... body :is(...)` sits at (0,2,2). + // Inner code/kbd/samp INSIDE a pre get an explicit `inherit` instead: + // the UA stylesheet declares `code { font-family: monospace }` and a + // direct declaration (even a UA one) always beats inheritance, so + // exclusion alone would still cut `
` off from the book's
+        // font declared on pre. The zero-specificity inherit restores the
+        // chain while any author declaration on the element still wins it.
+        // Standalone inline code keeps the monospace fallback.
+        // Vertical writing (.vrtl/.vltr) keeps its own behavior: the forced
+        // overrides carry the horizontal guard like the pre-existing rules.
+        const readerFontOverride = currentUseBookFonts
+          ? `:where(html) {
+  font-family: var(--readany-font-family);
+}
+:where(pre, :not(pre) > code, :not(pre) > kbd, :not(pre) > samp) {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+:where(pre :is(code, kbd, samp)) {
+  font-family: inherit;
+}`
+          : `:root:not(.vrtl):not(.vltr),
+:root:not(.vrtl):not(.vltr) body {
+  font-family: var(--readany-font-family) !important;
+}
+:root:not(.vrtl):not(.vltr) body *:not(svg):not(svg *):not(math):not(math *):not(pre):not(pre *):not(code):not(code *):not(kbd):not(kbd *):not(samp):not(samp *) {
   font-family: var(--readany-font-family) !important;
+}
+:root:not(.vrtl):not(.vltr) body :is(pre, code, kbd, samp) {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
 }`;
 
         const baseStyles = `
@@ -2107,12 +2145,11 @@
           }
           :root:not(.vrtl):not(.vltr),
           :root:not(.vrtl):not(.vltr) body {
-            font-family: var(--readany-font-family) !important;
             font-size: ${fSize}px !important;
             -webkit-text-size-adjust: none;
             text-size-adjust: none;
           }
-          ${bodyStarFontOverride}
+          ${readerFontOverride}
           :root:not(.vrtl):not(.vltr) body *:not(svg):not(svg *):not(math):not(math *):not(pre):not(pre *):not(code):not(code *):not(kbd):not(kbd *):not(samp):not(samp *):not(rt):not(rp) {
             font-size: ${fSize}px !important;
           }
@@ -2145,9 +2182,6 @@
           :root:not(.vrtl):not(.vltr) h6 {
             line-height: ${lh} !important;
           }
-          pre, code, kbd, samp {
-            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
-          }
           :root:not(.vrtl):not(.vltr) p {
             margin-top: ${ps}px !important;
             margin-bottom: ${ps}px !important;
diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html
index 87e38df3e..0c0d1795a 100644
--- a/packages/app-expo/assets/reader/reader.template.html
+++ b/packages/app-expo/assets/reader/reader.template.html
@@ -1918,12 +1918,50 @@
         const ps = Math.round(currentParagraphSpacing * layoutScale);
 
         // When useBookFonts is enabled (default), do not force the reader
-        // font family onto every element so the book's own fonts (e.g.
-        // embedded @font-face families) render where the book specifies them.
-        const bodyStarFontOverride = currentUseBookFonts
-          ? ''
-          : `:root:not(.vrtl):not(.vltr) body *:not(svg):not(svg *):not(math):not(math *):not(pre):not(pre *):not(code):not(code *):not(kbd):not(kbd *):not(samp):not(samp *) {
+        // font family onto html/body with !important: the book's own
+        // font-family (on html, body, or any element) must win where specified,
+        // so embedded @font-face families render. The reader font stays as a
+        // zero-specificity fallback via :where(html). Target only html (not
+        // body): body then INHERITS html's font-family, so when the book sets
+        // one on html it propagates to body text instead of body being pinned
+        // to the reader font by a direct rule. When disabled, force the reader
+        // font on html/body and every descendant.
+        //
+        // The monospace rule swaps sides with the same toggle: with book fonts
+        // honored it is zero-specificity (a book's own `pre { font-family: ... }`
+        // — its embedded code font — wins), and when overriding it needs BOTH
+        // !important AND higher specificity, because specificity still breaks
+        // ties between important author declarations: a plain `pre, code, kbd`
+        // at (0,0,1) loses to an authored `body pre { ... !important }` at
+        // (0,0,2). The guarded `:root ... body :is(...)` sits at (0,2,2).
+        // Inner code/kbd/samp INSIDE a pre get an explicit `inherit` instead:
+        // the UA stylesheet declares `code { font-family: monospace }` and a
+        // direct declaration (even a UA one) always beats inheritance, so
+        // exclusion alone would still cut `
` off from the book's
+        // font declared on pre. The zero-specificity inherit restores the
+        // chain while any author declaration on the element still wins it.
+        // Standalone inline code keeps the monospace fallback.
+        // Vertical writing (.vrtl/.vltr) keeps its own behavior: the forced
+        // overrides carry the horizontal guard like the pre-existing rules.
+        const readerFontOverride = currentUseBookFonts
+          ? `:where(html) {
+  font-family: var(--readany-font-family);
+}
+:where(pre, :not(pre) > code, :not(pre) > kbd, :not(pre) > samp) {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+:where(pre :is(code, kbd, samp)) {
+  font-family: inherit;
+}`
+          : `:root:not(.vrtl):not(.vltr),
+:root:not(.vrtl):not(.vltr) body {
+  font-family: var(--readany-font-family) !important;
+}
+:root:not(.vrtl):not(.vltr) body *:not(svg):not(svg *):not(math):not(math *):not(pre):not(pre *):not(code):not(code *):not(kbd):not(kbd *):not(samp):not(samp *) {
   font-family: var(--readany-font-family) !important;
+}
+:root:not(.vrtl):not(.vltr) body :is(pre, code, kbd, samp) {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
 }`;
 
         const baseStyles = `
@@ -1932,12 +1970,11 @@
           }
           :root:not(.vrtl):not(.vltr),
           :root:not(.vrtl):not(.vltr) body {
-            font-family: var(--readany-font-family) !important;
             font-size: ${fSize}px !important;
             -webkit-text-size-adjust: none;
             text-size-adjust: none;
           }
-          ${bodyStarFontOverride}
+          ${readerFontOverride}
           :root:not(.vrtl):not(.vltr) body *:not(svg):not(svg *):not(math):not(math *):not(pre):not(pre *):not(code):not(code *):not(kbd):not(kbd *):not(samp):not(samp *):not(rt):not(rp) {
             font-size: ${fSize}px !important;
           }
@@ -1970,9 +2007,6 @@
           :root:not(.vrtl):not(.vltr) h6 {
             line-height: ${lh} !important;
           }
-          pre, code, kbd, samp {
-            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
-          }
           :root:not(.vrtl):not(.vltr) p {
             margin-top: ${ps}px !important;
             margin-bottom: ${ps}px !important;
diff --git a/packages/app-expo/src/screens/reader/usebook-fonts-contract.test.ts b/packages/app-expo/src/screens/reader/usebook-fonts-contract.test.ts
new file mode 100644
index 000000000..9459ff4d3
--- /dev/null
+++ b/packages/app-expo/src/screens/reader/usebook-fonts-contract.test.ts
@@ -0,0 +1,77 @@
+import { readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../..");
+
+function readSource(relativePath: string): string {
+  return readFileSync(resolve(repositoryRoot, relativePath), "utf8");
+}
+
+// The useBookFonts cascade recipe exists in three places that must not drift:
+// the mobile template, its generated artifact, and the desktop generator.
+// The behavior hinges on subtle specificity/!important interactions
+// (zero-specificity :where() fallback vs. (0,0,3)/(0,2,2) forced override),
+// so the contract pins the rule SHAPES per toggle state.
+describe("useBookFonts font cascade contract", () => {
+  const template = readSource("packages/app-expo/assets/reader/reader.template.html");
+  const generated = readSource("packages/app-expo/assets/reader/reader.html");
+  const desktop = readSource("packages/app/src/components/reader/FoliateViewer.tsx");
+
+  it("honors book fonts by default: zero-specificity :where() fallbacks, no !important", () => {
+    for (const [name, source] of [
+      ["template", template],
+      ["generated", generated],
+      ["desktop", desktop],
+    ] as const) {
+      expect(source, name).toMatch(/:where\(html\)\s*\{\s*font-family: var\(--readany-font-family\);/);
+      // Inner code/kbd/samp inside a pre are excluded so they keep inheriting
+      // the book's font from pre instead of getting a direct declaration.
+      expect(source, name).toMatch(
+        /:where\(pre, :not\(pre\) > code, :not\(pre\) > kbd, :not\(pre\) > samp\)\s*\{\s*font-family: ui-monospace[^}]*;/,
+      );
+      // The UA stylesheet declares `code { font-family: monospace }` and any
+      // direct declaration beats inheritance — this zero-specificity inherit
+      // restores the `
` chain to the book's font on pre.
+      expect(source, name).toMatch(
+        /:where\(pre :is\(code, kbd, samp\)\)\s*\{\s*font-family: inherit;/,
+      );
+    }
+    // The honoring branch must not carry !important anywhere.
+    expect(template).toMatch(/currentUseBookFonts\s*\?\s*`:where\(html\)[^`]*`/);
+    expect(template).not.toMatch(/:where\(html\)\s*\{[^}]*!important/);
+    expect(desktop).not.toMatch(/:where\(pre[^)]*\)\s*\{[^}]*!important/);
+  });
+
+  it("forces the reader font when the toggle is off, above authored !important rules", () => {
+    // Desktop: html body :is(...) at (0,0,3) — outranks an authored
+    // `body pre { ... !important }` at (0,0,2), which a bare `pre, code, kbd`
+    // at (0,0,1) would lose to despite its own !important.
+    expect(desktop).toMatch(
+      /html body :is\(pre, code, kbd, samp\)\s*\{\s*font-family: ui-monospace[^}]*!important/,
+    );
+    // Mobile: the same idea guarded to horizontal writing so the vertical
+    // branch keeps its pre-existing behavior (no descendant forcing).
+    expect(template).toMatch(
+      /:root:not\(\.vrtl\):not\(\.vltr\),\s*\n\s*:root:not\(\.vrtl\):not\(\.vltr\) body\s*\{\s*font-family: var\(--readany-font-family\) !important/,
+    );
+    expect(template).toMatch(
+      /:root:not\(\.vrtl\):not\(\.vltr\) body :is\(pre, code, kbd, samp\)\s*\{\s*font-family: ui-monospace[^}]*!important/,
+    );
+    // The forced body-star rule keeps the horizontal guard too.
+    expect(template).toMatch(
+      /:root:not\(\.vrtl\):not\(\.vltr\) body \*:not\(svg\)[^{]*\{\s*font-family: var\(--readany-font-family\) !important/,
+    );
+    // And the generated artifact carries the same shapes.
+    expect(generated).toMatch(/:root:not\(\.vrtl\):not\(\.vltr\) body :is\(pre, code, kbd, samp\)/);
+    expect(generated).toMatch(/:where\(pre, :not\(pre\) > code/);
+  });
+
+  it("emits exactly one unconditional monospace rule in the desktop output", () => {
+    // The legacy unconditional `pre, code, kbd, samp { ... !important }` must
+    // stay gone from the desktop generator — its reintroduction would silently
+    // override book code fonts again.
+    expect(desktop).not.toMatch(/(?` is the dominant code markup);
+  // standalone inline code still gets the fallback.
   const readerFontOverride =
     settings.useBookFonts === false
       ? `html, body {
@@ -3482,10 +3494,19 @@ function getRendererStyles(settings: ViewSettings, theme: AppTheme): string {
 body *:not(svg):not(svg *):not(math):not(math *):not(pre):not(pre *):not(code):not(code *):not(kbd):not(kbd *):not(samp):not(samp *) {
   font-family: var(--readany-font-family) !important;
 }
+html body :is(pre, code, kbd, samp) {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
+}
 `
       : `:where(html) {
   font-family: var(--readany-font-family);
 }
+:where(pre, :not(pre) > code, :not(pre) > kbd, :not(pre) > samp) {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+:where(pre :is(code, kbd, samp)) {
+  font-family: inherit;
+}
 `;
 
   return `${settings.customFontFaceCSS ? `/* Custom font faces */\n${settings.customFontFaceCSS}\n\n` : ""}/* Font styles */
@@ -3510,10 +3531,6 @@ body :not(#__readany_font_size_override):not(svg):not(svg *):not(math):not(math
   font-size: ${settings.fontSize}px !important;
 }
 
-pre, code, kbd, samp {
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
-}
-
 /* Line height for text blocks */
 p, div, blockquote, dd, li, span {
   line-height: ${settings.lineHeight} !important;