Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 65 additions & 32 deletions packages/pretui/components/alert.gts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,17 +23,32 @@ const ALERT_VARIANTS: Record<string, AlertTone> = {
default: 'info',
destructive: 'danger',
};
const ALERT_HUES: Record<AlertTone, string> = {
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<AlertTone, { hue: string; onHue: string }> = {
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<AlertTone, string> = {
info: 'i',
success: '✓',
warning: '!',
danger: '✕',
const ALERT_ICONS = {
info: InfoIcon,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] InfoIcon draws its own outer circle (<circle r=10> plus the i), and here it sits inside the 1rem filled disc. The info tone shows a ring inside a disc, while success, warning and danger show bare glyphs (check, triangle, x). The icon also loses most of its size to the inner ring at 12px. Use a bare "i" glyph, or drop the disc for info.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in ab5f4f188d. The info tone uses the bare i icon (info-small), so there is no ring inside the disc.

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
Expand Down Expand Up @@ -69,60 +89,73 @@ export class Alert extends Component<AlertSignature> {
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]}:`;
}
<template>
<div class='pretui-alert' role={{this.role}} style={{this.hueStyle}} data-test-pretui-alert ...attributes>
<span class='pretui-alert-glyph' aria-hidden='true'>{{this.glyph}}</span>
<div class='pretui-alert' role={{this.role}} style={{this.hueStyle}} {{keepStyle this.keptStyle}} data-test-pretui-alert ...attributes>
<span class='pretui-alert-glyph' aria-hidden='true'>
<this.Glyph width='12' height='12' />
</span>
<VisuallyHidden>{{this.spokenTone}}</VisuallyHidden>
<div class='pretui-alert-body'>
{{#if @title}}<div class='pretui-alert-title'>{{@title}}</div>{{/if}}
{{#if @title}}<p class='pretui-alert-title'>{{@title}}</p>{{/if}}
{{#if (has-block)}}<div>{{yield}}</div>{{/if}}
{{#if (has-block 'action')}}<div class='pretui-alert-action'>{{yield to='action'}}</div>{{/if}}
</div>
</div>
<style scoped>
@layer PretComponent {
.pretui-alert {
--pretui-alert-mix: 20%;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Alert no longer reads --pretui-chip-mix, and it declares --pretui-alert-mix: 20% on .pretui-alert itself, so a season or ancestor can't retune it. chip.md ("shared with Alert, so a banner and a chip about the same thing tint identically") and result.md still promise the shared token. A theme that raises --pretui-chip-mix now tints chips and results but leaves alerts at 20%. Either read var(--pretui-alert-mix, var(--pretui-chip-mix, 20%)) without declaring it on the root, or update the Chip and Result docs.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] @burieberry @habdelra this one needs a decision, so I left the code as it is. The review of the merged Alert asked not to read --pretui-chip-mix (a Pret UI season variable), so Alert declares its own --pretui-alert-mix: 20%. chip.md and result.md still say the tint strength is shared with Alert. Two ways: (a) keep Alert independent and update the Chip and Result docs; (b) read var(--pretui-alert-mix, var(--pretui-chip-mix, 20%)) so the shared knob keeps working. Which one?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] (a). --pretui-chip-mix is a Pret season variable, and nothing in boxel-ui or the host declares it, so in Boxel the "shared" knob never reached Alert or Chip. Keep --pretui-alert-mix as Alert's own, and drop the "shared with Alert" claims from chip.md (the mix-ratio paragraph and Theming) and result.md (Theming). Chip, Result, ErrorSummary, FormSection and TreeSelect still read --pretui-chip-mix with literal fallbacks; that is a follow-up for those components, not this change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Done in 9d5fa50713, option (a). Alert keeps its own --pretui-alert-mix, and the "shared with Alert" claims are removed from chip.md (the mix-ratio paragraph and Theming) and from the Theming section of result.md.

display: flex;
gap: 9px;
padding: 8px 10px;
border-radius: 10px;
background: color-mix(in oklch, var(--pretui-alert-hue, var(--chart-1)) var(--pretui-chip-mix, 20%), var(--card));
color: color-mix(in oklch, var(--foreground) 40%, var(--pretui-alert-hue, var(--chart-1)));
box-shadow: 0 0 0 1px color-mix(in oklch, var(--pretui-alert-hue, var(--chart-1)) 25%, var(--border));
font-size: var(--text-ui-md, 12.5px);
gap: var(--boxel-sp-xs);
padding: var(--boxel-sp-2xs) var(--boxel-sp-xs);
border-radius: var(--boxel-border-radius);
background-color: color-mix(in oklch, var(--pretui-alert-hue) var(--pretui-alert-mix), var(--card));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] The var(--pretui-alert-hue, var(--chart-1)) fallbacks are gone, so the tint, hairline, ink and disc now rely entirely on the inline style from hueStyle. A caller that passes style=... (through ...attributes, which replaces the component's own style) unsets --pretui-alert-hue, --pretui-alert-ink and --pretui-alert-on-hue. Each color-mix then becomes invalid at computed-value time: the background goes transparent, the text inherits and the glyph disc disappears. Before this change the alert fell back to --chart-1 and kept its styling.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in ab5f4f188d. The three hue properties are now also written by keepStyle, the way Avatar keeps its own, so a caller's style no longer drops the tint, ink or glyph disc. The reads stay bare. A new test passes style and checks the properties and the caller's declaration are both kept.

color: var(--foreground);
box-shadow: 0 0 0 1px color-mix(in oklch, var(--pretui-alert-hue) 25%, var(--border));
font-size: var(--boxel-font-size-xs);
}
.pretui-alert-glyph {
width: 16px;
height: 16px;
width: 1rem;
height: 1rem;
border-radius: 50%;
flex: none;
display: grid;
place-items: center;
font-size: 9px;
font-weight: 700;
background: var(--pretui-alert-hue, var(--chart-1));
color: var(--pretui-on-neutral, var(--boxel-light));
background-color: var(--pretui-alert-hue);
color: var(--pretui-alert-on-hue);
margin-top: 1px;
}
.pretui-alert-body {
display: grid;
gap: 2px;
gap: var(--boxel-sp-6xs);
min-width: 0;
}
.pretui-alert-title {
margin: 0;
font-weight: 600;
color: color-mix(in oklch, var(--foreground) 55%, var(--pretui-alert-hue, var(--chart-1)));
}
.pretui-alert-action {
margin-top: 4px;
margin-top: var(--boxel-sp-3xs);
}
}
</style>
Expand Down
14 changes: 7 additions & 7 deletions packages/pretui/components/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ An inline banner carrying a tone: something succeeded, something needs attention
<:default> <:action>
```

**One hue in, a complete treatment out.** This is the kit's Law 2, and Alert is its clearest expression: `@tone` selects a single custom property (`--pretui-alert-hue`), and the stylesheet derives _everything_ from it with `color-mix` — a 20% tint over `--card` for the background, a 25% mix with `--border` for the hairline, a 40% mix with `--foreground` for the body ink, 55% for the title, and the hue neat for the glyph disc. No semantic hexes are baked in anywhere. Adding a fifth tone is one line in the hue map.
**One hue in, a complete treatment out.** This is the kit's Law 2, and Alert is its clearest expression: `@tone` selects the hue and on-hue custom properties (`--pretui-alert-hue`, `--pretui-alert-on-hue`), and the stylesheet derives the tints from the hue with `color-mix` — a 20% tint over `--card` for the background, a 25% mix with `--border` for the hairline, and the hue neat for the glyph disc. Text uses `--foreground`, so the tint, hairline and disc carry the tone, and the glyph uses the fill's paired `-foreground`. No semantic hexes are baked in anywhere. Adding a fifth tone is one entry in the color map.

The glyph vocabulary (`i` / `✓` / `!` / `✕`) is shared with **FieldError** and **ErrorSummary**, so a field message, a summary row and a banner about the same thing read as one system.
Each tone has its own icon from `@cardstack/boxel-icons` (Info, Check, Alert Triangle and X), painted in the tone's `-foreground`. **FieldError** and **ErrorSummary** mark severity with their own glyphs and the same four hues, so a field message, a summary row and a banner about the same thing still read as one system.

**The role flips with the tone**: `danger` gets `role="alert"` (assertive), everything else gets `role="status"` (polite). That is a real decision, not a default — see below.

Expand All @@ -27,26 +27,26 @@ Where Pretui is better than shadcn: **shadcn puts `role="alert"` on an informati

Where Pretui is better than Web Awesome: the `color-mix` derivation. Web Awesome's variants are enumerated stylesheets; a new tone means a new block. Here the recipe is written once.

Where it is thinner: no `appearance` axis (Web Awesome's outlined/plain callouts have no equivalent), no dismiss affordance, no icon slot — the glyph is fixed per tone.
Where it is thinner: no `appearance` axis (Web Awesome's outlined/plain callouts have no equivalent), no dismiss affordance, no icon slot — the icon is fixed per tone.

## Accessibility

Governing pattern: APG **Alert** (`role="alert"`, nothing else required) and the live-region rules generally.

What is right: the assertive/polite split by tone, and the fact that both `alert` and `status` carry implicit `aria-atomic="true"`, so the whole banner is re-read rather than just the changed fragment. The glyph is `aria-hidden`, so the banner is not announced as "multiplication x" or "letter i" first. The role alone cannot carry the tone: it singles out `danger`, and `info`, `success` and `warning` all share `role="status"`. So a visually hidden tone word sits where the glyph is, and the banner is announced as "Warning: Low credit", the way GOV.UK's warning text carries a hidden "Warning". `@toneLabel` replaces the word, for a translation or a more exact one such as "Caution" on a delete confirmation.
What is right: the assertive/polite split by tone, and the fact that both `alert` and `status` carry implicit `aria-atomic="true"`, so the whole banner is re-read rather than just the changed fragment. The glyph is `aria-hidden`, so the banner is not announced as an image first. The role alone cannot carry the tone: it singles out `danger`, and `info`, `success` and `warning` all share `role="status"`. So a visually hidden tone word sits where the glyph is, and the banner is announced as "Warning: Low credit", the way GOV.UK's warning text carries a hidden "Warning". `@toneLabel` replaces the word, for a translation or a more exact one such as "Caution" on a delete confirmation.

Gaps, and one is significant:

- **The live region is created together with its content.** A live region must exist in the DOM _before_ its content changes to be reliably announced. `{{#if this.showAlert}}<Alert>` mounts the region and its text in the same frame, and several screen readers will say nothing. `role="alert"` is partly exempt — some readers do announce alerts inserted with content already present — but `role="status"` generally is not, so **`info`/`success`/`warning` alerts frequently announce nothing at all**. The fix is the pattern React Aria and Web Awesome both use: one persistent, empty, visually-hidden region that messages are written into. Render the Alert always and toggle its content, or pair it with such a region.
- **`role="alert"` on a persistent banner is wrong in the other direction.** An Alert that is always present (a standing warning on a record) will be announced on every re-render that touches it. Alerts are for messages that _appear_.
- **Contrast is derived, not verified.** Body ink is `color-mix(--foreground 40%, hue)` on a `color-mix(hue 20%, --card)` background. That reads well for the default palette and is not guaranteed for an arbitrary season hue — a light amber `--warning` produces low-contrast body text with nothing to catch it.
- **Text is `--foreground`, not the tone's `-ink`.** The `-ink` tokens are only guaranteed on `--background`, `--card` and `--muted`, and the 20% tint is none of those: the default `--destructive-ink` measures under 4.5:1 on the danger tint. `--foreground` clears 4.5:1 on all four tints in light and dark. The glyph disc pairs each fill with its `-foreground`.
- No dismiss control, so nothing to make keyboard-accessible — which is the honest upside of the smaller API.

## Theming

`--pretui-info`, `--success`, `--warning`, `--destructive` (the four tone hues); `--card` (the mix base and the glyph's ink), `--foreground` (mixed into title and body), `--border` (mixed into the hairline), `--pretui-chip-mix` (the tint strength, default 20%, shared with **Chip** so banners and chips tint identically), `--text-ui-md`.
`--info`, `--success`, `--warning`, `--destructive` (the four tone hues); `--foreground` (text); `--info-foreground`, `--success-foreground`, `--warning-foreground`, `--destructive-foreground` (the glyph's ink on its disc); `--card` (the mix base), `--border` (mixed into the hairline), and the `--boxel-sp-*`, `--boxel-border-radius` and `--boxel-font-size-xs` tokens for geometry and type. The 20% tint strength is the component's own `--pretui-alert-mix`, set on `.pretui-alert`; a caller can override it per instance. A caller's `style` attribute does not remove the tone's hue and on-hue properties: they are written again on top of it.

A season retunes the entire component by retuning those four hues plus `--pretui-chip-mix`. Because every derived colour is a mix against `--card`, a dark season gets correct dark treatments automatically — but it **must** define the four hues at a luminance that survives a 20% mix against a dark `--card`, or all four alerts converge on the same near-black rectangle.
A theme retunes the entire component by retuning those four hues. Because every derived colour is a mix against `--card`, a dark theme gets correct dark treatments automatically, but it **must** define the four hues at a luminance that survives a 20% mix against a dark `--card`, or all four alerts converge on the same near-black rectangle.

The styles sit in `@layer PretComponent`, so a caller's unlayered CSS overrides them without a more specific selector.

Expand Down
30 changes: 28 additions & 2 deletions packages/pretui/components/alert.test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
// Run with `boxel test`.
import { module, test } from 'qunit';
import { render } from '@ember/test-helpers';
import { htmlSafe } from '@ember/template';
import { setupCardTest } from '@cardstack/host/tests/helpers';
import { Alert } from './alert';

const CALLER_STYLE = htmlSafe('margin: 2px');

function alerts(): HTMLElement[] {
return [...document.querySelectorAll<HTMLElement>('[data-test-pretui-alert]')];
}
Expand Down Expand Up @@ -39,10 +42,20 @@ module('Pretui | components/alert', function (hooks) {
</template>,
);
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')),
Expand Down Expand Up @@ -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(
<template><Alert @tone='success' @title='Saved' style={{CALLER_STYLE}} /></template>,
);
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");
});
});
6 changes: 3 additions & 3 deletions packages/pretui/components/alert.usage.gts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ class AlertUsage extends GlimmerComponent {
/>
<Args.String
@name='toneLabel'
@description='The visually hidden tone word a screen reader hears before the title, in place of the hidden glyph. Defaults by @tone: Info for info, Success for success, Warning for warning, Error for danger.'
@description='The visually hidden tone word a screen reader hears before the title, in place of the hidden icon. Defaults by @tone: Info for info, Success for success, Warning for warning, Error for danger.'
/>
<Args.Yield
@name='default'
Expand All @@ -86,9 +86,9 @@ class AlertUsage extends GlimmerComponent {
</FreestyleUsage>
<style scoped>
.alert-col {
width: min(100%, 420px);
width: min(100%, 26rem);
display: grid;
gap: var(--space-3, 8px);
gap: var(--boxel-sp-2xs);
}
</style>
</template>
Expand Down
32 changes: 20 additions & 12 deletions packages/pretui/components/avatar.gts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,12 @@ export class Avatar extends Component<AvatarSignature> {
<span
class='pretui-avatar'
title={{@name}}
aria-label={{@name}}
role={{unless this.showImage 'img'}}
aria-label={{unless this.showImage @name}}
data-has-image={{if this.showImage 'true'}}
style={{this.style}}
data-test-pretui-avatar
{{keepStyle this.keptStyle}}
data-test-pretui-avatar
...attributes
>
{{#if this.showImage}}<img src={{@src}} alt={{@name}} {{on 'error' this.imageError}} />{{else}}{{this.initials}}{{/if}}
Expand All @@ -89,17 +91,20 @@ export class Avatar extends Component<AvatarSignature> {
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;
}
Expand All @@ -109,12 +114,15 @@ export class Avatar extends Component<AvatarSignature> {
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%;
Expand Down
Loading
Loading