diff --git a/.changeset/zoomable-font-scale.md b/.changeset/zoomable-font-scale.md
new file mode 100644
index 0000000000..ca6af360b4
--- /dev/null
+++ b/.changeset/zoomable-font-scale.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/perseus": patch
+---
+
+Fix table (and block math) text not scaling with the device font scale on mobile: Zoomable now fits content to the zoom-adjusted container width instead of cancelling out the CSS zoom applied for font enlargement
diff --git a/packages/perseus/src/components/__docs__/zoomable.stories.tsx b/packages/perseus/src/components/__docs__/zoomable.stories.tsx
index abfe6af184..cc801ea0c9 100644
--- a/packages/perseus/src/components/__docs__/zoomable.stories.tsx
+++ b/packages/perseus/src/components/__docs__/zoomable.stories.tsx
@@ -49,3 +49,52 @@ export const ZoomableExample: Story = {
),
},
};
+
+/**
+ * The mobile apps honor the device font scale by applying CSS `zoom` to
+ * the page. Zoomable fits content to the zoom-adjusted width, so the
+ * fitted content's visual size grows with the font scale instead of being
+ * scaled back down to its unzoomed size (LEMS-3885). The table below
+ * should render with visibly larger text than the same story without the
+ * zoomed container.
+ */
+export const UnderAncestorCSSZoom: Story = {
+ args: {
+ // Measure the actual table rather than the default story's
+ // hard-coded bounds.
+ computeChildBounds: undefined,
+ children: (
+
+
+
+ | Planet |
+ Diameter (km) |
+ Distance from sun (AU) |
+ Orbital period (years) |
+
+
+
+
+ | Mercury |
+ 4,879 |
+ 0.39 |
+ 0.24 |
+
+
+ | Neptune |
+ 49,244 |
+ 30.1 |
+ 164.8 |
+
+
+
+ ),
+ },
+ decorators: [
+ (StoryComponent) => (
+
+
+
+ ),
+ ],
+};
diff --git a/packages/perseus/src/components/__tests__/zoomable.test.tsx b/packages/perseus/src/components/__tests__/zoomable.test.tsx
index 21292f52d0..42402190da 100644
--- a/packages/perseus/src/components/__tests__/zoomable.test.tsx
+++ b/packages/perseus/src/components/__tests__/zoomable.test.tsx
@@ -330,6 +330,77 @@ describe("Zoomable", () => {
});
});
+ describe("under ancestor CSS zoom (device font scale)", () => {
+ // The mobile apps enlarge text by applying CSS `zoom` to .
+ // Zoomable must fit content to the zoom-adjusted width, otherwise
+ // its fit-to-width scale exactly cancels the font scaling
+ // (LEMS-3885).
+
+ const mockBodyZoom = (zoom: string) => {
+ const realGetComputedStyle = window.getComputedStyle;
+ jest.spyOn(window, "getComputedStyle").mockImplementation(
+ (el: Element) => {
+ const style = realGetComputedStyle(el);
+ if (el === document.body) {
+ Object.defineProperty(style, "zoom", {value: zoom});
+ }
+ return style;
+ },
+ );
+ };
+
+ it("scales to the zoom-adjusted width when the child overflows it", () => {
+ // Arrange
+ mockBodyZoom("1.5");
+ const {container} = render(
+
+ Some zoomable text
+ ,
+ );
+
+ // eslint-disable-next-line testing-library/no-node-access, no-restricted-syntax
+ const rootNode = container.firstElementChild as HTMLElement;
+ mockSize(rootNode, {width: 400, height: 100});
+ mockSize(screen.getByText("Some zoomable text"), {
+ width: 800,
+ height: 200,
+ });
+
+ // Act
+ act(() => jest.runAllTimers());
+
+ // Assert
+ // scale = (400 * 1.5) / 801, instead of 400 / 801 unzoomed
+ const scale = 600 / 801;
+ expect(rootNode.style.transform).toBe(`scale(${scale}, ${scale})`);
+ expect(rootNode.style.height).toBe(`${Math.ceil(scale * 201)}px`);
+ });
+
+ it("does not scale down when the child fits the zoom-adjusted width", () => {
+ // Arrange
+ mockBodyZoom("1.5");
+ const {container} = render(
+
+ Some zoomable text
+ ,
+ );
+
+ // eslint-disable-next-line testing-library/no-node-access, no-restricted-syntax
+ const rootNode = container.firstElementChild as HTMLElement;
+ mockSize(rootNode, {width: 400, height: 100});
+ mockSize(screen.getByText("Some zoomable text"), {
+ width: 500,
+ height: 200,
+ });
+
+ // Act
+ act(() => jest.runAllTimers());
+
+ // Assert
+ expect(rootNode.style.transform).toBe("scale(1, 1)");
+ });
+ });
+
describe("child node mutations", () => {
let computeChildBounds;
let componentContainer;
diff --git a/packages/perseus/src/components/zoomable.tsx b/packages/perseus/src/components/zoomable.tsx
index f3fb84d034..777f07f1db 100644
--- a/packages/perseus/src/components/zoomable.tsx
+++ b/packages/perseus/src/components/zoomable.tsx
@@ -6,6 +6,8 @@
import * as React from "react";
import ReactDOM from "react-dom";
+import {getCSSZoomFactor} from "../util/css-zoom-utils";
+
type Bounds = {
width: number;
height: number;
@@ -220,8 +222,15 @@ class Zoomable extends React.Component {
const childWidth = childBounds.width + 1;
const childHeight = childBounds.height + 1;
- if (childWidth > parentBounds.width) {
- const scale = parentBounds.width / childWidth;
+ // Fit to the zoom-adjusted width so the fitted content's visual
+ // size grows with the device font scale instead of being scaled
+ // back down to its unzoomed size. Overflow at the enlarged size is
+ // handled by the parent's overflowX scrolling and tap-to-zoom.
+ const availableWidth =
+ parentBounds.width * getCSSZoomFactor(this._node);
+
+ if (childWidth > availableWidth) {
+ const scale = availableWidth / childWidth;
this.setState({
scale,
diff --git a/packages/perseus/src/util/css-zoom-utils.test.ts b/packages/perseus/src/util/css-zoom-utils.test.ts
new file mode 100644
index 0000000000..aeb46222a4
--- /dev/null
+++ b/packages/perseus/src/util/css-zoom-utils.test.ts
@@ -0,0 +1,74 @@
+import {getCSSZoomFactor} from "./css-zoom-utils";
+
+describe("getCSSZoomFactor", () => {
+ const mockZoomByElement = (zoomByElement: Map) => {
+ jest.spyOn(window, "getComputedStyle").mockImplementation(
+ (el: Element) =>
+ // eslint-disable-next-line no-restricted-syntax -- partial mock of CSSStyleDeclaration; only `zoom` is read
+ ({zoom: zoomByElement.get(el) ?? ""}) as CSSStyleDeclaration,
+ );
+ };
+
+ it("returns 1 when no element in the tree has zoom applied", () => {
+ // Arrange
+ const node = document.createElement("div");
+ document.body.appendChild(node);
+
+ // Act
+ const zoomFactor = getCSSZoomFactor(node);
+
+ // Assert
+ expect(zoomFactor).toBe(1);
+ });
+
+ it("returns the zoom applied to an ancestor", () => {
+ // Arrange
+ const node = document.createElement("div");
+ document.body.appendChild(node);
+ mockZoomByElement(new Map([[document.body, "1.5"]]));
+
+ // Act
+ const zoomFactor = getCSSZoomFactor(node);
+
+ // Assert
+ expect(zoomFactor).toBe(1.5);
+ });
+
+ it("multiplies zoom values across the element and its ancestors", () => {
+ // Arrange
+ const parent = document.createElement("div");
+ const node = document.createElement("div");
+ parent.appendChild(node);
+ document.body.appendChild(parent);
+ mockZoomByElement(
+ new Map([
+ [node, "2"],
+ [parent, "1.5"],
+ ]),
+ );
+
+ // Act
+ const zoomFactor = getCSSZoomFactor(node);
+
+ // Assert
+ expect(zoomFactor).toBe(3);
+ });
+
+ it("ignores 'normal' and non-numeric zoom values", () => {
+ // Arrange
+ const node = document.createElement("div");
+ document.body.appendChild(node);
+ mockZoomByElement(
+ new Map([
+ [node, "normal"],
+ [document.body, "bogus"],
+ ]),
+ );
+
+ // Act
+ const zoomFactor = getCSSZoomFactor(node);
+
+ // Assert
+ expect(zoomFactor).toBe(1);
+ });
+});
diff --git a/packages/perseus/src/util/css-zoom-utils.ts b/packages/perseus/src/util/css-zoom-utils.ts
new file mode 100644
index 0000000000..90d5878204
--- /dev/null
+++ b/packages/perseus/src/util/css-zoom-utils.ts
@@ -0,0 +1,40 @@
+/**
+ * Gets the effective CSS zoom factor applied to an element or any of its ancestors.
+ * This is used to compensate for the mobile font scaling zoom applied to the body
+ * or exercise content via the fontScale query parameter.
+ *
+ * On mobile, the parent application may apply CSS zoom to accommodate device font
+ * size settings. This zoom affects coordinate calculations for click/touch events,
+ * as both clientX/clientY and getBoundingClientRect() return zoomed values, but
+ * the SVG coordinate system expects unzoomed pixel values. It also shrinks the
+ * widths reported by offsetWidth for viewport-constrained containers, which
+ * fit-to-width logic (e.g. Zoomable) must account for.
+ *
+ * Note: We calculate the cumulative zoom by traversing the DOM tree rather than
+ * targeting specific elements to avoid coupling Perseus to parent application
+ * implementation details (e.g., specific class names or DOM hierarchy).
+ *
+ * @param element - The DOM element to check for CSS zoom
+ * @returns The cumulative zoom factor (e.g., 1.5 for 150% zoom, 1.0 for no zoom)
+ */
+export function getCSSZoomFactor(element: Element): number {
+ let zoomFactor = 1;
+ let currentElement: Element | null = element;
+
+ // Traverse up the DOM tree to accumulate all zoom values
+ while (currentElement) {
+ const computedStyle = window.getComputedStyle(currentElement);
+ const zoom = computedStyle.zoom;
+
+ if (zoom && zoom !== "normal") {
+ const zoomValue = parseFloat(zoom);
+ if (!isNaN(zoomValue)) {
+ zoomFactor *= zoomValue;
+ }
+ }
+
+ currentElement = currentElement.parentElement;
+ }
+
+ return zoomFactor;
+}
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx
index 08b89be768..7f1403dfdb 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx
@@ -1,9 +1,9 @@
import {useTimeout} from "@khanacademy/wonder-blocks-timing";
import * as React from "react";
+import {getCSSZoomFactor} from "../../../util/css-zoom-utils";
import {actions} from "../reducer/interactive-graph-action";
import useGraphConfig from "../reducer/use-graph-config";
-import {getCSSZoomFactor} from "../utils";
import {
buildPointAriaLabel,
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx
index ec8e8f1f29..b95661e340 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx
@@ -9,6 +9,7 @@ import {
usePerseusI18n,
type I18nContextType,
} from "../../../components/i18n-context";
+import {getCSSZoomFactor} from "../../../util/css-zoom-utils";
import {snap} from "../math";
import {isInBound} from "../math/box";
import {actions} from "../reducer/interactive-graph-action";
@@ -17,7 +18,7 @@ import {
calculateSideSnap,
} from "../reducer/interactive-graph-reducer";
import useGraphConfig from "../reducer/use-graph-config";
-import {bound, getCSSZoomFactor, TARGET_SIZE} from "../utils";
+import {bound, TARGET_SIZE} from "../utils";
import {PolygonAngle} from "./components/angle-indicators";
import {
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts
index ed810c1c87..9dff5bf193 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts
@@ -3,9 +3,9 @@ import {useTransformContext, vec} from "mafs";
import * as React from "react";
import invariant from "tiny-invariant";
+import {getCSSZoomFactor} from "../../../util/css-zoom-utils";
import {X, Y} from "../math";
import useGraphConfig from "../reducer/use-graph-config";
-import {getCSSZoomFactor} from "../utils";
import type {RefObject} from "react";
diff --git a/packages/perseus/src/widgets/interactive-graphs/utils.ts b/packages/perseus/src/widgets/interactive-graphs/utils.ts
index 46bb86323f..0bbd91aa89 100644
--- a/packages/perseus/src/widgets/interactive-graphs/utils.ts
+++ b/packages/perseus/src/widgets/interactive-graphs/utils.ts
@@ -269,42 +269,3 @@ export const calculateNestedSVGCoords = (
viewboxY,
};
};
-
-/**
- * Gets the effective CSS zoom factor applied to an element or any of its ancestors.
- * This is used to compensate for the mobile font scaling zoom applied to the body
- * or exercise content via the fontScale query parameter.
- *
- * On mobile, the parent application may apply CSS zoom to accommodate device font
- * size settings. This zoom affects coordinate calculations for click/touch events,
- * as both clientX/clientY and getBoundingClientRect() return zoomed values, but
- * the SVG coordinate system expects unzoomed pixel values.
- *
- * Note: We calculate the cumulative zoom by traversing the DOM tree rather than
- * targeting specific elements to avoid coupling Perseus to parent application
- * implementation details (e.g., specific class names or DOM hierarchy).
- *
- * @param element - The DOM element to check for CSS zoom
- * @returns The cumulative zoom factor (e.g., 1.5 for 150% zoom, 1.0 for no zoom)
- */
-export function getCSSZoomFactor(element: Element): number {
- let zoomFactor = 1;
- let currentElement: Element | null = element;
-
- // Traverse up the DOM tree to accumulate all zoom values
- while (currentElement) {
- const computedStyle = window.getComputedStyle(currentElement);
- const zoom = computedStyle.zoom;
-
- if (zoom && zoom !== "normal") {
- const zoomValue = parseFloat(zoom);
- if (!isNaN(zoomValue)) {
- zoomFactor *= zoomValue;
- }
- }
-
- currentElement = currentElement.parentElement;
- }
-
- return zoomFactor;
-}