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:
@@ -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(<Button>Tap</Button>);
|
||||
expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
|
||||
unmount();
|
||||
render(<Button variant="link">Read more</Button>);
|
||||
expect(screen.getByRole("button", { name: "Read more" }).className).not.toContain(
|
||||
"touch-target",
|
||||
);
|
||||
});
|
||||
|
||||
it("fullWidth adds the w-full utility", () => {
|
||||
render(<Button fullWidth>Wide</Button>);
|
||||
expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full");
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -37,6 +37,27 @@ domain-free.
|
||||
| page & panel structure | `PageShell`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||
|
||||
## The size contract (E10 S1c)
|
||||
|
||||
The app optimises for **1024x768 (tablet landscape) as the floor**; below it we owe an honest
|
||||
message, above it correct rendering all the way up. That contract lives in these components, not in
|
||||
the ~160 call sites, because a rule repeated in 160 places is a rule that is already broken
|
||||
somewhere:
|
||||
|
||||
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES` are the rungs, derived from what
|
||||
the app actually used. Pick a rung; do not hand-roll padding on a primitive.
|
||||
- **Everything is rem-derived**, so the Settings text-size slider (`--font-scale` on the root
|
||||
font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an eslint error for
|
||||
exactly this reason.
|
||||
- **Minimum target: 2.75rem, on coarse pointers only** (`.touch-target`, index.css). A 32px icon
|
||||
button is fine with a mouse and not with a thumb, so the box grows where there is no precise
|
||||
pointer. It grows the real box rather than painting an invisible hit area: two expanded hit areas
|
||||
on neighbouring icon buttons would overlap, and an overlap fires the _wrong_ button.
|
||||
- `link` is excluded from the minimum: it is text inside a sentence.
|
||||
|
||||
Controls outside this directory (chips, list rows, the player's overlay buttons) are **not** covered
|
||||
yet -- they are the next thing to bring under the contract, not evidence that there isn't one.
|
||||
|
||||
## Adding one
|
||||
|
||||
Move a component in when a second module needs it and it is domain-free — not on speculation. Adding
|
||||
|
||||
@@ -539,6 +539,26 @@ body {
|
||||
.btn-warn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ===== E10 S1c · the minimum-target half of the 1024x768 comfort contract =====
|
||||
A 32px icon button is fine with a mouse (a precise pointer plus hover to confirm the aim) and
|
||||
not fine with a thumb. So the minimum grows ONLY where there is no precise pointer, which on
|
||||
this app's real second device — a tablet — is exactly where it is needed.
|
||||
|
||||
min-* rather than width/height so it can only ever grow a control, never shrink one, and so a
|
||||
`w-8 h-8` utility on the same element still loses to it (min-width beats width in CSS).
|
||||
Growing the BOX rather than painting an invisible ::after hit area is deliberate: an expanded
|
||||
hit area on two icon buttons sitting 4px apart would overlap, and the overlap fires the WRONG
|
||||
button — a worse failure than a small target. Real space cannot overlap.
|
||||
|
||||
2.75rem, not 44px: the whole app is rem-derived off `--font-scale`, so a target defined in px
|
||||
would be the one control that ignores the user's text-size setting. */
|
||||
@media (pointer: coarse) {
|
||||
.touch-target {
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
.glass-hover:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
|
||||
Reference in New Issue
Block a user