From 8b0b3794bae1169df9fe4322ab03c3b1d21a6801 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Oct 2026 13:57:30 +0200 Subject: [PATCH 01/21] Avatar: Explore colored fallbacks and foreground tinting --- packages/ui/CHANGELOG.md | 1 + .../avatar/stories/fallback-colors.module.css | 64 ++++++++++++ .../ui/src/avatar/stories/index.story.tsx | 97 +++++++++++++++++++ packages/ui/src/avatar/style.module.css | 36 +++++++ .../ui/src/avatar/test/index.browser.test.tsx | 66 +++++++++++++ packages/ui/src/avatar/types.ts | 4 +- 6 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/avatar/stories/fallback-colors.module.css diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 6d76db5efb7071..54a1bac1c78486 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -19,6 +19,7 @@ ### Enhancements +- `Avatar`: Explore CSS-only colored fallbacks with native contrast and contrast-limited foreground tinting, retaining neutral fallbacks when the required features are unavailable. - `Avatar`: Add an optional `color` prop for an internal ring, preserving the avatar's layout size and neutral fallbacks while leaving room for an external focus ring ([#84822](https://github.com/WordPress/gutenberg/pull/84822)). - `SelectControl`: Add a `trigger` slot and `SelectControl.Trigger` subcomponent for customizing trigger props ([#84828](https://github.com/WordPress/gutenberg/pull/84828)). - `Link`: Use a small border radius for consistent focus rings in the default variant ([#84697](https://github.com/WordPress/gutenberg/pull/84697)). diff --git a/packages/ui/src/avatar/stories/fallback-colors.module.css b/packages/ui/src/avatar/stories/fallback-colors.module.css new file mode 100644 index 00000000000000..fa864c87e2a1ed --- /dev/null +++ b/packages/ui/src/avatar/stories/fallback-colors.module.css @@ -0,0 +1,64 @@ +@layer wp-ui { + @layer utilities, components, compositions, overrides; + + @layer compositions { + .gallery { + max-inline-size: 960px; + overflow-x: auto; + font-family: var(--wpds-typography-font-family-body); + font-size: var(--wpds-typography-font-size-sm); + color: var(--wpds-color-foreground-content-neutral); + + table { + border-collapse: collapse; + } + + th, + td { + padding: var(--wpds-dimension-padding-lg); + text-align: start; + white-space: nowrap; + } + } + + .neutral { + background-color: var(--wpds-color-background-surface-neutral-weak); + color: var(--wpds-color-foreground-content-neutral); + + > svg { + color: var(--wpds-color-stroke-surface-neutral); + } + } + + /* Explicit comparison treatments; the tinted column uses the component defaults. */ + @media not (forced-colors: active) { + @supports (color: contrast-color(green)) and (color: rgb(from green r g b / 1)) { + .native { + --_wp-ui-avatar-exploration-background: rgb(from var(--_wp-ui-avatar-exploration-color) clamp(0, r, 255) clamp(0, g, 255) clamp(0, b, 255) / 1); + + background-color: var(--_wp-ui-avatar-exploration-background); + color: contrast-color(var(--_wp-ui-avatar-exploration-background)); + + > svg { + color: inherit; + } + } + } + + @supports (color: color(from green srgb-linear round(up, r) g b / 1)) { + .wcag { + --_wp-ui-avatar-exploration-background: rgb(from var(--_wp-ui-avatar-exploration-color) clamp(0, r, 255) clamp(0, g, 255) clamp(0, b, 255) / 1); + --_wp-ui-avatar-exploration-luminance: calc(0.2126 * r + 0.7152 * g + 0.0722 * b); + --_wp-ui-avatar-exploration-light: round(up, clamp(0, calc(0.1791287847 - var(--_wp-ui-avatar-exploration-luminance)), 1)); + + background-color: var(--_wp-ui-avatar-exploration-background); + color: color(from var(--_wp-ui-avatar-exploration-background) srgb-linear var(--_wp-ui-avatar-exploration-light) var(--_wp-ui-avatar-exploration-light) var(--_wp-ui-avatar-exploration-light) / 1); + + > svg { + color: inherit; + } + } + } + } + } +} diff --git a/packages/ui/src/avatar/stories/index.story.tsx b/packages/ui/src/avatar/stories/index.story.tsx index c684beebc11135..9482c84bbcdc0f 100644 --- a/packages/ui/src/avatar/stories/index.story.tsx +++ b/packages/ui/src/avatar/stories/index.story.tsx @@ -4,6 +4,7 @@ import * as Avatar from '../index'; import { Button } from '../../button'; import { Stack } from '../../stack'; import { Text } from '../../text'; +import explorationStyles from './fallback-colors.module.css'; // Profile illustration by Masantocreative: https://unsplash.com/illustrations/someone-is-thinking-and-strategizing-with-their-brain-1L--AMFSThk // Used under the Unsplash License: https://unsplash.com/license @@ -110,6 +111,102 @@ export const Interactive: Story = { ), }; +/** Compare neutral, native contrast, WCAG black/white, and contrast-limited tinted fallbacks. */ +export const FallbackColors: Story = { + args: { color: '#008a20', size: 'lg' }, + render: ( args ) => { + const colors = [ + [ 'Custom color', args.color ?? '#008a20' ], + [ 'Bright green', '#00e050' ], + [ 'Dark green', '#064e3b' ], + [ 'Midtone gray', '#777777' ], + [ 'Yellow', '#fbbf24' ], + [ 'Cyan', '#00cfff' ], + [ 'Theme color', 'var(--wpds-color-stroke-surface-brand)' ], + ]; + const approaches = [ + [ 'Neutral', 'neutral' ], + [ 'Native black/white', 'native' ], + [ 'WCAG black/white', 'wcag' ], + [ 'Tinted foreground', 'tinted' ], + ]; + return ( +
+

+ Change the custom color, size, and shape in Controls. Filled + fallbacks use opaque sRGB colors. The tinted column falls + back to native black/white, then neutral, as feature support + allows. +

+ + + + + { approaches.map( ( [ label ] ) => ( + + ) ) } + + + + { colors.map( ( [ label, color ] ) => ( + + + { approaches.map( ( [ approach, variant ] ) => ( + + ) ) } + + ) ) } + +
Color + { label } +
{ label } + + + + +
+
+ ); + }, +}; + /** Square avatars use the theme's corner radius for buttons and controls. */ export const Square: Story = { args: { shape: 'square' }, diff --git a/packages/ui/src/avatar/style.module.css b/packages/ui/src/avatar/style.module.css index b0baaeb99f5049..67ed783dc0ede8 100644 --- a/packages/ui/src/avatar/style.module.css +++ b/packages/ui/src/avatar/style.module.css @@ -119,5 +119,41 @@ translate: 0 15%; } } + + @media not (forced-colors: active) { + @supports (color: contrast-color(green)) and (color: rgb(from green r g b / 1)) { + .has-color > .fallback { + /* Use an opaque sRGB fill so contrast does not depend on the surrounding surface. */ + --_wp-ui-avatar-background: rgb(from var(--_wp-ui-avatar-color) clamp(0, r, 255) clamp(0, g, 255) clamp(0, b, 255) / 1); + + background-color: var(--_wp-ui-avatar-background); + color: contrast-color(var(--_wp-ui-avatar-background)); + } + + .has-color > .fallback > .person-icon { + color: inherit; + } + + @supports (color: color(from green srgb-linear round(up, r) g b / 1)) { + .has-color > .fallback { + /* These channel expressions resolve inside the relative color below. */ + --_wp-ui-avatar-luminance: calc(0.2126 * r + 0.7152 * g + 0.0722 * b); + /* The black/white crossover from the WCAG contrast formula is (sqrt(21) - 1) / 20. */ + --_wp-ui-avatar-light-foreground: round(up, clamp(0, calc(0.1791287847 - var(--_wp-ui-avatar-luminance)), 1)); + /* + * Solve the WCAG ratio for the maximum mix toward the background. + * Target 4.55:1 for a small rounding margin, with tint capped at 20%. + * Denominator floors avoid division by zero for black and white. + */ + --_wp-ui-avatar-dark-tint: clamp(0, calc(((var(--_wp-ui-avatar-luminance) + 0.05) / 4.55 - 0.05) / max(0.000001, var(--_wp-ui-avatar-luminance))), 0.2); + --_wp-ui-avatar-light-tint: clamp(0, calc((1.05 - 4.55 * (var(--_wp-ui-avatar-luminance) + 0.05)) / max(0.000001, 1 - var(--_wp-ui-avatar-luminance))), 0.2); + --_wp-ui-avatar-tint: calc(var(--_wp-ui-avatar-light-foreground) * var(--_wp-ui-avatar-light-tint) + (1 - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-dark-tint)); + + /* Linear sRGB mixing keeps the resulting luminance within the calculated bounds. */ + color: color(from var(--_wp-ui-avatar-background) srgb-linear calc(var(--_wp-ui-avatar-light-foreground) + (r - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-tint)) calc(var(--_wp-ui-avatar-light-foreground) + (g - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-tint)) calc(var(--_wp-ui-avatar-light-foreground) + (b - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-tint)) / 1); + } + } + } + } } } diff --git a/packages/ui/src/avatar/test/index.browser.test.tsx b/packages/ui/src/avatar/test/index.browser.test.tsx index d22ad0a016d0f4..2d99904a892d3a 100644 --- a/packages/ui/src/avatar/test/index.browser.test.tsx +++ b/packages/ui/src/avatar/test/index.browser.test.tsx @@ -35,7 +35,73 @@ async function renderGallery( ); } +function luminance( color: string ) { + const channels = color + .match( /[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?/gi )! + .map( Number ); + const linear = color.startsWith( 'color(srgb-linear' ) + ? channels + : channels.map( ( channel ) => { + const value = color.startsWith( 'color(' ) + ? channel + : channel / 255; + return value <= 0.04045 + ? value / 12.92 + : ( ( value + 0.055 ) / 1.055 ) ** 2.4; + } ); + return linear[ 0 ] * 0.2126 + linear[ 1 ] * 0.7152 + linear[ 2 ] * 0.0722; +} + describe( 'Avatar layout', () => { + it( 'keeps tinted initials readable on bright, dark, and midtone backgrounds', async () => { + const colors = [ + '#00e050', + '#064e3b', + '#6f42c1', + '#d94145', + '#fbbf24', + '#ff35ee', + '#879f11', + '#0f766e', + '#00cfff', + '#777777', + '#999999', + '#000000', + '#ffffff', + '#00800080', + 'color(display-p3 1 0 0)', + ]; + await render( + + { colors.map( ( color ) => ( + + + + ) ) } + + ); + for ( const color of colors ) { + const avatar = screen.getByRole( 'img', { name: color } ); + const styles = getComputedStyle( + within( avatar ).getByText( 'AM' ) + ); + const background = luminance( styles.backgroundColor ); + const foreground = luminance( styles.color ); + expect( foreground ).toBeGreaterThan( 0 ); + expect( foreground ).toBeLessThan( 1 ); + expect( styles.backgroundColor ).not.toBe( 'rgba(0, 0, 0, 0)' ); + expect( + ( Math.max( background, foreground ) + 0.05 ) / + ( Math.min( background, foreground ) + 0.05 ) + ).toBeGreaterThanOrEqual( 4.5 ); + } + } ); + it( 'keeps decorative avatars in flow with their typography inside wp-admin trees', async () => { const avatarRef = createRef< HTMLSpanElement >(); await render( diff --git a/packages/ui/src/avatar/types.ts b/packages/ui/src/avatar/types.ts index 3ae8ae4fc76de0..8f36b36bb24aad 100644 --- a/packages/ui/src/avatar/types.ts +++ b/packages/ui/src/avatar/types.ts @@ -7,7 +7,9 @@ export type RootProps = Omit< > & { /** * The color of an optional ring around the avatar. Accepts any CSS color, - * including CSS variables. The image and fallback keep their own colors. + * including CSS variables. Without an image, supporting browsers also use + * an opaque sRGB fill derived from this color and a contrasting foreground. + * Other browsers retain the neutral fallback. Images keep their own colors. * The ring paints over the avatar's inner edge without changing its layout size. */ color?: React.CSSProperties[ 'color' ]; From 84f725083fc7575ccc49f34a8241ce1bd885611a Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Oct 2026 13:59:48 +0200 Subject: [PATCH 02/21] Avatar: Link fallback exploration changelog entry --- packages/ui/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 54a1bac1c78486..f1293410d5c804 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -19,7 +19,7 @@ ### Enhancements -- `Avatar`: Explore CSS-only colored fallbacks with native contrast and contrast-limited foreground tinting, retaining neutral fallbacks when the required features are unavailable. +- `Avatar`: Explore CSS-only colored fallbacks with native contrast and contrast-limited foreground tinting, retaining neutral fallbacks when the required features are unavailable ([#84824](https://github.com/WordPress/gutenberg/pull/84824)). - `Avatar`: Add an optional `color` prop for an internal ring, preserving the avatar's layout size and neutral fallbacks while leaving room for an external focus ring ([#84822](https://github.com/WordPress/gutenberg/pull/84822)). - `SelectControl`: Add a `trigger` slot and `SelectControl.Trigger` subcomponent for customizing trigger props ([#84828](https://github.com/WordPress/gutenberg/pull/84828)). - `Link`: Use a small border radius for consistent focus rings in the default variant ([#84697](https://github.com/WordPress/gutenberg/pull/84697)). From 256646d0cd4435b582e06bfbb0b9d6ca45718fea Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Oct 2026 14:26:01 +0200 Subject: [PATCH 03/21] Avatar: Cover colors near the contrast crossover --- .../ui/src/avatar/test/index.browser.test.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/ui/src/avatar/test/index.browser.test.tsx b/packages/ui/src/avatar/test/index.browser.test.tsx index 2d99904a892d3a..7eb14db151c93c 100644 --- a/packages/ui/src/avatar/test/index.browser.test.tsx +++ b/packages/ui/src/avatar/test/index.browser.test.tsx @@ -54,6 +54,7 @@ function luminance( color: string ) { describe( 'Avatar layout', () => { it( 'keeps tinted initials readable on bright, dark, and midtone backgrounds', async () => { + const crossover = ( Math.sqrt( 21 ) - 1 ) / 20; const colors = [ '#00e050', '#064e3b', @@ -64,12 +65,29 @@ describe( 'Avatar layout', () => { '#879f11', '#0f766e', '#00cfff', + '#757575', + '#767676', '#777777', '#999999', '#000000', '#ffffff', '#00800080', 'color(display-p3 1 0 0)', + // The 4.5:1 limits and the point where black and white have equal contrast. + ...[ + 0.175, + crossover - 0.000001, + crossover, + crossover + 0.000001, + 1.05 / 4.5 - 0.05, + ].flatMap( ( background ) => [ + `color(srgb-linear ${ background } ${ background } ${ background })`, + `color(srgb-linear ${ background / 0.2126 } 0 0)`, + `color(srgb-linear 0 ${ background / 0.7152 } 0)`, + `color(srgb-linear 0.25 ${ + ( background - 0.2126 * 0.25 - 0.0722 ) / 0.7152 + } 1)`, + ] ), ]; await render( From 4cadbaeb87e0d17cccdca5d486f21332fac366bf Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Oct 2026 14:39:33 +0200 Subject: [PATCH 04/21] Avatar: Compare translucent fallback foregrounds --- .../avatar/stories/fallback-colors.module.css | 11 ++++-- .../ui/src/avatar/stories/index.story.tsx | 37 ++++++++++++++++--- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/avatar/stories/fallback-colors.module.css b/packages/ui/src/avatar/stories/fallback-colors.module.css index fa864c87e2a1ed..1efd225dbb1e4a 100644 --- a/packages/ui/src/avatar/stories/fallback-colors.module.css +++ b/packages/ui/src/avatar/stories/fallback-colors.module.css @@ -3,7 +3,7 @@ @layer compositions { .gallery { - max-inline-size: 960px; + max-inline-size: 1200px; overflow-x: auto; font-family: var(--wpds-typography-font-family-body); font-size: var(--wpds-typography-font-size-sm); @@ -46,18 +46,23 @@ } @supports (color: color(from green srgb-linear round(up, r) g b / 1)) { - .wcag { + .wcag, + .translucent { --_wp-ui-avatar-exploration-background: rgb(from var(--_wp-ui-avatar-exploration-color) clamp(0, r, 255) clamp(0, g, 255) clamp(0, b, 255) / 1); --_wp-ui-avatar-exploration-luminance: calc(0.2126 * r + 0.7152 * g + 0.0722 * b); --_wp-ui-avatar-exploration-light: round(up, clamp(0, calc(0.1791287847 - var(--_wp-ui-avatar-exploration-luminance)), 1)); background-color: var(--_wp-ui-avatar-exploration-background); - color: color(from var(--_wp-ui-avatar-exploration-background) srgb-linear var(--_wp-ui-avatar-exploration-light) var(--_wp-ui-avatar-exploration-light) var(--_wp-ui-avatar-exploration-light) / 1); + color: color(from var(--_wp-ui-avatar-exploration-background) srgb-linear var(--_wp-ui-avatar-exploration-light) var(--_wp-ui-avatar-exploration-light) var(--_wp-ui-avatar-exploration-light) / var(--_wp-ui-avatar-exploration-foreground-alpha, 1)); > svg { color: inherit; } } + + .translucent { + --_wp-ui-avatar-exploration-foreground-alpha: var(--_wp-ui-avatar-exploration-opacity, 0.8); + } } } } diff --git a/packages/ui/src/avatar/stories/index.story.tsx b/packages/ui/src/avatar/stories/index.story.tsx index 9482c84bbcdc0f..202f90250cb31a 100644 --- a/packages/ui/src/avatar/stories/index.story.tsx +++ b/packages/ui/src/avatar/stories/index.story.tsx @@ -111,14 +111,26 @@ export const Interactive: Story = { ), }; -/** Compare neutral, native contrast, WCAG black/white, and contrast-limited tinted fallbacks. */ -export const FallbackColors: Story = { - args: { color: '#008a20', size: 'lg' }, - render: ( args ) => { +/** Compare neutral, black/white, contrast-limited tint, and translucent fallbacks. */ +export const FallbackColors: StoryObj< + React.ComponentProps< typeof Avatar.Root > & { foregroundOpacity: number } +> = { + args: { color: '#008a20', size: 'lg', foregroundOpacity: 0.8 }, + argTypes: { + foregroundOpacity: { + name: 'Foreground opacity', + control: { type: 'range', min: 0, max: 1, step: 0.005 }, + description: + 'The text and icon alpha in the translucent comparison.', + }, + }, + render: ( { foregroundOpacity, ...args } ) => { const colors = [ [ 'Custom color', args.color ?? '#008a20' ], [ 'Bright green', '#00e050' ], [ 'Dark green', '#064e3b' ], + [ 'Gray below crossover', '#757575' ], + [ 'Gray above crossover', '#767676' ], [ 'Midtone gray', '#777777' ], [ 'Yellow', '#fbbf24' ], [ 'Cyan', '#00cfff' ], @@ -129,15 +141,30 @@ export const FallbackColors: Story = { [ 'Native black/white', 'native' ], [ 'WCAG black/white', 'wcag' ], [ 'Tinted foreground', 'tinted' ], + [ 'Foreground alpha', 'translucent' ], ]; return ( -
+

Change the custom color, size, and shape in Controls. Filled fallbacks use opaque sRGB colors. The tinted column falls back to native black/white, then neutral, as feature support allows.

+

+ The foreground alpha column uses{ ' ' } + { ( foregroundOpacity * 100 ).toFixed( 1 ) }% opacity over + the opaque fill. Lowering opacity can reduce contrast below + 4.5:1. Adjust Foreground opacity in Controls. +

From 8e7dad518d46a2ef22b76d44787c277fe44bc9b4 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 16 Oct 2026 21:09:03 +0200 Subject: [PATCH 05/21] Avatar: Remove opacity exploration and improve neutral icon contrast --- packages/ui/CHANGELOG.md | 1 + .../avatar/stories/fallback-colors.module.css | 13 ++----- .../ui/src/avatar/stories/index.story.tsx | 37 +++---------------- packages/ui/src/avatar/style.module.css | 2 +- 4 files changed, 12 insertions(+), 41 deletions(-) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index f1293410d5c804..d56fbd32057e40 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -33,6 +33,7 @@ ### Bug Fixes +- `Avatar`: Use the strong neutral stroke color for the person fallback to provide sufficient non-text contrast ([#84824](https://github.com/WordPress/gutenberg/pull/84824)). - Keep the `Link` new-tab indicator with the preceding text when wrapping ([#84806](https://github.com/WordPress/gutenberg/pull/84806)). - `Select`: Keep long popup lists scrollable when item alignment falls back to anchored positioning or the popup opens through touch ([#84682](https://github.com/WordPress/gutenberg/pull/84682)). - `Combobox.Input`, `SearchableSelect`, and `SearchableSelectControl`: Orient the search icon toward the top left in LTR and the top right in RTL ([#84690](https://github.com/WordPress/gutenberg/pull/84690)). diff --git a/packages/ui/src/avatar/stories/fallback-colors.module.css b/packages/ui/src/avatar/stories/fallback-colors.module.css index 1efd225dbb1e4a..a654e553778386 100644 --- a/packages/ui/src/avatar/stories/fallback-colors.module.css +++ b/packages/ui/src/avatar/stories/fallback-colors.module.css @@ -3,7 +3,7 @@ @layer compositions { .gallery { - max-inline-size: 1200px; + max-inline-size: 960px; overflow-x: auto; font-family: var(--wpds-typography-font-family-body); font-size: var(--wpds-typography-font-size-sm); @@ -26,7 +26,7 @@ color: var(--wpds-color-foreground-content-neutral); > svg { - color: var(--wpds-color-stroke-surface-neutral); + color: var(--wpds-color-stroke-surface-neutral-strong); } } @@ -46,23 +46,18 @@ } @supports (color: color(from green srgb-linear round(up, r) g b / 1)) { - .wcag, - .translucent { + .wcag { --_wp-ui-avatar-exploration-background: rgb(from var(--_wp-ui-avatar-exploration-color) clamp(0, r, 255) clamp(0, g, 255) clamp(0, b, 255) / 1); --_wp-ui-avatar-exploration-luminance: calc(0.2126 * r + 0.7152 * g + 0.0722 * b); --_wp-ui-avatar-exploration-light: round(up, clamp(0, calc(0.1791287847 - var(--_wp-ui-avatar-exploration-luminance)), 1)); background-color: var(--_wp-ui-avatar-exploration-background); - color: color(from var(--_wp-ui-avatar-exploration-background) srgb-linear var(--_wp-ui-avatar-exploration-light) var(--_wp-ui-avatar-exploration-light) var(--_wp-ui-avatar-exploration-light) / var(--_wp-ui-avatar-exploration-foreground-alpha, 1)); + color: color(from var(--_wp-ui-avatar-exploration-background) srgb-linear var(--_wp-ui-avatar-exploration-light) var(--_wp-ui-avatar-exploration-light) var(--_wp-ui-avatar-exploration-light) / 1); > svg { color: inherit; } } - - .translucent { - --_wp-ui-avatar-exploration-foreground-alpha: var(--_wp-ui-avatar-exploration-opacity, 0.8); - } } } } diff --git a/packages/ui/src/avatar/stories/index.story.tsx b/packages/ui/src/avatar/stories/index.story.tsx index 202f90250cb31a..4ea533efbc8518 100644 --- a/packages/ui/src/avatar/stories/index.story.tsx +++ b/packages/ui/src/avatar/stories/index.story.tsx @@ -48,7 +48,7 @@ type Story = StoryObj< typeof Avatar.Root >; export const Default: Story = {}; -/** The optional color ring leaves images, initials, and the person fallback unchanged. */ +/** The color ring preserves loaded images and colors their fallback content. */ export const WithColor: Story = { args: { color: '#008a20' }, render: ( args ) => ( @@ -111,20 +111,10 @@ export const Interactive: Story = { ), }; -/** Compare neutral, black/white, contrast-limited tint, and translucent fallbacks. */ -export const FallbackColors: StoryObj< - React.ComponentProps< typeof Avatar.Root > & { foregroundOpacity: number } -> = { - args: { color: '#008a20', size: 'lg', foregroundOpacity: 0.8 }, - argTypes: { - foregroundOpacity: { - name: 'Foreground opacity', - control: { type: 'range', min: 0, max: 1, step: 0.005 }, - description: - 'The text and icon alpha in the translucent comparison.', - }, - }, - render: ( { foregroundOpacity, ...args } ) => { +/** Compare neutral, native contrast, WCAG black/white, and contrast-limited tinted fallbacks. */ +export const FallbackColors: Story = { + args: { color: '#008a20', size: 'lg' }, + render: ( args ) => { const colors = [ [ 'Custom color', args.color ?? '#008a20' ], [ 'Bright green', '#00e050' ], @@ -141,30 +131,15 @@ export const FallbackColors: StoryObj< [ 'Native black/white', 'native' ], [ 'WCAG black/white', 'wcag' ], [ 'Tinted foreground', 'tinted' ], - [ 'Foreground alpha', 'translucent' ], ]; return ( -
+

Change the custom color, size, and shape in Controls. Filled fallbacks use opaque sRGB colors. The tinted column falls back to native black/white, then neutral, as feature support allows.

-

- The foreground alpha column uses{ ' ' } - { ( foregroundOpacity * 100 ).toFixed( 1 ) }% opacity over - the opaque fill. Lowering opacity can reduce contrast below - 4.5:1. Adjust Foreground opacity in Controls. -

diff --git a/packages/ui/src/avatar/style.module.css b/packages/ui/src/avatar/style.module.css index 67ed783dc0ede8..8b38fed5613dd5 100644 --- a/packages/ui/src/avatar/style.module.css +++ b/packages/ui/src/avatar/style.module.css @@ -113,7 +113,7 @@ > .person-icon { /* Keep the head visible and crop the torso at the bottom of the frame. */ - color: var(--wpds-color-stroke-surface-neutral); + color: var(--wpds-color-stroke-surface-neutral-strong); inline-size: 110%; block-size: 110%; translate: 0 15%; From 88a11c2548c19856f0226982505d4cb8cc78172f Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 16 Oct 2026 21:28:36 +0200 Subject: [PATCH 06/21] Avatar: Compare capped and uncapped fallback tinting --- .../avatar/stories/fallback-colors.module.css | 12 ++++++++++-- .../ui/src/avatar/stories/index.story.tsx | 19 ++++++++++++++++--- packages/ui/src/avatar/types.ts | 5 +++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/avatar/stories/fallback-colors.module.css b/packages/ui/src/avatar/stories/fallback-colors.module.css index a654e553778386..6e9ca04fa3547b 100644 --- a/packages/ui/src/avatar/stories/fallback-colors.module.css +++ b/packages/ui/src/avatar/stories/fallback-colors.module.css @@ -3,7 +3,7 @@ @layer compositions { .gallery { - max-inline-size: 960px; + max-inline-size: 1200px; overflow-x: auto; font-family: var(--wpds-typography-font-family-body); font-size: var(--wpds-typography-font-size-sm); @@ -30,7 +30,7 @@ } } - /* Explicit comparison treatments; the tinted column uses the component defaults. */ + /* Explicit comparison treatments; the capped tint uses the component defaults. */ @media not (forced-colors: active) { @supports (color: contrast-color(green)) and (color: rgb(from green r g b / 1)) { .native { @@ -43,6 +43,14 @@ color: inherit; } } + + @supports (color: color(from green srgb-linear round(up, r) g b / 1)) { + .uncapped { + /* Keep the component's 4.55:1 calculation and remove only its 20% blend cap. */ + --_wp-ui-avatar-dark-tint: clamp(0, calc(((var(--_wp-ui-avatar-luminance) + 0.05) / 4.55 - 0.05) / max(0.000001, var(--_wp-ui-avatar-luminance))), 1); + --_wp-ui-avatar-light-tint: clamp(0, calc((1.05 - 4.55 * (var(--_wp-ui-avatar-luminance) + 0.05)) / max(0.000001, 1 - var(--_wp-ui-avatar-luminance))), 1); + } + } } @supports (color: color(from green srgb-linear round(up, r) g b / 1)) { diff --git a/packages/ui/src/avatar/stories/index.story.tsx b/packages/ui/src/avatar/stories/index.story.tsx index 4ea533efbc8518..e2483f70c6fe5b 100644 --- a/packages/ui/src/avatar/stories/index.story.tsx +++ b/packages/ui/src/avatar/stories/index.story.tsx @@ -111,7 +111,7 @@ export const Interactive: Story = { ), }; -/** Compare neutral, native contrast, WCAG black/white, and contrast-limited tinted fallbacks. */ +/** Compare neutral, black/white, capped tint, and uncapped tint fallbacks. */ export const FallbackColors: Story = { args: { color: '#008a20', size: 'lg' }, render: ( args ) => { @@ -130,16 +130,29 @@ export const FallbackColors: Story = { [ 'Neutral', 'neutral' ], [ 'Native black/white', 'native' ], [ 'WCAG black/white', 'wcag' ], - [ 'Tinted foreground', 'tinted' ], + [ 'Tint capped at 20%', 'tinted' ], + [ 'Uncapped tint', 'uncapped' ], ]; return (

Change the custom color, size, and shape in Controls. Filled - fallbacks use opaque sRGB colors. The tinted column falls + fallbacks use opaque sRGB colors. Both tinted columns fall back to native black/white, then neutral, as feature support allows.

+

+ Both tints target 4.55:1 contrast. The capped tint blends at + most 20% of the background into black or white. The uncapped + tint uses the full blend that retains the target contrast. +

+

+ Colored fallbacks do not support currentColor, + including through CSS variables or color functions. Their + fill must resolve independently of the foreground. + Transparent colors become opaque, and out-of-gamut channels + are clipped to sRGB. +

diff --git a/packages/ui/src/avatar/types.ts b/packages/ui/src/avatar/types.ts index 8f36b36bb24aad..4e493971269df5 100644 --- a/packages/ui/src/avatar/types.ts +++ b/packages/ui/src/avatar/types.ts @@ -11,6 +11,11 @@ export type RootProps = Omit< * an opaque sRGB fill derived from this color and a contrasting foreground. * Other browsers retain the neutral fallback. Images keep their own colors. * The ring paints over the avatar's inner edge without changing its layout size. + * + * The colored fallback's fill must resolve independently of its foreground. + * `currentColor`, including through CSS variables or color functions, is + * unsupported for colored fallbacks and can produce insufficient contrast. + * Alpha is discarded, and out-of-gamut channels are clipped to sRGB. */ color?: React.CSSProperties[ 'color' ]; /** From 5d27d8405b7a47730b1b3e8b7ead580040a1e167 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 16 Oct 2026 21:43:33 +0200 Subject: [PATCH 07/21] Avatar: Warn about unsupported fallback colors --- packages/ui/CHANGELOG.md | 2 +- packages/ui/src/avatar/dev-warnings.ts | 38 +++++++ packages/ui/src/avatar/root.tsx | 5 + .../avatar/stories/fallback-colors.module.css | 6 +- .../ui/src/avatar/stories/index.story.tsx | 102 ++++++++++++++++++ packages/ui/src/avatar/style.module.css | 2 +- packages/ui/src/avatar/types.ts | 5 + 7 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/avatar/dev-warnings.ts diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index d56fbd32057e40..2ef58f5bbc4db9 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -19,7 +19,7 @@ ### Enhancements -- `Avatar`: Explore CSS-only colored fallbacks with native contrast and contrast-limited foreground tinting, retaining neutral fallbacks when the required features are unavailable ([#84824](https://github.com/WordPress/gutenberg/pull/84824)). +- `Avatar`: Explore CSS-only colored fallbacks with native contrast and contrast-limited foreground tinting, retaining neutral fallbacks when the required features are unavailable. Add best-effort development warnings for unsupported colors and use the weak neutral content token for neutral initials ([#84824](https://github.com/WordPress/gutenberg/pull/84824)). - `Avatar`: Add an optional `color` prop for an internal ring, preserving the avatar's layout size and neutral fallbacks while leaving room for an external focus ring ([#84822](https://github.com/WordPress/gutenberg/pull/84822)). - `SelectControl`: Add a `trigger` slot and `SelectControl.Trigger` subcomponent for customizing trigger props ([#84828](https://github.com/WordPress/gutenberg/pull/84828)). - `Link`: Use a small border radius for consistent focus rings in the default variant ([#84697](https://github.com/WordPress/gutenberg/pull/84697)). diff --git a/packages/ui/src/avatar/dev-warnings.ts b/packages/ui/src/avatar/dev-warnings.ts new file mode 100644 index 00000000000000..8caedbf687710d --- /dev/null +++ b/packages/ui/src/avatar/dev-warnings.ts @@ -0,0 +1,38 @@ +import warning from '@wordpress/warning'; +import type { RootProps } from './types'; + +export function warnAvatarColor( color: RootProps[ 'color' ] ): void { + if ( color === undefined ) { + return; + } + + // Skip comments and strings, and keep custom-property names as whole tokens. + // This is a best-effort scan; it does not decode CSS escapes or resolve var(). + const tokens = color.match( + /\/\*[\s\S]*?(?:\*\/|$)|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|(?:[-\w\u0080-\uFFFF]|\\(?:[\da-fA-F]{1,6}[ \t\r\n\f]?|[^\r\n\f]))+/g + ); + if ( tokens?.some( ( token ) => token.toLowerCase() === 'currentcolor' ) ) { + warning( + 'Avatar.Root: `color` contains `currentColor`, which can produce insufficient contrast in colored fallbacks. Use a color that does not depend on the foreground.' + ); + return; + } + + if ( typeof CSS === 'undefined' || ! CSS.supports ) { + return; + } + + if ( ! CSS.supports( 'color', color ) ) { + warning( + 'Avatar.Root: The browser does not support the `color` value. Use a valid CSS color supported by this browser.' + ); + } else if ( + // Browsers without relative colors retain the neutral fallback. + CSS.supports( 'color', 'rgb(from red r g b / 1)' ) && + ! CSS.supports( 'color', `rgb(from ${ color } r g b / 1)` ) + ) { + warning( + 'Avatar.Root: `color` cannot be used as a colored fallback fill. Use an explicit CSS color instead of a CSS-wide keyword such as `inherit`.' + ); + } +} diff --git a/packages/ui/src/avatar/root.tsx b/packages/ui/src/avatar/root.tsx index 38d3204f30504c..c2b070b3075c50 100644 --- a/packages/ui/src/avatar/root.tsx +++ b/packages/ui/src/avatar/root.tsx @@ -2,6 +2,7 @@ import { Avatar as _Avatar } from '@base-ui/react/avatar'; import clsx from 'clsx'; import { forwardRef } from '@wordpress/element'; import type { RootProps } from './types'; +import { warnAvatarColor } from './dev-warnings'; import resetStyles from '../utils/css/resets.module.css'; import defenseStyles from '../utils/css/global-css-defense.module.css'; import styles from './style.module.css'; @@ -27,6 +28,10 @@ export const Root = forwardRef< HTMLSpanElement, RootProps >( { size = 'md', shape = 'circle', color, className, style, ...props }, ref ) { + if ( process.env.NODE_ENV !== 'production' ) { + warnAvatarColor( color ); + } + const customProperties = color === undefined ? style diff --git a/packages/ui/src/avatar/stories/fallback-colors.module.css b/packages/ui/src/avatar/stories/fallback-colors.module.css index 6e9ca04fa3547b..d3cba00aad8cf6 100644 --- a/packages/ui/src/avatar/stories/fallback-colors.module.css +++ b/packages/ui/src/avatar/stories/fallback-colors.module.css @@ -23,13 +23,17 @@ .neutral { background-color: var(--wpds-color-background-surface-neutral-weak); - color: var(--wpds-color-foreground-content-neutral); + color: var(--wpds-color-foreground-content-neutral-weak); > svg { color: var(--wpds-color-stroke-surface-neutral-strong); } } + .unsupported { + --_wp-ui-avatar-unsupported-color: currentColor; + } + /* Explicit comparison treatments; the capped tint uses the component defaults. */ @media not (forced-colors: active) { @supports (color: contrast-color(green)) and (color: rgb(from green r g b / 1)) { diff --git a/packages/ui/src/avatar/stories/index.story.tsx b/packages/ui/src/avatar/stories/index.story.tsx index e2483f70c6fe5b..42ffb203654f4c 100644 --- a/packages/ui/src/avatar/stories/index.story.tsx +++ b/packages/ui/src/avatar/stories/index.story.tsx @@ -153,6 +153,12 @@ export const FallbackColors: Story = { Transparent colors become opaque, and out-of-gamut channels are clipped to sRGB.

+

+ Development warnings are best-effort. They do not resolve + CSS variables or detect every unsupported expression. The + absence of a warning does not guarantee sufficient contrast. + See Unsupported Colors for examples. +

@@ -222,6 +228,102 @@ export const FallbackColors: Story = { }, }; +/** Demonstrate unsupported fills and the limits of development warnings. */ +export const UnsupportedColors: Story = { + args: { size: 'lg' }, + parameters: { + // This demonstration intentionally reproduces insufficient contrast. + // Keep the accessibility findings visible without failing other stories. + a11y: { test: 'todo' }, + }, + render: ( args ) => { + const examples = [ + [ + 'currentColor', + 'Keyword scan detects the foreground dependency.', + ], + [ + 'color-mix(in srgb, currentColor 50%, red)', + 'Keyword scan detects the foreground dependency.', + ], + [ + 'var(--_wp-ui-avatar-unsupported-color)', + 'The variable resolves to currentColor. Not detected.', + ], + [ + String.raw`\63 urrentColor`, + 'Escaped currentColor. Not detected.', + ], + [ + 'var(--_wp-ui-avatar-missing-color)', + 'The variable is undefined. Not detected.', + ], + [ 'not-a-color', 'CSS.supports() rejects the color.' ], + [ 'inherit', 'CSS.supports() rejects the relative-color fill.' ], + ]; + return ( +
+

+ These examples intentionally use unsupported colors. A fill + that depends on its foreground can lose contrast. Invalid + colors and missing variables can remove the fill. +

+

+ In supporting browsers, development builds warn about + explicit currentColor tokens and values + rejected by CSS.supports(). Warnings are + best-effort and deduplicated. They do not resolve CSS + variables or detect every unsupported expression, including + escaped keywords. The absence of a warning does not + guarantee sufficient contrast. +

+
+ + + + + + + + + { examples.map( ( [ color, explanation ] ) => ( + + + + + + ) ) } + +
ColorFallbacksBest-effort validation
+ { color } + + + + + + { explanation }
+
+ ); + }, +}; + /** Square avatars use the theme's corner radius for buttons and controls. */ export const Square: Story = { args: { shape: 'square' }, diff --git a/packages/ui/src/avatar/style.module.css b/packages/ui/src/avatar/style.module.css index 8b38fed5613dd5..d6f8bf0dc0d27c 100644 --- a/packages/ui/src/avatar/style.module.css +++ b/packages/ui/src/avatar/style.module.css @@ -21,7 +21,7 @@ border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral); border-radius: 50%; background-color: var(--wpds-color-background-surface-neutral-weak); - color: var(--wpds-color-foreground-content-neutral); + color: var(--wpds-color-foreground-content-neutral-weak); font-family: var(--wpds-typography-font-family-body); font-size: var(--_wp-ui-avatar-font-size); font-weight: var(--wpds-typography-font-weight-emphasis); diff --git a/packages/ui/src/avatar/types.ts b/packages/ui/src/avatar/types.ts index 4e493971269df5..6c8d4e752d0b25 100644 --- a/packages/ui/src/avatar/types.ts +++ b/packages/ui/src/avatar/types.ts @@ -15,7 +15,12 @@ export type RootProps = Omit< * The colored fallback's fill must resolve independently of its foreground. * `currentColor`, including through CSS variables or color functions, is * unsupported for colored fallbacks and can produce insufficient contrast. + * CSS-wide keywords such as `inherit` cannot be used as fallback fills. * Alpha is discarded, and out-of-gamut channels are clipped to sRGB. + * + * Development warnings are best-effort. They do not resolve CSS variables + * or detect every unsupported expression, including escaped keywords. + * The absence of a warning does not guarantee sufficient contrast. */ color?: React.CSSProperties[ 'color' ]; /** From 563a3d3621d357da762dedd7acbe4afa910db50a Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 16 Oct 2026 21:52:05 +0200 Subject: [PATCH 08/21] Avatar: Target 3:1 contrast for person fallbacks --- packages/ui/CHANGELOG.md | 2 +- .../avatar/stories/fallback-colors.module.css | 8 ++++---- packages/ui/src/avatar/stories/index.story.tsx | 8 +++++--- packages/ui/src/avatar/style.module.css | 18 +++++++++++++++--- packages/ui/src/avatar/types.ts | 2 ++ 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 2ef58f5bbc4db9..52fc6a8b6c8a53 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -19,7 +19,7 @@ ### Enhancements -- `Avatar`: Explore CSS-only colored fallbacks with native contrast and contrast-limited foreground tinting, retaining neutral fallbacks when the required features are unavailable. Add best-effort development warnings for unsupported colors and use the weak neutral content token for neutral initials ([#84824](https://github.com/WordPress/gutenberg/pull/84824)). +- `Avatar`: Explore CSS-only colored fallbacks with native contrast and contrast-limited foreground tinting, targeting 4.5:1 for text and 3:1 for the person icon and retaining neutral fallbacks when the required features are unavailable. Add best-effort development warnings for unsupported colors and use the weak neutral content token for neutral initials ([#84824](https://github.com/WordPress/gutenberg/pull/84824)). - `Avatar`: Add an optional `color` prop for an internal ring, preserving the avatar's layout size and neutral fallbacks while leaving room for an external focus ring ([#84822](https://github.com/WordPress/gutenberg/pull/84822)). - `SelectControl`: Add a `trigger` slot and `SelectControl.Trigger` subcomponent for customizing trigger props ([#84828](https://github.com/WordPress/gutenberg/pull/84828)). - `Link`: Use a small border radius for consistent focus rings in the default variant ([#84697](https://github.com/WordPress/gutenberg/pull/84697)). diff --git a/packages/ui/src/avatar/stories/fallback-colors.module.css b/packages/ui/src/avatar/stories/fallback-colors.module.css index d3cba00aad8cf6..35c5125a746b26 100644 --- a/packages/ui/src/avatar/stories/fallback-colors.module.css +++ b/packages/ui/src/avatar/stories/fallback-colors.module.css @@ -49,10 +49,10 @@ } @supports (color: color(from green srgb-linear round(up, r) g b / 1)) { - .uncapped { - /* Keep the component's 4.55:1 calculation and remove only its 20% blend cap. */ - --_wp-ui-avatar-dark-tint: clamp(0, calc(((var(--_wp-ui-avatar-luminance) + 0.05) / 4.55 - 0.05) / max(0.000001, var(--_wp-ui-avatar-luminance))), 1); - --_wp-ui-avatar-light-tint: clamp(0, calc((1.05 - 4.55 * (var(--_wp-ui-avatar-luminance) + 0.05)) / max(0.000001, 1 - var(--_wp-ui-avatar-luminance))), 1); + .uncapped, + .uncapped > svg { + /* Keep each contrast target and remove only the 20% blend cap. */ + --_wp-ui-avatar-tint-limit: 1; } } } diff --git a/packages/ui/src/avatar/stories/index.story.tsx b/packages/ui/src/avatar/stories/index.story.tsx index 42ffb203654f4c..6a54d2a1e6a2a2 100644 --- a/packages/ui/src/avatar/stories/index.story.tsx +++ b/packages/ui/src/avatar/stories/index.story.tsx @@ -142,9 +142,11 @@ export const FallbackColors: Story = { allows.

- Both tints target 4.55:1 contrast. The capped tint blends at - most 20% of the background into black or white. The uncapped - tint uses the full blend that retains the target contrast. + Both tints target 4.55:1 contrast for initials and 3.05:1 + for the person icon, with a small margin above 4.5:1 and + 3:1. The capped tint blends at most 20% of the background + into black or white. The uncapped tint uses the full blend + that retains the target contrast.

Colored fallbacks do not support currentColor, diff --git a/packages/ui/src/avatar/style.module.css b/packages/ui/src/avatar/style.module.css index d6f8bf0dc0d27c..ea9da3c1aa77e0 100644 --- a/packages/ui/src/avatar/style.module.css +++ b/packages/ui/src/avatar/style.module.css @@ -136,17 +136,29 @@ @supports (color: color(from green srgb-linear round(up, r) g b / 1)) { .has-color > .fallback { + --_wp-ui-avatar-contrast-target: 4.55; + } + + .has-color > .fallback > .person-icon { + --_wp-ui-avatar-contrast-target: 3.05; + } + + .has-color > .fallback, + .has-color > .fallback > .person-icon { + --_wp-ui-avatar-tint-limit: 0.2; + /* These channel expressions resolve inside the relative color below. */ --_wp-ui-avatar-luminance: calc(0.2126 * r + 0.7152 * g + 0.0722 * b); /* The black/white crossover from the WCAG contrast formula is (sqrt(21) - 1) / 20. */ --_wp-ui-avatar-light-foreground: round(up, clamp(0, calc(0.1791287847 - var(--_wp-ui-avatar-luminance)), 1)); /* * Solve the WCAG ratio for the maximum mix toward the background. - * Target 4.55:1 for a small rounding margin, with tint capped at 20%. + * Targets include a small rounding margin above 4.5:1 for text and 3:1 for the person icon. + * Cap the tint at 20%. * Denominator floors avoid division by zero for black and white. */ - --_wp-ui-avatar-dark-tint: clamp(0, calc(((var(--_wp-ui-avatar-luminance) + 0.05) / 4.55 - 0.05) / max(0.000001, var(--_wp-ui-avatar-luminance))), 0.2); - --_wp-ui-avatar-light-tint: clamp(0, calc((1.05 - 4.55 * (var(--_wp-ui-avatar-luminance) + 0.05)) / max(0.000001, 1 - var(--_wp-ui-avatar-luminance))), 0.2); + --_wp-ui-avatar-dark-tint: clamp(0, calc(((var(--_wp-ui-avatar-luminance) + 0.05) / var(--_wp-ui-avatar-contrast-target) - 0.05) / max(0.000001, var(--_wp-ui-avatar-luminance))), var(--_wp-ui-avatar-tint-limit)); + --_wp-ui-avatar-light-tint: clamp(0, calc((1.05 - var(--_wp-ui-avatar-contrast-target) * (var(--_wp-ui-avatar-luminance) + 0.05)) / max(0.000001, 1 - var(--_wp-ui-avatar-luminance))), var(--_wp-ui-avatar-tint-limit)); --_wp-ui-avatar-tint: calc(var(--_wp-ui-avatar-light-foreground) * var(--_wp-ui-avatar-light-tint) + (1 - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-dark-tint)); /* Linear sRGB mixing keeps the resulting luminance within the calculated bounds. */ diff --git a/packages/ui/src/avatar/types.ts b/packages/ui/src/avatar/types.ts index 6c8d4e752d0b25..498aa0881b4815 100644 --- a/packages/ui/src/avatar/types.ts +++ b/packages/ui/src/avatar/types.ts @@ -11,6 +11,8 @@ export type RootProps = Omit< * an opaque sRGB fill derived from this color and a contrasting foreground. * Other browsers retain the neutral fallback. Images keep their own colors. * The ring paints over the avatar's inner edge without changing its layout size. + * Foreground tinting targets at least 4.5:1 contrast for text and 3:1 for + * the default person icon when the fill resolves independently. * * The colored fallback's fill must resolve independently of its foreground. * `currentColor`, including through CSS variables or color functions, is From 19426bfd00ea151b9198d839cc15269c0b3151a6 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 16 Oct 2026 22:01:15 +0200 Subject: [PATCH 09/21] Avatar: Use uncapped tinting and simplify color stories --- packages/ui/CHANGELOG.md | 2 +- .../avatar/stories/fallback-colors.module.css | 48 ---- .../ui/src/avatar/stories/index.story.tsx | 212 ++++++++---------- packages/ui/src/avatar/style.module.css | 53 ++--- 4 files changed, 118 insertions(+), 197 deletions(-) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 52fc6a8b6c8a53..df1dcc5685eb29 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -19,7 +19,7 @@ ### Enhancements -- `Avatar`: Explore CSS-only colored fallbacks with native contrast and contrast-limited foreground tinting, targeting 4.5:1 for text and 3:1 for the person icon and retaining neutral fallbacks when the required features are unavailable. Add best-effort development warnings for unsupported colors and use the weak neutral content token for neutral initials ([#84824](https://github.com/WordPress/gutenberg/pull/84824)). +- `Avatar`: Add CSS-only colored fallbacks with contrast-limited foreground tinting, targeting 4.5:1 for text and 3:1 for the person icon and retaining neutral fallbacks when the required features are unavailable. Add best-effort development warnings for unsupported colors and use the weak neutral content token for neutral initials ([#84824](https://github.com/WordPress/gutenberg/pull/84824)). - `Avatar`: Add an optional `color` prop for an internal ring, preserving the avatar's layout size and neutral fallbacks while leaving room for an external focus ring ([#84822](https://github.com/WordPress/gutenberg/pull/84822)). - `SelectControl`: Add a `trigger` slot and `SelectControl.Trigger` subcomponent for customizing trigger props ([#84828](https://github.com/WordPress/gutenberg/pull/84828)). - `Link`: Use a small border radius for consistent focus rings in the default variant ([#84697](https://github.com/WordPress/gutenberg/pull/84697)). diff --git a/packages/ui/src/avatar/stories/fallback-colors.module.css b/packages/ui/src/avatar/stories/fallback-colors.module.css index 35c5125a746b26..8e8cf430dacc97 100644 --- a/packages/ui/src/avatar/stories/fallback-colors.module.css +++ b/packages/ui/src/avatar/stories/fallback-colors.module.css @@ -21,56 +21,8 @@ } } - .neutral { - background-color: var(--wpds-color-background-surface-neutral-weak); - color: var(--wpds-color-foreground-content-neutral-weak); - - > svg { - color: var(--wpds-color-stroke-surface-neutral-strong); - } - } - .unsupported { --_wp-ui-avatar-unsupported-color: currentColor; } - - /* Explicit comparison treatments; the capped tint uses the component defaults. */ - @media not (forced-colors: active) { - @supports (color: contrast-color(green)) and (color: rgb(from green r g b / 1)) { - .native { - --_wp-ui-avatar-exploration-background: rgb(from var(--_wp-ui-avatar-exploration-color) clamp(0, r, 255) clamp(0, g, 255) clamp(0, b, 255) / 1); - - background-color: var(--_wp-ui-avatar-exploration-background); - color: contrast-color(var(--_wp-ui-avatar-exploration-background)); - - > svg { - color: inherit; - } - } - - @supports (color: color(from green srgb-linear round(up, r) g b / 1)) { - .uncapped, - .uncapped > svg { - /* Keep each contrast target and remove only the 20% blend cap. */ - --_wp-ui-avatar-tint-limit: 1; - } - } - } - - @supports (color: color(from green srgb-linear round(up, r) g b / 1)) { - .wcag { - --_wp-ui-avatar-exploration-background: rgb(from var(--_wp-ui-avatar-exploration-color) clamp(0, r, 255) clamp(0, g, 255) clamp(0, b, 255) / 1); - --_wp-ui-avatar-exploration-luminance: calc(0.2126 * r + 0.7152 * g + 0.0722 * b); - --_wp-ui-avatar-exploration-light: round(up, clamp(0, calc(0.1791287847 - var(--_wp-ui-avatar-exploration-luminance)), 1)); - - background-color: var(--_wp-ui-avatar-exploration-background); - color: color(from var(--_wp-ui-avatar-exploration-background) srgb-linear var(--_wp-ui-avatar-exploration-light) var(--_wp-ui-avatar-exploration-light) var(--_wp-ui-avatar-exploration-light) / 1); - - > svg { - color: inherit; - } - } - } - } } } diff --git a/packages/ui/src/avatar/stories/index.story.tsx b/packages/ui/src/avatar/stories/index.story.tsx index 6a54d2a1e6a2a2..5e905108309829 100644 --- a/packages/ui/src/avatar/stories/index.story.tsx +++ b/packages/ui/src/avatar/stories/index.story.tsx @@ -4,7 +4,7 @@ import * as Avatar from '../index'; import { Button } from '../../button'; import { Stack } from '../../stack'; import { Text } from '../../text'; -import explorationStyles from './fallback-colors.module.css'; +import styles from './fallback-colors.module.css'; // Profile illustration by Masantocreative: https://unsplash.com/illustrations/someone-is-thinking-and-strategizing-with-their-brain-1L--AMFSThk // Used under the Unsplash License: https://unsplash.com/license @@ -111,116 +111,108 @@ export const Interactive: Story = { ), }; -/** Compare neutral, black/white, capped tint, and uncapped tint fallbacks. */ +function FallbackSamples( props: React.ComponentProps< typeof Avatar.Root > ) { + return ( + + { ( [ 'circle', 'square' ] as const ).map( ( shape ) => ( + + + + + ) ) } + + ); +} + +/** Colored initials and person fallbacks across hues, saturation, and lightness. */ export const FallbackColors: Story = { - args: { color: '#008a20', size: 'lg' }, + args: { size: 'lg' }, + parameters: { controls: { include: [ 'size' ] } }, render: ( args ) => { - const colors = [ - [ 'Custom color', args.color ?? '#008a20' ], - [ 'Bright green', '#00e050' ], - [ 'Dark green', '#064e3b' ], - [ 'Gray below crossover', '#757575' ], - [ 'Gray above crossover', '#767676' ], - [ 'Midtone gray', '#777777' ], - [ 'Yellow', '#fbbf24' ], - [ 'Cyan', '#00cfff' ], - [ 'Theme color', 'var(--wpds-color-stroke-surface-brand)' ], - ]; - const approaches = [ - [ 'Neutral', 'neutral' ], - [ 'Native black/white', 'native' ], - [ 'WCAG black/white', 'wcag' ], - [ 'Tint capped at 20%', 'tinted' ], - [ 'Uncapped tint', 'uncapped' ], - ]; + const hues = [ + [ 'Red', 0 ], + [ 'Orange', 30 ], + [ 'Yellow', 60 ], + [ 'Lime', 90 ], + [ 'Green', 120 ], + [ 'Mint', 150 ], + [ 'Cyan', 180 ], + [ 'Azure', 210 ], + [ 'Blue', 240 ], + [ 'Violet', 270 ], + [ 'Magenta', 300 ], + [ 'Rose', 330 ], + ] as const; + const variants = [ + [ 'Pastel', 70, 85 ], + [ 'Muted', 25, 50 ], + [ 'Vivid', 90, 50 ], + [ 'Deep', 65, 20 ], + ] as const; return ( -

-

- Change the custom color, size, and shape in Controls. Filled - fallbacks use opaque sRGB colors. Both tinted columns fall - back to native black/white, then neutral, as feature support - allows. -

-

- Both tints target 4.55:1 contrast for initials and 3.05:1 - for the person icon, with a small margin above 4.5:1 and - 3:1. The capped tint blends at most 20% of the background - into black or white. The uncapped tint uses the full blend - that retains the target contrast. -

+

- Colored fallbacks do not support currentColor, - including through CSS variables or color functions. Their - fill must resolve independently of the foreground. - Transparent colors become opaque, and out-of-gamut channels - are clipped to sRGB. + Each cell shows circle and square fallbacks, with initials + followed by the person icon for each shape. Column + percentages are HSL saturation / lightness. Change the size + in Controls, or use With Color to try a custom color.

- Development warnings are best-effort. They do not resolve - CSS variables or detect every unsupported expression. The - absence of a warning does not guarantee sufficient contrast. - See Unsupported Colors for examples. + Foregrounds blend toward the background until they reach + 4.55:1 contrast for initials or 3.05:1 for the person icon. + These targets include a small margin above 4.5:1 and 3:1. + Browsers without the required color math retain neutral + fallbacks. See Unsupported Colors for input limitations and + the limits of best-effort development warnings.

- - { approaches.map( ( [ label ] ) => ( - - ) ) } + + { variants.map( + ( [ label, saturation, lightness ] ) => ( + + ) + ) } - { colors.map( ( [ label, color ] ) => ( + { hues.map( ( [ label, hue ] ) => ( - { approaches.map( ( [ approach, variant ] ) => ( - - ) ) } + color={ `hsl(${ hue } ${ saturation }% ${ lightness }%)` } + /> + + ) + ) } ) ) } @@ -234,6 +226,7 @@ export const FallbackColors: Story = { export const UnsupportedColors: Story = { args: { size: 'lg' }, parameters: { + controls: { include: [ 'size' ] }, // This demonstration intentionally reproduces insufficient contrast. // Keep the accessibility findings visible without failing other stories. a11y: { test: 'todo' }, @@ -265,15 +258,16 @@ export const UnsupportedColors: Story = { ]; return (

- These examples intentionally use unsupported colors. A fill - that depends on its foreground can lose contrast. Invalid - colors and missing variables can remove the fill. + These examples intentionally use unsupported colors in both + shapes. A fill that depends on its foreground can lose + contrast. Invalid colors and missing variables can remove + the fill.

In supporting browsers, development builds warn about @@ -299,22 +293,10 @@ export const UnsupportedColors: Story = { { color }

diff --git a/packages/ui/src/avatar/style.module.css b/packages/ui/src/avatar/style.module.css index ea9da3c1aa77e0..86cd44f330db88 100644 --- a/packages/ui/src/avatar/style.module.css +++ b/packages/ui/src/avatar/style.module.css @@ -121,49 +121,36 @@ } @media not (forced-colors: active) { - @supports (color: contrast-color(green)) and (color: rgb(from green r g b / 1)) { + @supports (color: rgb(from green r g b / 1)) and (color: color(from green srgb-linear round(up, r) g b / 1)) { .has-color > .fallback { /* Use an opaque sRGB fill so contrast does not depend on the surrounding surface. */ --_wp-ui-avatar-background: rgb(from var(--_wp-ui-avatar-color) clamp(0, r, 255) clamp(0, g, 255) clamp(0, b, 255) / 1); + --_wp-ui-avatar-contrast-target: 4.55; background-color: var(--_wp-ui-avatar-background); - color: contrast-color(var(--_wp-ui-avatar-background)); } .has-color > .fallback > .person-icon { - color: inherit; + --_wp-ui-avatar-contrast-target: 3.05; } - @supports (color: color(from green srgb-linear round(up, r) g b / 1)) { - .has-color > .fallback { - --_wp-ui-avatar-contrast-target: 4.55; - } - - .has-color > .fallback > .person-icon { - --_wp-ui-avatar-contrast-target: 3.05; - } - - .has-color > .fallback, - .has-color > .fallback > .person-icon { - --_wp-ui-avatar-tint-limit: 0.2; - - /* These channel expressions resolve inside the relative color below. */ - --_wp-ui-avatar-luminance: calc(0.2126 * r + 0.7152 * g + 0.0722 * b); - /* The black/white crossover from the WCAG contrast formula is (sqrt(21) - 1) / 20. */ - --_wp-ui-avatar-light-foreground: round(up, clamp(0, calc(0.1791287847 - var(--_wp-ui-avatar-luminance)), 1)); - /* - * Solve the WCAG ratio for the maximum mix toward the background. - * Targets include a small rounding margin above 4.5:1 for text and 3:1 for the person icon. - * Cap the tint at 20%. - * Denominator floors avoid division by zero for black and white. - */ - --_wp-ui-avatar-dark-tint: clamp(0, calc(((var(--_wp-ui-avatar-luminance) + 0.05) / var(--_wp-ui-avatar-contrast-target) - 0.05) / max(0.000001, var(--_wp-ui-avatar-luminance))), var(--_wp-ui-avatar-tint-limit)); - --_wp-ui-avatar-light-tint: clamp(0, calc((1.05 - var(--_wp-ui-avatar-contrast-target) * (var(--_wp-ui-avatar-luminance) + 0.05)) / max(0.000001, 1 - var(--_wp-ui-avatar-luminance))), var(--_wp-ui-avatar-tint-limit)); - --_wp-ui-avatar-tint: calc(var(--_wp-ui-avatar-light-foreground) * var(--_wp-ui-avatar-light-tint) + (1 - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-dark-tint)); - - /* Linear sRGB mixing keeps the resulting luminance within the calculated bounds. */ - color: color(from var(--_wp-ui-avatar-background) srgb-linear calc(var(--_wp-ui-avatar-light-foreground) + (r - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-tint)) calc(var(--_wp-ui-avatar-light-foreground) + (g - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-tint)) calc(var(--_wp-ui-avatar-light-foreground) + (b - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-tint)) / 1); - } + .has-color > .fallback, + .has-color > .fallback > .person-icon { + /* These channel expressions resolve inside the relative color below. */ + --_wp-ui-avatar-luminance: calc(0.2126 * r + 0.7152 * g + 0.0722 * b); + /* The black/white crossover from the WCAG contrast formula is (sqrt(21) - 1) / 20. */ + --_wp-ui-avatar-light-foreground: round(up, clamp(0, calc(0.1791287847 - var(--_wp-ui-avatar-luminance)), 1)); + /* + * Solve the WCAG ratio for the maximum mix toward the background. + * Targets include a small rounding margin above 4.5:1 for text and 3:1 for the person icon. + * Denominator floors avoid division by zero for black and white. + */ + --_wp-ui-avatar-dark-tint: clamp(0, calc(((var(--_wp-ui-avatar-luminance) + 0.05) / var(--_wp-ui-avatar-contrast-target) - 0.05) / max(0.000001, var(--_wp-ui-avatar-luminance))), 1); + --_wp-ui-avatar-light-tint: clamp(0, calc((1.05 - var(--_wp-ui-avatar-contrast-target) * (var(--_wp-ui-avatar-luminance) + 0.05)) / max(0.000001, 1 - var(--_wp-ui-avatar-luminance))), 1); + --_wp-ui-avatar-tint: calc(var(--_wp-ui-avatar-light-foreground) * var(--_wp-ui-avatar-light-tint) + (1 - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-dark-tint)); + + /* Linear sRGB mixing keeps the resulting luminance within the calculated bounds. */ + color: color(from var(--_wp-ui-avatar-background) srgb-linear calc(var(--_wp-ui-avatar-light-foreground) + (r - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-tint)) calc(var(--_wp-ui-avatar-light-foreground) + (g - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-tint)) calc(var(--_wp-ui-avatar-light-foreground) + (b - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-tint)) / 1); } } } From 0829e9b9343b915a48add391b8298aef88a7ca47 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 16 Oct 2026 22:17:16 +0200 Subject: [PATCH 10/21] Avatar: Match fallback borders to the person icon tint --- packages/ui/CHANGELOG.md | 2 +- packages/ui/src/avatar/stories/index.story.tsx | 7 ++++--- packages/ui/src/avatar/style.module.css | 14 ++++++++++++-- packages/ui/src/avatar/types.ts | 7 +++++-- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index df1dcc5685eb29..8ec599773b1224 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -19,7 +19,7 @@ ### Enhancements -- `Avatar`: Add CSS-only colored fallbacks with contrast-limited foreground tinting, targeting 4.5:1 for text and 3:1 for the person icon and retaining neutral fallbacks when the required features are unavailable. Add best-effort development warnings for unsupported colors and use the weak neutral content token for neutral initials ([#84824](https://github.com/WordPress/gutenberg/pull/84824)). +- `Avatar`: Add CSS-only colored fallbacks with contrast-limited foreground tinting, targeting 4.5:1 for text and 3:1 for the person icon and fallback ring against the fill, and retaining neutral fallbacks when the required features are unavailable. Add best-effort development warnings for unsupported colors and use the weak neutral content token for neutral initials ([#84824](https://github.com/WordPress/gutenberg/pull/84824)). - `Avatar`: Add an optional `color` prop for an internal ring, preserving the avatar's layout size and neutral fallbacks while leaving room for an external focus ring ([#84822](https://github.com/WordPress/gutenberg/pull/84822)). - `SelectControl`: Add a `trigger` slot and `SelectControl.Trigger` subcomponent for customizing trigger props ([#84828](https://github.com/WordPress/gutenberg/pull/84828)). - `Link`: Use a small border radius for consistent focus rings in the default variant ([#84697](https://github.com/WordPress/gutenberg/pull/84697)). diff --git a/packages/ui/src/avatar/stories/index.story.tsx b/packages/ui/src/avatar/stories/index.story.tsx index 5e905108309829..50657a663abf7f 100644 --- a/packages/ui/src/avatar/stories/index.story.tsx +++ b/packages/ui/src/avatar/stories/index.story.tsx @@ -48,7 +48,7 @@ type Story = StoryObj< typeof Avatar.Root >; export const Default: Story = {}; -/** The color ring preserves loaded images and colors their fallback content. */ +/** Loaded images keep the supplied ring color. Fallback rings use the person icon's tint. */ export const WithColor: Story = { args: { color: '#008a20' }, render: ( args ) => ( @@ -67,7 +67,7 @@ export const WithColor: Story = { ), }; -/** Keyboard focus stays outside the avatar, even when both rings have the same color. */ +/** Keyboard focus stays outside the avatar, even when the fill matches the focus color. */ export const Interactive: Story = { args: { color: '#008a20' }, render: ( args ) => ( @@ -80,7 +80,7 @@ export const Interactive: Story = { }, { name: 'Sam Rivera', - label: 'Matches focus color', + label: 'Fill matches focus color', color: 'var(--wpds-color-stroke-focus)', }, ].map( ( { name, label, color } ) => ( @@ -178,6 +178,7 @@ export const FallbackColors: Story = { Foregrounds blend toward the background until they reach 4.55:1 contrast for initials or 3.05:1 for the person icon. These targets include a small margin above 4.5:1 and 3:1. + Fallback rings use the same tint as the person icon. Browsers without the required color math retain neutral fallbacks. See Unsupported Colors for input limitations and the limits of best-effort development warnings. diff --git a/packages/ui/src/avatar/style.module.css b/packages/ui/src/avatar/style.module.css index 86cd44f330db88..9e537f85d37036 100644 --- a/packages/ui/src/avatar/style.module.css +++ b/packages/ui/src/avatar/style.module.css @@ -122,18 +122,28 @@ @media not (forced-colors: active) { @supports (color: rgb(from green r g b / 1)) and (color: color(from green srgb-linear round(up, r) g b / 1)) { - .has-color > .fallback { + .has-color { /* Use an opaque sRGB fill so contrast does not depend on the surrounding surface. */ --_wp-ui-avatar-background: rgb(from var(--_wp-ui-avatar-color) clamp(0, r, 255) clamp(0, g, 255) clamp(0, b, 255) / 1); + } + + .has-color > .fallback { --_wp-ui-avatar-contrast-target: 4.55; background-color: var(--_wp-ui-avatar-background); } + .has-color:has(> .fallback)::after, .has-color > .fallback > .person-icon { --_wp-ui-avatar-contrast-target: 3.05; } + .has-color:has(> .fallback)::after { + /* Join the cropped person strokes to the ring; loaded images keep the supplied ring color. */ + border-color: currentColor; + } + + .has-color:has(> .fallback)::after, .has-color > .fallback, .has-color > .fallback > .person-icon { /* These channel expressions resolve inside the relative color below. */ @@ -142,7 +152,7 @@ --_wp-ui-avatar-light-foreground: round(up, clamp(0, calc(0.1791287847 - var(--_wp-ui-avatar-luminance)), 1)); /* * Solve the WCAG ratio for the maximum mix toward the background. - * Targets include a small rounding margin above 4.5:1 for text and 3:1 for the person icon. + * Targets include a small rounding margin above 4.5:1 for text and 3:1 for the person icon and fallback ring. * Denominator floors avoid division by zero for black and white. */ --_wp-ui-avatar-dark-tint: clamp(0, calc(((var(--_wp-ui-avatar-luminance) + 0.05) / var(--_wp-ui-avatar-contrast-target) - 0.05) / max(0.000001, var(--_wp-ui-avatar-luminance))), 1); diff --git a/packages/ui/src/avatar/types.ts b/packages/ui/src/avatar/types.ts index 498aa0881b4815..7a9bc0d5678956 100644 --- a/packages/ui/src/avatar/types.ts +++ b/packages/ui/src/avatar/types.ts @@ -9,10 +9,13 @@ export type RootProps = Omit< * The color of an optional ring around the avatar. Accepts any CSS color, * including CSS variables. Without an image, supporting browsers also use * an opaque sRGB fill derived from this color and a contrasting foreground. - * Other browsers retain the neutral fallback. Images keep their own colors. + * The fallback ring uses the same tint as the default person icon. + * Other browsers retain the neutral fallback. Images keep their own colors + * and the supplied ring color. * The ring paints over the avatar's inner edge without changing its layout size. * Foreground tinting targets at least 4.5:1 contrast for text and 3:1 for - * the default person icon when the fill resolves independently. + * the default person icon and fallback ring against the fill when it resolves + * independently. The decorative ring has no contrast target against the page. * * The colored fallback's fill must resolve independently of its foreground. * `currentColor`, including through CSS variables or color functions, is From ad2bbab6356c2e26028299e3664a4ca184d6fa0a Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 16 Oct 2026 22:23:50 +0200 Subject: [PATCH 11/21] Avatar: Remove borders from colored fallbacks --- packages/ui/CHANGELOG.md | 2 +- .../ui/src/avatar/stories/index.story.tsx | 11 +++++----- packages/ui/src/avatar/style.module.css | 21 +++++++++---------- packages/ui/src/avatar/types.ts | 5 ++--- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 8ec599773b1224..b3cd830c5c2042 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -19,7 +19,7 @@ ### Enhancements -- `Avatar`: Add CSS-only colored fallbacks with contrast-limited foreground tinting, targeting 4.5:1 for text and 3:1 for the person icon and fallback ring against the fill, and retaining neutral fallbacks when the required features are unavailable. Add best-effort development warnings for unsupported colors and use the weak neutral content token for neutral initials ([#84824](https://github.com/WordPress/gutenberg/pull/84824)). +- `Avatar`: Add CSS-only colored fallbacks with contrast-limited foreground tinting, targeting 4.5:1 for text and 3:1 for the person icon. Colored fallbacks have no border outside forced-colors mode, and browsers without the required features retain neutral fallbacks. Add best-effort development warnings for unsupported colors and use the weak neutral content token for neutral initials ([#84824](https://github.com/WordPress/gutenberg/pull/84824)). - `Avatar`: Add an optional `color` prop for an internal ring, preserving the avatar's layout size and neutral fallbacks while leaving room for an external focus ring ([#84822](https://github.com/WordPress/gutenberg/pull/84822)). - `SelectControl`: Add a `trigger` slot and `SelectControl.Trigger` subcomponent for customizing trigger props ([#84828](https://github.com/WordPress/gutenberg/pull/84828)). - `Link`: Use a small border radius for consistent focus rings in the default variant ([#84697](https://github.com/WordPress/gutenberg/pull/84697)). diff --git a/packages/ui/src/avatar/stories/index.story.tsx b/packages/ui/src/avatar/stories/index.story.tsx index 50657a663abf7f..aa5c82123f7be4 100644 --- a/packages/ui/src/avatar/stories/index.story.tsx +++ b/packages/ui/src/avatar/stories/index.story.tsx @@ -48,7 +48,7 @@ type Story = StoryObj< typeof Avatar.Root >; export const Default: Story = {}; -/** Loaded images keep the supplied ring color. Fallback rings use the person icon's tint. */ +/** Loaded images keep the supplied ring color. Colored fallbacks have no border. */ export const WithColor: Story = { args: { color: '#008a20' }, render: ( args ) => ( @@ -178,10 +178,11 @@ export const FallbackColors: Story = { Foregrounds blend toward the background until they reach 4.55:1 contrast for initials or 3.05:1 for the person icon. These targets include a small margin above 4.5:1 and 3:1. - Fallback rings use the same tint as the person icon. - Browsers without the required color math retain neutral - fallbacks. See Unsupported Colors for input limitations and - the limits of best-effort development warnings. + Colored fallbacks have no border. Forced-colors mode keeps a + visible border. Browsers without the required color math + retain neutral fallbacks. See Unsupported Colors for input + limitations and the limits of best-effort development + warnings.

Color - { label } - Hue + { label } +
+ + { saturation }% / { lightness }% + +
{ label } - - - ( + +
- - - - + { explanation }
diff --git a/packages/ui/src/avatar/style.module.css b/packages/ui/src/avatar/style.module.css index 9e537f85d37036..b69f3d68df61ed 100644 --- a/packages/ui/src/avatar/style.module.css +++ b/packages/ui/src/avatar/style.module.css @@ -122,28 +122,27 @@ @media not (forced-colors: active) { @supports (color: rgb(from green r g b / 1)) and (color: color(from green srgb-linear round(up, r) g b / 1)) { - .has-color { - /* Use an opaque sRGB fill so contrast does not depend on the surrounding surface. */ - --_wp-ui-avatar-background: rgb(from var(--_wp-ui-avatar-color) clamp(0, r, 255) clamp(0, g, 255) clamp(0, b, 255) / 1); + .has-color:has(> .fallback) { + /* Let the colored fallback fill the frame, including the cropped person strokes. */ + border: none; + } + + .has-color:has(> .fallback)::after { + content: none; } .has-color > .fallback { + /* Use an opaque sRGB fill so contrast does not depend on the surrounding surface. */ + --_wp-ui-avatar-background: rgb(from var(--_wp-ui-avatar-color) clamp(0, r, 255) clamp(0, g, 255) clamp(0, b, 255) / 1); --_wp-ui-avatar-contrast-target: 4.55; background-color: var(--_wp-ui-avatar-background); } - .has-color:has(> .fallback)::after, .has-color > .fallback > .person-icon { --_wp-ui-avatar-contrast-target: 3.05; } - .has-color:has(> .fallback)::after { - /* Join the cropped person strokes to the ring; loaded images keep the supplied ring color. */ - border-color: currentColor; - } - - .has-color:has(> .fallback)::after, .has-color > .fallback, .has-color > .fallback > .person-icon { /* These channel expressions resolve inside the relative color below. */ @@ -152,7 +151,7 @@ --_wp-ui-avatar-light-foreground: round(up, clamp(0, calc(0.1791287847 - var(--_wp-ui-avatar-luminance)), 1)); /* * Solve the WCAG ratio for the maximum mix toward the background. - * Targets include a small rounding margin above 4.5:1 for text and 3:1 for the person icon and fallback ring. + * Targets include a small rounding margin above 4.5:1 for text and 3:1 for the person icon. * Denominator floors avoid division by zero for black and white. */ --_wp-ui-avatar-dark-tint: clamp(0, calc(((var(--_wp-ui-avatar-luminance) + 0.05) / var(--_wp-ui-avatar-contrast-target) - 0.05) / max(0.000001, var(--_wp-ui-avatar-luminance))), 1); diff --git a/packages/ui/src/avatar/types.ts b/packages/ui/src/avatar/types.ts index 7a9bc0d5678956..5741e0e0f65438 100644 --- a/packages/ui/src/avatar/types.ts +++ b/packages/ui/src/avatar/types.ts @@ -9,13 +9,12 @@ export type RootProps = Omit< * The color of an optional ring around the avatar. Accepts any CSS color, * including CSS variables. Without an image, supporting browsers also use * an opaque sRGB fill derived from this color and a contrasting foreground. - * The fallback ring uses the same tint as the default person icon. + * Colored fallbacks have no border. Forced-colors mode retains a visible border. * Other browsers retain the neutral fallback. Images keep their own colors * and the supplied ring color. * The ring paints over the avatar's inner edge without changing its layout size. * Foreground tinting targets at least 4.5:1 contrast for text and 3:1 for - * the default person icon and fallback ring against the fill when it resolves - * independently. The decorative ring has no contrast target against the page. + * the default person icon when the fill resolves independently. * * The colored fallback's fill must resolve independently of its foreground. * `currentColor`, including through CSS variables or color functions, is From 7fca70e4f9be5225cecf06deddca771d97cd47b8 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 16 Oct 2026 22:35:48 +0200 Subject: [PATCH 12/21] Avatar: Consolidate colored examples in Storybook --- ...ck-colors.module.css => colors.module.css} | 6 + .../ui/src/avatar/stories/index.story.tsx | 197 +++++++++--------- 2 files changed, 105 insertions(+), 98 deletions(-) rename packages/ui/src/avatar/stories/{fallback-colors.module.css => colors.module.css} (80%) diff --git a/packages/ui/src/avatar/stories/fallback-colors.module.css b/packages/ui/src/avatar/stories/colors.module.css similarity index 80% rename from packages/ui/src/avatar/stories/fallback-colors.module.css rename to packages/ui/src/avatar/stories/colors.module.css index 8e8cf430dacc97..595647dd545025 100644 --- a/packages/ui/src/avatar/stories/fallback-colors.module.css +++ b/packages/ui/src/avatar/stories/colors.module.css @@ -13,6 +13,12 @@ border-collapse: collapse; } + tbody + tbody { + border-block-start: + var(--wpds-border-width-xs) solid + var(--wpds-color-stroke-surface-neutral); + } + th, td { padding: var(--wpds-dimension-padding-lg); diff --git a/packages/ui/src/avatar/stories/index.story.tsx b/packages/ui/src/avatar/stories/index.story.tsx index aa5c82123f7be4..d1513dd8346b32 100644 --- a/packages/ui/src/avatar/stories/index.story.tsx +++ b/packages/ui/src/avatar/stories/index.story.tsx @@ -4,7 +4,7 @@ import * as Avatar from '../index'; import { Button } from '../../button'; import { Stack } from '../../stack'; import { Text } from '../../text'; -import styles from './fallback-colors.module.css'; +import styles from './colors.module.css'; // Profile illustration by Masantocreative: https://unsplash.com/illustrations/someone-is-thinking-and-strategizing-with-their-brain-1L--AMFSThk // Used under the Unsplash License: https://unsplash.com/license @@ -48,23 +48,112 @@ type Story = StoryObj< typeof Avatar.Root >; export const Default: Story = {}; -/** Loaded images keep the supplied ring color. Colored fallbacks have no border. */ -export const WithColor: Story = { - args: { color: '#008a20' }, - render: ( args ) => ( - - +function ColorSamples( props: React.ComponentProps< typeof Avatar.Root > ) { + return ( + + - + - + - ), + ); +} + +/** Compare image rings and colored fallbacks across both shapes and all five sizes. */ +export const WithColor: Story = { + args: { color: '#008a20' }, + parameters: { controls: { include: [ 'color' ] } }, + render: ( args ) => { + const colors = [ + ...new Set( [ + args.color ?? '#008a20', + '#d94145', + '#fbbf24', + '#f3d1ab', + '#00cfff', + '#3858e9', + '#6f42c1', + '#757575', + '#000000', + '#ffffff', + ] ), + ]; + const sizes = [ 'xs', 'sm', 'md', 'lg', 'xl' ] as const; + return ( +
+

+ Each size shows an image, initials, and the person icon. + Both shapes appear for every color. Change Color in Controls + to update the first sample. +

+

+ Initials target at least 4.5:1 contrast and the person icon + 3:1 against the fill. Colored fallbacks have no border; + loaded images keep the supplied color ring. Forced-colors + mode retains a visible border. See Unsupported Colors for + input limitations and best-effort development warnings. +

+
+ + + + + { sizes.map( ( size ) => ( + + ) ) } + + + { colors.map( ( color, index ) => ( + + { ( [ 'circle', 'square' ] as const ).map( + ( shape, shapeIndex ) => ( + + { shapeIndex === 0 && ( + + ) } + + { sizes.map( ( size ) => ( + + ) ) } + + ) + ) } + + ) ) } +
ColorShape + { size } +
+ { index === 0 && ( + <> + Custom +
+ + ) } + { color } +
+ { shape } + + +
+
+ ); + }, }; /** Keyboard focus stays outside the avatar, even when the fill matches the focus color. */ @@ -136,94 +225,6 @@ function FallbackSamples( props: React.ComponentProps< typeof Avatar.Root > ) { ); } -/** Colored initials and person fallbacks across hues, saturation, and lightness. */ -export const FallbackColors: Story = { - args: { size: 'lg' }, - parameters: { controls: { include: [ 'size' ] } }, - render: ( args ) => { - const hues = [ - [ 'Red', 0 ], - [ 'Orange', 30 ], - [ 'Yellow', 60 ], - [ 'Lime', 90 ], - [ 'Green', 120 ], - [ 'Mint', 150 ], - [ 'Cyan', 180 ], - [ 'Azure', 210 ], - [ 'Blue', 240 ], - [ 'Violet', 270 ], - [ 'Magenta', 300 ], - [ 'Rose', 330 ], - ] as const; - const variants = [ - [ 'Pastel', 70, 85 ], - [ 'Muted', 25, 50 ], - [ 'Vivid', 90, 50 ], - [ 'Deep', 65, 20 ], - ] as const; - return ( -
-

- Each cell shows circle and square fallbacks, with initials - followed by the person icon for each shape. Column - percentages are HSL saturation / lightness. Change the size - in Controls, or use With Color to try a custom color. -

-

- Foregrounds blend toward the background until they reach - 4.55:1 contrast for initials or 3.05:1 for the person icon. - These targets include a small margin above 4.5:1 and 3:1. - Colored fallbacks have no border. Forced-colors mode keeps a - visible border. Browsers without the required color math - retain neutral fallbacks. See Unsupported Colors for input - limitations and the limits of best-effort development - warnings. -

- - - - - { variants.map( - ( [ label, saturation, lightness ] ) => ( - - ) - ) } - - - - { hues.map( ( [ label, hue ] ) => ( - - - { variants.map( - ( [ variant, saturation, lightness ] ) => ( - - ) - ) } - - ) ) } - -
Hue - { label } -
- - { saturation }% / { lightness }% - -
{ label } - -
-
- ); - }, -}; - /** Demonstrate unsupported fills and the limits of development warnings. */ export const UnsupportedColors: Story = { args: { size: 'lg' }, From 69c425321516648762616044805ca39c2ac42484 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 16 Oct 2026 22:45:13 +0200 Subject: [PATCH 13/21] Avatar: Simplify colored Storybook examples --- .../ui/src/avatar/stories/colors.module.css | 6 -- .../ui/src/avatar/stories/index.story.tsx | 72 +++++++------------ 2 files changed, 25 insertions(+), 53 deletions(-) diff --git a/packages/ui/src/avatar/stories/colors.module.css b/packages/ui/src/avatar/stories/colors.module.css index 595647dd545025..8e8cf430dacc97 100644 --- a/packages/ui/src/avatar/stories/colors.module.css +++ b/packages/ui/src/avatar/stories/colors.module.css @@ -13,12 +13,6 @@ border-collapse: collapse; } - tbody + tbody { - border-block-start: - var(--wpds-border-width-xs) solid - var(--wpds-color-stroke-surface-neutral); - } - th, td { padding: var(--wpds-dimension-padding-lg); diff --git a/packages/ui/src/avatar/stories/index.story.tsx b/packages/ui/src/avatar/stories/index.story.tsx index d1513dd8346b32..4f18409cb39f8d 100644 --- a/packages/ui/src/avatar/stories/index.story.tsx +++ b/packages/ui/src/avatar/stories/index.story.tsx @@ -65,10 +65,10 @@ function ColorSamples( props: React.ComponentProps< typeof Avatar.Root > ) { ); } -/** Compare image rings and colored fallbacks across both shapes and all five sizes. */ +/** Compare image rings and colored fallbacks with shared size and shape controls. */ export const WithColor: Story = { - args: { color: '#008a20' }, - parameters: { controls: { include: [ 'color' ] } }, + args: { color: '#008a20', shape: 'circle' }, + parameters: { controls: { include: [ 'color', 'size', 'shape' ] } }, render: ( args ) => { const colors = [ ...new Set( [ @@ -84,7 +84,6 @@ export const WithColor: Story = { '#ffffff', ] ), ]; - const sizes = [ 'xs', 'sm', 'md', 'lg', 'xl' ] as const; return (

- Each size shows an image, initials, and the person icon. - Both shapes appear for every color. Change Color in Controls - to update the first sample. + Each color shows an image, initials, and the person icon. + Use Size and Shape in Controls to update every sample. + Change Color to update the first sample.

Initials target at least 4.5:1 contrast and the person icon @@ -108,48 +107,27 @@ export const WithColor: Story = { Color - Shape - { sizes.map( ( size ) => ( - - { size } - - ) ) } + Image / initials / person icon - { colors.map( ( color, index ) => ( - - { ( [ 'circle', 'square' ] as const ).map( - ( shape, shapeIndex ) => ( - - { shapeIndex === 0 && ( - - { index === 0 && ( - <> - Custom -
- - ) } - { color } - - ) } - - { shape } - - { sizes.map( ( size ) => ( - - - - ) ) } - - ) - ) } - - ) ) } + + { colors.map( ( color, index ) => ( + + + { index === 0 && ( + <> + Custom +
+ + ) } + { color } + + + + + + ) ) } +

); From 09465dd2791323f5e7b8d289e67e7c1b1cff9645 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 19 Oct 2026 12:21:56 +0200 Subject: [PATCH 14/21] Avatar: Keep color documentation focused on consumer use --- packages/ui/CHANGELOG.md | 2 +- packages/ui/src/avatar/style.module.css | 10 +++------- packages/ui/src/avatar/types.ts | 23 +++++------------------ 3 files changed, 9 insertions(+), 26 deletions(-) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index b3cd830c5c2042..acd4d70ba3a5f8 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -19,7 +19,7 @@ ### Enhancements -- `Avatar`: Add CSS-only colored fallbacks with contrast-limited foreground tinting, targeting 4.5:1 for text and 3:1 for the person icon. Colored fallbacks have no border outside forced-colors mode, and browsers without the required features retain neutral fallbacks. Add best-effort development warnings for unsupported colors and use the weak neutral content token for neutral initials ([#84824](https://github.com/WordPress/gutenberg/pull/84824)). +- `Avatar`: Use `color` for fallback backgrounds with contrasting text and icons ([#84824](https://github.com/WordPress/gutenberg/pull/84824)). - `Avatar`: Add an optional `color` prop for an internal ring, preserving the avatar's layout size and neutral fallbacks while leaving room for an external focus ring ([#84822](https://github.com/WordPress/gutenberg/pull/84822)). - `SelectControl`: Add a `trigger` slot and `SelectControl.Trigger` subcomponent for customizing trigger props ([#84828](https://github.com/WordPress/gutenberg/pull/84828)). - `Link`: Use a small border radius for consistent focus rings in the default variant ([#84697](https://github.com/WordPress/gutenberg/pull/84697)). diff --git a/packages/ui/src/avatar/style.module.css b/packages/ui/src/avatar/style.module.css index b69f3d68df61ed..ef6d5b6a9aa85c 100644 --- a/packages/ui/src/avatar/style.module.css +++ b/packages/ui/src/avatar/style.module.css @@ -123,7 +123,6 @@ @media not (forced-colors: active) { @supports (color: rgb(from green r g b / 1)) and (color: color(from green srgb-linear round(up, r) g b / 1)) { .has-color:has(> .fallback) { - /* Let the colored fallback fill the frame, including the cropped person strokes. */ border: none; } @@ -132,7 +131,7 @@ } .has-color > .fallback { - /* Use an opaque sRGB fill so contrast does not depend on the surrounding surface. */ + /* Opaque sRGB keeps contrast independent of the surrounding surface. */ --_wp-ui-avatar-background: rgb(from var(--_wp-ui-avatar-color) clamp(0, r, 255) clamp(0, g, 255) clamp(0, b, 255) / 1); --_wp-ui-avatar-contrast-target: 4.55; @@ -145,20 +144,17 @@ .has-color > .fallback, .has-color > .fallback > .person-icon { - /* These channel expressions resolve inside the relative color below. */ --_wp-ui-avatar-luminance: calc(0.2126 * r + 0.7152 * g + 0.0722 * b); /* The black/white crossover from the WCAG contrast formula is (sqrt(21) - 1) / 20. */ --_wp-ui-avatar-light-foreground: round(up, clamp(0, calc(0.1791287847 - var(--_wp-ui-avatar-luminance)), 1)); /* - * Solve the WCAG ratio for the maximum mix toward the background. - * Targets include a small rounding margin above 4.5:1 for text and 3:1 for the person icon. - * Denominator floors avoid division by zero for black and white. + * Solve the WCAG ratio for the mix toward the background, with a rounding margin. + * Denominator floors handle black and white. */ --_wp-ui-avatar-dark-tint: clamp(0, calc(((var(--_wp-ui-avatar-luminance) + 0.05) / var(--_wp-ui-avatar-contrast-target) - 0.05) / max(0.000001, var(--_wp-ui-avatar-luminance))), 1); --_wp-ui-avatar-light-tint: clamp(0, calc((1.05 - var(--_wp-ui-avatar-contrast-target) * (var(--_wp-ui-avatar-luminance) + 0.05)) / max(0.000001, 1 - var(--_wp-ui-avatar-luminance))), 1); --_wp-ui-avatar-tint: calc(var(--_wp-ui-avatar-light-foreground) * var(--_wp-ui-avatar-light-tint) + (1 - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-dark-tint)); - /* Linear sRGB mixing keeps the resulting luminance within the calculated bounds. */ color: color(from var(--_wp-ui-avatar-background) srgb-linear calc(var(--_wp-ui-avatar-light-foreground) + (r - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-tint)) calc(var(--_wp-ui-avatar-light-foreground) + (g - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-tint)) calc(var(--_wp-ui-avatar-light-foreground) + (b - var(--_wp-ui-avatar-light-foreground)) * var(--_wp-ui-avatar-tint)) / 1); } } diff --git a/packages/ui/src/avatar/types.ts b/packages/ui/src/avatar/types.ts index 5741e0e0f65438..0b91dc4a28e289 100644 --- a/packages/ui/src/avatar/types.ts +++ b/packages/ui/src/avatar/types.ts @@ -6,25 +6,12 @@ export type RootProps = Omit< 'color' > & { /** - * The color of an optional ring around the avatar. Accepts any CSS color, - * including CSS variables. Without an image, supporting browsers also use - * an opaque sRGB fill derived from this color and a contrasting foreground. - * Colored fallbacks have no border. Forced-colors mode retains a visible border. - * Other browsers retain the neutral fallback. Images keep their own colors - * and the supplied ring color. - * The ring paints over the avatar's inner edge without changing its layout size. - * Foreground tinting targets at least 4.5:1 contrast for text and 3:1 for - * the default person icon when the fill resolves independently. + * Colors the image's inner ring or the fallback background without changing + * the avatar's size. Fallbacks use an opaque color with a contrasting foreground. * - * The colored fallback's fill must resolve independently of its foreground. - * `currentColor`, including through CSS variables or color functions, is - * unsupported for colored fallbacks and can produce insufficient contrast. - * CSS-wide keywords such as `inherit` cannot be used as fallback fills. - * Alpha is discarded, and out-of-gamut channels are clipped to sRGB. - * - * Development warnings are best-effort. They do not resolve CSS variables - * or detect every unsupported expression, including escaped keywords. - * The absence of a warning does not guarantee sufficient contrast. + * Accepts CSS colors and variables that resolve to a color. Do not use + * `currentColor`, including through variables, or CSS-wide keywords such as + * `inherit`. Browsers without support for colored fallbacks use neutral colors. */ color?: React.CSSProperties[ 'color' ]; /** From 9b112755b8e263a4f42863de5637dc75482acd71 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 19 Oct 2026 12:22:04 +0200 Subject: [PATCH 15/21] Avatar: Reduce stories and cover color behavior in browser tests --- .../ui/src/avatar/stories/colors.module.css | 28 -- .../ui/src/avatar/stories/index.story.tsx | 458 ++---------------- .../avatar/test/dev-warnings.browser.test.tsx | 46 ++ .../ui/src/avatar/test/index.browser.test.tsx | 177 ++++--- 4 files changed, 206 insertions(+), 503 deletions(-) delete mode 100644 packages/ui/src/avatar/stories/colors.module.css create mode 100644 packages/ui/src/avatar/test/dev-warnings.browser.test.tsx diff --git a/packages/ui/src/avatar/stories/colors.module.css b/packages/ui/src/avatar/stories/colors.module.css deleted file mode 100644 index 8e8cf430dacc97..00000000000000 --- a/packages/ui/src/avatar/stories/colors.module.css +++ /dev/null @@ -1,28 +0,0 @@ -@layer wp-ui { - @layer utilities, components, compositions, overrides; - - @layer compositions { - .gallery { - max-inline-size: 1200px; - overflow-x: auto; - font-family: var(--wpds-typography-font-family-body); - font-size: var(--wpds-typography-font-size-sm); - color: var(--wpds-color-foreground-content-neutral); - - table { - border-collapse: collapse; - } - - th, - td { - padding: var(--wpds-dimension-padding-lg); - text-align: start; - white-space: nowrap; - } - } - - .unsupported { - --_wp-ui-avatar-unsupported-color: currentColor; - } - } -} diff --git a/packages/ui/src/avatar/stories/index.story.tsx b/packages/ui/src/avatar/stories/index.story.tsx index 4f18409cb39f8d..2e220a5f3d96ce 100644 --- a/packages/ui/src/avatar/stories/index.story.tsx +++ b/packages/ui/src/avatar/stories/index.story.tsx @@ -1,10 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { forwardRef } from '@wordpress/element'; import * as Avatar from '../index'; import { Button } from '../../button'; import { Stack } from '../../stack'; import { Text } from '../../text'; -import styles from './colors.module.css'; // Profile illustration by Masantocreative: https://unsplash.com/illustrations/someone-is-thinking-and-strategizing-with-their-brain-1L--AMFSThk // Used under the Unsplash License: https://unsplash.com/license @@ -21,6 +19,7 @@ const meta: Meta< typeof Avatar.Root > = { 'Avatar.Initials': Avatar.Initials, }, parameters: { + controls: { include: [ 'color', 'size', 'shape' ] }, componentStatus: { status: 'use-with-caution', whereUsed: 'global', @@ -29,6 +28,7 @@ const meta: Meta< typeof Avatar.Root > = { }, args: { size: 'md', + shape: 'circle', role: 'img', 'aria-label': 'Alex Morgan', }, @@ -48,262 +48,48 @@ type Story = StoryObj< typeof Avatar.Root >; export const Default: Story = {}; -function ColorSamples( props: React.ComponentProps< typeof Avatar.Root > ) { - return ( - -