diff --git a/apps/playground/src/app/pages/catalog/avatar-page.ts b/apps/playground/src/app/pages/catalog/avatar-page.ts
index fd5fb99..003cab5 100644
--- a/apps/playground/src/app/pages/catalog/avatar-page.ts
+++ b/apps/playground/src/app/pages/catalog/avatar-page.ts
@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
FfAvatarComponent,
FfAvatarSize,
+ FfAvatarTone,
} from '@fireflyframework/design-system';
import { DemoSection } from '../../shared/demo-section';
@@ -29,6 +30,48 @@ import { DemoSection } from '../../shared/demo-section';
}
+
+ @for (px of numericSizes; track px) {
+
+ }
+
+
+
+
+
+
+
+ ← the last one keeps "XX": the explicit initials input wins over name
+
+
+
+
+
+
+
+
+
+ @for (t of tones; track t) {
+
+ }
+
+
`,
+ numericSize: ``,
+ name: `
+`,
+ shape: `
+
+`,
+ tones: `
+`,
fallback: ``,
};
}
diff --git a/packages/design-system-contract/CHANGELOG.md b/packages/design-system-contract/CHANGELOG.md
index da22c3a..30bf952 100644
--- a/packages/design-system-contract/CHANGELOG.md
+++ b/packages/design-system-contract/CHANGELOG.md
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Changed
+- `AvatarContract`: `size` type widened to accept a literal pixel number; new `name`, `round`, `cornerRadius` and `tone` inputs; the `aria` clause documents that the resolved initials (explicit `initials`, else derived from `name`) drive the aria-label fallback
+
## [0.2.0] - 2026-07-21
### Added
diff --git a/packages/design-system-contract/src/lib/primitives/avatar.contract.ts b/packages/design-system-contract/src/lib/primitives/avatar.contract.ts
index 9df128e..5116ff0 100644
--- a/packages/design-system-contract/src/lib/primitives/avatar.contract.ts
+++ b/packages/design-system-contract/src/lib/primitives/avatar.contract.ts
@@ -3,9 +3,12 @@ import type { DsComponentContract } from '../contract.types';
/**
* Contract of the `ff-avatar` primitive.
*
- * Circular avatar displaying an image, falling back automatically to
- * `initials` (max 2 characters, uppercased) when `src` is empty or the
- * image fails to load.
+ * Circular (or square, via `round`) avatar displaying an image, falling back
+ * automatically to initials when `src` is empty or the image fails to load.
+ * Initials come from the explicit `initials` input when set, otherwise they
+ * are derived from `name` (first + last word, max 2 characters, uppercased).
+ * `size` accepts the predefined tokens or a literal pixel number, and `tone`
+ * applies a decorative background palette independent of the content.
*/
export const AvatarContract: DsComponentContract = {
selector: 'ff-avatar',
@@ -13,15 +16,23 @@ export const AvatarContract: DsComponentContract = {
inputs: {
src: { type: 'string', required: false, default: "''" },
initials: { type: 'string', required: false, default: "''" },
+ name: { type: 'string', required: false, default: "''" },
alt: { type: 'string', required: false, default: "''" },
- size: { type: "'sm' | 'md' | 'lg'", required: false, default: "'md'" },
+ size: { type: "'sm' | 'md' | 'lg' | number", required: false, default: "'md'" },
+ round: { type: 'boolean', required: false, default: 'true' },
+ cornerRadius: { type: 'string | undefined', required: false, default: 'undefined' },
+ tone: {
+ type: "'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | 'neutral' | undefined",
+ required: false,
+ default: 'undefined',
+ },
},
outputs: {},
behavior: {
hostAttributeOwnership: ['class', 'role', 'aria-label'],
aria: [
'host has role="img"',
- 'host aria-label is derived from alt (falling back to initials)',
+ 'host aria-label is derived from alt (falling back to the resolved initials — explicit `initials` or derived from `name`)',
],
},
};
diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md
index d4617f6..2241e2b 100644
--- a/packages/design-system/CHANGELOG.md
+++ b/packages/design-system/CHANGELOG.md
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- `ff-avatar`: `size` now also accepts a literal pixel number (proportional initials font size); `name` derives initials automatically (first + last word, uppercased) when the explicit `initials` input is unset; `round`/`cornerRadius` for a square shape with a custom corner radius; `tone` decorative background palette mirroring `ff-badge`'s `color` axis; new `FfAvatarTone` type
+
## [0.4.0] - 2026-07-21
### Added
diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts
index 0912997..b77e0f0 100644
--- a/packages/design-system/src/index.ts
+++ b/packages/design-system/src/index.ts
@@ -54,7 +54,7 @@ export type { FfChipVariant, FfChipSize } from './lib/primitives/ff-chip';
export { FfLinkComponent } from './lib/primitives/ff-link';
export type { FfLinkVariant, FfLinkTarget } from './lib/primitives/ff-link';
export { FfAvatarComponent } from './lib/primitives/ff-avatar';
-export type { FfAvatarSize } from './lib/primitives/ff-avatar';
+export type { FfAvatarSize, FfAvatarTone } from './lib/primitives/ff-avatar';
export { FfTooltipComponent } from './lib/primitives/ff-tooltip';
export type { FfTooltipPosition } from './lib/primitives/ff-tooltip';
export { FfIconComponent, FF_ICONS, provideFfIcons } from './lib/primitives/ff-icon';
diff --git a/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.html b/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.html
index 66c106a..84a4c9c 100644
--- a/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.html
+++ b/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.html
@@ -6,5 +6,5 @@
(error)="onImgError()"
/>
} @else {
- {{ displayInitials }}
+ {{ displayInitials() }}
}
diff --git a/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.scss b/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.scss
index aae2c35..d71ce6f 100644
--- a/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.scss
+++ b/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.scss
@@ -53,4 +53,55 @@
&--lg .ff-avatar__initials {
font-size: var(--ff-font-size-md);
}
+
+ // A numeric `size` sets width/height inline on the host; the class only
+ // owns the initials font size, scaled proportionally from the component.
+ &--custom .ff-avatar__initials {
+ font-size: var(--ff-avatar-initials-size, var(--ff-font-size-sm));
+ }
+
+ // --- Shape ---
+
+ // `round="false"`: the corner radius comes from `cornerRadius` (component
+ // token with a fallback, same override pattern as the background/color).
+ &--square {
+ border-radius: var(--ff-avatar-radius, var(--ff-radius-md));
+ }
+
+ // --- Tones (decorative background palette, independent of content) ---
+
+ &--tone-primary {
+ background-color: var(--ff-avatar-tone-primary-bg, var(--ff-color-primary-100));
+ color: var(--ff-avatar-tone-primary-color, var(--ff-color-primary-700));
+ }
+
+ &--tone-secondary {
+ background-color: var(--ff-avatar-tone-secondary-bg, var(--ff-color-secondary-100));
+ color: var(--ff-avatar-tone-secondary-color, var(--ff-color-secondary-700));
+ }
+
+ &--tone-success {
+ background-color: var(--ff-avatar-tone-success-bg, var(--ff-color-success-100));
+ color: var(--ff-avatar-tone-success-color, var(--ff-color-success-700));
+ }
+
+ &--tone-warning {
+ background-color: var(--ff-avatar-tone-warning-bg, var(--ff-color-warning-100));
+ color: var(--ff-avatar-tone-warning-color, var(--ff-color-warning-700));
+ }
+
+ &--tone-error {
+ background-color: var(--ff-avatar-tone-error-bg, var(--ff-color-error-100));
+ color: var(--ff-avatar-tone-error-color, var(--ff-color-error-700));
+ }
+
+ &--tone-info {
+ background-color: var(--ff-avatar-tone-info-bg, var(--ff-color-info-100));
+ color: var(--ff-avatar-tone-info-color, var(--ff-color-info-700));
+ }
+
+ &--tone-neutral {
+ background-color: var(--ff-avatar-tone-neutral-bg, var(--ff-color-neutral-100));
+ color: var(--ff-avatar-tone-neutral-color, var(--ff-color-neutral-700));
+ }
}
diff --git a/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.spec.ts b/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.spec.ts
index 97aa3f6..fa97e57 100644
--- a/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.spec.ts
+++ b/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.spec.ts
@@ -138,4 +138,158 @@ describe('FfAvatarComponent', () => {
expect(initials).toBeTruthy();
expect(initials.textContent.trim()).toBe('JD');
});
+
+ describe('name → initials derivation', () => {
+ function initialsText(): string {
+ const el = fixture.nativeElement.querySelector('.ff-avatar__initials');
+ return el.textContent.trim();
+ }
+
+ it('should derive a single initial from a one-word name', () => {
+ fixture.componentRef.setInput('name', 'Madonna');
+ fixture.detectChanges();
+
+ expect(initialsText()).toBe('M');
+ });
+
+ it('should derive first + last initials from a two-word name', () => {
+ fixture.componentRef.setInput('name', 'Jane Doe');
+ fixture.detectChanges();
+
+ expect(initialsText()).toBe('JD');
+ });
+
+ it('should derive first + last initials from a compound (3+ word) name, ignoring middle words', () => {
+ fixture.componentRef.setInput('name', 'Maria Garcia Luque');
+ fixture.detectChanges();
+
+ expect(initialsText()).toBe('ML');
+ });
+
+ it('should collapse extra/irregular whitespace between words', () => {
+ fixture.componentRef.setInput('name', ' Jane Doe ');
+ fixture.detectChanges();
+
+ expect(initialsText()).toBe('JD');
+ });
+
+ it('should uppercase derived initials', () => {
+ fixture.componentRef.setInput('name', 'jane doe');
+ fixture.detectChanges();
+
+ expect(initialsText()).toBe('JD');
+ });
+
+ it('should derive initials from unicode names', () => {
+ fixture.componentRef.setInput('name', 'Émile Zola');
+ fixture.detectChanges();
+
+ expect(initialsText()).toBe('ÉZ');
+ });
+
+ it('should render empty initials for an empty name', () => {
+ fixture.componentRef.setInput('name', '');
+ fixture.detectChanges();
+
+ expect(initialsText()).toBe('');
+ });
+
+ it('should render empty initials for a whitespace-only name', () => {
+ fixture.componentRef.setInput('name', ' ');
+ fixture.detectChanges();
+
+ expect(initialsText()).toBe('');
+ });
+
+ it('should let the explicit initials input override the name derivation', () => {
+ fixture.componentRef.setInput('name', 'Jane Doe');
+ fixture.componentRef.setInput('initials', 'XX');
+ fixture.detectChanges();
+
+ expect(initialsText()).toBe('XX');
+ });
+ });
+
+ describe('numeric size', () => {
+ it('should apply the "custom" size class for a numeric size', () => {
+ fixture.componentRef.setInput('size', 72);
+ fixture.detectChanges();
+
+ const hostEl = fixture.nativeElement as HTMLElement;
+ expect(hostEl.classList.contains('ff-avatar--custom')).toBe(true);
+ });
+
+ it('should set inline width/height for a numeric size', () => {
+ fixture.componentRef.setInput('size', 72);
+ fixture.detectChanges();
+
+ const hostEl = fixture.nativeElement as HTMLElement;
+ expect(hostEl.style.width).toBe('72px');
+ expect(hostEl.style.height).toBe('72px');
+ });
+
+ it('should not set inline width/height for a predefined size', () => {
+ fixture.componentRef.setInput('size', 'lg');
+ fixture.detectChanges();
+
+ const hostEl = fixture.nativeElement as HTMLElement;
+ expect(hostEl.style.width).toBe('');
+ expect(hostEl.style.height).toBe('');
+ });
+
+ it('should set a proportional initials font size for a numeric size', () => {
+ fixture.componentRef.setInput('size', 100);
+ fixture.detectChanges();
+
+ const hostEl = fixture.nativeElement as HTMLElement;
+ expect(hostEl.style.getPropertyValue('--ff-avatar-initials-size').trim()).toBe('40px');
+ });
+ });
+
+ describe('round / cornerRadius', () => {
+ it('should default to round (no square modifier class)', () => {
+ const hostEl = fixture.nativeElement as HTMLElement;
+ expect(hostEl.classList.contains('ff-avatar--square')).toBe(false);
+ });
+
+ it('should apply the square modifier class when round is false', () => {
+ fixture.componentRef.setInput('round', false);
+ fixture.detectChanges();
+
+ const hostEl = fixture.nativeElement as HTMLElement;
+ expect(hostEl.classList.contains('ff-avatar--square')).toBe(true);
+ });
+
+ it('should set the corner radius custom property when provided', () => {
+ fixture.componentRef.setInput('round', false);
+ fixture.componentRef.setInput('cornerRadius', '12px');
+ fixture.detectChanges();
+
+ const hostEl = fixture.nativeElement as HTMLElement;
+ expect(hostEl.style.getPropertyValue('--ff-avatar-radius').trim()).toBe('12px');
+ });
+
+ it('should keep the round shape when cornerRadius is set but round stays true', () => {
+ fixture.componentRef.setInput('cornerRadius', '12px');
+ fixture.detectChanges();
+
+ const hostEl = fixture.nativeElement as HTMLElement;
+ expect(hostEl.classList.contains('ff-avatar--square')).toBe(false);
+ });
+ });
+
+ describe('tone', () => {
+ it('should not apply a tone class by default', () => {
+ const hostEl = fixture.nativeElement as HTMLElement;
+ expect(hostEl.className).not.toContain('ff-avatar--tone-');
+ });
+
+ it('should apply the matching tone class when set', () => {
+ fixture.componentRef.setInput('tone', 'success');
+ fixture.detectChanges();
+
+ const hostEl = fixture.nativeElement as HTMLElement;
+ expect(hostEl.classList.contains('ff-avatar--tone-success')).toBe(true);
+ });
+ });
});
diff --git a/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.ts b/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.ts
index d990b91..817127f 100644
--- a/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.ts
+++ b/packages/design-system/src/lib/primitives/ff-avatar/ff-avatar.component.ts
@@ -2,23 +2,65 @@ import {
ChangeDetectionStrategy,
Component,
ViewEncapsulation,
+ computed,
input,
signal,
} from '@angular/core';
-/** Predefined size of the avatar. */
-export type FfAvatarSize = 'sm' | 'md' | 'lg';
+/**
+ * Predefined size of the avatar (`'sm'` 32px, `'md'` 40px, `'lg'` 56px), or a
+ * literal pixel number for an arbitrary custom size.
+ */
+export type FfAvatarSize = 'sm' | 'md' | 'lg' | number;
+
+/**
+ * Decorative background tone of the avatar, independent of image/initials
+ * content. The seven values are the semantic palette of `FfBadgeColor`.
+ */
+export type FfAvatarTone =
+ | 'primary'
+ | 'secondary'
+ | 'success'
+ | 'warning'
+ | 'error'
+ | 'info'
+ | 'neutral';
+
+/**
+ * Derives up to two initials from a full name: the first grapheme of the
+ * first word and, when there is more than one word, the first grapheme of
+ * the last word. Extra whitespace is ignored and the result is uppercased.
+ *
+ * @param name - Full name (e.g. `'Maria Garcia Luque'`). May be empty.
+ * @returns The derived initials (`''`, 1 or 2 characters).
+ */
+function deriveInitialsFromName(name: string): string {
+ const words = name.trim().split(/\s+/).filter(Boolean);
+ if (words.length === 0) return '';
+
+ const firstChar = Array.from(words[0])[0] ?? '';
+ if (words.length === 1) return firstChar.toUpperCase();
+
+ const lastChar = Array.from(words[words.length - 1])[0] ?? '';
+ return (firstChar + lastChar).toUpperCase();
+}
/**
* Firefly avatar atom.
*
- * Circular avatar displaying an image or initials as fallback.
- * Falls back to initials automatically when the image fails to load.
+ * Circular (or square, via `round`) avatar displaying an image, or falling
+ * back to initials when `src` is empty or the image fails to load. Initials
+ * come from the explicit `initials` input when set, otherwise they are
+ * derived automatically from `name` (first + last word, uppercased). `size`
+ * accepts the predefined tokens or a literal pixel number; `tone` applies a
+ * decorative background palette independent of the image/initials content.
*
* @example
* ```html
*
- *
+ *
+ *
+ *
* ```
*/
@Component({
@@ -29,31 +71,109 @@ export type FfAvatarSize = 'sm' | 'md' | 'lg';
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
- '[class]': '"ff-avatar ff-avatar--" + size()',
+ '[class]': 'hostClasses()',
'role': 'img',
- '[attr.aria-label]': 'alt() || initials() || null',
+ '[attr.aria-label]': 'alt() || displayInitials() || null',
+ '[style.width.px]': 'numericSizePx()',
+ '[style.height.px]': 'numericSizePx()',
+ '[style.--ff-avatar-initials-size]': 'initialsFontSize()',
+ '[style.--ff-avatar-radius]': 'cornerRadius() ?? null',
},
})
export class FfAvatarComponent {
/** Image URL. When empty, initials are displayed. */
readonly src = input('');
- /** Fallback text (1–2 characters). Displayed when `src` is empty or fails to load. */
+ /**
+ * Explicit fallback text (1–2 characters). Takes precedence over the
+ * initials derived from `name`. Displayed when `src` is empty or fails to
+ * load.
+ */
readonly initials = input('');
+ /**
+ * Full name used to derive initials automatically (first + last word,
+ * uppercased) when `initials` is not set.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+ readonly name = input('');
+
/** Alt text for the image. Also used as aria-label on the host. */
readonly alt = input('');
- /** Avatar size: `'sm'` (32px) | `'md'` (40px) | `'lg'` (56px). Defaults to `'md'`. */
+ /**
+ * Avatar size: `'sm'` (32px) | `'md'` (40px) | `'lg'` (56px), or a literal
+ * pixel number for an arbitrary size. Defaults to `'md'`. Initials font
+ * size scales proportionally when a numeric size is used.
+ */
readonly size = input('md');
+ /**
+ * Whether the avatar is fully rounded (`true`, default) or square with
+ * rounded corners (`false`, sized via `cornerRadius`).
+ */
+ readonly round = input(true);
+
+ /**
+ * Corner radius applied when `round` is `false`, as a CSS length (e.g.
+ * `'8px'`, `'20%'`). Falls back to `--ff-radius-md` when unset. Has no
+ * effect while `round` is `true`.
+ */
+ readonly cornerRadius = input();
+
+ /**
+ * Decorative background tone, independent of the image/initials content.
+ * When unset, the avatar keeps its default neutral background.
+ */
+ readonly tone = input();
+
/** @internal Whether the image failed to load. */
readonly imgError = signal(false);
- /** @internal Truncated initials (max 2 chars, uppercase). */
- get displayInitials(): string {
- return this.initials().slice(0, 2).toUpperCase();
- }
+ /** @internal Pixel value of `size` when it is a literal number, else `null`. */
+ protected readonly numericSizePx = computed(() => {
+ const s = this.size();
+ return typeof s === 'number' ? s : null;
+ });
+
+ /**
+ * @internal `font-size` (px) for the initials label, proportional to a
+ * numeric `size`; `null` when `size` is a predefined token (CSS classes
+ * own the font size in that case).
+ */
+ protected readonly initialsFontSize = computed(() => {
+ const px = this.numericSizePx();
+ return px === null ? null : `${Math.round(px * 0.4)}px`;
+ });
+
+ /** @internal BEM size modifier: the token itself, or `'custom'` for a numeric size. */
+ protected readonly sizeClass = computed(() => {
+ const s = this.size();
+ return typeof s === 'number' ? 'custom' : s;
+ });
+
+ /** @internal Host BEM classes derived from the active axes. */
+ protected readonly hostClasses = computed(() => {
+ const classes = ['ff-avatar', `ff-avatar--${this.sizeClass()}`];
+ if (!this.round()) classes.push('ff-avatar--square');
+ const tone = this.tone();
+ if (tone) classes.push(`ff-avatar--tone-${tone}`);
+ return classes.join(' ');
+ });
+
+ /**
+ * @internal Truncated, uppercased initials (max 2 chars): the explicit
+ * `initials` input when set, otherwise derived from `name`.
+ */
+ protected readonly displayInitials = computed(() => {
+ const explicit = this.initials();
+ if (explicit) return explicit.slice(0, 2).toUpperCase();
+ return deriveInitialsFromName(this.name());
+ });
/** @internal Whether to show the image. */
get showImage(): boolean {
diff --git a/packages/design-system/src/lib/primitives/ff-avatar/index.ts b/packages/design-system/src/lib/primitives/ff-avatar/index.ts
index f336eb2..8ebfdd0 100644
--- a/packages/design-system/src/lib/primitives/ff-avatar/index.ts
+++ b/packages/design-system/src/lib/primitives/ff-avatar/index.ts
@@ -1,2 +1,2 @@
export { FfAvatarComponent } from './ff-avatar.component';
-export type { FfAvatarSize } from './ff-avatar.component';
+export type { FfAvatarSize, FfAvatarTone } from './ff-avatar.component';