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

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:

  • light — bg-gray-100 + text-primary-accessible
  • dark — bg-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 washes — bg-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-watch — 2868 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
raoakkineni pushed a commit to AuraWorx/auraxai-agentcore-public-stack that referenced this pull request Sep 22, 2026
… list

The chip cloud had outgrown itself at 30-plus tools: a ragged wrap with no
scanning order, descriptions trapped in hover-only tooltips (dead on touch),
and selection state scattered across nine rows. The structural tell was the
per-tool scoping panel — a wrap layout has nowhere to nest it, so it rendered
after the *entire* cloud rather than under the chip that opened it.

Tools is now a disclosure over a searchable, grouped list:

- Rows carry name + description + a trailing switch, matching the sub-tool rows
  the same section already used, so one decision has one idiom instead of two.
- Search filters label/description/category client-side. Everything is already
  loaded, so it is instant; same idiom as customize/tools.
- Grouped by `meta.category`, which the bindable catalog already sent and the
  chips discarded — no backend change. Both levels sorted alphabetically;
  display order is free, the determinism contract is on `toolConfig`.
- Sub-tool selection nests under the row that owns it, deliberately quieter
  than the section accordion (indent + hairline, not a second card) since it
  is the third level of disclosure on the page.

The collapse default is conditional, not always-closed. Create mode and an
empty selection stay OPEN — a section reading "none selected" hides the list
at exactly the moment the author needs it, and a first-time author has no
other way to learn what the platform can do. A saved agent with tools
collapses to `Tools · 5 / Class Search, Policy Search, … +2 more`, which
answers "what can this agent do?" better than the open list does. State is
derived per load, never persisted; a template prefill forces it open so the
author sees the toolset they were handed.

Accessibility: one tab stop per row (the row *is* the `role="switch"`), with
the caret a separate control in a slot reserved on every row so the name
column stays a straight edge. Contrast measured from the running app in both
themes — selected 16.13/6.87 light and 10.30/7.00 dark, unselected 17.75/4.84
and 14.67/5.64. The selected fill is `bg-gray-100`/`dark:bg-gray-700` per
PR Boise-State-Development#1091, NOT a primary tint: this app's `primary-*` scale keeps full chroma,
so `bg-primary-50` is a saturated mid-blue that fails at 2.23:1.

Costs nothing against the model — this is authoring UI only; the bindings it
writes are unchanged, so the prompt prefix is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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