Skip to content

Design accessibility feedback - #141

Merged
graeme merged 7 commits into
mainfrom
design-accessibility-feedback
Sep 2, 2026
Merged

Design accessibility feedback#141
graeme merged 7 commits into
mainfrom
design-accessibility-feedback

Conversation

@graeme

@graeme graeme commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

PR: Accessible colour palette via a high-contrast variant

Summary

Feedback reported low text contrast in at least eight colour combinations. Rather than darken the default palette and lose the Homebrew identity, this adds High Contrast variants to the colour sets, which macOS swaps in when Accessibility > Display > Increase contrast is on. Conformance and brand identity no longer have to be the same colours.

Changes

Palette

  • Every token that needs one gains a high-contrast value. The standard palette is untouched, and is byte-identical to before for all 31 pre-existing colour sets.
  • Five new tokens, each taking over the value it replaces so nothing moves in standard mode: text.brand (amber as a foreground, since brand.primary is a fill), status.warningBold (vivid yellow for glyphs and fills, leaving status.warning as the text colour), text.onWarning, text.magenta, text.cyan.

Views

  • Console maps ANSI magenta and cyan to tokens instead of SwiftUI .purple and .cyan, which fall under 2:1 on the app surface the console actually renders on.
  • Package kind icon chip becomes an outline rather than a pale tint, centred in the row, and hidden while a placeholder is on screen.
  • Terminal command block moves off the terminal surface: light on dark text under the system selection highlight measured 1.01:1, and SwiftUI cannot restyle that highlight.
  • Installed packages gain an OUTDATED pill after the kind pill, with the installed check moved to the same slot.

Tests

  • BrewColorTokenContrastTests asserts WCAG AA in the high-contrast appearances, plus an invariant that high contrast can never render a pairing worse than standard.
  • WarningGlyphTests pins the SF Symbols palette layer order by rendering offscreen and sampling pixels.

Why this split

The palette lands before the views consuming it, so every commit builds and tests independently.

Testing

  • scripts/test passes at every commit, not just the tip
  • SwiftFormat, SwiftLint --strict and BrewUILint clean
  • xcodebuild -scheme Brew succeeds
  • assetutil confirms all 36 colour sets carry accessibility variants
  • Manual pass with Increase contrast on and off, light and dark

PR checklist

  • Have you followed this repository's contribution and workflow guidance?
  • Have you explained what changed and why this should land now?
  • Have you run relevant local checks for the changed scope?
  • Are changes scoped and free of unrelated modifications?

  • AI was used to generate or assist with generating this PR.
  • If yes, describe exactly how AI was used and what manual verification was performed.

Claude Code derived every colour value by calculation against WCAG 2.1 rather than by eye, and wrote the audit enforcing them, without ever launching the app. Visual review and every trade-off decision were mine.

Follow-ups

  • Borders sit at 1.1 to 1.7:1 in both palettes, outside the audit. High contrast is the natural place to raise them.
  • An in-app toggle is possible via NSApp.appearance if the system setting proves insufficient.
  • The pill treatment is now inlined in five places and could be extracted.

@graeme
graeme force-pushed the design-accessibility-feedback branch from e34a959 to a19f19f Compare September 2, 2026 10:38
@graeme
graeme marked this pull request as ready for review September 2, 2026 11:58
graeme and others added 7 commits September 3, 2026 07:59
Colour sets can carry a High Contrast value per appearance, which macOS uses
when Accessibility → Display → Increase contrast is on. That splits two things
the palette could not previously satisfy at once: the Homebrew identity, and
WCAG AA.

The standard palette is unchanged — byte-identical to before for all 31
existing colour sets. Each token that needs it gains a high-contrast value:

                    standard              high contrast
  text.secondary    #5C5C58 / #B0B0B0     #4A4A46 / #C7C7C7
  text.tertiary     #9C9C96 / #6B6B6B     #64645E / #A9A9A9
  text.link         #5B9BD5 / #7AB3E0     #2D71AF / -
  text.brand        #E8971C / #E8971C     #9A6310 / -
  text.magenta      #CB30E0 / #DB34F2     #BA1FCF / #E25AF4
  text.cyan         #00C0E8 / #3CD3FE     #007A93 / -
  status.success    #3CB371 / #52C98A     #2A7D4F / -
  status.warning    #F0AD4E / #F5C26B     #9D610D / -
  status.error      #D9534F / #E87370     #CB302C / #ED908E
  status.info       #5B9BD5 / #7AB3E0     #2D71AF / -

Five tokens are new, each carrying the value it takes over from so nothing
moves: text.brand is the brand amber that foreground call sites read out of
brand.primary; status.warningBold is the current status.warning, kept vivid for
icons and fills; text.onWarning is the upgrades badge knockout; text.magenta
and text.cyan are the system purple and cyan the ANSI map fell through to.

AppKit's rule for relaxing a *missing* high-contrast variant is not observable
unless the system setting is on — a runtime probe just returns standard values
and looks like confirmation. So every colour set that varies by luminosity
states its high-contrast dark value outright rather than relying on fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgwyUSxEn786mZUfZx8bCv
Asserts the palette by calculation rather than by eye: WCAG 2.1 relative
luminance and contrast ratio over the colour sets as they ship, resolved for
all four appearances.

AA is required in the high-contrast appearances only. The standard palette is
instead held to a monotonicity invariant — high contrast may never render a
pairing worse than standard — which is what stops the accessible palette
drifting behind the default one.

Pairings are hand-maintained rather than a cross-product; requiring success
green to stay legible on the error tint would cost chroma for a combination no
screen renders.

The catalogue is parsed from source rather than resolved through NSColor:
SwiftPM copies .xcassets into the test bundle uncompiled, so a runtime lookup
returns nil under `swift test`. Resolution is exact-match-or-universal, with a
test enforcing that no colour set can declare a dark value without a
high-contrast dark one.

WarningGlyphTests pins an SF Symbols detail Apple owns rather than we do — that
palette layer 1 is the inner mark — by rendering offscreen and sampling pixels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgwyUSxEn786mZUfZx8bCv
Both colours were doing two jobs at once, which is why neither could be tuned
without breaking the other.

Brand amber was only ever used as a foreground — the sidebar's selected item,
the console status dot, badge labels — while brand.primary is defined as a fill.
Those call sites move to text.brand, leaving brand.primary free to stay the
undiluted Homebrew amber for filled surfaces.

Warning splits the same way: status.warning stays the text colour and darkens
under high contrast, status.warningBold stays vivid for glyphs, dots and fills.
Doctor needed both, so severity styling grows a glyph alongside its foreground.

brewWarningGlyphStyle() renders those glyphs monochrome normally and knocks the
inner mark out in near-black under high contrast, where the enclosure alone is
1.8:1 on a light surface.

The console maps ANSI magenta and cyan to tokens instead of SwiftUI's .purple
and .cyan, which drop under 2:1 on white — output renders on an app surface,
not the terminal surface.

The upgrades badge keeps its white count on yellow and only goes black under
high contrast, so it no longer needs to branch on colour scheme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgwyUSxEn786mZUfZx8bCv
The chip was a pale tint behind an accent glyph, which barely separated from
the row and disappeared entirely once the row was selected and the background
became the brand tint.

It is now an outline: the accent draws both the glyph and a 1pt ring, with the
row showing through. As a non-text graphic the bar is 3:1, which it clears on
every row background including a selected one; the pale tint never did.
PackageKindIconBackgroundToken goes away with the fill it named.

The chip is also centred against the row rather than pinned to the first line,
and hidden while a placeholder is on screen — SwiftUI redacts text and images
but leaves Shape alone, so the ring stayed crisp and coloured around redacted
content.

Package views pick up the foreground roles from the previous commit at the same
time: text.brand for amber, status.warningBold for the upgrade-available glyph
and the dependents dots, and black on yellow for the uninstall-blocked badge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgwyUSxEn786mZUfZx8bCv
The command text sat light-on-dark on the terminal surface, which put it under
a system selection highlight drawn for dark-on-light: selecting a command
measured 1.01:1. SwiftUI offers no hook to restyle that highlight, so the
surface changes instead.

The code well now uses the sidebar's surface, with the header and footer
recessed around it — so the card reads as chrome wrapped around an inset well,
and the well matches a surface the eye already knows from the sidebar. Command
text measures 16.7:1 in light and 13.9:1 in dark.

The footer keeps the raised contrast it needs as the line explaining what the
command does, moving from tertiary to secondary text along with the step
numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgwyUSxEn786mZUfZx8bCv
Captures what the diff cannot: why there are two palettes and which one is held
to AA, why the audit parses the catalogue instead of resolving NSColor, why the
warning yellow is deliberately below the non-text guideline, and the probe that
looks like it confirms high-contrast fallback but confirms nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgwyUSxEn786mZUfZx8bCv
Installed rows marked an available upgrade with a bare warning glyph sitting
between the package name and its kind pill. That put a status indicator ahead
of the label it qualifies, and gave the same information a different shape from
Discover's INSTALLED pill.

Outdated is now a pill of its own, outlined like the kind pill beside it and
placed after it. The green installed check moves to the same slot so both
states read from the same position.

Neutral grey rather than warning-coloured: an available upgrade is routine, and
the version line underneath already carries the amber.

Applied to the list row and the detail hero, which shared the glyph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgwyUSxEn786mZUfZx8bCv
@graeme
graeme force-pushed the design-accessibility-feedback branch from a19f19f to 7e2c9a9 Compare September 2, 2026 22:00
@graeme
graeme merged commit 271be0b into main Sep 2, 2026
10 checks passed
@graeme
graeme deleted the design-accessibility-feedback branch September 2, 2026 22:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants