Skip to content

fix(frontend): meet WCAG AA contrast and show keyboard focus - #769

Merged
cristim merged 2 commits into
mainfrom
fix/ux-contrast-focus
Oct 9, 2026
Merged

cristim merged 2 commits into
mainfrom
fix/ux-contrast-focus

Conversation

@cristim

@cristim cristim commented Oct 9, 2026

Copy link
Copy Markdown
Member

What

Contrast tokens to WCAG AA and visible keyboard focus (#696, #750). Independent of the other open PRs (branched from main e5ac75d); CSS plus one e2e spec.

Contrast (base.css, components.css):

  • --cudly-success #34a853 -> #15803d (savings text 3.05 -> 5.0:1; white text on it 5.0:1); button hover greens follow.
  • --cudly-error #ea4335 -> #d93025 (white on it 3.92 -> 4.77:1); hard-coded #d33426 hover now uses --cudly-error-fg.
  • --cudly-text-muted #6b7280 -> #4b5563, --cudly-text-subtle #9ca3af -> #6b7280 (4.8:1); .text-muted uses the muted token instead of subtle (2.5 -> 7.5:1; the muted badge text 3.9 -> 6.1:1).
  • --cudly-warn-fg #e65100 -> #b45309 (3.46 -> 4.58:1 on the warn background); GCP badge #4285f4 -> #1a73e8 (3.56 -> 4.51:1); sort and column-filter icons #bbb -> subtle token (>3:1).

Focus (base.css, forms.css, plans.css, components.css):

  • New --cudly-focus-ring (surface gap + primary ring) and a zero-specificity default :where(a[href],button,input,select,textarea,summary,[tabindex]):focus-visible outline, so components with their own focus style keep it.
  • Chips, chip selects, filter inputs and the recommendation chevron use the ring instead of the #eff6ff halo (1.07:1) or no indicator.
  • .ramp-option input[type=radio] was display:none (both forms.css and the duplicate in plans.css): now visually hidden, so it is in the tab order, and the card shows the ring via :has(input:focus-visible).

Not changed: charts' JS colour literals, dark mode/theming (#699), the duplicated ramp block (#725).

Refs #696 (VIS-03), #750 (UX-26).

Verification

  • axe color-contrast against the review mock, 10 pages at 1280 (/tmp/claude/ux-fixes/contrast.js): main fails 5 distinct colour pairs (white/#ea4335 3.92 x23, white/#4285f4 3.56 x8, #34a853/white 3.05 x36, #6b7280/#e5e7eb 3.9 x2, #9ca3af/white 2.53 x1); this head: 0. Per-page counts at 1280/768/390 for 7 pages go from 1-23 nodes to 0 everywhere (/tmp/claude/ux-fixes/r-main.txt vs r-pr4.txt). Measured on the mock data only; states not reachable there (e.g. warning badge, .error text) are covered by the computed ratios above, not by a render.
  • Keyboard (/tmp/claude/ux-fixes/focus.js): on main the ramp radios are not reachable by Tab; on this head Tab reaches the radio and the card computes 0 0 0 2px #fff, 0 0 0 4px rgb(37,99,235). New tests-e2e/ramp-focus.spec.ts fails on a build of main and passes here.
  • tsc clean, jest 3144 passed, full Playwright e2e after a real build on a private port: 109/109.
  • Before/after pixel diffs of the 7 pages x 3 widths: /tmp/claude/ux-fixes/shots/*-{main,pr4}-*.png; expect green and muted-grey text changes only.
  • Mock-based evidence, not a real backend.

🤖 Generated with Claude Code

Darken the success, error, muted, subtle and warning text tokens, the GCP
badge and the sort and filter icons so the measured failures pass 4.5:1
(3:1 for icons). Add a --cudly-focus-ring token, a zero-specificity default
:focus-visible outline, and use the ring for chips, filters and the
recommendation chevron instead of a near-invisible halo. The plan ramp
radios are visually hidden rather than display: none, so they are in the tab
order, and the card shows the ring on focus.

Refs #696, #750

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 5 billable files and costs up to $1.25.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 56 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 87 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 7df2e750-b3e3-4d3e-9251-ce23edcbd755

📥 Commits

Reviewing files that changed from the base of the PR and between 6125d2c and f176679.


📒 Files selected for processing (5)
  • frontend/src/styles/base.css
  • frontend/src/styles/components.css
  • frontend/src/styles/forms.css
  • frontend/src/styles/plans.css
  • frontend/tests-e2e/ramp-focus.spec.ts


  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@cristim cristim added priority/p2 Backlog-worthy triaged Item has been triaged severity/high Significant harm severity/medium Moderate harm urgency/this-quarter Within the quarter impact/all-users Affects every user effort/m Days type/bug Defect labels Oct 9, 2026
@cristim

cristim commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Independent review (rv-ux-visual): PR #769 at 26da018df939ffdc56fdcee1986024e5d9a5e09b

Verdict: approved (no blocking findings). CI 26/26 green at this SHA. This PR's base is 0e34519; main is now 7e2a3d8. GitHub's merge preview is stale (computed against 0e34519), so I reviewed this PR's diff applied with git apply onto main 7e2a3d8 (applies cleanly), compared with main 7e2a3d8. Production webpack builds, mock API, Playwright Chromium at 1280/768/390, axe-core 4.11. Mock-based evidence; states the mock cannot render (warning badge, .error text) rest on the token ratios.

Verified (main vs main + #769)

  • Contrast (fix(frontend): core color tokens fail WCAG AA contrast #696 / audit H3): axe color-contrast goes from failing on 27 of 33 page/width combinations (up to 23 nodes per page: savings green, white on #ea4335, the GCP badge, muted text) to 0 on all 33. No other axe rule changed, and there are no console errors.
  • Spot-checked tokens: savings green #15803d reads well on the Home KPIs. The sort and column-filter icons use the subtle token.
  • Focus (fix(frontend): focus rings are near-invisible and ramp-option radios are keyboard-unreachable #750 / audit M4):
    • Focused chip-selects go from a #eff6ff halo (about 1.07:1 on white, effectively invisible) to 2px #fff + 4px rgb(37,99,235).
    • Every tab stop on Home, Opportunities and Admin General (13, 14 and 12 stops) has an indicator.
    • Plan ramp radios: on main not reachable by Tab (display:none); with this PR, Tab reaches the first radio ("immediate") and its card shows the ring (0 0 0 2px #fff, 0 0 0 4px rgb(37,99,235) once the transition settles).
  • No layout change: every page is pixel-diff same size at all widths, with 0.3 to 2.6% of pixels changed (text and badge colours only). Page overflow is unchanged at 0 everywhere.

Findings (non-blocking)

  1. Merge conflict with fix(frontend): define the button system and make the default button quiet #765 (verified: this diff does not apply on top of fix(frontend): define the button system and make the default button quiet #765's). Both edit button.success:hover / button.danger:hover (and .btn-small.success:hover) in components.css. fix(frontend): define the button system and make the default button quiet #765 adds border-color: #2d9048 / #d33426 there. If this PR merges second, the rebase must also turn those new border colours into #166534 / var(--cudly-error-fg), not just the backgrounds.
  2. The YTD sparkline and other Chart.js series keep the old JS colour literals (light #34a853 next to the new #15803d text). That is out of scope here as the PR body says; it belongs with the chart-theme work (chore(frontend): no dark mode or theming path, literals bypass color tokens #699 / audit H6).

Evidence (local, review host): /tmp/claude/ux-pr/shots-765-769/ (<page>-{main,p769}-{1280,768,390}.png, *-diff-*.png, axe-{main,p769}.json, focus-*-{main,p769}.png, crop-home-top-p769.png). Scripts: /tmp/claude/ux-run/{build-merged.sh,review-shoot.js,focus-probe.js,ramp-focus.js}.

Resolve components.css hover conflicts: keep #765 border-color lines with
#769 AA colours (#166534, var(--cudly-error-fg)) as background and border.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@cristim

cristim commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Delta review (independent UX reviewer): conflict resolution at f176679 - APPROVE

  • Resolution (git show --cc): only two hunks differ from both parents: button.success:hover = #166534 bg + border, button.danger:hover = var(--cudly-error-fg) bg + border. Diff of f176679 vs main 6125d2c in styles touches only fix(frontend): meet WCAG AA contrast and show keyboard focus #769's intended rules (tokens, :where focus ring, chip/chevron rings, .btn-small.success:hover #166534, .provider-badge.gcp, #bbb sort icons). fix(frontend): define the button system and make the default button quiet #765's quiet default button, sizes, .btn-link/.btn-danger/.btn-warning, button:disabled (opacity .55) and states are untouched. Only fix(frontend): define the button system and make the default button quiet #765's lighter hover borders (#2d9048/#d33426) are dropped, intentionally.
  • Contrast (white text, script): success #15803d 5.02, hover #166534 7.13; danger #d93025 4.77, hover #c5221f (resolved --cudly-error-fg) 5.80. All >= 4.5. Border = background, so no halo. No dark/theme token variants exist in the CSS. Disabled is opacity .55 (inactive, WCAG-exempt; not focusable).
  • Focus ring: base.css :where(button,...):focus-visible { outline: 2px solid #2563eb; outline-offset: 2px } (an outline, not box-shadow). No rule sets outline/box-shadow on button, .primary/.success/.danger, .btn, .btn-small, .btn-destructive, in any state, so hover+focus keeps the ring. The 2px offset gap shows the page, so the ring is measured against it: #2563eb vs #fff 5.17, vs page #f5f7fa 4.82 (>= 3). Ring vs button fills is 1.0-1.4, but the gap makes that irrelevant.
  • Build at f176679 (fresh archive): npm ci 0, tsc --noEmit 0, jest 100 suites / 3168 passed / 1 skipped, exit 0, eslint 0. CI: 27/27 pass. I did not rerun Playwright or a browser; this is a static CSS plus unit review.
  • Non-blocking: .btn-small.danger:hover sets no border-color (#d93025 vs border #d93025 already, fine).

@cristim

cristim commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

UX merge gate at head f176679 (CLEAN, CI 27/27). Verdict: independent reviewer APPROVED the conflict resolution at this SHA (comment 6085392213); rv-ux-visual APPROVED 26da018. Local evidence from my own merge worktree (macOS): npm ci, tsc --noEmit, jest (100 suites, 3168 passed, 1 skipped), eslint all exit 0; Playwright e2e 136/136 on a private port against a fresh prod build. Merging squash with --match-head-commit.

@cristim

cristim commented Oct 10, 2026

Copy link
Copy Markdown
Member Author

Post-merge check (rv-ux-visual)

Merged at head f176679c (merge commit 059292f7), rebased after #765. I compared the merged change with the patch I approved at 26da018d: identical, except exactly the four border-color lines from my conflict note (#2d9048 to #166534, #d33426 to var(--cudly-error-fg) in button.success:hover / button.danger:hover). On main 63d68366 no stale #2d9048 / #d33426 remain in components.css. CI 26/26 green at the merged head. Approval stands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/all-users Affects every user priority/p2 Backlog-worthy severity/high Significant harm severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant