From fd8db15c59a2871aef1e8c0ea90d311566980372 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 19:13:29 +0000 Subject: [PATCH 1/4] fix(a11y): scope coarse-pointer touch-target floor to class-less controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @media (pointer: coarse) minimum-touch-target floor in core/accessibility.css targeted every bare ` and un-classed native controls — almost always + the author's own quick markup — keep the WCAG 2.5.5 44px floor on both axes. + To opt a control OUT, give it any class. To put the floor back ON an owned + control, set its own min-block/inline-size, or on .sf-btn use + `--sf-btn-min-height: var(--sf-touch-target)`. */ @media (pointer: coarse) { - button:not([class~="sf-btn"]), - input[type="button"], - input[type="submit"], - input[type="reset"], - select, - summary { - min-block-size: var(--sf-touch-target); - min-inline-size: var(--sf-touch-target); - } - - input[type="checkbox"], - input[type="radio"] { + button:not([class]), + input[type="button"]:not([class]), + input[type="submit"]:not([class]), + input[type="reset"]:not([class]), + select:not([class]), + summary:not([class]), + input[type="checkbox"]:not([class]), + input[type="radio"]:not([class]) { min-block-size: var(--sf-touch-target); min-inline-size: var(--sf-touch-target); } diff --git a/demo/index.html b/demo/index.html index 444b6af6..cd8c6af3 100644 --- a/demo/index.html +++ b/demo/index.html @@ -2125,7 +2125,7 @@

Disabled States

Touch Targets (pointer: coarse)

-

On touch devices, a, button, input, select, summary automatically get min-height: var(--sf-touch-target) = var(--sf-size-l) = 2.75rem (≈44px). No extra classes needed.

+

On touch devices, class-less button, form inputs, select, and summary automatically get min-block-size/min-inline-size: var(--sf-touch-target) = 2.75rem (≈44px). Give a control any class (e.g. .sf-btn, or a third-party widget class) and it owns its own sizing — the floor never overrides styled or third-party markup.

High Contrast & Reduced Transparency

diff --git a/docs/migration.md b/docs/migration.md index 895ba496..6d1581c6 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -79,6 +79,37 @@ you're on a bundle that includes `optional/utilities.css` (the `full` bundle, or your own custom build — the `optimal` bundle does not include it). Full rationale for every class: [states.md § "Prefer native state"](states.md). +### Coarse-pointer touch-target floor scoped to class-less controls (breaking) + +The `@media (pointer: coarse)` minimum-touch-target floor in +`core/accessibility.css` used to target **every** bare `` }, async (page) => { const h = await heights(page, ['b']); expect(h[0]).toBeGreaterThanOrEqual(44); }); }); + + // Regression guard: the blanket floor must not reach into markup the framework + // does not own. Any class marks a control as owned/styled (a third-party + // page-builder hamburger toggle, a plugin control), so the floor is scoped to + // classless controls only — a classed button escapes it on both axes without + // needing !important. See core/accessibility.css "Minimum touch targets". + test('a classed `; + await withTouch(browser, { html }, async (page) => { + const box = await page.evaluate(() => { + const r = document.getElementById('t').getBoundingClientRect(); + return { w: r.width, h: r.height }; + }); + // Neither axis is stretched to the 44px floor — the author's 32px wins. + expect(box.w).toBeLessThan(44); + expect(box.h).toBeLessThan(44); + }); + }); }); From e7c9652d0449a498e9d35bbe6164089e0dace61e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 09:22:15 +0000 Subject: [PATCH 2/4] feat(a11y): add .sf-touch-target opt-in class for the WCAG 44px hit area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoping the automatic coarse-pointer floor to class-less controls (prev commit) left classed controls — i.e. almost all real-world controls — with no first-class way to request the 44px target beyond hand-rolling min-sizes or the .sf-btn-only --sf-btn-min-height knob. Add the explicit opt-in counterpart so the floor stays a safety net for bare markup while the everyday mechanism is a class. .sf-touch-target enforces min-block-size/min-inline-size: var(--sf-touch-target) on both axes and centres a short label via inline-flex (so it also works on inline elements like ). It lives in core/accessibility.css alongside .sr-only / .skip-link / .sf-focus-shadow, so it ships in every bundle, not just full. It is a single class (specificity 0,1,0) with no !important, so a component rule can still opt a specific control back out. Unlike the automatic floor it is not gated to a coarse pointer — an explicit opt-in is predictable on every device. - core/accessibility.css: add the .sf-touch-target helper + rationale - tests/a11y-patterns.spec.js: enforce-on-classed-control and overridable-without-!important guards - docs/migration.md: document .sf-touch-target as the primary remedy Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GgL6mqSxe2ihjaUCxx78eR --- configurator/src/data/classes.generated.json | 10 ++++++ core/accessibility.css | 23 +++++++++++++ docs/api-index.json | 35 ++++++++++++++++---- docs/api-index.md | 9 ++--- docs/classes.md | 5 +-- docs/migration.md | 22 +++++++++--- docs/registry.json | 3 +- tests/a11y-patterns.spec.js | 28 ++++++++++++++++ 8 files changed, 118 insertions(+), 17 deletions(-) diff --git a/configurator/src/data/classes.generated.json b/configurator/src/data/classes.generated.json index 2f134350..171e2911 100644 --- a/configurator/src/data/classes.generated.json +++ b/configurator/src/data/classes.generated.json @@ -3074,6 +3074,16 @@ "optional": false, "layer": "slashed.themes" }, + { + "name": "sf-touch-target", + "selector": ".sf-touch-target", + "kind": "accessibility", + "category": "Accessibility", + "group": "", + "description": ".sf-touch-target — the explicit opt-IN counterpart to the class-less floor above. Because the automatic floor deliberately never touches a control that carries a class, put this class on any control you own (a button with your own class, a link, a third-party widget you can add…", + "optional": false, + "layer": "slashed.accessibility" + }, { "name": "sf-truncate", "selector": ".sf-truncate", diff --git a/core/accessibility.css b/core/accessibility.css index b80e7721..b0c1c798 100644 --- a/core/accessibility.css +++ b/core/accessibility.css @@ -127,6 +127,29 @@ } } + /* .sf-touch-target — the explicit opt-IN counterpart to the class-less floor + above. Because the automatic floor deliberately never touches a control + that carries a class, put this class on any control you own (a button with + your own class, a link, a third-party widget you can add a class to) to + guarantee the WCAG 2.5.5 44px minimum hit area on both axes. Unlike the + automatic floor it is NOT gated to a coarse pointer — an explicit opt-in + is predictable everywhere. Size follows --sf-touch-target, so a wrapper (or + the element itself) can retune it locally; the flex centring keeps a short + label centred inside the enlarged box instead of pinned top-left. + + + + No !important: it's a single class (specificity 0,1,0), so your own + component rule can still override it when a specific control needs to opt + back out. */ + .sf-touch-target { + display: inline-flex; + align-items: center; + justify-content: center; + min-block-size: var(--sf-touch-target); + min-inline-size: var(--sf-touch-target); + } + /* Disabled states */ [disabled], [aria-disabled="true"] { diff --git a/docs/api-index.json b/docs/api-index.json index a97e1ddb..594958ef 100644 --- a/docs/api-index.json +++ b/docs/api-index.json @@ -25,14 +25,14 @@ "optimal" ], "counts": { - "total": 1053, + "total": 1054, "by_type": { - "class": 312, + "class": 313, "token": 741 }, "by_tier": { "INTERNAL": 1, - "PUBLIC": 1003, + "PUBLIC": 1004, "PUBLIC-ADVANCED": 49 }, "by_role": { @@ -40,12 +40,12 @@ "knob": 253 }, "tokens": 741, - "classes": 312, - "sf_classes": 289, + "classes": 313, + "sf_classes": 290, "is_classes": 15, "unprefixed_classes": 8, "by_category": { - "Accessibility": 8, + "Accessibility": 9, "Component tokens": 46, "Components": 30, "Core tokens": 610, @@ -26314,6 +26314,29 @@ "optimal" ] }, + { + "name": "sf-touch-target", + "type": "class", + "tier": "PUBLIC", + "selector": ".sf-touch-target", + "prefix": "sf", + "kind": "accessibility", + "category": "Accessibility", + "area": "accessibility", + "group": "", + "description": ".sf-touch-target — the explicit opt-IN counterpart to the class-less floor above. Because the automatic floor deliberately never touches a control that carries a class, put this class on any control you own (a button with your own class, a link, a third-party widget you can add…", + "isVariant": false, + "baseClass": null, + "optional": false, + "layer": "slashed.accessibility", + "sourceFiles": [ + "core/accessibility.css" + ], + "bundles": [ + "full", + "optimal" + ] + }, { "name": "sf-truncate", "type": "class", diff --git a/docs/api-index.md b/docs/api-index.md index 9a9f0373..d7495a35 100644 --- a/docs/api-index.md +++ b/docs/api-index.md @@ -10,11 +10,11 @@ and a short description. The machine-readable companion (with all columns) is [registry.json](registry.json); for the tier contract see [architecture.md](architecture.md). -**1053 elements** — 741 tokens, 312 classes. +**1054 elements** — 741 tokens, 313 classes. | Tier | Count | Meaning | |---|---|---| -| PUBLIC | 1003 | Everyday surface. SemVer-stable. | +| PUBLIC | 1004 | Everyday surface. SemVer-stable. | | PUBLIC-ADVANCED | 49 | Same SemVer guarantee; niche/powerful. | | INTERNAL | 1 | Implementation detail; may change without a major bump. | @@ -781,9 +781,9 @@ and a short description. The machine-readable companion (with all columns) is | `--sf-surface-bg-size` | PUBLIC | knob | surface | `cover` | background-size for the .sf-surface-bg image. Default: cover. | | `--sf-surface-color` | PUBLIC | consumption | surface | `var(--sf-color-base)` | Input for the generic .sf-surface macro. Set any color (including palette shades); the macro derives background, auto-contrast foreground, and the contextual token set from it. | -## Classes (312) +## Classes (313) -### Accessibility (8) +### Accessibility (9) | Class | Tier | Kind | Group | Description | |---|---|---|---|---| @@ -792,6 +792,7 @@ and a short description. The machine-readable companion (with all columns) is | `.sf-clickable-parent__overlay` | PUBLIC | accessibility | — | The full-bleed overlay link inside an sf-clickable-parent. Stretches to cover the parent and uses pointer-events to let interactive children keep their own clicks. | | `.sf-focus-parent` | PUBLIC | accessibility | — | Forwards focus-visible styling to this container when any descendant is keyboard-focused. Useful for custom controls that wrap a visually-hidden . | | `.sf-focus-shadow` | PUBLIC | accessibility | — | Applies the standard focus ring as a box-shadow to any element. Use on custom controls where the native :focus-visible ring doesn't render correctly. | +| `.sf-touch-target` | PUBLIC | accessibility | — | .sf-touch-target — the explicit opt-IN counterpart to the class-less floor above. Because the automatic floor deliberately never touches a control that carries a class, put this class on any control you own (a button with your own class, a link, a third-party widget you can add… | | `.skip-link` | PUBLIC | accessibility | — | Accessible skip-navigation link. Hidden until focused; jumps keyboard users past repeated navigation blocks. Place as the very first element in . | | `.sr-only` | PUBLIC | accessibility | — | Visually hides content while keeping it accessible to screen readers. Use for labels, descriptions, and off-screen text that assistive technology needs. | | `.sr-only-focusable` | PUBLIC | accessibility | — | Extends sr-only — also reveals the element when it receives keyboard focus. Typical use: skip links and visually-hidden focus targets. | diff --git a/docs/classes.md b/docs/classes.md index ba5e1ea9..84cf7b29 100644 --- a/docs/classes.md +++ b/docs/classes.md @@ -3,7 +3,7 @@ > **Generated** from source by `scripts/gen-class-reference.js` — > run `npm run docs:classes` to refresh. Do not edit by hand. -**289 .sf-classes, 15 .sf-is-classes.** Every `.sf-*` layout/macro class and `.sf-is-*` state class. +**290 .sf-classes, 15 .sf-is-classes.** Every `.sf-*` layout/macro class and `.sf-is-*` state class. See [architecture.md](architecture.md) for layer order and naming conventions. ## Layout primitives (`core/layout.css`) @@ -240,7 +240,7 @@ See [architecture.md](architecture.md) for layer order and naming conventions. ## Accessibility (`core/accessibility.css`) -6 classes. +7 classes. | Class | |---| @@ -250,6 +250,7 @@ See [architecture.md](architecture.md) for layer order and naming conventions. | `.sf-focus-shadow` | | `.sf-is-loading` | | `.sf-text-gradient` | +| `.sf-touch-target` | ## Motion / entrances (`core/motion.css`) diff --git a/docs/migration.md b/docs/migration.md index 6d1581c6..2c161a95 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -100,13 +100,27 @@ keeps whatever size its owner gives it. Genuinely bare, un-classed controls This generalises the earlier `.sf-btn` carve-out (see 0.7.8 → 0.8.0 below) into a single rule: *the framework never re-sizes a control someone else has styled.* +Because most real controls carry a class, the automatic floor is now a +**safety net for bare markup**, not the everyday mechanism. The everyday +mechanism is the new opt-in class: + +**`.sf-touch-target` (new)** — the explicit counterpart to the class-less +floor. Put it on any control you own to guarantee the WCAG 2.5.5 44px hit +area on both axes; it lives in `core/accessibility.css` so it's in every +bundle. Unlike the automatic floor it is not gated to a coarse pointer, and it +centres a short label inside the enlarged box: + +```html + +``` + **What changed for you:** - A bare, class-less ``); + const box = await page.locator('#t').evaluate(el => { + const r = el.getBoundingClientRect(); + return { w: r.width, h: r.height }; + }); + expect(box.w).toBeGreaterThanOrEqual(44); + expect(box.h).toBeGreaterThanOrEqual(44); + }); + + // A single class (0,1,0) — a component rule can still override it without + // !important, so a specific control can opt back out. + test('.sf-touch-target is overridable by a plain class rule (no !important)', async ({ page }) => { + await setup(page, ` + `); + const box = await page.locator('#t').evaluate(el => { + const r = el.getBoundingClientRect(); + return { w: r.width, h: r.height }; + }); + expect(box.w).toBeLessThan(44); + expect(box.h).toBeLessThan(44); + }); + // #582: the WCAG floor must NOT track the configurable --sf-size-* scale. // Shrinking a size rung must not drag the touch target below spec. test('--sf-touch-target is independent of the --sf-size-* scale', async ({ page }) => { From 53b5e43303edf320854611faf01cf1b812bdd340 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 09:52:10 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(a11y):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20empty=20class=3D"",=20native=20display,=20docs,=20g?= =?UTF-8?q?allery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #648: - Empty class="" bypassed the floor (P1): renderers routinely emit class="" for an unstyled control, and :not([class]) excluded it, so a genuinely-unstyled control could sit below 44px on touch. Scope the floor to "no effective class" via :where(button, …):not([class]:not([class=""])) — matches no class attribute OR an empty one. :where() keeps the element list at zero specificity. - .sf-touch-target replaced native display (P1): display: inline-flex stripped a classed

's list-item marker (and any control's native baseline). Drop display entirely — the class now sets only the two min-size properties, mirroring the automatic floor. Document that a purely inline target () needs its own display. - Class gallery coverage test failed: add the .sf-touch-target preview card and the #cov-data entry so the demo gallery matches the API class set. - llm-guide.md: document the class-less-only automatic floor and the .sf-touch-target opt-in (per CLAUDE.md llm-guide-sync mandate). - Tests: guard the empty-class floor (button-touch) and that .sf-touch-target leaves a as list-item (a11y-patterns). - migration.md: drop the centring claim; note sizing-only / inline caveat. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GgL6mqSxe2ihjaUCxx78eR --- configurator/src/data/classes.generated.json | 2 +- core/accessibility.css | 51 ++++++++++++-------- demo/index.html | 6 ++- docs/api-index.json | 2 +- docs/api-index.md | 2 +- docs/llm-guide.md | 11 +++++ docs/migration.md | 14 +++--- tests/a11y-patterns.spec.js | 9 ++++ tests/button-touch.spec.js | 16 +++++- 9 files changed, 81 insertions(+), 32 deletions(-) diff --git a/configurator/src/data/classes.generated.json b/configurator/src/data/classes.generated.json index 171e2911..e09ad391 100644 --- a/configurator/src/data/classes.generated.json +++ b/configurator/src/data/classes.generated.json @@ -3080,7 +3080,7 @@ "kind": "accessibility", "category": "Accessibility", "group": "", - "description": ".sf-touch-target — the explicit opt-IN counterpart to the class-less floor above. Because the automatic floor deliberately never touches a control that carries a class, put this class on any control you own (a button with your own class, a link, a third-party widget you can add…", + "description": ".sf-touch-target — the explicit opt-IN counterpart to the class-less floor above. Because the automatic floor deliberately never touches a control that carries a class, put this class on any control you own (a button with your own class, a third-party widget you can add a class…", "optional": false, "layer": "slashed.accessibility" }, diff --git a/core/accessibility.css b/core/accessibility.css index b0c1c798..31fc00ba 100644 --- a/core/accessibility.css +++ b/core/accessibility.css @@ -105,23 +105,31 @@ so the owner couldn't even override it without !important. Element-level selectors that reach for someone else's markup are the "too aggressive base style" trap; scoping the floor to classless controls keeps it from ever - touching markup the framework doesn't own. (`:not([class])` matches the - same "un-styled element" idiom already used for `a:not([class])` in base.) + touching markup the framework doesn't own. It extends the "un-styled + element" idiom already used for `a:not([class])` in base. + + "Un-styled" here means *no effective class* — no class attribute at all, OR + an empty one (`class=""`), which templating/component renderers routinely + emit for a control the author never styled. Both keep the floor. The test + is written `:not([class]:not([class=""]))` — read it as "not (has a + non-empty class)": it excludes only controls carrying a real class token. + (A whitespace-only `class=" "` is treated as owned; that pathological case + isn't worth widening the selector for.) `:where()` keeps the element list + at zero specificity so the whole rule stays a low, easily-reasoned weight. Bare `` and un-classed native controls — almost always the author's own quick markup — keep the WCAG 2.5.5 44px floor on both axes. To opt a control OUT, give it any class. To put the floor back ON an owned - control, set its own min-block/inline-size, or on .sf-btn use + control, add the .sf-touch-target opt-in class below, set its own + min-block/inline-size, or on .sf-btn use `--sf-btn-min-height: var(--sf-touch-target)`. */ @media (pointer: coarse) { - button:not([class]), - input[type="button"]:not([class]), - input[type="submit"]:not([class]), - input[type="reset"]:not([class]), - select:not([class]), - summary:not([class]), - input[type="checkbox"]:not([class]), - input[type="radio"]:not([class]) { + :where( + button, + input[type="button"], input[type="submit"], input[type="reset"], + select, summary, + input[type="checkbox"], input[type="radio"] + ):not([class]:not([class=""])) { min-block-size: var(--sf-touch-target); min-inline-size: var(--sf-touch-target); } @@ -130,22 +138,25 @@ /* .sf-touch-target — the explicit opt-IN counterpart to the class-less floor above. Because the automatic floor deliberately never touches a control that carries a class, put this class on any control you own (a button with - your own class, a link, a third-party widget you can add a class to) to - guarantee the WCAG 2.5.5 44px minimum hit area on both axes. Unlike the - automatic floor it is NOT gated to a coarse pointer — an explicit opt-in - is predictable everywhere. Size follows --sf-touch-target, so a wrapper (or - the element itself) can retune it locally; the flex centring keeps a short - label centred inside the enlarged box instead of pinned top-left. + your own class, a third-party widget you can add a class to) to guarantee + the WCAG 2.5.5 44px minimum hit area on both axes. Unlike the automatic + floor it is NOT gated to a coarse pointer — an explicit opt-in is + predictable everywhere. Size follows --sf-touch-target, so a wrapper (or + the element itself) can retune it locally. + It sets ONLY the two min-size properties — deliberately no `display` — so it + never strips a native affordance (e.g. a 's list-item marker, a + control's baseline alignment). It therefore expects a target whose display + already honours min-inline-size: buttons and native form controls + (inline-block/flex) do. On a purely inline element (a bare ) add your own + `display: inline-flex` (or inline-block) alongside it. + No !important: it's a single class (specificity 0,1,0), so your own component rule can still override it when a specific control needs to opt back out. */ .sf-touch-target { - display: inline-flex; - align-items: center; - justify-content: center; min-block-size: var(--sf-touch-target); min-inline-size: var(--sf-touch-target); } diff --git a/demo/index.html b/demo/index.html index cd8c6af3..f66d0efd 100644 --- a/demo/index.html +++ b/demo/index.html @@ -4222,6 +4222,10 @@

Accessibility .sf-focus-shadow public

Applies the standard focus ring as a box-shadow to any element. Use on custom controls where the native :focus-visible ring doesn't render correctly.

+
+
.sf-touch-target public

Opt-in WCAG 2.5.5 minimum hit area — enforces a 44px min-block/inline-size on a control you own. Counterpart to the automatic floor, which only backstops controls with no class.

+
+
.skip-link public

Accessible skip-navigation link. Hidden until focused; jumps keyboard users past repeated navigation blocks. Place as the very first element in <body>.

@@ -5017,7 +5021,7 @@

Design tokens 7
--sf-z-tooltip
- + diff --git a/docs/api-index.json b/docs/api-index.json index 594958ef..fb0e5cd6 100644 --- a/docs/api-index.json +++ b/docs/api-index.json @@ -26324,7 +26324,7 @@ "category": "Accessibility", "area": "accessibility", "group": "", - "description": ".sf-touch-target — the explicit opt-IN counterpart to the class-less floor above. Because the automatic floor deliberately never touches a control that carries a class, put this class on any control you own (a button with your own class, a link, a third-party widget you can add…", + "description": ".sf-touch-target — the explicit opt-IN counterpart to the class-less floor above. Because the automatic floor deliberately never touches a control that carries a class, put this class on any control you own (a button with your own class, a third-party widget you can add a class…", "isVariant": false, "baseClass": null, "optional": false, diff --git a/docs/api-index.md b/docs/api-index.md index d7495a35..624e573b 100644 --- a/docs/api-index.md +++ b/docs/api-index.md @@ -792,7 +792,7 @@ and a short description. The machine-readable companion (with all columns) is | `.sf-clickable-parent__overlay` | PUBLIC | accessibility | — | The full-bleed overlay link inside an sf-clickable-parent. Stretches to cover the parent and uses pointer-events to let interactive children keep their own clicks. | | `.sf-focus-parent` | PUBLIC | accessibility | — | Forwards focus-visible styling to this container when any descendant is keyboard-focused. Useful for custom controls that wrap a visually-hidden . | | `.sf-focus-shadow` | PUBLIC | accessibility | — | Applies the standard focus ring as a box-shadow to any element. Use on custom controls where the native :focus-visible ring doesn't render correctly. | -| `.sf-touch-target` | PUBLIC | accessibility | — | .sf-touch-target — the explicit opt-IN counterpart to the class-less floor above. Because the automatic floor deliberately never touches a control that carries a class, put this class on any control you own (a button with your own class, a link, a third-party widget you can add… | +| `.sf-touch-target` | PUBLIC | accessibility | — | .sf-touch-target — the explicit opt-IN counterpart to the class-less floor above. Because the automatic floor deliberately never touches a control that carries a class, put this class on any control you own (a button with your own class, a third-party widget you can add a class… | | `.skip-link` | PUBLIC | accessibility | — | Accessible skip-navigation link. Hidden until focused; jumps keyboard users past repeated navigation blocks. Place as the very first element in . | | `.sr-only` | PUBLIC | accessibility | — | Visually hides content while keeping it accessible to screen readers. Use for labels, descriptions, and off-screen text that assistive technology needs. | | `.sr-only-focusable` | PUBLIC | accessibility | — | Extends sr-only — also reveals the element when it receives keyboard focus. Typical use: skip links and visually-hidden focus targets. | diff --git a/docs/llm-guide.md b/docs/llm-guide.md index e2409cb2..6c255b7d 100644 --- a/docs/llm-guide.md +++ b/docs/llm-guide.md @@ -1072,6 +1072,17 @@ Ready-made `animation` values — keyframe + duration + easing + fill-mode. --sf-blur: 12px /* frosted glass backdrop-filter */ ``` +On a coarse (touch) pointer the framework auto-applies a `--sf-touch-target` +(44px) minimum on both axes — but **only to controls with no effective class** +(no `class` attribute, or an empty `class=""`). Any control carrying a real +class is treated as *owned* (by `.sf-btn`, by you, or by a third-party widget) +and keeps its own sizing, so the floor never re-sizes styled or third-party +markup. To request the 44px hit area on a control you own, add the **`.sf-touch-target`** +opt-in class (`core/accessibility.css`, every bundle) — it sets only the two +min-sizes (no `display`, so it never strips a native affordance) and, unlike the +automatic floor, applies regardless of pointer type. On `.sf-btn` the size ladder +governs instead; opt a button into 44px with `--sf-btn-min-height: var(--sf-touch-target)`. + ### 9.9 Form field tokens ```css diff --git a/docs/migration.md b/docs/migration.md index 2c161a95..24a5dcd3 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -90,10 +90,10 @@ most visibly third-party page-builder and plugin controls. A narrow custom control such as a hamburger toggle (` diff --git a/tests/a11y-patterns.spec.js b/tests/a11y-patterns.spec.js index 5d1d5aaf..d0c824f1 100644 --- a/tests/a11y-patterns.spec.js +++ b/tests/a11y-patterns.spec.js @@ -227,6 +227,15 @@ test.describe('a11y: touch target token', () => { expect(box.h).toBeLessThan(44); }); + // .sf-touch-target sets only min-sizes (no display), so it must not strip a + // native affordance — e.g. a 's list-item display / disclosure + // marker. Guards the review finding about replacing native display modes. + test('.sf-touch-target does not override a native control display (summary stays list-item)', async ({ page }) => { + await setup(page, `
Morebody
`); + const display = await page.locator('#t').evaluate(el => getComputedStyle(el).display); + expect(display).toBe('list-item'); + }); + // #582: the WCAG floor must NOT track the configurable --sf-size-* scale. // Shrinking a size rung must not drag the touch target below spec. test('--sf-touch-target is independent of the --sf-size-* scale', async ({ page }) => { diff --git a/tests/button-touch.spec.js b/tests/button-touch.spec.js index 505593e3..bf478108 100644 --- a/tests/button-touch.spec.js +++ b/tests/button-touch.spec.js @@ -2,8 +2,10 @@ // Touch-device (pointer: coarse) behaviour for .sf-btn. // // The WCAG 44px touch-target floor in core/accessibility.css is scoped to -// class-less controls (`button:not([class])`), so it never touches styled or -// third-party markup. .sf-btn carries a class and is therefore exempt: its +// controls with no effective class (no class attribute, or an empty class=""; +// `:not([class]:not([class=""]))`), so it never touches styled or third-party +// markup but still backstops unstyled markup. .sf-btn carries a class and is +// therefore exempt: its // XS–XL size ladder stays visible on phones/tablets instead of every rung // collapsing to 44px. This is the regression guard for the "all button sizes // look identical on mobile" report: the ladder rendered correctly with a mouse @@ -93,6 +95,16 @@ test.describe('.sf-btn on a coarse (touch) pointer', () => { }); }); + // Renderers routinely emit an empty class="" for a control the author never + // styled. That is still an unstyled control, so it must keep the floor — + // hence :not([class]:not([class=""])) rather than a plain :not([class]). + test('a ` }, async (page) => { + const h = await heights(page, ['b']); + expect(h[0]).toBeGreaterThanOrEqual(44); + }); + }); + // Regression guard: the blanket floor must not reach into markup the framework // does not own. Any class marks a control as owned/styled (a third-party // page-builder hamburger toggle, a plugin control), so the floor is scoped to From ccdadb30ecd49b2d8623656f762bef72d2f8de86 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 10:17:52 +0000 Subject: [PATCH 4/4] chore(deps): bump fast-uri to 3.1.4 to clear high-severity audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm audit fix` bumps fast-uri 3.1.2 → 3.1.4, resolving the two high-severity advisories (GHSA-4c8g-83qw-93j6 host confusion via failed IDN canonicalization; GHSA-v2hh-gcrm-f6hx literal backslash authority delimiter). fast-uri is a deep devDependency transitive (stylelint → table → ajv → fast-uri); the bump is in-major and touches only package-lock.json. `npm audit` now reports 0 vulnerabilities; stylelint (lint:css) and the unit suite (129/129) still pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GgL6mqSxe2ihjaUCxx78eR --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 15ebe870..3a56a98f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2460,9 +2460,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ {