AhaSlides Design
v0.52.0React · Vue · Lit

Agent feeds · raw

llms-full.txt raw feed

Every component doc concatenated — the full-context feed.

◆ generated — the exact file served to agents at /llms-full.txt · do not edit by hand

llms-full.txt
# Add Item Button
> Generated from add-item-button.contract.json — do not edit by hand.

The full-width "+ Add" affordance that sits under a settings list (OptionRow / QuestionList) — a dashed, tertiary add row that appends a new item.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, settings, dashboard.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | string | `Add` | The add-affordance text; carries the accessible name |
| `disabled` | boolean | `false` | Non-interactive (e.g. at the max item count); muted, no hover, sets aria-disabled |
| `add` | CustomEvent<{}> | `—` | Composed event fired on click when enabled — the host appends a new item |

## Visual standard (measured)
- Box: full width (100%) · height 40 · radius 8 · padding 0 12
- Border: 1px dashed #E3E3E3 (rest)
- Content: centered — system-plus 16px + label, 8px gap
- Text: Plus Jakarta 14/600, #4A4A4A (secondary)
- Hover: border + text → #6A1EBB, #F9F5FF fill (animated)
- Focus: #6A1EBB border + 2px soft ring
- Disabled: #B5B5B5 text, #EBEBEB border, not-allowed, no hover, aria-disabled

## When to use
- **Add Item Button** — append a new row to an editable settings list — options, questions, list items
- **Button (tertiary)** — a standalone action that isn't 'add another row to this list'

