Skip to content

feat(a11y)!: let .sf-btn size scale win over the coarse-pointer 44px floor - #589

Merged
jackgranatowski merged 2 commits into
mainfrom
claude/button-size-visibility-issue-40xlwy
Jul 10, 2026
Merged

feat(a11y)!: let .sf-btn size scale win over the coarse-pointer 44px floor#589
jackgranatowski merged 2 commits into
mainfrom
claude/button-size-visibility-issue-40xlwy

Conversation

@jackgranatowski

@jackgranatowski jackgranatowski commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Problem

On touch devices the whole .sf-btn XS–XL size scale collapsed to one height — the recurring "all button sizes look identical on mobile" report. The framework CSS, the deployed build and the size ladder were all correct; the scale rendered fine with a mouse but flattened on phones/tablets.

Root cause: the blanket @media (pointer: coarse) rule in core/accessibility.css applied min-block-size: var(--sf-touch-target) (44px) to every <button>, including .sf-btn. On any coarse pointer this overrode the button's own min-height ladder and pinned every rung (XS/S/M/L) to 44px. It was tied to the device pointer type, which is why incognito, a different browser, a clean URL and resetting overrides made no difference.

Change

.sf-btn is excluded from that blanket floor, so buttons honour their --sf-btn-min-height / --sf-size-* ladder everywhere, touch included. The 44px WCAG 2.5.5 AAA target becomes an explicit opt-in.

  • The default control (--sf-size-m, 40px) still clears WCAG 2.2 AA (24px), and .sf-btn--xs sits at exactly the 24px AA minimum.
  • Bare <button>s (no .sf-btn) and native controls (input, select, summary) keep the 44px floor.
  • The exclusion is written button:not([class~="sf-btn"]) rather than :not(.sf-btn) so the class-catalogue tooling (which masks quoted strings) keeps .sf-btn filed under its components layer instead of re-attributing it to accessibility — behaviour and specificity are identical.

Opt back into the 44px AAA touch target

:root { --sf-btn-min-height: var(--sf-touch-target); } /* 44px on every .sf-btn */

or, in the configurator, Min height → touch-target.

Breaking change

On touch devices, un-customised .sf-btn--xs/.sf-btn--s now render at their true (smaller) height instead of being floored to 44px. Migration + opt-in documented in docs/migration.md.

Testing

  • New tests/button-touch.spec.js (emulated coarse pointer, Chromium): size ladder stays visible on touch (xs < s < m < l < xl, xs < 44px, ≥ 24px AA); the --sf-btn-min-height: var(--sf-touch-target) opt-in restores 44px on every rung; a bare <button> keeps the floor.
  • Existing tests/button.spec.js (50 tests) unchanged and green.
  • Verified rendering directly: mouse 24·33·41·51·64, touch 24·40·52·67·87 (px) — scales in both, no 44px flattening.
  • Local gates green: lint:css, check:llm-guide, audit:check, check:version, check:registry, check:macros, check-artifacts.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GnwqYHgHNAWPbsUJWUeY1w


Generated by Claude Code

Summary by CodeRabbit

  • Accessibility

    • On touch devices, styled .sf-btn controls now preserve their size variants instead of being automatically enlarged to 44px.
    • Native buttons and other controls retain the 44px minimum touch target.
    • The 44px minimum can be restored for styled buttons through the documented opt-in setting.
  • Documentation

    • Updated guidance and migration notes to explain touch sizing behavior and configuration options.
  • Tests

    • Added coverage for touch-device button sizing, size variants, opt-in 44px sizing, and native button behavior.

…floor

The blanket `@media (pointer: coarse)` touch-target rule in
core/accessibility.css applied `min-block-size: var(--sf-touch-target)` (44px)
to every <button>, including .sf-btn. On any touch device this overrode the
button's own XS–XL size ladder and pinned every rung to 44px — so the size
scale collapsed to one height on phones/tablets even though it renders
correctly with a mouse (the "all button sizes look identical on mobile" report).

.sf-btn is now excluded from that floor (`button:not([class~="sf-btn"])`; the
attribute form keeps .sf-btn filed under its `components` layer in the class
catalogue instead of being re-attributed to `accessibility`). Buttons honour
their --sf-btn-min-height / --sf-size-* ladder everywhere, touch included. The
default control (--sf-size-m, 40px) still clears WCAG 2.2 AA (24px), and
.sf-btn--xs sits at exactly the 24px AA minimum. Bare <button>s and native
controls keep the 44px floor.

Opt back into the 44px AAA touch target with
`--sf-btn-min-height: var(--sf-touch-target)` (globally or per button).

- core/accessibility.css: exclude .sf-btn from the coarse-pointer floor
- optional/components.css: document the touch behaviour on the size ladder
- docs/migration.md: breaking-change entry + opt-in path
- docs/llm-guide.md: note touch behaviour on --sf-btn-min-height
- tests/button-touch.spec.js: coarse-pointer regression guard

BREAKING CHANGE: on touch devices, un-customised .sf-btn--xs/--s now render at
their true (smaller) height instead of being floored to 44px. Restore the old
behaviour with `--sf-btn-min-height: var(--sf-touch-target)`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnwqYHgHNAWPbsUJWUeY1w
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jackgranatowski, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 37 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e7a11c1a-00d0-4719-bc2f-57e9ed9e9958

📥 Commits

Reviewing files that changed from the base of the PR and between 402d1de and da20ac1.

📒 Files selected for processing (5)
  • demos/full-api-demo-with-overrides.html
  • demos/full-api-demo.html
  • demos/ultimate-override.css
  • docs/migration.md
  • tests/button-touch.spec.js
📝 Walkthrough

Walkthrough

The coarse-pointer accessibility rule now excludes .sf-btn, preserving its size ladder on touch devices. Tests cover default and opt-in sizing, while component, token, and migration documentation describe the updated behavior.

Changes

Touch-target behavior

Layer / File(s) Summary
CSS exception and touch validation
core/accessibility.css, optional/components.css, tests/button-touch.spec.js
The coarse-pointer rule excludes .sf-btn; Chromium tests verify ladder sizing, opt-in 44px sizing, and native button behavior.
Touch-sizing documentation
docs/llm-guide.md, docs/migration.md
Documentation describes the .sf-btn exception and methods for restoring the 44px touch-target floor.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested labels: codex

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main breaking change: .sf-btn keeps its size scale on coarse-pointer devices instead of being forced to 44px.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/button-size-visibility-issue-40xlwy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added the codex label Jul 10, 2026
@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes the "all button sizes look identical on mobile" regression by excluding .sf-btn from the blanket @media (pointer: coarse) { min-block-size: var(--sf-touch-target) } rule in core/accessibility.css, letting the XS–XL size ladder render correctly on touch devices instead of collapsing every rung to 44px.

  • CSS core change (core/accessibility.css): button, becomes button:not([class~="sf-btn"]), — an attribute-selector form deliberately chosen so the class-catalogue tooling keeps .sf-btn filed under the components layer; bare <button>s and native controls retain the 44px floor.
  • Breaking change + migration path (docs/migration.md): documents that un-customised .sf-btn--xs/--s now render at their true smaller heights on touch, and provides the :root { --sf-btn-min-height: var(--sf-touch-target); } opt-in to restore the 44px AAA floor.
  • New regression test (tests/button-touch.spec.js): Chromium-only Playwright spec verifying the ladder stays visible under a coarse pointer, the opt-in restores 44px, and a bare <button> keeps the floor.

Confidence Score: 4/5

The CSS change is a single, well-scoped selector tweak with no logic outside of the @media (pointer: coarse) block; the rest of the accessibility rules are untouched.

The core change is minimal and correct — the attribute-selector exclusion is behaviorally identical to :not(.sf-btn) and all surrounding rules are unchanged. The migration guide has a small selector mismatch and the new tests don't guard ctx.close() in finally blocks, but neither affects runtime behaviour.

docs/migration.md (selector shown differs from CSS), tests/button-touch.spec.js (missing try/finally around ctx.close())

Important Files Changed

Filename Overview
core/accessibility.css Excludes .sf-btn from the blanket coarse-pointer 44px min-block-size floor using button:not([class~="sf-btn"]). Change is correct, specificity-neutral, and extensively commented; bare <button>s and native controls retain the floor.
docs/migration.md Documents the breaking touch-target change with opt-in instructions. The documented selector (button:not(.sf-btn)) diverges from the actual CSS (button:not([class~="sf-btn"])), which could confuse users inspecting DevTools.
tests/button-touch.spec.js New Chromium-only Playwright spec covering size-ladder visibility, opt-in 44px floor, and bare-button floor preservation. Browser contexts aren't closed in finally blocks, risking resource leaks on assertion failure.
docs/llm-guide.md One-line token comment updated to note that --sf-btn-min-height is honoured on touch and how to opt back into 44px. Change is accurate and consistent with the CSS.
optional/components.css Comment block on .sf-btn--xs extended to document that the size ladder is preserved on touch devices. No code logic changed.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["@media (pointer: coarse)"] --> B{Is it a button?}
    B -->|"button:not([class~='sf-btn'])\nbare buttons"| C["Apply 44px min-block-size\n(WCAG AAA floor)"]
    B -->|"button.sf-btn\n(any size modifier)"| D["Honour --sf-btn-min-height\n(size ladder: xs=24px to xl=56px)"]
    D --> E{Opt-in requested?}
    E -->|"--sf-btn-min-height: var(--sf-touch-target)"| F["44px floor restored"]
    E -->|"No override"| G["True design-scale height >= 24px WCAG AA"]
    B -->|"input, select, summary"| C
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["@media (pointer: coarse)"] --> B{Is it a button?}
    B -->|"button:not([class~='sf-btn'])\nbare buttons"| C["Apply 44px min-block-size\n(WCAG AAA floor)"]
    B -->|"button.sf-btn\n(any size modifier)"| D["Honour --sf-btn-min-height\n(size ladder: xs=24px to xl=56px)"]
    D --> E{Opt-in requested?}
    E -->|"--sf-btn-min-height: var(--sf-touch-target)"| F["44px floor restored"]
    E -->|"No override"| G["True design-scale height >= 24px WCAG AA"]
    B -->|"input, select, summary"| C
