Skip to content

fix(spa): stop using the primary scale as a light-tint background fill - #1091

Merged
philmerrell merged 1 commit into
developfrom
feature/primary-tint-fills
Sep 13, 2026
Merged

fix(spa): stop using the primary scale as a light-tint background fill#1091
philmerrell merged 1 commit into
developfrom
feature/primary-tint-fills

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

The finding

The primary scale in frontend/ai.client/src/styles/generated/brand-theme.css is generated from Boise State blue by lightness offset alone, keeping full chroma at every step:

--color-primary-50:  oklch(from #0033a0 calc(l + 0.4)  c h);   /* rgb(118, 179, 255) */
--color-primary-100: oklch(from #0033a0 calc(l + 0.35) c h);   /* rgb(103, 162, 255) */
--color-primary-200: oklch(from #0033a0 calc(l + 0.3)  c h);   /* rgb( 88, 146, 255) */

So primary-50 is not a pale wash like a Tailwind -50 normally is — it is a saturated mid-blue. Used as a chip/badge/selected-row fill it reads as a blue blob behind small text, and the text on it fails WCAG AA. The state-* scales are real tints (state-success-50 = rgb(240, 253, 244)), which is exactly why the pattern looked safe by analogy and isn't.

What was measured

Ratios computed from the app's own resolved token values (read back via getComputedStyle on the running SPA, not from a palette table):

Pairing Before After
citation chip — text-primary-accessible on the fill 4.13 9.63
citation chip :hover 3.52 8.56
selected-row sub-label (text-gray-500) 2.23 6.87
dark half — text-primary-accessible-dark on the dark tint 2.63 4.74

All 17 shipped pairings pass in both themes; the full table is in the verification section below.

The fix

47 opaque bg-primary-50/100/200 fills and hover: affordances across 25 files, replaced with the neutral-surface pattern PR #1090 established for the skill-command chip:

  • lightbg-gray-100 + text-primary-accessible
  • darkbg-gray-700 + text-primary-50

Covered: citation badge + number pills, share/artifact-share owner chips, artifact "Latest" pill, KB engine badge, selected rows in the role dialogs, selected cards in the report/export/share/artifact-share/fine-tuning flows, dialog header icon tiles, the agents empty-state and migration step tiles, the top-users avatar, the cost-report segmented control, two admin form panels, and 11 ghost-button hover: states (hover:brightness-90 goes with them — it only ever existed to tame the blue).

Two knock-on fixes the new fills exposed, since a neutral fill sits closer to its text than a blue one did:

  • text-gray-500 on bg-gray-100 is 4.39 — those sub-labels move to text-gray-600 / dark:text-gray-300.
  • dark:text-primary-accessible-dark is only 2.63 on dark:bg-gray-700, so every ghost button whose dark hover surface changed gained dark:hover:text-primary-50.

Deliberately left alone

  • Fractional washesbg-primary-50/40 composites to rgb(200, 225, 255), a genuine pale tint that passes comfortably. These are large transient drag-and-drop surfaces (create-training-job, create-inference-job, skill-form, knowledge-base opt-in panel) where the blue is the signal and no small text sits on it. Happy to convert them too if you'd rather the blue go entirely.
  • chat-input.component.html (drag-over state) — same category, and PR feat(composer): slash commands to invoke enabled skills #1090 is actively editing that file.
  • Every solid bg-primary-500+ fill with white text — toggles, progress bars, checkboxes, status dots. Those were never the problem.

Guardrails

src/branding/README.md was recommending the broken pattern outright — | Decorative tint (badge or panel background) | bg-primary-50, dark:bg-primary-900/30 |. That row is what propagated it. It now documents the neutral pattern, plus why the scale is not a tint ramp and why accessible-dark is not safe on a tinted fill. The same rule, with the measured numbers, is added to .claude/skills/tailwind-ui/references/app-conventions.md.

Verification

  • npx ng test --no-watch2868 passed (241 files)
  • npx ng build --configuration development — clean
  • Browser-verified in both themes (class removed + pane colorScheme, body background confirmed at oklch(0.985 0.002 247.839)), with contrast read from computed styles rather than eyeballed:
=== LIGHT ===                                                    === DARK ===
PASS   9.63  chip/tile/segmented/hover  primary-accessible/gray-100    PASS  4.74  primary-50/gray-700
PASS   8.56  citation chip hover        primary-accessible/gray-200    PASS  7.56  white/gray-600
PASS  16.13  selected-row title         gray-900/gray-100              PASS 10.30  white/gray-700
PASS   6.87  selected-row sub-label     gray-600/gray-100              PASS  7.00  gray-300/gray-700
PASS   7.56  unselected sub-label       gray-600/white                 PASS  9.96  gray-300/gray-800
PASS   8.32  export pill                gray-700/gray-200              PASS  6.10  gray-200/gray-600
PASS  10.60  ft-access panel heading    primary-accessible/white       PASS  6.75  primary-50/gray-800
PASS   9.86  override-detail label      gray-700/gray-50               PASS 12.05  gray-300/gray-900

Branched from develop at 3c2ea5d1; PR #1090's two skill-command sites are untouched.

🤖 Generated with Claude Code

The `primary` scale is generated from #0033a0 by lightness offset alone and
keeps full chroma at every step, so `primary-50` is not the pale wash its name
implies — it resolves to rgb(118, 179, 255), a saturated mid-blue. Used as a
chip, badge, icon tile or selected-row fill it reads as a blue blob behind
small text, and it fails WCAG AA. The `state-*` scales ARE real tints
(`state-success-50` = rgb(240, 253, 244)), which is why the pattern looked safe
by analogy and wasn't.

Measured against the app's own resolved tokens:

  text-primary-accessible on bg-primary-100      4.13:1  FAIL
  ...with hover:bg-primary-200                   3.52:1  FAIL
  text-gray-500 sub-label on bg-primary-50       2.23:1  FAIL
  text-primary-accessible-dark on the dark half  2.63:1  FAIL

Replaced 47 opaque `bg-primary-50/100/200` fills and `hover:` affordances
across 25 files with the neutral-surface pattern already established by the
skill-command chip: `bg-gray-100` + `text-primary-accessible` in light,
`bg-gray-700` + `text-primary-50` in dark (9.63:1 / 4.74:1). Sub-labels that
the new fills brought closer together were stepped with them — `text-gray-500`
on `bg-gray-100` is 4.39:1, so those move to `text-gray-600`/`dark:text-gray-300`.
`hover:brightness-90` goes too; it only ever existed to tame the blue.

Left alone deliberately: fractional washes (`bg-primary-50/40` composites to
rgb(200, 225, 255), a genuine pale tint) on large transient drag-and-drop
surfaces, and every solid `bg-primary-500`+ fill with white text.

Guardrails so this does not come back: branding/README.md recommended
`bg-primary-50` as the decorative-tint utility — that row is what propagated
the pattern, and it now documents the neutral one. The tailwind-ui skill's
app-conventions reference gains the same rule with the measured numbers.

Verified in both themes with computed styles: all 17 shipped pairings pass.
2868 SPA tests pass; development build is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@philmerrell
philmerrell merged commit 9e7c182 into develop Sep 13, 2026
6 checks passed
@philmerrell
philmerrell deleted the feature/primary-tint-fills branch September 13, 2026 17:21
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