# Swivel Design System
> The design system for Swivel, the command center for modern ad operations. One stylesheet
> carries the brand fonts, every token, and every component class. The system is dark by
> default; light is an opt-in.
>
> - Human reference: https://design-system.swivel-dev.net/
> - Drop-in stylesheet: https://design-system.swivel-dev.net/stylesheet.css
> - This file: https://design-system.swivel-dev.net/llms.txt
## Quick start
Link one file. Nothing else is required for tokens, type, and component classes.
```html
A million decisionS a minute.
You set the rules. Swivel executes against them.
```
`stylesheet.css` already contains:
- `@font-face` for **Avantt** (display, 600/700) and **Antarctica Condensed** (body, 400/500 plus
italics), served from https://design-system.swivel-dev.net/fonts/ with CORS enabled, so it works from any origin.
- JetBrains Mono (code and IDs) and Material Symbols Outlined (product icons) from Google Fonts.
- Every token as a CSS custom property, in both themes.
- Every component class (`.btn`, `.field`, `.chip`, `.table-card`, `.app-shell`, ...).
Do not add Inter, Roboto, or any other family. Do not add a second icon font in product UI.
### React components without a build step
The React components are compiled into a browser bundle that registers them on
`window.SwivelDesignSystem_498725`. Load React 18 globals first.
```html
```
## Theming
**Dark is the default.** With no attribute set, every token resolves to its dark value.
| You want | Set |
|---|---|
| Dark (default) | nothing, or `` |
| Light | `` |
| A dark island inside a light page | `data-theme="dark"` on that element |
| A light island inside a dark page | `data-theme="light"` on that element |
Rules that are not optional:
1. **Every artifact ships a working light/dark switch.** Never a decorative or stubbed toggle.
2. It is the canonical `.theme-switch` (sun icon, track, moon icon, no text label). It is a
switch, never a button group. `aria-checked="true"` means dark.
3. Persist the choice in `localStorage` under `swivel-theme`. Apply it before first paint with
an inline script in `` so there is no flash. First visit with nothing stored is dark.
4. Build exclusively from tokens (`var(--surface-base)`, `var(--graphite-90)`). A hard-coded hex
breaks one of the two themes.
5. Verify both themes before you finish.
```html
```
Drop `.is-fixed` to place the switch inline in a header or sidebar instead of pinned top-right.
### How the token names behave across themes
Token names describe a **role**, and the ramp inverts between themes so the role holds.
`--graphite-90` is the primary text color in both themes: near-black `#131514` in light,
near-white `#F2F3F2` in dark. `--surface-base` is the card surface in both. Use the role; never
pick a ramp step because of how it looks in one theme.
## Brand foundations
### Color
Four canonical colors. No other hues. No gradients unless derived from the palette. No warm tones
outside data and status indicators.
| Token | Hex | Role |
|---|---|---|
| `--switch-100` | `#A8F8FF` | Primary accent. Energy, CTAs, the "live" color. |
| `--graphite-base` | `#131514` | Dark backgrounds. Primary text on light. |
| `--slate-100` | `#637D9C` | Supporting elements and secondary text. Mid-tone, never dominant. |
| `--white` | `#FFFFFF` | Light backgrounds. Reverse text. |
Approved pairings: Graphite background + Switch accent + white text (product default); white
background + Graphite text + Switch accent (light marketing); Switch background + Graphite text
(high-contrast call-out blocks).
Status colors (`--success`, `--warning`, `--info`, `--error`, each with `-bg` and `-fg`) mark
**data states only**, never decoration.
### Type
| Role | Family | Token |
|---|---|---|
| Headlines, display | Avantt (600, 700) | `--font-display` |
| Body, UI | Antarctica Condensed (400, 500, italics) | `--font-body` |
| Code, IDs, data | JetBrains Mono | `--font-mono` |
Type classes: `.t-display`, `.t-h1` to `.t-h4`, `.t-body-l`, `.t-body`, `.t-body-s`,
`.t-body-label`, `.t-body-caption`.
- Every headline ends with a period. That period is the brand signature.
- Mixed case. A single stylized capital inside a word ("decisionS") is allowed on display
moments only, never in product UI headings.
- Never fully uppercase headlines outside display moments. No ALL CAPS body.
### Spacing, radius, elevation
- 4px base: `--space-sm` 4, `--space-md` 8, `--space-lg` 12, `--space-xl` 16, `--space-xxl` 20,
`--space-3xl` 24, `--space-4xl` 32, `--space-5xl` 40, `--space-section` 64.
- Radii lean sharp: `--radius-xs` 2, `--radius-sm` 4 (default UI), `--radius-md` 6,
`--radius-lg` 8 (max on cards), `--radius-xl` 12, `--radius-full` for pills.
- Shadows are minimal. `--elev-1` to `--elev-3` and `--elev-modal` are for floating layers
only (menus, modals, popovers). Inline cards get a 1px `--surface-stroke` border, no shadow.
- Control heights: `--control-h-sm` 24px, `--control-h` 34px, `--control-h-lg` 40px.
### Motion
Lines and arrows in motion, angular geometry, directional energy. Sharp transitions, no bounce,
no springs, no parallax.
- Durations: `--motion-instant` 80ms (press), `--motion-fast` 120ms (hover, focus),
`--motion-base` 200ms (default), `--motion-slow` 240ms (expand, drawer),
`--motion-deliberate` 360ms (attention reveals).
- Easings: `--ease-standard` (default), `--ease-emphasized` (large surfaces), `--ease-enter`,
`--ease-exit`, `--ease-linear`.
- Press is `scale(0.98)` over 80ms with no color flicker.
- Respect `prefers-reduced-motion`.
### Interaction states
- Focus: `box-shadow: var(--focus-ring)` (the Switch glow). Never remove a focus ring.
- Hover on a dark surface lifts one graphite step; Switch text moves to `--switch-contrast`.
- Disabled: `.is-disabled` on the control, plus the native `disabled` attribute on inputs.
### Iconography
- **Product UI:** Material Symbols Outlined, `bolt`.
Outline by default, filled only for a selected or active state. 16px in body, 20px in nav,
24px in headers. Approved subset: https://design-system.swivel-dev.net/design-system/icons.json.
- **Marketing:** Lucide at 1.5px stroke. Never mix the two families in one surface.
- Unicode arrows (→ ↑ ↗ ↘) are brand elements in metric readouts and CTAs, at headline weight.
- No emoji. No multi-color icons.
- Every icon-only control carries a tooltip (`data-tooltip`, a verb phrase) and an
`aria-label`.
### Logo
Logos live under https://design-system.swivel-dev.net/assets/logo/. Do not alter, rotate, recolor, or add effects.
- `swivel-lockup-switch.svg`, `swivel-lockup-white.svg`, `swivel-lockup-graphite.svg`, ...
- `swivel-symbol-switch.svg`, `swivel-symbol-white.svg`, `swivel-symbol-graphite.svg`, ...
## Component rules
- **Button groups filter or segment. They never take actions.** Anything that saves, runs,
deletes, or opens is a `Button`, `IconButton`, or `SplitButton`.
- **There are no radio buttons.** A single choice is a `ButtonGroup` (2 to 5 short options) or a
`SelectField` (6 or more, or long labels).
- **Light/dark is `.theme-switch`.** Any other binary setting that applies immediately is
`.switch`. A value submitted with a form is a `Checkbox`.
- **Tables are always `.table-card` + `.table-toolbar` + `.table` + `.table-pagination`.**
Never a bare `
`.
- **A count opens its records in place.** When a row counts child objects (demand tags,
creatives, packages), the count is a `.count-btn`; clicking it opens a sub-table directly
beneath the row. One level, one sub-table per row, at most 10 rows plus a "View all" link.
Never a chevron column, a row click, a Drawer, or a Modal. Full contract under
Table → Expandable rows below.
- **Async regions have three states:** Skeleton shaped like the content while loading,
EmptyState when nothing came back, ErrorState with a retry on failure. Never a blank card.
- **Every product page sits in `.app-shell`**: `TopHeader` pinned on top, `Sidenav` and the
scrolling `main` in the row below.
### Choosing a component
**binary input**
- A value submitted with a form or list — multi-select, table row selection, an opt-in → **Checkbox**. Not: Switch — a checkbox is pending until the form is saved
- A setting that takes effect the moment it is flipped → **Switch**. Not: Checkbox — a switch has no save step
- Light / dark mode → **ThemeSwitch**. Not: Switch or a ButtonGroup — this control is canonical and must actually work
**single choice**
- Two to five short options, all visible, used to filter or segment a view → **ButtonGroup**. Not: A SelectField — hiding a short filter set behind a menu costs a click for nothing
- Six or more options, or long labels → **SelectField**. Not: A ButtonGroup — it will wrap and lose its segmented read
- Anything that saves, runs, deletes, or navigates → **Button / IconButton / SplitButton**. Not: A ButtonGroup — it is selection only, never an action
- A single choice from a fixed small set in a form → **ButtonGroup or SelectField**. Not: Radio buttons — this system has none
**multi value input**
- Pick several from a known list → **SelectField multi (chips)**. Not: A count label like "3 selected" — the values must stay visible
- Type arbitrary values — IDs, keywords, emails → **TagInput**. Not: A SelectField — there is no list to pick from
- A bare option list inside a popover you own → **SelectMenu**. Not: SelectField — that is the field plus this menu
- A prose answer — a note, a description, a reason → **Textarea**. Not: TextField — a single-line input hides everything past the first line
**navigation**
- Get back up the hierarchy from a detail page → **Breadcrumb, above the page h1**. Not: A back link — a trail shows where you are, not just where you came from. And never a heading-scale trail: the breadcrumb never replaces the
.
- Switch between views of the same object → **Tabs**. Not: A ButtonGroup — tabs swap a panel, a group filters a list
- Move between top-level areas of the product → **Sidenav**. Not: Tabs
**status and feedback**
- A persistent state word on a row or object → **Badge**. Not: Chip — a chip is a value the user put there, a badge is a state the system reports
- A value the user selected or entered, removable → **Chip**. Not: Badge
- A receipt for something the user already did — saved, paused, archived → **Toast (useToasts + ToastRegion)**. Not: An inline Alert — it would persist on a page the user is done with
- Anything the user must read, act on, or refer back to → **Alert, inline where it applies**. Not: A Toast — it disappears, so the task becomes unfinishable
- A message about the page or form, staying put → **Alert**. Not: A Modal — do not block the page for information
- Extra detail on hover for an icon-only control → **Tooltip / data-tip**. Not: Help text — and never a tooltip for information the user needs to complete a field
**containers**
- Group related content on a page → **Card**. Not: A bare div — surface, stroke, and radius are contracted
- A decision that must be answered before continuing → **Modal**. Not: Drawer
- Secondary or ephemeral UI beside the page — filters, detail, settings → **Drawer**. Not: Modal — a drawer does not block, and never holds primary navigation
- A titled block the user can collapse → **Section**. Not: A hand-rolled card-header with a chevron icon you swap on click
**async regions**
- Data is loading → **Skeleton, shaped like the content that will land**. Not: A spinner or a blank card
- The query returned nothing → **EmptyState**. Not: A blank card
- The request failed → **ErrorState with a retry**. Not: EmptyState — the user needs to know it can be retried
**tables**
- Any list of records → **Table (.table-card + .table-toolbar + .table + Pagination)**. Not: A bare
- Row count and page navigation → **Pagination**. Not: Hand-rolled markup — it exists as a component
- A count on a list that is not paged → **Pagination countOnly**. Not: A loose
- See the child records behind a count in a row (demand tags, creatives, packages) without leaving the list → **Count expander: .count-btn opening a sub-table beneath the row (Table.expandable_rows)**. Not: A chevron column, a click-anywhere row, a Drawer, or a Modal. The count is the way in, and the children stay in the context of their parent
**data viz**
- A continuous metric over time → **Chart type="line"**. Not: Bars — they compare categories, not a trajectory
- Magnitude over time where the total matters more than the rate of change → **Chart type="area"**. Not: line — the fill is the point
- Ranked categories, "top N" → **Chart type="bar-horizontal", sorted by value**. Not: A vertical bar chart — long labels do not fit under vertical bars
- Part of a whole where the total is also interesting → **Chart type="bar" stacked, six slices maximum**. Not: A pie chart — this system has none
- One dominant percentage or count with a few supporting slices → **Donut with a centre anchor, four slices maximum**. Not: A pie — the anchor is what justifies the format
- Actual versus target → **Bullet**. Not: A gauge — it reads angle instead of length
- How far along something is against a known whole — delivery, an upload, an export → **ProgressBar**. Not: Bullet — that is actual against a target with qualitative bands
- Actual against a target where the bands matter (behind / on / ahead) → **Bullet**. Not: ProgressBar — it carries one reference line, not a qualitative range
- Where a flight is in its window, and which days it went dark → **FlightProgress**. Not: ProgressBar — it measures one quantity and cannot show gaps
- How much of a goal has been delivered → **ProgressBar**. Not: FlightProgress — that measures elapsed time, which is not the same as delivery
- Pacing in a table column → **ProgressCell**. Not: A bare percentage — the bar makes the row scannable at a glance
- One number on a dashboard → **KpiCard**. Not: A chart with one point
- Trend beside a number, in a table cell or a card → **Sparkline**. Not: A full Chart — no axes fit at that size
- More than three series → **Small multiples**. Not: Stacking them on one plot
- Axis labels on a plot you built yourself → **ChartFormat.axis**. Not: Hand-formatted strings — the currency, suffix, and precision rules are easy to half-apply
**dates**
- A date that is a property of the object being edited → **DateField (default, stacked label)**. Not: variant="inset-label" — a stacked label reads as a form field being filled in
- A relative range — last 7 days, this week → **DatePicker mode="range" presets**. Not: Making the user count backwards from today in the grid
- A date that changes what a table or list shows → **DateField variant="inset-label"**. Not: The stacked default — an inset label reads as a control acting on the view, and fits a --control-h toolbar row
- A single date the user types more often than picks → **DateField, with onTrailingClick opening a DatePicker**. Not: A bare TextField — the calendar affordance is what says this is a date
- A flight, campaign window, or reporting period → **DatePicker mode="range" — two months, ribbon fill**. Not: Two separate single pickers — the user cannot see the span
- A time of day, usually a round one → **TimePicker (dial, the default)**. Not: A TextField — the dial is the M3 spec and handles the meridiem
- A time needing exact minutes, or a time field in a dense form → **TimePicker defaultMode="input"**. Not: A bare TextField — the toggle lets the user go back to the dial
- Anything about picker behaviour the components do not cover → **The Material Design 3 spec, then recolour through the --md-* tokens**. Not: Inventing a Swivel-specific interaction — the policy is M3 structure, Swivel colour only
**activity**
- What a user or agent did, with the ability to see what changed → **ActivityPanel**. Not: A Table — it cannot carry a nested before/after diff, and it offers sorting and selection an audit log does not have
- A record that was created → **detail mode 'value'**. Not: mode 'diff' with empty befores — there was no previous value
- A record that was changed → **detail mode 'diff'**. Not: mode 'value' showing only the new state — the previous value is why the row is opened
- Filters for an activity stream → **.filter-rail beside the stream — date range, user select, grouped checkbox facets**. Not: A filter bar above it: four axes either wrap or push the first event below the fold
- An event with nothing to expand → **Omit detail; the chevron disappears**. Not: An empty detail table
**table cells**
- A record identifier → **kind id — .cell-id with an .id-chip, hugging**. Not: Plain text in a stretched column
- A number to compare down the column → **kind count or money — .cell-num header, .cell-metric body, right-aligned**. Not: Left-aligned text; digits only line up when right-aligned
- A status or lifecycle state → **kind state — one filled 18px icon with a hover label**. Not: A Badge per row; a column of pills is a wall
- A date or timestamp → **kind date — .cell-date, --graphite-bg at 13px**. Not: Body size at full strength, which competes with the row name
- Position in a fixed set of stages → **kind lifecycle — .lifecycle segments**. Not: ProgressBar; the stages are countable
- Delivery or pacing → **kind progress — ProgressCell with the figures in a tooltip**. Not: A bare bar
- A per-row action → **kind actions — last column, .cell-actions, revealed on hover**. Not: A column of always-visible glyphs, or three actions inline
- A count that opens a sub-list → **kind children — .children-stamp, centred**. Not: A bare number, which reads as a metric
- A count the user can click into → **kind count-button — .count-btn, --link coloured, .is-zero when empty**. Not: A .children-stamp, which does not look pressable
- Several values in one cell → **kind chips — first three plus a "+n" carrying the full list**. Not: All of them, which widens the row unpredictably
- A boolean qualifying the object name → **kind mark — an icon before the name inside .name-cell**. Not: Its own column, or a word repeated down every row
**lists**
- Homogeneous records to sort, select, and act on in bulk → **The data-table page**. Not: Cards, which cannot align values for comparison
- Records whose state is the first thing scanned for, with a few attributes read together → **ObjectCard in an ObjectStack — every card opens its detail page; add onMore for a per-row action**. Not: A table, which flattens them into columns that only make sense read together
- Two or more related record types on one page → **The tabbed list page**. Not: Separate pages
- What happened and what changed → **ActivityPanel**. Not: Either of the above
### Inventory
#### Button
- Root class: `.btn` · category: actions
- Variants: `.btn-primary`, `.btn-secondary`, `.btn-tertiary`, `.btn-tonal`, `.btn-accent`, `.btn-danger`, `.btn-tonal-error`
- Sizes: `.btn-sm`, md (default), `.btn-lg`
- React: `Button` from `components/Button/Button.jsx`
#### ButtonGroup
- Root class: `.btn-group` · category: inputs
- Variants: `text`, `.icon-only`, `icon-and-text`
- React: `ButtonGroup` from `components/ButtonGroup/ButtonGroup.jsx`
- Note: Unselected segments sit in --slate-100 with a --surface-muted hover fill; the selected segment takes the --slate-bg fill and deepens fill + label together on hover. Segment glyphs may carry .is-success / .is-warning / .is-error / .is-neutral for status tones. Filtering and segmented single-selection only — never actions.
#### Tooltip
- Root class: `.tooltip` · category: feedback
- Variants: `.tooltip`, `.tooltip tooltip-top`, `.tooltip tooltip-bottom`, `.tooltip tooltip-right`, `.tooltip tooltip-left`
- React: `Tooltip` from `components/Tooltip/Tooltip.jsx`
- Note: Fill contrasts page background (graphite/base). Typography: UI/Small (12px Avantt SemiBold). Two usage patterns: [data-tooltip] attribute for pure-CSS auto-tooltip; .tooltip class for JS-positioned overlays.
#### SplitButton
- Root class: `.btn-split` · category: actions
- Variants: `.btn-split-primary`, `.btn-split-primary btn-split-compact`, `.btn-split-ghost`
- React: `SplitButton` from `components/SplitButton/SplitButton.jsx`
- Note: Two-segment control: left fires the primary action, right opens a dropdown of related options. Use when 2-4 closely related actions share a surface and one is a clear default. Not for unrelated actions.
#### IconButton
- Root class: `.btn-icon` · category: actions
- Variants: `.btn-icon btn-primary`, `.btn-icon btn-secondary`, `.btn-icon btn-tertiary`, `.btn-icon btn-tonal`, `.btn-icon btn-danger`
- Sizes: `.btn-sm`, md (default), `.btn-lg`
- React: `IconButton` from `components/IconButton/IconButton.jsx`
- Note: Standalone square icon-only button. Shares variant/size tokens with Button — do not use ButtonGroup's .bg-btn.icon-only for standalone actions.
#### TextField
- Root class: `.field` · category: inputs
- Variants: `default`, `.field-inset`
- React: `TextField` from `components/TextField/TextField.jsx`
- Note: The icon adornment is not a variant — set the icon / iconRight props on either variant. Fields floor at min-width:240px. Wrap a field in .field-narrow to opt out of that floor (popover rows, packed toolbars); the modifier also clears the floor on .field-inset and .tag-input. An unlabelled field (a min/max pair under one group heading, a toolbar filter) takes ariaLabel — without a visible label or ariaLabel the input has no accessible name.
#### Textarea
- Root class: `textarea.field-input` · category: inputs
- Variants: `default`, `.field-inset`
- React: `Textarea` from `components/Textarea/Textarea.jsx`
- Note: Multi-line sibling of TextField — the same .field / .field-input / .field-help contract, so stroke, focus, and error treatment are shared. The one difference that matters: height is a row count, never --control-h. A textarea must not resolve to the 34px control height. Use it whenever the answer is prose (a note, a description, a pause reason) rather than a value.
#### DateField
- Root class: `.md-input` · category: inputs
- Variants: `.field`, `.md-input`
- React: `DateField` from `components/DatePicker/DatePicker.jsx`
- Note: Date input with a calendar affordance, in both label treatments. A text input, not a masked control — the host owns parsing and validation. Pair with DatePicker on the trailing glyph (see docked_pairing).
#### DatePicker
- Root class: `.md-datepicker` · category: inputs
- Variants: `.md-datepicker`, `.md-range`, `.md-range has-presets`
- React: `DatePicker` from `components/DatePicker/DatePicker.jsx`
- Note: Calendar for one date or a range. Single is 360px with 40x40 cells; range is 680px showing two months with a ribbon fill and 36x36 cells — an ad-ops flight is almost always read across a month boundary. Six week rows always render, so the grid height never jumps between months.
#### TimePicker
- Root class: `.md-timepicker` · category: inputs
- Variants: `dial`, `.is-input`
- React: `TimePicker` from `components/DatePicker/DatePicker.jsx`
- Note: M3 time picker in both spec'd modes. Dial is the default and is faster for round times (00, 15, 30, 45); input turns the hour and minute into text fields for sub-minute precision or a dense form. The M3 toggle sits on the action row and is preserved. 12-hour with an AM/PM stack; dial hours step 1, dial minutes step 5.
#### SelectField
- Root class: `.field` · category: inputs
- Variants: `default`, `.field-inset`
- React: `SelectField` from `components/SelectField/SelectField.jsx`
- Note: Field-shaped trigger that owns a SelectMenu popover. Closes on outside click, Escape, and on choice when not multi. With multi + chips it renders the .tag-input box, showing each selection as a dismissible chip rather than an 'N selected' summary — prefer that when the user needs to see and remove individual values. Multi-select shows dismissible chips (chips prop), not an "N selected" summary. Inside .field-inset the chips trigger draws no box of its own.
#### SelectMenu
- Root class: `.select-menu` · category: inputs
- Variants: `default`, `with-icons`, `with-search`, `multi-select`, `with-sections`
- React: `SelectMenu` from `components/SelectMenu/SelectMenu.jsx`
- Note: Popover dropdown — the surface SelectField opens, also usable on its own. Options are set by props, not variant classes: icons come from item.icon / item.iconUrl, sections from items with a header, search and multi from the search and multi props.
#### TagInput
- Root class: `.tag-input` · category: inputs
- Variants: `.tag-input`, `.field-inset tag-inset`
- React: `TagInput` from `components/TagInput/TagInput.jsx`
- Note: Chips inside are the canonical .chip element. Enter and comma commit the draft; Backspace on an empty draft removes the last tag.
#### Checkbox
- Root class: `.checkbox` · category: inputs
- Variants: `md`, `.checkbox-sm`
- React: `Checkbox` from `components/Checkbox/Checkbox.jsx`
- Note: A real , appearance:none, styled to match .sm-check so a menu row and a standalone box read identically. Table selection columns inherit the same rules via .table-card .cell-select input[type="checkbox"] at 15px. For a setting that applies the moment it is flipped, use Switch. There is no Radio in this system — single-choice pickers are a ButtonGroup or a SelectField.
#### Chip
- Root class: `.chip` · category: data
- Variants: `.is-selected`, `.is-outlined`, `.is-accent`, `.chip-square`
- Sizes: `.chip-sm`, md (default), `.chip-lg`
- React: `Chip` from `components/Chip/Chip.jsx`
- Note: Pill is the default shape; there is no round class. Style is set by boolean props, not a variant enum.
#### Badge
- Root class: `.badge` · category: data
- React: `Badge` from `components/Badge/Badge.jsx`
#### PlatformIcon
- Root class: `.pf-tile` · category: data
- Sizes: symbol (default), lockup (default)
- React: `PlatformIcon` from `components/PlatformIcon/PlatformIcon.jsx`
- Note: Third-party platform logos. Never recolor, restyle, or substitute. Always render on a neutral white plate. Assets in assets/partners/.
#### ChatSticker
- Root class: `.chat-sticker` · category: brand
- Variants: `.chat-sticker-reaction`, `.chat-sticker-thread`, `.chat-sticker-emphasis`, `.chat-sticker-inline`
- Sizes: reaction (default), thread (default), emphasis (default)
- React: `ChatSticker` from `components/ChatSticker/ChatSticker.jsx`
- Note: Animated brand GIF for use as a standalone chat message. Never embed inside a text bubble. Transparent background — no plate or shadow. The class sits on the itself, not a wrapper — target it as img.chat-sticker. basePath takes a prefix like "../.." and the component adds the separator. Its chat context is the .as-* agent workspace vocabulary — .as-working uses size="inline" (32px) — a real ChatSticker size, not a caller-side override — sized to sit beside the working-state text without crowding it. A separate .beacon-* demo block used to exist in components.css and was removed: there is one chat contract, not two.
#### Switch
- Root class: `.switch` · category: inputs
- Variants: `default`, `.accent`
- React: `Switch` from `components/Switch/Switch.jsx`
- Note: Binary on/off for a single setting (dark mode, auto-pause, notifications). Not for choosing between options — that is a ButtonGroup. Track is .switch-track; the label sits beside it inside .switch. Add role="switch" and aria-checked when the element is interactive.
#### ThemeSwitch
- Root class: `.theme-switch` · category: inputs
- Variants: `inline`, `.is-fixed`
- React: `ThemeSwitch` from `components/ThemeSwitch/ThemeSwitch.jsx`
- Note: THE canonical light/dark control. Sun icon, track, moon icon, no text label — the icon on the active side tints slate, the other stays muted. Drive it from aria-checked (true = dark), never from a button group. Track is .ts-track, icons are .ts-ico.ts-sun / .ts-ico.ts-moon. Add .is-fixed to pin it top-right on a specimen page. Persist the choice in localStorage under swivel-theme and apply it before first paint; first visit defaults to dark.
#### ActivityPanel
- Root class: `.activity` · category: feedback
- React: `ActivityPanel` from `components/ActivityPanel/ActivityPanel.jsx`
- Note: A reverse-chronological stream of what users and agents did, each row expandable into the record it wrote. Use for any audit, history, or activity surface. Not a Table: a table cannot carry a nested before/after diff, and it would offer sorting and selection that an audit log does not have. Not a bare timeline either — the point is that each row opens.
#### Alert
- Root class: `.alert` · category: feedback
- Variants: `.alert-filled`, `.alert-outlined`
- React: `Alert` from `components/Alert/Alert.jsx`
#### Toast
- Root class: `.toast-region` · category: feedback
- Variants: `.alert-info`, `.alert-success`, `.alert-warning`, `.alert-error`
- React: `Toast` from `components/Toast/Toast.jsx`
- Note: A transient receipt for an action the user already took — "Plan saved", "3 rows paused". Reuses the .alert body wholesale (.alert-icon, .alert-body, .alert-title, .alert-dismiss) and adds only the fixed stack, --elev-3, and enter/exit motion. Anything the user must READ, act on, or refer back to is an inline Alert; a destructive confirmation is a Modal.
#### IdChip
- Root class: `.id-chip` · category: navigation
- Note: Copyable object-ID pill, promoted out of Breadcrumb/Table so every consumer shares one contract: mono-ish body type, --graphite-10 fill, --graphite-90 text, pill radius, 22px, white-space: nowrap and flex: 0 0 auto (real IDs are hyphenated and the pill cannot grow). Used in Breadcrumb's object segment, the leading cell of any object table, ActivityPanel entries, and Beacon focus-object headers.
#### Breadcrumb
- Root class: `.breadcrumb` · category: navigation
- React: `Breadcrumb` from `components/Breadcrumb/Breadcrumb.jsx`
- Note: A small trail (--fs-ui-small, body font) sitting ABOVE the page's own
— it never replaces the heading. Parents in --graphite-bg, the current segment in --graphite-90 at weight 600. On an object page the object's ID always appears as a copyable IdChip (its own component — see "id-chip" below). The separator is always "/" and the last item is never a link (aria-current="page"). Required on every detail page: it is how an operator returns to the list they arrived from. Replaces the showcase-only .pp-crumb scaffolding, which rendered the trail at title scale — that treatment is retired.
#### TopHeader
- Root class: `.top-header` · category: navigation
- React: `TopHeader` from `components/TopHeader/TopHeader.jsx`
- Note: Fixed slot order, left to right: hamburger, brand lockup, account selector, spacer, theme switch, user indicator, logout. The bare .top-header tracks the page theme through tokens — use it in real product shells. .is-dark / .is-light are hardcoded specimen variants for showing both themes side by side on the showcase page; never ship them. Clicking the user indicator opens a grouped account popover (userMenu prop) when userMenu or onLogout is set — self-managed open state, closes on outside click, Escape, or picking an item.
#### Sidenav
- Root class: `.sidenav` · category: navigation
- Variants: `expanded`, `.is-collapsed`
- React: `Sidenav` from `components/Sidenav/Sidenav.jsx`
- Note: Production root is .sidenav; .sidenav-demo is the showcase alias with identical rules. Rows are .sn-item, active row takes .is-selected, groups sit under .sn-section headers.
#### Tabs
- Root class: `n/a` · category: navigation
- Variants: `undefined`, `undefined`
- React: `Tabs` from `components/Tabs/Tabs.jsx`
- Note: A tab never wraps: .tab is white-space: nowrap and flex: 0 0 auto, because a shrinkable tab breaks a multi-word label and leaves the underline indicator under a two-line box. Counts are items[].count, rendered as .tab-count — concatenating "(12)" into the label makes the preceding space a break opportunity and orphans the count on its own line.
#### Card
- Root class: `.card` · category: containers
- Variants: `.card-outline`, `.card-elevation`, `.card-flat`
- Sizes: `.card-sm`, md (default), `.card-lg`
- React: `Card` from `components/Card/Card.jsx`
#### Drawer
- Root class: `.drawer-overlay` · category: containers
- Variants: `.drawer-overlay`, `.drawer-overlay drawer-left`
- Sizes: `.drawer-sm`, default (default), `.drawer-lg`
- React: `Drawer` from `components/Drawer/Drawer.jsx`
- Note: Slide-in panel anchored to viewport edge. Use for settings, detail views, filter panels. Open by adding .is-open to .drawer-overlay.
#### Modal
- Root class: `n/a` · category: containers
- React: `Modal` from `components/Modal/Modal.jsx`
#### EditableTitle
- Root class: `.editable-title` · category: layout
- Variants: `.is-h1`, `.is-h2`
- Note: Click-to-rename h1/h2 for object detail pages. Static state shows a pencil glyph on hover/focus; click (or Enter/Space) swaps to a text input; Enter/blur commits, Escape reverts. Sits below the Breadcrumb, replacing the plain heading — never used on a list/collection heading, which has no single name to rename.
#### Chart
- Root class: `.chart` · category: data-viz
- Variants: `line`, `area`, `bar`, `bar-horizontal`
- React: `Chart` from `components/Chart/Chart.jsx`
- Note: Line, area, and bar charts with the Swivel axis, tooltip, and palette contract built in. design-system/charts.rules.yaml is the full rule registry and Charts.html the rendered showcase — this component implements the universal rules so a consumer cannot half-apply them. The plot is SVG; the .chart-* classes own the chrome (head, axis type, tooltip, legend). Bars always compare from zero; the axis is never truncated.
#### Sparkline
- Root class: `.chart-sparkline` · category: data-viz
- React: `Sparkline` from `components/Chart/Chart.jsx`
- Note: Inline trend for one metric — no axes, no tooltip. Context beside a number, not analysis. Also the trend inside a KpiCard.
#### Donut
- Root class: `.chart-donut-wrap` · category: data-viz
- React: `Donut` from `components/Chart/Chart.jsx`
- Note: One anchor number with up to four supporting slices. The centre is what justifies the format — never a pie. Past four slices, or when values sit within 5% of each other, or when there is no anchor number, switch to Chart type="bar-horizontal": humans rank lengths, not arcs.
#### Bullet
- Root class: `.chart-bullet-measure` · category: data-viz
- React: `Bullet` from `components/Chart/Chart.jsx`
- Note: Actual versus target with qualitative bands. Replaces gauges — a gauge spends a lot of space on one number and reads its angle, not its length.
#### ProgressBar
- Root class: `.progress` · category: data-viz
- Variants: `determinate`, `.is-indeterminate`
- Sizes: sm (default), md (default), lg (default)
- React: `ProgressBar` from `components/ProgressBar/ProgressBar.jsx`
- Note: Completion of a known whole — a delivery share, an upload, an export. Distinct from two neighbours: Bullet is actual against a target with qualitative bands, and Skeleton stands in for content that has not arrived. A progress bar says how far along something is; a skeleton says nothing is here yet.
#### FlightProgress
- Root class: `.progress-flight` · category: data-viz
- React: `FlightProgress` from `components/ProgressBar/ProgressBar.jsx`
- Note: Elapsed time across a flight window, with dark days blocked out. This measures TIME, not delivery — impressions against a goal is a ProgressBar, pacing against a target with qualitative bands is a Bullet. Two encodings in deliberately different colour families: --slate-100 for time spent, --graphite-80 for time the flight was not delivering.
#### KpiCard
- Root class: `.kpi-card` · category: data-viz
- React: `KpiCard` from `components/KpiCard/KpiCard.jsx`
- Note: One number, one label, one delta. If a second dimension is needed the answer is a chart, not a bigger card. The label states the window the delta covers ("vs. last 7d") — never the card chrome.
#### Table
- Root class: `.table-card` · category: data
- Variants: `.table-card-elevation`, `.table-card-outline`
- CSS only: class hooks, no React component.
- Note: A table is always .table-card + a variant class wrapping .table-toolbar, the table itself, and .table-foot. The List page pattern requires the toolbar — never ship a bare
. Cell classes carry the type: .cell-select, .cell-id (with .id-chip), .cell-metric (right-aligned numerals), .cell-date, .cell-state. Sortable headers use a .th-sort button. Column visibility: add .col-hidden to a th/td pair to hide that column. Status cells are icon-only: .cell-state plus a tone class (.is-success, .is-warning, .is-error, .is-neutral) and .is-outline for an unfilled glyph; give the glyph data-tip so hover reveals the status text. Row hover uses --slate-bg so it reads as selection-adjacent and never matches the --graphite-10 header fill. The .id-chip in the ID column is the same contract the Breadcrumb uses; as a