mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-16 21:00:03 +02:00
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:
parent
54ee30a449
commit
ccb0239f63
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
|||
<p>Project NOMAD™ 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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"\] \*\)\);/
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user