Place it directly under the list it grows, full-width so it reads as part of the stack. Label it with the item noun — 'Add option', 'Add question' — not a bare 'Add' when the context needs it. Disable (don't hide) it at the max count so the ceiling stays discoverable; pair with a tooltip or helper line explaining the limit.

---

# Alert
> Generated from alert.contract.json — do not edit by hand.

An inline, contextual feedback banner — info, success, warning, error, or branding — in regular/small sizes, with an optional full-width banner mode and a trailing action.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `type` | info | success | warning | error | branding | `info` | Tone + status icon + surface/border tokens |
| `heading` | string | `—` | Optional bold title above the message |
| `size` | regular | small | `regular` | Compact padding + type scale |
| `banner` | boolean | `false` | Full-width, square-cornered edge-to-edge page notice |
| `closable` | boolean | `false` | Shows a dismiss control (✕ chrome) |
| `hide-icon` | boolean | `false` | Suppresses the leading status icon (the showIcon toggle) |
| `slot="action"` | slot | `—` | Trailing action — a button or link |
| `close` | CustomEvent | `—` | Composed event when the banner is dismissed |

## Visual standard (measured)
- Box: flex row, gap 10 · padding 10 14 · radius 8 · 1px tonal border
- Text: Plus Jakarta 14/1.5; heading SemiBold 600
- Info: bg #F4F8FF, border #BFD2FF, icon #9BB3E9
- Success: bg #D8FAEF, border #16C49A, icon/text #13A181
- Warning: bg #FFF5F0, border #FF7747, icon/text #E65B29
- Error: bg #FFF1F0, border #F5222D, icon/text #F5222D
- Branding: bg-accent #F9F5FF, border-focus #D3B4FF, icon/text color-primary #6A1EBB
- Small: padding 6 10 · gap 8 · 13px text · 16px glyph
- Banner: full-width, square (radius 0), border on the bottom edge only
- Action: trailing slot="action" for a button/link
- Icon: status glyph via <aha-icon> (system-info / system-check-circle / system-warning-circle / system-x-circle / system-sparkle)

## When to use
- **Alert** — a persistent, inline message tied to a region of the page (a form warning, a page-level notice)
- **Toast** — a brief, transient confirmation of an action that then disappears
- **Notification** — a richer, stacking message with a title + description, often from a background event

Keep the message to one or two lines; lead with what happened, then what to do. Reserve error for real failures — over-using red trains people to ignore it.

---

# AutoComplete
> Generated from autocomplete.contract.json — do not edit by hand.

Free-text input with suggestions as you type — search boxes, tag entry, known-but-open fields — rendered through the shared AutoComplete.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `options` | { value, label? }[] | { label, options }[] | `[]` | Suggestions shown as the user types — flat, or grouped ({ label, options }) for section headers |
| `value` | string | `—` | Current free-text value |
| `size` | 'small' | 'default' | 'large' | `'default'` | Control height — 24 / 32 / 40, matching the DS V3 Input size set |
| `status` | 'error' | 'warning' | `—` | Validation state — red / amber border, same as the DS V3 Input |
| `disabled` | boolean | `false` | Non-interactive, muted control |
| `allowClear` | boolean | `false` | Shows a clear (×) affordance once there's a value |
| `onSearch` | (text) => void | `—` | Fires as the query changes — fetch/filter suggestions |
| `onSelect` | (value) => void | `—` | Fires when a suggestion is chosen |
| `filterOption` | boolean | (input, option) => boolean | `true` | Client-side filter; set false when onSearch fetches server-side |
| `placeholder` | string | `—` | Hint shown when empty |

## Visual standard (measured)
- Control: height 32 · radius 8 · 1px #E3E3E3 border
- Sizes: small 24 · default 32 · large 40 (DS V3 Input set)
- Status: error #F5222D · warning #FAAD14 border
- Focus: brand #6A1EBB border
- Active option: #F7F7F7 (gray-20) row
- Selected option: brand-tint #F9F5FF row
- Placeholder: #8A8A8A

## When to use
- **AutoComplete** — free text where suggestions help but the user may type their own value
- **Select** — the value MUST come from a known set — don't allow free text
- **Input** — free text with no suggestions to offer

Filter suggestions on the query, don't dump the whole list. AutoComplete allows values outside the options — if the value must be from the set, use Select instead.

---

# Avatar
> Generated from avatar.contract.json — do not edit by hand.

Represent a person or entity with a photo, initials, or an icon — solo or in a stacked group.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `name` | string | `—` | Full name; initials are derived when no src/icon. Also the alt/aria-label |
| `src` | string | `—` | Image URL; the photo content mode (overrides icon/initials) |
| `icon` | string | `—` | DS icon name (e.g. 'system-user'); the glyph content mode when no src |
| `size` | 'small' | 'default' | 'large' | number | `default` | 24 / 40 / 64 named steps, or any numeric px |
| `shape` | circle | square | `circle` | Clip shape |
| `color` | token name | `—` | Ground tint — an --aha-* token suffix, e.g. 'bg-positive' |
| `max` | number | `—` | (aha-avatar-group) collapse children past this count into a +N chip |

## Visual standard (measured)
- Size: small 24 · default 40 · large 64 (named), or any numeric px via `size`
- Shape: circle (pill radius) · square (radius 8)
- Initials: bg-accent #F9F5FF, purple-60 #6A1EBB, SemiBold 600
- Image: object-fit cover, clipped to shape
- Icon: <aha-icon> glyph by name, ~half the avatar diameter, currentColor
- Color: ground tint from an --aha-* token via `color` (default brand tint)
- Group: stacked overlap (-10px), 2px container-white ring, +N chip past `max`

## When to use
- **Avatar** — identifying a person or entity — a presenter, a participant, an author
- **Icon** — a generic symbol, not a specific identity
- **Tag** — labelling with a category rather than an identity

Always provide `name` even when `src` is set — it becomes the alt text and the initials fallback if the image fails. Prefer initials or a photo over a generic icon for a real identity; reserve the icon mode for a placeholder/guest slot. In a group, keep sizes uniform and set `max` so the +N chip stays legible.

---

# Badge
> Generated from badge.contract.json — do not edit by hand.

A small count or status marker — notification counts, unread dots, a standalone status label.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `count` | number | `0` | The number shown in the bubble; 0 hides it unless showZero |
| `overflowCount` | number | `99` | Cap; above it renders as N+ (alias: max) |
| `showZero` | boolean | `false` | Keep the bubble visible when count is 0 |
| `dot` | boolean | `false` | Render a bare marker, no number |
| `status` | success | processing | error | warning | default | primary | `error` | Semantic colour; with `text` (no count/dot) renders a standalone dot + label |
| `text` | string | `—` | Label for a standalone status badge |
| `color` | CSS colour | `—` | Custom colour overriding the semantic default |
| `ribbon` | boolean | `false` | Wrap the slotted child (a card) with a corner ribbon banner; `text` is the label, colour from color/status (default primary) |
| `placement` | start | end | `end` | Ribbon corner: end = top-right (default), start = top-left |
| `tone` | danger | success | positive | essential | pro | branding | primary-alt | `—` | Filled/tinted LABEL chip; each tone binds bg + fg to an --aha-* token |
| `plan` | free | edu | essential | pro | enterprise | `—` | Plan preset chip (solid brand fill); pro/enterprise lead with a crown glyph |
| `cycle` | monthly | yearly | `—` | Appended to a plan chip's default label (e.g. "Pro · Yearly") |
| `rank` | 1 | 2 | 3 | 4 | `—` | Leaderboard rank chip; medal-tinted (gold/silver/bronze) with a leading system-Medal glyph |
| `session` | string | `—` | Renders a "Session N" chip; the value is N (slotted content / `text` overrides the label) |
| `href` | URL | `—` | Makes a tone/plan/rank/session chip a clickable LINK — renders <a class="chip" href> (native focusable, animates on hover), never a <button>. Omit for a static <span> chip |

## Visual standard (measured)
- Count bubble: height 18 · min-width 18 · radius pill · padding 0 6 · scales in
- On a child: floats to the child's top-right, 1px container-colour ring
- Dot: 8×8, no label
- Status: standalone 6px dot + 14px label; processing pulses
- Text: Plus Jakarta SemiBold 600, 11/18, #FFFFFF
- Default colour: count/dot → error #F5222D; status → its semantic token; ribbon → primary #6A1EBB
- Overflow / zero: count > overflowCount → N+; 0 hides unless showZero
- Ribbon: corner banner over a wrapped card; 22px tall, radius 4, triangular fold; placement start/end
- Tone chip: filled/tinted pill (radius pill, 22px); danger·success·positive·essential·pro·branding·primary-alt, each bound to an --aha token
- Plan chip: preset (free·edu·essential·pro·enterprise); solid brand fill, cycle monthly/yearly; pro/enterprise lead with a crown
- Rank chip: leaderboard 1st–4th, medal-tinted (gold/silver/bronze), leading system-Medal glyph
- Session chip: "Session N" brand-accent pill; label from `session` / slotted text
- Clickable = link: with `href` renders <a class="chip"> (native focusable link, animates on hover) — never a <button>; no href = plain <span>

## When to use
- **Badge count/dot** — a count or presence marker attached to another element (a bell, a tab, an avatar)
- **Badge status** — a standalone semantic state label (Published, Generating, Failed) in a list or header
- **Tag** — a standalone label/keyword chip with text but no status semantics

A count/dot marks another element — wrap the element it belongs to so the pill anchors to its top-right. Keep counts meaningful; cap large numbers with overflowCount so the bubble stays small. Reserve the standalone status form for a semantic lifecycle state, and let processing pulse rather than spin a separate loader.

---

# Breadcrumb
> Generated from breadcrumb.contract.json — do not edit by hand.

A trail of ancestor links ending in the current page — and, at size=page-title, the page heading itself (there is no separate page-title component).

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, admin, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `items` | JSON [{label, href?, icon?, disabled?}] | `[]` | The trail; an entry with href is a link, the last / href-less entry is the current page, icon adds a leading DS glyph, disabled mutes it |
| `size` | 'default' | 'mini' | 'page-title' | `default` | Scale of the trail. default 13/22 · mini 12/18 for sub-page nav; page-title renders the current crumb as the page heading (24/600 alone, 18/600 in a trail) |
| `heading-level` | 1 | 2 | 3 | 4 | 5 | 6 | `1` | Heading tag used for the current crumb when size=page-title — an <h1> page title by default; drop to h2–h6 if the breadcrumb isn't the page's top heading |
| `separator` | 'caret' | 'slash' | `caret` | Divider glyph between crumbs — the DS caret (default) or a slash |
| `maxItems` | number | `0` | Collapse the middle into an ellipsis toggle when the trail is longer than N (0 = never) |
| `navigate` | CustomEvent | `—` | Composed event on a link click, detail { index, href } |

## Visual standard (measured)
- Type: Plus Jakarta — default 13/22, mini 12/18, page-title 18/1.3 (solo title 24/1.3)
- Link: text-secondary #4A4A4A → hover color-primary #6A1EBB (motion-fast + ease-out)
- Current: text-default #1A1A1A, SemiBold 600, aria-current=page (stronger than links)
- Page title: size=page-title → current crumb is a real heading (<h1>, heading-level picks h1–h6); whole trail SemiBold 600; standalone (1 item) = Heading4 24/600/ls 0, in a trail = 18/1.3/ls 0.2
- Item icon: optional leading <aha-icon>, currentColor — 12 mini / 14 default / 16 page-title
- Disabled: text-disabled #B5B5B5, not a link (aria-disabled)
- Separator: caret (<aha-icon name="system-caret-right">, icon-muted, 12 · 16 at page-title) or slash "/"
- Collapse: maxItems > trail → first + ellipsis toggle + trailing (maxItems−1)
- Gap: 8 between crumbs (4 at mini), 6 icon→label

## When to use
- **Breadcrumb (page-title)** — the heading of a product page — size=page-title makes the current crumb the page <h1>, alone or at the end of its ancestor path. This is THE page title; do not hand-roll a plain <h1>/heading
- **Breadcrumb (default / mini)** — showing where a page sits in a hierarchy the user can climb back up, without heading-level prominence
- **Screen heading** — the page header ROW — a page title (via this breadcrumb) plus right-aligned action buttons
- **Tabs** — switching between sibling views at the same level — not a hierarchy
- **Back button** — a single linear step back is all that's needed

The page title is NOT a plain <h1> — it is <aha-breadcrumb size="page-title">, with just the current page as the only item (or the full ancestor path in front of it). A hand-rolled heading is the wrong answer: it drifts from the DS type scale and loses the built-in trail, aria-current, and navigate event. Keep breadcrumbs to the real ancestor path — don't pad them. The last item is the current page and is never a link. Labels are short nouns, sentence case.

---

# Button
> Generated from button.contract.json — do not edit by hand.

Trigger an action or navigation — the primary interactive control.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `variant` | primary | secondary | tertiary | link | danger | success | positive | primary-alt | text | text-link | `secondary` | tone × emphasis; one primary per view. success is a synonym of positive; primary-alt of primary; text / text-link are borderless label buttons |
| `size` | sm | md | lg | `lg` | 28 / 36 / 40px height — lg (40) is the default |
| `disabled` | boolean | `false` | non-interactive; grey fill |
| `loading` | boolean | `false` | shows a spinner, blocks clicks |
| `block` | boolean | `false` | full-width |
| `iconOnly` | boolean (attr icon-only) | `false` | square, label-less; requires aria-label |
| `icon` | slot | `—` | leading glyph via <span slot="icon">; for icon-only, the glyph is the default child |

## Visual standard (measured)
- Sizes: sm 28 (r4) · md 36 (r8) · lg 40 (r8, default) — heights; fontSize 14 (sm/md) / 16 (lg)
- Primary: bg #6A1EBB, label #FDFDFD, hover #8644D4, active #5715A0
- Secondary: white bg, #1A1A1A label, 1px #E3E3E3 border; hover brand-tint #F9F5FF + #A96FF0 border
- Tertiary / Link: transparent; tertiary hover #F9F5FF, active #F0E4FF; link #6A1EBB weight 400
- Danger: bg #F5222D, white label, hover #FF4D4F, active #CF1322
- Positive / Success: bg #4EF1C5, dark label #1A1A1A, hover #93F5DA, active #20E8B5 (success = alias of positive)
- Primary-alt: same fill as primary #6A1EBB (a second primary slot for a two-primary layout)
- Text / Text-link: white bg, no border, weight 400; text = secondary-grey label, hover grey fill; text-link = #6A1EBB label, hover #A96FF0
- Disabled: bg #E3E3E3, label #B5B5B5
- Label / icon: Plus Jakarta Sans SemiBold 600, 14 (sm/md) / 16 (lg/xl); icon 16px, inline-flex, 8px gap
- Focus: soft box-shadow ring, spread 2 — #D3B4FF@30% (default) · danger #FF2850@20% · positive #20E8B5@30%
- Elevation: subtle drop-shadow (primary rgba0,0,0,.04 · secondary .016)
- Icon-only: square (width = height per size: 28/36/40/52), glyph centred, no label; needs aria-label

## When to use
- **primary** — the single most important action on a view (one per screen)
- **secondary** — the default for most actions — neutral, white with a border
- **tertiary / link** — low-emphasis or inline navigation, no container weight
- **danger** — a destructive action (delete, remove) — pair with a confirm
- **positive** — a confirming/success action where a positive signal helps
- **icon-only** — a compact action where the glyph is self-explanatory (close, more, add) — always give an aria-label

Size: LARGE (lg) is the default for most actions; step down to MEDIUM (md) or SMALL (sm) in denser/secondary regions to lower an action's information-hierarchy, not by whim. Size only via the size attribute — never inline height/padding/radius/fontSize. Primary fill is #6A1EBB; hover #8644D4 per DS V3.

---

# Card
> Generated from card.contract.json — do not edit by hand.

A surface that groups related content — cover, title, body and footer actions — in default/small size, bordered or flat, with a hover lift and a loading skeleton.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `card-title` | string | `—` | Renders the header row; omit for a bare body |
| `size` | 'default' | 'small' | `default` | Padding density of header/body/actions |
| `bordered` | boolean-ish | `true` | Set bordered="false" for a flat, outline-less surface |
| `hoverable` | boolean | `false` | Lift the card (border + shadow) on hover |
| `loading` | boolean | `false` | Swap the body for a shimmer skeleton while content loads |
| `cover` | slot | `—` | Full-bleed image/media above the header |
| `extra` | slot | `—` | Right-aligned header action (a link or button) |
| `actions` | slot | `—` | Ruled-off footer row of actions |

## Visual standard (measured)
- Container: border 1px #E3E3E3 · radius 12px · bg #FFFFFF
- Cover: full-bleed media above the header, no inset; hidden when the slot is empty
- Header: padding 16/20 · 16/24 SemiBold #1A1A1A · divider #F1F1F1 · extra action right-aligned
- Body: padding 20 · 14/22 #4A4A4A
- Actions: footer row, 12/20 padding, 8 gap, top divider #F1F1F1; hidden when empty
- Small size: header 8/12 · body 12 · actions 8/12 (antd Card size=small)
- Bordered=false: border-color transparent — a flat surface
- Hover (hoverable): border #D3B4FF + shadow, motion-mid ease-out on a persistent node
- Loading: body → shimmer skeleton bars (aria-busy), motion-slow sweep

## When to use
- **Card** — grouping related content into a titled surface on a dashboard or settings page
- **List** — many uniform rows of the same shape
- **Descriptions** — read-only label/value pairs about one entity

Titles are sentence case and name the group — 'Live results', not 'LIVE RESULTS'. Keep one idea per card; reach for hoverable only when the whole card is clickable. Use a cover for a media-led card, footer actions for card-level operations, and loading while the body fetches.

---

# Card select
> Generated from card-select.contract.json — do not edit by hand.

Single-select as an icon+label card grid — pick one of N options each shown as its own visual card, with roving radiogroup keyboard navigation. Two layouts: labelled tiles (stretch to fill `columns`) or compact 44px icon-only squares.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `options` | Array<{value,label,icon}> | `[]` | The cards — each an icon name + a label; set as a property or a JSON attribute |
| `value` | string | `—` | The selected option's value (single-select) |
| `columns` | number | `3` | Grid column count — labelled tiles fill each track; iconOnly lays out that many 44px squares |
| `iconOnly` | boolean | `false` | Compact layout — fixed 44px icon squares packed to the start; the label is hidden but kept as title + aria-label |
| `disabled` | boolean | `false` | Non-interactive; grey fill |
| `change` | CustomEvent<{value}> | `—` | Composed event on selection; read e.detail.value |

## Visual standard (measured)
- Card: icon 24 over label; padding 16 12; radius 8; 1px #D4D4D4 border
- Grid: repeat(columns, minmax(0,1fr)) — tiles stretch to fill; columns default 3; 8px gap
- iconOnly: repeat(columns, 44px) — fixed 44px squares packed to the start; label hidden, kept as title + aria-label
- Selected: #6A1EBB border + #F9F5FF tint + #6A1EBB label/icon (persistent node)
- Hover: #D3B4FF border
- Keyboard: roving radiogroup — one tab-stop; Arrow keys move-and-select; Space/Enter select
- Disabled: #F1F1F1 fill, #EBEBEB border, not-allowed

## When to use
- **Card select** — pick one of N options, each carrying a visual (icon/preview) — a chart-type, layout or mode picker
- **Segmented** — 2–4 short text-only options in one row (SETTINGS-38)
- **Select** — a longer or space-constrained set with no per-option visual

Reach for CardSelect only when each option carries a visual. Do not hand-roll a grid of bespoke <button>/<div> tiles with inline <svg> icons and a manual selected state (SETTINGS-51) — the roving radiogroup a11y is the hard part and it lives here.

---

# Carousel
> Generated from carousel.contract.json — do not edit by hand.

A swipeable set of slides — onboarding, a feature tour, a gallery — rendered through the shared Carousel.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `autoplay` | boolean | `false` | Advance slides automatically |
| `dots` | boolean | `true` | Show the position dots |
| `dotPosition` | top | bottom | left | right | `bottom` | Where the dots sit relative to the track |
| `effect` | scrollx | fade | `scrollx` | Slide transition style |
| `arrows` | boolean | `false` | Show prev/next arrows |
| `afterChange` | (current) => void | `—` | Fires after the active slide changes |

## Visual standard (measured)
- Slide: radius 8 · brand-tint #F9F5FF panel
- Active dot: brand #6A1EBB, wider (28px)
- Rest dot: 16×4, gap 8
- Dot position: bottom (default) · top · left · right
- Effect: slide (scrollx, default) · fade
- Arrows: opt-in prev/next via the arrows prop
- Motion: antd's built-in slide/fade transition (kept)
- Autoplay: opt-in via the autoplay prop

## When to use
- **Carousel** — a small, ordered set of equally-weighted panels the user can browse at their own pace
- **Tabs** — named sections the user jumps between directly, not in sequence
- **Steps** — a linear process with a clear start and finish

Keep it to a handful of slides and always show the dots so people know there is more. Autoplay only for ambient galleries — never for content people need to read.

---

# Checkbox
> Generated from checkbox.contract.json — do not edit by hand.

Let a user turn one or more independent options on or off.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `checked` | boolean | `false` | Controlled selection |
| `indeterminate` | boolean | `false` | Mixed state for check-all |
| `disabled` | boolean | `false` | Disables the control |
| `change` | CustomEvent<{checked}> | `—` | Composed event; read e.detail.checked |

## Visual standard (measured)
- Box: 14×14 r4
- Rest border: #B4BCCF
- Hover border: #D3B4FF
- Checked fill: #6A1EBB + white 12px tick
- Label: Plus Jakarta 14/21

## When to use
- **Checkbox** — one or more independent options; applies on submit
- **Radio** — exactly one from a small mutually-exclusive set
- **Switch** — a single setting that takes effect immediately

Labels are sentence case and state what the control does — 'Let participants join anonymously', not 'Join Anonymously'. Keep product nouns: Q&A, Word cloud.

---

# Collapse
> Generated from collapse.contract.json — do not edit by hand.

A single expandable panel that hides secondary content until asked.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `open` | boolean | `false` | Whether the panel is expanded; toggled on header click (observed → aria-expanded synced) |
| `accordion` | boolean | `false` | Opening this panel closes sibling accordion panels under the same parent (one-open-at-a-time) |
| `ghost` | boolean | `false` | Borderless, transparent surface that sits seamlessly on the page |
| `size` | 'small' | `—` | Denser header + body padding |
| `icon-position` | 'start' | 'end' | `start` | Caret on the leading or trailing edge of the header |
| `disabled` | boolean | `false` | Non-interactive, dimmed panel (observed → aria-disabled synced) |
| `header` | slot | `Section` | The always-visible summary row |
| `extra` | slot | `—` | A trailing header accessory (a tag, a count, an icon) |
| `toggle` | CustomEvent<{open}> | `—` | Composed event; read e.detail.open |

## Visual standard (measured)
- Panel: border 1px #E3E3E3 · radius 8px · bg #FFFFFF (ghost: borderless, transparent)
- Header: padding 14/16 · 15/22 SemiBold #1A1A1A · hover #F7F7F7 (small: 8/12 · 14/20)
- Caret: system-caret-right <aha-icon>, rotate 0°→90° on open, motion-mid ease-in-out
- Icon position: start (leading, default) or end (trailing edge, via order)
- Body: grid-rows 0fr→1fr, motion-mid ease-in-out · 14/22 #4A4A4A
- Disabled: text-disabled #B5B5B5, not-allowed, hover suppressed, aria-disabled

## When to use
- **Collapse** — hiding secondary detail (advanced settings, an FAQ answer) until wanted
- **Tabs** — switching between peer views of equal importance
- **Card** — content that should always be visible

Header text names what's inside — 'Advanced settings', not 'More'. Keep panels shallow; deep nesting hides content people never find.

---

# Color picker
> Generated from color-picker.contract.json — do not edit by hand.

Pick a colour from a selectable palette of presets.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | string | `#6A1EBB` | Selected colour (hex) — its swatch is ringed + checked |
| `swatches` | string | `brand ramp (12)` | Pipe-separated hex presets, e.g. "#6A1EBB|#FF4081" |
| `size` | 'small' | 'default' | 'large' | `default` | Scales trigger + swatch dimensions |
| `disabled` | boolean | `false` | Greys + inerts the whole control |
| `open` | boolean | `false` | Panel open state; reflected attribute |
| `change` | CustomEvent<{value}> | `—` | Composed event; read e.detail.value |

## Visual standard (measured)
- Trigger: 1px border #E3E3E3, radius 6; current swatch 16/20/24 by size (radius 4)
- Panel: bg-elevated #FFFFFF, radius 8, 4-col swatch grid
- Swatch: 22/28/34 by size, radius 4; selected ringed color-primary + check glyph
- Selected: aria-pressed=true, .on ring + system-check (dark on pale chips)
- Disabled: bg-container-disabled fill, border-disabled, not-allowed, inert
- Motion: panel fade + lift; swatch hover scale; check fade (ease-out)

## When to use
- **Color picker** — choosing a brand or theme colour from a curated set
- **Segmented** — picking a labelled option rather than a colour
- **Input** — an arbitrary free-form value is genuinely needed

Prefer a curated palette over a free spectrum — on-brand results, fewer accessibility misses. Show the current value beside the trigger so the choice is legible.

---

# Counted input
> Generated from counted-input.contract.json — do not edit by hand.

A single-line settings field whose character counter shows only while the field is focused and is hidden at rest — the shared CountedInput pattern for option/answer text.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | string | `""` | Controlled text value |
| `placeholder` | string | `—` | Hint shown when empty |
| `maxlength` | number | `100` | Character cap (settings-lab default 100); drives the focus-only counter (shows current/max). Pass `none` to drop the cap and hide the counter |
| `size` | small | default | large | `default` | Control height — small 24 · default 32 · large 40; radius stays 8 |
| `status` | default | error | `default` | Error recolours the border + ring and sets aria-invalid |
| `disabled` | boolean | `false` | Non-interactive; grey fill |
| `readonly` | boolean | `false` | Selectable but not editable; secondary fill |
| `input` | CustomEvent<{value}> | `—` | Composed event on each keystroke; read e.detail.value |
| `change` | CustomEvent<{value}> | `—` | Composed event on commit (blur/enter) |

## Visual standard (measured)
- Box: height 32 (default) · radius 8 · padding 0 12
- Sizes: small 24 · default 32 · large 40 — all radius 8
- Rest border: 1px #E3E3E3
- Focus: #6A1EBB border + 2px #D3B4FF@30% ring (on the persistent wrapper)
- Counter: 12/18 #8A8A8A, overlay inside the field, right 12; opacity 0 at rest → 1 on focus
- Reveal: focus-only (:host([_focused][_counted])) — hidden at rest; space always reserved (no shift)
- Error: #F5222D border + error-tinted ring; sets aria-invalid
- Disabled: #F1F1F1 fill, #EBEBEB border, not-allowed
- Text: Plus Jakarta 14/21; placeholder #8A8A8A

## When to use
- **Counted input** — a settings option/answer/label field that needs a character cap — the counter appears only on focus
- **Counted textarea** — the same, but multi-line (descriptions, long answers)
- **Input** — a general single-line field with no counter (names, search)

Never show a permanent counter on every field — it reads as heavy and steals width. The counter belongs inside the field, revealed on focus. Label the field with a noun phrase; use placeholder for an example, never as the label.

---

# Counted textarea
> Generated from counted-textarea.contract.json — do not edit by hand.

The multi-line settings field — a CountedInput that wraps to several lines, grows from minRows to maxRows then scrolls, with the same focus-only character counter.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | string | `""` | Controlled text value |
| `placeholder` | string | `—` | Hint shown when empty |
| `maxlength` | number | `150` | Character cap (settings-lab default 150); drives the focus-only counter (shows current/max). Pass `none` to drop the cap and hide the counter |
| `minrows` | number | `2` | Rows shown at rest — the field never shrinks below this |
| `maxrows` | number | `5` | Rows the field grows to before it scrolls internally instead of growing |
| `status` | default | error | `default` | Error recolours the border + ring and sets aria-invalid |
| `disabled` | boolean | `false` | Non-interactive; grey fill |
| `readonly` | boolean | `false` | Selectable but not editable; secondary fill |
| `borderless` | boolean | `false` | Drop the field's own border/ring/fill — for composing inside a parent that carries the one border (OptionRow, SETTINGS-34) |
| `input` | CustomEvent<{value}> | `—` | Composed event on each keystroke; read e.detail.value |
| `change` | CustomEvent<{value}> | `—` | Composed event on commit (blur) |

## Visual standard (measured)
- Box: radius 8 · padding 6 12 · resize off
- Autogrow: minrows (default 2) → maxrows (default 5); beyond maxrows scrolls internally, never grows unbounded
- Rest border: 1px #E3E3E3
- Focus: #6A1EBB border + 2px #D3B4FF@30% ring (on the persistent wrapper)
- Counter: 12/18 #8A8A8A, overlay bottom-right; opacity 0 at rest → 1 on focus
- Reveal: focus-only (:host([_focused][_counted])) — hidden at rest; space always reserved (no shift)
- Error: #F5222D border + error-tinted ring; sets aria-invalid
- Disabled: #F1F1F1 fill, #EBEBEB border, not-allowed
- Text: Plus Jakarta 14/21; placeholder #8A8A8A

## When to use
- **Counted textarea** — a multi-line settings field — a description, a long answer — that needs a character cap; the counter appears only on focus
- **Counted input** — the single-line sibling — option/answer/label text with a cap
- **Input** — a general single-line field with no counter (names, search)

Never show a permanent counter on every field — it reads as heavy. The counter belongs inside the field, revealed on focus. Cap the growth: a textarea that grows without bound pushes the rest of the panel down; grow to maxRows then scroll.

---

# CSAT
> Generated from csat.contract.json — do not edit by hand.

The shared thumbs up/down satisfaction prompt — the one CSAT control, not a bespoke rating.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `prompt` | string | `—` | Optional question shown before the thumbs |
| `source` | string | `—` | Stable placement token for analytics — passed in the rate and feedback events |
| `value` | up | down | `—` | The current rating (also reflects a click); syncs the selected thumb's aria-pressed |
| `thanks` | string | `—` | Opt-in thank-you line — shown (role=status) after rating (on down, after feedback submit); fades in on a persistent node |
| `feedback-prompt` | string | `What could be better?` | Heading shown at the top of the thumbs-down feedback popover |
| `feedback-placeholder` | string | `Tell us more (optional)` | Placeholder for the free-text field in the feedback popover |

## Visual standard (measured)
- Layout: borderless single row (21px high): 14px #4A4A4A prompt + 16px thumb icons, no chrome — the only layout (legacy `inline` is a no-op)
- Rest: icon #8A8A8A
- Hover: scale 1.12 · icon primary #6A1EBB (motion tokens)
- Selected: icon primary #6A1EBB · aria-pressed synced
- Thumbs-up: rates instantly — reflects value='up', emits `rate`
- Thumbs-down: registers value='down' + emits `rate`, then opens a feedback popover (shared <aha-popover>, role=dialog · Esc/outside-click close) anchored to the down thumb — a prompt, an <aha-counted-textarea> and a primary Send button; dismissing keeps the down rating
- Thank-you: opt-in `thanks` — check + success #16C49A line fades in (motion tokens): instantly on up, after feedback submit on down
- Events: `rate` → { rating: 'up' | 'down', source } · `feedback` → { rating: 'down', source, feedback }

## When to use
- **CSAT** — a lightweight satisfaction check on a feature or a piece of content
- **Result** — a full-block outcome after an operation
- **a Form** — you need structured, multi-field feedback with validation

Use the shared control — never build a bespoke rating. Always pass a stable `source` per placement so segmentation stays typo-proof. Keep it to the binary thumbs; free-text is asked for ONLY after a thumbs-down (the built-in feedback popover), and never block rendering on tracking.

---

# DatePicker
> Generated from datepicker.contract.json — do not edit by hand.

Pick a date or a date range from a calendar — schedules, deadlines, filters — rendered through the shared DatePicker.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | Dayjs | [Dayjs, Dayjs] | `—` | Selected date (or range for RangePicker) |
| `picker` | date | week | month | quarter | year | `date` | Granularity |
| `size` | small | middle | large | `middle` | Field height — 24 / 32 / 40 |
| `status` | error | warning | `—` | Validation state on the field |
| `showTime` | boolean | object | `false` | Add a time picker to the panel |
| `allowClear` | boolean | `true` | Show the clear affordance when a value is set |
| `presets` | { label, value }[] | `—` | Quick ranges (RangePicker) — reporting windows |
| `disabled` | boolean | `false` | Disable the field |
| `format` | string | `YYYY-MM-DD` | Display/parse format |
| `disabledDate` | (d) => boolean | `—` | Constrain selectable dates |
| `onChange` | (date) => void | `—` | Fires with the new date(s) |

## Visual standard (measured)
- Field: height 32 · radius 8 · 1px #E3E3E3 border
- Selected cell: brand #6A1EBB
- In-range: brand-tint #F9F5FF
- Hover cell: #F7F7F7 (gray-20)
- Placeholder: #8A8A8A

## When to use
- **DatePicker** — a single date — a deadline, a start date
- **RangePicker** — a start and end together — a reporting window, a schedule
- **Input** — a free-form date the user already knows precisely — but prefer the picker for validity

Pre-fill a sensible default (today, or the current period) rather than empty. Constrain impossible dates with disabledDate instead of validating after the fact. Dates use Day.js.

---

# Descriptions
> Generated from descriptions.contract.json — do not edit by hand.

A read-only label/value grid summarising one entity's fields — N columns, bordered or plain, horizontal or vertical, with per-item span.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `items` | JSON [{label,value,span?}] | `—` | The items, as a JSON tree. Alternative to light-DOM children |
| `desc-title` | string | `—` | Renders an optional header row |
| `column` | number | `2 (vertical 3)` | Label/value pairs per row |
| `bordered` | boolean attr | `false` | Ruled table with cell borders + tinted label column |
| `size` | 'default' | 'small' | 'large' | `default` | Cell padding scale |
| `layout` | 'horizontal' | 'vertical' | `horizontal` | Label : value side-by-side, or label stacked over value |
| `label` | string (on children) | `—` | Each child's label attribute names its item |
| `span` | number (items/children) | `1` | How many columns the item spans |
| `(children)` | slot | `—` | Each child's content is the item value (when not using items) |

## Visual standard (measured)
- Container: border 1px #E3E3E3 · radius 8px · bg #FFFFFF
- Title: padding 12/16 · 15/22 SemiBold #1A1A1A · divider #F1F1F1
- Label cell: SemiBold #4A4A4A · bordered → bg #F7F7F7 + tinted column
- Value cell: #1A1A1A · 14/22 · rows/cells ruled #F1F1F1
- Column: N label/value pairs flow per row; an item spans several (default 2 · vertical 3)
- Bordered: ruled table — cell borders #F1F1F1 + tinted label column; plain = row dividers only
- Size: padding scale — default 12/16 · small 8/12 · large 16/20
- Layout: horizontal (label : value) · vertical (label over value)

## When to use
- **Descriptions** — read-only label/value facts about one entity (a plan, an account)
- **Form** — the same fields need to be edited
- **Table** — the same fields across many records

Labels are sentence case nouns — 'Billing cycle', not 'BILLING CYCLE'. Keep to the facts that matter; a long grid reads like a form nobody can fill in.

---

# Divider
> Generated from divider.contract.json — do not edit by hand.

A thin rule that separates blocks of content — full-width, with a label, or a vertical hairline.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `orientation` | horizontal | vertical | `horizontal` | Horizontal rule or an inline vertical hairline |
| `align` | left | center | right | `center` | Where the slotted label sits along a horizontal rule |
| `dashed` | boolean | `false` | Render the rule as a dashed line |
| `plain` | boolean | `false` | Drop the label from heading emphasis (bold, text-default) to a quiet regular note (400, text-secondary) |

## Visual standard (measured)
- Rule: 1px solid, border #E3E3E3
- Label (heading): Plus Jakarta SemiBold 600, 13/22, text-default #1A1A1A
- Label (plain): `plain` → Regular 400, text-secondary #4A4A4A
- Align: label center (default) · left · right; the short leg becomes a 24px stub
- Gap: 12 between rule and label
- Vertical: 1px left hairline, 1em tall, 0 8 margin
- Dashed: `dashed` swaps the rule to a dashed border

## When to use
- **Divider** — separating stacked sections, list groups, or toolbar items
- **Space** — you only need consistent gaps, not a visible line
- **whitespace** — the separation reads clearly without any rule — prefer less chrome

A divider is quiet structure — keep it 1px and on the border token. Use a label only when the section genuinely needs naming; reach for whitespace before adding a rule.

---

# Drawer
> Generated from drawer.contract.json — do not edit by hand.

A side panel that slides in for a longer edit or detail view — rendered through the shared Drawer.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `open` | boolean | `false` | Controls visibility |
| `title` | string | `—` | Panel heading |
| `placement` | top | right | bottom | left | `right` | Which edge it slides from |
| `width` | number | string | `378` | Panel width for left/right (large ≈ 736) |
| `height` | number | string | `378` | Panel height for top/bottom (large ≈ 480) |
| `size` | default | large | `default` | antd preset size (378/736 l·r); the DS default is 378 |
| `footer` | ReactNode | `—` | Pinned actions row (keep the primary action here) |
| `mask` | boolean | `true` | Dim + scroll-lock the page behind the panel |
| `closable` | boolean | `true` | Show the header close (X) |
| `onClose` | () => void | `—` | Dismiss handler |

## Visual standard (measured)
- Panel: white surface, slides from the right (or any edge)
- Title: ink #1A1A1A · SemiBold
- Body: 24 padding · ink text
- Mask: ink overlay rgba(26,26,46,.7)
- Motion: antd's built-in slide enter/leave (kept)

## When to use
- **Drawer** — a longer edit form or detail panel where keeping page context matters
- **Modal** — a short, blocking decision the user must complete first
- **a page** — the task is large enough to deserve its own route

Give it a clear title and keep the primary action pinned in a footer. Right placement for settings/edit; bottom for mobile-style sheets.

---

# Dropdown
> Generated from dropdown.contract.json — do not edit by hand.

A trigger button that reveals a floating overlay of actions — with leading icons, dividers, disabled rows and a red danger action.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, admin, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | string | `Menu` | The trigger button text |
| `items` | JSON list | `[]` | Rows. Leaf {key,label,icon?,disabled?,danger?}; submenu-parent {key,label,icon?,children:[…]} → adjacent flyout; rule {type:'divider'}; section {type:'group',label,children:[]} |
| `placement` | 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight' | `bottomLeft` | Where the overlay opens relative to the trigger |
| `trigger` | 'click' | 'hover' | `click` | How the overlay is revealed |
| `select` | CustomEvent | `—` | Composed event on choosing an item, detail { key } |

## Visual standard (measured)
- Trigger: height 40 · radius 8 · white bg, 1px border #E3E3E3, SemiBold 600
- Trigger hover: border-hover #D3B4FF (motion-fast + ease-out)
- Caret: <aha-icon name="system-caret-down" size=16>, rotates 180° when open (motion-mid)
- Panel: elevated white, radius 8, 6 padding, min-width 200, soft shadow
- Placement: bottomLeft · bottomRight · topLeft · topRight — anchors the persistent panel
- Trigger mode: click (default) or hover
- Open motion: opacity + translateY on a persistent node (motion-mid + ease-out)
- Item: height 36 · radius 8 · 10px gap to a 16px leading icon · hover bg-hover #F7F7F7
- Danger item: color-error #F5222D text, error-tinted hover
- Divider: 1px split #F1F1F1 rule, role=separator
- Group title: 11px uppercase, text-tertiary #8A8A8A, non-interactive section header
- Disabled item: text-disabled #B5B5B5, not-allowed
- Submenu parent: item with children[]; aria-haspopup=menu, trailing <aha-icon name="system-caret-right" size=14>; brand-tinted (bg-accent #F9F5FF, color-primary #6A1EBB) while its flyout is open
- Flyout: second elevated panel adjacent to the parent (left:100%), opens on hover / ArrowRight / Enter; fades+scales on a persistent node (motion-mid + ease-out)
- Edge-collision flip: flyout measures on open; if it overflows the viewport it flips to open on the left (right:100%)
- Flyout keyboard: Up/Down within the flyout, ArrowLeft / Escape closes it back to the parent (focus returns to the parent row)

## When to use
- **Dropdown** — a set of actions or options should sit behind a trigger to save space
- **Menu** — the same list belongs inline, always visible — not behind a trigger
- **Select** — the control picks a single value for a form field, not fires an action

A dropdown holds actions or navigation, not form values — use Select for a field. Keep the item list short; the panel closes on outside-click and Escape. Give each action a leading icon, rule off a destructive action with a divider and mark it danger. Reach for a submenu-parent (children[]) only when a group of related actions would otherwise crowd the top level — one level of flyout is plenty; deeper nesting is a menu smell. The overlay reuses the shared Menu item vocabulary rather than reinventing the row styling.

---

# Empty
> Generated from empty.contract.json — do not edit by hand.

The placeholder for a surface that has no data yet.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `image` | 'default' | 'simple' | `default` | Built-in illustration: 64×41 line art, or a smaller 48×30 low-key graphic for tight surfaces |
| `description` | string | `—` | The caption; falls back to the default slot when absent, and renders nothing when set to "" |
| `(default slot)` | slot | `No data` | The caption (alternative to the `description` attribute) |
| `image` | slot | `—` | A custom picture (e.g. an <aha-icon>) that supersedes the built-in illustration |
| `action` | slot | `—` | An optional call to action (a button or link) |

## Visual standard (measured)
- Layout: centred column · gap 10 · padding 32/16 (simple: gap 8 · 20/16)
- Illustration: default 64×41 · simple 48×30 line art · #B5B5B5 (text-disabled)
- Caption: 14/22 (simple 13/20) #8A8A8A (text-tertiary) · hidden when description=""
- Custom image: slot="image" · supersedes the built-in art
- Action: slot="action" · hidden when empty

## When to use
- **Empty** — a list, table, or panel has nothing to show yet
- **Result** — reporting the outcome of an action (success, 404)
- **Skeleton** — content is loading, not absent

The caption says what's missing and, ideally, the next step — 'No responses yet — share the join link'. Keep one action; an empty state is a nudge, not a menu.

---

# Flex
> Generated from flex.contract.json — do not edit by hand.

A flexbox container with the DS spacing scale baked into `gap` — direction, align, justify, wrap.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `gap` | small | middle | large | number | `0` | Space between items — named DS step (8/16/24) or a raw pixel number |
| `vertical` | boolean | `false` | Stack in a column — AntD's boolean shorthand for direction=column (direction wins if both set) |
| `direction` | row | row-reverse | column | column-reverse | `row` | Main-axis direction (flex-direction) — the full matrix; overrides `vertical` |
| `align` | start | center | end | baseline | stretch | `stretch` | Cross-axis alignment (align-items) |
| `justify` | start | center | end | space-between | space-around | space-evenly | `start` | Main-axis distribution (justify-content) |
| `wrap` | boolean | `false` | Allow items to wrap onto multiple lines |

## Visual standard (measured)
- Display: flex
- Gap scale: small 8 · middle 16 · large 24 (or a raw px number)
- Direction: row · row-reverse · column · column-reverse (or `vertical` for column)
- Align: start · center · end · baseline · stretch (align-items)
- Justify: start · center · end · space-between/around/evenly

## When to use
- **Flex** — arranging a block of items in one dimension with consistent DS spacing
- **Grid** — you need two-dimensional rows-and-columns, not a single flow
- **Space** — you only need even gaps between a small inline set of items

Keep gaps on the named scale (small/middle/large) so spacing stays consistent across surfaces — reach for a raw pixel number only for a genuine one-off. Flex is layout, never decoration: it paints no background.

---

# Form
> Generated from form.contract.json — do not edit by hand.

A labelled, validated set of fields — sign-in, settings, create dialogs — rendered through the shared Form.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `layout` | vertical | horizontal | inline | `vertical` | Label placement |
| `size` | small | default | large | `default` | Control height for every field (24 / 32 / 40) |
| `fields` | FormItem[] | `[]` | name / label / rules / control per row |
| `requiredMark` | boolean | 'optional' | `true` | Show the required asterisk (or mark optional fields instead) |
| `validateStatus` | error | warning | success | validating | `—` | Per-Form.Item validation state — coloured border + help text |
| `initialValues` | object | `—` | Seed values |
| `onFinish` | (values) => void | `—` | Fires on a valid submit |
| `disabled` | boolean | `false` | Disables the whole form |

## Visual standard (measured)
- Controls: height 32 · radius 8 · 1px #E3E3E3 border
- Label: #4A4A4A, above the field (vertical layout)
- Submit: primary Button #6A1EBB
- Error: #F5222D border + message below the field
- Item gap: 20px between items
- Size: control height 24 (small) · 32 (default) · 40 (large)
- Validation: error / warning / success — coloured border + help text

## When to use
- **Form** — two or more related fields submitted together, with validation
- **a single Input/Select** — one value that applies on its own — no submit step
- **Settings pattern** — a settings surface — see the Settings pattern for grouping and spacing

Label every field with a noun phrase above the control (vertical layout reads best on narrow surfaces). Validate on blur and on submit; show the error below the field with how to fix it. One primary submit per form.

---

# Grid
> Generated from grid.contract.json — do not edit by hand.

A CSS-grid container — a fixed column count or a responsive auto-fit, with the DS gutter scale, track justify/align, and a responsive collapse.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `columns` | number | `—` | Fixed number of equal-width columns (minmax(0, 1fr) tracks) |
| `min` | number | `—` | Minimum track width for a responsive auto-fit grid; wins over columns |
| `gap` | none | small | middle | large | number | "h v" | `0` | Gutter between cells — a named DS step (8/16/24) / raw px, or two values "h v" for a separate horizontal/vertical gutter |
| `gutter` | same as gap | `—` | Alias of gap (AntD Row vocabulary); gap wins if both are set |
| `justify` | start | center | end | space-between | space-around | space-evenly | `—` | Horizontal distribution of the tracks (justify-content) |
| `align` | start | center | end | stretch | `—` | Vertical alignment of the cells within their row (align-items) |
| `responsive` | boolean | `false` | With columns=N, lets tracks collapse to fewer as the container narrows — no media queries |

## Visual standard (measured)
- Display: grid
- Columns: columns=N → repeat(N, minmax(0, 1fr))
- Auto-fit: min=W → repeat(auto-fit, minmax(W, 1fr))
- Responsive collapse: columns=N + responsive → tracks reflow to fewer as the container narrows
- Gutter scale: none 0 · small 8 · middle 16 · large 24 (or a raw px number)
- Gutter [h,v]: gap="h v" → separate column-gap / row-gap
- Justify: start · center · end · space-between · space-around · space-evenly (justify-content)
- Align: start · center · end · stretch (align-items)

## When to use
- **Grid** — a two-dimensional layout — a card gallery, a stat row, an image grid
- **Flex** — a one-dimensional flow where items size to content
- **min (auto-fit)** — the column count should follow the container width — prefer it over media queries

Reach for `min` (auto-fit) or `responsive` before hand-writing breakpoints — the grid reflows itself. Use `gap="h v"` when rows need tighter spacing than columns. Keep gutters on the named scale so spacing matches the rest of the surface. Grid is layout, never decoration: it paints no background.

---

# Icon
> Generated from icon.contract.json — do not edit by hand.

One glyph set, called by name — 259 icons imported from Figma, rendered by the shared <aha-icon>, never inline SVG.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `name` | string | `—` | Registry glyph name, e.g. system-bell, slidetype-poll, filetype-course |
| `size` | 12 | 16 | 24 | 32 | `24` | On-grid pixel size; the master stroke scales with it |
| `label` | string | `—` | Accessible name (role=img). Omit for a decorative icon |
| `decorative` | boolean | `false` | aria-hidden — for an icon that only repeats adjacent text |

## Visual standard (measured)
- Set: 259 glyphs · system · slide-type · file-type
- Source: Figma Design System V3 · node 50699-7126
- Sizes: 12 · 16 · 24 · 32 (only)
- Colour: currentColor / semantic token (254 recolorable)
- State: outline = neutral · -filled asset = active

## When to use
- **system-* glyph** — the default for all product-UI chrome, buttons, menus, list rows, status
- **-filled asset** — a selected/active/on state — e.g. system-bookmark-simple-filled; a solid glyph survives greyscale
- **slide-type / file-type** — labelling a slide kind or a file kind; file-type glyphs keep their baked brand colour

Call by name from the registry — never inline an <svg>, never pull a second icon set (Lucide/Heroicons/FontAwesome/@ant-design/icons). Off-grid sizes (no 20px) aren't supported. Icon-only buttons need an aria-label (and a tooltip when the meaning isn't obvious). If a glyph is genuinely missing, add the SVG under icons/svg and re-run build-icons.mjs.

---

# Illustration
> Generated from illustration.contract.json — do not edit by hand.

One spot-art set, called by name — 20 multi-colour illustrations imported from Figma, rendered by the shared <aha-illustration>, never inline SVG.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: dashboard, editor, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `name` | string | `—` | Registry illustration name, e.g. empty-team, onboarding-business, plan-pro |
| `size` | number (px) | `art intrinsic` | Longer edge in px; the shorter edge scales to preserve aspect. Omit to use the art's own pixels |
| `label` | string | `—` | Accessible name (role=img). Omit for a decorative illustration |
| `decorative` | boolean | `false` | aria-hidden — for art that only repeats adjacent text |

## Visual standard (measured)
- Set: 20 illustrations · empty · onboarding · plan · offer · state · team · misc
- Source: Figma · Oldies library · file P764iQ6y4ZwW7W3f7FLZyW
- Colour: multi-colour — keeps its own palette (NOT currentColor)
- Size: one edge in px; the other follows the art's aspect ratio
- Fallback: unknown name → a dashed error box (var(--aha-color-error))

## When to use
- **empty-* illustration** — an empty state — no presentations, empty trash, no templates, no team yet
- **onboarding-* / plan-* illustration** — onboarding choice cards and plan-tier cards (edu / essential / pro / enterprise)
- **error-* / not-found illustration** — a full-surface error or 404 state, paired with a recovery action

Call by name from the registry — never inline an <svg>, never paste a raster PNG. An illustration is multi-colour art: it does NOT follow currentColor (that is <aha-icon>). Size it by a single edge; the aspect ratio is fixed by the source. If an illustration is genuinely missing, add the SVG under illustrations/svg and re-run build-illustrations.mjs. Raster-backed art (embedded bitmaps) is intentionally excluded — the library stays pure-SVG.

---

# Image
> Generated from image.contract.json — do not edit by hand.

A framed image with rounded corners, a hover preview mask, a loading placeholder and a broken-src fallback.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `src` | string | `—` | Image source URL (empty/broken → the fallback state) |
| `alt` | string | `—` | Alternative text for the image |
| `width` | number | `—` | Frame width in px |
| `height` | number | `—` | Frame height in px |
| `preview` | 'true' | 'false' | `true` | Click-to-zoom modal. `false` → a plain framed picture (no mask, no click, no dialog) |
| `radius` | 'xs'|'sm'|'default'|'lg'|'xl'|'pill' | `default` | Frame corner from the DS radius scale |
| `placeholder` | boolean | `off` | Show a shimmer loading skeleton until the image loads |
| `fallback` | string | `Image unavailable` | Label shown in the broken-src error state (its presence never forces the state — a broken/empty src does) |
| `(default slot)` | slot | `Preview` | The label shown in the hover mask |

## Visual standard (measured)
- Frame: radius 8px (radius attr xs 4·sm 6·default 8·lg 12·xl 16·pill) · bg #F7F7F7 · overflow hidden · role=button, keyboard-focusable
- Image: object-fit cover · scale 1.06 on hover, motion-slow ease-out
- Mask: overlay rgba(26,26,46,.7) · #FFFFFF 13/SemiBold · fade motion-mid
- Preview: click/Enter opens a modal dialog (role=dialog, aria-modal); Esc / close button / backdrop dismiss; focus moves in and is restored on close. preview="false" → plain picture, no mask/click/dialog
- Placeholder: shimmer skeleton (icon-muted on bg-secondary) shown while loading; fades on the image load, persistent node
- Fallback: broken/empty src → system-image-square glyph + label, text-tertiary on bg-secondary, role=img
- Sizing: width/height attributes drive the frame

## When to use
- **Image** — a framed picture that invites a click to preview (a deck cover, an upload)
- **Avatar** — a small round identity image
- **Card** — a picture is one part of a larger grouped surface

Always set alt for accessibility. Keep the mask label short — 'Preview', 'View' — it is an invitation, not a caption.

---

# Image action button
> Generated from image-action-button.contract.json — do not edit by hand.

The compact per-option image control — an image icon button with empty / loading / thumbnail states and a Change/Edit/Delete menu; it emits intents, the host owns the modals.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `state` | empty | loading | filled | `empty` | The control's state — drives which affordance shows |
| `src` | string | `—` | The thumbnail image URL when filled |
| `label` | string | `Add image` | The empty-state tooltip / aria-label |
| `disabled` | boolean | `false` | Non-interactive; grey fill |
| `add / change / edit / delete` | CustomEvent | `—` | Composed intents — the host runs the corresponding modal/flow |

## Visual standard (measured)
- Trigger: 36×36 icon button, radius 8, 1px #E3E3E3 border
- Empty: system-image-square glyph + "Add image" tooltip; click emits `add`
- Loading: spinner at the same 36×36 size — no layout jump; role=status, aria-label "Loading", aria-busy
- Filled: thumbnail + hover pencil overlay; click opens Change/Edit/Delete
- Menu: Change / Edit / Delete (Delete in #F5222D); each emits an intent
- Intents: add · change · edit · delete — host owns the modals, this owns none
- Disabled: #F1F1F1 fill, #EBEBEB border, not-allowed

## When to use
- **Image action button** — a per-OPTION image cell in an option row
- **Image dropzone** — a standalone MAIN image field where the image IS the field (SETTINGS-44)

Per-option image support is all-or-nothing: present with its FULL state set, or the control is omitted entirely — never a disabled/greyed stub when unsupported (SETTINGS-22). It is a compact icon button, not a text "Image" button (SETTINGS-32). It emits intents; the host owns the upload/crop modals — never a self-built uploader.

---

# Image dropzone
> Generated from image-dropzone.contract.json — do not edit by hand.

The full-width settings image field where the image IS the field — a dashed upload card with empty / loading / filled states and a Change/Edit/Delete overlay; it emits intents, the host owns the modals (SETTINGS-44).

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `state` | empty | loading | filled | `empty` | The field's state — drives which affordance shows, at a constant box height |
| `src` | string | `—` | The image URL when filled |
| `fit` | contain | cover | `contain` | How the filled image sits in the box — contain (whole image, letterboxed) or cover (fill, cropped) |
| `label` | string | `Add image` | The empty-state prompt text |
| `disabled` | boolean | `false` | Non-interactive; grey fill |
| `add / change / edit / delete` | CustomEvent | `—` | Composed intents — the host runs the corresponding modal/flow |

## Visual standard (measured)
- Zone: full-width, min-height 132, radius 8
- Empty: 1px dashed #E3E3E3 card, centred system-image-square + label; click emits `add`
- Loading: spinner at the SAME 132 min-height — the panel does not jump
- Filled: image fitted (fit=contain default | cover) + hover Change/Edit/Delete overlay; solid border
- Intents: add · change · edit · delete — host owns the modals, this owns none
- Hover: #D3B4FF border + #6A1EBB label/icon
- Disabled: #F1F1F1 fill, #EBEBEB border, not-allowed

## When to use
- **Image dropzone** — a standalone MAIN image field — a background/hero image, an interactive-image base, a picture reveal (SETTINGS-44)
- **Image action button** — a per-OPTION image cell in an option row

The MAIN image field is this dropzone — NOT a hand-rolled "Add image"/"Replace image" button plus a separate "Remove image" danger button (SETTINGS-44). The loading state matches the empty card's height so the panel doesn't jump. It emits intents; the host owns the upload/crop modals.

---

# Info box
> Generated from info-box.contract.json — do not edit by hand.

A settings-only tinted callout in four tones (information / success / warning / error), optionally dismissible — a bespoke component, not Ant Alert.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `variant` | information | success | warning | error | `information` | The tone — sets fill, border and leading glyph |
| `icon` | string | `—` | Override the tone's default glyph with a named DS icon |
| `dismissible` | boolean | `false` | Show the ✕ dismiss control |
| `dismiss` | CustomEvent | `—` | Composed event after the box fades out |

## Visual standard (measured)
- Box: padding 12 · radius 8 · 1px tinted border + tinted fill per tone
- Information: #F4F8FF fill · #BFD2FF border · #9BB3E9 glyph (system-info)
- Success: #D8FAEF fill · #16C49A border/glyph (system-check-circle)
- Warning: #FFF5F0 fill · #FF7747 border/glyph (system-warning-circle)
- Error: #FFE3E9 fill · #FF4081 border/glyph — Radical Pink, NOT red (system-x-circle)
- Dismiss: optional ✕ (system-x); fades out on a persistent node, emits `dismiss`
- Text: Plus Jakarta 14/21 #1A1A1A

## When to use
- **Info box** — a settings-panel callout explaining a state or a consequence in place
- **Help text** — a one-line must-see consequence attached to a single setting (≤90 chars) — not a box
- **Tooltip** — secondary what/why/how elaboration on a ? glyph

Bespoke, NOT Ant Alert — Alert renders outside the DS theme. Keep callouts rare: most explanation is a ? tooltip or nothing (SETTINGS-28). Use error/warning for a consequence, not decoration.

---

# Input
> Generated from input.contract.json — do not edit by hand.

A single-line text field — names, search, numbers, short free text — with sizes, error/warning status, affixes, a clear ✕ and a password reveal.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | string | `""` | Controlled text value |
| `placeholder` | string | `—` | Hint shown when empty |
| `type` | text | number | email | password | search | `text` | Native input type; password adds a reveal (eye) toggle |
| `size` | small | default | large | `default` | Control height — small 24 · default 32 · large 40 (DS V3 field scale); radius stays 8 (sm/md/lg accepted as aliases) |
| `status` | default | error | warning | `default` | Border + ring recolour; error also sets aria-invalid (legacy `invalid` boolean = status error) |
| `disabled` | boolean | `false` | Non-interactive; grey fill |
| `readonly` | boolean | `false` | Selectable but not editable; secondary fill |
| `prefix-icon / prefix` | string | `—` | Leading affix — an icon by name (from the DS library) or plain text |
| `suffix-icon / suffix` | string | `—` | Trailing affix — an icon by name or plain text (e.g. a domain) |
| `clearable` | boolean | `false` | Shows a ✕ when non-empty that wipes the value and emits `clear` |
| `input` | CustomEvent<{value}> | `—` | Composed event on each keystroke; read e.detail.value |
| `clear` | CustomEvent<{value}> | `—` | Composed event when the ✕ clears the field |

## Visual standard (measured)
- Box: height 32 (default) · radius 8 · padding 0 12
- Sizes: small 24 · default 32 · large 40 — all radius 8
- Rest border: 1px #E3E3E3
- Hover border: #D3B4FF (purple-30)
- Focus: #6A1EBB border + 2px #D3B4FF@30% ring (on the persistent wrapper)
- Error: #F5222D border + error-tinted ring; sets aria-invalid
- Warning: #FF7747 border + warning-tinted ring
- Disabled: #F1F1F1 fill, #EBEBEB border, not-allowed
- Readonly: #F7F7F7 fill, still selectable
- Affixes: prefix/suffix icon (by name) or text; clearable ✕; password reveal (eye)
- Text: Plus Jakarta 14/21; placeholder #8A8A8A

## When to use
- **Input** — short single-line free text — a name, a search term, a number
- **Textarea** — multi-line free text (descriptions, messages)
- **Select** — one value from a known set — don't make the user type it

Label the field with a noun phrase; use placeholder for an example, never as the label. Show the error status only after the user leaves the field, and pair it with a message that says how to fix it. Reach for a prefix icon to hint the field's job (a magnifier for search, an envelope for email), a suffix for a fixed unit or domain, clearable for search/filter fields, and password reveal for credentials.

---

# InputNumber
> Generated from input-number.contract.json — do not edit by hand.

Enter a number with steppers, min/max and precision — quantities, limits, durations — rendered through the shared InputNumber.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | number | `—` | Controlled numeric value |
| `size` | 'small' | 'middle' | 'large' | `'middle'` | Field height: small 24 · default 32 · large 40 |
| `status` | 'error' | 'warning' | `—` | Validation state — coloured border/glow |
| `min` | number | `-Infinity` | Lower bound |
| `max` | number | `Infinity` | Upper bound |
| `step` | number | `1` | Increment for steppers / arrow keys |
| `precision` | number | `—` | Fixed decimal places |
| `controls` | boolean | `true` | Show the up/down steppers |
| `disabled` | boolean | `false` | Non-interactive, greyed field |
| `readOnly` | boolean | `false` | Value visible but not editable |
| `prefix` | ReactNode | `—` | Inline leading affix (e.g. $) |
| `addonBefore` | ReactNode | `—` | Attached leading label |
| `addonAfter` | ReactNode | `—` | Attached trailing label (e.g. unit) |
| `onChange` | (value) => void | `—` | Fires with the new number |

## Visual standard (measured)
- Field: height 32 (md) · radius 8 · 1px #E3E3E3 border
- Sizes: small 24 · default 32 · large 40 · font 14
- Hover: #D3B4FF (purple-30) border
- Focus: brand #6A1EBB border
- Status: error #F5222D · warning border/glow
- Steppers: up/down on hover; keyboard ↑ ↓; toggle via controls
- Placeholder: #8A8A8A

## When to use
- **InputNumber** — a bounded numeric quantity — a limit, a count, a duration where steppers help
- **Slider** — an approximate value across a known range where the exact number matters less
- **Input type=number** — a free number with no bounds or steppers — but prefer InputNumber for validity

Set min/max to the real limits so the field constrains instead of validating after the fact. Pre-fill a sensible default rather than empty; use precision for currency/percentages.

---

# List
> Generated from list.contract.json — do not edit by hand.

A vertical list of uniform rows — avatar + title/description meta and trailing actions, with size, bordered and split options.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `items` | JSON array | `[]` | Structured rows: { title, description?, avatar? (icon name), actions?:[{ key, icon, danger?, label? }] } |
| `(children)` | slot | `—` | Free-form fallback — each light-DOM child renders as one plain row (used when `items` is empty) |
| `size` | 'small' | 'default' | 'large' | `default` | Row vertical padding scale (8 · 12 · 16) |
| `bordered` | boolean | `false` | Draw the outer container border |
| `split` | boolean | `true` | Row dividers; set split="false" to remove them |
| `list-title` | string | `—` | Renders an optional header row |
| `footer` | string | `—` | Renders an optional footer row |
| `loading` | boolean | `false` | Show shimmer skeleton rows in place of content |
| `action` | CustomEvent | `—` | Composed event on a trailing action click, detail { key, index } |

## Visual standard (measured)
- Container: radius 12px · bg #FFFFFF · border 1px #E3E3E3 only when `bordered`
- Header: padding 14/20 · 15/22 SemiBold #1A1A1A · divider #F1F1F1
- Row: padding 12/20 (small 8, large 16) · 14/22 #4A4A4A · avatar 36 + meta + trailing actions
- Avatar: 36px pill, bg-accent #F9F5FF fill, color-primary #6A1EBB glyph
- Meta: title 600 #1A1A1A + description 13/20 #8A8A8A, both ellipsised
- Action: 28px icon button, text-tertiary #8A8A8A; danger hover error-tinted
- Split: row dividers #F1F1F1, on by default; split=false drops them
- Row hover: interactive rows → bg #F7F7F7, motion-fast ease-out (persistent node)
- Footer: padding 12/20 · 13/20 #8A8A8A · top divider

## When to use
- **List** — many uniform rows of the same shape (recent items, participants, feeds)
- **Table** — multi-column records that need sorting or alignment
- **Card** — one titled group of mixed content

Keep rows to one shape — a title, optionally a description and a small trailing actions cluster. Reserve `bordered` for a standalone list; drop the border when the list sits inside a card. For dense multi-column data reach for the Table instead.

---

# Loader
> Generated from aha-loader.contract.json — do not edit by hand.

The full-surface branded loading screen shown while a new environment boots — workspace → editor, or editor → presenting.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, audience, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | string | `Loading` | Accessible name announced by the role=status live region (e.g. "Opening the editor…") |

## Visual standard (measured)
- Ground: white full-surface (--aha-white); fills its container, min-height 240px
- Tiles: five 120×120 rounded tiles (radius-xl 16), each centred, one prominent at a time
- Art: reused <aha-illustration> spot art at 96px — loader-award · loader-wand · loader-plane · loader-ballot · loader-chart
- Motion: soft-flow keyframe · 7.5s loop · 1.5s stagger across the five · easing --aha-ease-in-out
- Reduced motion: prefers-reduced-motion stills the loop and holds one branded tile
- Duration: override the loop length via the CSS custom property --aha-loader-duration (default 7.5s)

## When to use
- **Loader** — a full-screen environment transition — the user lands in a new surface (workspace → editor, editor → presenting) and waits for it to boot
- **Spin** — an inline, indeterminate wait inside an existing surface — a button, a panel, a section loading in place
- **Skeleton** — the layout of the content being loaded is known — show its shape rather than a spinner

One Loader per transition, filling the whole surface. It is decorative brand motion, not a progress bar — don't pair it with a percentage. Keep the `label` a short present-tense phrase naming the destination ("Opening the editor…").

---

# Menu
> Generated from menu.contract.json — do not edit by hand.

A list of selectable options — a side nav, a settings list, an overflow menu — with icons, groups, submenus and danger actions.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, admin.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `items` | JSON tree | `[]` | Rows. Leaf {key,label,icon?,disabled?,danger?}; submenu {key,label,icon?,children:[]}; section {type:'group',label,children:[]}; rule {type:'divider'} |
| `value` | string | `—` | The selected item's key (brand-tinted row) |
| `mode` | 'vertical' | 'inline' | 'horizontal' | `vertical` | Layout: compact list · indented sidebar tree · top nav bar |
| `select` | CustomEvent | `—` | Composed event on selection, detail { key } |

## Visual standard (measured)
- Container: white surface, 1px border #E3E3E3, radius 8, 6 padding, min-width 200
- Row: height 36 · radius 8 · SemiBold 600 · 10px gap to a 16px leading icon
- Group title: 11px uppercase, text-tertiary #8A8A8A, non-interactive section header
- Divider: 1px split #F1F1F1 rule, role=separator
- Submenu: caret rotates 90° + children expand inline (motion-mid), aria-expanded synced
- Hover: bg-hover #F7F7F7 (motion-fast + ease-out, persistent node)
- Selected: bg-accent #F9F5FF fill + color-primary #6A1EBB text
- Danger: color-error #F5222D text, error-tinted hover
- Disabled: text-disabled #B5B5B5, not-allowed

## When to use
- **Menu** — a list of destinations or options — a side nav, a settings list, an overflow list
- **Tabs** — switching horizontal views within one screen
- **Dropdown** — the same list should sit inside a triggered overlay, not inline

One selection at a time — reserve the brand tint for the active row. Keep labels short and in sentence case. Group related rows under a title; rule off a destructive action with a divider and mark it danger. For a triggered overlay, put a Menu inside a Dropdown rather than reinventing the list.

---

# Modal
> Generated from modal.contract.json — do not edit by hand.

A focused, blocking dialog in two types — a Confirmation (yes/no decision) and an Action (a task surface) — rendered through the shared Modal.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `open` | boolean | `false` | Controls visibility |
| `title` | string | `—` | Dialog heading |
| `onOk` | () => void | `—` | Confirm handler |
| `onCancel` | () => void | `—` | Dismiss handler |
| `confirmLoading` | boolean | `false` | Spinner on the confirm button |
| `width` | string | `modalWidth('simple')` | Dialog width — pass modalWidth(size): min(target px, calc(100vw − 32px)) so it's the px width on desktop and near-full-width on mobile. Targets: simple 504 / complexity 720 / rich 1280 |
| `styles` | object | `modalStyles('simple')` | Height cap + body scroll for the size — modalStyles(size) |
| `footer` | ReactNode | `—` | DS footer: Learn-more link (left, a --aha-text-link anchor — external-link glyph only when it leaves AhaSlides) + Cancel + Apply (right) |
| `centered` | boolean | `false` | Vertically centre the dialog in the viewport |
| `closable` | boolean | `true` | Show the top-right close (X) |
| `mask` | boolean | `true` | Render the dimming overlay behind the dialog |

## Visual standard (measured)
- Content: white surface · radius 8
- Title: ink #1A1A1A · 18 · SemiBold
- Footer: brand primary confirm + secondary cancel
- Overlay: always-on mask (ink rgba(26,26,46,.7)) · portals to body · locks page scroll · click mask / Esc / ✕ to close (destructive: mask not closable)
- Motion: antd's built-in zoom enter/leave (kept)
- Types: Confirmation (status icon + copy) · Action (task surface)
- Confirmation: contexts: default · confirm · warning · info · danger (status icon left of title)
- Action sizes: simple 504 · complexity 720 · rich 1280 (px width) × height 75/80/90vh · divider on/off
- Viewport cap: width={modalWidth(size)} = min(px, calc(100vw − 32px)) — px on desktop, near-full-width on mobile; styles={modalStyles(size)} caps height, body scrolls past it

## When to use
- **Modal** — the user must confirm or complete a focused task before continuing
- **Drawer** — a longer edit form or detail panel that keeps page context
- **Popconfirm** — a lightweight yes/no on a single control

Two types. A CONFIRMATION modal is a focused yes/no — one of five contexts (default · confirm · warning · info · danger) puts a status icon left of the title, with a short line of copy, a Learn-more link, and Cancel + Apply; it's always `simple` size. An ACTION modal is a task surface (form, picker, editor) at one of three sizes — simple / complexity / rich — with an optional header/footer divider. Both are controlled `<Modal>`s, never the static Modal.confirm(). Every modal opens as a real overlay: it portals to `<body>` with an always-on mask, locks page scroll while open, and closes on a mask click / Esc / the ✕ — EXCEPT a destructive confirmation, which overrides `mask={{ closable: false }}` so a stray backdrop click can't trigger the action (Esc + ✕ only). Name the primary button the action ('Delete team', not 'OK'). A modal must never grow bigger than the screen in either axis: set `width={modalWidth(size)}` (resolves to `min(<target px>, calc(100vw − 32px))` — the px width on desktop, near-full-width on mobile with a 16px gutter; antd centres the dialog by its width, so the cap lives on the prop, not inner styles) and `styles={modalStyles(size)}` (height `auto` up to the cap, then the body scrolls while title + footer stay pinned). Anything bigger than `rich` belongs in its own page.

---

# Mode field
> Generated from mode-field.contract.json — do not edit by hand.

A labelled field with an inline exclusive mode control whose body swaps in place — the settings field for a control that has two-or-more mutually-exclusive modes.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | string | `—` | The field label, left of the mode control |
| `options` | Array<{value,label,icon?}> | `[]` | The exclusive modes; set as a property or a JSON attribute (alias: modes) |
| `value` | string | `—` | The active option's value (alias: mode) |
| `variant` | 'radio' | 'segmented' | `'radio'` | radio = outline segmented (brand border, white fill); segmented = neutral grey |
| `labelVariant` | 'field' | 'section' | `'field'` | field = regular weight; section = semibold header for a grouped-list body (attr: label-variant) |
| `help` | string | `—` | When set, a "?" tooltip after the label (icon system-question-mark) |
| `disabled` | boolean | `false` | Non-interactive; grey buttons |
| `change` | CustomEvent<{value}> | `—` | Composed event on change; read e.detail.value (e.detail.mode is a back-compat alias) |

## Visual standard (measured)
- Head: label LEFT / mode control RIGHT on one row; gap 12, wraps (row-gap 8) on narrow; 32 min-height
- Radio variant (default): OUTLINE segmented, 32 high, semibold 13; selected = #6A1EBB border + #6A1EBB text on WHITE fill (no tint pill)
- Segmented variant: neutral grey control — #F7F7F7 track, secondary-grey labels, active = white raised on default-text
- Label variant: field = regular 400; section = semibold 600 (a header for a grouped-list body)
- Help: optional "?" tooltip (system-question-mark) after the label when `help` is set; opens below (bottom-start) so it clears the panel top, and the bubble wraps within 240px
- Body: the active value's light-DOM child; others hidden in place (persistent, keep state); margin-top 12; de-emphasised help text — secondary #4A4A4A, 13/20, regular — so the label stays the field's primary line
- Hover: #D3B4FF border + #6A1EBB label on a radio-variant button
- Disabled: #F1F1F1 fill, #EBEBEB border, not-allowed

## When to use
- **Mode field** — a single setting whose control has 2+ exclusive modes and a mode-specific body (automatic vs manual, value-source A vs B)
- **Segmented** — an exclusive choice with NO mode-specific body to swap
- **Switch** — a plain immediate on/off with no modes

Only the active value's body is live — the others stay in the DOM (persistent) but hidden, so switching back restores their state. Default `variant='radio'` is an OUTLINE segmented control (brand border + brand text on white); a solid brand-filled pill is NOT the mode-field look — pick `variant='segmented'` for the neutral grey alternative. A mode toggle for an action may keep a verb-led label ('Show results manually') per SETTINGS-01.

---

# Notification
> Generated from notification.contract.json — do not edit by hand.

A richer, stacking message with a title + description — rendered through antd's notification API.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `message` | string | `—` | The bold title line |
| `description` | string | `—` | The supporting body text |
| `type` | success | info | warning | error | `—` | Status glyph + tone |
| `placement` | topLeft | topRight | bottomLeft | bottomRight | `topRight` | Which corner it stacks in |
| `duration` | number | `4.5` | Seconds on screen; 0 keeps it until dismissed |
| `btn` | ReactNode | `—` | One optional action (e.g. Undo) rendered in the card |

## Visual standard (measured)
- Card: white elevated · 384 wide · radius 8
- Title: ink #1A1A1A, SemiBold
- Type: success · info · warning · error (status glyph + tone)
- Placement: topRight default (4 corners)
- Duration: 4.5s default (0 = sticky)

## When to use
- **Notification** — a message with a title + detail, or one from a background event the user may act on later
- **Toast** — a one-line, past-tense confirmation with nothing to act on
- **Modal** — the user must respond before continuing

Give it a short title and a one-sentence description. Put at most one action in it; anything more belongs in a Modal or the page itself.

---

# Number with unit
> Generated from number-with-unit.contract.json — do not edit by hand.

A fixed-width digit input with the unit written IN FULL inline (rendered exactly as passed — "seconds", "points" — muted grey), a hover ▲/▼ stepper, a hard digit cap, clamp-on-change, and an error line — the settings field for a number that carries a unit.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | number | `—` | Controlled numeric value |
| `unit` | string | `—` | Inline suffix — written IN FULL, rendered exactly as passed (seconds, points, %) |
| `min` | number | `—` | Lower clamp bound |
| `max` | number | `—` | Upper clamp bound |
| `step` | number | `1` | Stepper / arrow-key increment |
| `maxdigits` | number | `4` | Hard digit cap — a keystroke past this many digits is ignored |
| `size` | small | default | large | `default` | Control height — small 24 · default 32 · large 40; radius stays 8 |
| `errormessage` | string | `—` | Presence draws the red border/ring and the message line below |
| `disabled` | boolean | `false` | Non-interactive; grey fill |
| `change` | CustomEvent<{value}> | `—` | Composed event on commit / stepper / arrow; read e.detail.value (clamped number) |

## Visual standard (measured)
- Box: height 32 (default) · radius 8 · digit input + inline unit + stepper
- Sizes: small 24 · default 32 · large 40 — all radius 8
- Unit: 14/21 #8A8A8A inline suffix; written IN FULL, rendered exactly as passed (seconds, points)
- Stepper: ▲/▼ tertiary (system-caret-up/down), revealed on hover/focus; disabled at min/max
- Digit cap: maxDigits (default 4) — a keystroke past the cap is dropped; numeric only
- Clamp: value clamps to min/max on change AND blur (immediate fallback)
- Error: #F5222D border + ring + a 12/18 #F5222D message line below; sets aria-invalid
- Focus: #6A1EBB border + 2px #D3B4FF@30% ring (persistent wrapper)
- Disabled: #F1F1F1 fill, #EBEBEB border, not-allowed

## When to use
- **Number with unit** — a number the host does NOT provide that carries a unit — a per-item timer, points-per-answer, a spin duration
- **Host time limit** — a standard whole-slide countdown — use the host-native enableTimeLimit, not this
- **Select** — the unit itself is changeable — a unit dropdown, not an inline suffix

A fixed unit is an inline suffix inside the field (SETTINGS-41) — a bordered box beside the input implies the unit is selectable. Write the unit IN FULL, rendered exactly as passed (`seconds`, `points`) — never truncated to a short label. Ship pre-filled with the recommended default and clamp immediately.

---

# Numbered item
> Generated from numbered-item.contract.json — do not edit by hand.

The numbered wrapper for a repeatable composite item — a grey card with a muted number chip, a "<Label> N" header, a hover delete, and a body slot (SETTINGS-43).

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `variant` | contained | plain | `contained` | contained = grey card (composite items); plain = no fill (single-field items only) |
| `n` | number | `—` | The item's number, shown in the chip and the header |
| `label` | string | `—` | Header noun — rendered as "<Label> N" |
| `deletable` | boolean | `false` | Show the hover delete; omit / false hides it entirely |
| `candelete` | boolean | `true` | false disables the delete (at the minimum item count) without hiding it |
| `collapsible` | boolean | `false` | Show a header caret that collapses the body in place (the QuestionList case) |
| `delete` | CustomEvent<{n}> | `—` | Composed event when the delete is pressed; read e.detail.n |
| `toggle` | CustomEvent<{collapsed}> | `—` | Composed event when the collapse caret is toggled; read e.detail.collapsed |

## Visual standard (measured)
- Contained: whole item in ONE #F7F7F7 grey card, radius 12, no border, padding 12
- Chip: 22px muted-grey pill (#F1F1F1 / #4A4A4A), semibold 12 — never radical-purple
- Header: chip + "<Label> N" title (semibold 14) + hover delete
- Delete: tertiary text trash (system-trash), hover-only, space reserved; #F5222D on hover
- deletable=false: the delete control is hidden entirely (non-removable item)
- candelete=false: the delete is shown but disabled (minimum-count case)
- Plain: no fill — for SINGLE-FIELD items only

## When to use
- **Numbered item** — a repeatable COMPOSITE item — more than one field, or a nested option list (a Question, a Card, a Round)
- **Option row** — a flat list of single values — not wrapped in a numbered card
- **Plain variant** — a single-field repeatable item that does not warrant a grey card

This is the ONE exception to "never a container around settings" (SETTINGS-15): it wraps a repeatable composite item, not plain settings. A composite item must keep the contained grey card — dropping the fill (plain, or a hand-rolled header over a fill-less body) is a build mistake (SETTINGS-43). The chip is muted grey, never a purple badge.

---

# Option row
> Generated from option-row.contract.json — do not edit by hand.

The repeatable option/answer row — one bordered row with an inside drag handle, a borderless counted textarea, an optional correct toggle (circle or square) and per-option image, and a floating hover delete; marking correct paints the whole card success-green (SETTINGS-20/34/23/39).

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | string | `""` | The option text |
| `placeholder` | string | `Option text` | Empty-field hint |
| `maxlength` | number | `—` | Character cap — turns the focus-only counter on |
| `orderable` | boolean | `false` | Show the drag handle (order carries meaning) — omit for an unordered set (SETTINGS-39) |
| `correctable` | boolean | `false` | Show the leading correct-answer checkbox (scored types) |
| `correct` | boolean | `false` | Whether this option is marked correct — paints the whole card success-green + fills the toggle |
| `checkboxShape` | circle | square (attr checkboxshape) | `circle` | The correct-answer toggle shape — a 20px outline circle, or a radius-6 square; both fill success-green with a white tick when correct |
| `singleLine` | boolean (attr singleline) | `false` | Single-line answer — blocks Enter (no newline) and trims the value on blur |
| `image` | boolean | `false` | Show the per-option image control |
| `imagestate` | empty | loading | filled | `empty` | The image control's state |
| `imagesrc` | string | `—` | The image thumbnail URL when filled |
| `candelete` | boolean | `true` | false disables the delete at the minimum item count |
| `input / correct-change / delete` | CustomEvent | `—` | Composed events; image-add/change/edit/delete are forwarded from the image control |

## Visual standard (measured)
- Row: the ONE border — 1px #E3E3E3, radius 8; field inside is borderless (no double border)
- Drag handle: system-drag, FIRST child inside the border; shown only when order matters (orderable)
- Text: borderless counted textarea — focus-only counter, wraps, scrolls beyond ~4 lines (SETTINGS-21)
- Correct toggle: optional (correctable), scored types only — 20px circle (default) or radius-6 square; fills --aha-color-success with a white tick when correct
- Correct card: when correct the WHOLE row paints success-green: --aha-color-success border over a faint --aha-bg-positive tint fill
- Single line: singleLine → Enter never inserts a newline; value is trimmed on blur (single-line answers)
- Image: optional per-option ImageActionButton (image) — the full state set (SETTINGS-22)
- Delete: floats -14px OUTSIDE the top-right, hover-only, disabled (not hidden) at min (candelete=false)
- Focus: focus-within → #6A1EBB row border + ring (success-green ring when correct), on the one persistent border

## When to use
- **Option row** — a slide type's option / answer list — the "Items" list (SETTINGS-20)
- **Numbered item** — a repeatable COMPOSITE item with its own body of fields (a Question)

The row is the one border; the field inside is borderless — never a box-in-a-box (SETTINGS-34). The drag handle lives INSIDE the border and only when order matters (SETTINGS-39). The delete is hover-only and disables (not hides) at the minimum count (SETTINGS-23). No "X of Y" counter — the +Add disables at max and delete at min (SETTINGS-36).

---

# Pagination
> Generated from pagination.contract.json — do not edit by hand.

A page selector for a paged list or table — prev / numbered pages with ellipsis / next; two sizes, a simple mode, and a whole-control disabled state.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: dashboard, admin, editor.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `total` | number | `0` | Total item count across all pages (used with page-size) |
| `page-size` | number | `10` | Items per page; total ÷ page-size decides the page count |
| `pages` | number | `—` | Direct page count; wins over total+page-size when set |
| `current` | number | `1` | The active page (1-based); reflected as it changes |
| `size` | 'default' | 'small' | `default` | Cell scale: 32px square (default) or compact 24px (small) |
| `simple` | boolean | `false` | Collapse the numbers to prev · n / total · next |
| `disabled` | boolean | `false` | Disable the whole control (every cell not-allowed) |
| `change` | CustomEvent | `—` | Composed event on page change, detail { page } |

## Visual standard (measured)
- Cell (default): min-width 32 · height 32 · radius 8 · SemiBold 600
- Cell (small): min-width 24 · height 24 · radius 6 · 12px
- Rest: white bg, 1px border #E3E3E3, text-default
- Hover: border-hover #D3B4FF + color-primary text (motion-fast + ease-out)
- Current: color-primary #6A1EBB fill, text-inverse #FFFFFF
- Ellipsis: … text-tertiary #8A8A8A, aria-hidden, for elided ranges
- Simple: prev · <b>n</b> / total · next — n in color-primary
- Disabled prev/next: text-disabled #B5B5B5, not-allowed
- Disabled (all): every cell not-allowed; current keeps brand fill at .55 opacity
- Arrows: <aha-icon name="system-caret-left|right" size=16>

## When to use
- **Pagination** — a long list or table is split into pages the user steps through
- **infinite scroll** — a feed the user grazes — not a set they navigate by position
- **Load more** — occasional appends where exact page position doesn't matter

Show pagination only when there is more than one page. Keep page cells on the 32px square + radius-8 scale (or the 24px small scale for dense tables), and reserve the brand fill for the current page. The first/last ellipsis keeps long ranges compact; reach for simple mode when width is tight.

---

# Paywall
> Generated from paywall.contract.json — do not edit by hand.

Wrap a pro-gated affordance; the shared upsell renders itself — the crown upgrade mark, title, one-sentence body, the fixed unlock line, and Upgrade + See all plans CTAs are baked in.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `feature-key` | string (snake_case) | `—` | Stable token (e.g. custom_survey_url) — drives analytics + DOM hooks. Required. |
| `feature-label` | string | `Pro feature` | Short noun-phrase title next to the crown (used as the popover title unless title is set) |
| `title` | string | `—` | Popover title override; falls back to feature-label. May wrap to two lines |
| `body` | string | `—` | Exactly one sentence describing the benefit of unlocking; ends with a period |
| `description` | string | `—` | Popover description override; falls back to body |
| `required-plan` | essential | pro | `pro` | Drives the unlock line and the analytics plan prop |
| `placement` | top | bottom | `top` | Popover side relative to the anchor |
| `trigger` | click | hover | `click` | How the popover opens; click-away + Escape close a click popover |
| `cta-label` | string | `Upgrade` | The positive CTA verb; override only with a clear reason |
| `plans-label` | string | `See all plans` | The secondary CTA label — routes to the full pricing page |
| `upgrade-url` | string (URL) | `ahaslides.com/pricing` | Where Upgrade navigates (new tab, noopener) |
| `plans-url` | string (URL) | `ahaslides.com/pricing` | Where See all plans navigates (new tab, noopener) |
| `slot (default)` | element | `upgrade mark` | The gated affordance = the anchor; omit it to use the crown upgrade mark as the anchor |
| `paywall-shown / paywall-upgrade / paywall-plans` | CustomEvent<{feature,plan}> | `—` | Analytics contract; paywall-upgrade + paywall-plans are cancelable (preventDefault to run your own flow) |

## Visual standard (measured)
- Upgrade mark: 16×16 purple circle (--aha-color-primary) + white crown — a focusable control with Default/Hover/Active states; the custom mark, not a Phosphor crown
- Popover: dark indigo surface #242442 (--aha-bg-dark-raised), 300px wide, 16px padding, radius 12; animates opacity + lift on open via the motion tokens
- Anatomy: header (crown + title) · body (one sentence + "Unlock with the <Plan> plan.") · footer (Upgrade + See all plans)
- Buttons: TWO shared <aha-button>s — Upgrade (variant=positive, green --aha-button-positive-bg) + See all plans (variant=secondary, re-themed for the dark surface); never hand-rolled
- Analytics: paywall-shown on open · paywall-upgrade before Upgrade nav · paywall-plans before See-all-plans nav — all { feature, plan }
- Behaviour: Upgrade opens ahaslides.com/pricing (new tab, noopener); See all plans opens plans-url; preventDefault either event to run your own flow. Esc / click-away close

## When to use
- **aha-paywall** — any feature locked behind a paid plan — editor affordance, share option, results feature, dashboard action
- **crown-badge anchor** — the feature has no natural clickable anchor (e.g. a field label) — omit the slot and the crown is the anchor
- **required-plan pro** — the default gate; use essential only for essential-tier features
- **never a bespoke upsell** — a hand-rolled upgrade modal/tooltip drifts from the presenter app — always this element

Feature label is a short noun phrase; body is exactly one sentence about the benefit (not "This is a Pro feature"); the unlock line is fixed — do not reword. Popover/placement mechanics defer to aha-design-overlays; a single plan-gated setting row lives in the Settings surface.

---

# Popconfirm
> Generated from popconfirm.contract.json — do not edit by hand.

A lightweight inline confirmation popover anchored to the trigger — confirm/cancel a low-risk action.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `title` | string | `—` | The confirmation question |
| `description` | string | `—` | Optional supporting line |
| `okText` | string | `OK` | Confirm button label |
| `cancelText` | string | `Cancel` | Cancel button label |
| `onConfirm` | () => void | `—` | Fires when the confirm button is clicked |
| `placement` | 'top' | 'bottom' | 'left' | 'right' | … | `top` | Where the popover opens relative to the trigger |
| `okType` | 'primary' | 'danger' | `primary` | danger styles the confirm as a red destructive button |
| `icon` | ReactNode | `warning` | Leading warning glyph in the popover header |

## Visual standard (measured)
- Popover: white · radius 8 · elevated
- Confirm: primary Button #6A1EBB · white label
- Cancel: default Button · 1px #E3E3E3 border · #1A1A1A label
- Title: #1A1A1A, one short question
- Anchor: pops from the trigger; not a full modal
- Placement: top · bottom · left · right (arrow tracks the trigger)
- Danger: okType="danger" → red confirm #F5222D for a heavier delete

## When to use
- **Popconfirm** — a low-risk, reversible action needs a quick inline check (delete a row, discard a draft)
- **Modal** — a destructive or irreversible action needs a deliberate, focused confirmation
- **no confirm** — the action is trivial and easily undone — prefer an undo toast

Reserve Popconfirm for low-risk, reversible actions — anchor it to the trigger and keep the question to one line. A destructive or irreversible action (delete account, publish) needs a Modal (see the overlays pattern), never a popover.

---

# Popover
> Generated from popover.contract.json — do not edit by hand.

Anchor a floating card of rich content — a title, a body, and an optional arrow — to a trigger.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `open` | boolean | `false` | Controlled open state; reflected attribute |
| `placement` | top | bottom | left | right | top-start | top-end | bottom-start | bottom-end | left-start | left-end | right-start | right-end | `bottom` | Side the panel anchors to. The 4 cardinal values align the panel to the trigger's leading edge; the 8 edge-aligned values (…-start / …-end) pin it to the trigger's start/end edge with the arrow offset near that edge (AntD naming) |
| `trigger` | click | hover | focus | `click` | How the panel opens. Click closes on outside-click + Escape (focus returns); hover/focus track pointer/focus |
| `title` | string | `—` | Bold header rendered above the body (hidden when empty) |
| `content` | string | `—` | Text body alternative to slotted content (used when nothing is slotted) |
| `arrow` | boolean | `false` | Show the pointer arrow at the panel edge |
| `[slot=trigger]` | slotted element | `—` | The element that toggles the panel |
| `open / close` | CustomEvent | `—` | Composed events on show / hide |

## Visual standard (measured)
- Panel: bg-elevated #FFFFFF, 1px border #E3E3E3, radius 8, min-width 180 / max 280
- Elevation: 0 6px 16px ink 12%
- Title: SemiBold 600, 14/21 text-default, 6px above the body
- Body: pad 12 16, 14/21 text-secondary #4A4A4A (text-default when title-less)
- Placement: top · bottom · left · right (8px offset), PLUS 8 edge-aligned (top-start/-end, bottom-start/-end, left-start/-end, right-start/-end) — same side, aligned to the trigger edge with the arrow offset near it
- Trigger: click (default) · hover · focus
- Arrow: optional 8px pointer at the panel edge, matches the surface + border
- Motion: fade + 4px directional lift on open (ease-out, persistent node)

## When to use
- **Popover** — a small panel of rich or interactive content anchored to a control
- **Tooltip** — a short, non-interactive hint on hover
- **Modal** — a focused task that should block the rest of the page

Trigger on click for interactive content (Tooltip owns hover). Give it a short `title` and a one-line body; a long form belongs in a Modal. A click trigger closes on outside-click and Escape (focus returns to the trigger) — both built in.

---

# Progress
> Generated from progress.contract.json — do not edit by hand.

A determinate progress indicator — completion, upload, a quiz timer — as a line, segmented steps, or a circle.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `percent` | number | `0` | Completion 0–100; clamped and drives the fill |
| `type` | 'line' | 'circle' | `line` | Shape: horizontal bar or SVG ring |
| `steps` | number | `0` | Split a line into N discrete segments (line type only) |
| `size` | 'default' | 'small' | `default` | Compact Mini variant (thinner bar / smaller ring) |
| `status` | default | active | success | warning | exception | `default` | Fill colour (exception aliases error) |
| `show-info` | boolean | `true` | Show the trailing percent/glyph label (set 'false' to hide) |

## Visual standard (measured)
- Track: line height 8 (small 6) · radius pill · #F1F1F1
- Fill: default primary #6A1EBB · width (line) or stroke-dashoffset (circle) = percent
- Type: line (default) · circle (SVG ring, box 96 / small 40)
- Steps: steps=N → N equal segments, first round(percent) filled
- Status: active primary · success #16C49A · warning #FF7747 · exception #F5222D
- Motion: width / stroke-dashoffset animate on --aha-motion-slow / --aha-ease-out
- Info: percent label (✓/✕ glyph at success/exception); hide with show-info=false
- A11y: role=progressbar · aria-valuemin 0 / valuemax 100 / valuenow synced to percent

## When to use
- **Progress** — a determinate task with a known percentage (upload, steps, a timer)
- **Spin** — an indeterminate wait with no measurable percentage
- **Skeleton** — content is loading and you want to hold its layout

Use Progress only when you can measure completion. A line for inline flow, steps for a discrete multi-stage task, a circle for a compact score dial. Let the fill colour carry success/exception, not a separate label. For an indeterminate wait use Spin.

---

# QR code
> Generated from qr-code.contract.json — do not edit by hand.

A scannable QR code — a join link, a deck URL, a Wi-Fi handoff — rendered through the shared QRCode.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: audience, editor, dashboard.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | string | `—` | The URL or text to encode |
| `size` | number | `160` | Rendered pixel size |
| `errorLevel` | L | M | Q | H | `M` | Error-correction level |
| `status` | active | expired | loading | scanned | `active` | Render state — expired/scanned overlay a mask, loading shows a spinner |
| `icon` | string | `—` | Optional centre logo URL |
| `color` | string | `#1A1A1A` | Module colour (defaults to ink) |
| `bgColor` | string | `#FFFFFF` | Module background (kept white per DS) |
| `bordered` | boolean | `false` | antd's own 1px frame — off, so the .aha-qr container owns the DS frame |

## Visual standard (measured)
- Modules: ink #1A1A1A on white #FFFFFF (color / bgColor)
- Frame: 1px #E3E3E3 border · radius 8 · padding 16
- Size: 160 default (set via size)
- Error level: M default (L / M / Q / H)
- Status: active default (active / expired / loading / scanned)
- Bordered: off — the .aha-qr frame supplies the DS border (antd's own bordered defaults on)

## When to use
- **QR code** — a link people scan from a screen with a phone — a join code, a deck URL, an app download
- **a plain link** — the destination is clicked, not scanned

Keep the encoded value short so the code stays low-density and scans fast. Always show the human-readable link or join code beside it as a fallback.

---

# Question list
> Generated from question-list.contract.json — do not edit by hand.

An editable list of collapsible questions — each a prompt field plus its OptionRow choices — with a full-width "+ Add question" that disables at max; composes NumberedItem + OptionRow (SETTINGS-31/36).

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `questions` | Array<{id,prompt,options:[{id,text,correct}]}> | `[]` | The model; set as a property or a JSON attribute |
| `min` | number | `1` | Minimum questions — delete disables at this count |
| `max` | number | `∞` | Maximum questions — +Add disables at this count |
| `minoptions` | number | `2` | Minimum options per question — option delete disables at this count |
| `maxoptions` | number | `∞` | Maximum options per question — +Add option disables at this count |
| `disabled` | boolean | `false` | Disable adding questions |
| `change` | CustomEvent<{questions}> | `—` | Composed event on any edit; read e.detail.questions (the full model) |

## Visual standard (measured)
- Question: a collapsible NumberedItem — prompt (counted input) + OptionRow choices + "+ Add option"
- Add question: full-width dashed "+ Add question" (system-plus), disabled at max (SETTINGS-31)
- Add option: per-question "+ Add option", disabled at maxoptions
- Limits: no "X of Y" counter — +Add disables at max, delete disables at min (SETTINGS-36)
- Delete: question delete disables at min; option delete disables at minoptions

## When to use
- **Question list** — an editable set of questions, each with its own prompt + choices (a quiz builder, a survey)
- **Option row list** — a flat list of options for ONE question — compose OptionRow directly

The primary +Add spans the full panel width (SETTINGS-31) — never hugged to one side. Never a textual "X of Y" count (SETTINGS-36) — the +Add disables at max and delete disables at min. Composes NumberedItem + OptionRow; it does not re-implement the numbered card or the option row.

---

# Radio
> Generated from radio.contract.json — do not edit by hand.

Pick exactly one option from a small mutually-exclusive set, all shown at once.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `checked` | boolean | `false` | Controlled selection |
| `value` | string | `""` | The value this option contributes to the group |
| `name` | string | `—` | Groups radios; one `name` = one mutually-exclusive set |
| `variant` | 'dot' | 'button' | 'card' | `dot` | dot = ring + brand dot; button = segmented connected pill (Radio-Button-Group); card = the whole bordered card is the target (dot + title + optional description), selected card gets a brand border + bg-accent fill |
| `description` | string | `—` | Card variant only — a secondary line rendered under the title |
| `size` | 'default' | 'small' | `default` | default = 16px ring / 32px button; small = 14px ring / 24px button |
| `direction` | 'horizontal' | 'vertical' | `horizontal` | How a group flows; in button variant also picks which edges the connected pills round + share |
| `disabled` | boolean | `false` | Disables the control |
| `change` | CustomEvent<{value, checked}> | `—` | Composed event; read e.detail.value |

## Visual standard (measured)
- Ring: 16×16 circle · 1px #D4D4D4 border (small: 14×14)
- Hover border: #D3B4FF (purple-30)
- Checked: #6A1EBB ring + 8px brand dot (scales in)
- Disabled: #F1F1F1 fill · #EBEBEB border
- Label: Plus Jakarta 14/21 (small: 13/20)
- Button variant: segmented pill · 32px (small 24) · checked = bg-accent + brand border, shared edge collapsed
- Card variant: bordered card · dot + title + optional description · checked = brand border + bg-accent fill (16px pad, small 12px)

## When to use
- **Radio** — exactly one from a small (2–5) mutually-exclusive set, all worth showing at once
- **Select** — one value from a known set of more than ~5 options — don't crowd the surface
- **Checkbox** — one or more independent options that don't exclude each other

Pre-select a sensible default rather than leaving the group empty. Labels are sentence case and state the choice — 'Show results after each question', not 'Show Results'. Keep product nouns: Q&A, Word cloud. Reach for variant='button' when the options are short, mutually-exclusive views (Grid/List, Day/Week/Month) that read better as a segmented control. Reach for variant='card' when each option carries a title plus a line of explanation and the whole tile should be the click target (plan pickers, mode choosers).

---

# Rate
> Generated from rate.contract.json — do not edit by hand.

Capture or display a score as a row of stars.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | number | `0` | Current score (0…count; .5 steps with allow-half) |
| `count` | number | `5` | Number of stars (DS V3 name; `max` is an alias) |
| `allow-half` | boolean | `false` | Half-star selection — the left half of a star scores n−0.5 |
| `allow-clear` | boolean | `false` | Click the current value again to clear to 0 |
| `icon` | string | `—` | Custom character: a DS icon name (e.g. system-heart-straight) drawn instead of the star |
| `readonly` | boolean | `false` | Display only — no interaction |
| `disabled` | boolean | `false` | Dims and disables the control |
| `change` | CustomEvent<{value}> | `—` | Composed event; read e.detail.value |

## Visual standard (measured)
- Star: 24×24, gap 4
- Filled: yellow-50 #FFE32C
- Empty: gray-40 #E3E3E3
- Half: filled overlay clipped to 50% (allow-half)
- Character: any DS icon by name via `icon` (outline, brand-tinted)
- Hover: scale 1.12, fill preview to cursor (half-precise)

## When to use
- **Rate** — a subjective score on a small fixed scale — a review, a difficulty, satisfaction
- **Segmented** — picking one labelled option from a few, not a magnitude
- **Slider** — a continuous value across a wide range

Keep the scale small (5 is the norm). Pair with a short label — 'Rate this session' — and show the chosen value nearby for confirmation.

---

# Result
> Generated from result.contract.json — do not edit by hand.

A full-block outcome state — success, error, info, warning, 404, 403 or 500 — with an icon, title and actions.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `status` | success | error | info | warning | 404 | 403 | 500 | `info` | Outcome — picks the glyph and colour |
| `result-title` | string | `—` | The headline message |
| `subtitle` | string | `—` | Optional supporting line under the title |
| `icon` | string (DS icon name) | `—` | Overrides the status glyph with any DS icon (e.g. system-shield-warning) |

## Visual standard (measured)
- Icon: 48px DS glyph, coloured by status (or an `icon` override)
- Success: check-circle · #16C49A
- Error / 500: x-circle / cloud-disconnected · #F5222D
- Warning / 403: warning-circle / lock · #FF7747
- Info / 404: info / magnifying-glass · #9BB3E9
- Title: 24/32 SemiBold #1A1A1A, centred
- Subtitle: 14/21 Regular #8A8A8A

## When to use
- **Result** — a full-block outcome after an operation or a not-found/empty route (with a next action)
- **Alert** — an inline, in-context banner that stays in the layout (feedback pattern)
- **Toast** — a transient, auto-dismissing confirmation

Reserve Result for a whole-region outcome — lead with the glyph + a clear title, keep the subtitle to one line, and give a primary next action in slot="extra". For an in-context message use an Alert; for a transient confirmation use a Toast.

---

# Screen heading
> Generated from screen-heading.contract.json — do not edit by hand.

A product page header — a title/greeting or a breadcrumb trail on the left, action buttons on the right, responsive across desktop / tablet / phone.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: dashboard, editor, admin, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `title` | string | `—` | The page title — rendered via <aha-breadcrumb size="page-title"> (Heading4 24/600, text-default), unless `highlight` makes it an accent greeting |
| `highlight` | string | `—` | Optional substring of `title` rendered in color-primary — e.g. the person's name in a greeting |
| `breadcrumb` | JSON [{label, href?}] | `—` | When present, a `›` trail renders as the page title via the reused <aha-breadcrumb size="page-title"> (for a sub-page) |
| `description` | string | `—` | Optional subtitle under the title (14px, text-secondary) |
| `device` | 'desktop' | 'tablet' | 'phone' | `desktop` | Deterministic responsive layout — phone wraps the actions below the title |

## Visual standard (measured)
- Type: title = <aha-breadcrumb size="page-title"> — Heading4 24 / SemiBold 600 / line-height 1.3 / #1A1A1A (text-default) for a single-crumb page title; 18/600 with an ancestor path
- Highlight: optional `highlight` substring of the title in color-primary #6A1EBB (a person's name) → a local greeting <h1>, matched to the breadcrumb page-title solo
- Breadcrumb: `breadcrumb` JSON [{label, href?}] → the `›` trail renders as the page title via the reused <aha-breadcrumb size="page-title"> (heading-level 1)
- Description: optional subtitle, Plus Jakarta 14/22 · text-secondary #4A4A4A, under the title
- Actions: slot="actions" — the consumer drops <aha-button>s here (never a hand-rolled button)
- Gap: 16 between the title area and the actions · 8 title→description and between action buttons
- Layout: desktop / tablet: title left, actions right on one row · phone: actions wrap below the title

## When to use
- **Screen heading** — the header row of a product page — a title/greeting or a hierarchy trail plus the page's primary actions
- **Breadcrumb** — only the ancestor trail / page title is needed, with no right-hand actions (use <aha-breadcrumb size="page-title"> directly — it IS the title this header wraps)
- **Card** — a titled content container, not the page-level header

One primary action on the right at most; the rest secondary. Use a plain `title` for a top-level page (optionally with a `highlight` name), and a `breadcrumb` trail for a sub-page — both render the page heading via the reused <aha-breadcrumb size="page-title">, never a hand-rolled <h1>. Keep the title a short sentence-case phrase.

---

# Section Header
> Generated from section-header.contract.json — do not edit by hand.

A settings GROUP header — the hierarchy parent that a stack of setting rows sits under: a noun-phrase label, an optional "?" help, and a right-aligned action slot (a master switch, a count, a link).

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | string | `—` | The group title — a noun phrase in sentence case; exposed as a role=heading aria-level=3 |
| `help` | string | `—` | Optional guidance — renders the "?" help trigger (circled question glyph) after the label; the tooltip carries the text |
| `action` | slot | `—` | Right-aligned slot (slot="action") — a master switch, a count, or a link |

## Visual standard (measured)
- Row: flex; align-items center; gap 8
- Label: Plus Jakarta 14/21, weight 600, text-default (#1A1A1A) — a role=heading aria-level=3
- Casing: noun phrase, sentence case — "Presentation", NOT "Presentation Settings"
- Help: optional "?" after the label (DS <aha-tooltip help> — the circled question glyph)
- Action: right-aligned slot (margin-left:auto) — a master switch / count / link
- Motion: none — static header, no interactive state of its own

## When to use
- **Section header** — the title of a GROUP of settings — the hierarchy parent over a stack of rows
- **Setting row** — ONE setting inside the group — a label + a control
- **Settings list** — a whole schema-driven surface of groups + rows at once

Name the group with a short noun phrase in sentence case ("Audience", "Interaction") — never a full sentence and never Title Case. Add `help` only when the group needs a word of guidance; keep the label itself terse. Reach for the `action` slot to hang a master switch that toggles the whole group, a count of items, or a "Manage" link — anything that acts on the group, not on one row.

---

# Segmented
> Generated from segmented.contract.json — do not edit by hand.

Switch between a few mutually-exclusive options inline — labels, leading icons, or icon-only, in three sizes.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `options` | string | JSON array | `—` | Pipe labels "Day|Week|Month" OR items [{value,label?,icon?,disabled?,ariaLabel?}] |
| `value` | string | `first option` | Selected option value |
| `size` | 'small' | 'medium' | 'large' | `medium` | Height/padding/font/radius scale |
| `block` | boolean | `false` | Full-width; options flex-equal |
| `disabled` | boolean | `false` | Dims and disables the whole control |
| `change` | CustomEvent<{value,index}> | `—` | Composed event; read e.detail.value |

## Visual standard (measured)
- Track: gray-20 #F7F7F7, radius 8 (sm 6 · lg 12), pad 2
- Thumb: bg-container #FFFFFF, radius 6, slides on select
- Segment: medium h32 pad 0 16 · small h24 12/12px · large h40 20/16px, SemiBold 600
- Content: label · leading icon (16) + label · icon-only (aria-label required)
- Selected: text-default #1A1A1A over the thumb
- Disabled: per-option or whole control → text-disabled, not-allowed
- Block: full-width track, options flex-equal

## When to use
- **Segmented** — 2–4 short, mutually-exclusive views or modes that switch in place
- **Tabs** — navigating between larger content panels
- **Select** — one option from a longer list that needn't be all visible

Keep labels to one word where possible and the option count small (2–4). Icon-only options MUST carry an aria-label. It is a single-select control — for a magnitude use Rate or a Slider.

---

# Select
> Generated from select.contract.json — do not edit by hand.

Pick one value (or several) from a known set — with type-to-search — rendered through the shared Select.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `options` | { label, value }[] | `[]` | The selectable set |
| `value` | string | string[] | `—` | Selected value(s); array when mode=multiple |
| `showSearch` | boolean | `true` | Type to filter options |
| `mode` | single | multiple | tags | `single` | Selection cardinality |
| `size` | small | default | large | `default` | Control height 24 / 32 / 40 |
| `status` | error | warning | `—` | Validation border colour |
| `allowClear` | boolean | `false` | Show an × to clear the selection |
| `disabled` | boolean | `false` | Non-interactive, muted control |
| `loading` | boolean | `false` | Spinner while async options load |
| `placeholder` | string | `—` | Hint shown when empty |
| `onChange` | (value) => void | `—` | Fires with the new selection |

## Visual standard (measured)
- Control: height 32 · radius 8 · 1px #E3E3E3 border
- Search: showSearch — type to filter options
- Focus: brand #6A1EBB border
- Selected option: brand-tint #F9F5FF row, #6A1EBB label
- Placeholder: #8A8A8A
- Size: small 24 · default 32 · large 40
- Status: error #F5222D · warning #FAAD14 border

## When to use
- **Select** — one value from a known set of more than ~5 options — don't make the user type it
- **Radio** — 2–5 mutually-exclusive options all worth showing at once
- **Input** — free text that isn't a known set

Pre-select the recommended default rather than leaving it empty. Keep option labels short and sentence case; for long lists keep showSearch on. For a short, known set (or a select inside a shadow-DOM surface like a settings row) reach for the leaf Select field (slug `select-field`, <aha-select>) instead — this composite is for search / tags / multi / virtualised lists.

---

# Select field
> Generated from select-field.contract.json — do not edit by hand.

Pick one value from a short, known set — a lightweight native-select-backed field that embeds anywhere.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `options` | { label, value }[] | `[]` | The selectable set — a JSON `options` attribute or an `.options` property |
| `value` | string | `—` | Selected value (controlled) |
| `placeholder` | string | `—` | Dimmed hint shown when no value is selected |
| `size` | small | default | large | `default` | Control height 24 / 32 / 40 |
| `status` | error | warning | `—` | Validation border colour |
| `disabled` | boolean | `false` | Non-interactive, muted control |
| `change` | CustomEvent<{value}> | `—` | Composed event; read e.detail.value |
| `open` | boolean | `false` | Reflects/controls the themed listbox popup being open (also toggled by click/keyboard). aria-expanded stays in sync. |

## Visual standard (measured)
- Control: height 32 · radius 8 · 1px #E3E3E3 border
- Size: small 24 · default 32 · large 40
- Focus: brand #6A1EBB border + soft ring
- Placeholder: #8A8A8A
- Caret: trailing aha-icon system-caret-down (UA arrow suppressed)
- Status: error #F5222D · warning #FF7747 border
- Popup: themed role=listbox — white surface, radius 8, elevation shadow, brand-tint hover, check on selected; Arrow/Home/End + typeahead + Escape; outside-click close

## When to use
- **Select field (this leaf)** — one value from a short, known set (≤ ~10), or a select inside a shadow-DOM surface like a settings row — no search/tags/multi needed
- **Select (composite)** — a long list needing type-to-search, multiple/tags, async loading, or option groups
- **Radio** — 2–5 mutually-exclusive options all worth showing at once

Pre-select the recommended default rather than leaving it empty. Keep option labels short and sentence case; keep product nouns — Q&A, Word cloud. This is the leaf half of the hybrid — reach for the composite Select the moment you need search, tags, or a virtualised list.

---

# Setting group
> Generated from setting-group.contract.json — do not edit by hand.

The settings GROUP container — an optional semibold header (with a "?" help and an action slot) over a column of rows — that bakes in the panel spacing scale so gaps come out right automatically.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, settings, dashboard.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | string | `—` | Optional group header (a 1–3 word noun phrase). When set, renders a semibold header and becomes the group's aria-label |
| `help` | string | `—` | Optional guidance shown as the "?" help tooltip (composed DS <aha-tooltip help>) after the header label — never a standing line |
| `tone` | 'default' | 'danger' | `default` | danger → a danger-zone group, dropped 48px below the previous group (overrides the automatic 32). Spacing only — the danger CTA styling lives on the button |
| `action` | slot | `—` | Forwarded to the header, right-aligned — e.g. a small link or button beside the group title |
| `(default slot)` | slot | `—` | The setting rows / sub-groups; they stack 16px apart automatically |

## Visual standard (measured)
- Rows: column · 16px between sibling settings (the settings scale) · header→rows 12px
- Between groups: 32px automatically (:host(:not(:first-child))) — no divider, no wiring
- Danger zone: tone=danger → 48px above (overrides the 32); the danger CTA styling lives on the button, not here
- Header: optional (label) · 14/21 Semibold (600) #1A1A1A · optional "?" help (composed <aha-tooltip help>) · action slot pushed right (margin-left:auto)
- Structure: no divider lines, no card/box — spacing only, white background

## When to use
- **Setting group** — a titled cluster of related settings in a panel — it bakes in the 16/32/48 spacing scale
- **Sub setting group** — dependent child settings that appear under one parent toggle (indent + tight gap)
- **Settings list** — you want the whole surface rendered from a schema (rows + reused DS controls) in one element

Reach for a group whenever you place more than one setting in a panel — it removes the temptation to eyeball gaps or draw a divider. Title it with a noun phrase; put guidance in the "?" help, not a standing line. Never wrap plain settings in a card or separate them with a line — hierarchy is spacing. Reserve tone="danger" for a genuine danger zone (delete / reset), and put the destructive styling on the CTA button inside it.

---

# Setting Row
> Generated from setting-row.contract.json — do not edit by hand.

ONE setting — a label (with an optional "?" help) plus a slotted DS control; inline (label-left / control-right) by default, or stacked (label above, control full-width) for a wide control.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | string | `—` | The setting name — a noun phrase in sentence case |
| `help` | string | `—` | Optional guidance — renders the "?" help trigger (circled question glyph) after the label; the tooltip carries the text |
| `layout` | inline | stack | `inline` | inline: label-left / control-right (space-between). stack: label above, control full-width — for a wide control |
| `disabled` | boolean | `false` | Dims the label to text-disabled and sets aria-disabled; the slotted control disables itself on its own element |
| `control` | slot | `—` | The DS control (slot="control") — an existing element like <aha-switch> / <aha-checkbox> / <aha-input>; reuse, never a re-implemented control |

## Visual standard (measured)
- Inline row: flex; align-items center; justify-content space-between; gap 12; min-height 24
- Label: Plus Jakarta 14/21, regular (400), text-default (#1A1A1A); flex:1 (label-left)
- Control: flex:0 0 auto (control-right); slotted DS control — reuse, not re-implemented
- Stack: flex-direction column; align-items stretch; gap 8; control full-width
- Help: optional "?" after the label (DS <aha-tooltip help> — the circled question glyph)
- Disabled: label dims to text-disabled (#B5B5B5); the control disables itself
- Leading icon: NONE — no decorative leading-icon slot on the label (SETTINGS-40)

## When to use
- **Setting row (inline)** — a compact control — a switch, a checkbox, a short input — sits to the right of its label
- **Setting row (stack)** — a wide control — a textarea, a wide select — needs the full row width below the label
- **Section header** — the title of the GROUP these rows sit under

Name the setting with a noun phrase in sentence case ("Progress bar", "Speaker notes") — never a question and never Title Case. Put guidance in `help` (the "?" tooltip), not a standing line. Choose `inline` for a toggle/checkbox/short input; switch to `stack` the moment the control needs real width (a textarea, a wide select). Never hand-place an icon before the label — functional icons belong inside the control (SETTINGS-40).

---

# Settings list
> Generated from settings-list.contract.json — do not edit by hand.

A schema-driven group of settings rows — semibold label left (with a "?" help tooltip), control right — reusing existing DS controls.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, settings, dashboard.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `schema` | object | `—` | (list, property) Settings schema { sections:[{ label, rows:[{ key, label, description?, layout?, consequence?, control }] }] } — an array is treated as one section's rows; renders rows and reuses DS controls. Also settable as a JSON `schema` attribute for the build-free HTML form A row may declare visible_if (structured `visibleIf:{key,equals?,in?,not?}` or the Shopify string `visible_if:"{{ settings.<key> }}"`) — the row is shown only when its trigger is on and hidden when off, rendered nested (indented, de-emphasised). |
| `label` | string | `—` | Group header (a 1–3 word noun phrase); on <aha-settings-item> it is the setting name. Ignored when a schema supplies section labels |
| `description` | string | `—` | (item / schema row) Guidance text — rendered as the "?" HELP TOOLTIP after the label (DS <aha-tooltip help>), NOT a standing line. Default: no help glyph |
| `consequence` | string | `—` | (item / schema row) RARE opt-in must-see-consequence line shown as a standing line under the label (e.g. 'This cannot be undone.') — default none |
| `layout` | 'inline' | 'stack' | `inline` | (item / schema row) inline = label left / control right (align-items:center, space-between, gap 12); stack = label above / control full-width (column, gap 8) for wide controls (textarea / wide select) |
| `control` | slot | schema | `—` | (item) The DS control on the right — <aha-switch>, <aha-checkbox>, <aha-input>… slotted by hand, or named in a schema row's `control:{ type }` and instantiated for you |
| `density` | 'default' | 'compact' | `default` | (list) Row/group rhythm. compact tightens the spacing (16 between groups, 8 between siblings) — never adds lines or boxes |
| `disabled` | boolean | `false` | (item / schema row) Dims the label to text-disabled, disables the reused control, sets aria-disabled |
| `locked` | boolean | `false` | (schema row) Plan-gates the row: stays visible, its control becomes the shared Paywall crown badge → upsell. Pair with `plan` ('essential'|'pro') and `feature` (the upsell label) |

## Visual standard (measured)
- Group: column · 16px between sibling settings · 32px between groups · semibold (600) header (SectionHeader); member/row labels are ALSO semibold (600) per the reference SettingRow
- Row (inline, default): label left, control right · align-items:center · justify-content:space-between · min-height 24 · 12px gap
- Row (stack): layout=stack → label above, control full-width (flex-direction:column · 8px gap) — for wide controls (textarea / wide select)
- Label: 14/21 Semibold (600) #1A1A1A (name = noun phrase, no leading verb)
- Help: guidance = a "?" help trigger after the label (DS <aha-tooltip help>, icon system-question-mark, muted ~50% = text-tertiary #8A8A8A → brand on hover/focus) + tooltip; NO standing description line. `description` is the tooltip content
- Consequence (rare): opt-in `consequence` → a standing must-see line 12/18 #8A8A8A under the label; default none
- Disabled row: row disabled → label dims to text-disabled #B5B5B5, control disabled, aria-disabled
- Plan-gated row: row locked → visible but the control is the shared Paywall crown (opens the upsell), never hidden
- Density: density=compact tightens the rhythm (16 between groups, 8 between siblings) — spacing, never lines
- Structure: no divider lines, no card/box around plain settings
- Schema: sections[] → rows[] { key, label, description?, layout?, consequence?, disabled?, locked?, plan?, feature?, control:{ type, …props } }; renders + reuses DS controls
- visible_if: a follow-up row shown only when its trigger is on (Shopify model) — nested (24 indent, tighter gap, de-emphasised label); hidden = display:none, no phantom gap (SETTINGS-19/07/14)

## When to use
- **Settings list** — a group of related on/off or choice settings in a panel, page or drawer
- **Form** — values are submitted together with validation and a submit step
- **a single Switch/Select** — one setting that applies on its own

Name each setting as a noun phrase (no leading verb — the control says on/off). Use a toggle for immediate effect, a checkbox for consent/saved-together — never mix in one group. Guidance lives in the "?" help tooltip after the label — never a standing description line (reserve the rare `consequence` line for a genuine must-see, e.g. an irreversible action). Hierarchy is spacing, never lines or boxes. Use `layout:'stack'` for wide controls (textarea / wide select). See the settings pattern for grouping, sub-settings and the danger zone.

---

# Skeleton
> Generated from skeleton.contract.json — do not edit by hand.

A greyed placeholder that holds a component's shape while its content loads.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `variant` | text | paragraph | title | avatar | button | input | image | `text` | Placeholder shape |
| `rows` | number | `3` | Paragraph line count (last row is shorter); paragraph variant only |
| `round` | boolean | `false` | Soften button/input/image corners to the pill |
| `active` | boolean | `true` | Animate the shimmer (set 'false' to freeze) |

## Visual standard (measured)
- Fill: #F1F1F1 · radius xs (4)
- Text: height 16 · full width
- Paragraph: N rows (12 gap); last row 60% width
- Title: height 24 · 38% width
- Avatar: 40×40 · radius pill
- Button / Input: height 32 / 36 · radius default (8)
- Image: height 96 · centred gray-50 image glyph · radius default
- Round: corner-bearing variants soften to the pill
- Motion: opacity pulse 1.6s (respects reduced-motion); active=false stops it

## When to use
- **Skeleton** — content is loading and you want to hold its exact layout to avoid a jump
- **Spin** — a short, indeterminate wait where the final shape is unknown
- **Progress** — you can measure completion (a percentage)

Match the skeleton to the real content's shape and size so nothing shifts when it resolves. Use it for the first load of a region; don't stack many pulsing blocks for a sub-second wait — a Spin is calmer.

---

# Slider
> Generated from slider.contract.json — do not edit by hand.

Drag to set a value across a known range — volume, opacity, thresholds, ranges — rendered through the shared Slider.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | number | [number, number] | `—` | Position(s); a pair renders a range slider |
| `min` | number | `0` | Range floor |
| `max` | number | `100` | Range ceiling |
| `step` | number | null | `1` | Increment; null with marks to snap to marks only |
| `marks` | { [value]: label } | `—` | Labelled tick positions |
| `range` | boolean | `false` | Two thumbs — select a [low, high] span |
| `vertical` | boolean | `false` | Orient the rail vertically |
| `disabled` | boolean | `false` | Non-interactive, dimmed track + handle |
| `onChange` | (value) => void | `—` | Fires with the new value(s) |

## Visual standard (measured)
- Rail: 4px · #F1F1F1 (gray-30)
- Track: brand #6A1EBB
- Handle: 14px · brand ring
- Hover rail: #E3E3E3 (gray-40)
- Range: two thumbs when value is a [min,max] pair

## When to use
- **Slider** — an approximate value across a continuous range where feel beats an exact number
- **InputNumber** — the exact number matters — a precise limit or count
- **Slider range** — a span with a low and high bound — a price or time window

Pair a slider with a live value readout (or an InputNumber) when the exact number matters. Use marks and step to snap to meaningful positions rather than an arbitrary continuum.

---

# Space
> Generated from space.contract.json — do not edit by hand.

An even, DS-scale gap between a small inline set of items — buttons, tags, inline controls.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `size` | small | middle | large | number | `small` | Gap between items — named DS step (8/16/24) or a raw pixel number |
| `direction` | horizontal | vertical | `horizontal` | Lay items in a row or stack them |
| `align` | start | center | end | baseline | `center` | Cross-axis alignment (align-items) |
| `wrap` | boolean | `false` | Allow items to wrap onto multiple lines |
| `split` | boolean | `false` | Rule a thin --aha-split divider between adjacent items |

## Visual standard (measured)
- Display: inline-flex (row) · flex (vertical)
- Size scale: small 8 · middle 16 · large 24 (or a raw px number)
- Default: size small (8), row, items centred
- Align: start · center · end · baseline (align-items)
- Wrap: off by default; on lets a row flow onto multiple lines
- Split: 1px --aha-split #F1F1F1 rule between adjacent items (half-gap inset)

## When to use
- **Space** — spacing a small inline set of items evenly — buttons in a toolbar, tags in a row
- **Flex** — you need alignment/justification control over a block-level layout
- **Grid** — the items belong in two-dimensional rows and columns

Space is the lightweight spacer — no alignment gymnastics, just a consistent gap. Keep sizes on the named scale so inline spacing matches across surfaces. It is layout, never decoration: it paints no background.

---

# Spin
> Generated from spin.contract.json — do not edit by hand.

A brand-coloured spinner for an indeterminate wait — loading a page, a panel, or results.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `size` | small | default | large | `default` | Spinner diameter |
| `tip` | string | `—` | Optional caption shown under the ring |
| `spinning` | boolean | `false` | Wrapper mode only (with slotted children): show/hide the overlay scrim + centred ring. Toggling it flips a class on a persistent node — the child subtree is never rebuilt. |

## Visual standard (measured)
- Ring: 20×20 · 2px · track purple-20, head primary #6A1EBB
- Sizes: small 14 · default 20 · large 32
- Motion: rotate 0.9s linear infinite (respects reduced-motion); scrim fade + blur via motion-mid
- Tip: optional caption below (standalone) or under the centred ring (wrapper), text-secondary
- Wrapper: slot children; when spinning, scrim = bg-container @60% over blurred child, ring centred

## When to use
- **Spin** — an indeterminate wait with no measurable percentage
- **Progress** — you can measure completion (a percentage)
- **Skeleton** — you want to hold the shape of the content that is loading

Reach for Spin only when you cannot show a percentage or hold the layout. Keep one spinner per waiting region; pair it with a short tip for waits over ~1s. To block a region while it loads, wrap the content and toggle `spinning` rather than swapping it for a bare centred spinner — the layout holds and the child stays mounted.

---

# Statistic
> Generated from statistic.contract.json — do not edit by hand.

A single headline number with a caption, prefix/suffix (text or icon), precision, trend colour and a loading skeleton.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: dashboard, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | string | `—` | The caption above the value |
| `value` | string | number | `—` | The headline figure |
| `precision` | number | `—` | Format value to N decimal places with en-US thousands separators (non-numeric values left verbatim) |
| `prefix` | string | `—` | Small text before the value (e.g. a currency mark) |
| `suffix` | string | `—` | Small text after the value (e.g. %, unit) |
| `prefix-icon` | icon name | `—` | A DS icon before the value (e.g. system-currency-circle-dollar) |
| `suffix-icon` | icon name | `—` | A DS icon after the value |
| `trend` | up | down | `—` | Tint the value success-green/error-red + a matching trend arrow glyph |
| `loading` | boolean | `false` | Show a shimmer skeleton in place of the figure |

## Visual standard (measured)
- Caption: 14/22 #4A4A4A
- Value: 24/32 SemiBold #1A1A1A
- Prefix / suffix: 14/22 Regular #8A8A8A, text or a 16px DS icon
- Precision: value formatted to N decimals, en-US thousands separators
- Trend: up color-success #16C49A + trend-up glyph · down color-error #F5222D + trend-down glyph
- Loading: shimmer skeleton (label + value bars), motion-token animation, role=status

## When to use
- **Statistic** — a single KPI on a dashboard or results screen
- **Badge** — a small count attached to another element
- **Progress** — a value's share of a whole

Caption names the metric in sentence case — 'Active players', not 'ACTIVE PLAYERS'. Use trend only when up/down carries meaning; the element pairs the tint with a trend arrow glyph so colour is never the sole signal. Reach for prefix-icon/suffix-icon (a currency mark, a %) over a decorative image; set precision for money/rates so the figure reads consistently.

---

# Status badge
> Generated from status-badge.contract.json — do not edit by hand.

A glanceable lifecycle pill for a domain object — draft, published, closed, archived, active, inactive.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: dashboard, editor, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `status` | draft | published | closed | archived | active | inactive | `draft` | The lifecycle state — drives the pill and dot colour via a CSS-only persistent node |

## Visual standard (measured)
- Shape: height 22 · radius pill · dot 6 + label
- Draft: neutral #F7F7F7 · dot #B5B5B5
- Published: positive #D8FAEF / #13A181 · dot #16C49A
- Closed: warning #FFF5F0 / #E65B29 · dot #FF7747
- Archived: muted #F3F3F3 / #8A8A8A · dot #8A8A8A
- Active: positive #D8FAEF / #13A181 · dot #16C49A
- Inactive: neutral #F3F3F3 / #8A8A8A · dot #B5B5B5

## When to use
- **Status badge** — reflecting a value from a known status enum (a survey/presentation/collector's lifecycle state)
- **Tag** — a free-form label, keyword or category chip
- **Badge** — a count or a bare notification dot

Use only for a value from a real status enum — not free-form labels, counts, or notification dots (those are Tag / Badge). Always keep the text label; the dot colour is decorative and never the sole signal.

---

# Steps
> Generated from steps.contract.json — do not edit by hand.

Show progress through a sequence of ordered stages — wizards, onboarding, checkouts — rendered through the shared Steps.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `current` | number | `0` | Zero-based index of the active step |
| `items` | { title, description?, icon? }[] | `[]` | The ordered stages |
| `direction` | horizontal | vertical | `horizontal` | Layout axis |
| `size` | default | small | `default` | Step scale |
| `progressDot` | boolean | `false` | Render a compact dot rail instead of numbered icons |
| `status` | wait | process | finish | error | `process` | State of the current step — error paints it red |
| `onChange` | (current) => void | `—` | Fires when a step is clicked (if navigable) |

## Visual standard (measured)
- Icon: 32px circle · brand #6A1EBB for current/finished
- Current: filled brand icon + brand title
- Finished: brand ring + check
- Waiting: muted ring + grey title
- Layout: horizontal (default) or direction=vertical
- Size: default (32 icon) or size=small (24 icon)
- Style: numbered icon (default) or progressDot for a compact dot rail
- Error: status=error paints the current step red (colorError)

## When to use
- **Steps** — a linear sequence of stages where the user should see where they are and what's left
- **Tabs** — peer views the user switches between freely — no order, no progress
- **Progress** — a single continuous completion metric, not discrete named stages

Keep titles to a short noun phrase per stage. Reserve Steps for genuinely ordered flows — if the sections have no order, use Tabs. Show a description only when the title alone is ambiguous.

---

# Sub setting group
> Generated from sub-setting-group.contract.json — do not edit by hand.

The dependent (child) settings wrapper — indents a cluster of sub-settings under one parent toggle with a tight gap, spacing only (no border, no card).

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, settings, dashboard.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `hidden` | boolean | `false` | Author-controlled: collapse the sub-settings when the parent setting is off (the component does not derive this itself) |
| `(default slot)` | slot | `—` | The dependent sub-settings; they stack 16px apart, indented 24px under the parent |

## Visual standard (measured)
- Bind: margin-top 8px — tighter than the 16 between siblings, so the child binds to its parent
- Indent: padding-left 24px — the primary nesting signal (no left border, no card)
- Rows: column · 16px between dependent sub-settings (the settings scale)
- Visibility: host owns it — author adds `hidden` when the parent is off (:host([hidden]){display:none})
- Structure: no divider lines, no card/box — spacing + indent only

## When to use
- **Sub setting group** — settings that only apply when a parent toggle is on — indent them under it and hide when off
- **Setting group** — a top-level titled cluster of settings (the 16/32/48 scale + optional header)
- **a disabled row** — the setting always shows but isn't currently changeable — dim it, don't nest it

Use this only for genuinely dependent settings — ones that are meaningless until a parent is on. Keep the parent row and its sub-group inside the same setting group. Hide (not disable) the sub-settings when the parent is off, so the panel doesn't fill with dead controls. Never draw a left rail or box around the nest — the 24px indent + the tight 8px bind are the whole signal.

---

# Switch
> Generated from switch.contract.json — do not edit by hand.

A single setting that takes effect immediately — on or off.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `checked` | boolean | `false` | On/off state (reflected) |
| `disabled` | boolean | `false` | Non-interactive |
| `loading` | boolean | `false` | Spinner in the knob; blocks toggle while busy (aria-busy) |
| `size` | 'default' | 'small' | `default` | Track 44×22 (default) or 28×16 (small) |
| `on-text` | string | `—` | Text shown inside the track when on |
| `off-text` | string | `—` | Text shown inside the track when off |
| `on-icon` | icon name | `—` | DS icon shown inside the track when on (wins over on-text) |
| `off-icon` | icon name | `—` | DS icon shown inside the track when off (wins over off-text) |
| `change` | CustomEvent<{checked}> | `—` | Composed event on toggle; read e.detail.checked |

## Visual standard (measured)
- Track: 44×22 (small 28×16) · radius pill · 1px #D4D4D4 border
- Knob: 18×18 white circle, slides 20px (small 12×12, slides 12px)
- Off: track #F7F7F7 (gray-20)
- On: track #6A1EBB (primary)
- Disabled: 40% opacity, not-allowed
- Loading: system-circle-notch spinner rides in the knob; interaction blocked
- Children: optional text or icon inside the track, away from the knob (checked ↔ unchecked)

## When to use
- **Switch** — a single setting that applies immediately — no save step
- **Checkbox** — options saved together as a group, or expressing consent
- **Radio** — exactly one from a small mutually-exclusive set

Never mix switches and checkboxes in one group. The label names what the setting controls as a noun phrase; the switch communicates the action — don't prefix the label with Enable/Turn on. Reach for `loading` on an async setting that may fail, `size=small` in a dense list, and checked/unchecked children only when the on/off meaning isn't obvious from context.

---

# Table
> Generated from table.contract.json — do not edit by hand.

A data grid — results, leaderboard, participant/admin list — rendered through the shared DataTable.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, admin.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `columns` | Column[] | `[]` | dataIndex / key / title / sorter / filter / fixed / width |
| `dataSource` | object[] | `[]` | row data |
| `rowKey` | string | fn | `—` | unique row id |
| `pagination` | object | false | `—` | brand-outlined page + N/page size changer |
| `reorderable` | boolean | `false` | drag headers to reorder |
| `freezable` | boolean | `false` | right-click header to freeze one column |

## Visual standard (measured)
- Container: white surface, 1px #E3E3E3 border, radius 8 — wraps the whole grid
- Header: white #fff + secondary-grey #8A8A8A labels (row 60px)
- Body: horizontal dividers #E3E3E3 only, no zebra; row 60px
- Radius: 8
- Cell padding: 16 (literal)
- Row hover: gray-30 #F1F1F1
- Customisations (DS V3): sorting (single arrow) · filtering (checklist/range) · reorder · resize · rows-per-page · freeze · add/remove columns

## When to use
- **DataTable** — any rows×columns grid — results, leaderboard, admin list
- **never a raw <Table>** — a bare antd Table restyled per call site is the drift this replaces

White header (not antd grey #fafafa), single-arrow sort (not dual caret), Phosphor icons, tokenised colour. The look is theme-driven; call sites pass data + columns only.

---

# Tabs
> Generated from tabs.contract.json — do not edit by hand.

Switch between sibling content panels from a tab bar — line, primary or card, in two sizes, with per-tab icons.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | number | `0` | Zero-based index of the active tab |
| `type` | 'line' | 'primary' | 'card' | `line` | Underline (line), emphasised Bold underline (primary), or filled bg-accent segment (card) |
| `size` | 'default' | 'small' | `default` | Bar height / label size — small is a tighter, shorter bar |
| `tab-position` | 'top' | 'left' | `top` | Orientation — top (horizontal bar, default) or left (vertical rail with the panel to the right; active indicator becomes a side border, arrow-key nav flips to up/down) |
| `items` | JSON array | `—` | Label-only bar (no panels): [{key?,label,icon?,disabled?}]. Omit to use slotted panels instead |
| `[data-tab]` | attribute on each panel child | `—` | The tab label for that panel; optional data-icon (icon name) + data-disabled |
| `change` | CustomEvent<{value}> | `—` | Composed event; read e.detail.value (index) |

## Visual standard (measured)
- Bar: gap 24, 1px border bottom (border #E3E3E3) — card drops the rule, gap 8
- Tab: SemiBold 600 14 / line-height 21, pad 8 0, 8px gap to a 16px leading icon
- Line (default): active color-primary #6A1EBB text + 2px color-primary underline
- Primary: same underline, active label Bold 700
- Card: active in a bg-accent #F9F5FF pill, radius-sm 6, no underline; hover bg-hover
- Inactive: text-secondary #4A4A4A → text-default on hover
- Disabled: text-disabled #B5B5B5, not-allowed, skipped by arrow-key nav
- Small: 13px label, tighter padding + gap
- Left rail: tab-position=left — vertical rail, right-edge 1px rule; active moves to a 2px color-primary side border (line/primary), card keeps its pill; up/down arrow-key nav

## When to use
- **Tabs** — several peer content panels the user moves between within one screen
- **Segmented** — switching a compact view or mode, not larger panels
- **Select** — the choices are many or space is tight

Keep labels to one or two words, sentence case. Order tabs by expected use, most common first, and don't hide critical content behind a rarely-picked tab. Use line/primary for in-page view switching; reach for card when the bar sits on a tinted surface and needs a filled active chip.

---

# Tag
> Generated from tag.contract.json — do not edit by hand.

A small label chip for a category, keyword, or state — filled or bordered, with an icon, a dismiss ×, or a checkable toggle.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `color` | neutral | primary | success | processing | warning | error | `neutral` | Semantic tone preset (alias: `variant`) |
| `bordered` | boolean | `false` | Outlined shape — white surface + a coloured 1px hairline instead of the filled tint |
| `icon` | string | `—` | Leading DS icon name, e.g. system-tag (resolved via <aha-icon>) |
| `closable` | boolean | `false` | Shows a dismiss × that animates out, removes the tag, and emits close |
| `checkable` | boolean | `false` | Renders a selectable toggle chip (button, aria-pressed) |
| `checked` | boolean | `false` | Selected state of a checkable tag (brand fill) |
| `close` | CustomEvent | `—` | Composed event when the × is pressed |
| `change` | CustomEvent | `—` | Composed event on a checkable toggle, detail { checked } |

## Visual standard (measured)
- Box: height 22 · radius 4 · padding 0 8 · 6px gap to a 14px leading icon
- Text: Plus Jakarta SemiBold 600, 12/18
- Neutral: bg #F1F1F1, label #4A4A4A
- Primary: bg #F9F5FF, label #6A1EBB
- Success / Processing / Warning / Error: tinted bg + matching text token
- Bordered: white surface + a coloured 1px hairline (the outlined DS V3 flavour)
- Closable: system-x ✕; animates out (scale + fade, motion-mid) then removes + emits close
- Checkable: outlined toggle; checked → brand fill #6A1EBB / white label; aria-pressed synced

## When to use
- **Tag** — labelling content with a category/keyword, or showing a non-urgent state inline
- **Badge** — a count or a status dot attached to another element
- **Button** — the chip should trigger an action — a Tag is a label, not a control

Keep tag text to one or two words, sentence case. Colour carries meaning — reserve success/processing/warning/error for real states, not decoration. Use `closable` for user-removable keywords; use `checkable` for an inline filter toggle. For a domain lifecycle state prefer the status pill (aha-design-status-badges).

---

# Textarea
> Generated from textarea.contract.json — do not edit by hand.

Enter multi-line free text — descriptions, messages, notes — with autosize and char count, rendered through the shared Textarea.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | string | `""` | Controlled text value |
| `size` | 'small' | 'middle' | 'large' | `'middle'` | Control height / padding: small 24, default 32, large 40 |
| `status` | 'error' | 'warning' | `—` | Validation state — recolours the border |
| `disabled` | boolean | `false` | Non-interactive; greyed fill |
| `readOnly` | boolean | `false` | Shows value but blocks editing |
| `placeholder` | string | `—` | Empty-state hint (#8A8A8A) |
| `rows` | number | `—` | Fixed visible rows |
| `autoSize` | boolean | { minRows, maxRows } | `false` | Grow with content between bounds |
| `maxLength` | number | `—` | Cap length; pair with showCount |
| `showCount` | boolean | `false` | Show the character counter |
| `onChange` | (e) => void | `—` | Fires on each change; read e.target.value |

## Visual standard (measured)
- Box: radius 8 · 1px #E3E3E3 border · padding 4 11
- Hover: #D3B4FF (purple-30) border
- Focus: brand #6A1EBB border
- Text: Plus Jakarta 14/21; placeholder #8A8A8A
- Sizing: rows or autoSize={{minRows,maxRows}}
- Size: small · default · large (control height 24/32/40)
- Status: error #F5222D · warning #FAAD14 border

## When to use
- **Textarea** — multi-line free text — a description, a message, notes
- **Input** — short single-line text — a name, a title, a search term
- **Select** — one value from a known set — don't make the user type it

Prefer autoSize with a minRows/maxRows band so the field grows with content but never dominates the layout. Pair maxLength with showCount so the limit is visible, not a surprise on submit.

---

# TimePicker
> Generated from time-picker.contract.json — do not edit by hand.

Pick a time of day from scrolling hour/minute/second columns — schedules, reminders, durations — rendered through the shared TimePicker.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | Dayjs | `—` | Selected time |
| `size` | 'small' | 'middle' | 'large' | `'middle'` | Field height: 24 / 32 / 40 |
| `status` | 'error' | 'warning' | `—` | Validation state — tints the border |
| `disabled` | boolean | `false` | Non-interactive, greyed field |
| `format` | string | `HH:mm:ss` | Display/parse format (HH:mm · HH:mm:ss · h:mm a) |
| `use12Hours` | boolean | `false` | 12-hour clock with AM/PM |
| `allowClear` | boolean | `true` | Show the clear (×) affordance |
| `minuteStep` | number | `1` | Granularity of the minute column |
| `onChange` | (time) => void | `—` | Fires with the new time |

## Visual standard (measured)
- Field: height 32 · radius 8 · 1px #E3E3E3 border
- Sizes: small 24 · default 32 · large 40 (via size)
- Status: error/warning tints the border (#FF5A7A error)
- Selected cell: brand #6A1EBB
- Hover cell: #F7F7F7 (gray-20)
- Format: HH:mm · HH:mm:ss · h:mm a (use12Hours)
- Range: TimePicker.RangePicker — start → end
- Placeholder: #8A8A8A

## When to use
- **TimePicker** — a time of day on its own — a reminder, a daily schedule slot
- **DatePicker showTime** — a date AND a time together — a precise timestamp
- **Select** — a small fixed set of times (e.g. 15-min slots) — a list beats scrolling

Match format to the precision you need — HH:mm hides seconds. Use minuteStep to snap to sensible increments rather than every minute. Times use Day.js.

---

# Toast
> Generated from toast.contract.json — do not edit by hand.

A brief, auto-dismissing confirmation of an action — rendered through antd's message API.

Tier: **composite-antd**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `type` | success | info | warning | error | loading | `info` | Status glyph + tone |
| `content` | string | `—` | The message text |
| `duration` | number | `3` | Seconds on screen; 0 keeps it until dismissed |
| `onClose` | () => void | `—` | Fires after the toast leaves |

## Visual standard (measured)
- Surface: white elevated pill · radius 8 · ink #1A1A1A text
- Position: top-centre, floating
- Duration: 3s default (0 = sticky)
- Status: success / info / warning / error / loading

## When to use
- **Toast** — confirming a quick action succeeded (saved, copied, sent) with nothing to act on
- **Notification** — a richer message with a title + description, or one the user may need later
- **Alert** — a persistent, inline message tied to a region of the page

One line, past tense, no title. Never put an action inside a toast that disappears — if the user must respond, use a Notification or a Modal.

---

# Tooltip
> Generated from tooltip.contract.json — do not edit by hand.

A short, transient hint shown on hover or focus of its trigger.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `text` | string | `—` | The hint text shown in the bubble |
| `placement` | 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' | `top` | Which side of the trigger the bubble + arrow sit on. The 4 cardinal values centre the bubble; the 8 edge-aligned values (…-start / …-end) align it to the trigger's start/end edge with the arrow offset near that edge (AntD naming) |
| `color` | 'dark' | 'brand' | `dark` | Bubble fill: dark-navy #1A1A2E or brand color-primary #6A1EBB |
| `arrow` | boolean | `true` | Show the caret pointing at the trigger; arrow="false" hides it |
| `trigger` | 'hover' | 'focus' | 'click' | `hover` | How it opens: hover+focus · focus only · click to toggle |
| `open` | boolean | `false` | Force the bubble visible (demos/tests); otherwise the trigger opens it |
| `help` | boolean | `false` | Render a built-in `?` help trigger — a focusable button carrying the DS <aha-icon name="system-question-mark"> (16px, muted → brand on hover/focus) — instead of requiring a slotted trigger. The settings-label help-glyph pattern; shows on focus as well as hover |

## Visual standard (measured)
- Bubble: dark-navy #1A1A2E (or brand #6A1EBB), white text, radius 6, padding 6 10
- Text: Plus Jakarta 12/18
- Arrow: anchored to the trigger, matches the fill; `arrow=false` hides it
- Placement: top (default) · bottom · left · right centred, PLUS 8 edge-aligned (top-start/-end, bottom-start/-end, left-start/-end, right-start/-end) — same side, aligned to the trigger edge with the arrow offset near it
- Trigger: hover + focus (default) · focus · click (toggle, Esc/click-away to close); `open` forces it visible
- Help: `help` renders a built-in `?` trigger — a DS <aha-icon name="system-question-mark"> at 16px, muted (--aha-icon-muted #8A8A8A) → brand (#6A1EBB) on hover/focus — the settings-label help glyph; focusable, so it shows on focus, not hover-only
- a11y: role=tooltip, aria-describedby wired to the active trigger (slotted, or the built-in help button), shows on focus, no leaked listeners

## When to use
- **Tooltip** — a brief, non-essential hint — an icon-only button's name, a ? help elaboration
- **help text** — a must-see consequence that is hard to undo — put it inline, not in a tooltip
- **Popover** — richer content or interactive controls — a tooltip is text only

Never hide essential information in a tooltip — it is unavailable on touch and to keyboard users who don't focus the trigger. Keep it to a short phrase; the trigger must be focusable.

---

# Upload
> Generated from uploader.contract.json — do not edit by hand.

Select files by clicking a drop zone or dragging onto it — then manage them as rows or picture tiles with per-item state.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: editor, dashboard, settings.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `listtype` | 'text' | 'picture-card' | `text` | Drop zone + file rows, or a grid of thumbnail tiles with a dashed add-box |
| `items` | JSON list | `[]` | Managed files: [{ name, status?: 'normal'|'uploading'|'error'|'done', percent?, thumb? }] |
| `multiple` | boolean | `false` | Allow selecting more than one file |
| `accept` | string | `—` | MIME/type filter, e.g. "image/*" |
| `maxcount` | number | `∞` | Cap; the add trigger hides once items reach it |
| `disabled` | boolean | `false` | Dims and disables the zone / add-box |
| `change` | CustomEvent<{files}> | `—` | Composed event on select; read e.detail.files (File[]) |
| `remove` | CustomEvent<{name,index}> | `—` | Composed event when an item's ✕ is clicked |

## Visual standard (measured)
- Zone: 1px dashed border-strong #D4D4D4, radius 8, pad 24 16
- Rest text: text-tertiary #8A8A8A, 14/21
- Hover / drag: border + accent tint color-primary / bg-accent (motion-mid, persistent zone)
- File row: 1px border #E3E3E3, radius 8, file icon + name + remove ✕
- Uploading: 4px pill progress track (split), color-primary fill, width animates (motion-slow)
- Error item: border + text color-error #F5222D on bg-negative, warning glyph
- Done item: check-circle glyph color-success #16C49A
- Picture-card: 104px tiles + dashed add-box; add-box is the trigger

## When to use
- **Upload** — the user brings a file in — an image, a CSV, a document
- **Input** — capturing typed text, not a file
- **Colour picker** — choosing a value from presets rather than importing

State what you accept in the zone copy and set `accept` to match. Drive `items[].status` from your upload lifecycle (uploading → done/error) and update `percent` as bytes land. Show the error reason near the failed row. Use `picture-card` for images, `text` for documents. Offer both click and drag — both are wired here.

---

# User info
> Generated from user-info.contract.json — do not edit by hand.

A person/identity row — an avatar next to a name, with an optional email underneath.

Tier: **leaf-lit**. Frameworks: HTML (paste-and-run, no build step) · React · Vue 3.

Surfaces: dashboard, settings, editor, audience.

## Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `name` | string | `—` | The person's full name — rendered as text, and passed to the avatar for initials/alt |
| `email` | string | `—` | Optional email; when present (and not compact) it renders under the name in the default layout |
| `src` | string | `—` | Avatar image URL; falls through to initials of `name` when absent |
| `size` | 'default' | 'compact' | `default` | 'default' → 40px avatar + email row; 'compact' → 24px avatar + name only. Omitting `email` implies compact |

## Visual standard (measured)
- Layout: avatar + (name over email) · gap 8
- Name: 14px · SemiBold 600 · text-default #1A1A1A
- Email: 12px · Regular 400 · text-secondary #8A8A8A
- Avatar: 40 when email shown · 24 name-only (reuses <aha-avatar>)
- Compact: size="compact" (or no email) → 24px avatar + name only

## When to use
- **User info** — showing a person's identity as a row — avatar + name (+ email): a member list, an author byline, an account switcher
- **Avatar** — just the picture/initials with no name text beside it
- **Descriptions** — a labelled field grid about an entity, not a compact identity row

Always provide `name` — it's both the visible label and the avatar's initials/alt. Reach for User info over a plain Avatar whenever the name (and email) belong next to the face; reach for the bare Avatar when only the picture is wanted. Omit `email` (or set size="compact") for a tight one-line identity in menus and dense lists.

---

# Changelog

All notable changes to `@ahaslides-product/design`, **newest first**.

**The rule:** every merge adds one entry here **and** bumps `version` in `package.json`.
The top entry's version MUST equal `package.json` → `version` — `standards.mjs` enforces it, so a
PR that forgets either goes red. The `v<version>` release tag (what `npm publish` ships) matches too.
The gate also rejects an unfilled `(#PR)` placeholder in the top entry: link the **real** PR number.

**Format** — one entry per version:

```
## X.Y.Z — YYYY-MM-DD
### Added        ← new component / prop / token / export
### Changed      ← behaviour or API change to something that already shipped
### Fixed        ← bug / gate / doc fix, no API change
### Removed       ← a removed export / component / token
- one short bullet per change, written for a consumer; link the real PR: (#123) — never a bare (#PR)
```

Include only the sections you touched. **Versioning is [SemVer](https://semver.org)** — pre-1.0:
an additive change (new component/prop/token) bumps **MINOR** (`0.x.0`); a fix with no API change
bumps **PATCH** (`0.0.x`); a breaking change also bumps MINOR until 1.0, and is called out in the bullet.

## 0.52.0 — 2026-09-15
### Changed
- **Landing Button — tertiary is now the DS ghost button, and the page is restructured.** The
  Landing → Button page follows the requested structure: a short intro, then a Primary, Secondary
  and Tertiary section, each showing all three control sizes (sm 28px / md 36px / lg 40px) with its
  own paste-and-run snippet. The tertiary variant (`aha-btn--tertiary`) replaces the 0.51
  `aha-btn--text-link`: it now follows the product Button's `variant=tertiary` (a ghost — transparent,
  purple label, soft purple hover fill `#F9F5FF` / active `#F0E4FF`, per-tone soft focus ring), the
  shape the requester accepted, rather than the live Text link's colour-only hover. `landing/SCAN.md`'s
  Text link section is re-reconciled against the ghost tertiary — hover fill, active fill, focus style
  and focus radius are the Webflow-to-update deltas; label weight and hover colour are now matches. (#96)
### Added
- **Landing blocks — per-variant / per-size sections.** A landing block may declare a `variants`
  array (each with `sizes`) in `landing/<slug>.json`; `renderLandingBlock` renders one section per
  variant, each previewing every size with its own copy-paste snippet. Blocks without `variants`
  render exactly as before. (#96)

## 0.51.0 — 2026-09-15
### Added
- **Landing Button — fourth `text-link` variant.** Webflow's "Text link" component (the live
  site's tertiary CTA, `.text-link-wrapper`) is now reflected in `landing/button.json` as
  `aha-btn--text-link`: transparent, borderless, brand-purple label, bound to the product
  text-link colour tokens (`--aha-color-primary` / `--aha-text-link-hover`) and the shared
  `--aha-button-focus-ring`, at the landing button family's semibold weight. `landing/SCAN.md`
  gets a new Text link subsection (round 4) logging three Webflow-to-update deltas: the live
  label weight (`600` vs the DS text-link contract's `400`), the hover colour (one shade too
  dark), and the hard-border focus style (vs the DS soft ring). (#96)

## 0.50.1 — 2026-09-15
### Fixed
- **Landing Section container — definition pass against the live homepage.** Re-scanned the live
  marketing site's `--_spacing---section-p-*` custom properties: max-width and the padding-x cap
  already matched the DS tokens exactly, so fixed a one-token-off mobile padding-x floor
  (`--aha-space-20` &rarr; `--aha-space-16`, matching the live `1rem` mobile value) and a stale
  title font-family/line-height pair left over from before the round-3 Fonts decision (the title
  was forcing the H1/Display treatment onto what is an H2; now inherits the body face and
  `--aha-line-height-heading` like `landing/fonts.json`'s own H2). No Webflow-to-update items —
  the live geometry already agreed with the DS tokens everywhere a live counterpart exists.
  `landing/SCAN.md`'s Section container entry expanded to a full deep-pass table. (#97)
## 0.50.0 — 2026-09-15
### Changed
- **Landing Button preview — annotated items, no container box.** The Preview drops the bordered
  `landing-stage` container, and each variant now carries a caption annotation naming its class and
  role (`aha-btn--primary` — one main call-to-action per section, etc.). Annotations render in the
  Preview only via a new optional `previewHtml` field on landing blocks; the paste-and-run snippet
  stays the clean block. The annotated items stack vertically, one per row. Landing **Fonts** gets a
  `previewHtml` too — a Role/Size type-scale specimen table (each role rendered at its `--aha-size-*`
  token, with a size pill), mirroring the Foundations typography table. (#94)

## 0.49.0 — 2026-09-15
### Changed
- **CSAT — borderless single row only, with a thumbs-down feedback popover** (**breaking**, pre-1.0). `<aha-csat>` now renders one layout: the canonical borderless surface — prompt + two 16px thumb icons on one line, no chrome, a 400-weight prompt. The old boxed form is gone (the `card` attribute is removed), and the legacy `inline` attribute stays a no-op (a stray `<aha-csat inline>` renders the same row). **Thumbs-up** rates instantly (emits `rate`); **thumbs-down** registers the down rating (emits `rate`) *and* opens a feedback popover anchored to the down thumb — a short prompt, a free-text field and a primary Send button — reusing the shared `<aha-popover>` + `<aha-counted-textarea>` + `<aha-button>`; dismissing it (Esc / outside-click) keeps the down rating. New optional `feedback-prompt` / `feedback-placeholder` attributes theme the popover copy. Submitting emits a dedicated composed **`feedback`** event `{ rating: 'down', source, feedback }` (mapping to the pattern's distinct `CSAT_FEEDBACK_SUBMITTED`), then the opt-in `thanks` line cross-fades in over the same cell — replacing the prompt + thumbs in place rather than sitting beside them — shown instantly on an up rating, after submit on a down rating. (#90)
### Fixed
- **Popover — clicking inside the panel no longer closes it when the popover is nested in another element's shadow root.** The outside-click guard used `contains(event.target)`, but a document-level click is retargeted to the shadow host, so any click read as "outside" — closing the popover before an interactive control inside it (e.g. the CSAT feedback field) could be used. It now tests the composed event path. (#90)

## 0.48.0 — 2026-09-15
### Added
- **Responsive gate — one UI, every device.** Device responsiveness is now a house non-negotiable with a rule + gates, so a new component/pattern/screen can't ship a layout that traps on a phone. Product UI must be **fluid from a 360px floor up** (reflow, never a horizontal scroll) with **≥ 24px** touch targets (WCAG 2.5.8 AA). `standards.mjs` (component source) and `screen-lint.mjs` (consumer screens) now **hard-fail a fixed `min-width` ≥ 360px trap** — the zero-interpretation source tell that a layout can't fit a phone (a fixed *width* below the floor is fine; a per-line `ds-lint-allow: responsive (why)` overrides). `screen-lint.mjs` also gains an opt-in **`--measure`** pass that renders a real screen at **360 / 768 / 1200** and hard-fails a horizontal overflow — the render-side twin, Chrome-gated so the static path stays dependency-free for consumer CI. New **`measureAtViewports()`** in `cdp.mjs` sweeps several viewports in one Chrome launch. (The measured pass is scoped to *screens*, not component showcases — a single composed screen must fit a phone, whereas a doc/showcase page legitimately packs many wide variants; a wide data table that scrolls is the one exception.) (#95)

## 0.47.3 — 2026-09-15
### Changed
- **Landing Button — matches the buttons the site ships today.** `landing/button.json` reverts the
  round-3 XL-pill styling: the block now uses the DS default `8px` radius (`--aha-radius-default`)
  and the large control height (`--aha-control-height-button-lg`, 40px) with `20px` padding — the
  same shape and size as both the product `aha-button` and the live `.btn`, so the docs preview reads
  as the real buttons. The pink-accent variant now uses `--aha-pink-60` (`#FF4081`, the live bold
  pink) resting and `--aha-pink-50` on hover. The state fixes from 0.47.2 (focus ring, secondary
  hover/press) are kept. `landing/SCAN.md`'s radius/size/pink rows and Webflow-to-update items 2 and
  8 are corrected accordingly (`#ff4081` is on the DS pink scale; no pill to apply in Webflow).
- **Landing docs — preview labels + no block badge.** The Button preview now labels each variant by
  name (Primary / Secondary / Pink), and the `landing block` badge is removed from every landing
  page heading in `generate.mjs`. (#93)

### Fixed
- **Landing Button — re-scanned against the live Webflow homepage, closed the completeness gaps.**
  `landing/button.json`'s secondary variant was missing the hover text-colour and press-border
  states, and used a plain `outline` for focus instead of the product `aha-button.js`'s soft
  box-shadow ring (`--aha-button-focus-ring`) — both now match the canonical Button primitive
  exactly. `landing/SCAN.md`'s Buttons section is rewritten with a full per-property verdict table
  and 14 new Webflow-to-update deltas (primary press colour, focus-ring colour, pill radius vs the
  live site's square `8px`, label weight, and the secondary border/hover/font-size shades) — DS
  wins every one, nothing was reconciled down to the live site. (#92)

## 0.47.1 — 2026-09-15
### Fixed
- **Modal — responsive width on mobile.** `modalWidth(size)` capped the dialog at a fixed viewport fraction (`35vw`/`50vw`/`90vw`), so on a phone a `simple` modal collapsed to ~135px. It now resolves to `min(<target px>, calc(100vw − 32px))` — the tier's px width on desktop, and near-full-width (16px gutter each side) on mobile. Height caps (`75/80/90vh`) are unchanged. `modalMaxWidth` stays exported as the per-tier desktop reference (no longer used to compute the width). (#91)

## 0.47.0 — 2026-09-15
### Added
- **Modal — two types + viewport size cap.** Reworked the shared Modal to the DS V3 spec: **two types** (a Confirmation modal — five contexts default/confirm/warning/info/danger with a status icon, copy, a Learn-more link and Cancel/Apply — and an Action modal — a task surface with an optional divider), each **capped to the viewport in both axes** so it never grows bigger than the screen. New `modalWidth(size)`, `modalStyles(size)`, `modalMaxWidth`, `modalMaxHeight` exports on `@ahaslides-product/design/modal-theme` keyed by three sizes — **simple 504·35vw·75vh · complexity 720·50vw·80vh · rich 1280·90vw·90vh**: `width={modalWidth('complexity')}` caps the width (`min(target px, vw)`, stays centred) and `styles={modalStyles('complexity')}` caps the height (`auto` up to the cap, then the body scrolls while title + footer stay pinned). Every modal opens as a real overlay — portals to `<body>`, an always-on mask, page scroll locked, closes on mask/Esc/✕ (a destructive confirmation overrides to non-mask-closable). The Learn-more link is a `--aha-text-link` anchor whose external-link glyph shows only when it leaves AhaSlides. The doc playground **triggers each variant with a button**, and the HTML/React/Vue snippets show both types. (#89)

## 0.46.1 — 2026-09-14
### Fixed
- **Landing area links 404** — the Landing index page linked to its block pages with a doubled `landing/landing/<slug>/` path (it rendered as if hosted at the site root, one level too shallow), so every block link and the page's own nav/fonts 404'd. The index now resolves links from its real `landing/` depth. (#88)

## 0.46.0 — 2026-09-14
### Added
- **Landing basics** — the framework-free landing tier gains its core marketing blocks alongside Hero: **Section container** and **Grid** (Layout), **Button** and **Link** (Elements), and the **Spacing** and **Fonts** Foundations-usage references. Each is a paste-and-run `landing/<slug>.json` bound entirely to the shared `--aha-*` tokens (no second brand source): Button follows the product `aha-button` contract in an XL marketing register, Spacing/Fonts reference the shared scales, and every value was grounded against the live AhaSlides Webflow homepage (see `landing/SCAN.md`). (#87)

## 0.45.0 — 2026-09-14
### Added
- **Landing** — a new top-level area for the AhaSlides landing/marketing sites, alongside Components and Patterns. It shares Foundations (the same `--aha-*` tokens) and is a separate tier from the product-app Patterns: framework-free, paste-and-run HTML+CSS marketing sections a landing builder drops into Webflow/WordPress/a static page. One artifact per block in `landing/<slug>.json`; the generator emits the scoped sidebar, a gallery, a doc page per block, and the `landing.llms.txt` / `landing.agent.json` feeds. First block: **Hero**. (#87)
- **Type & spacing tokens as CSS vars** — the canonical `size`, `space`, `weight`, `lineHeight`, `letterSpacing`, `controlHeight` and `breakpoints` scales are now emitted as `--aha-*` custom properties (e.g. `--aha-size-h1`, `--aha-space-24`, `--aha-weight-semibold`, `--aha-radius-marketing`, `--aha-font-secondary`), so framework-free surfaces and components can bind every dimension to a token instead of hardcoding px. Values are derived from `tokens.canonical.json`; no new numbers authored. (#87)

## 0.44.0 — 2026-09-14
### Added
- **Screen-lint** — a mechanical composition gate (`node screen-lint.mjs --surface=product|canvas <files>`; `./screen-lint` export, `npm run lint:screen`, now in `npm run check`). It's the **static companion to the anti-slop self-judge**: before the binary judge, it hard-fails the zero-interpretation defects on a consumer screen — raw hex / off-scale radius / off-scale font-weight / gradient fills / icon-only-without-an-accessible-name (product surface); hardcoded colour / sub-16px + viewport-unit fonts (canvas surface). `--surface` routes the world first, like the judge. Statically-undecidable rules (token-layer contrast, copy, right-instrument) stay the self-judge's job. (#86)
### Changed
- **Type scale** — weights are now **400/600 only**; `700` is dropped from the product type scale (Display uses 600, not Bold). The one remaining literal `font-weight:700` is the measured `aha-tabs` primary-tab label, grandfathered pending re-measure; screen-lint hard-fails any other weight. (#86)
### Fixed
- **Badge snippet** — the paste-and-run example set a raw `background:#fff`; now bound to `var(--aha-bg-container,#fff)` (the first defect screen-lint caught). (#86)

## 0.43.2 — 2026-09-14
### Fixed
- **Foundations · Typography** — the ROLE column specimen font-size was capped at 28px, so display1/display2/h1/h2/h3/h4 all rendered at the same size and the type scale looked flat; each role now renders at its true token size (display1 64px down to bodySM 12px). (#85)

## 0.43.1 — 2026-09-11
### Fixed
- **Tooltip** — a full-sentence `?` help hint now wraps inside the bubble's 240px max-width instead of laying out as one long `nowrap` line that overflowed and got clipped near a panel edge (the settings-label help tooltip, e.g. on Mode field, was getting cut off). (#83)
### Changed
- **Mode field** — the active option's body now renders as de-emphasised help text (secondary colour, regular weight, 13/20) so the field label stays the primary line; and its `?` help tooltip now opens below the label (`bottom-start`) so it clears the panel top instead of being clipped above. (#83)

## 0.43.0 — 2026-09-11
### Added
- **Anti-slop consumer feeds** — the DS now ships the official AhaSlides build→judge→fix loop to any agent that connects: `anti-slop.md` + `anti-slop.agent.json` carry, per surface, the composition rules and a **binary self-judge** (PASS/FAIL each, no partial credit), and `llms.txt` points a connecting agent at them first. A new **App shell** guideline closes the screen-composition gap (real brand mark, no dead placeholders, deliberate hierarchy, tokenised chrome, animated nav state). Criteria live in the DS-owned `anti-slop/criteria.json` — the DS is the single source of truth for anti-slop, seeded once from the aha-design skills. `standards.mjs` gains a consistency gate over the store + feeds. (#82)

## 0.42.0 — 2026-09-11
### Added
- **Loader** — `<aha-loader>`, the full-surface branded loading *screen* shown while a new environment boots (workspace → editor, editor → presenting). Fills its container on a white ground and cycles five branded illustration tiles with a staggered soft-flow (fade + slide + unblur in, hold, out). Reuses the shared `<aha-illustration>` spot art (`loader-award`/`-wand`/`-plane`/`-ballot`/`-chart`, added to the illustration registry), themed by `--aha-*` tokens, `role="status"`, and stills under `prefers-reduced-motion`. Lands under **Patterns · AhaSlides surfaces**. Distinct from `<aha-spin>` (the inline indeterminate spinner). (#78)

## 0.41.0 — 2026-09-11
### Changed
- **Screen heading** — the page title now renders via the reused `<aha-breadcrumb size="page-title">` instead of a hand-rolled `<h1>`, for BOTH a plain `title` (a single-crumb page title) and a `breadcrumb` trail (the heading with its ancestor path in front) — so a sub-page header is now a real page-title-size heading, not a 13px default trail. The DS's single page-heading owner is the breadcrumb; the only local `<h1>` left is the accent-name greeting (`highlight`), which the breadcrumb can't express. No API change. (#81)

## 0.40.0 — 2026-09-11
### Added
- **Settings list — `visible_if` conditional visibility (the Shopify model).** A schema row can declare a trigger: it is shown ONLY when its trigger setting is on and hidden when off, updating live as the trigger changes. Structured form `visibleIf: { key, equals?, in?, not? }` or the Shopify string `visible_if: "{{ settings.<key> }}"` / `"{{ settings.mode == 'advanced' }}"`. A dependent row renders NESTED — indented 24, bound tighter to its parent, de-emphasised label — and when hidden is `display:none` so it leaves no phantom gap (SETTINGS-07/14/19). (#80)

## 0.39.1 — 2026-09-11
### Changed
- **Settings — setting label weight.** A member setting's label is now regular (400), not semibold — only the group/section header (`aha-section-header` / `aha-setting-group` / `settings-list` group header) carries weight (600). Restores the header-vs-member hierarchy (SETTINGS-37) on `aha-setting-row` and `<aha-settings-list>` rows. (#79)