Skip to content

fix: put a floor under every button's clickable size — the kill button was 15px wide - #2356

Merged
laurentiu021 merged 2 commits into
mainfrom
fix/hit-target-floor
Sep 18, 2026
Merged

laurentiu021 merged 2 commits into
mainfrom
fix/hit-target-floor

Conversation

@laurentiu021

Copy link
Copy Markdown
Owner

Problem

ButtonBase set no MinWidth or MinHeight, so a button was exactly its padding plus its content. 26 row-action buttons cut their padding to 4,2 or 6,1 with FontSize 10–12 to fit inside a DataGrid row.

Measured on the STA thread with the app's real dictionaries loaded, rather than computed:

Style Padding FontSize Content Measured
PrimaryButton 4,2 12 X 15.4 × 20.0
DangerButton 4,2 12 X 15.4 × 20.0
GhostButton 6,1 10 ? 16.4 × 15.3
SecondaryButton 4,2 12 X 17.4 × 22.0
AdminButton 4,2 12 X 47.5 × 23.3

WCAG 2.5.8 AA asks for 24 × 24. The worst of these is the X that terminates a running process — the smallest target and the highest consequence in the app, one glyph away from the button beside it.

Two things the issue's estimate got wrong, and both matter: it said "roughly 20×20", but the width is the worse failure (15.4), and AdminButton fails on height while looking comfortably large. And the count has grown from the 10 it listed to 26, across three more views — which is the argument for fixing the shared style rather than the call sites.

What changed

One setter pair on ButtonBase, inherited by all five derived styles.

28 rather than 24, for two reasons. A floor set exactly at the threshold leaves nothing for a fractional DPI scale. And 28 × 28 is already this app's size for a compact interactive chip — the Appearance and Help chips in the sidebar footer are both 28 × 28 — so this makes the row actions consistent with a decision already taken rather than introducing a second number.

The template's Border honours Padding through TemplateBinding, so the box grows without the text moving.

Verification

12 integration tests, and they measure rather than reading the setters. A test asserting MinWidth would pass while a derived style, a template, or a call-site Padding made the rendered box smaller — and the rendered box is what a user has to hit.

The floor they assert is the WCAG 24, deliberately below the 28 the style sets. The guard exists to catch a regression under the accessible minimum, not to pin the value: pinning 28 would fail a deliberate rise to 32 that improves the same property.

A companion test asserts the ordinary buttons are still sized by their content. A minimum size is only free while it does not reshape the buttons that were already large enough, and "already large enough" was measured at 60 × 33 and up.

Red ritual — four cases:

Mutation Result
the floor removed entirely 8 red — the original defect
only the height floored 6 red, not 8
floored at exactly 24 green, and that is correct
floored at 120 × 48 4 red on the ordinary-button test

The second row is the interesting one. I predicted 8 and the tests said 6: SecondaryButton with an arrow at 6,2 measured 24.4 wide and AdminButton 47.5, so those two only ever needed the height. The other six are what a height-only fix would have left broken — which is exactly the half the issue's own estimate missed.

The third row deserves a note too: a green there is the intended behaviour, not a gap. A guard that failed on 24 would be pinning my choice of 28 rather than the accessibility property.

  • Unit suite: 5815 passed, 0 failed.
  • All four projects: 0 errors, 0 warnings.
  • dotnet format --verify-no-changes: clean.
  • Leak scan: 43 patterns over 6 changed files; only the long-standing false positive from a Windows privacy-toggle name, not in this diff.

Known cost, stated rather than discovered

Rows in Process Manager, Disk Analyzer, Duplicate Finder, Environment Variables, Services and System Logs get slightly taller. That is the trade — the buttons that grew are the ones that were too small to aim at — and it wants an eyeball pass on a machine that runs the application, which this one does not. The companion test is what bounds it: if a future floor grew into the normal buttons, it fails rather than being noticed on screen.

Docs

README's accessibility section states the 28px floor, what was measured, and why 28 rather than 24. ARCHITECTURE documents the setter, both tests, and why the guard's floor is deliberately lower than the style's.

Closes #1556

…n was 15px wide

ButtonBase set no MinWidth or MinHeight, so a button was exactly its padding plus
its content. 26 row-action buttons cut their padding to 4,2 or 6,1 with FontSize
10-12 to fit inside a DataGrid row, and measured on the STA thread rather than
computed:

  PrimaryButton   "X"  pad 4,2  fs 12  ->  15.4 x 20.0
  DangerButton    "X"  pad 4,2  fs 12  ->  15.4 x 20.0
  GhostButton     "?"  pad 6,1  fs 10  ->  16.4 x 15.3
  SecondaryButton "X"  pad 4,2  fs 12  ->  17.4 x 22.0
  AdminButton     "X"  pad 4,2  fs 12  ->  47.5 x 23.3

WCAG 2.5.8 AA asks for 24 x 24. The worst of these is the X that TERMINATES A
RUNNING PROCESS, which is the smallest target and the highest consequence in the
app, one glyph away from the button beside it. #1556 estimated "roughly 20x20";
the width is worse than that, and AdminButton fails on height while looking
comfortably large.

One setter pair on the shared style, inherited by all five derived styles. 28 rather
than 24 for two reasons: a floor at exactly the threshold leaves nothing for a
fractional DPI scale, and 28 x 28 is already this app's size for a compact
interactive chip — the Appearance and Help chips in the sidebar footer are both 28 x
28 — so this makes the row actions consistent with a decision already taken rather
than introducing a second number. The template's Border honours Padding through
TemplateBinding, so the box grows without the text moving.

The count has grown since the issue was filed: 26 small-padding buttons now against
the 10 it listed, across three more views. That is the argument for fixing the shared
style rather than the call sites.

12 integration tests, and they MEASURE rather than reading the setters. A test
asserting MinWidth would pass while a derived style, a template or a call-site
Padding made the rendered box smaller — and the rendered box is what a user has to
hit. The floor they assert is the WCAG 24, deliberately below the 28 the style sets,
because the guard exists to catch a regression under the accessible minimum and not
to pin the exact value: pinning 28 would fail a deliberate rise to 32.

A companion test asserts the ordinary buttons are still sized by their content. A
minimum size is only free while it does not reshape the buttons that were already
large enough, and "already large enough" was measured at 60 x 33 and up.

Red ritual, four cases:

  - the floor removed entirely: 8 red, the original defect;
  - only the height floored: 6 red. Not 8 — SecondaryButton with an arrow at 6,2
    measured 24.4 wide and AdminButton 47.5, so those two only ever needed the
    height. The other six are what a height-only fix would have left broken, and
    the width was the half the issue's own estimate missed;
  - floored at exactly 24: GREEN, which is correct — the guard catches a regression
    below the minimum, not a value change;
  - floored at 120 x 48: 4 red, on the ordinary-button test, which is the cost side
    of this change being watched rather than assumed.

Known cost, stated: rows in Process Manager, Disk Analyzer, Duplicate Finder,
Environment Variables, Services and System Logs get slightly taller. That is the
trade — the buttons that grew are the ones that were too small to aim at — and it
wants an eyeball pass on a machine that runs the application, which this one does not.

Unit 5815 passed, 0 failed. All four projects 0 warnings. dotnet format clean.

Closes #1556
…r the clear button

Two problems with the previous commit, both found by measuring properly instead of
grepping.

The counts were wrong. "26 row-action buttons" came from a LINE-based grep for a
tight Padding on a line containing "Button", which both under-counts (a Padding on a
continuation line) and over-counts (a non-Button on the same line). Measured instead:
every one of the 110 buttons in the views that names an explicit Padding, rendered on
the STA thread with its own style, padding, font size and label. 30 fall under 24 x 24,
across 16 files — 26 on height alone, 4 on both axes, NONE on width alone.

