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
53 changes: 53 additions & 0 deletions apps/storybook/stories/Avatar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {Meta, StoryObj} from '@storybook/react';
import * as stylex from '@stylexjs/stylex';
import {Avatar} from '@astryxdesign/core/Avatar';
import {AvatarStatusDot} from '@astryxdesign/core/Avatar';
import {Theme, defineTheme} from '@astryxdesign/core/theme';
import {
spacingVars,
typographyVars,
Expand Down Expand Up @@ -555,3 +556,55 @@ export const NumericSizes: Story = {
</div>
),
};

// A theme can re-scope the fallback initials' typography per size tier via the
// Avatar-scoped derived vars — a smaller-per-size type scale, regular weight,
// and a muted secondary-text color on an accent wash fill — without forking the
// component. The default row is unchanged (size × 0.4, medium weight, neutral
// fill); only the themed row opts in.
const fallbackScaleTheme = defineTheme({
name: 'avatar-fallback-scale',
components: {
avatar: {
base: {
fontWeight: 'var(--font-weight-normal)',
color: 'var(--color-text-secondary)',
backgroundColor: 'var(--color-accent-muted)',
},
'size:xsm': {fontSize: '8px'},
'size:sm': {fontSize: '9px'},
'size:md': {fontSize: '13px'},
'size:lg': {fontSize: '16px'},
'size:xl': {fontSize: '40px'},
},
},
});

