Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions configurator/src/data/classes.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 third-party widget you can add a class…",
"optional": false,
"layer": "slashed.accessibility"
},
{
"name": "sf-truncate",
"selector": ".sf-truncate",
Expand Down
89 changes: 64 additions & 25 deletions core/accessibility.css
Original file line number Diff line number Diff line change
Expand Up @@ -90,36 +90,75 @@
}
}

/* Minimum touch targets.
.sf-btn is excluded on purpose: it owns a deliberate XS–XL size ladder
(--sf-btn-min-height / --sf-size-* rungs) whose default already clears the
WCAG 2.2 AA 24px target, and a blanket floor here would silently override
that ladder on every touch device — collapsing the whole size scale to
44px. Consumers who want the AAA 44px target on buttons opt in explicitly
with `--sf-btn-min-height: var(--sf-touch-target)`. Bare <button>s (no
.sf-btn) and native form controls keep the floor.

The exclusion is written as [class~="sf-btn"] rather than :not(.sf-btn) on
purpose: the class-catalogue tooling masks quoted string literals, so this
keeps .sf-btn filed under its home `components` layer instead of being
re-attributed to `accessibility`. It is exactly equivalent to :not(.sf-btn)
in behaviour and specificity. */
/* Minimum touch targets — a backstop for genuinely bare, unstyled controls.
The floor targets only controls that carry NO class at all. The moment a
control has any class it is considered *owned* — by SLASHED itself (.sf-btn
owns a deliberate XS–XL size ladder via --sf-btn-min-height / --sf-size-*
rungs), by the author, or by a third-party widget (a page-builder hamburger
toggle, a plugin control) — and its sizing belongs to whoever put that class
there, not to this blanket rule.

Why so conservative: a blanket floor on *every* button/control silently
overrode its owners. It collapsed .sf-btn's whole size ladder to one height
on touch, and it stretched narrow custom widgets (e.g. a hamburger icon
button) to 44px on BOTH axes — at a specificity higher than a single class,
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. 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 `<button>text</button>` 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, 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~="sf-btn"]),
input[type="button"],
input[type="submit"],
input[type="reset"],
select,
summary {
: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);
}
}

input[type="checkbox"],
input[type="radio"] {
min-block-size: var(--sf-touch-target);
min-inline-size: var(--sf-touch-target);
}
/* .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 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.

<button class="my-menu-toggle sf-touch-target" aria-label="Menu">…</button>

It sets ONLY the two min-size properties — deliberately no `display` — so it
never strips a native affordance (e.g. a <summary>'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 <a>) 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 {
min-block-size: var(--sf-touch-target);
min-inline-size: var(--sf-touch-target);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/* Disabled states */
Expand Down
8 changes: 6 additions & 2 deletions demo/index.html

Large diffs are not rendered by default.

35 changes: 29 additions & 6 deletions docs/api-index.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,27 +25,27 @@
"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": {
"consumption": 488,
"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,
Expand Down Expand Up @@ -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 third-party widget you can add a class…",
"isVariant": false,
"baseClass": null,
"optional": false,
"layer": "slashed.accessibility",
"sourceFiles": [
"core/accessibility.css"
],
"bundles": [
"full",
"optimal"
]
},
{
"name": "sf-truncate",
"type": "class",
Expand Down
9 changes: 5 additions & 4 deletions docs/api-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down Expand Up @@ -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 |
|---|---|---|---|---|
Expand All @@ -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 <input>. |
| `.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 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 <body>. |
| `.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. |
Expand Down
5 changes: 3 additions & 2 deletions docs/classes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -240,7 +240,7 @@ See [architecture.md](architecture.md) for layer order and naming conventions.

## Accessibility (`core/accessibility.css`)

6 classes.
7 classes.

| Class |
|---|
Expand All @@ -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`)

Expand Down
11 changes: 11 additions & 0 deletions docs/llm-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,53 @@ 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 `<button>` and native
control (only `.sf-btn` was carved out). Because it set both `min-block-size`
and `min-inline-size` to `var(--sf-touch-target)` (44px) at a specificity
higher than a single class, it reached into markup the framework doesn't own —
most visibly third-party page-builder and plugin controls. A narrow custom
control such as a hamburger toggle (`<button class="…-menu-toggle">`) was
stretched to 44px on both axes and could not be resized without `!important`.

The floor is now scoped to **controls with no effective class**
(`:where(button, input[type="button"], …, summary):not([class]:not([class=""]))`)
— no class attribute at all, or an empty `class=""` (which renderers often emit
for an unstyled control). Any control that carries a real class token is treated
as *owned* — by SLASHED (`.sf-btn`), by you, or by a third-party widget — and
keeps whatever size its owner gives it. Genuinely bare, un-classed controls
(the author's own quick markup) keep the WCAG 2.5.5 44px floor on both axes.
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. It sets
only the two min-size properties (no `display`), so it never strips a native
affordance such as a `<summary>` marker; on a purely inline element (a bare
`<a>`) pair it with your own `display: inline-flex`/`inline-block`:

```html
<button class="my-menu-toggle sf-touch-target" aria-label="Menu">☰</button>
```

**What changed for you:**
- A bare, class-less `<button>` / native control is unaffected — it still gets
the 44px floor on touch.
- A control that carries **any** class no longer gets the automatic floor. If
you want the 44px hit area on a class-bearing control, add `.sf-touch-target`
(or set `min-block-size`/`min-inline-size` yourself, or on a `.sf-btn` use
`:root { --sf-btn-min-height: var(--sf-touch-target); }`).
- To opt a control **out** of the floor (e.g. a custom icon button that was
being stretched), give it any class — no `!important` needed.

## SLASHED 0.7.8 → 0.8.0

### `.sf-btn` size scale honoured on touch; blanket 44px floor no longer applies to buttons (breaking)
Expand Down
3 changes: 2 additions & 1 deletion docs/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
],
"counts": {
"tokens": 741,
"sf_classes": 289,
"sf_classes": 290,
"is_classes": 15
}
},
Expand Down Expand Up @@ -1055,6 +1055,7 @@
"sf-theme-dark",
"sf-theme-light",
"sf-theme-transition",
"sf-touch-target",
"sf-truncate",
"sf-visible"
],
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions tests/a11y-patterns.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,43 @@ test.describe('a11y: touch target token', () => {
expect(px).toBeGreaterThanOrEqual(44);
});

// .sf-touch-target — explicit opt-in for a classed control the automatic
// class-less floor no longer reaches. Not gated to a coarse pointer, so it
// enforces the 44px hit area here on a normal (fine) pointer too.
test('.sf-touch-target enforces a ≥44px hit area on both axes on a classed control', async ({ page }) => {
await setup(page, `<button id="t" class="my-toggle sf-touch-target"
style="inline-size:20px;block-size:20px;padding:0">x</button>`);
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, `<style>.small{min-block-size:20px;min-inline-size:20px}</style>
<button id="t" class="sf-touch-target small"
style="inline-size:20px;block-size:20px;padding:0">x</button>`);
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);
});

// .sf-touch-target sets only min-sizes (no display), so it must not strip a
// native affordance — e.g. a <summary>'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, `<details><summary id="t" class="sf-touch-target">More</summary>body</details>`);
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 }) => {
Expand Down
Loading