diff --git a/packages/pretui/components/alert.gts b/packages/pretui/components/alert.gts index 37094adb159..2129aea5462 100644 --- a/packages/pretui/components/alert.gts +++ b/packages/pretui/components/alert.gts @@ -1,6 +1,11 @@ // Pretui — Alert: an inline message with a tone, a title and an optional action. import Component from '@glimmer/component'; import { htmlSafe } from '@ember/template'; +import AlertTriangleIcon from '@cardstack/boxel-icons/alert-triangle'; +import CheckIcon from '@cardstack/boxel-icons/check'; +import InfoIcon from '@cardstack/boxel-icons/info-small'; +import XIcon from '@cardstack/boxel-icons/x'; +import { keepStyle, type KeptProperty } from '../internal/keep-style'; import { resolveTone } from '../pretui-primitives'; import type { PretuiToneArg } from '../pretui-primitives'; import { VisuallyHidden } from './visually-hidden'; @@ -18,17 +23,32 @@ const ALERT_VARIANTS: Record = { default: 'info', destructive: 'danger', }; -const ALERT_HUES: Record = { - info: 'var(--pretui-info)', - success: 'var(--success)', - warning: 'var(--warning)', - danger: 'var(--destructive)', +// Each tone names its fill and the fill's paired foreground (the ink that +// reads on the disc). The text is `--foreground`: the `-ink` tokens are only +// guaranteed on `--background`, `--card` and `--muted`, not on the tint. +const ALERT_COLORS: Record = { + info: { + hue: 'var(--info)', + onHue: 'var(--info-foreground)', + }, + success: { + hue: 'var(--success)', + onHue: 'var(--success-foreground)', + }, + warning: { + hue: 'var(--warning)', + onHue: 'var(--warning-foreground)', + }, + danger: { + hue: 'var(--destructive)', + onHue: 'var(--destructive-foreground)', + }, }; -const ALERT_GLYPHS: Record = { - info: 'i', - success: '✓', - warning: '!', - danger: '✕', +const ALERT_ICONS = { + info: InfoIcon, + success: CheckIcon, + warning: AlertTriangleIcon, + danger: XIcon, }; // The glyph is hidden from assistive technology, so the tone is spoken as a // word instead. Without it, `info`, `success` and `warning` all share @@ -69,20 +89,34 @@ export class Alert extends Component { return this.tone === 'danger' ? 'alert' : 'status'; } get hueStyle() { - return htmlSafe(`--pretui-alert-hue: ${ALERT_HUES[this.tone]}`); + let { hue, onHue } = ALERT_COLORS[this.tone]; + return htmlSafe(`--pretui-alert-hue: ${hue}; --pretui-alert-on-hue: ${onHue}`, + ); } - get glyph() { - return ALERT_GLYPHS[this.tone]; + // The same properties again, kept on top of a caller's `style`: a caller's + // `style` attribute replaces the component's own, and the tint, hairline + // and glyph disc all read these properties. + get keptStyle(): KeptProperty[] { + let { hue, onHue } = ALERT_COLORS[this.tone]; + return [ + { property: '--pretui-alert-hue', value: hue, strength: 'arg' }, + { property: '--pretui-alert-on-hue', value: onHue, strength: 'arg' }, + ]; + } + get Glyph() { + return ALERT_ICONS[this.tone]; } get spokenTone(): string { return `${this.args.toneLabel ?? ALERT_TONE_LABELS[this.tone]}:`; } , ); let glyphs = alerts().map((el) => el.querySelector('.pretui-alert-glyph')); + // The test host serves every icon module as one placeholder, so only the + // presence of an svg can be asserted here, not which icon each tone gets. + assert.true( + glyphs.every((g) => g?.querySelector('svg')), + 'each tone paints an svg icon, and no text glyph', + ); assert.deepEqual( glyphs.map((g) => g?.textContent?.trim()), - ['i', '✓', '!', '✕'], - 'each tone still paints its glyph', + ['', '', '', ''], + 'the glyph carries no text', + ); + assert.true( + glyphs.every((g) => g?.querySelector('svg')?.hasAttribute('width') && g?.querySelector('svg')?.hasAttribute('height')), + 'the icons are sized by attribute, not CSS', ); assert.deepEqual( glyphs.map((g) => g?.getAttribute('aria-hidden')), @@ -104,4 +117,17 @@ module('Pretui | components/alert', function (hooks) { 'Avertissement: Crédit faible', ]); }); + + test("a caller's style keeps the tone's hue and on-hue properties", async function (assert) { + await render( + , + ); + let el = alerts()[0]; + assert.strictEqual(el.style.getPropertyValue('--pretui-alert-hue').trim(), 'var(--success)'); + assert.strictEqual( + el.style.getPropertyValue('--pretui-alert-on-hue').trim(), + 'var(--success-foreground)', + ); + assert.strictEqual(el.style.margin, '2px', "the caller's own declarations are kept"); + }); }); diff --git a/packages/pretui/components/alert.usage.gts b/packages/pretui/components/alert.usage.gts index 5b2ed0ec372..dfc4ee430aa 100644 --- a/packages/pretui/components/alert.usage.gts +++ b/packages/pretui/components/alert.usage.gts @@ -72,7 +72,7 @@ class AlertUsage extends GlimmerComponent { /> diff --git a/packages/pretui/components/avatar.gts b/packages/pretui/components/avatar.gts index 7f4598ff519..197f5cd6f4b 100644 --- a/packages/pretui/components/avatar.gts +++ b/packages/pretui/components/avatar.gts @@ -75,10 +75,12 @@ export class Avatar extends Component { {{#if this.showImage}}{{@name}}{{else}}{{this.initials}}{{/if}} @@ -89,17 +91,20 @@ export class Avatar extends Component { display: inline-flex; align-items: center; justify-content: center; - width: var(--pretui-avatar-size, 1.5rem); - height: var(--pretui-avatar-size, 1.5rem); + /* the default diameter, declared once */ + --_avatar-size: var(--pretui-avatar-size, 1.5rem); + --_avatar-hue: var(--pretui-chip-hue, var(--primary)); + width: var(--_avatar-size); + height: var(--_avatar-size); /* 0.42 of the diameter; rounded to the whole pixel below where round() is supported */ - font-size: calc(var(--pretui-avatar-size, 1.5rem) * 0.42); + font-size: calc(var(--_avatar-size) * 0.42); border-radius: 50%; font-family: var(--font-mono); font-weight: 600; - background: color-mix(in oklch, var(--pretui-chip-hue, var(--primary)) 16%, var(--card)); - color: color-mix(in oklch, var(--foreground) 20%, var(--pretui-chip-hue, var(--primary))); - box-shadow: 0 0 0 1px color-mix(in oklch, var(--pretui-chip-hue, var(--primary)) 28%, var(--border)); + background-color: color-mix(in oklch, var(--_avatar-hue) 16%, var(--card)); + color: var(--foreground); + box-shadow: 0 0 0 1px color-mix(in oklch, var(--_avatar-hue) 28%, var(--border)); overflow: hidden; flex: none; } @@ -109,12 +114,15 @@ export class Avatar extends Component { the parent's font size instead of using the fallback. */ @supports (font-size: round(1px, 1px)) { .pretui-avatar { - font-size: round( - calc(var(--pretui-avatar-size, 1.5rem) * 0.42), - 1px - ); + font-size: round(calc(var(--_avatar-size) * 0.42), 1px); } } + /* A photo gets a neutral ring: the name's hue carries no meaning once + the photo shows. The ring sits on the root, whose overflow: hidden + circle would clip an outline on the square img. */ + .pretui-avatar[data-has-image] { + box-shadow: 0 0 0 1px color-mix(in oklch, var(--foreground) 10%, transparent); + } .pretui-avatar img { width: 100%; height: 100%; diff --git a/packages/pretui/components/avatar.md b/packages/pretui/components/avatar.md index e7bcfba52ab..d681d6bb8b0 100644 --- a/packages/pretui/components/avatar.md +++ b/packages/pretui/components/avatar.md @@ -10,11 +10,11 @@ A person or entity as a circle: a photo if there is one, hashed initials if ther Element: HTMLSpanElement ``` -**`@name` is required even when `@src` is present**, and that is the load-bearing decision: it is the `alt` text on the image, the `title`, the source of the initials fallback, and the seed for the hue. An avatar without a name is a coloured circle, and this component makes that unrepresentable. +**`@name` is required even when `@src` is present**, and that is the load-bearing decision: it is the `alt` text on the image, the `title`, the source of the initials fallback, and the seed for the hue. An avatar without a name is a colored circle, and this component makes that unrepresentable. **Initials are the first letter of up to two whitespace-separated words**, uppercased. "Ada Lovelace" → "AL", "Cher" → "C", "Jean-Luc Picard" → "JP" (the hyphen is not a separator). Non-Latin scripts get their first two characters, which is right for CJK and wrong for scripts with combining marks — worth knowing before using this for arbitrary user input. -**The hue is `statusHue(@name)`** — the same 32-bit hash used by **StatusChip**, over the name — so a given person is the same colour on every card and in every realm, with no registry. Everything else derives from that hue by `color-mix`: a 16% fill over `--card`, a 20% ink mix, a 28% hairline. Same Law 2 recipe as **Chip**, tuned lighter. +**The hue is `statusHue(@name)`** — the same 32-bit hash used by **StatusChip**, over the name — so a given person is the same color on every card and in every realm, with no registry. Everything else derives from that hue by `color-mix`: a 16% fill over `--card`, a 28% hairline, and `--foreground` for the initials. Same Law 2 recipe as **Chip**, tuned lighter. A photo gets a neutral ring instead of the hue ring: `color-mix(--foreground 10%, transparent)`, set on the root because its `overflow: hidden` circle would clip an outline on the square `img`. The name's hue means nothing once the photo shows, and avatars inside **AvatarGroup** still get the group's `--card` ring. `@size` sets width, height **and** font size (`round(size * 0.42)` to the whole pixel), so initials scale correctly rather than staying 11px in a 48px circle. The number is the diameter in px at a 16px root, and Avatar writes it as `--pretui-avatar-size` in rem (`@size={{40}}` is `2.5rem`), so it follows the root font size. Without `@size`, nothing is written and the size is `--pretui-avatar-size` from the cascade, `1.5rem` by default, so a class, a container query or an ancestor can set it. Give it a `rem`, `px` or container-query length (`cqi`, `cqw`, …). Those keep the 0.42 type ratio. `em` and `%` do not: the font size resolves them against the parent, while the width and height resolve them against the Avatar's own font size and its containing block, so `3em` under a 16px parent is a 60px disc with 20px type. @@ -32,7 +32,7 @@ Element: HTMLSpanElement **Web Awesome `wa-avatar`** takes `image`, `label`, `initials`, `loading` and `shape` (`circle | square | rounded`), with an icon slot as the third fallback tier. **Radix `Avatar`** is `Root`/`Image`/`Fallback` with a `delayMs` on the fallback so a fast-loading image does not flash initials. **React Spectrum `Avatar`** has `src`, `alt`, `size` and `isDisabled`. -Where Pretui is better: **the hue is derived, not chosen.** Web Awesome and Spectrum both give you one neutral avatar colour, so a list of eight initials-only avatars is eight identical grey circles — which defeats the purpose. Deriving the hue from the name makes initials-only avatars genuinely scannable, and it costs no configuration. +Where Pretui is better: **the hue is derived, not chosen.** Web Awesome and Spectrum both give you one neutral avatar color, so a list of eight initials-only avatars is eight identical gray circles — which defeats the purpose. Deriving the hue from the name makes initials-only avatars genuinely scannable, and it costs no configuration. Where it is behind, and these are real: @@ -49,17 +49,17 @@ What is right: `alt={{@name}}` on the image is real alternative text rather than Gaps: -- **The root's `aria-label={{@name}}` sits on a `` with no role.** It names the initials fallback as "Ada Lovelace" rather than the letters "A L" where a screen reader honours it, but `aria-label` on a generic element is prohibited by ARIA and several readers ignore it. A `role='img'` on the initials case would make the name reliable. +- **The initials case is `role='img'` with `aria-label={{@name}}`** on the root, so it is announced as "Ada Lovelace" rather than the letters "A L". With a photo, the root has neither and the ``'s `alt` carries the name, so it is announced once. - **`title` is the only hover affordance**, which means no touch access, no keyboard access, and UA-controlled presentation. -- **When `@src` _is_ present, the `alt`, the `title` and the root's `aria-label` all carry the name**, so several readers announce it more than once. +- **`title` repeats the name** on the root in both cases; some readers announce it as well as the `alt` or `aria-label`. - **The avatar is decorative in many contexts and nothing says so.** An Avatar next to a name that is already visible should be `aria-hidden`; there is no `@decorative` arg, so it announces redundantly in exactly the layout where it is most common (**EntityDisplay**, **Feed** rows, comment lists). -- **Contrast**: initials are `color-mix(--foreground 20%, hue)` on a **16%** hue fill — a lighter, lower-contrast pairing than **Chip**'s. At the default 24px the type is ~10px, weight 600, mono. That is small text at low contrast and is a likely **WCAG 1.4.3** failure for pale chart hues. Check all five per season. +- **Contrast**: initials are `--foreground` on a **16%** hue fill over `--card`, so the text is the theme's own foreground on a surface that stays close to `--card`, rather than a hue on its own tint. At the default 24px the type is ~10px, weight 600, mono, which is small, so check the five chart hues per theme. ## Theming -`--pretui-chip-hue` (set per instance from the name hash — note it reuses **Chip**'s property name, so an ancestor setting `--pretui-chip-hue` for a chip will _not_ affect an Avatar, because the inline style wins), `--pretui-avatar-size` (the diameter; inline only when `@size` is given, otherwise from the cascade with a `1.5rem` fallback), `--card` (mix base and the group ring), `--foreground` (mixed into initials), `--border` (mixed into the hairline), `--primary` (the fallback hue when the name is empty), `--font-mono`. +`--pretui-chip-hue` (set per instance from the name hash, and read once into a private `--_avatar-hue` — note it reuses **Chip**'s property name, so an ancestor setting `--pretui-chip-hue` for a chip will _not_ affect an Avatar, because the inline style wins), `--pretui-avatar-size` (the diameter; inline only when `@size` is given, otherwise from the cascade; the `1.5rem` default is declared once, as a private `--_avatar-size` that width, height and the font size all read), `--card` (mix base and the group ring), `--foreground` (the initials, and the ring around a photo), `--border` (mixed into the hairline), `--primary` (the fallback hue when the name is empty), `--font-mono`. -The 16% / 20% / 28% mix ratios are fixed — unlike **Chip**, whose ratios are tokenised — so a season cannot make avatars more or less saturated. A season or a card can set a default size through `--pretui-avatar-size`; `@size` wins over it. As with **StatusChip**, the palette that matters is `--chart-1` … `--chart-5`, and they must work as a mutually distinguishable set at 16% tint behind small mono type. +The 16% / 28% mix ratios are fixed — unlike **Chip**, whose ratios are tokenized — so a season cannot make avatars more or less saturated. A season or a card can set a default size through `--pretui-avatar-size`; `@size` wins over it. As with **StatusChip**, the palette that matters is `--chart-1` … `--chart-5`, and they must work as a mutually distinguishable set at 16% tint behind small mono type. The styles sit in `@layer PretComponent`, so a caller's unlayered CSS overrides them without a more specific selector. diff --git a/packages/pretui/components/avatar.test.gts b/packages/pretui/components/avatar.test.gts index c2bce7351d5..28f459d09a2 100644 --- a/packages/pretui/components/avatar.test.gts +++ b/packages/pretui/components/avatar.test.gts @@ -22,6 +22,9 @@ function hue(el: HTMLElement): string { return el.style.getPropertyValue('--pretui-chip-hue').trim(); } // Caller styles as a card would pass them: a bound SafeString. +// A 1x1 GIF, so the image loads and the photo markup stays. +const LOADABLE_PHOTO = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; const RING_STYLE = htmlSafe('--status-ring: var(--chart-2); margin: 2px'); const CALLER_HUE_STYLE = htmlSafe('--pretui-chip-hue: var(--muted-foreground)'); const CALLER_SIZE_STYLE = htmlSafe('--pretui-avatar-size: 3rem'); @@ -32,6 +35,26 @@ const CALLER_IMPORTANT_STYLE = htmlSafe( module('Pretui | components/avatar', function (hooks) { setupCardTest(hooks); + test('the initials case is an image with the name as its label; the photo case leaves that to the img alt', async function (assert) { + await render( + , + ); + let initials = q('[data-test-initials]'); + assert.strictEqual(initials.getAttribute('role'), 'img'); + assert.strictEqual(initials.getAttribute('aria-label'), 'Ada Lovelace'); + assert.strictEqual(initials.textContent?.trim(), 'AL'); + assert.notOk(initials.hasAttribute('data-has-image')); + + let photo = q('[data-test-photo]'); + assert.notOk(photo.hasAttribute('role'), 'no role on the root with a photo'); + assert.notOk(photo.hasAttribute('aria-label'), 'no label on the root with a photo'); + assert.strictEqual(photo.getAttribute('data-has-image'), 'true'); + assert.strictEqual(photo.querySelector('img')?.getAttribute('alt'), 'Ada Lovelace', 'the img alt carries the name'); + }); + test('@size is written as rem, and no size is written without it', async function (assert) { await render(