diff --git a/apps/storybook/stories/Avatar.stories.tsx b/apps/storybook/stories/Avatar.stories.tsx index 2c4768ef28cf..7bbc1168138e 100644 --- a/apps/storybook/stories/Avatar.stories.tsx +++ b/apps/storybook/stories/Avatar.stories.tsx @@ -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, @@ -555,3 +556,55 @@ export const NumericSizes: Story = { ), }; + +// 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: () => ( +
+

Default fallback (size × 0.4)

+
+ + + + + +
+ +

+ Themed fallback (per-size scale, regular weight, wash fill) +

+ +
+ + + + + +
+
+
+ ), +}; diff --git a/packages/core/src/Avatar/Avatar.doc.mjs b/packages/core/src/Avatar/Avatar.doc.mjs index 89f0d2a73d4d..d39577e124f6 100644 --- a/packages/core/src/Avatar/Avatar.doc.mjs +++ b/packages/core/src/Avatar/Avatar.doc.mjs @@ -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: [ diff --git a/packages/core/src/Avatar/Avatar.test.tsx b/packages/core/src/Avatar/Avatar.test.tsx index aced7382915d..513c600603e5 100644 --- a/packages/core/src/Avatar/Avatar.test.tsx +++ b/packages/core/src/Avatar/Avatar.test.tsx @@ -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(); + 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( , diff --git a/packages/core/src/Avatar/Avatar.tsx b/packages/core/src/Avatar/Avatar.tsx index 2db1ef9493a8..d58f15e060fd 100644 --- a/packages/core/src/Avatar/Avatar.tsx +++ b/packages/core/src/Avatar/Avatar.tsx @@ -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', @@ -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: { @@ -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, diff --git a/packages/core/src/theme/derivedVarRegistry.test.ts b/packages/core/src/theme/derivedVarRegistry.test.ts index 94b52e14a301..61d3a3d99299 100644 --- a/packages/core/src/theme/derivedVarRegistry.test.ts +++ b/packages/core/src/theme/derivedVarRegistry.test.ts @@ -186,6 +186,7 @@ function discoverComponents(): ComponentInfo[] { // --------------------------------------------------------------------------- const DIR_TO_REGISTRY_KEY: Record = { + Avatar: 'avatar', Banner: 'banner', Button: 'button', Card: 'card', diff --git a/packages/core/src/theme/derivedVarRegistry.ts b/packages/core/src/theme/derivedVarRegistry.ts index 3a037c2f4a03..88449e8ee613 100644 --- a/packages/core/src/theme/derivedVarRegistry.ts +++ b/packages/core/src/theme/derivedVarRegistry.ts @@ -35,6 +35,12 @@ export interface DerivedVarEntry { * entries share the same property. */ export const derivedVarRegistry: Record = { + 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: [ diff --git a/packages/core/src/theme/generateThemeRules.test.ts b/packages/core/src/theme/generateThemeRules.test.ts index b77d671143df..df61188b1875 100644 --- a/packages/core/src/theme/generateThemeRules.test.ts +++ b/packages/core/src/theme/generateThemeRules.test.ts @@ -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',