export const ThemedFallbackScale: Story = {
name: 'Themed Fallback Type Scale',
render: () => (
<div {...stylex.props(styles.storyWrapper)}>
<h4 {...stylex.props(styles.heading)}>Default fallback (size × 0.4)</h4>
<div {...stylex.props(styles.row)}>
<Avatar name="TY" size="xsm" />
<Avatar name="XS" size="sm" />
<Avatar name="SM" size="md" />
<Avatar name="MD" size="lg" />
<Avatar name="LG" size="xl" />
</div>

<h4 {...stylex.props(styles.heading)}>
Themed fallback (per-size scale, regular weight, wash fill)
</h4>
<Theme theme={fallbackScaleTheme} mode="light">
<div {...stylex.props(styles.row)}>
<Avatar name="TY" size="xsm" />
<Avatar name="XS" size="sm" />
<Avatar name="SM" size="md" />
<Avatar name="MD" size="lg" />
<Avatar name="LG" size="xl" />
</div>
</Theme>
</div>
),
};
12 changes: 12 additions & 0 deletions packages/core/src/Avatar/Avatar.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ export const docs = {
{className: 'astryx-avatar', visualProps: ['size']},
{className: 'astryx-avatar-status-dot', visualProps: ['variant']},
],
vars: [
{name: '--_avatar-fallback-font-size', description: 'Initials font size; default is proportional to the avatar size (size × 0.4). Override per size tier (e.g. size:sm) for a custom type scale.', default: 'calc(avatar-size × 0.4)', private: true},
{name: '--_avatar-fallback-font-weight', description: 'Initials font weight', default: 'var(--font-weight-medium)', private: true},
{name: '--_avatar-fallback-color', description: 'Fallback text and default-icon color', default: 'var(--color-text-secondary)', private: true},
{name: '--_avatar-fallback-background', description: 'Fallback wash background fill', default: 'var(--color-neutral)', private: true},
],
derived: [
{property: 'fontSize', vars: ['--_avatar-fallback-font-size']},
{property: 'fontWeight', vars: ['--_avatar-fallback-font-weight']},
{property: 'color', vars: ['--_avatar-fallback-color']},
{property: 'backgroundColor', vars: ['--_avatar-fallback-background']},
],
},
description: 'Displays a user avatar with image, initials fallback, and optional status indicator.',
props: [
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/Avatar/Avatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ describe('Avatar', () => {
expect(innerImg).toHaveAttribute('alt', '');
});

it('renders fallback initials through the themeable font-size var, not a bare px literal', () => {
render(<Avatar name="Ada Lovelace" size="sm" data-testid="a" />);
const initials = screen.getByText('AL');
// The seam: the dynamic font size resolves to the Avatar-scoped var (with
// the proportional `size × 0.4` default baked in as the fallback), so a
// theme can re-scope it per size. A regression to a bare px literal would
// break theming.
const style = initials.getAttribute('style') ?? '';
expect(style).toContain('var(--_avatar-fallback-font-size,');
// Default still reproduces the proportional scale (sm = 24 × 0.4 = 9.6px).
expect(style).toMatch(/var\(--_avatar-fallback-font-size,\s*9\.6\d*px\)/);
});

it('retries a new src after a previous src failed to load', () => {
const {rerender} = render(
<Avatar name="Ada" src="https://example.com/broken.jpg" />,
Expand Down
22 changes: 18 additions & 4 deletions packages/core/src/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ const styles = stylex.create({
position: 'relative',
display: 'inline-flex',
flexShrink: 0,
// The wrapper is not clipped (so the status dot can overflow), so it must be
// rounded itself: a themed fallback background lands on `.astryx-avatar` (the
// class-bearing wrapper) as well as the internal var, and an unrounded
// wrapper would show that fill as square corners behind the circular content.
borderRadius: radiusVars['--radius-full'],
},
content: {
display: 'flex',
Expand All @@ -124,10 +129,14 @@ const styles = stylex.create({
justifyContent: 'center',
width: '100%',
height: '100%',
backgroundColor: colorVars['--color-neutral'],
color: colorVars['--color-text-secondary'],
// Fallback surface (initials + default icon). Each property reads an
// Avatar-scoped internal var so a theme can re-scope the fallback wash and
// initials weight/color without forking; the defaults reproduce today's
// exact output. See derivedVarRegistry (avatar) + Avatar.doc.mjs theming.
backgroundColor: `var(--_avatar-fallback-background, ${colorVars['--color-neutral']})`,
color: `var(--_avatar-fallback-color, ${colorVars['--color-text-secondary']})`,
fontFamily: typographyVars['--font-family-body'],
fontWeight: fontWeightVars['--font-weight-medium'],
fontWeight: `var(--_avatar-fallback-font-weight, ${fontWeightVars['--font-weight-medium']})`,
textTransform: 'uppercase',
},
status: {
Expand All @@ -143,8 +152,13 @@ const dynamicStyles = stylex.create({
width: size,
height: size,
}),
// Initials font size defaults to the proportional `size × ratio` scale but is
// reachable via the `--_avatar-fallback-font-size` derived var, so a theme can
// set a per-size type scale (e.g. `components.avatar['size:sm']`).
fontSize: (size: number) => ({
fontSize: size * INITIALS_FONT_SIZE_RATIO,
fontSize: `var(--_avatar-fallback-font-size, ${
size * INITIALS_FONT_SIZE_RATIO
}px)`,
}),
statusPosition: (size: number) => ({
bottom: size * CIRCLE_EDGE_OFFSET_RATIO,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/theme/derivedVarRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ function discoverComponents(): ComponentInfo[] {
// ---------------------------------------------------------------------------

const DIR_TO_REGISTRY_KEY: Record<string, string> = {
Avatar: 'avatar',
Banner: 'banner',
Button: 'button',
Card: 'card',
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/theme/derivedVarRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export interface DerivedVarEntry {
* entries share the same property.
*/
export const derivedVarRegistry: Record<string, DerivedVarEntry[]> = {
avatar: [
{property: 'fontSize', vars: ['--_avatar-fallback-font-size']},
{property: 'fontWeight', vars: ['--_avatar-fallback-font-weight']},
{property: 'color', vars: ['--_avatar-fallback-color']},
{property: 'backgroundColor', vars: ['--_avatar-fallback-background']},
],
banner: [{property: 'borderRadius', vars: ['--_banner-radius']}],
button: [{property: 'borderRadius', vars: ['--_button-radius']}],
card: [
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/theme/generateThemeRules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,48 @@ describe('derived var expansion', () => {
expect(rule).toContain('--_button-radius: 8px');
});

it('emits internal vars for avatar fallback typography (base)', () => {
const theme = defineTheme({
name: 'test-derived-avatar',
components: {
avatar: {
base: {
fontWeight: 'var(--font-weight-normal)',
color: 'var(--color-text-secondary)',
backgroundColor: 'var(--color-accent-muted)',
},
},
},
});
const rules = generateThemeRules(theme);
const rule = rules.find(r => r.includes('.astryx-avatar'));
expect(rule).toBeDefined();
expect(rule).toContain(
'--_avatar-fallback-font-weight: var(--font-weight-normal)',
);
expect(rule).toContain(
'--_avatar-fallback-color: var(--color-text-secondary)',
);
expect(rule).toContain(
'--_avatar-fallback-background: var(--color-accent-muted)',
);
});

it('emits a per-size fallback font-size for avatar (size:sm)', () => {
const theme = defineTheme({
name: 'test-derived-avatar-size',
components: {
avatar: {
'size:sm': {fontSize: '9px'},
},
},
});
const rules = generateThemeRules(theme);
const rule = rules.find(r => r.includes('.astryx-avatar.sm'));
expect(rule).toBeDefined();
expect(rule).toContain('--_avatar-fallback-font-size: 9px');
});

it('does not emit derived vars for components without registry entries', () => {
const theme = defineTheme({
name: 'test-no-derived',
Expand Down