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.
This commit is contained in:
2026-09-05 18:49:48 +02:00
parent 721467ef69
commit 7e97559615
4 changed files with 64 additions and 0 deletions
+11
View File
@@ -60,6 +60,11 @@ const VARIANTS: Record<ButtonVariant, string> = {
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<ButtonSize, string> = {
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<HTMLButtonElement, ButtonProps>(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 ? <Loader2 className="w-4 h-4 animate-spin" /> : icon;
return (
<button
@@ -129,6 +139,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
BASE,
VARIANTS[variant],
SIZES[resolvedSize],
touchTarget && "touch-target",
fullWidth && "w-full",
className,
)}