Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
13913a3
Give Pret UI's usage page, Viewport and test targets data-test hooks,…
burieberry Oct 7, 2026
fabdb19
Put Toast and Snackbar on the theme contract and drop Toast's own sta…
burieberry Oct 7, 2026
b7a7cd3
Give Chip a tone with an ink ring, and let StatusChip pass it through
burieberry Oct 7, 2026
0e5c402
Bring Token's hues, sizes and docs onto the theme contract
burieberry Oct 7, 2026
f6dfe5a
Let Table drop its frame inside a panel, and paint zebra and hover on…
burieberry Oct 7, 2026
a77d209
Keep SegmentedControl's segments at their bold width and give its pil…
burieberry Oct 7, 2026
5face9a
Label Viewport's controls, caption Fill as 100%, and size 3-up artboa…
burieberry Oct 7, 2026
95e28b0
Make ThemeFrame's mode a light/dark switch inside the PretComponent l…
burieberry Oct 7, 2026
b6ec47d
Rebuild the usage page shell on the theme contract with real headings…
burieberry Oct 7, 2026
d00a4b4
Bring the Pret UI Spec page, its example gallery and the Note card on…
burieberry Oct 7, 2026
16cbb16
Fix the UI review findings: keep catalog chips' size and outline, pai…
burieberry Oct 7, 2026
6426356
Put Autocomplete on the theme contract: ink edges and match text, lad…
burieberry Oct 8, 2026
231b112
Bring SegmentedControl and SlidingHighlight onto the contract, with B…
burieberry Oct 8, 2026
7b1d78a
Name the usage page's panels from their headings, scroll its source i…
burieberry Oct 8, 2026
ddc009d
Paint ThemeFrame's island with --background, fill the frame, and name…
burieberry Oct 8, 2026
58b6b93
Use the caption size and a shared gutter on the Spec page, show a loa…
burieberry Oct 8, 2026
59f21b7
Keep Chip's legacy text-size fallback for catalog chips, and correct …
burieberry Oct 8, 2026
91cc4d2
Give Toast's message a contrast-safe ink and the title's size, and co…
burieberry Oct 8, 2026
109a8ae
Put Token's sizes on the ladder with one growing line height, and tid…
burieberry Oct 8, 2026
fe7b1cc
Make Viewport's width controls, stage settings and canvas behave in e…
burieberry Oct 8, 2026
13ab133
Label each property knob with its row, announce required arguments, h…
burieberry Oct 8, 2026
16efbb9
Fix the Spec edit view's flag labels, wrap long names, label the note…
burieberry Oct 8, 2026
80fe6dc
Link the array and CSS-variable knobs to their row labels, and descri…
burieberry Oct 8, 2026
64c2af8
Put ...attributes last on Autocomplete, SegmentedControl, SlidingHigh…
burieberry Oct 8, 2026
0de5678
Caption a dragged Viewport width by its mode, and drop the specimen l…
burieberry Oct 8, 2026
1d4711c
Fit Viewport, Toast and the Spec page to narrow panels, scroll code-m…
burieberry Oct 8, 2026
9dcdf68
Show Spec flags as switches, caption fields in sentence case, fill an…
burieberry Oct 8, 2026
e397645
Offer the Spec's closed vocabularies (category, tier, ownership, adop…
burieberry Oct 8, 2026
be0d301
Show Spec notes as title and excerpt beside the heading, keep the fra…
burieberry Oct 8, 2026
19ed0a6
Space the hidden (required) from the asterisk, and read API row names…
burieberry Oct 8, 2026
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
2 changes: 1 addition & 1 deletion packages/pretui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Every component's `<style scoped>` content sits in one of two layers, so a calle

Rules that restyle a boxel-ui component whose own CSS is unlayered stay outside the layer, since unlayered CSS beats any layer. Select is the one case: BoxelSelect's trigger and option styles are unlayered.

The `Pret` prefix matters because layer names are document-global. Usage pages, example galleries, `pretui-component.gts` and `pretui-note.gts` stay unlayered: they are callers of the kit, and their styles win the way any caller's do.
The `Pret` prefix matters because layer names are document-global. Usage pages and their shell (`freestyle-usage`, the `usage-*` argument rows and `internal/freestyle`), example galleries, `pretui-component.gts` and `pretui-note.gts` stay unlayered: they are callers of the kit, and their styles win the way any caller's do.

