Skip to content

fix: restore contrast correction for themes and CLI output, add high-contrast mode#362

Merged
parsakhaz merged 1 commit into
mainfrom
theme-accessibility
Jul 23, 2026
Merged

fix: restore contrast correction for themes and CLI output, add high-contrast mode#362
parsakhaz merged 1 commit into
mainfrom
theme-accessibility

Conversation

@parsakhaz

Copy link
Copy Markdown
Member

Why

Text has been hard to read across Pane — most visibly in the Claude Code and Codex panels — since the theme system changed. Two independent root causes, both verified:

1. Terminal contrast correction was off. 0984a21 replaced filter: invert(1) hue-rotate(180deg) on .light .xterm with a baked light palette plus minimumContrastRatio: 4.5. The old filter operated on rendered pixels, so it flipped everything the CLIs painted — including 24-bit truecolor, which bypasses the 16-slot ANSI palette entirely. The replacement only covers the 16 slots, and only in light mode: all 10 dark themes were left at minimumContrastRatio: 1, which disables xterm's correction completely.

2. The muted text tokens never met AA. The theme-expansion PRs (ed850b4 dusk/forge → 937209e ember/aurora → 0ff14c3 night-owl/terracotta) each added backgrounds and palettes without a contrast gate. tests/axeTest.ts can't catch this — axe cannot see canvas-rendered terminal text, and these tokens were never audited.

Measured before this PR, --color-text-muted family vs --color-bg-primary:

theme ratio
forge 2.90
night-owl 3.05
night-owl-oled 3.31
dusk 3.67
terracotta 3.95
dusk-oled 4.34
dark 4.47

What changed

Baseline (always on). The four muted tokens now clear 4.5:1 in all 12 themes — 4.70 to 5.57. Hue preserved, lightness only. Applied at the semantic-token level rather than the palette primitives, because --owl-400 / --forge-gray7 / --dusk-500 / --terra-400 also feed --color-terminal-bright-black; changing them would have silently shifted ANSI output.

High contrast toggle (Appearance settings, off by default). A .high-contrast class pushes the same tokens to ~7:1 (measured 7.01–7.08 across all 12 themes) and raises the terminal ratio.

Terminal minimumContrastRatio:

dark themes light themes
default 3 (was 1 — no correction) 4.5 (unchanged)
high contrast 9 9

9 is deliberate, not a typo. Both renderers halve the configured ratio for dim (SGR 2) cells — TextureAtlas.ts:409 and DomRendererRowFactory.ts:496. Claude Code and Codex use dim heavily for hints, spinners, and secondary text, which is the bulk of the unreadable output. 9 is what lands dim cells at 4.5 AA.

The off-state floor is deliberately 3 and not 4.5: the muted grays in forge/ember/aurora/night-owl are doing design work, and forcing AA on every ANSI color flattens them. 3 only bites the pairs that were genuinely broken.

Also fixed: frontend/index.html's pre-React bootstrap had drifted — it omitted night-owl, night-owl-oled, and terracotta, so those three fell back to light-rounded and flashed light on every launch. Now synced with THEME_CLASSES.

Remote PWA terminal gets a static minimumContrastRatio: 4.5. It's browser-served with no AppConfig transport, so it's intentionally not wired to the setting.

Deliberate non-goals

  • --color-text-disabled is untouched and still fails contrast in all 12 themes. WCAG 1.4.3 exempts disabled controls; raising it destroys the disabled affordance.
  • Ratios are anchored to --color-bg-primary. Muted text on elevated surfaces still lands below AA in places (dusk cards 3.90, forge tertiary 3.82, dark secondary 4.36). Real, but a separate scope call — noted below as follow-up.
  • CLI theme plumbing. Pane never tells Claude Code or Codex which theme it's running, so their built-in themes pick colors blind. minimumContrastRatio now covers the symptom; matching the themes properly is separate work.

Review

Two independent review lanes (Claude + Codex), both of which re-derived all 24 contrast ratios from scratch rather than trusting the implementation. Both confirmed the math, the cascade specificity across all 12 themes including the OLED children and light-rounded, and that no palette primitive was modified.

Two Must-Fix findings, both resolved in this branch:

  • Codex: the index.html bootstrap omitted three themes.
  • Claude: getMinimumContrastRatio() read documentElement.classList, but that class is stamped by ThemeProvider's effect — an ancestor. React flushes passive effects child-first, so TerminalPanel observed the previous value and the terminal was one toggle behind. It now takes highContrast as an argument.

pnpm typecheck clean. pnpm lint 0 errors (163 main / 165 frontend warnings, all pre-existing).

Manual testing

  • Toggle high contrast with a Claude panel open — CLI output should change immediately, no restart
  • Cycle all 12 themes with high contrast on and off
  • Launch on night-owl / terracotta and confirm no light flash
  • Confirm dark-theme ANSI output still looks right (non-dim colors are nudged now too, going 1 → 3)

Follow-up

  • Muted text on elevated surfaces (cards, modals, secondary backgrounds)
  • Push a matching theme into the Claude Code and Codex CLIs
  • The bootstrap logic is triplicated (head script / body script / ThemeContext), which is why it drifted — worth collapsing

…contrast mode

Terminal text and app chrome have been hard to read since the theme system
changed. Two independent causes:

1. xterm's minimumContrastRatio was 1 (correction fully disabled) in every
   dark theme, and only 4.5 in light. Removing the old
   `filter: invert(1) hue-rotate(180deg)` on `.light .xterm` took away the
   structural guarantee that Claude Code / Codex truecolor output stayed
   readable, and nothing replaced it for the 10 dark themes.
2. The muted text tokens fail WCAG AA against their own background in 7 of
   12 themes (forge 2.90, night-owl 3.05, dusk 3.67, terracotta 3.95, ...).
   The theme-expansion PRs shipped without a contrast gate.

Changes:

- Raise --color-text-muted / -navigation-muted / -navigation-section /
  -interactive-muted to >=4.5:1 in dark, dusk, dusk-oled, forge, night-owl,
  night-owl-oled and terracotta. Hue preserved; lightness only. Applied at
  the semantic-token level, not the palette primitives, since --owl-400 and
  friends also feed --color-terminal-bright-black.
- Add an opt-in "High contrast" toggle (Appearance settings) that pushes the
  same tokens to ~7:1 via a .high-contrast class.
- Terminal minimumContrastRatio: 3 dark / 4.5 light by default, 9 when high
  contrast is on. 9 is deliberate — both xterm renderers halve the ratio for
  dim (SGR 2) cells, which is most of Claude Code's and Codex's secondary
  text, so 9 is what lands dim output at 4.5 AA.
- Sync the pre-React bootstrap in index.html with THEME_CLASSES; it had
  drifted and was falling back to light-rounded for night-owl,
  night-owl-oled and terracotta, causing a light flash on every launch.
- Give the remote PWA terminal a static minimumContrastRatio of 4.5. It has
  no AppConfig transport, so it is intentionally not wired to the setting.

--color-text-disabled is deliberately left as-is; WCAG 1.4.3 exempts
disabled controls and raising it destroys the disabled affordance.
@parsakhaz
parsakhaz merged commit 4f3a41f into main Jul 23, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4f3a41f0ce

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

scrollback: 10_000,
// Static AA floor: the remote PWA is browser-served with no AppConfig transport,
// so the desktop high-contrast setting cannot be plumbed here.
minimumContrastRatio: 4.5,

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 Badge Raise the remote threshold for dim text

When Remote Pane renders SGR 2 (dim) output from Claude Code or Codex, xterm halves this configured contrast threshold—the same behavior documented in TerminalPanel.tsx when choosing a value of 9—so this value only enforces approximately 2.25:1 rather than the claimed AA floor. Since the remote terminal has no toggle and always uses this static value, set it to 9 as well if dim CLI output is meant to remain legible.

Useful? React with 👍 / 👎.


/* Night Owl */
:root.high-contrast.night-owl {
--color-text-muted: #b69496; /* 7.1:1 on --owl-900 */

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 Badge Calibrate high-contrast tokens against their actual surfaces

When Night Owl high-contrast mode is enabled, this color reaches 7:1 only against --owl-900, but --color-text-muted is also rendered on lighter surfaces—for example the command input placeholder in PanelTabBar.tsx uses it on --color-surface-secondary (#221618). Against that surface #b69496 is only about 6.4:1, so the newly advertised AAA mode does not provide AAA contrast in that user-visible context; the overrides need to account for the lightest backgrounds on which each semantic token is used.

Useful? React with 👍 / 👎.

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.

1 participant