diff --git a/frontend/src/components/ui/Button.dom.test.tsx b/frontend/src/components/ui/Button.dom.test.tsx
index dc20d45a..77bd7c2e 100644
--- a/frontend/src/components/ui/Button.dom.test.tsx
+++ b/frontend/src/components/ui/Button.dom.test.tsx
@@ -98,6 +98,18 @@ describe("Button primitive (S5)", () => {
}
});
+ it("carries the coarse-pointer minimum target, except on a link", () => {
+ // The 1024x768 comfort contract: the minimum lives in the primitive, so no call site can opt
+ // out of it by accident. A link is text in a sentence and is excluded by design.
+ const { unmount } = render();
+ expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
+ unmount();
+ render();
+ expect(screen.getByRole("button", { name: "Read more" }).className).not.toContain(
+ "touch-target",
+ );
+ });
+
it("fullWidth adds the w-full utility", () => {
render();
expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full");
diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx
index e7b47d7a..15b2bc79 100644
--- a/frontend/src/components/ui/Button.tsx
+++ b/frontend/src/components/ui/Button.tsx
@@ -60,6 +60,11 @@ const VARIANTS: Record = {
link: "font-semibold text-accent hover:underline",
};
+// The geometry ladder, and the whole of it — a call site picks a rung, it does not invent one. The
+// rungs were derived from what the app actually used (counted during E10 S1b): `px-3 py-2 text-sm`
+// (22 sites), `px-2 py-1 text-xs` (12), `px-3 py-1.5 text-sm` (11). Every value is rem-derived, so
+// the Settings text-size slider (--font-scale, applied to the root font-size) scales the padding and
+// the box with the label instead of leaving a shrunken button around bigger text.
const SIZES: Record = {
xs: "px-2 py-1 text-xs rounded-md gap-1",
sm: "px-3 py-1.5 text-sm rounded-lg gap-1.5",
@@ -113,6 +118,11 @@ export const Button = forwardRef(function Button
// here rather than by asking every call site to pass `size="none"` alongside `variant="link"` —
// a pairing everyone would eventually forget, which is how a "link" grows a button's box.
const resolvedSize: ButtonSize = size ?? (variant === "link" ? "none" : "md");
+ // The 1024x768 comfort contract lives HERE, not in the call sites: on a coarse pointer every
+ // button-shaped control gets at least a 2.75rem box (see .touch-target in index.css). `link` is
+ // excluded on purpose — it is a run of text inside a sentence, and a 44px-tall inline link would
+ // wreck the paragraph it sits in rather than make anything easier to hit.
+ const touchTarget = variant !== "link";
const leading = loading ? : icon;
return (