That last figure inverts a claim I made. I said the width was the worse half, and for
the synthetic shapes in the guard it is — a Content="X" at Padding="4,2" measures 15.4
wide. But no REAL call site fails on width alone, because the real labels are longer
than one glyph. The guard keeps its synthetic shapes, which is right: they are the
shapes a future call site could take. The docs now describe the real ones.

And the "X that kills a process" is no longer a button at all. Kill moved to the row
context menu as a DangerMenuItem, so Process Manager's contribution to the list is an
"Open" at 39.7 x 22.0 — a height failure. The real worst pair is DNS & Hosts'
remove-entry X at 20.2 x 19.3, which is the smallest DESTRUCTIVE target in the app and
a better example than the one I reached for, and the sidebar's clear-search glyph at
49.1 x 15.3.

The second problem is a regression I would have shipped blind. That clear-search glyph
is Width="20" Height="20", overlaid inside a TextBox that reserved exactly 22px of
right padding for it. WPF resolves a size as Max(MinWidth, Min(MaxWidth, Width)), so
the floor clamps it UP to 28 and it would have sat on top of the text. The reserved
padding is 32 now, and the explicit 20x20 is gone rather than left in the markup
lying about what renders.

I asserted the Max(MinWidth, Width) resolution rather than assuming it, because the
intuitive belief is the opposite one and this button depends on the answer. It is the
only button in the app with an explicit size under 28 — checked, not assumed.

Unit 5815 passed, 0 failed. Integration 13 in HitTargetSizeTests, 0 failed. All four
projects 0 warnings.

Refs #1556
@laurentiu021

Copy link
Copy Markdown
Owner Author

Update after measuring properly — the counts in the first commit were wrong

I grepped where I should have measured, and the second commit fixes both consequences.

The counts. "26 row-action buttons" came from a line-based grep for a tight Padding on a line containing "Button", which under-counts (a Padding on a continuation line) and over-counts (a non-Button on the same line). Measured instead: all 110 buttons in the views that name an explicit Padding, each rendered on the STA thread with its own style, padding, font size and label.

buttons with an explicit padding 110
under 24 × 24 30, across 16 files
fail on height only 26
fail on both axes 4
fail on width only 0

That last row inverts a claim I made. I said the width was the worse half. For the synthetic shapes in the guard it is — a Content="X" at Padding="4,2" measures 15.4 wide. But no real call site fails on width alone, because real labels are longer than one glyph. The guard keeps its synthetic shapes deliberately, since they are the shapes a future call site could take; the docs now describe the real ones.

And the "X that kills a process" is no longer a button. Kill moved to the row context menu as a DangerMenuItem, so Process Manager's entry in the list is an Open at 39.7 × 22.0 — a height failure. The real worst pair is:

  • DNS & Hosts' remove-entry X at 20.2 × 19.3 — the smallest destructive target in the app, and a better example than the one I reached for.
  • the sidebar's clear-search glyph at 49.1 × 15.3.

A regression I would have shipped blind

That clear-search glyph is Width="20" Height="20", overlaid inside a TextBox that reserved exactly 22px of right padding for it. WPF resolves a size as Max(MinWidth, Min(MaxWidth, Width)), so the floor clamps it up to 28 and it would have sat on top of the text.

The reserved padding is 32 now, and the explicit 20x20 is gone rather than left in the markup lying about what renders. I asserted the Max(MinWidth, Width) resolution rather than assuming it — the intuitive belief is the opposite one, and this button depends on the answer. It is also the only button in the app with an explicit size under 28, checked rather than assumed.

  • Unit suite: 5815 passed, 0 failed.
  • HitTargetSizeTests: 13 passed (12 before, plus the size-resolution test).
  • All four projects: 0 errors, 0 warnings.

The four ritual cases from the original description still hold; the tables above replace the "26 across 7 views" figures in it.

@laurentiu021
laurentiu021 merged commit 89dcd20 into main Sep 18, 2026
6 checks passed
@laurentiu021
laurentiu021 deleted the fix/hit-target-floor branch September 18, 2026 08:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant