diff --git a/packages/pretui/README.md b/packages/pretui/README.md
index dce349004ca..b9f134e47e1 100644
--- a/packages/pretui/README.md
+++ b/packages/pretui/README.md
@@ -37,7 +37,7 @@ Every component's `
;
diff --git a/packages/pretui/components/freestyle-usage.md b/packages/pretui/components/freestyle-usage.md
index 96bfc245406..c7145b7aff9 100644
--- a/packages/pretui/components/freestyle-usage.md
+++ b/packages/pretui/components/freestyle-usage.md
@@ -1,6 +1,6 @@
## What it is
-The usage page: a component's example, its interactive knobs, its API table and its source, in one frame. Use it to document a component. Every page in the Pretui catalogue is one of these. If you only need the artboard frame, **Viewport**; if you need the theme switcher, **ThemeFrame**.
+The usage page: a component's example, its interactive knobs, its API table and its source, in one frame. Use it to document a component. Every page in the Pret UI catalog is one of these. If you only need the artboard frame, **Viewport**; if you need the theme switcher, **ThemeFrame**.
## The contract
@@ -24,7 +24,7 @@ Where the Pretui port differs, and each is a deliberate delta:
- **The machinery wears the kit.** **Select**, **Input**, **Switch** and **Slider** are the knob controls; **Table** renders the API docs; **Viewport** frames every example. So the documentation surface dogfoods the components it documents — a broken Select breaks its own usage page, which is a useful forcing function Storybook's React-based panel does not have.
- **The dual-lens `<:api>`** (above). Storybook derives its controls table from types; freestyle makes you author knobs; this makes you author once and renders both.
- **No ember-freestyle service.** The upstream keeps a global service for section registration; this is component-local, which suits a realm where a page is a card.
-- **Plain `
` for `@source`.** No syntax highlighter — Law 9 forbids vendoring one, and the trade is stated rather than hidden.
+- **Plain `` for `@source`.** No syntax highlighter — Law 9 forbids vendoring one, and the trade is stated rather than hidden.
- **Labeled controls**, where upstream's are bare.
Where it is behind Storybook, honestly: no addons, no interaction testing, no accessibility panel, no visual regression, and no auto-generated argTypes.
@@ -35,17 +35,15 @@ No pattern governs it; it is a page composed of the kit's own components, and it
Gaps worth knowing, because a documentation surface is read by exactly the people who care about these:
-- **The page's regions have no landmark or heading structure of their own.** Description, example, knobs, API table and source are four or five regions with no `role`, no `aria-label` and — verify — possibly no headings. A screen-reader user cannot jump between "the example" and "the API table". For a docs page that is the single most useful thing to add.
-- **`@name` is not necessarily a heading**, and if it is, its level is fixed — the kit-wide problem (**Panel**, **Toolbar**, **EmptyState**, **Stat**, **FittedCard** all share it).
+- **Only part of the page has heading structure.** Properties, API and CSS Variables are headed by `h2`s, so heading navigation reaches the knobs and the tables; Properties is an `` named "Properties", and each table carries its heading as its name. The description and the example have no heading or region, so a screen-reader user cannot jump to "the example".
+- **`@name` is not rendered.** It is accepted for ember-freestyle parity; the page's heading is the Spec's `h1`, and the section titles are `h2`s under it.
- **The knobs and the API table describe the same arguments and are not linked.** A user reading a row in the docs table has no route to the control that changes it.
-- **The `` source block** needs `tabindex="0"` if it scrolls, or a keyboard user cannot reach the end of a long line (**WCAG 2.1.1**), and it has no language annotation.
+- **The `` source has no language annotation.** It scrolls inside a named region ("Usage source") with a tab stop, so a keyboard user can reach the end of a long line, and the Copy button stays in view beside it.
- **Changing a knob re-renders the example silently.** A `role="status"` region would make the cause-and-effect available; without it, a screen-reader user changing a Select has no confirmation that anything happened.
- **The example region contains arbitrary live components**, so the page's overall accessibility is whatever is being demonstrated — including deliberately-broken states. That is unavoidable and worth stating: a usage page is not a claim about the component's accessibility.
## Theming
-Composes **Viewport**, **Table**, **Select**, **Input**, **Switch**, **Slider**, **SegmentedControl**, **CopyButton** and **Popover**, so it consumes their token sets rather than defining many of its own. Page structure uses `--card`, `--inset`, `--border`, `--foreground`, `--muted-foreground` and the type scale; the property list and API table pick up **Label**'s eyebrow voice.
+Composes **Viewport**, **Table**, **Select**, **Input**, **Switch**, **Slider**, **SegmentedControl**, **CopyButton** and **Popover**, so it consumes their token sets rather than defining many of its own. Page structure uses `--card` with `--card-foreground`, `--border`, `--muted-foreground`, `--boxel-border-radius` and the `--boxel-sp-*` scale; the section titles take the eyebrow role (`--boxel-eyebrow-*`), and the source is set in `--font-mono` at `--boxel-font-size-xs`. The Properties panel and the two tables are named by their `h2`s.
-The whole page renders inside the theme island, so a season change re-dresses both the documentation chrome _and_ the example — which is the point, and is what **ThemeFrame** exists to drive.
-
-The styles sit in `@layer PretComponent`, so a caller's unlayered CSS overrides them without a more specific selector.
+The whole page renders inside the theme island, so a theme change re-dresses both the documentation chrome _and_ the example — which is the point, and is what **ThemeFrame** exists to drive.
diff --git a/packages/pretui/components/freestyle-usage.test.gts b/packages/pretui/components/freestyle-usage.test.gts
index f11a592e00a..75df3db96c4 100644
--- a/packages/pretui/components/freestyle-usage.test.gts
+++ b/packages/pretui/components/freestyle-usage.test.gts
@@ -9,10 +9,10 @@ import { setupCardTest } from '@cardstack/host/tests/helpers';
import { FreestyleUsage } from './freestyle-usage';
function root(): HTMLElement {
- return document.querySelector('.FreestyleUsage') as HTMLElement;
+ return document.querySelector('[data-test-pretui-usage]') as HTMLElement;
}
function titles(): string[] {
- return Array.from(root().querySelectorAll('.FreestyleUsage-sectionTitle')).map((h) => h.textContent?.trim() ?? '');
+ return Array.from(root().querySelectorAll('[data-test-pretui-usage-section-title]')).map((h) => h.textContent?.trim() ?? '');
}
module('Pretui | components/freestyle-usage', function (hooks) {
@@ -26,9 +26,9 @@ module('Pretui | components/freestyle-usage', function (hooks) {
,
);
- assert.strictEqual(root().querySelector('.FreestyleUsage-description')?.textContent, 'Primary action.');
- assert.ok(root().querySelector('[data-test-pretui-viewport] .pretui-artboard-body [data-test-demo]'), 'the example is framed, so it can be resized');
- assert.strictEqual(root().querySelector('.wb-codestrip'), null, 'no source, no code strip');
+ assert.strictEqual(root().querySelector('[data-test-pretui-usage-description]')?.textContent, 'Primary action.');
+ assert.ok(root().querySelector('[data-test-pretui-viewport] [data-test-pretui-artboard-body] [data-test-demo]'), 'the example is framed, so it can be resized');
+ assert.strictEqual(root().querySelector('[data-test-pretui-usage-source]'), null, 'no source, no code strip');
assert.deepEqual(titles(), [], 'no api block, no Properties aside and no API table');
});
@@ -41,9 +41,9 @@ module('Pretui | components/freestyle-usage', function (hooks) {
,
);
- assert.strictEqual(root().querySelector('.FreestyleUsage-description')?.textContent, 'From the block');
- assert.strictEqual(root().querySelector('.wb-codestrip .wb-code')?.textContent, 'Go ');
- assert.ok(root().querySelector('.wb-codestrip button'), 'copy usage');
+ assert.strictEqual(root().querySelector('[data-test-pretui-usage-description]')?.textContent, 'From the block');
+ assert.strictEqual(root().querySelector('[data-test-pretui-usage-source-code]')?.textContent, 'Go ');
+ assert.ok(root().querySelector('[data-test-pretui-usage-source] [data-test-pretui-copy-button]'), 'copy usage');
});
test('the api block is yielded twice — once as knobs in the Properties aside, once as rows in the API table', async function (assert) {
@@ -66,12 +66,12 @@ module('Pretui | components/freestyle-usage', function (hooks) {
,
);
assert.deepEqual(titles(), ['Properties', 'API', 'CSS Variables']);
- let aside = root().querySelector('.FreestyleUsage-props') as HTMLElement;
- assert.deepEqual(Array.from(aside.querySelectorAll('.proprow-label')).map((l) => l.textContent?.trim()), ['label', 'disabled', '--pretui-btn-radius'], 'actions and yields have no knob');
- let apiRows = Array.from(root().querySelectorAll('.FreestyleUsage-api')[0]?.querySelectorAll('tr.FreestyleUsageArgument') ?? []);
- assert.deepEqual(apiRows.map((r) => r.querySelector('td')?.textContent?.replace(/\s+/g, ' ').trim()), ['@label', '@disabled', '@onClick', '{{default}}']);
- assert.deepEqual(apiRows.map((r) => r.querySelectorAll('td')[1]?.textContent?.trim()), ['String', 'Bool', 'Action', 'Yield']);
- let cssRows = Array.from(root().querySelectorAll('.FreestyleUsage-api')[1]?.querySelectorAll('tr.FreestyleUsageArgument') ?? []);
- assert.deepEqual(cssRows.map((r) => Array.from(r.querySelectorAll('td')).map((td) => td.textContent?.replace(/\s+/g, ' ').trim())), [['--pretui-btn-radius', 'CSS', '', '6px']]);
+ let aside = root().querySelector('[data-test-pretui-usage-props]') as HTMLElement;
+ assert.deepEqual(Array.from(aside.querySelectorAll('[data-test-pretui-prop-row-label]')).map((l) => l.textContent?.trim()), ['label', 'disabled', '--pretui-btn-radius'], 'actions and yields have no knob');
+ let apiRows = Array.from(root().querySelector('[data-test-pretui-usage-api]')?.querySelectorAll('[data-test-pretui-usage-arg]') ?? []);
+ assert.deepEqual(apiRows.map((r) => r.querySelector('th')?.textContent?.replace(/\s+/g, ' ').trim()), ['@label', '@disabled', '@onClick', '{{default}}']);
+ assert.deepEqual(apiRows.map((r) => r.querySelector('td')?.textContent?.trim()), ['String', 'Bool', 'Action', 'Yield']);
+ let cssRows = Array.from(root().querySelector('[data-test-pretui-usage-css-vars]')?.querySelectorAll('[data-test-pretui-usage-arg]') ?? []);
+ assert.deepEqual(cssRows.map((r) => Array.from(r.querySelectorAll(':is(th, td)')).map((td) => td.textContent?.replace(/\s+/g, ' ').trim())), [['--pretui-btn-radius', 'CSS', '', '6px']]);
});
});
diff --git a/packages/pretui/components/segmented-control.gts b/packages/pretui/components/segmented-control.gts
index c50fde46612..66498580159 100644
--- a/packages/pretui/components/segmented-control.gts
+++ b/packages/pretui/components/segmented-control.gts
@@ -1,5 +1,5 @@
-// Pretui — SegmentedControl: compact view switcher. The active card is
-// motion-core's SlidingHighlight, not the segment's own background.
+// Pretui — SegmentedControl: compact view switcher. The active card is a
+// SlidingHighlight pill, not the segment's own background.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
@@ -32,25 +32,14 @@ export interface SegmentedControlSignature {
Element: HTMLDivElement;
}
-// The active segment's card face is not painted by the segment: it is ONE
-// that travels between segments, driven
-// by motion-core's slidingHighlight modifier on the rail. Adopting the shared
-// primitive rather than keeping a local copy means the measuring code, the
-// first-paint suppression and the reduced-motion fallback live in exactly one
-// place for Segmented, Tabs and anything that adopts it next. The modifier
-// reads the same data-state='active' the styling already used, so nothing
-// here had to hand over its DOM or thread an active index through.
+// The active segment's card face is one
+// that slides between segments, driven by the slidingHighlight modifier on
+// the rail; it reads each label's data-state='active'.
//
-// **A radiogroup, not a tablist.** A segmented control swaps a *value*, not
-// a panel, so it is radios; a tablist's children must be tabs. Tabs, three components down this file, is the one that swaps a
-// panel. It is now what it always was: a single-choice value picker, built on
-// the same native ` ` foundation `RadioGroup` uses, which
-// hands over the entire APG radio contract — one tab stop for the group,
-// arrows to move and select, checked state exposed, form participation — with
-// no roving-tabindex code and no keyboard handler of our own. Nothing about
-// how it LOOKS changed: the radio is visually hidden, the `` wears the
-// old `.pretui-seg-item` dress and keeps `data-state='active'` so
-// SlidingHighlight measures exactly what it measured before.
+// A radiogroup, not a tablist: a segmented control picks a value, not a
+// panel. Native radios sharing a name give the APG radio contract (one tab
+// stop, arrows move and select, checked state, form participation) with no
+// keyboard code here; each radio is visually hidden and its label is the face.
export class SegmentedControl extends Component {
@tracked internal =
this.args.defaultValue ??
@@ -62,7 +51,7 @@ export class SegmentedControl extends Component {
get value() {
return this.args.value ?? this.internal;
}
- pick = (option: SegmentOption, event: Event) => {
+ pick = (option: SegmentOption) => {
if (this.args.value === undefined) {
this.internal = option.value;
}
@@ -70,10 +59,11 @@ export class SegmentedControl extends Component {
// controlled: the browser already moved the radio; move it back unless the
// owner took the new value
if (this.args.value !== undefined && this.args.value !== option.value) {
- let group = (event.target as HTMLElement).closest('.pretui-seg');
- group?.querySelectorAll('.pretui-seg-input').forEach((radio) => {
+ for (let radio of document.getElementsByName(
+ this.name,
+ ) as NodeListOf) {
radio.checked = radio.value === this.args.value;
- });
+ }
}
};
isActive = (option: SegmentOption) => this.value === option.value;
@@ -82,9 +72,9 @@ export class SegmentedControl extends Component {
class='pretui-seg'
role='radiogroup'
aria-label={{@label}}
+ {{slidingHighlight}}
data-test-pretui-segmented
...attributes
- {{slidingHighlight}}
>
{{#each this.options as |option|}}
@@ -100,50 +90,66 @@ export class SegmentedControl extends Component {
checked={{this.isActive option}}
disabled={{@disabled}}
{{on 'change' (fn this.pick option)}}
+ data-test-pretui-segmented-option={{option.value}}
/>
- {{option.label}}
+ {{option.label}}
{{/each}}
-
-;
+const ThemeControls: TemplateOnlyComponent =
+
+
+ {{#if @frame.showThemeSelect}}
+
+
+
+ {{else if @frame.activeTheme}}
+ {{@frame.themeName}}
+ {{/if}}
+
+
-
-;
+ .pretui-theme-pick {
+ min-width: var(--pretui-theme-pick-min-w);
+ }
+ .pretui-theme-name {
+ font-size: var(--boxel-font-size-xs);
+ color: var(--muted-foreground);
+ white-space: nowrap;
+ }
+ }
+
+;
export class ThemeFrame extends Component {
- @tracked mode: string = 'auto';
+ // Starts light, like ThemeDashboard: the Boxel chrome around the island
+ // has fixed colors, so the switch previews dark rather than following the OS.
+ @tracked isDarkMode = false;
@tracked selectedThemeId: string | undefined;
- setMode = (v: string) => (this.mode = v);
+ // a toggle, not a setter: the Switch in the CLI test harness passes its
+ // click event to @onChange, not a boolean
+ toggleDarkMode = () => (this.isDarkMode = !this.isDarkMode);
pickTheme = (v: string) => (this.selectedThemeId = v);
// All Theme instances in the linked theme's realm. Absent context
@@ -246,7 +185,7 @@ export class ThemeFrame extends Component {
}
get dataTheme() {
- return this.mode === 'auto' ? undefined : this.mode;
+ return this.isDarkMode ? 'dark' : 'light';
}
get themeCss() {
return this.activeTheme?.cssVariables ?? undefined;
@@ -260,7 +199,7 @@ export class ThemeFrame extends Component {
return themeScope(this.activeThemeId, this.themeCss) ?? guidFor(this);
}
get themeName() {
- return this.activeTheme?.cardTitle ?? 'No theme';
+ return this.activeTheme?.cardTitle ?? 'Untitled theme';
}
get showBar() {
return this.args.bar ?? true;
@@ -288,34 +227,20 @@ export class ThemeFrame extends Component {
{{! template-lint-enable require-scoped-style }}
{{/if}}
{{#if this.showBar}}
-
-
- {{#if this.showThemeSelect}}
-
-
-
- {{else}}
-
{{this.themeName}}
- {{/if}}
+
+
{{/if}}
- {{yield
- (component ThemePopoverControls frame=this)
- (component ThemeInlineControls frame=this)
- }}
+ {{yield (component ThemeControls frame=this)}}
diff --git a/packages/pretui/components/theme-frame.md b/packages/pretui/components/theme-frame.md
index 7a30e6ea71d..5da19a73c60 100644
--- a/packages/pretui/components/theme-frame.md
+++ b/packages/pretui/components/theme-frame.md
@@ -1,48 +1,48 @@
## What it is
-The season switcher for a documentation or catalogue surface: it applies a theme to everything inside it and yields the controls for changing which one.
+The theme previewer for a documentation or catalog surface: it applies a theme to everything inside it, light or dark, and yields the controls for changing them.
-It is a tool, not a product control — a real application's theme setting belongs in its own settings surface. This exists so a reader can see any component under any season.
+It is a tool, not a product control — a real application's theme setting belongs in its own settings surface. This exists so a reader can see any component under any theme in the realm, in both modes.
## The contract
```
@theme? — the Theme card linked from the page card's cardInfo
-@context? — pass it to enable the season selector: the frame queries the realm
- for every Theme instance, so all shipped seasons are choosable
-@bar? — false hides the frame's own pill, for pages that place the yielded
- control in their own header instead
+@context? — pass it to enable the theme selector: the frame queries the realm
+ for every Theme instance, so each one can be previewed
+@bar? — false hides the frame's own bar, for pages that place the yielded
+ controls in their own header instead
-<:default> — yields TWO header-seatable controls:
- [collapsed popover trigger, expanded inline segmented + select]
+<:default> — yields the controls: the dark mode switch, and the theme select
+ when the realm has more than one theme
```
-**The frame owns the switching state**: the light/dark/auto mode and the selected theme id live here, defaulting to `auto`. What it does not own is _persistence_ — nothing is written to storage, so a host that wants the choice to survive a reload stores it itself.
+**The mode switch is the one Boxel's ThemeDashboard uses**: a boxel-ui **Switch** labeled "Dark mode", with sun and moon icons. Off stamps `data-theme='light'` on the frame and on stamps `data-theme='dark'`, so the theme's light or dark variables apply through the same `--boxel-color-scheme` signal the host uses.
-**`@context` is the switch between one season and all of them.** Without it the frame dresses its content in `@theme` and offers only a mode toggle; with it, the frame queries the realm for every Theme instance and the season select appears.
+**The frame owns the switching state**: the mode and the selected theme id live here, starting light like ThemeDashboard. The Boxel chrome around the frame has fixed colors, so the switch previews dark mode rather than following the reader's system setting. What it does not own is _persistence_ — nothing is written to storage, so a host that wants the choice to survive a reload stores it itself.
-**It yields two presentations of the same controls, and the page picks one.** The collapsed form is a single trigger opening a **Popover**; the expanded form is an inline **SegmentedControl** plus a **Select**. A page with room seats the expanded one in its own header; a cramped one takes the trigger. `@bar={{false}}` is what stops the frame drawing its own pill when you have seated a yielded control elsewhere.
+**`@context` is the switch between one theme and all of them.** Without it the frame dresses its content in `@theme` and offers only the mode switch and the theme's name; with it, the frame queries the realm for every Theme instance, and when it finds more than one, a **Select** replaces the name.
-## Prior art
+**`@bar={{false}}`** stops the frame drawing its own bar when the page seats the yielded controls in its own header.
-The theme switcher in every component-documentation site.
+## Prior art
-Where Pretui is better: yielding the controls rather than only rendering them. A switcher that can only appear where the frame is drawn forces every catalogue page into the same header layout; yielding both presentations lets the page decide, and `@bar` lets it opt out of the default entirely.
+The theme switcher in every component-documentation site, and Boxel's own ThemeDashboard, whose mode switch this reuses.
-Where it is thinner: no persistence, no per-component theme override, and no way to preview two seasons side by side — the frame dresses one subtree at a time.
+Where it is thinner: no persistence, no per-component theme override, and no way to preview two themes side by side — the frame dresses one subtree at a time.
## Accessibility
-- **Mode and season are real controls** — a **SegmentedControl** and a **Select** — so both are reachable and announced, in either presentation.
-- **The collapsed trigger carries the active theme's name in its title**, so what is currently applied is discoverable without opening the popover.
-- **The popover is the kit's**, so light-dismiss, Escape and focus return come with it.
-- **Changing season changes contrast across the whole subtree.** That is the component's purpose, and it means a catalogue can be put into a season whose tokens fail contrast — the frame applies what it is given and does not check it.
-- **`auto` follows the platform**, which is the right default: a reader who has set a system preference should not have to set it again to browse a catalogue.
+- **The mode switch is a native checkbox with `role='switch'`**, named "Dark mode", so it is reachable, announced with its on/off state, and toggled with Space or Enter.
+- **The theme select is named "Theme".**
+- **Changing theme changes contrast across the whole subtree.** That is the component's purpose, and it means a catalog can be put into a theme whose tokens fail contrast — the frame applies what it is given and does not check it.
## Theming
-The frame applies a theme rather than being themed by one — it is the component that establishes the token scope its subtree renders in, using boxel-ui's own theme helpers rather than hand-rolling the scoping.
+The frame applies a theme rather than being themed by one — it establishes the token scope its subtree renders in, using boxel-ui's own theme helpers rather than hand-rolling the scoping.
+
+Its own controls take the active theme's tokens, so the switch and select restyle with the theme they switch to.
-Its own chrome takes the kit's control and overlay tokens, which means the switcher itself is dressed by whichever season is active. That is deliberate and occasionally disorienting: switching to a low-contrast season restyles the control you switched with.
+The island paints its own surface, `--background` with `--foreground` in `--font-sans`, because its tokens flip below the card's own surface and the card's would otherwise show through. It grows to fill the frame. The controls are a group named "Theme preview"; the theme's name, when shown, is `--muted-foreground`.
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/toast.gts b/packages/pretui/components/toast.gts
index 7b4e51fae83..937d25d0611 100644
--- a/packages/pretui/components/toast.gts
+++ b/packages/pretui/components/toast.gts
@@ -16,7 +16,9 @@ export interface ToastSignature {
export const Toast: TemplateOnlyComponent =
- {{#if (has-block 'icon')}}{{yield to='icon'}}{{/if}}
+ {{#if (has-block 'icon')}}
{{yield
+ to='icon'
+ }} {{/if}}
{{@title}}
{{#if @message}}
{{@message}}
@@ -24,43 +26,58 @@ export const Toast: TemplateOnlyComponent
=
class='pretui-toast-msg'
>{{@description}} {{/if}}
- {{#if (has-block 'action')}}{{yield to='action'}}
{{/if}}
+ {{#if (has-block 'action')}}{{yield
+ to='action'
+ }}
{{/if}}
diff --git a/packages/pretui/components/usage-argument.gts b/packages/pretui/components/usage-argument.gts
index dc7b249a1d4..90e06c6db8a 100644
--- a/packages/pretui/components/usage-argument.gts
+++ b/packages/pretui/components/usage-argument.gts
@@ -1,6 +1,7 @@
// Pretui — UsageArgument: one documented argument row (doc lens: table row; prop lens: nothing).
import Component from '@glimmer/component';
-import { isPresent } from '../internal/freestyle';
+import { VisuallyHidden } from './visually-hidden';
+import { isPresent, readOnlyText } from '../internal/freestyle';
import type { ArgsMode } from '../internal/freestyle';
// ── Freestyle::Usage::Argument (doc lens: table row; prop lens: nothing) ──
@@ -31,7 +32,7 @@ export class UsageArgument extends Component {
return isPresent(this.args.defaultValue);
}
get defaultText() {
- return String(this.args.defaultValue);
+ return readOnlyText(this.args.defaultValue);
}
// yields print as {{name}}, css vars bare (names carry --), args as @name
get sigilPre() {
@@ -44,61 +45,64 @@ export class UsageArgument extends Component {
}
{{#if this.isDoc}}
-
-
- {{this.sigilPre}} {{#if @name}}{{@name}}{{/if}}{{this.sigilPost}}
- {{#if @required}}* {{/if}}
-
- {{this.typeLabel}}
- {{@description}}
-
+
+ {{! the name heads the row, so each cell is announced with it }}
+
+ {{this.sigilPre}} {{#if
+ @name
+ }}{{@name}}{{/if}}{{this.sigilPost}}
+ {{#if @required}}* (required) {{/if}}
+
+ {{this.typeLabel}}
+ {{@description}}
+
{{#if this.shouldRenderDefaultValue}}
{{this.defaultText}}
{{else}}
- —
+ —
{{/if}}
{{/if}}
diff --git a/packages/pretui/components/usage-argument.md b/packages/pretui/components/usage-argument.md
index 4a583673a45..8ba7c06c5f1 100644
--- a/packages/pretui/components/usage-argument.md
+++ b/packages/pretui/components/usage-argument.md
@@ -7,8 +7,8 @@ The base argument row of a **FreestyleUsage** page — `Args.Base` in the yielde
```
@mode? 'doc' | 'prop' (default 'doc')
@name?, @type?, @typeLabel?, @description?, @defaultValue?
-@required?, @optional?, @hideControls?
-<:default>
+@required?, @hideControls?
+@optional? — accepted for ember-freestyle parity; it renders nothing
```
**`@mode` is the lens, and it is the architecture of the whole family.** A usage page authors its `<:api>` block **once**; `FreestyleUsage` renders it twice — as a right-hand **property list** (`prop`) and as an **API table** (`doc`) below. Every Usage component therefore knows both presentations. **`UsageArgument` renders a table row in `doc` and nothing at all in `prop`**, because there is no control to show. That asymmetry is what makes the dual-lens trick work: components with knobs appear in both, components without appear only in the docs.
@@ -33,16 +33,12 @@ No pattern governs it; it is a table row in one lens and nothing in the other.
Gaps, and most belong to the page rather than the row:
-- **The `doc` lens renders into `Table`**, so it inherits that component's contract — real ``/` ` markup, and everything **Table**'s note says: no `scope`, no caption, no `aria-sort`, all supplied by the caller. Here the caller is **FreestyleUsage**, so the API table's header semantics are its responsibility to get right.
-- **The required marker** (`@required` / `@optional`) must reach the accessible name of the row, not just render an asterisk. **FormField** in this kit made exactly that fix — a visually-hidden "(required)" inside the label — and a docs table has the same obligation.
-- **The sigil is decorative typography carrying meaning.** `{{name}}` versus `@name` versus a bare token name is a real distinction, conveyed by punctuation that screen readers announce inconsistently (or spell out). The `@type` value is presumably in its own column, which mitigates it.
+- **The name heads its row** (` `), so each Type, Description and Default cell is announced with the argument it describes. The table itself is named by its section heading in **FreestyleUsage**.
+- **The required marker** is a visual asterisk hidden from assistive tech, with a visually hidden "(required)" beside it, the same fix **FormField** made.
+- **The sigil is decorative typography carrying meaning.** `{{name}}` versus `@name` versus a bare token name is a real distinction, conveyed by punctuation that screen readers announce inconsistently (or spell out). The type is in the Type column, which mitigates it.
- **`@description` is prose in a table cell**, so it is announced only when the reader traverses to that cell — which is correct for a table and means the description is not part of the argument's name.
- **The `prop` lens renders nothing**, so an argument documented with `Base` is invisible in the property list. That is intended; it does mean a reader working from the knobs alone will not know the argument exists.
## Theming
-**Table**'s tokens for the row (`--card`, `--border`, `--stripe`, `--hover`, the mono eyebrow header voice) plus **Token**'s treatment for the type and default value, and `--muted-foreground` for the description.
-
-Nothing of its own. The type and default cells should use the mono voice — machine values as jewelry, Law 3 — so a reader can distinguish `'md'` the default from _md_ the prose.
-
-The styles sit in `@layer PretComponent`, so a caller's unlayered CSS overrides them without a more specific selector.
+**Table**'s tokens for the row (`--card` with `--card-foreground`, `--border`, `--stripe`, `--hover`, the label role in the header). The name, type and default cells are set in `--font-mono`, with the type, default and sigils in `--muted-foreground`, so a reader can tell `'md'` the default from _md_ the prose; the asterisk is `--destructive-ink`. The description takes the row's ink and stops at `--pretui-usage-arg-description-max-w` (32.5rem), a local measure.
diff --git a/packages/pretui/components/usage-argument.test.gts b/packages/pretui/components/usage-argument.test.gts
index 4d2eb4c235f..49e1ce5f6d6 100644
--- a/packages/pretui/components/usage-argument.test.gts
+++ b/packages/pretui/components/usage-argument.test.gts
@@ -9,7 +9,7 @@ import { setupCardTest } from '@cardstack/host/tests/helpers';
import { UsageArgument } from './usage-argument';
function cellTexts(): string[] {
- return Array.from(document.querySelectorAll('tr.FreestyleUsageArgument td')).map((td) => td.textContent?.replace(/\s+/g, ' ').trim() ?? '');
+ return Array.from(document.querySelectorAll('[data-test-pretui-usage-arg] :is(th, td)')).map((td) => td.textContent?.replace(/\s+/g, ' ').trim() ?? '');
}
module('Pretui | components/usage-argument', function (hooks) {
@@ -18,13 +18,14 @@ module('Pretui | components/usage-argument', function (hooks) {
test('doc mode is one API-table row: sigil + name, type, description, default', async function (assert) {
await render( );
assert.deepEqual(cellTexts(), ['@variant', 'String', 'Visual weight', 'primary']);
- assert.strictEqual(document.querySelector('.u-req'), null);
+ assert.strictEqual(document.querySelector('[data-test-pretui-usage-arg] th')?.getAttribute('scope'), 'row', 'the name heads its row');
+ assert.strictEqual(document.querySelector('[data-test-pretui-usage-arg-required]'), null);
});
test('a missing default reads as a dash, required adds the asterisk, and typeLabel overrides type', async function (assert) {
await render( );
- assert.deepEqual(cellTexts(), ['@items *', 'Item[]', '', '—']);
- assert.strictEqual(document.querySelector('.u-req')?.getAttribute('title'), 'Required');
+ assert.deepEqual(cellTexts(), ['@items * (required)', 'Item[]', '', '—']);
+ assert.strictEqual(document.querySelector('[data-test-pretui-usage-arg-required]')?.getAttribute('aria-hidden'), 'true', 'the asterisk is visual; "(required)" is the text a screen reader hears');
await render( );
assert.strictEqual(cellTexts()[3], '0', 'zero is a real default, not a missing one');
});
diff --git a/packages/pretui/components/usage-array.gts b/packages/pretui/components/usage-array.gts
index 8e419546654..e4ea0077850 100644
--- a/packages/pretui/components/usage-array.gts
+++ b/packages/pretui/components/usage-array.gts
@@ -1,5 +1,6 @@
// Pretui — UsageArray: an array argument with its knob.
import Component from '@glimmer/component';
+import { guidFor } from '@ember/object/internals';
import { Input } from './input';
import { UsageArgument } from './usage-argument';
import { PropReadOnly, PropRow, readOnlyText } from '../internal/freestyle';
@@ -34,6 +35,11 @@ export class UsageArray extends Component {
get readOnlyValue() {
return readOnlyText(this.args.value ?? this.args.defaultValue);
}
+ controlId = `${guidFor(this)}-control`;
+ // the rail labels the control when there is one
+ get labelFor() {
+ return this.hasControl ? this.controlId : undefined;
+ }
callOnInput = (raw: string) => {
this.args.onInput?.(
raw
@@ -45,12 +51,16 @@ export class UsageArray extends Component {
{{#if this.isProp}}
{{#unless @hideControls}}
-
+
{{#if this.hasControl}}
{{else}}
diff --git a/packages/pretui/components/usage-css-variable.gts b/packages/pretui/components/usage-css-variable.gts
index 37c8b6d655a..58b9791c7ee 100644
--- a/packages/pretui/components/usage-css-variable.gts
+++ b/packages/pretui/components/usage-css-variable.gts
@@ -1,5 +1,6 @@
// Pretui — UsageCssVariable: a documented CSS custom property.
import Component from '@glimmer/component';
+import { guidFor } from '@ember/object/internals';
import { Input } from './input';
import { UsageArgument } from './usage-argument';
import { PropRow } from '../internal/freestyle';
@@ -28,17 +29,18 @@ export class UsageCssVariable extends Component {
get hasControl() {
return this.args.onInput !== undefined;
}
+ controlId = `${guidFor(this)}-control`;
callOnInput = (v: string) => {
this.args.onInput?.(v);
};
{{#if this.isProp}}
{{#if this.hasControl}}
-
+
{{/if}}
diff --git a/packages/pretui/components/usage-css-variable.test.gts b/packages/pretui/components/usage-css-variable.test.gts
index 6e521829196..95f081886a0 100644
--- a/packages/pretui/components/usage-css-variable.test.gts
+++ b/packages/pretui/components/usage-css-variable.test.gts
@@ -13,7 +13,7 @@ module('Pretui | components/usage-css-variable', function (hooks) {
test('doc mode is an API row typed CSS with no sigil', async function (assert) {
await render( );
- let cells = Array.from(document.querySelectorAll('tr.FreestyleUsageArgument td')).map((td) => td.textContent?.replace(/\s+/g, ' ').trim());
+ let cells = Array.from(document.querySelectorAll('[data-test-pretui-usage-arg] :is(th, td)')).map((td) => td.textContent?.replace(/\s+/g, ' ').trim());
assert.deepEqual(cells, ['--pretui-gap', 'CSS', 'Grid gutter', '8px']);
});
@@ -21,9 +21,10 @@ module('Pretui | components/usage-css-variable', function (hooks) {
let seen: string[] = [];
let onInput = (v: string) => seen.push(v);
await render( );
- assert.strictEqual(document.querySelector('.proprow-label')?.textContent?.trim(), '--pretui-gap');
- let input = document.querySelector('.proprow input') as HTMLInputElement;
- assert.strictEqual(input.getAttribute('aria-label'), '--pretui-gap');
+ assert.strictEqual(document.querySelector('[data-test-pretui-prop-row-label]')?.textContent?.trim(), '--pretui-gap');
+ let input = document.querySelector('[data-test-pretui-prop-row] input') as HTMLInputElement;
+ let label = document.querySelector('[data-test-pretui-prop-row-label]');
+ assert.strictEqual(label?.getAttribute('for'), input.id, 'the rail is the field\'s label');
assert.strictEqual(input.value, '8px');
await fillIn(input, '12px');
assert.deepEqual(seen, ['12px']);
diff --git a/packages/pretui/components/usage-number.gts b/packages/pretui/components/usage-number.gts
index c8e3737b883..beed6c5b058 100644
--- a/packages/pretui/components/usage-number.gts
+++ b/packages/pretui/components/usage-number.gts
@@ -1,5 +1,6 @@
// Pretui — UsageNumber: a number argument with its knob.
import Component from '@glimmer/component';
+import { guidFor } from '@ember/object/internals';
import { Input } from './input';
import { Slider } from './slider';
import { UsageArgument } from './usage-argument';
@@ -44,6 +45,13 @@ export class UsageNumber extends Component {
get textValue() {
return this.args.value == null ? undefined : String(this.args.value);
}
+ controlId = `${guidFor(this)}-control`;
+ // the rail labels the text field; Slider names itself with @label
+ get labelFor() {
+ return this.hasControl && !this.shouldRenderRangeInput
+ ? this.controlId
+ : undefined;
+ }
onSlide = (v: number) => {
this.args.onInput?.(v);
};
@@ -53,10 +61,14 @@ export class UsageNumber extends Component {
{{#if this.isProp}}
{{#unless @hideControls}}
-
+
{{#if this.hasControl}}
{{#if this.shouldRenderRangeInput}}
-
+
{
@onValueChange={{this.onSlide}}
/>
{{@value}}
-
+
{{else}}
{{/if}}
{{else}}
@@ -90,22 +105,22 @@ export class UsageNumber extends Component {
/>
{{/if}}
diff --git a/packages/pretui/components/usage-number.md b/packages/pretui/components/usage-number.md
index 4d79d29b660..fb224472643 100644
--- a/packages/pretui/components/usage-number.md
+++ b/packages/pretui/components/usage-number.md
@@ -34,17 +34,15 @@ No pattern of its own; it renders a **Slider** or an **Input** plus a table row,
Gaps, and two are inherited and consequential in this context:
-- **Slider's `aria-label` defaults to the literal `'Slider'`** and it accepts no `@controlId`, so **Field**-style `` wiring is not available to it at all. A property list of numeric knobs can therefore end up as several controls all announced "Slider". This is an API hole in **Slider** that shows up first here.
-- **Slider sets no `aria-valuetext`**, so a knob announces "8" where "8 pixels" is meant — and a usage page is precisely where the unit matters, because the reader is learning what the argument does.
-- **Slider styles only `::-webkit-slider-thumb`**, so in Firefox the thumb falls back to the UA default. On a documentation page that is visible on every numeric knob.
+- **The number field is labeled by the rail.** Without bounds the knob is an **Input**, and the property row's label is its ``, so clicking the name focuses the field; `@min`, `@max` and `@step` reach the field too.
+- **The slider names itself.** With both bounds it is a **Slider**, which takes no `@controlId`, so it is named by `@label` (the argument name) and the rail stays plain text.
+- **No unit is announced.** Slider supports `@formatValue` for `aria-valuetext`, but UsageNumber has no unit argument to pass, so a knob announces "8" where "8 pixels" is meant.
- **Changing a knob re-renders the example silently** — no live region, no confirmation.
-- **`@required` must reach the accessible name** in the docs lens rather than only rendering an asterisk.
+- **`@required`** adds a visually hidden "(required)" beside the asterisk, in the property row and the doc row.
- **`null` versus `0`** is not distinguishable in the control's announcement; an unset numeric argument and one explicitly set to zero read the same.
## Theming
-**Slider**'s tokens (`--primary` for the filled track, `--line-strong` for the remainder and the thumb hairline, `--card` for the thumb, `--shadow-ink-mid`, `--font-mono` and `--ink-3` for tick labels) or **Input**'s, plus **Table**'s for the doc row and the property rail's label voice.
+**Slider**'s or **Input**'s tokens for the control, **Table**'s for the doc row, and the property rail's mono label in `--muted-foreground`. The readout beside a slider is mono at a fixed minimum width (`--numrange-readout-min-w`), so the slider doesn't shift as the value changes.
-Nothing of its own. Check Slider's thumb against `--line-strong` per season — a property list shows sliders at several positions at once, and a thumb that disappears at either end of the track makes the knob unreadable exactly where the bounds are being demonstrated.
-
-The styles sit in `@layer PretComponent`, so a caller's unlayered CSS overrides them without a more specific selector.
+A property list shows sliders at several positions at once, so a thumb that disappears at either end of the track makes the knob unreadable exactly where the bounds are being demonstrated; check the thumb against the track in both schemes.
diff --git a/packages/pretui/components/usage-object.gts b/packages/pretui/components/usage-object.gts
index ed5ab64ea12..6967861b5da 100644
--- a/packages/pretui/components/usage-object.gts
+++ b/packages/pretui/components/usage-object.gts
@@ -5,13 +5,6 @@ import { UsageArgument } from './usage-argument';
import { PropRow } from '../internal/freestyle';
import type { ArgsMode } from '../internal/freestyle';
-function stringify(v: unknown): string {
- try {
- return JSON.stringify(v, null, 2) ?? String(v);
- } catch {
- return String(v);
- }
-}
// ── Freestyle::Usage::Object (read-only) ─────────────────────────────────
export interface UsageObjectSignature {
Args: {
@@ -31,23 +24,10 @@ export class UsageObject extends Component {
get isProp() {
return this.args.mode === 'prop';
}
- get json() {
- return stringify(this.args.value);
- }
{{#if this.isProp}}
{{#unless @hideControls}}
- {{!-- Adopted 2026-08-13: this was a plain of the stringified
- value. JsonTree gives every Args.Object knob in the gallery
- collapsible nodes, type badges, copy-path and keyboard
- navigation — and an empty state instead of the literal text
- "undefined" for the call sites that pass no @value.
- The json getter is kept: it is the only caller of stringify.
- NOTE the long-form comment delimiters. A short {{! }} comment
- ends at its first closing pair, so a mustache inside one
- escapes into the template and orphans the next closing tag —
- local parse accepts it; the realm transpiler does not. --}}
{
/>
{{/if}}
diff --git a/packages/pretui/components/usage-object.md b/packages/pretui/components/usage-object.md
index c8d87f98901..4d7bde1d34a 100644
--- a/packages/pretui/components/usage-object.md
+++ b/packages/pretui/components/usage-object.md
@@ -12,7 +12,7 @@ The object argument row of a **FreestyleUsage** page — `Args.Object`. Docs len
**Note what is absent: there is no input callback.** Every other typed knob in the family round-trips a value; this one **displays** and does not edit. That is the honest boundary — a generic structured editor is a component in its own right, and inventing a half one inside a documentation row would produce a knob that can corrupt the example it is demonstrating.
-**`@value` is `unknown`**, the widest type in the family, and the component leans on `JsonTree` to render it. The module's `stringify` helper wraps `JSON.stringify` in a `try`/`catch` falling back to `String(v)`, so a circular structure or a value with a throwing `toJSON` degrades to something printable rather than breaking the page.
+**`@value` is `unknown`**, the widest type in the family, and goes straight to `JsonTree`, which takes plain JSON-safe data. A circular structure is not handled.
**`@mode` is the lens.** The `<:api>` block is authored once and rendered twice — property list (`prop`) and API table (`doc`).
@@ -24,9 +24,9 @@ This is where **the kit's most common array argument lands**: an options list of
**Storybook's `object` control** is the analogue and it _does_ edit — a JSON textarea that parses on change, plus a tree view. That is more capability and it is genuinely useful for exploring a data-driven component; it is also the control most likely to leave a story in a broken state after a typo, which is presumably why the port stopped short.
-Where the Pretui version is better: **a real tree view rather than a blob of JSON text.** `JsonTree` gives collapsible nodes, so a reader inspecting a fifty-row options array sees its shape rather than scrolling a wall. Storybook's editing textarea makes you read the raw serialisation.
+Where the Pretui version is better: **a real tree view rather than a blob of JSON text.** `JsonTree` gives collapsible nodes, so a reader inspecting a fifty-row options array sees its shape rather than scrolling a wall. Storybook's editing textarea makes you read the raw serialization.
-Where it is behind: no editing (above), no copy affordance on the tree (**CopyButton** is right there in the same file, used for `@source`), and no path display — a reader who wants to know how to reach a nested value has to count.
+Where it is behind: no editing (above). `JsonTree` rows do copy their path and value from the keyboard (Ctrl/Cmd+Shift+C and Ctrl/Cmd+C).
## Accessibility
@@ -34,17 +34,15 @@ No pattern of its own; it renders a JSON tree plus a table row.
The tree is where the accessibility questions are, and they are worth checking rather than assuming, because JSON viewers are routinely built as nested ``s with click handlers:
-- **A collapsible tree should be the APG Tree View pattern** — `role="tree"`, `role="treeitem"`, `aria-expanded` on branches (and **absent**, not `false`, on leaves), `aria-level`/`aria-posinset`/`aria-setsize`, a roving tabindex so the tree is one tab stop, and Right/Left/Up/Down/Home/End. **Tree** in this kit implements exactly that and would be the natural composition; whether `JsonTree` does is the first thing to verify.
+- **A collapsible tree should be the APG Tree View pattern** — `role="tree"`, `role="treeitem"`, `aria-expanded` on branches (and **absent**, not `false`, on leaves), `aria-level`/`aria-posinset`/`aria-setsize`, a roving tabindex so the tree is one tab stop, and Right/Left/Up/Down/Home/End. `JsonTree` implements it: `role="tree"` and `treeitem`, `aria-level`/`aria-posinset`/`aria-setsize`, `aria-expanded` on branches and a roving tabindex.
- **If it is not a tree, it is at minimum a set of disclosure buttons**, each needing `aria-expanded` and a name that says what expands.
- **A large object is a lot of announced text.** Deep JSON read linearly is close to unusable; the collapsed-by-default state is the accessibility feature, and expanding should be the user's choice.
- **Punctuation-heavy content** — braces, brackets, quotes, colons — is announced inconsistently and often verbosely. A tree with structural nesting instead of visible punctuation reads far better.
-- **`@required` must reach the accessible name** in the docs lens rather than only rendering an asterisk.
+- **`@required`** adds a visually hidden "(required)" beside the asterisk, in the property row and the doc row.
- **Nothing is editable**, so there is no invalid state to announce — which is one genuine upside of the read-only decision.
## Theming
-`JsonTree`'s tokens for the tree (mono voice, key and value inks, the disclosure affordance), **Table**'s for the doc row, plus the property rail's label voice and **Token**'s treatment for the default value.
+`JsonTree`'s tokens for the tree (mono voice, key and value inks, the disclosure affordance), **Table**'s for the doc row, and the property rail's mono label. The knob caps the tree at 7.5rem tall and sets its indent to `--boxel-sp-sm`; rows keep JsonTree's own height.
-Nothing of its own. The key/value ink distinction is the thing to check per season: a JSON tree is read by scanning for keys, and if `--foreground` and `--muted-foreground` converge, the structure flattens into an undifferentiated block of mono text.
-
-The styles sit in `@layer PretComponent`, so a caller's unlayered CSS overrides them without a more specific selector.
+The key/value ink distinction is the thing to check in each theme: a JSON tree is read by scanning for keys, and if `--foreground` and `--muted-foreground` converge, the structure flattens into an undifferentiated block of mono text.
diff --git a/packages/pretui/components/usage-object.test.gts b/packages/pretui/components/usage-object.test.gts
index 5184ee6e09e..fd7752b7408 100644
--- a/packages/pretui/components/usage-object.test.gts
+++ b/packages/pretui/components/usage-object.test.gts
@@ -13,15 +13,15 @@ module('Pretui | components/usage-object', function (hooks) {
test('doc mode is an API row typed Object', async function (assert) {
await render(
);
- let cells = Array.from(document.querySelectorAll('tr.FreestyleUsageArgument td')).map((td) => td.textContent?.replace(/\s+/g, ' ').trim());
- assert.deepEqual(cells, ['@item *', 'Object', 'The record', '—']);
+ let cells = Array.from(document.querySelectorAll('[data-test-pretui-usage-arg] :is(th, td)')).map((td) => td.textContent?.replace(/\s+/g, ' ').trim());
+ assert.deepEqual(cells, ['@item * (required)', 'Object', 'The record', '—']);
});
test('prop mode shows the value as a labelled JSON tree, or nothing when controls are hidden', async function (assert) {
const VALUE = { id: 7, tags: ['a', 'b'] };
await render(
);
- assert.strictEqual(document.querySelector('.proprow-label')?.textContent?.replace(/\s+/g, ' ').trim(), 'item *');
- let tree = document.querySelector('.proprow [data-test-pretui-json-tree]') as HTMLElement;
+ assert.strictEqual(document.querySelector('[data-test-pretui-prop-row-label]')?.textContent?.replace(/\s+/g, ' ').trim(), 'item * (required)');
+ let tree = document.querySelector('[data-test-pretui-prop-row] [data-test-pretui-json-tree]') as HTMLElement;
assert.ok(tree, 'a JsonTree, not a
of stringified JSON');
assert.true(tree.textContent?.includes('tags'));
assert.true(tree.textContent?.includes('7'));
@@ -32,7 +32,7 @@ module('Pretui | components/usage-object', function (hooks) {
test('prop mode with no value still renders the tree, so a call site that passes no @value gets an empty state rather than "undefined"', async function (assert) {
await render( );
- let tree = document.querySelector('.proprow [data-test-pretui-json-tree]') as HTMLElement;
+ let tree = document.querySelector('[data-test-pretui-prop-row] [data-test-pretui-json-tree]') as HTMLElement;
assert.ok(tree);
assert.false(tree.textContent?.includes('undefined'));
});
diff --git a/packages/pretui/components/usage-string.gts b/packages/pretui/components/usage-string.gts
index b791c016cfd..a686ba94764 100644
--- a/packages/pretui/components/usage-string.gts
+++ b/packages/pretui/components/usage-string.gts
@@ -1,5 +1,6 @@
// Pretui — UsageString: a string argument with its knob.
import Component from '@glimmer/component';
+import { guidFor } from '@ember/object/internals';
import { Input } from './input';
import { Select } from './select';
import { UsageArgument } from './usage-argument';
@@ -39,25 +40,35 @@ export class UsageString extends Component {
get readOnlyValue() {
return readOnlyText(this.args.value ?? this.args.defaultValue);
}
+ controlId = `${guidFor(this)}-control`;
+ // the rail labels the control when there is one
+ get labelFor() {
+ return this.hasControl ? this.controlId : undefined;
+ }
callOnInput = (v: string) => {
this.args.onInput?.(v);
};
{{#if this.isProp}}
{{#unless @hideControls}}
-
+
{{#if this.hasControl}}
{{#if @options}}
{{else}}
{{/if}}
{{else}}
diff --git a/packages/pretui/components/usage-string.md b/packages/pretui/components/usage-string.md
index 116cf38f838..5cb90478b0c 100644
--- a/packages/pretui/components/usage-string.md
+++ b/packages/pretui/components/usage-string.md
@@ -34,8 +34,8 @@ No pattern of its own; it renders one of two kit controls plus a table row.
Gaps:
-- **The knob's label is the property-row rail, not a ``.** Verify the label and the control are actually associated — a property list of unlabelled Inputs and Selects is the most likely failure here, and neither **Input** nor **Select** generates its own name. Both accept `@controlId`, so the fix is available.
-- **`@required` must reach the accessible name**, not only render an asterisk — the fix **FormField** already made in the forms territory.
+- **The rail is the control's ``**, so the visible argument name is the control's accessible name, and clicking it focuses the Input. The Select's trigger takes the same label through `aria-labelledby`.
+- **`@required`** adds a visually hidden "(required)" beside the asterisk, so it is part of the label, the same fix **FormField** made.
- **Changing a knob re-renders the example silently.** No live region announces the effect, so a screen-reader user changing `@size` from `md` to `lg` gets no confirmation that anything happened. This belongs to **FreestyleUsage** but is felt here.
- **Free-text mode has no format guidance.** A string argument expecting a CSS length or an ISO date offers a bare Input with no hint (**WCAG 3.3.2**), and `@description` lives in the _other_ lens — so the docs table has the explanation and the knob does not.
- **The two lenses are not linked.** A reader in the property list has no route to the row documenting the same argument.
@@ -43,6 +43,4 @@ Gaps:
## Theming
-**Select** and **Input** token sets for the control, **Table**'s for the doc row, plus the property rail's label voice (`--muted-foreground`, the mono eyebrow treatment) and **Token**'s for the type and default value.
-
-Nothing of its own. Because the property list is dense and its labels are small, a season should verify the rail's ink against `--card` at the eyebrow size — this is a surface read closely by people comparing values, and it is one of the smallest text sizes in the kit.
+**Select** and **Input** token sets for the control, **Table**'s for the doc row, and the property rail's label: mono at `--boxel-font-size-xs` in `--muted-foreground`, which measures 6.9:1 on `--card` in light and 5.6:1 in dark.
diff --git a/packages/pretui/components/viewport.gts b/packages/pretui/components/viewport.gts
index 067383e4b0e..fbd7557945e 100644
--- a/packages/pretui/components/viewport.gts
+++ b/packages/pretui/components/viewport.gts
@@ -1,6 +1,7 @@
// Pretui — Viewport: the artboard every usage example renders in.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
+import { htmlSafe } from '@ember/template';
import { modifier } from 'ember-modifier';
import { SegmentedControl } from './segmented-control';
import { Select } from './select';
@@ -8,7 +9,7 @@ import { Slider } from './slider';
import { Switch } from './switch';
function htmlWidth(w: string) {
- return `width: ${w}`;
+ return htmlSafe(`width: ${w}`);
}
function isInline(mode: string) {
return mode === 'inline';
@@ -32,19 +33,20 @@ const VIEWPORT_MODES = [
{ value: 'grid', label: 'Grid' },
];
const PRESET_WIDTHS: Record = {
- phone: 375,
- tablet: 768,
+ phone: 320,
+ tablet: 600,
desktop: 1120,
};
-const BREAKPOINTS = [
- { bp: 'phone', caption: 'Phone · 375px' },
- { bp: 'tablet', caption: 'Tablet · 768px' },
- { bp: 'desktop', caption: 'Desktop · 1120px' },
-];
+const BREAKPOINTS = Object.entries(PRESET_WIDTHS).map(([bp, px]) => ({
+ bp,
+ width: `${px}px`,
+ caption: `${VIEWPORT_MODES.find((m) => m.value === bp)?.label} · ${px}px`,
+}));
const SURFACES = [
{ value: 'background', label: 'Background' },
{ value: 'card', label: 'Card' },
{ value: 'inset', label: 'Inset' },
+ { value: 'sidebar', label: 'Sidebar' },
];
// pre-artboard mode names still referenced by older pages
const LEGACY_MODES: Record = {
@@ -65,8 +67,6 @@ export interface ViewportSignature {
| 'grid'
| 'narrow'
| 'wide';
- // artboard caption, e.g. the component name — renders "Name · "
- label?: string;
};
Blocks: { default: [] };
Element: HTMLDivElement;
@@ -85,12 +85,14 @@ export class Viewport extends Component {
this.mode = v;
this.customWidth = 0;
};
+ // A free width belongs to Fill, so every preset click afterwards is a real
+ // change that resets it; snapped to the slider's 5px step so the slider
+ // can land on a dragged width.
setWidth = (v: number) => {
- this.customWidth = Math.round(Math.min(1600, Math.max(240, v)));
- if (!this.isFramed) {
- this.mode = 'fill';
- }
+ this.customWidth = Math.round(Math.min(1600, Math.max(240, v)) / 5) * 5;
+ this.mode = 'fill';
};
+ formatWidth = (v: number) => `${v} pixels`;
setSurface = (v: string) => (this.surface = v);
setGutter = (v: boolean) => (this.gutter = v);
get isFramed() {
@@ -100,13 +102,13 @@ export class Viewport extends Component {
return this.customWidth || PRESET_WIDTHS[this.mode] || 0;
}
get widthLabel() {
- return this.artboardWidth ? `${this.artboardWidth}px` : 'fill';
- }
- get frameStyleWidth() {
return this.artboardWidth ? `${this.artboardWidth}px` : '100%';
}
+ // named by the selected mode, like the 3-up captions; a dragged width is
+ // Fill, so it reads "Fill · 412px"
get frameCaption() {
- return `${this.args.label ?? 'Specimen'} · ${this.widthLabel}`;
+ let mode = VIEWPORT_MODES.find((m) => m.value === this.mode)?.label;
+ return `${mode} · ${this.widthLabel}`;
}
// Drag-to-resize on the artboard edge. Pointer capture keeps every event
// on the handle — no document listeners (backdrop-close discipline).
@@ -126,6 +128,7 @@ export class Viewport extends Component {
// user-select: none for the duration in case a selection was already
// under way when the drag started.
let down = (e: PointerEvent) => {
+ if (e.button !== 0) return;
active = true;
startX = e.clientX;
startW = artboard?.getBoundingClientRect().width ?? 0;
@@ -159,6 +162,8 @@ export class Viewport extends Component {
{
@@ -186,13 +195,19 @@ export class Viewport extends Component {
@max={{1600}}
@step={{5}}
@label='Artboard width'
+ @formatValue={{this.formatWidth}}
@onValueChange={{this.setWidth}}
/>
{{this.widthLabel}}
+ {{! wide artboards pan inside the canvas, so it takes a tab stop and a
+ name for keyboard users }}
@@ -201,12 +216,15 @@ export class Viewport extends Component
{
class='pretui-artboard'
data-label={{this.frameCaption}}
data-width={{this.widthLabel}}
- style={{htmlWidth this.frameStyleWidth}}
+ style={{htmlWidth this.widthLabel}}
+ data-test-pretui-artboard
>
{{yield}}
@@ -224,11 +242,15 @@ export class Viewport extends Component {
class='pretui-artboard'
data-bp={{b.bp}}
data-label={{b.caption}}
+ style={{htmlWidth b.width}}
+ data-test-pretui-artboard
>
{{yield}}
@@ -236,16 +258,23 @@ export class Viewport extends Component {
{{/each}}
{{else if (isInline this.mode)}}
-
The order ledger closes at noon, and
- {{yield}}
+ {{! a div, not a p: the specimen may be block-level markup }}
+
The order ledger closes at noon,
+ and
+
{{yield}}
sits inline with the running text — cap-line trim and optical
- spacing judged mid-paragraph, exactly where machine values live.
+ spacing judged mid-paragraph, exactly where machine values live.
{{else}}
{{#each this.cells}}
{{yield}}
{{/each}}
@@ -255,7 +284,32 @@ export class Viewport extends Component
{
diff --git a/packages/pretui/components/viewport.md b/packages/pretui/components/viewport.md
index cc57cc67eaf..591c8692add 100644
--- a/packages/pretui/components/viewport.md
+++ b/packages/pretui/components/viewport.md
@@ -1,24 +1,26 @@
## What it is
-The artboard: a framed stage that renders its content at a chosen device width, with a caption stating the real width. Use it around any example that needs to be seen at a size other than the page's — every **FreestyleUsage** example sits in one. If you just need a bounded box, plain CSS is enough; Viewport is for _comparing_ a component across widths.
+The artboard: a framed stage that renders its content at a chosen device width, with a caption stating its width. Use it around any example that needs to be seen at a size other than the page's — every **FreestyleUsage** example sits in one. If you just need a bounded box, plain CSS is enough; Viewport is for _comparing_ a component across widths.
## The contract
```
-@defaultMode? 'fill' | 'phone' | 'tablet' | 'desktop' | 'bp' | 'inline' | 'grid' | 'narrow' | 'wide'
-@label? — artboard caption; renders "Name · "
+@defaultMode? 'fill' | 'phone' | 'tablet' | 'desktop' | 'bp' | 'inline' | 'grid'
+ ('narrow' and 'wide' are kept as aliases for 'phone' and 'desktop')
<:default>
```
-**True widths that pan rather than clamp.** A `phone` artboard is genuinely 390px wide and the stage scrolls horizontally to show it, rather than scaling the content down or clamping to the available space. That is the difference between an artboard and a resized div: a clamped preview lies about what the component does at that width, and a scaled one lies about its type size.
+**True widths that pan rather than clamp.** A `phone` artboard is genuinely 320px wide and the stage scrolls horizontally to show it, rather than scaling the content down or clamping to the available space. That is the difference between an artboard and a resized div: a clamped preview lies about what the component does at that width, and a scaled one lies about its type size.
-**`bp` is a 3-up mode** — three breakpoints side by side, which is how you actually check a responsive component.
+**`bp` is a 3-up mode** — three breakpoints side by side, which is how you actually check a responsive component. **`inline`** sets the specimen inside a line of running text, to judge it mid-paragraph. **`grid`** repeats it in six cells, for density.
-**The caption is live**: `"Name · 390px"`, updating as the drag handle moves. An artboard whose label does not track its real width is worse than no label, because it invites you to trust it.
+**The stage** takes one of four surfaces (Background, a translucent veil of the page color; Card; Sidebar; Inset) and an optional Padding. Both apply to the framed modes and the grid cells; Inline sits in prose, so the two controls are disabled there.
-**The drag handle uses pointer capture, not document listeners.** Same discipline as **Popover**'s backdrop and **SplitPanes**' handle: the element's lifetime is the interaction's lifetime, so there is nothing to leak and nothing to remove on teardown.
+**The caption is live**: `"Phone · 320px"` for a preset, `"Fill · 100%"` for the full width, and `"Fill · 412px"` once you drag or use the width slider, updating as it moves. A dragged width belongs to Fill, so clicking a preset afterwards always resets it. An artboard whose label does not track its real width is worse than no label, because it invites you to trust it.
-State is reflected as `data-*`, so a season or a test can read the current mode.
+**The drag handle uses pointer capture, not document listeners.** Same discipline as **Popover**'s backdrop and **SplitPanes**' handle: the listeners live on the handle, so teardown only unbinds them and clears the resize flag. Only the primary button starts a drag.
+
+State is reflected as `data-*`, so a theme or a test can read the current mode.
## Prior art
@@ -37,19 +39,17 @@ Where it is thinner: no device chrome, no orientation toggle, no zoom, and no de
No pattern governs it; it is a tool, and the criteria are WCAG **2.1.1 Keyboard**, **2.5.7 Dragging Movements** and **1.4.10 Reflow**.
-Gaps, and the first two are the ones that matter for a tool:
-
-- **The drag handle needs a keyboard path.** A width control operated only by dragging is a **WCAG 2.1.1** failure and, separately, a **2.5.7 Dragging Movements** failure — 2.5.7 specifically requires a single-pointer alternative, which keyboard support does _not_ satisfy. The mode presets are the pointer alternative for the common cases, so 2.5.7 is arguably met; arrow keys on a focused handle would close 2.1.1. Verify whether the handle is focusable at all.
-- **The handle should be `role="separator"` with `aria-valuenow`/`min`/`max` and an accessible name** — the same contract **SplitPanes**' divider needs, and the same one to check.
-- **The live caption is not a live region**, so a screen-reader user dragging (or arrowing) the handle gets no width feedback. Since the caption exists and is already updating, wrapping it in `role="status"` is nearly free — though it would then chatter during a drag, so `aria-valuenow` on the handle is the better channel.
-- **The panning stage needs `tabindex="0"`** if it scrolls horizontally, or a keyboard-only user cannot pan a `desktop` artboard inside a narrow page.
-- **Mode presets are presumably a SegmentedControl**, which in this kit carries `role="tablist"` over children with no `role="tab"` — see that component's note. The mode choice may be announced as an unstructured group of buttons.
-- **The framed content is arbitrary**, so anything inside keeps its own tab order — which means tabbing through a 3-up view traverses three copies of the same component. That is expected for a tool and worth knowing.
+- **The width slider is the keyboard path.** The drag handle is a redundant pointer target, hidden from assistive tech and not focusable; the **Slider** beside it sets the same width with the arrow keys (5px steps), Page Up/Down and Home/End, and announces it as "N pixels". The presets and a click on the slider track are the single-pointer alternatives 2.5.7 asks for.
+- **The canvas is a named region with a tab stop** ("Artboard canvas"), so a keyboard user can pan a `desktop` or 3-up artboard that is wider than the page.
+- **The mode picker is a SegmentedControl** (a radiogroup named "Viewport mode"). In a panel narrower than its seven segments it scrolls on its own instead of widening the page.
+- **In Fill, 3-up, Inline and Grid the slider reads its 240px minimum**, since those modes have no single fixed width to report.
+- **The live caption is not a live region**, so dragging gives a screen-reader user no width feedback; the slider's announced value is the channel.
+- **The framed content is arbitrary**, so anything inside keeps its own tab order: tabbing through a 3-up view traverses three copies of the component, and Grid six. That is expected for a tool and worth knowing.
## Theming
-Stage and gutter surfaces (`--canvas` or `--inset`), the frame's `--border` and `--pretui-shadow-card`, `--muted-foreground` for the caption, and **SegmentedControl**'s tokens for the mode picker.
+Stage surfaces (`--background`, `--card`, `--inset`, and `--sidebar` / `--sidebar-foreground` / `--sidebar-border`), `--border` for frames and the canvas dots, `--shadow-sm` on the card surface, `--border-strong` for the resize grip, `--primary-ink` for the artboard caption and the hovered grip, `--muted-foreground` for the width readout and the Padding label, a translucent `--background` veil over the dot floor on the Background surface, and and the tokens of the **SegmentedControl**, **Select**, **Switch** and **Slider** in the toolbar.
-**The stage is deliberately neutral** with explicit surface and gutter settings, because an artboard that shares the page's background makes the framed component's own surface invisible. A season must keep the stage distinguishable from `--card` — otherwise every example appears to float in nothing, which is exactly the illusion an artboard exists to prevent.
+**The stage is deliberately neutral** with explicit surface and padding settings, because an artboard that shares the page's background makes the framed component's own surface invisible. A theme must keep the stage distinguishable from `--card` — otherwise every example appears to float in nothing, which is exactly the illusion an artboard exists to prevent.
The styles sit in `@layer PretComponent`, so a caller's unlayered CSS overrides them without a more specific selector.
diff --git a/packages/pretui/controls-entry.test.gts b/packages/pretui/controls-entry.test.gts
index aa9dc6e8ba2..3dffc025a99 100644
--- a/packages/pretui/controls-entry.test.gts
+++ b/packages/pretui/controls-entry.test.gts
@@ -240,6 +240,18 @@ module('controls-entry | Toggle', function (hooks) {
assert.strictEqual(button.getAttribute('title'), 'Pin this row');
});
+ test('a disabled field with a value shows no Clear', async function (assert) {
+ await render(
+
+ );
+ assert.strictEqual(one('[data-test-pretui-autocomplete-clear]'), null);
+ });
+
test('the treatment is reflected as data attributes', async function (assert) {
await render(
X
@@ -567,7 +579,7 @@ module('controls-entry | Autocomplete', function (hooks) {
0,
'the option is a bare overlay; every glyph lives in the aria-hidden face',
);
- let face = one('.pretui-ac-face');
+ let face = one('[data-test-pretui-autocomplete-face]');
assert.strictEqual(face.getAttribute('aria-hidden'), 'true');
});
@@ -598,6 +610,7 @@ module('controls-entry | Autocomplete', function (hooks) {
let input = one(FIELD) as HTMLInputElement;
assert.strictEqual(input.getAttribute('aria-disabled'), 'true');
assert.false(input.disabled, 'aria-disabled, never the native attribute');
+ assert.true(input.readOnly, 'readonly, so the browser cannot edit the text');
await focus(input);
assert.strictEqual(input.getAttribute('aria-expanded'), 'false');
assert.strictEqual(
@@ -669,7 +682,13 @@ module('controls-entry | usage pages', function (hooks) {
let Demo = PAGES[name] as AnyComponent;
assert.ok(Demo, name + ' is present in the registry');
await render( );
- assert.ok(one('.FreestyleUsage'), name + ' rendered a FreestyleUsage shell');
+ assert.ok(one('[data-test-pretui-usage]'), name + ' rendered a FreestyleUsage shell');
+ assert.ok(
+ Array.from(root().querySelectorAll('[data-test-pretui-viewport-specimen]')).some(
+ (s) => s.childElementCount > 0 || Boolean(s.textContent?.trim()),
+ ),
+ name + ' shows its example',
+ );
});
}
});
diff --git a/packages/pretui/design-layers.test.gts b/packages/pretui/design-layers.test.gts
index 02a0c795c8c..cc687f06b82 100644
--- a/packages/pretui/design-layers.test.gts
+++ b/packages/pretui/design-layers.test.gts
@@ -722,9 +722,15 @@ module('Pretui | design-layers | demo pages', function (hooks) {
assert.ok(Demo, name + ' is in the registry');
await render( );
assert.ok(
- root().querySelector('.FreestyleUsage'),
+ root().querySelector('[data-test-pretui-usage]'),
name + ' rendered its usage shell',
);
+ assert.ok(
+ Array.from(root().querySelectorAll('[data-test-pretui-viewport-specimen]')).some(
+ (s) => s.childElementCount > 0 || Boolean(s.textContent?.trim()),
+ ),
+ name + ' shows its example',
+ );
});
}
});
diff --git a/packages/pretui/example-gallery.gts b/packages/pretui/example-gallery.gts
index 81806ecd477..ae5e6b62aeb 100644
--- a/packages/pretui/example-gallery.gts
+++ b/packages/pretui/example-gallery.gts
@@ -17,83 +17,112 @@ export class ExampleGallery extends Component<{
{{#if this.specs}}
- Examples
+ Examples
{{this.countLabel}}
-
+
{{#each this.specs as |spec|}}
-
+
- {{spec.title}}
- {{#if spec.note}}{{spec.note}} {{/if}}
+
{{spec.title}}
+ {{#if spec.note}}{{spec.note}} {{/if}}
-
+
{{/each}}
-
+
{{/if}}
diff --git a/packages/pretui/feedback-toaster.test.gts b/packages/pretui/feedback-toaster.test.gts
index a29e021de18..9cdefdbf5e6 100644
--- a/packages/pretui/feedback-toaster.test.gts
+++ b/packages/pretui/feedback-toaster.test.gts
@@ -441,6 +441,6 @@ module('Pretui | Toaster | usage page', function (hooks) {
let Page = PAGES['Toaster'];
assert.ok(Page, 'the page is in the registry');
await render( );
- assert.dom('.FreestyleUsage').exists('the page mounted');
+ assert.dom('[data-test-pretui-usage]').exists('the page mounted');
});
});
diff --git a/packages/pretui/foundations-render.test.gts b/packages/pretui/foundations-render.test.gts
index cce3eb9af93..a280a7d6868 100644
--- a/packages/pretui/foundations-render.test.gts
+++ b/packages/pretui/foundations-render.test.gts
@@ -27,7 +27,13 @@ module('Pretui | foundation usage pages', function (hooks) {
test(`${name} mounts with representative data`, async function (assert) {
let Demo = PAGES[name] as AnyComponent;
await render( );
- assert.ok(document.querySelector('.FreestyleUsage'), `${name} rendered`);
+ assert.ok(document.querySelector('[data-test-pretui-usage]'), `${name} rendered`);
+ assert.ok(
+ Array.from(document.querySelectorAll('[data-test-pretui-viewport-specimen]')).some(
+ (s) => s.childElementCount > 0 || Boolean(s.textContent?.trim()),
+ ),
+ `${name} shows its example`,
+ );
assert.notOk(
document.body.textContent?.includes('There is no JSON to display.'),
`${name} has no empty object fixture`,
diff --git a/packages/pretui/freestyle-controls.test.gts b/packages/pretui/freestyle-controls.test.gts
index 8b93f6cfe3e..85b460ab8b2 100644
--- a/packages/pretui/freestyle-controls.test.gts
+++ b/packages/pretui/freestyle-controls.test.gts
@@ -22,6 +22,17 @@ class Sink {
takeArray = (value: string[]) => (this.arrayValue = value);
}
+// the control a property row's visible label points at, so the test also
+// proves the rail names its control
+function controlLabelled(name: string): HTMLElement {
+ let label = Array.from(
+ document.querySelectorAll('[data-test-pretui-prop-row-label]'),
+ ).find((element) => element.textContent?.trim() === name);
+ let target = label?.getAttribute('for');
+ if (!target) throw new Error(`no names the ${name} control`);
+ return document.getElementById(target) as HTMLElement;
+}
+
module('Pretui | freestyle property controls', function (hooks) {
setupCardTest(hooks);
@@ -101,10 +112,10 @@ module('Pretui | freestyle property controls', function (hooks) {
/>
);
- await fillIn('[aria-label="label"]', 'New lot');
+ await fillIn(controlLabelled('label'), 'New lot');
await click('[aria-label="enabled"]');
- await fillIn('[aria-label="count"]', '8');
- await fillIn('[aria-label="tags"]', 'green, spring');
+ await fillIn(controlLabelled('count'), '8');
+ await fillIn(controlLabelled('tags'), 'green, spring');
assert.strictEqual(sink.stringValue, 'New lot');
assert.strictEqual(sink.boolValue, true);
diff --git a/packages/pretui/ink.test.gts b/packages/pretui/ink.test.gts
index ea77eb9ac52..6abd09d440e 100644
--- a/packages/pretui/ink.test.gts
+++ b/packages/pretui/ink.test.gts
@@ -76,6 +76,17 @@ module('Pretui | ink', function (hooks) {
);
});
+ test('Chip is neutral by default and reflects a known @tone', async function (assert) {
+ await render(
+
+
+
+ ,
+ );
+ assert.strictEqual(q('[data-test-neutral]').getAttribute('data-tone'), 'neutral');
+ assert.strictEqual(q('[data-test-toned]').getAttribute('data-tone'), 'success');
+ });
+
// ── StatusChip ──────────────────────────────────────────────────────────
test('StatusChip derives its hue from the value and renders it as the label', async function (assert) {
await render( );
@@ -95,6 +106,25 @@ module('Pretui | ink', function (hooks) {
);
});
+ test('a toned StatusChip hands its dot to the tone, and neutral keeps the status hue', async function (assert) {
+ await render(
+
+
+
+ ,
+ );
+ assert.strictEqual(q('[data-test-toned]').getAttribute('data-tone'), 'success');
+ assert.strictEqual(
+ q('[data-test-toned]').getAttribute('style'),
+ null,
+ 'no status hue overrides the tone on a toned chip',
+ );
+ assert.true(
+ q('[data-test-neutral]').getAttribute('style')?.includes(statusHue('live')),
+ 'an explicit neutral tone renders like an omitted one',
+ );
+ });
+
// ── Delta ───────────────────────────────────────────────────────────────
test('Delta signs the number and reflects the direction', async function (assert) {
await render(
diff --git a/packages/pretui/internal/freestyle.gts b/packages/pretui/internal/freestyle.gts
index c83353c5595..ef43ce99915 100644
--- a/packages/pretui/internal/freestyle.gts
+++ b/packages/pretui/internal/freestyle.gts
@@ -1,15 +1,9 @@
-// Pretui — shared by the usage-page machinery: ember-freestyle, ported (verbatim-reuse directive) and dogfooded:
-// same invocation surface as addon/components/freestyle/usage (<:example>,
-// <:api as |Args|> with Args.String/Bool/Number/Array/Object/Component/
-// Action/Yield/Base, <:cssVars as |Css|>), but the machinery wears the kit —
-// Select/Input/Switch/Slider as knob controls, Table for the API docs, and
-// the Viewport frame around every example. Layout evolution (Chris): the
-// interactive knobs render as a right-hand PROPERTY LIST while the API table
-// below documents types/descriptions/defaults — the same <:api> block is
-// yielded twice through two lenses (prop / doc), so usage pages stay
-// verbatim-freestyle. Deliberate deltas: no ember-freestyle service, plain
-// for @source, labeled controls.
+// Pretui — shared pieces of the usage pages (components/freestyle-usage.gts
+// and the usage-* argument components): PropRow, one row of the property
+// list; PropReadOnly, a knob's value when it has no control; the
+// isPresent/readOnlyText helpers; and the ArgsMode/UsagePresetSignature types.
import type { TemplateOnlyComponent } from '@ember/component/template-only';
+import { VisuallyHidden } from '../components/visually-hidden';
export function isPresent(v: unknown): boolean {
return v !== undefined && v !== null && v !== '';
@@ -23,41 +17,64 @@ export function readOnlyText(v: unknown): string {
export type ArgsMode = 'doc' | 'prop';
// ── Property-list row (the prop lens) ────────────────────────────────────
interface PropRowSignature {
- Args: { label?: string; required?: boolean };
+ Args: {
+ label?: string;
+ required?: boolean;
+ /** id of the row's control: the rail becomes its , so the
+ * visible text names the control and a click on it focuses it */
+ controlId?: string;
+ };
Blocks: { default: [] };
Element: HTMLDivElement;
}
export const PropRow: TemplateOnlyComponent =
-
-
- {{@label}}
- {{#if @required}}* {{/if}}
-
-
{{yield}}
+
+ {{#if @controlId}}
+
+ {{@label}}
+ {{#if @required}}* (required) {{/if}}
+
+ {{else}}
+
+ {{@label}}
+ {{#if @required}}* (required) {{/if}}
+
+ {{/if}}
+
{{yield}}
;
@@ -67,22 +84,23 @@ interface PropReadOnlySignature {
Element: HTMLSpanElement;
}
-export const PropReadOnly: TemplateOnlyComponent
=
- {{@value}}
-
- ;
+
+ ;
// ── Action / Yield / Component presets (doc-only rows) ───────────────────
export interface UsagePresetSignature {
Args: {
diff --git a/packages/pretui/media-library.test.gts b/packages/pretui/media-library.test.gts
index 3d3b800eb6d..3c2e834d088 100644
--- a/packages/pretui/media-library.test.gts
+++ b/packages/pretui/media-library.test.gts
@@ -588,9 +588,15 @@ module('Pretui | media-library | demo pages', function (hooks) {
assert.ok(Demo, name + ' is in the registry');
await render( );
assert.ok(
- root().querySelector('.FreestyleUsage'),
+ root().querySelector('[data-test-pretui-usage]'),
name + ' rendered its usage shell',
);
+ assert.ok(
+ Array.from(root().querySelectorAll('[data-test-pretui-viewport-specimen]')).some(
+ (s) => s.childElementCount > 0 || Boolean(s.textContent?.trim()),
+ ),
+ name + ' shows its example',
+ );
});
}
});
diff --git a/packages/pretui/menu-context.test.gts b/packages/pretui/menu-context.test.gts
index b33af3d238f..02818448dd5 100644
--- a/packages/pretui/menu-context.test.gts
+++ b/packages/pretui/menu-context.test.gts
@@ -422,6 +422,6 @@ module('Pretui | ContextMenu | usage page', function (hooks) {
let Page = PAGES['ContextMenu'];
assert.ok(Page, 'the page is in the registry');
await render( );
- assert.dom('.FreestyleUsage').exists('the page mounted');
+ assert.dom('[data-test-pretui-usage]').exists('the page mounted');
});
});
diff --git a/packages/pretui/note-text.ts b/packages/pretui/note-text.ts
new file mode 100644
index 00000000000..c70f8b718a7
--- /dev/null
+++ b/packages/pretui/note-text.ts
@@ -0,0 +1,32 @@
+// Pretui — plain-text views of a sticky note's markdown, shared by the note
+// card and the Spec page's notes rail (which can't import the note card: the
+// note links to the Spec, so the import would be a cycle).
+
+// a line's leading markdown furniture: quote, heading and list markers
+const LINE_PREFIX = /^[\s>#*\-+]+/;
+// inline emphasis and code marks
+const INLINE_MARKS = /[`*_]/g;
+
+function plainLines(note: string | undefined): string[] {
+ return (note ?? '')
+ .split('\n')
+ .map((line) =>
+ line.replace(LINE_PREFIX, '').replace(INLINE_MARKS, '').trim(),
+ )
+ .filter((line) => line.length > 0);
+}
+
+/**
+ * First line of a note, flattened to plain text and shortened — the note's
+ * title in card lists, search results, and the assistant's card picker.
+ */
+export function noteSummary(note: string | undefined): string {
+ let first = plainLines(note)[0];
+ if (!first) return 'Sticky note';
+ return first.length > 72 ? `${first.slice(0, 71)}…` : first;
+}
+
+/** Everything after the first line, as one run of plain text ('' if none). */
+export function noteExcerpt(note: string | undefined): string {
+ return plainLines(note).slice(1).join(' ');
+}
diff --git a/packages/pretui/overlay-confirm.test.gts b/packages/pretui/overlay-confirm.test.gts
index 3fe784ac410..9058a83f5d5 100644
--- a/packages/pretui/overlay-confirm.test.gts
+++ b/packages/pretui/overlay-confirm.test.gts
@@ -607,20 +607,20 @@ module('Pretui | DEMOS_OVERLAY_CONFIRM | usage pages', function (hooks) {
let Page = PAGES['AlertDialog'];
assert.ok(Page, 'the page is in the registry');
await render( );
- assert.dom('.FreestyleUsage').exists('the page mounted');
+ assert.dom('[data-test-pretui-usage]').exists('the page mounted');
});
test('the Popconfirm usage page renders', async function (assert) {
let Page = PAGES['Popconfirm'];
assert.ok(Page, 'the page is in the registry');
await render( );
- assert.dom('.FreestyleUsage').exists('the page mounted');
+ assert.dom('[data-test-pretui-usage]').exists('the page mounted');
});
test('the HoverCard usage page renders', async function (assert) {
let Page = PAGES['HoverCard'];
assert.ok(Page, 'the page is in the registry');
await render( );
- assert.dom('.FreestyleUsage').exists('the page mounted');
+ assert.dom('[data-test-pretui-usage]').exists('the page mounted');
});
});
diff --git a/packages/pretui/pretui-component.gts b/packages/pretui/pretui-component.gts
index a71ba9e6504..db49a8bd8f7 100644
--- a/packages/pretui/pretui-component.gts
+++ b/packages/pretui/pretui-component.gts
@@ -17,23 +17,34 @@ import {
} from 'https://cardstack.com/base/card-api';
import { Spec } from 'https://cardstack.com/base/spec';
import { MarkdownDef } from 'https://cardstack.com/base/markdown-file-def';
+import { MarkdownPreview } from 'https://cardstack.com/base/file-formats/index';
import StringField from 'https://cardstack.com/base/string';
+import enumField from 'https://cardstack.com/base/enum';
import BooleanField from 'https://cardstack.com/base/boolean';
import NumberField from 'https://cardstack.com/base/number';
import GlimmerComponent from '@glimmer/component';
+import { guidFor } from '@ember/object/internals';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
-import { cssStyle } from './pretui-css';
+import { modifier } from 'ember-modifier';
import { fn } from '@ember/helper';
+import { not } from '@cardstack/boxel-ui/helpers';
+import StarIcon from '@cardstack/boxel-icons/star';
import type { Query, RealmResourceIdentifier } from '@cardstack/runtime-common';
import { ThemeFrame } from './components/theme-frame';
import { EmptyState } from './components/empty-state';
+import { LoadingState } from './components/loading-state';
import { StatusChip } from './components/status-chip';
+import { Chip } from './components/chip';
import { Token } from './components/token';
-import { statusHue } from './internal/ink';
import { Button } from './components/button';
+import { Switch } from './components/switch';
+import { noteExcerpt, noteSummary } from './note-text';
+import { Tooltip } from './components/tooltip';
+import { VisuallyHidden } from './components/visually-hidden';
import { Textarea } from './components/textarea';
import { StepList } from './components/step-list';
+import { toIsoDate } from './components/known-date';
import type { StepItem, StepState } from './components/step-list';
import { Popover } from './components/popover';
import {
@@ -47,21 +58,12 @@ import { iconFor } from './icon-registry';
import { ExampleGallery } from './example-gallery';
import type { ExampleSpec } from './examples-kit';
-// Today's callers pass a statusHue() result, which is always a `var(--chart-N)`
-// we built ourselves — but the hue originates in card data, so it goes through
-// the kit-wide allowlist rather than straight to htmlSafe. Nothing unvalidated
-// reaches an inline style anywhere in the kit.
-function htmlSafeHue(hue: string) {
- return cssStyle('--pretui-fit-hue', hue);
-}
-
// Structural shape of a PretuiNote instance as read off a getCards result.
// Declared rather than imported: pretui-note.gts imports THIS module for its
// linksTo target, so importing it back would make the pair circular. The
// query names the type by module + name strings instead.
interface NoteLike {
id?: string;
- title?: string;
note?: string;
status?: string;
}
@@ -87,6 +89,149 @@ function createCardAction(context: unknown): CreateCardAction | undefined {
// resolves wherever it is written.
const NOTE_REF = { module: siblingHref('./pretui-note'), name: 'PretuiNote' };
+// The stage as shown: capitalized, from the stored lowercase value.
+function stageLabel(stage: string | undefined): string {
+ let value = stage || 'planned';
+ return value.charAt(0).toUpperCase() + value.slice(1);
+}
+
+// A live component's stage chip takes the success tone; every other stage
+// keeps StatusChip's muted pill with its name-derived dot.
+function stageTone(stage: string | undefined): 'success' | undefined {
+ return stage === 'live' ? 'success' : undefined;
+}
+
+// A component name split at its camel-case humps ("ApprovalFooter" →
+// "Approval", "Footer"), so a narrow title breaks between words.
+const NAME_HUMP_RE = new RegExp('(?<=[a-z0-9])(?=[A-Z])');
+function nameParts(name: string): string[] {
+ return name.split(NAME_HUMP_RE);
+}
+
+// The write-up shows its first lines under a fade, with a centered toggle
+// over the fade; opening it grows the region to the content's height. Its own
+// component for the same reason as NoteComposer: the open state can't live in
+// the format class.
+interface WriteupFoldSignature {
+ Args: { writeup: MarkdownDef };
+ Element: HTMLDivElement;
+}
+
+class WriteupFold extends GlimmerComponent {
+ @tracked open = false;
+ @tracked overflows = false;
+ regionId = `${guidFor(this)}-writeup`;
+ toggle = () => (this.open = !this.open);
+ get showToggle(): boolean {
+ return this.open || this.overflows;
+ }
+ // The toggle and its fade only matter when the closed region clips the
+ // prose; measured while closed, so opening keeps "Show less".
+ measureOverflow = modifier((region: HTMLElement) => {
+ let prose = region.firstElementChild;
+ let check = () => {
+ if (!this.open && prose) {
+ this.overflows = prose.scrollHeight > region.clientHeight;
+ }
+ };
+ let observer = new ResizeObserver(check);
+ observer.observe(region);
+ if (prose) observer.observe(prose);
+ return () => observer.disconnect();
+ });
+
+
+
+ {{! the write-up's prose only — the panel owns the padding, so the
+ file's own embedded chrome and surface stay out }}
+
+ {{#if this.showToggle}}
+
+ {{if this.open 'Show less' 'Show more'}}
+
+ {{/if}}
+
+
+
+}
+
// ── The sticky-note composer ─────────────────────────────────────────────
// A top-level component, not an inline block in the page: reactive state
// cannot live in a format-class expression (`static isolated = class …`),
@@ -104,6 +249,8 @@ interface NoteComposerSignature {
class NoteComposer extends GlimmerComponent {
@tracked draft = '';
+ fieldId = `${guidFor(this)}-note`;
+ hintId = `${guidFor(this)}-hint`;
setDraft = (v: string) => (this.draft = v);
get draftEmpty(): boolean {
return this.draft.trim().length === 0;
@@ -116,44 +263,50 @@ class NoteComposer extends GlimmerComponent {
};
- Note on
- {{if @componentName @componentName 'this component'}}
+ Note on
+ {{if @componentName @componentName 'this component'}}
@@ -219,6 +375,55 @@ function examplesLoadFor(name: string): Loaded {
// ── The card ─────────────────────────────────────────────────────────────
+// Closed vocabularies for the Spec's classification fields: the edit form
+// offers these as dropdowns. Each list covers every value the catalog's
+// Specs store, so existing instances read unchanged.
+const CATEGORY_OPTIONS = [
+ 'Actions',
+ 'Agentic',
+ 'Authoring Tools',
+ 'Containers',
+ 'Data Display',
+ 'Feedback',
+ 'Forms',
+ 'Foundations',
+ 'Inputs',
+ 'Layout',
+ 'Media',
+ 'Motion & Effects',
+ 'Navigation',
+ 'Overlays',
+].map((value) => ({ value, label: value }));
+const TIER_OPTIONS = [
+ 'Primitive',
+ 'Element',
+ 'Compound',
+ 'Block',
+ 'Surface',
+ 'Runtime',
+].map((value) => ({ value, label: value }));
+const OWNERSHIP_OPTIONS = [
+ { value: 'pretui', label: 'Pret UI' },
+ { value: 'boxel-ui', label: 'boxel-ui' },
+];
+const ADOPTION_OPTIONS = [
+ { value: 'in-use', label: 'In use' },
+ { value: 'demo-ready', label: 'Demo ready' },
+ { value: 'experimental', label: 'Experimental' },
+];
+const SOURCE_OPTIONS = [
+ { value: 'design-v1', label: 'design-v1' },
+ { value: 'react-ecosystem', label: 'React ecosystem' },
+ { value: 'boxel-ui', label: 'boxel-ui' },
+ { value: 'ember-freestyle', label: 'ember-freestyle' },
+ { value: 'webawesome', label: 'Web Awesome' },
+ { value: 'pretui', label: 'Pret UI' },
+];
+const DEMAND_OPTIONS = [1, 2, 3, 4, 5].map((value) => ({
+ value,
+ label: String(value),
+}));
+
export class PretUISpec extends Spec {
static displayName = 'Pret UI Spec';
static prefersWideFormat = true;
@@ -231,23 +436,33 @@ export class PretUISpec extends Spec {
// category = the primary user-facing home; tier = compositional
// complexity; tags = cross-cutting capabilities. Independent axes — never
// infer one from another.
- @field category = contains(StringField);
- @field tier = contains(StringField);
+ @field category = contains(
+ enumField(StringField, { options: CATEGORY_OPTIONS }),
+ );
+ @field tier = contains(enumField(StringField, { options: TIER_OPTIONS }));
@field tags = containsMany(StringField);
- @field ownership = contains(StringField);
+ @field ownership = contains(
+ enumField(StringField, { options: OWNERSHIP_OPTIONS }),
+ );
@field implementationStatus = contains(StringField);
- @field adoptionStatus = contains(StringField);
+ @field adoptionStatus = contains(
+ enumField(StringField, { options: ADOPTION_OPTIONS }),
+ );
@field introducedVersion = contains(StringField);
// ── legacy axes, retained through the migration ──
@field territory = contains(StringField);
@field stage = contains(StringField);
- @field source = contains(StringField);
+ @field source = contains(
+ enumField(StringField, { options: SOURCE_OPTIONS }),
+ );
@field lineage = contains(StringField);
@field version = contains(StringField);
@field brief = contains(StringField);
// prose citation ('shadcn Button · wa-button'), not Spec's `ref` CodeRef
@field refs = contains(StringField);
@field buildsOn = contains(StringField);
+ /** a hand-picked flagship of the kit, the components to look at first;
+ * shown as a gold star on the Spec page and its fitted card */
@field featured = contains(BooleanField);
@field isNew = contains(BooleanField);
@field hasDesign = contains(BooleanField);
@@ -255,7 +470,9 @@ export class PretUISpec extends Spec {
@field liveInUse = contains(BooleanField);
// cross-library demand signal, 1-5 — how many independent kits converged
// on this component (sourcing/index.md dupe density)
- @field demand = contains(NumberField);
+ @field demand = contains(
+ enumField(NumberField, { options: DEMAND_OPTIONS }),
+ );
// the write-up lives in the sibling .md; the indexer extracts its
// content into the linked MarkdownDef, so the .md stays the single source.
// searchable puts that content in the spec's search doc, once.
@@ -263,6 +480,8 @@ export class PretUISpec extends Spec {
// each format is cast: Spec's format classes carry getters these views do not use
static isolated = class Isolated extends Component {
+ writeupHeadingId = `${guidFor(this)}-writeup-heading`;
+ provenanceHeadingId = `${guidFor(this)}-provenance-heading`;
get demoLoad() {
let m = this.args.model;
return m.componentName
@@ -273,6 +492,11 @@ export class PretUISpec extends Spec {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return this.demoLoad?.value as any;
}
+ // the brief stands in for a missing usage page; hidden while one loads,
+ // so it doesn't show and then vanish
+ get showBrief() {
+ return Boolean(this.args.model.brief) && !this.demo && !this.isDemoLoading;
+ }
get examples() {
let name = this.args.model.componentName;
return name ? examplesLoadFor(name).value : undefined;
@@ -294,10 +518,10 @@ export class PretUISpec extends Spec {
}
get metaItems() {
let m = this.args.model;
- let rows = [
+ let rows: { key: string; value: string; dots?: boolean[] }[] = [
{ key: 'Category', value: m.category ?? m.territory ?? '—' },
{ key: 'Tier', value: m.tier ?? '—' },
- { key: 'Stage', value: m.stage ?? '—' },
+ { key: 'Stage', value: m.stage ? stageLabel(m.stage) : '—' },
// versions are earned by use — nothing worn shows no number
...(m.liveInUse
? [{ key: 'Version', value: m.version ?? '0.0.0' }]
@@ -313,6 +537,13 @@ export class PretUISpec extends Spec {
if (m.refs) {
rows.push({ key: 'Elsewhere', value: m.refs });
}
+ if (this.demand) {
+ rows.push({
+ key: 'Demand',
+ value: `${this.demand} of 5`,
+ dots: [1, 2, 3, 4, 5].map((i) => i <= this.demand!),
+ });
+ }
return rows;
}
get facetPills() {
@@ -338,36 +569,10 @@ export class PretUISpec extends Spec {
state: (f.on ? 'complete' : 'upcoming') as StepState,
}));
}
- get railFacts() {
- let m = this.args.model;
- return [
- { label: 'Category', value: m.category ?? m.territory ?? '—', dim: true },
- { label: 'Tier', value: m.tier ?? '—', dim: true },
- {
- label: 'Stage',
- value: m.stage ?? 'planned',
- accent: m.stage === 'live',
- },
- ...(m.liveInUse
- ? [{ label: 'Version', value: m.version ?? '0.0.0' }]
- : []),
- { label: 'Source', value: m.source ?? '—', dim: true },
- ...(m.lineage
- ? [{ label: 'Lineage', value: `boxel-ui/${m.lineage}`, dim: true }]
- : []),
- ...(this.demandDots
- ? [{ label: 'Demand', value: this.demandDots, accent: true }]
- : []),
- ];
- }
- get demandDots() {
+ get demand(): number | undefined {
let d = this.args.model.demand;
if (!d || d < 1) return undefined;
- let n = Math.min(5, Math.round(d));
- return '●'.repeat(n) + '○'.repeat(5 - n);
- }
- get titleHue() {
- return htmlSafeHue(statusHue(this.args.model.stage ?? 'planned'));
+ return Math.min(5, Math.round(d));
}
// ── Sticky notes ───────────────────────────────────────────────────
// Notes are their own cards (pretui-note.gts) that LINK to this one, so
@@ -410,6 +615,13 @@ export class PretUISpec extends Spec {
(n) => (n.status ?? '').toLowerCase() !== 'addressed',
);
}
+ // the rail shows the first few open notes; the rest sit in a disclosure
+ get firstNotes(): NoteLike[] {
+ return this.openNotes.slice(0, 3);
+ }
+ get moreNotes(): NoteLike[] {
+ return this.openNotes.slice(3);
+ }
get addressedCount(): number {
return this.notes.length - this.openNotes.length;
}
@@ -443,7 +655,13 @@ export class PretUISpec extends Spec {
// card must prerender identically); recording when a note was written
// is data capture at the moment of a user action, which is exactly
// what a timestamp is for.
- let noted = new Date().toISOString().slice(0, 10);
+ // the author's local date, not toISOString()'s UTC one
+ let now = new Date();
+ let noted = toIsoDate(
+ now.getFullYear(),
+ now.getMonth() + 1,
+ now.getDate(),
+ );
create(ref, realm, {
realmURL: realm,
doc: {
@@ -460,461 +678,548 @@ export class PretUISpec extends Spec {
-
-
-
- Pretui
- /
- {{if
- @model.category
- @model.category
- (if @model.territory @model.territory 'components')
- }}
- /
- {{if
- @model.componentName
- @model.componentName
- 'Component'
- }}
-
-
- {{#if @model.liveInUse}}
-
- {{/if}}
-
- {{#if @model.isNew}}NEW {{/if}}
- {{#if @model.featured}}★ {{/if}}
-
-
-
-
-
- {{! Sticky notes live top-right, where you left them. Visible by
- design: an annotation hidden behind a disclosure is an annotation
- nobody reads. }}
- {{#if this.showNotes}}
-
- {{! The trigger sits FIRST — directly under the theme control and
- above the artboard — so leaving a note is a fixed target on
- every page, not something that moves as notes accumulate. }}
-
+
+
+
+
+
+ {{#let (iconFor @model.icon) as |TitleIcon|}}
+ {{#if TitleIcon}}
+
+
+
+ {{/if}}
+ {{/let}}
+ {{#each
+ (nameParts (if @model.componentName @model.componentName 'Component'))
+ as |part i|
+ }}{{#if i}} {{/if}}{{part}}{{/each}}
+
+ {{! Sticky notes sit beside the title, where you left them, and
+ the first few stay visible: an annotation hidden behind a
+ disclosure is an annotation nobody reads. }}
+ {{#if this.showNotes}}
+
+ {{! The trigger sits FIRST — directly under the theme control and
+ above the artboard — so leaving a note is a fixed target on
+ every page, not something that moves as notes accumulate. }}
+
+ {{#if this.openNotes}}
+
+ {{#each this.firstNotes key='id' as |n|}}
+
+
+ {{noteSummary n.note}}
+ {{#let (noteExcerpt n.note) as |excerpt|}}
+ {{#if excerpt}}
+ {{excerpt}}
+ {{/if}}
+ {{/let}}
+
+
+ {{/each}}
+
+ {{#if this.moreNotes.length}}
+ {{! buttons in a details' content, outside its summary, are
+ valid; the rule counts details itself as interactive }}
+ {{! template-lint-disable no-nested-interactive }}
+
+ Show
+ {{this.moreNotes.length}}
+ more
+
+ {{#each this.moreNotes key='id' as |n|}}
+
+
+ {{noteSummary n.note}}
+ {{#let (noteExcerpt n.note) as |excerpt|}}
+ {{#if excerpt}}
+ {{excerpt}}
+ {{/if}}
+ {{/let}}
+
+
+ {{/each}}
+
+
+ {{! template-lint-enable no-nested-interactive }}
+ {{/if}}
+ {{/if}}
+
{{/if}}
- {{/let}}
- {{if @model.componentName @model.componentName 'Component'}}
-
- {{#unless this.demo}}
- {{#if @model.brief}}
+
+ {{#if this.showBrief}}
{{@model.brief}}
{{/if}}
- {{/unless}}
-
- {{#if this.demo}}
-
- {{else if this.isDemoLoading}}
-
- {{else if this.isDemoExcluded}}
-
- {{#if this.isHost}}
+
+ {{#if this.demo}}
+
+ {{else if this.isDemoLoading}}
+
+ {{else if this.isDemoExcluded}}
+
+ {{#if this.isHost}}
+
+ {{else}}
+
+ {{/if}}
+
+ {{else}}
+
+
+
+ {{#if @model.writeup}}
+
+
+
Write-up
+
+
- {{/if}}
-
- {{else}}
-
-
+ {{/if}}
+
+
+
Provenance
+
+
+
+ {{#each this.metaItems as |row|}}
+
+
{{row.key}}
+
+ {{#if row.dots}}
+ {{! drawn, not ●/○ glyphs, so filled and hollow share one
+ size and baseline }}
+
+ {{#each row.dots as |on|}}
+
+ {{/each}}
+
+ {{else}}
+ {{row.value}}
+ {{/if}}
+
+
+ {{/each}}
+
- {{/if}}
-
-
- {{#if @model.writeup}}
-
-
- Write-up
-
- <@fields.writeup @format='embedded' />
-
- {{/if}}
-
-
-
- Provenance
-
-
-
- {{#each this.metaItems as |row|}}
-
-
{{row.key}}
- {{row.value}}
-
- {{/each}}
-
-
-
-
-
Component
- {{#each this.railFacts as |f|}}
-
- {{f.label}}
- {{f.value}}
-
- {{/each}}
-
-
-
Provenance
- {{#each this.facetPills as |f|}}
-
- {{f.label}}
- {{if f.on '✓' '—'}}
-
- {{/each}}
-
-
-
-
+
+
} as unknown as typeof Spec.isolated;
static fitted = class Fitted extends Component {
- get hue() {
- return statusHue(this.args.model.stage ?? 'planned');
- }
get monogram() {
return (this.args.model.componentName ?? '?').charAt(0);
}
- get monoStyle() {
- return htmlSafeHue(this.hue);
- }
{{! The container element and the element the queries STYLE must be
two different elements. An unnamed `@container` resolves against
@@ -1057,10 +1301,10 @@ export class PretUISpec extends Spec {
root; this is the same shape. }}
-
+
{{#let (iconFor @model.icon) as |FitIcon|}}
{{#if FitIcon}}
-
+
{{else}}
{{this.monogram}}
{{/if}}
@@ -1069,23 +1313,36 @@ export class PretUISpec extends Spec {
{{@model.componentName}}
- {{#if @model.featured}}★ {{/if}}
+ {{#if @model.featured}}Featured: a flagship component {{/if}}
- {{if
+ {{if
@model.category
@model.category
@model.territory
}}
{{#if @model.liveInUse}}
- {{@model.version}}
+ {{if @model.version @model.version '0.0.0'}}
{{/if}}
- {{#if @model.brief}}
{{@model.brief}}
{{/if}}
+ {{#if @model.brief}}
{{@model.brief}}
{{/if}}
@@ -1097,59 +1354,62 @@ export class PretUISpec extends Spec {
overflow: hidden;
}
.fit-root {
+ --fit-mono-size: 1.875rem;
+ --fit-mono-font-size: var(--boxel-font-size-sm);
+ --fit-mono-radius: var(--boxel-border-radius);
+
width: 100%;
height: 100%;
display: flex;
align-items: center;
- gap: 10px;
- padding: 8px 10px;
- background: var(--card);
- font-family: var(--font-sans);
+ gap: var(--boxel-sp-xs);
+ padding: var(--boxel-sp-xs);
+ background-color: var(--card);
+ color: var(--card-foreground);
overflow: hidden;
box-sizing: border-box;
}
.mono {
+ --icon-color: currentColor;
+ --icon-bg: none;
+
flex: none;
- width: 30px;
- height: 30px;
- border-radius: 9px;
+ width: var(--fit-mono-size);
+ height: var(--fit-mono-size);
+ border-radius: var(--fit-mono-radius);
display: grid;
place-items: center;
font-weight: 800;
- font-size: 15px;
- background: color-mix(in oklch, var(--pretui-fit-hue, var(--chart-1)) 16%, var(--card));
- color: color-mix(in oklch, var(--pretui-fit-hue, var(--chart-1)) 60%, var(--foreground));
- box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--pretui-fit-hue, var(--chart-1)) 32%, var(--border));
- }
- .mono {
- --icon-color: currentColor;
- --icon-bg: none;
- }
- .mono-icon {
- width: 60%;
- height: 60%;
+ font-size: var(--fit-mono-font-size);
+ background-color: var(--muted);
+ color: var(--foreground);
+ box-shadow: inset 0 0 0 1px var(--border);
}
.fit-body {
min-width: 0;
display: grid;
- gap: 2px;
+ gap: var(--boxel-sp-6xs);
}
.fit-name {
font-weight: 600;
- font-size: var(--text-ui-md, 12.5px);
+ font-size: var(--boxel-font-size-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fit-star {
- color: var(--warning, var(--boxel-warning));
+ color: var(--warning-ink);
+ }
+ /* the icon set's star is outline-only; filled reads as a flag */
+ .wb-star {
+ fill: currentColor;
}
.fit-sub {
display: flex;
- gap: 8px;
+ gap: var(--boxel-sp-xs);
font-family: var(--font-mono);
- font-size: 10px;
- letter-spacing: 0.06em;
+ font-size: var(--boxel-eyebrow-font-size);
+ letter-spacing: var(--boxel-eyebrow-letter-spacing);
text-transform: uppercase;
color: var(--muted-foreground);
}
@@ -1157,24 +1417,15 @@ export class PretUISpec extends Spec {
.fit-brief {
display: none;
}
- .fit-new {
- font-family: var(--font-mono);
- font-size: 9px;
- font-weight: 700;
- letter-spacing: 0.08em;
- color: var(--pretui-attention-ink, var(--boxel-fuschia));
- }
/* badge: monogram + name only */
@container ((max-width: 139px) or (max-height: 47px)) {
.fit-root {
- gap: 7px;
- padding: 5px 8px;
- }
- .mono {
- width: 22px;
- height: 22px;
- font-size: 12px;
- border-radius: 7px;
+ --fit-mono-size: 1.375rem;
+ --fit-mono-font-size: var(--boxel-font-size-xs);
+ --fit-mono-radius: var(--boxel-border-radius-sm);
+
+ gap: var(--boxel-sp-2xs);
+ padding: var(--boxel-sp-3xs) var(--boxel-sp-xs);
}
.fit-sub {
display: none;
@@ -1183,22 +1434,20 @@ export class PretUISpec extends Spec {
/* tile & card: stack vertically, grow the monogram */
@container ((min-width: 140px) and (min-height: 140px)) {
.fit-root {
+ --fit-mono-size: 2.75rem;
+ --fit-mono-font-size: var(--boxel-font-size-lg);
+ --fit-mono-radius: var(--boxel-border-radius-lg);
+
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
- padding: 12px;
- gap: 8px;
- }
- .mono {
- width: 44px;
- height: 44px;
- font-size: 22px;
- border-radius: 12px;
+ padding: var(--boxel-sp-sm);
+ gap: var(--boxel-sp-xs);
}
.fit-extra {
display: flex;
align-items: center;
- gap: 7px;
+ gap: var(--boxel-sp-2xs);
}
}
/* full card: show the brief */
@@ -1208,9 +1457,9 @@ export class PretUISpec extends Spec {
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
- font-size: var(--text-ui-sm, 11.5px);
+ font-size: var(--boxel-caption-font-size);
color: var(--muted-foreground);
- line-height: 1.45;
+ line-height: 1.5;
white-space: normal;
}
}
@@ -1222,8 +1471,15 @@ export class PretUISpec extends Spec {
- {{@model.componentName}}
-
+ {{if
+ @model.componentName
+ @model.componentName
+ 'Component'
+ }}
+
{{if
@@ -1231,36 +1487,38 @@ export class PretUISpec extends Spec {
@model.category
@model.territory
}}
-
+ {{#if @model.source}} {{/if}}
diff --git a/packages/pretui/pretui-component.test.gts b/packages/pretui/pretui-component.test.gts
index e60d96927e4..e20b11669c7 100644
--- a/packages/pretui/pretui-component.test.gts
+++ b/packages/pretui/pretui-component.test.gts
@@ -21,8 +21,8 @@ module('Pretui | PretUISpec', function (hooks) {
test('renders the usage page and examples it loads', async function (assert) {
let model = specModel('Button');
await render(
);
- await waitFor('[data-demo-policy="included"] .FreestyleUsage');
- assert.dom('[data-demo-policy="included"] .FreestyleUsage').exists();
+ await waitFor('[data-demo-policy="included"] [data-test-pretui-usage]');
+ assert.dom('[data-demo-policy="included"] [data-test-pretui-usage]').exists();
await waitFor('[data-test-pretui-examples]');
assert.dom('[data-test-pretui-examples]').containsText('Examples');
});
@@ -30,8 +30,8 @@ module('Pretui | PretUISpec', function (hooks) {
test('a page in a shared usage module renders too', async function (assert) {
let model = specModel('EmailInput');
await render(
);
- await waitFor('[data-demo-policy="included"] .FreestyleUsage');
- assert.dom('[data-demo-policy="included"] .FreestyleUsage').exists();
+ await waitFor('[data-demo-policy="included"] [data-test-pretui-usage]');
+ assert.dom('[data-demo-policy="included"] [data-test-pretui-usage]').exists();
});
test('a component with no usage page says so', async function (assert) {
@@ -46,8 +46,8 @@ module('Pretui | PretUISpec', function (hooks) {
test('a planned entry that has a page shows it', async function (assert) {
let model = specModel('Button', 'planned');
await render(
);
- await waitFor('[data-demo-policy="included"] .FreestyleUsage');
- assert.dom('[data-demo-policy="included"] .FreestyleUsage').exists();
+ await waitFor('[data-demo-policy="included"] [data-test-pretui-usage]');
+ assert.dom('[data-demo-policy="included"] [data-test-pretui-usage]').exists();
});
test('a Runtime entry without a page is excluded', async function (assert) {
@@ -78,8 +78,8 @@ module('Pretui | PretUISpec', function (hooks) {
test('the breadcrumb names the kit without linking to a catalog card this package does not ship', async function (assert) {
let model = specModel('Button');
await render(
);
- assert.dom('.wb-crumb').containsText('Pretui');
- assert.dom('.wb-crumb button').doesNotExist('no navigation to a card that is not here');
+ assert.dom('[data-test-pretui-spec-crumb]').containsText('Pret UI');
+ assert.dom('[data-test-pretui-spec-crumb] button').doesNotExist('no navigation to a card that is not here');
});
test('a note adopts from the PretuiNote module in this package, wherever the Spec lives', async function (assert) {
diff --git a/packages/pretui/pretui-css.gts b/packages/pretui/pretui-css.gts
index 88dd22199ca..405b8613569 100644
--- a/packages/pretui/pretui-css.gts
+++ b/packages/pretui/pretui-css.gts
@@ -8,13 +8,13 @@
// 1. `cssValue` — the guard for caller-supplied strings that reach CSS
// ─────────────────────────────────────────────────────────────────────────
//
-// Law 2 routes a hue through nearly every component, and a hue arrives as a
+// A hue is routed through nearly every component, and it arrives as a
// caller string. Interpolating that string into an inline style and handing
// it to `htmlSafe` lets `red; background: url(https://evil/x)` inject
// arbitrary declarations — `htmlSafe` means "I have already made this safe",
// and nothing had.
//
-// The guard is an ALLOWLIST, not a sanitiser: it validates and returns the
+// The guard is an ALLOWLIST, not a sanitizer: it validates and returns the
// value unchanged, or returns `undefined` for anything it does not fully
// understand. It never strips-and-continues, because strip-and-continue is
// how a value that looked harmless after cleaning turns out not to be.
@@ -28,10 +28,10 @@
// 2. `--pretui-z-*` — the stacking scale
// ─────────────────────────────────────────────────────────────────────────
//
-// Seven components in the kit float above the page — Popup, Popover, Dialog,
-// Drawer, Tooltip, Menu, Toast — and before this scale each picked its own
-// integer, so they could not be ordered among themselves, and a host
-// application could not slot its own chrome between them.
+// The kit's floating surfaces (Popup, and Popover through it; Dialog,
+// Drawer, Tooltip, Menu and the other dropdowns; Toaster) take their tier
+// from one scale, so they order among themselves and a host can slot its own
+// chrome between them.
//
// The order, and why it is this order:
//
@@ -51,9 +51,13 @@
// floating surface. Always one step BELOW the surface it
// dismisses and above everything the surface covers — a scrim
// that outranks its own panel eats the panel's clicks.
-// dropdown 60 Menu, Select's listbox, Combobox, CommandPalette. Anchored
-// to a trigger, dismissed by an outside click, and always
-// subordinate to a panel that may contain it.
+// dropdown 60 Menu (MenuPanel), MultiSelect, TreeSelect, Cascader,
+// NavigationMenu, Mentions, EmojiPicker and Autocomplete.
+// Anchored to a trigger, dismissed by an outside click, and
+// always subordinate to a panel that may contain it. Select and
+// Combobox render boxel-ui's listbox, which takes boxel-ui's
+// own layer (above toast), and CommandPalette is a modal
+//
in the top layer.
// overlay 70 Popup and Popover — a deliberate floating panel. Above
// dropdown because a Popover can contain a Select, and the
// containing panel must never paint under its own content.
@@ -64,21 +68,27 @@
// dialog 90 Dialog and Drawer. FALLBACK ONLY: both use native
// `` + `showModal()`, which promotes them to the top
// layer, above every z-index on the page regardless of value.
-// The token exists so a non-modal or polyfilled variant lands
-// in the right place, and so the intended rank is written
-// down rather than implied by the platform.
+// Nothing reads this token; it records the intended rank
+// rather than leaving it implied by the platform.
// toast 100 the last word. A toast reports something that just happened
// and must stay readable over whatever is open, including a
// modal — so it is the only tier deliberately above `dialog`.
+// Toaster's fixed region and SkipLink take it; a bare Toast is
+// in flow and takes no tier, so whatever positions one owns
+// its z-index.
//
// The numbers are gapped so a host can interleave its own chrome (a global
// nav at 65, an assistant panel at 85) without editing Pretui.
//
-// **Consumption rule.** Every component writes
-// `z-index: var(--pretui-z-, )`. The literal fallback
-// is not optional and is not a guess — it is this table, restated, so a
-// season that has never defined these tokens renders in exactly the same
-// order. A season redefines a token to move a whole tier at once.
+// **Consumption rule.** A component that stacks against OTHER components
+// writes `z-index: var(--pretui-z-, )`. The literal
+// fallback is not optional and is not a guess — it is this table, restated,
+// so a host that has never defined these tokens renders in exactly the same
+// order; a host redefines a token to move a whole tier at once. Layering
+// inside one component's own box (a 1, 2 or 3) is the `raised` role and
+// stays literal. Exceptions that still pick their own number: PageScaffold's
+// skip link (20) and sticky masthead (10), FloatButton (--pretui-float-z, 20)
+// and Backdrop (--pretui-backdrop-z, 50).
import { htmlSafe } from '@ember/template';
@@ -119,7 +129,7 @@ const ALLOWED_FUNCTIONS = new Set([
'clamp',
'round',
'abs',
- // colour
+ // color
'rgb',
'rgba',
'hsl',
@@ -131,8 +141,7 @@ const ALLOWED_FUNCTIONS = new Set([
'oklch',
'color',
'color-mix',
- // gradients — added 2026-08-13 after the colour work measured the gap.
- // Their ARGUMENTS are still validated by the same character and function
+ // gradients. Their ARGUMENTS are still validated by the same character and function
// allowlist, so admitting the names widens nothing: `url()` and `attr()`
// remain rejected wherever they appear, at any nesting depth.
'linear-gradient',
@@ -158,12 +167,9 @@ const CALL = /([A-Za-z][A-Za-z0-9-]*)?\(/g;
/** Anything starting `#` up to the next separator. */
const HEXISH = /#[^\s,()]*/g;
-/** Longer than any legitimate colour, length or gradient stop list. */
-// 256 was chosen for hand-authored values and was wrong for GENERATED ones: a
-// 32-step channel track measures 1069 chars and a 20-step track 629, so an
-// 8-stop gradient already exceeded the old cap and was silently dropped. The
-// cap exists to bound the parser's work, not to police intent — 4096 keeps
-// that bound while admitting every gradient the kit actually emits.
+/** Longer than any legitimate color, length or gradient stop list. */
+// Bounds the parser's work; generated gradient tracks run past 1000
+// characters (a 32-step channel track is 1069), so the cap sits well above.
const MAX_VALUE_LENGTH = 4096;
/** Deeper than `color-mix(in oklch, var(--a, oklch(…)) 40%, var(--b))`. */
@@ -226,7 +232,7 @@ export function cssValue(raw: unknown): string | undefined {
}
call = CALL.exec(value);
}
- // Every `#` introduces a well-formed hex colour.
+ // Every `#` introduces a well-formed hex color.
HEXISH.lastIndex = 0;
let hex = HEXISH.exec(value);
while (hex !== null) {
@@ -252,7 +258,6 @@ const NUMERIC = /^-?(?:\d+\.?\d*|\.\d+)$/;
* falls through to whatever the stylesheet's own fallback is — the caller
* loses their override, never the component's rendering.
*/
-
export function cssDeclaration(
property: string,
raw: unknown,
@@ -363,7 +368,7 @@ export function zVarName(layer: PretuiZLayer): string {
}
/**
- * The scale as CSS custom properties, for a season or host stylesheet that
+ * The scale as CSS custom properties, for a host stylesheet that
* wants to declare them explicitly. Declaring them changes nothing on its
* own — every consumer already falls back to these exact numbers — but it
* gives one place to shift a whole tier.
diff --git a/packages/pretui/pretui-note.gts b/packages/pretui/pretui-note.gts
index a4ac13c0265..f7ef36bdac2 100644
--- a/packages/pretui/pretui-note.gts
+++ b/packages/pretui/pretui-note.gts
@@ -10,13 +10,14 @@
// - The note LINKS to its component (`target`), so the relationship is a
// real edge in the graph rather than a name match. The component page
// finds its own notes by querying for that edge.
-// - Colour is the Law 2 recipe over one hue, not a hardcoded yellow, so a
-// sticky re-tints with the season and reads correctly in light or dark.
-// `--pretui-note-hue` is the knob.
-// - `status` is deliberately a plain string rather than an enum field: an
-// agent writes it, and a value this small does not need a field class.
-// Anything that is not 'addressed' counts as open, so a note created by
-// hand with no status set still shows up in the queue.
+// - Color is a tint of `--attention` over `--card`, not a hardcoded yellow,
+// so a sticky re-tints with the theme; it is mixed in oklab, which keeps
+// the tint warm over a cool dark card where oklch would turn it violet.
+// - `status` is an Open / Addressed enum. Anything that is not
+// 'addressed' counts as open, so a note created with no status set still
+// shows up in the queue.
+// - The title is `cardTitle`: `cardInfo.name` when one is set, otherwise
+// the note's first line, so a note is never "Untitled" in a list.
import {
CardDef,
Component,
@@ -27,52 +28,79 @@ import {
import type { RealmResourceIdentifier } from '@cardstack/runtime-common';
import StringField from 'https://cardstack.com/base/string';
import MarkdownField from 'https://cardstack.com/base/markdown';
+import DateField from 'https://cardstack.com/base/date';
+import enumField from 'https://cardstack.com/base/enum';
+import { FittedCard } from '@cardstack/boxel-ui/components';
+import StickyNoteIcon from '@cardstack/boxel-icons/sticky-note';
import { on } from '@ember/modifier';
+import type { TemplateOnlyComponent } from '@ember/component/template-only';
+import { Button } from './components/button';
+import { Chip } from './components/chip';
import { PretUISpec } from './pretui-component';
+import { noteSummary } from './note-text';
/** A note is open unless it has been explicitly addressed. */
export function isAddressed(status: string | undefined): boolean {
return (status ?? '').toLowerCase() === 'addressed';
}
-/**
- * First line of a note, flattened to plain text and shortened — the note's
- * title in card lists, search results, and the assistant's card picker.
- * Notes are markdown, so the leading `#`/`>`/`-`/backtick furniture is
- * stripped rather than shown.
- */
-export function noteSummary(note: string | undefined): string {
- let first = (note ?? '')
+export { noteSummary };
+
+// The note's state as a chip: open in the attention tone, addressed in
+// success.
+const NoteStatus: TemplateOnlyComponent<{
+ Args: { addressed: boolean };
+ Element: HTMLSpanElement;
+}> =
+
+ ;
+
+// The title is the note's first line unless cardInfo.name is set, so the
+// body only adds something when there is a name or more than that line.
+function bodyAddsToTitle(note: string | undefined, name: string | undefined) {
+ let text = note?.trim();
+ if (!text) return false;
+ if (name?.trim()) return true;
+ let lines = text
.split('\n')
- .map((line) => line.replace(/^[\s>#*\-+]+/, '').trim())
- .find((line) => line.length > 0);
- if (!first) return 'Sticky note';
- let plain = first.replace(/[`*_]/g, '');
- return plain.length > 72 ? `${plain.slice(0, 71)}…` : plain;
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+ return lines.length > 1 || noteSummary(text).endsWith('…');
}
export class PretuiNote extends CardDef {
static displayName = 'Sticky Note';
+ static icon = StickyNoteIcon;
/** what you want changed, in your own words */
@field note = contains(MarkdownField);
/** the component this note is stuck to */
@field target = linksTo(() => PretUISpec);
/** 'open' (default, and anything unrecognised) or 'addressed' */
- @field status = contains(StringField);
+ @field status = contains(
+ enumField(StringField, {
+ options: [
+ { value: 'open', label: 'Open' },
+ { value: 'addressed', label: 'Addressed' },
+ ],
+ }),
+ );
/** what the agent actually did about it — written when the note is closed */
@field resolution = contains(MarkdownField);
- /** ISO date the note was left. Stamped at creation by the page that
+ /** the date the note was left. Stamped at creation by the page that
* creates it, never read from the clock at render time. */
- @field noted = contains(StringField);
+ @field noted = contains(DateField);
/** who left it, when that is worth recording */
@field author = contains(StringField);
- /** Derived so a note is never "Untitled" in a list, a search result, or the
- * assistant's card picker — the note text IS the title. */
- @field title = contains(StringField, {
+ @field cardTitle = contains(StringField, {
computeVia: function (this: PretuiNote) {
- return noteSummary(this.note);
+ return this.cardInfo?.name?.trim() || noteSummary(this.note);
},
});
@@ -86,139 +114,148 @@ export class PretuiNote extends CardDef {
get addressed() {
return isAddressed(this.args.model.status);
}
+ get showBody() {
+ return bodyAddsToTitle(
+ this.args.model.note,
+ this.args.model.cardInfo?.name,
+ );
+ }
-
-
@@ -228,45 +265,51 @@ export class PretuiNote extends CardDef {
get addressed() {
return isAddressed(this.args.model.status);
}
+ get showBody() {
+ return bodyAddsToTitle(
+ this.args.model.note,
+ this.args.model.cardInfo?.name,
+ );
+ }
-
<@fields.note />
- {{#if this.addressed}}
-
addressed
+
+
<@fields.cardTitle />
+ {{#if this.showBody}}
+
<@fields.note />
{{/if}}
@@ -277,56 +320,46 @@ export class PretuiNote extends CardDef {
return isAddressed(this.args.model.status);
}
-
-
- {{! the computed title is the note's first line, already flattened out
- of markdown — the raw field would show `#`/backtick furniture }}
- {{if @model.title @model.title 'Sticky note'}}
- {{if
- @model.target.componentName
- @model.target.componentName
- ''
- }}
-
+ {{! badgeRight stays at the sizes where FittedCard hides the badge
+ row, so open and addressed never differ by fill alone }}
+ <:badgeRight>
+ <:title><@fields.cardTitle />
+ <:subtitle>
+ {{#if @model.cardDescription}}
+ <@fields.cardDescription />
+ {{else if @model.target.componentName}}
+ On
+ {{@model.target.componentName}}
+ {{/if}}
+
+ <:footer>
+ {{#if @model.author}}<@fields.author /> {{/if}}
+ {{#if @model.noted}}<@fields.noted />{{/if}}
+
+
diff --git a/packages/pretui/pretui-note.test.gts b/packages/pretui/pretui-note.test.gts
index 141ad9988c0..7b097171137 100644
--- a/packages/pretui/pretui-note.test.gts
+++ b/packages/pretui/pretui-note.test.gts
@@ -22,7 +22,11 @@ module('Pretui | PretuiNote', function (hooks) {
target: { id: targetId, componentName: 'Button' },
};
let fields = { note: NoteField };
- await render( );
+ await render(
+
+
+ ,
+ );
await click('[data-test-pretui-note-target]');
assert.deepEqual(viewed, [targetId]);
});
diff --git a/packages/pretui/reading-listing.test.gts b/packages/pretui/reading-listing.test.gts
index 9ba48218811..40748c63807 100644
--- a/packages/pretui/reading-listing.test.gts
+++ b/packages/pretui/reading-listing.test.gts
@@ -524,7 +524,7 @@ module('Pretui | reading-listing', function (hooks) {
@onExpandedChange={{knobs.onExpandedChange}}
>
<:expanded as |row|>
- {{row.place}}
+ {{row.place}}
,
@@ -544,7 +544,7 @@ module('Pretui | reading-listing', function (hooks) {
let controls = toggle.getAttribute('aria-controls') as string;
let detail = tableRoot().querySelector('#' + controls) as HTMLElement;
assert.ok(detail, 'aria-controls resolves to a real element');
- assert.dom(detail.querySelector('.t-detail')).hasText('Uji');
+ assert.dom(detail.querySelector('[data-test-detail]')).hasText('Uji');
assert.strictEqual(
detail.querySelector('td')?.getAttribute('colspan'),
'5',
@@ -762,11 +762,11 @@ module('Pretui | reading-listing', function (hooks) {
await render(
- <:item as |row|>{{row.tea}}
+ <:item as |row|>{{row.tea}}
,
);
- await click(listRoot().querySelectorAll('.t-tea')[1] as HTMLElement);
+ await click(listRoot().querySelectorAll('[data-test-tea]')[1] as HTMLElement);
assert.deepEqual(knobs.activations, ['B-2'], 'the row text activates');
await click(listRoot().querySelectorAll('[data-test-pretui-list-select]')[0] as HTMLElement);
assert.deepEqual(knobs.activations, ['B-2'], 'the radio does not');
@@ -837,7 +837,7 @@ module('Pretui | reading-listing', function (hooks) {
);
assert.deepEqual(terms, ['Lot', 'Tea', 'Note', 'Empty']);
let pairs = Array.from(
- root.querySelectorAll('.pretui-desc-pair'),
+ root.querySelectorAll('[data-test-pretui-descriptions-pair]'),
) as HTMLElement[];
assert.strictEqual(pairs[2]?.getAttribute('data-span'), 'fill');
assert.strictEqual(root.getAttribute('data-bordered'), 'true');
@@ -853,15 +853,15 @@ module('Pretui | reading-listing', function (hooks) {
await render(
- <:value as |item|>{{item.label}} block
+ <:value as |item|>{{item.label}} block
,
);
let root = document.querySelector(
'[data-test-pretui-descriptions]',
) as HTMLElement;
- assert.dom(root.querySelector('.t-v')).hasText('Lot block');
- let colon = root.querySelector('.pretui-desc-colon') as HTMLElement;
+ assert.dom(root.querySelector('[data-test-value]')).hasText('Lot block');
+ let colon = root.querySelector('[data-test-pretui-descriptions-colon]') as HTMLElement;
assert.strictEqual(
colon.getAttribute('aria-hidden'),
'true',
@@ -935,7 +935,7 @@ module('Pretui | reading-listing | usage pages', function (hooks) {
assert.ok(Page, name + ' present');
await render( );
assert.ok(
- document.querySelector('.FreestyleUsage'),
+ document.querySelector('[data-test-pretui-usage]'),
name + ' rendered a FreestyleUsage shell',
);
});
diff --git a/packages/pretui/scripts/usage-proof.mjs b/packages/pretui/scripts/usage-proof.mjs
index d5e60fd6519..3e4d1c32411 100644
--- a/packages/pretui/scripts/usage-proof.mjs
+++ b/packages/pretui/scripts/usage-proof.mjs
@@ -57,7 +57,9 @@ function registries() {
}
function render(list) {
- let imports = list.map((r) => `import { ${r.name} } from '${r.module}';`).join('\n');
+ let imports = list
+ .map((r) => `import { ${r.name} } from '${r.module}';`)
+ .join('\n');
let names = list.map((r) => ` ${r.name},`).join('\n');
return `// Pretui — render proof: every usage page in the package mounts. Generated by
// \`node scripts/usage-proof.mjs --write\`; \`pnpm lint\` fails when a page module
@@ -83,7 +85,13 @@ module('Pretui | usage pages', function (hooks) {
let Demo = registry[name] as AnyComponent;
assert.ok(Demo, \`\${name} present\`);
await render( );
- assert.ok(document.querySelector('.FreestyleUsage'), \`\${name} rendered a FreestyleUsage shell\`);
+ assert.ok(document.querySelector('[data-test-pretui-usage]'), \`\${name} rendered a FreestyleUsage shell\`);
+ assert.ok(
+ Array.from(document.querySelectorAll('[data-test-pretui-viewport-specimen]')).some(
+ (s) => s.childElementCount > 0 || Boolean(s.textContent?.trim()),
+ ),
+ \`\${name} shows its example\`,
+ );
});
}
}
@@ -103,7 +111,9 @@ if (process.argv.includes('--write')) {
// reported below
}
if (actual !== expected) {
- console.error('usage-pages.test.gts is out of date: run `node scripts/usage-proof.mjs --write`.');
+ console.error(
+ 'usage-pages.test.gts is out of date: run `node scripts/usage-proof.mjs --write`.',
+ );
process.exit(1);
}
}
diff --git a/packages/pretui/structure-flow.test.gts b/packages/pretui/structure-flow.test.gts
index 275df219763..8e7442db6d4 100644
--- a/packages/pretui/structure-flow.test.gts
+++ b/packages/pretui/structure-flow.test.gts
@@ -834,7 +834,7 @@ module('Pretui | structure-flow | usage pages', function (hooks) {
assert.ok(Demo, name + ' is present in the registry');
await render( );
assert.ok(
- document.querySelector('.FreestyleUsage'),
+ document.querySelector('[data-test-pretui-usage]'),
name + ' rendered a FreestyleUsage shell',
);
});
diff --git a/packages/pretui/theme-frame.test.gts b/packages/pretui/theme-frame.test.gts
index 06901d148b0..29df7040323 100644
--- a/packages/pretui/theme-frame.test.gts
+++ b/packages/pretui/theme-frame.test.gts
@@ -26,18 +26,17 @@ function probeColor(): string {
return getComputedStyle(probe).backgroundColor;
}
-// The mode picker is a SegmentedControl: a radiogroup over native
-// . The helper accepts a tab shape too, so it survives
-// the theme bar being re-cut.
-async function clickMode(label: string) {
- let candidates = Array.from(
- document.querySelectorAll('[data-test-pretui-theme-bar] button, [data-test-pretui-theme-bar] label'),
- );
- let target = candidates.find((b) => b.textContent?.trim() === label);
- if (!target) {
- throw new Error(`no mode button labeled ${label}`);
+// The mode control is the dark mode switch: a checkbox with role='switch'.
+async function setDarkMode(on: boolean) {
+ let input = document.querySelector(
+ '[data-test-pretui-theme-mode] input',
+ ) as HTMLInputElement | null;
+ if (!input) {
+ throw new Error('no dark mode switch rendered');
+ }
+ if (input.checked !== on) {
+ await click(input);
}
- await click(target.querySelector('input') ?? target);
}
module('Pretui | ThemeFrame', function (hooks) {
@@ -56,22 +55,31 @@ module('Pretui | ThemeFrame', function (hooks) {
assert.strictEqual(
probeColor(),
'rgb(10, 20, 30)',
- 'auto mode resolves the light token set',
+ 'the frame starts light',
);
+ assert
+ .dom('[data-test-pretui-theme-frame]')
+ .hasAttribute('data-theme', 'light', 'the frame stamps light, not an auto scheme');
- await clickMode('Dark');
+ await setDarkMode(true);
assert.strictEqual(
probeColor(),
'rgb(40, 50, 60)',
'dark mode re-resolves the theme dark block — no component changed',
);
+ assert
+ .dom('[data-test-pretui-theme-frame]')
+ .hasAttribute('data-theme', 'dark', 'dark mode stamps dark');
- await clickMode('Light');
+ await setDarkMode(false);
assert.strictEqual(
probeColor(),
'rgb(10, 20, 30)',
'light mode forces the light set back on',
);
+ assert
+ .dom('[data-test-pretui-theme-frame]')
+ .hasAttribute('data-theme', 'light', 'light mode stamps light');
});
test('frame without a theme still flips boxel-ui ambient tokens', async function (assert) {
@@ -85,7 +93,7 @@ module('Pretui | ThemeFrame', function (hooks) {
);
let lightBg = probeColor();
- await clickMode('Dark');
+ await setDarkMode(true);
assert.notStrictEqual(
probeColor(),
lightBg,
@@ -146,13 +154,13 @@ module('Pretui | ThemeFrame · seasons', function (hooks) {
);
- await clickMode('Light');
+ await setDarkMode(false);
let lightBg = probeBg();
let lightPrimary = readVar('--primary');
let lightFg = readVar('--foreground');
assert.ok(lum(lightBg) > 0.9, `light --background is paper (${lightBg})`);
- await clickMode('Dark');
+ await setDarkMode(true);
let darkBg = probeBg();
let darkPrimary = readVar('--primary');
let darkFg = readVar('--foreground');
@@ -205,7 +213,7 @@ module('Pretui | ThemeFrame · seasons', function (hooks) {
probe
);
- await clickMode('Light');
+ await setDarkMode(false);
seen.push(probeBg());
}
assert.strictEqual(
@@ -246,12 +254,12 @@ module('Pretui | ThemeFrame · season selector', function (hooks) {
document.querySelector('[data-test-pretui-theme-bar]'),
'the theme bar rendered — the selector branch does not throw',
);
- assert.dom('.pretui-theme-name').doesNotExist(
+ assert.dom('[data-test-pretui-theme-name]').doesNotExist(
'with 3 themes found the static name is replaced by the picker',
);
assert.ok(
- document.querySelector('.pretui-theme-pick'),
- 'the season picker is present',
+ document.querySelector('[data-test-pretui-theme-pick]'),
+ 'the theme picker is present',
);
});
@@ -303,7 +311,7 @@ module('Pretui | ThemeFrame · season selector', function (hooks) {
probe
);
- assert.dom('.pretui-theme-name').hasText(
+ assert.dom('[data-test-pretui-theme-name]').hasText(
'AW26',
'prerender/test contexts still name the linked theme',
);
diff --git a/packages/pretui/usage-pages.test.gts b/packages/pretui/usage-pages.test.gts
index 838fdba4ba4..665ee5e326d 100644
--- a/packages/pretui/usage-pages.test.gts
+++ b/packages/pretui/usage-pages.test.gts
@@ -592,7 +592,13 @@ module('Pretui | usage pages', function (hooks) {
let Demo = registry[name] as AnyComponent;
assert.ok(Demo, `${name} present`);
await render( );
- assert.ok(document.querySelector('.FreestyleUsage'), `${name} rendered a FreestyleUsage shell`);
+ assert.ok(document.querySelector('[data-test-pretui-usage]'), `${name} rendered a FreestyleUsage shell`);
+ assert.ok(
+ Array.from(document.querySelectorAll('[data-test-pretui-viewport-specimen]')).some(
+ (s) => s.childElementCount > 0 || Boolean(s.textContent?.trim()),
+ ),
+ `${name} shows its example`,
+ );
});
}
}
diff --git a/packages/pretui/viewport.test.gts b/packages/pretui/viewport.test.gts
index a59a264f6e0..065a088e08d 100644
--- a/packages/pretui/viewport.test.gts
+++ b/packages/pretui/viewport.test.gts
@@ -7,13 +7,15 @@ import { Viewport } from './components/viewport';
// SegmentedControl is a radiogroup over native . Click
// the input: a synthetic click on the label would rely on label
// activation forwarding, and the input is the thing that actually changes.
-function segButton(label: string): HTMLElement {
- let labels = Array.from(
- document.querySelectorAll('[data-test-pretui-viewport] .pretui-seg label'),
- );
- let target = labels.find((b) => b.textContent?.trim() === label);
- if (!target) throw new Error(`no viewport segment labeled ${label}`);
- return target.querySelector('input') as HTMLElement;
+function segButton(mode: string): HTMLElement {
+ let target = document.querySelector(
+ `[data-test-pretui-viewport] [data-test-pretui-segmented-option='${mode}']`,
+ ) as HTMLElement | null;
+ if (!target) throw new Error(`no viewport segment for ${mode}`);
+ return target;
+}
+function artboard(): HTMLElement {
+ return document.querySelector('[data-test-pretui-artboard]') as HTMLElement;
}
module('Pretui | Viewport artboard', function (hooks) {
@@ -21,75 +23,67 @@ module('Pretui | Viewport artboard', function (hooks) {
test('R1: presets set exact widths; R5: caption is honest', async function (assert) {
await render(
- content
+ content
);
- let artboard = () =>
- document.querySelector('.pretui-artboard') as HTMLElement;
assert.strictEqual(
- artboard().getAttribute('data-width'),
- 'fill',
- 'starts at fill',
+ artboard().getAttribute('data-label'),
+ 'Fill · 100%',
+ 'starts at fill, captioned as the full width',
);
+ assert.strictEqual(artboard().style.width, '100%', 'fill is 100% wide');
- await click(segButton('Tablet'));
+ await click(segButton('tablet'));
assert.strictEqual(
artboard().style.width,
- '768px',
- 'tablet artboard is exactly 768px',
+ '600px',
+ 'tablet artboard is exactly 600px',
);
assert.strictEqual(
artboard().getAttribute('data-label'),
- 'Probe · 768px',
- 'caption carries name and true width',
+ 'Tablet · 600px',
+ 'a device preset is captioned with the device and its true width',
);
- await click(segButton('Phone'));
- assert.strictEqual(artboard().style.width, '375px', 'phone is 375px');
+ await click(segButton('phone'));
+ assert.strictEqual(artboard().style.width, '320px', 'phone is 320px');
});
test('R3: 3-up renders all three labeled breakpoints', async function (assert) {
await render(
- content
+ content
);
- await click(segButton('3-up'));
- let boards = document.querySelectorAll('.pretui-bp-row .pretui-artboard');
+ await click(segButton('bp'));
+ let boards = Array.from(
+ document.querySelectorAll('[data-test-pretui-artboard]'),
+ ) as HTMLElement[];
assert.strictEqual(boards.length, 3, 'three artboards');
assert.deepEqual(
- Array.from(boards).map((b) => b.getAttribute('data-label')),
- ['Phone · 375px', 'Tablet · 768px', 'Desktop · 1120px'],
+ boards.map((b) => b.getAttribute('data-label')),
+ ['Phone · 320px', 'Tablet · 600px', 'Desktop · 1120px'],
'each labeled with its true width',
);
+ assert.deepEqual(
+ boards.map((b) => b.style.width),
+ ['320px', '600px', '1120px'],
+ 'each is exactly the width its caption states',
+ );
});
test('R2/R9: surface and gutter are explicit, reflected settings', async function (assert) {
await render(
- content
+ content
);
let body = () =>
- document.querySelector('.pretui-artboard-body') as HTMLElement;
+ document.querySelector('[data-test-pretui-artboard-body]') as HTMLElement;
assert.strictEqual(body().getAttribute('data-surface'), 'background');
assert.strictEqual(body().getAttribute('data-gutter'), 'true');
- let gutterSwitch = document.querySelector(
- '.pretui-viewport-gutter [data-test-pretui-switch]',
- ) as HTMLElement | null;
- if (gutterSwitch) {
- await click(gutterSwitch);
- assert.strictEqual(
- body().getAttribute('data-gutter'),
- 'false',
- 'gutter toggles off',
- );
- } else {
- let anySwitch = document.querySelector(
- '.pretui-viewport-gutter button',
- ) as HTMLElement | null;
- assert.ok(anySwitch, 'gutter switch rendered');
- if (anySwitch) {
- await click(anySwitch);
- assert.strictEqual(body().getAttribute('data-gutter'), 'false');
- }
- }
+ await click('[data-test-pretui-viewport-gutter]');
+ assert.strictEqual(
+ body().getAttribute('data-gutter'),
+ 'false',
+ 'gutter toggles off',
+ );
});
});