## Development

Expand Down
237 changes: 123 additions & 114 deletions packages/pretui/components/autocomplete.gts

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions packages/pretui/components/autocomplete.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ That last clause is the whole difference from **Combobox**: an autocomplete's va
<:empty> — replaces the built-in empty line
```

**`@onCommit` is the callback that distinguishes typing from meaning it.** It fires on Enter, a click or a blur, and it tells you *which* happened: a suggestion object when one was chosen, `undefined` when the reader meant their own text.
**`@onCommit` is the callback that distinguishes typing from meaning it.** It fires on Enter, a click or a blur, and it tells you _which_ happened: a suggestion object when one was chosen, `undefined` when the reader meant their own text.

**`@enterCommits` defaults to `'deliberate'`, and that is the interesting decision.** When the highlight and the typed text disagree, Enter takes the typed text unless the reader deliberately moved to the highlight. An automatic highlight does **not** win Enter — so `@autoHighlight` is a visual aid rather than a trap that silently replaces what someone typed.

Expand All @@ -51,6 +51,7 @@ Where it is thinner: no multi-select or token mode — that is **TokenInput**
## Accessibility

- **Rows are rendered inside an `aria-hidden` face, and the option's accessible name is computed rather than scraped.** That is what makes `<:item>` safe: any markup is legal in the block because none of it reaches the accessibility tree.
- **`@disabled` keeps the field focusable.** It sets `aria-disabled` and `readonly` rather than the native `disabled`, so a reader can still reach the field and hear that it's unavailable, while the text can't be edited. The Clear button is hidden.
- **`@busy` retains focus.** A field that disables itself while fetching suggestions throws the reader to the document mid-word.
- **The field is always named** — through `@label` as an `sr-only` label, or through a wrapper's `@controlId`.
- **`@minChars` suppresses the layer silently.** A reader who types one character and gets nothing is not told why; if the threshold is high, say so in `@placeholder` or in help text.
Expand All @@ -59,8 +60,8 @@ Where it is thinner: no multi-select or token mode — that is **TokenInput**

## Theming

Tone, appearance and size resolve through the kit's shared recipe system, so the field matches every other control in a form at the same size.
Tone, appearance and size resolve through the kit's shared recipe system, so the field matches every other control in a form at the same size. A tone sets two properties: `--pretui-tone`, the fill (`--primary`, `--info`, `--success`, `--warning`, `--destructive`, `--attention`) for tints, and `--pretui-tone-ink`, its `-ink`, for rings, the busy arc and the matched text, since a fill misses the contrast a line or text needs. The field reads `--field` or `--card` with its foreground; its edge is `--input` when neutral (`--border-strong` on hover) and the tone's `-ink` otherwise, `--destructive-ink` when invalid, `--ring` for focus and `--muted-foreground` for the placeholder. Sizes come from `--pretui-size-*`, falling back to `--boxel-font-size-2xs` (xs and s), `-xs` (m, as on Select), `-sm` (l) and `--boxel-font-size` (xl).

The suggestion layer rides the kit's overlay tokens rather than defining its own surface, which is what keeps an autocomplete's dropdown at the same elevation and radius as a **Select**'s or a **Combobox**'s in the same season — three components that would look like three different products if each owned its own popover styling.
The suggestion layer is a `--popover` surface with `--popover-foreground`, `--shadow-md`, `--boxel-border-radius` and the kit's `dropdown` stacking tier, the same elevation and radius as a **Select**'s or a **Combobox**'s, so three components don't look like three different products. The clear button is a plain neutral **Button** with a boxel-icons `x`.

The styles sit in `@layer PretComponent`, so a caller's unlayered CSS overrides them without a more specific selector.
116 changes: 101 additions & 15 deletions packages/pretui/components/chip.gts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
// Pretui — Chip: a compact label in one hue (Law 2: one hue in, complete treatment out).
// Pretui — Chip: a compact label, muted or outlined in a tone; @hue colors the dot.
import Component from '@glimmer/component';
import { hueStyle } from '../internal/ink';
import {
PRETUI_TONES,
resolveTone,
type PretuiTone,
type PretuiToneArg,
} from '../pretui-primitives';

export interface ChipSignature {
Args: { label?: string; hue?: string; dot?: boolean };
Args: {
label?: string;
/** the dot's color */
hue?: string;
/** neutral (the default) is the muted pill; any other tone is outlined in
* that tone, with its -ink as the text */
tone?: PretuiToneArg;
dot?: boolean;
};
Blocks: { default: [] };
Element: HTMLSpanElement;
}
Expand All @@ -12,36 +26,108 @@ export class Chip extends Component<ChipSignature> {
get showDot() {
return this.args.dot ?? true;
}
get tone(): PretuiTone {
return resolveTone(this.args.tone, PRETUI_TONES, 'neutral');
}
get style() {
return hueStyle('--pretui-chip-hue', this.args.hue);
}
<template>
<span class='pretui-chip' style={{this.style}} data-test-pretui-chip ...attributes>
<span
class='pretui-chip'
style={{this.style}}
data-tone={{this.tone}}
data-test-pretui-chip
...attributes
>
{{#if this.showDot}}<span class='pretui-chip-dot'></span>{{/if}}
{{#if @label}}{{@label}}{{else}}{{yield}}{{/if}}
</span>
<style scoped>
@layer PretComponent {
.pretui-chip {
--pretui-chip-hue: var(--muted-foreground);
--pretui-chip-height: 1.125rem;
--pretui-chip-dot-size: 0.3125rem;

display: inline-flex;
align-items: center;
gap: 5px;
height: 18px;
padding: 0 7px;
border-radius: var(--radius-chip, 6px);
font-size: var(--text-ui-xs, 11px);
gap: var(--boxel-sp-3xs);
height: var(--pretui-chip-height);
padding: 0 var(--boxel-sp-2xs);
border-radius: var(--boxel-border-radius-xs);
/* --text-ui-xs is the legacy size knob catalog chips still set */
font-size: var(
--pretui-chip-font-size,
var(--text-ui-xs, var(--boxel-font-size-2xs))
);
font-weight: 500;
letter-spacing: var(--track-ui, 0.01em);
letter-spacing: var(--boxel-lsp-xs);
white-space: nowrap;
background: color-mix(in oklch, var(--pretui-chip-hue, var(--muted-foreground)) var(--pretui-chip-mix, 20%), var(--card));
color: color-mix(in oklch, var(--foreground) var(--pretui-ink-mix, 34%), var(--pretui-chip-hue, var(--muted-foreground)));
box-shadow: 0 0 0 1px color-mix(in oklch, var(--pretui-chip-hue, var(--muted-foreground)) 45%, var(--border));
/* --pretui-chip-mix / --pretui-ink-mix are legacy knobs catalog chips
still set: 0% / 100% (unset) resolve to plain --muted / --foreground */
background-color: color-mix(
in oklch,
var(--pretui-chip-hue) var(--pretui-chip-mix, 0%),
var(--muted)
);
color: color-mix(
in oklch,
var(--foreground) var(--pretui-ink-mix, 100%),
var(--pretui-chip-hue)
);
/* the hue hairline legacy chips were drawn with: any
--pretui-chip-mix above 0% shows it at full strength, and the
default 0% leaves it transparent */
box-shadow: 0 0 0 1px
color-mix(
in oklch,
color-mix(in oklch, var(--pretui-chip-hue) 45%, var(--border))
min(calc(var(--pretui-chip-mix, 0%) * 100), 100%),
transparent
);
}
/* a toned chip is outlined on --card: its -ink draws the text and the
ring (the fill hue misses 3:1 as a ring), the dot takes the tone */
.pretui-chip:not([data-tone='neutral']) {
background-color: var(--card);
box-shadow: inset 0 0 0 1px currentColor;
}
.pretui-chip[data-tone='primary'] {
--pretui-chip-hue: var(--primary);

color: var(--primary-ink);
}
.pretui-chip[data-tone='info'] {
--pretui-chip-hue: var(--info);

color: var(--info-ink);
}
.pretui-chip[data-tone='success'] {
--pretui-chip-hue: var(--success);

color: var(--success-ink);
}
.pretui-chip[data-tone='warning'] {
--pretui-chip-hue: var(--warning);

color: var(--warning-ink);
}
.pretui-chip[data-tone='danger'] {
--pretui-chip-hue: var(--destructive);

color: var(--destructive-ink);
}
.pretui-chip[data-tone='attention'] {
--pretui-chip-hue: var(--attention);

color: var(--attention-ink);
}
.pretui-chip-dot {
width: 5px;
height: 5px;
width: var(--pretui-chip-dot-size);
height: var(--pretui-chip-dot-size);
border-radius: 50%;
background: var(--pretui-chip-hue, var(--muted-foreground));
background-color: var(--pretui-chip-hue);
flex: none;
}
}
Expand Down
18 changes: 8 additions & 10 deletions packages/pretui/components/chip.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,23 @@
## What it is

A small tinted pill for a categorical value: a status, a tag, a label. It is display only — no click, no dismiss, no selection. Use it wherever a value is a _category_ rather than a number or a name. If the hue should be derived from the value automatically, use **StatusChip**, which is this component with `statusHue()` applied. If the value is machine-readable (an id, a hash, a path), use **Token**, which is the mono jewelry treatment. If the pill filters a list, use **FilterChips**. If it represents a record, **RecordPill**.
A small pill for a categorical value: a status, a tag, a label. It is display only — no click, no dismiss, no selection. Use it wherever a value is a _category_ rather than a number or a name. If the dot's hue should be derived from the value automatically, use **StatusChip**, which is this component with `statusHue()` applied. If the value is machine-readable (an id, a hash, a path), use **Token**, which is the mono jewelry treatment. If the pill filters a list, use **FilterChips**. If it represents a record, **RecordPill**.

## The contract

```
@label?, @hue?, @dot? (default true)
@label?, @tone? (default neutral), @hue?, @dot? (default true)
<:default> — used when @label is absent
```

**One hue in, a complete treatment out.** This is the kit's Law 2 and Chip is its purest expression: `@hue` sets a single custom property and the stylesheet derives everything with `color-mix` — a 20% tint over `--card` for the fill, a 45% mix with `--border` for the hairline, a 34% mix with `--foreground` for the ink, and the hue neat for the dot. There are no variants and no colour enum. Passing any CSS colour, or any `var(--chart-3)`, produces a complete, coherent chip.
**Tone outlines, hue colors the dot.** A neutral chip (the default) is the theme's `--muted` surface with `--foreground` text. Any other `@tone` outlines it on `--card` instead: the tone's `-ink` (`--success-ink`, `--destructive-ink` for `danger`…) for the text and the ring, and the tone itself for the dot, the same tone names and inks Button and Alert use. `@hue` recolors only the dot (any CSS color, or a `var(--chart-3)`).

**The dot is on by default.** That is the decision most kits get backwards: without it, a row of chips is distinguished only by fill colour, and colour alone is a WCAG 1.4.1 problem _and_ hard to scan. The dot gives the hue a saturated anchor next to muted ink, so the category reads at a glance even when the fills are close. Turn it off (`@dot={{false}}`) only when the chip's own text already names the category unambiguously.

The mix ratios are tokens (`--pretui-chip-mix`, `--pretui-ink-mix`).

## Prior art

**Web Awesome `wa-tag`** takes `variant` (brand/success/neutral/warning/danger), `appearance` (accent/filled/outlined/plain), `size` and `with-remove` — a closed enum of five semantic colours plus a removable mode. **shadcn `Badge`** is `default | secondary | destructive | outline`, a `cva` map. **React Spectrum** splits it in two: `Badge` (static, eleven `variant` colours) and `Tag`/`TagGroup` (interactive, removable, selectable, with keyboard navigation).

Where Pretui is better: **the hue is open, and the treatment is derived.** Every kit above enumerates its colours, so a chip for "Region: EMEA" has to be shoehorned into `secondary` or a new variant added. Here you pass a hue — most usefully one of the five `--chart-*` tokens — and get a correct fill, ink and hairline for it automatically. Adding a category costs nothing.
Where Pretui is better: **the hue is open, and the treatment is derived.** Every kit above enumerates its colors, so a chip for "Region: EMEA" has to be shoehorned into `secondary` or a new variant added. Here you pass a hue — most usefully one of the `--chart-*` tokens — and it colors the dot, while the text stays the theme's guaranteed pair. Adding a category costs nothing.

Where it is thinner, and the gaps are real: **no remove affordance** (Web Awesome and Spectrum both have one, and it is the single most-requested chip feature), **no size axis**, **no icon slot** (the dot is the only leading element), and **no interactive mode** — Spectrum's `TagGroup` with its roving tabindex and Delete-key removal has no analogue here. Reach for **FilterChips** or **RecordPill** when you need behaviour.

Expand All @@ -32,17 +30,17 @@ That is the right answer for a static chip, and it means the accessibility quest
- **The dot is an empty `<span>` with no `aria-hidden`.** It contributes nothing to the announced text (there is nothing in it), so this is correct in effect, though an explicit `aria-hidden="true"` would be clearer about intent.
- **The chip's meaning must survive without colour.** `@label` carries it, so a chip reading "Blocked" is fine; a chip reading "3" whose colour means severity is a **WCAG 1.4.1** failure. The component cannot enforce this, and it is the most common way chips go wrong.
- **The chip has no accessible relationship to what it describes.** A status chip next to a record name is announced as loose adjacent text. If the chip _is_ the value of a labelled property, put it inside a **KeyValue** or a **FormField**'s static block so the label reaches it.
- **Contrast is derived, not verified.** Ink is `color-mix(--foreground 34%, hue)` on a `color-mix(hue 20%, --card)` background at **11px, weight 500**. That is the kit's smallest text on a tinted ground, and it is the most likely **1.4.3** failure in the ink territory. A pale `--chart-*` hue produces pale ink on a pale fill; check all five chart hues per season, not just one.
- **Text contrast is a guaranteed theme pair**: `--foreground` on `--muted` for a neutral chip, the tone's `-ink` on `--card` for a toned one, at **11px, weight 500**, the same for every hue. The dot is decorative; a pale hue makes a faint dot, never faint text.
- **`white-space: nowrap`** means a long label overflows rather than wraps. Chips are for short values; nothing enforces that.
- Nothing is focusable, which is correct — there is nothing to do.

## Theming

`--pretui-chip-hue` (the per-instance hue, defaulting to `--muted-foreground`), `--pretui-chip-mix` (fill strength, default 20%), `--pretui-ink-mix` (ink strength, default 34%), `--card` (the mix base), `--foreground` (mixed into ink), `--border` (mixed into the hairline), `--radius-chip` (6px), `--text-ui-xs`, `--track-ui`.
`--muted` / `--foreground` (a neutral pill and its text), `--card` with each tone's `--x-ink` (text and ring) and `--x` (dot) for a toned chip, `--pretui-chip-hue` (the dot, defaulting to `--muted-foreground`, or the tone on a toned chip), `--boxel-border-radius-xs`, `--boxel-font-size-2xs`, `--boxel-lsp-xs`, and the `--boxel-sp-*` spacing scale. `--pretui-chip-height`, `--pretui-chip-dot-size` and `--pretui-chip-font-size` set the pill height, dot size and text size.

The 18px height, 7px padding and 5px dot are fixed.
A theme restyles every chip through `--muted` and `--foreground`, and the dot colors through the `--chart-*` tokens. Dark mode needs nothing extra: the theme's dark `--muted` / `--foreground` pair applies.

A season retunes every chip in the product through `--pretui-chip-mix` and `--pretui-ink-mix`, and defines the palette through `--chart-1` … `--chart-5`. Because every derived colour mixes against `--card`, a dark season gets correct dark chips automatically — but it must pick chart hues that survive a 20% mix against a dark `--card`, or all five chips converge on the same near-black pill and the dot becomes the only distinction.
`--pretui-chip-mix` and `--pretui-ink-mix` are legacy knobs that tint a neutral chip's surface and text toward `--pretui-chip-hue`, and any `--pretui-chip-mix` above 0% also draws the hue hairline those chips were designed with. `--text-ui-xs` is the legacy text-size knob, read when `--pretui-chip-font-size` is unset. All three are kept for callers that still set them; new code uses `@tone` and `--pretui-chip-font-size`.

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

Expand Down
Loading
Loading