fix(ui): clear the remaining dark mode contrast failures

An axe color-contrast pass in dark mode found 71 failures on the Supply
Depot, 34 on /settings/models and 7 on /chat. Afterwards there is one,
on the Supply Depot.

- Muted text (#8f8f82) sat at 4.49:1 on cards and 3.85:1 on secondary
  surfaces. It is now #a0a093, still dimmer than secondary text.
- Category chips paired a *-lighter background with *-dark text, and
  both tokens invert in dark mode. Tan, red, stone and olive came out at
  2.9-3.5:1, and orange at 2.43:1 because its lighter shade stayed light.
  The chips use the semantic primary text token, as #1358 did for the
  Builder Tag controls, and dark orange-lighter inverts like the other
  families. Warning alerts sit on the same orange background and get
  the same text token.
- Secondary (tan) buttons had white text on a lightened tan (2.97:1),
  and hover moved to the lightest tan. Dark tan stays dark enough for
  white text, and hover/active get btn-tan tokens like btn-green. The
  OTC badge on the same tan uses white text instead of desert-white,
  which turns dark in dark mode.
- dark: utilities followed the OS color scheme rather than the in-app
  theme, so the chat KB index banner kept its light-blue background
  under Night Ops (1.05:1). A custom variant ties dark: to data-theme.
- The Debug Info links used text-gray-500 (3.56:1) and now use the
  secondary text token.

Fixes #1418
This commit is contained in:
Matt Moyles 2026-10-09 14:25:19 -04:00
parent 54ee30a449
commit ccb0239f63
No known key found for this signature in database
GPG Key ID: 7746D1F8D7DA0259
9 changed files with 78 additions and 19 deletions

View File

@ -115,7 +115,7 @@ export default function Alert({
switch (type) {
case 'warning':
return 'text-desert-orange-dark'
return 'text-text-primary'
case 'error':
return 'text-desert-red-dark'
case 'success':
@ -134,7 +134,7 @@ export default function Alert({
switch (type) {
case 'warning':
return 'text-desert-orange-dark text-opacity-80'
return 'text-text-primary text-opacity-80'
case 'error':
return 'text-desert-red-dark text-opacity-80'
case 'success':

View File

@ -18,7 +18,7 @@ export default function Footer() {
<span className="text-gray-300">|</span>
<button
onClick={() => setDebugModalOpen(true)}
className="text-sm/6 text-gray-500 hover:text-desert-green flex items-center gap-1 cursor-pointer"
className="text-sm/6 text-text-secondary hover:text-desert-green flex items-center gap-1 cursor-pointer"
>
<IconBug className="size-3.5" />
Debug Info

View File

@ -26,7 +26,7 @@ const Link = ({
<a
href={href}
title={title}
className="text-desert-orange font-medium hover:text-desert-orange-dark underline decoration-desert-orange-lighter/50 underline-offset-2 hover:decoration-desert-orange transition-colors"
className="text-desert-orange font-medium hover:text-desert-orange-dark underline decoration-desert-orange-light/50 underline-offset-2 hover:decoration-desert-orange transition-colors"
{...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}

View File

@ -67,8 +67,8 @@ const StyledButton: React.FC<StyledButtonProps> = ({
case 'secondary':
return clsx(
'bg-desert-tan text-white',
'hover:bg-desert-tan-dark hover:shadow-lg',
'active:bg-desert-tan-dark',
'hover:bg-btn-tan-hover hover:shadow-lg',
'active:bg-btn-tan-active',
'disabled:bg-desert-tan-lighter disabled:text-desert-stone-light',
baseTransition,
baseHover

View File

@ -90,7 +90,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
<p>Project NOMAD&trade; Command Center v{appVersion}</p>
<button
onClick={() => setDebugModalOpen(true)}
className="text-gray-500 hover:text-desert-green inline-flex items-center gap-1 cursor-pointer"
className="text-text-secondary hover:text-desert-green inline-flex items-center gap-1 cursor-pointer"
>
<IconBug className="size-3.5" />
Debug Info

View File

@ -35,7 +35,7 @@ export default function InteractionColumn({ entry, onRemove }: Props) {
</span>
)}
{isOtc && (
<span className="px-1.5 py-0.5 rounded text-xs font-semibold bg-desert-tan text-desert-white flex-shrink-0">
<span className="px-1.5 py-0.5 rounded text-xs font-semibold bg-desert-tan text-white flex-shrink-0">
OTC
</span>
)}

View File

@ -1,5 +1,8 @@
@import 'tailwindcss';
/* dark: utilities follow the in-app theme toggle, not the OS color scheme. */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
@theme {
--color-desert-white: #f6f6f4;
--color-desert-sand: #f7eedc;
@ -48,6 +51,8 @@
/* Button interactive states (green hover/active swap conflicts with text color inversion) */
--color-btn-green-hover: #353518;
--color-btn-green-active: #2a2a15;
--color-btn-tan-hover: #6b5d4f;
--color-btn-tan-active: #6b5d4f;
}
body {
@ -78,11 +83,11 @@ body {
--color-desert-orange-dark: #c85815;
--color-desert-orange: #c85815;
--color-desert-orange-light: #e69556;
--color-desert-orange-lighter: #f0b87a;
--color-desert-orange-lighter: #8a3d0f;
/* Tan: lightened for readability */
/* Tan: lightened for readability; the base stays dark enough for white button text */
--color-desert-tan-dark: #c9b99f;
--color-desert-tan: #a8927a;
--color-desert-tan: #80694f;
--color-desert-tan-light: #8b7355;
--color-desert-tan-lighter: #6b5d4f;
@ -110,13 +115,15 @@ body {
--color-surface-elevated: #3d3c2a;
--color-text-primary: #f7eedc;
--color-text-secondary: #afafa5;
--color-text-muted: #8f8f82;
--color-text-muted: #a0a093;
--color-border-default: #424420;
--color-border-subtle: #353420;
/* Button interactive states: darker green for hover/active on dark bg */
--color-btn-green-hover: #474a28;
--color-btn-green-active: #3a3c24;
--color-btn-tan-hover: #705c45;
--color-btn-tan-active: #5e5040;
color-scheme: dark;
}

View File

@ -68,12 +68,12 @@ const CATEGORIES = [
const CATEGORY_COLORS: Record<string, string> = {
productivity: 'border border-desert-green-light bg-desert-green-lighter text-desert-green-dark',
media: 'border border-desert-tan-light bg-desert-tan-lighter text-desert-tan-dark',
security: 'border border-desert-red-light bg-desert-red-lighter text-desert-red-dark',
networking: 'border border-desert-stone-light bg-desert-stone-lighter text-desert-stone-dark',
utility: 'border border-desert-olive-light bg-desert-olive-lighter text-desert-olive-dark',
media: 'border border-desert-tan-light bg-desert-tan-lighter text-text-primary',
security: 'border border-desert-red-light bg-desert-red-lighter text-text-primary',
networking: 'border border-desert-stone-light bg-desert-stone-lighter text-text-primary',
utility: 'border border-desert-olive-light bg-desert-olive-lighter text-text-primary',
ai: 'border border-desert-green bg-desert-green-light text-desert-green-darker',
education: 'border border-desert-orange-light bg-desert-orange-lighter text-desert-orange-dark',
education: 'border border-desert-orange-light bg-desert-orange-lighter text-text-primary',
custom: 'border border-border-subtle bg-surface-secondary text-text-secondary',
}
@ -978,7 +978,7 @@ function AppCard({
) : null}
{service.is_user_modified && !service.is_custom ? (
<span
className="text-xs px-2 py-0.5 rounded-full font-medium bg-desert-tan-lighter text-desert-tan-dark border border-desert-tan-light"
className="text-xs px-2 py-0.5 rounded-full font-medium bg-desert-tan-lighter text-text-primary border border-desert-tan-light"
title="You've customized this app, so it won't be overwritten by catalog updates."
>
modified
@ -986,7 +986,7 @@ function AppCard({
) : null}
{service.is_deprecated ? (
<span
className="text-xs px-2 py-0.5 rounded-full font-medium bg-desert-orange-lighter text-desert-orange-dark border border-desert-orange-light"
className="text-xs px-2 py-0.5 rounded-full font-medium bg-desert-orange-lighter text-text-primary border border-desert-orange-light"
title="This is a legacy version that's no longer maintained. Install the current Education Platform from the catalog, then uninstall this one."
>
legacy

View File

@ -7,6 +7,11 @@ const builderTagSelector = await readFile(
new URL('../../inertia/components/BuilderTagSelector.tsx', import.meta.url),
'utf8'
)
const alert = await readFile(new URL('../../inertia/components/Alert.tsx', import.meta.url), 'utf8')
const supplyDepot = await readFile(
new URL('../../inertia/pages/supply-depot.tsx', import.meta.url),
'utf8'
)
const darkTheme = css.match(/\[data-theme="dark"\]\s*\{(?<tokens>[\s\S]*?)\n\}/)?.groups?.tokens
@ -90,3 +95,50 @@ test('Builder Tag controls pair their inverted background with semantic text', (
'Builder Tag text and background must have at least 4.5:1 contrast'
)
})
test('dark muted text clears WCAG AA on the page and card surfaces (#1418)', () => {
const muted = token('--color-text-muted')
for (const surface of [
token('--color-desert-sand'),
token('--color-surface-primary'),
token('--color-surface-secondary'),
]) {
assert.ok(contrast(muted, surface) >= 4.5, `${muted} on ${surface} must have at least 4.5:1`)
}
})
test('dark category chips pair each tinted background with primary text (#1418)', () => {
for (const family of ['tan', 'red', 'stone', 'olive', 'orange']) {
assert.doesNotMatch(
supplyDepot,
new RegExp(`bg-desert-${family}-lighter text-desert-${family}-dark`),
`${family} chips must not pair two tokens that both invert in dark mode`
)
const background = token(`--color-desert-${family}-lighter`)
assert.ok(
contrast(token('--color-text-primary'), background) >= 4.5,
`chip text on ${background} must have at least 4.5:1`
)
}
})
test('dark warning alerts pair the orange background with primary text (#1418)', () => {
assert.doesNotMatch(alert, /case 'warning':\s*return 'text-desert-orange-dark/)
assert.ok(
contrast(token('--color-text-primary'), token('--color-desert-orange-lighter')) >= 4.5,
'warning alert text must have at least 4.5:1 contrast'
)
})
test('dark secondary buttons keep white text readable at rest, hover and active (#1418)', () => {
for (const name of ['--color-desert-tan', '--color-btn-tan-hover', '--color-btn-tan-active']) {
assert.ok(contrast('#ffffff', token(name)) >= 4.5, `white on ${name} must have at least 4.5:1`)
}
})
test('dark: utilities follow the in-app theme, not the OS color scheme (#1418)', () => {
assert.match(
css,
/@custom-variant dark \(&:where\(\[data-theme="dark"\], \[data-theme="dark"\] \*\)\);/
)
})