Loading

Reviews (1): Last reviewed commit: "feat(a11y)!: let .sf-btn size scale win ..." | Re-trigger Greptile

Comment thread docs/migration.md Outdated
size ladder and pinned every rung to 44px — so the whole size scale collapsed to
one height on phones and tablets, even though it renders correctly with a mouse.

`.sf-btn` is now **excluded** from that blanket floor (`button:not(.sf-btn)`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Migration doc selector doesn't match the actual CSS

The migration guide documents the exclusion as button:not(.sf-btn), but the live CSS in core/accessibility.css uses button:not([class~="sf-btn"]). While these are semantically identical, a developer following the migration guide and then inspecting DevTools would see the attribute-selector form and might wonder if they're looking at a different rule. The PR description and the CSS comment both explain the attribute-selector choice; the migration doc is the only place that shows the :not(.sf-btn) shorthand without that context.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +53 to +82

test('size ladder stays visible on touch (xs < s < m < l < xl), not flattened to 44px', async ({ browser }) => {
const { ctx, page } = await renderTouch(browser);
const h = await heights(page, ['xs', 's', 'm', 'l', 'xl']);
for (let i = 1; i < h.length; i++) {
expect(h[i], `#${i} taller than #${i - 1}`).toBeGreaterThan(h[i - 1]);
}
// xs sits below the 44px AAA floor — proof .sf-btn is exempt from the
// blanket coarse-pointer rule — while still clearing the WCAG 2.2 AA 24px.
expect(h[0]).toBeLessThan(44);
expect(h[0]).toBeGreaterThanOrEqual(24);
await ctx.close();
});

test('opt-in: --sf-btn-min-height: var(--sf-touch-target) restores the 44px floor on every size', async ({ browser }) => {
const { ctx, page } = await renderTouch(browser, {
extraCss: ':root{--sf-btn-min-height:var(--sf-touch-target)}',
});
const h = await heights(page, ['xs', 's', 'm', 'l', 'xl']);
for (const v of h) expect(v).toBeGreaterThanOrEqual(44);
await ctx.close();
});

test('a bare <button> (no .sf-btn) keeps the 44px floor on touch', async ({ browser }) => {
const { ctx, page } = await renderTouch(browser, { html: `<button id="b">x</button>` });
const h = await heights(page, ['b']);
expect(h[0]).toBeGreaterThanOrEqual(44);
await ctx.close();
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Browser context not closed on assertion failure

Each test creates a browser context via renderTouch(browser) and calls ctx.close() at the very end, but there is no try/finally guard. If any expect() assertion throws (which is the normal Playwright failure path), ctx.close() is skipped and the context leaks until the browser process is torn down. This doesn't affect correctness on a happy path, but it can cause dangling contexts during a failing test run and makes it harder to read resource-usage output in CI. Wrapping the body in try/finally { await ctx.close(); } is the idiomatic fix.

- demos/*: regenerate full-api demos so the version stamp matches v0.7.12.
  Inherited drift — the release bump doesn't run demos/generate.mjs, so the
  check-artifacts CI gate flagged the stale 0.7.11 stamp. Diff is the stamp only.
- docs/migration.md: show the real `button:not([class~="sf-btn"])` selector
  (was the `:not(.sf-btn)` shorthand) with a one-line note on the attribute
  form, so the migration guide matches what DevTools shows. (greptile P2)
- tests/button-touch.spec.js: wrap each touch context in a withTouch() helper
  with try/finally so a failing assertion never leaks the browser context.
  (greptile P2)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnwqYHgHNAWPbsUJWUeY1w
@jackgranatowski
jackgranatowski merged commit bee02da into main Jul 10, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants