Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/avatar-fallback-dark-contrast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[fix] Avatar: resolve fallback initials color via light-dark() to meet WCAG AA contrast in dark mode (#5279)

@Abidit
69 changes: 69 additions & 0 deletions packages/core/src/Avatar/Avatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,43 @@ import {render, screen, fireEvent} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {Avatar} from './Avatar';
import {AvatarStatusDot} from './AvatarStatusDot';
import {colorDefaults} from '../theme/tokens.stylex';
import {contrastRatio, compositeOver} from '../theme/contrast';
import {parseColor, formatColor} from '../utils/color';

/**
* Split a light-dark(a, b) token into its [light, dark] halves, respecting
* nested parens (e.g. the `rgba(...)` inside --color-neutral's arguments).
*/
function splitLightDark(value: string): [light: string, dark: string] {
const prefix = 'light-dark(';
if (!value.startsWith(prefix) || !value.endsWith(')')) {
return [value, value];
}
const inner = value.slice(prefix.length, -1);
let depth = 0;
for (let i = 0; i < inner.length; i++) {
const char = inner[i];
if (char === '(') {
depth++;
} else if (char === ')') {
depth--;
} else if (char === ',' && depth === 0) {
return [inner.slice(0, i).trim(), inner.slice(i + 1).trim()];
}
}
return [value, value];
}

/** Composite a translucent color over its backdrop, returning a solid hex/rgb string. */
function compositeHex(foreground: string, backdrop: string): string {
const fg = parseColor(foreground);
const bg = parseColor(backdrop);
if (fg === null || bg === null) {
throw new Error(`could not parse "${foreground}" or "${backdrop}"`);
}
return formatColor(compositeOver(fg, bg));
}

describe('Avatar', () => {
it('exposes role="img" with the name as accessible name', () => {
Expand Down Expand Up @@ -65,6 +102,38 @@ describe('Avatar', () => {
expect(icon?.querySelector('svg')).not.toBeNull();
});

it('gives fallback initials at least 4.5:1 contrast in both light and dark mode (#5279)', () => {
// The fallback surface (--color-neutral) is translucent, so its rendered
// color depends on what shows through — composite it over the surface
// it's paired with (matching expandColorScale's WCAG test convention)
// before measuring contrast against the initials' light-dark() color.
const [neutralLight, neutralDark] = splitLightDark(
colorDefaults['--color-neutral'],
);
const [surfaceLight, surfaceDark] = splitLightDark(
colorDefaults['--color-background-surface'],
);
const [textSecondaryLight] = splitLightDark(
colorDefaults['--color-text-secondary'],
);
const [, textPrimaryDark] = splitLightDark(
colorDefaults['--color-text-primary'],
);

const surfaceOnLight = compositeHex(neutralLight, surfaceLight);
const surfaceOnDark = compositeHex(neutralDark, surfaceDark);

// Light mode: initials render in --color-text-secondary.
expect(
contrastRatio(textSecondaryLight, surfaceOnLight),
).toBeGreaterThanOrEqual(4.5);
// Dark mode: initials render in --color-text-primary (the fix) — using
// --color-text-secondary here would fall below 4.5:1.
expect(
contrastRatio(textPrimaryDark, surfaceOnDark),
).toBeGreaterThanOrEqual(4.5);
});

it('puts the avatar box on the element that carries the theme target', () => {
// T7: `.astryx-avatar` documents a `size` visual prop, so the width and
// height that prop selects on must live on the targeted element — a theme
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ const styles = stylex.create({
// variant, `.astryx-avatar-fallback.<size>`), so the defaults here are
// plain values with no internal-var seam. See Avatar.doc.mjs theming.
backgroundColor: colorVars['--color-neutral'],
color: colorVars['--color-text-secondary'],
color: `light-dark(${colorVars['--color-text-secondary']}, ${colorVars['--color-text-primary']})`,
fontFamily: typographyVars['--font-family-body'],
fontWeight: fontWeightVars['--font-weight-medium'],
textTransform: 'uppercase',
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/AvatarGroup/AvatarGroupOverflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const styles = stylex.create({
borderRadius: radiusVars['--radius-full'],
// Use opaque background to prevent avatar bleed-through
backgroundColor: colorVars['--color-background-surface'],
color: colorVars['--color-text-secondary'],
color: `light-dark(${colorVars['--color-text-secondary']}, ${colorVars['--color-text-primary']})`,
fontFamily: typographyVars['--font-family-body'],
fontWeight: fontWeightVars['--font-weight-medium'],
userSelect: 'none',
Expand Down