From 7e97559615524920cf0a32e971484710202ff30e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 5 Sep 2026 18:49:48 +0200 Subject: [PATCH] feat(e10): put the 1024x768 size contract inside the primitives S1c, the last part of S1. The contract now lives in components/ui/ and in one CSS recipe instead of in ~160 call sites: - Minimum target 2.75rem, on coarse pointers only (.touch-target). A 32px icon button is fine with a mouse -- precise pointer, hover to confirm the aim -- and not fine with a thumb, so it grows exactly where there is no precise pointer, which on this app's real second device is the tablet. It grows the real box rather than painting an invisible ::after hit area, because two expanded hit areas on icon buttons 4px apart would overlap and the overlap fires the WRONG button. `link` is excluded: it is text inside a sentence. - 2.75rem rather than 44px so the target obeys the text-size slider like everything else; verified that the type/geometry ladder was already rem-derived off --font-scale, so nothing else was needed there. - The ladder is documented as a closed set in Button.tsx, with the site counts it was derived from, and ui/README.md states the contract. Measured in the browser: fine pointer 36x36 and 34px-tall buttons (unchanged), coarse pointer the same buttons at 44x44 and 44px tall. 1024x768 renders identically. 3x tsc, eslint, knip, prettier and 690 vitest green. --- .../src/components/ui/Button.dom.test.tsx | 12 +++++++++++ frontend/src/components/ui/Button.tsx | 11 ++++++++++ frontend/src/components/ui/README.md | 21 +++++++++++++++++++ frontend/src/index.css | 20 ++++++++++++++++++ 4 files changed, 64 insertions(+) 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 (