via-quantum

Design system reference

The single visual surface for design-system review. Colors, type, and components live here. Every shared component in @viatrm/ui lands with a section below in the same PR.

Token values shown are placeholders pending UI/UX confirmation. See packages/ui/src/styles/tokens.css and CLAUDE.md §8.3.

Colors

Four-layer cascade per CLAUDE.md §8.3: --via-* (brand, locked) → --client-* (per-Client overrides, defaults to Via) → --product-* (brand-locked product accents) → --color-* (resolved tokens components reference).

Supplied by UI/UX, 2026-08-11. These are the canonical Via values, not placeholders. The design also assigns each one a JOB, and four jobs moved with the values: links, the focus ring and selection borders are now tertiary; the active nav rail is now secondary. Ratios below are computed from the resolved hexes — vitest-axe cannot measure contrast, so a green axe run proves nothing about them.

Via brand (locked)

Cascade source. Override at the Client layer; never edit directly.

#FFFFFF on

#1C0B41

Background
--via-color-primary
Foreground
--via-color-primary-foreground
Contrast
17.89:1 AA

Primary buttons, headings, strongest text, selected states.

#000000 on

#F72684

Background
--via-color-secondary
Foreground
--via-color-secondary-foreground
Contrast
5.57:1 AA

Brand accent — active nav rail, section washes, broadcast, unread dots. Foreground is BLACK: white on this measures 3.77:1 and fails AA.

#000000 on

#026FFF

Background
--via-color-tertiary
Foreground
--via-color-tertiary-foreground
Contrast
4.72:1 AA

Interactive only — links, tertiary buttons, selection borders, focus. Foreground is BLACK: white on this measures 4.45:1 and fails AA.

Brand as text — the -ink tokens

A brand FILL and brand TEXT are different jobs with different floors. Neither new brand value clears 4.5:1 as text (#F72684 is 3.77:1 on surface, #026FFF is 4.45:1), so text uses the darkened -ink token instead. The ink is DERIVED from the Client value with color-mix, not replaced by a Via literal, so it still follows a whitelabel override. Primary needs no ink in light — #1C0B41 is 17.89:1 as text.

#FFFFFF on

#B21B5F

Background
--color-secondary-ink
Foreground
on --color-surface
Contrast
6.53:1 AA

color-mix(in srgb, secondary 72%, black). 6.15:1 on canvas, 5.91:1 on surface-2.

#FFFFFF on

#0259CC

Background
--color-tertiary-ink
Foreground
on --color-surface
Contrast
6.36:1 AA

color-mix(in srgb, tertiary 80%, black). 5.99:1 on canvas, 5.75:1 on surface-2. --color-link resolves through this.

Client overrides (default to Via)

Per-Client whitelabel surface — primary, secondary, tertiary, and their foregrounds. Currently rendering the Via fallback because no Client overrides are loaded. Per-tenant inline <style> at runtime is Phase 2.

#FFFFFF on

#1C0B41

Background
--client-color-primary
Foreground
--client-color-primary-foreground
Contrast
17.89:1 AA

#000000 on

#F72684

Background
--client-color-secondary
Foreground
--client-color-secondary-foreground
Contrast
5.57:1 AA

#000000 on

#026FFF

Background
--client-color-tertiary
Foreground
--client-color-tertiary-foreground
Contrast
4.72:1 AA

Product accents (brand-locked)

Per-product-line accent for visual identity inside a Client-themed shell. International and Contracts are out of scope per §3.5.4. All three published ratios below were wrong (recomputed 2026-08-20, sRGB relative luminance) — Global was wrong in the DANGEROUS direction, claiming 4.65:1 (a pass) for a real 3.56:1 (a fail); Travel and Courses were wrong the safe way, claiming 4.55:1 / 5.40:1 for real 5.47:1 / 5.70:1. Global renders fill only, with no text drawn on the raw hue, until the product-accent display/text-safe pairing exists (a later Global Design Convergence slice — see unsafe-token-pairings.test.ts); Travel and Courses keep their white-text pairing because it genuinely clears AA today.

Fill
--product-global-accent
Hex
#EA580C
Contrast
3.56:1 below AA — see note

Fill only. White text on this fill measures 3.56:1, below AA's 4.5:1 for normal text — the fix is a dedicated text-safe twin, not this styleguide inventing a passing color for a value that fails.

#FFFFFF on

#0F766E

Background
--product-travel-accent
Foreground
--product-travel-accent-foreground
Contrast
5.47:1 AA

#FFFFFF on

#7C3AED

Background
--product-courses-accent
Foreground
--product-courses-accent-foreground
Contrast
5.70:1 AA

Semantic (locked to Via)

Status meaning is consistent across all Clients — not whitelabeled. The 2026-08-11 design supplies a Foreground and a Background per meaning, and both halves are tokens now. Each foreground does double duty: it is the solid fill (carrying white) AND the ink on its own background.

Naming: the design calls the amber meaning “Attention”. The token family stays warning (Gary’s ruling, 2026-08-11) — renaming a family used across five packages is churn with no user-visible payoff. Read the design’s Attention row as this page’s warning row.

Semantic — foreground / solid fill

The design's Foreground column. Also the -ink token: --color-danger-ink and friends now resolve to these same values, replacing the stepped-down amber-800 / green-800 the old saturated fills needed.

#FFFFFF on

#157A0A

Background
--via-color-success
Foreground
--via-color-success-foreground
Contrast
5.50:1 AA

Done, complete, sent.

#FFFFFF on

#8A5300

Background
--via-color-warning
Foreground
--via-color-warning-foreground
Contrast
6.33:1 AA

The design’s “Attention”. Needs someone to act — needs reply, closing soon.

#FFFFFF on

#BF2929

Background
--via-color-danger
Foreground
--via-color-danger-foreground
Contrast
5.89:1 AA

Irreversible or blocking — delete, an audience of nobody.

#FFFFFF on

#0F62D6

Background
--via-color-info
Foreground
--via-color-info-foreground
Contrast
5.62:1 AA

Neutral state — scheduled, in progress.

Semantic — background (new)

The design's Background column, as real tokens (bg-success-surface, …). These REPLACE the /10 and /15 opacity tints Phoenix synthesised: an opacity tint composites over whatever sits behind it, so the same class produced a different colour on canvas, on surface-2 and inside a dialog — and none of them was the colour the design specified. Each card below is the surface carrying its own ink.

#157A0A on

#E8F7E5

Background
--via-color-success-surface
Foreground
--color-success-ink
Contrast
4.94:1 AA

#8A5300 on

#FEF1D9

Background
--via-color-warning-surface
Foreground
--color-warning-ink
Contrast
5.67:1 AA

#BF2929 on

#FAEAEA

Background
--via-color-danger-surface
Foreground
--color-danger-ink
Contrast
5.06:1 AA

#0F62D6 on

#E8F1FD

Background
--via-color-info-surface
Foreground
--color-info-ink
Contrast
4.93:1 AA

Dark mode is DERIVED, not supplied. The 2026-08-11 palette defines light values only. Every dark counterpart in tokens.css is ours: the hue is kept, carried to a luminance that clears AA on a dark field, and the measurement recorded beside it. The four semantic backgrounds are re-derived as dark tints of the same hue (they are ~95% white in light and would be invisible otherwise); the brand inks swapped hue because the brand values did — primary is violet-300 and secondary is pink-300 on dark, where they used to be rose-400 and blue-300.

Semantic surface roles

The neutral fills are ink-over-white and compressed toward white, so the three surfaces a page stacks span ~6 points of relative luminance rather than ~16. Emphasis and selection are their own Via-locked roles — never an opacity of --color-primary, which is client-overridable and used to re-hue every informational band per tenant.

bg-canvas

Workspace / page background

bg-surface

Card, panel, rail

bg-surface-2

Subtle raised — chips, bands, footers

bg-surface-hover

Row / nav hover

bg-surface-recessed

Disabled / turned-off panel — recedes by hairline, not by darkening

bg-surface-accent

Informational / emphasis band

bg-surface-selected

Selected row, option, chip

border-border

Hairline — carries structural separation

border-border-strong

Outlined control boundary — 3.08:1 on surface

Informational band

bg-surface-accent + border-surface-accent-border + text-surface-accent-ink. This is the surface bg-surface-2 was being asked to be.

Unselected row
Selected row — bg-surface-selected + bg-surface-selected-rail
Hover me — hover:bg-surface-hover
shadow-sm
shadow-md
shadow-lg
shadow-xl

Elevation is ink-tinted and deliberately flatter than Tailwind's pure-black defaults — the pass is about less visual weight, not more.

Interactive (locked to Via)

The affordance color — the one that says this does something. Use it for links, tertiary buttons, and other interactive elements. Added on Product’s 2026-08-10 Messages review (items 6 and 12).

This is not the brand color and it does not replace --color-primary. That token is the Client’s brand color and is Client-overridable per §8.3; spending it on controls both makes an identity color do control duty and lets a tenant re-theme the product’s affordance layer. This one is Via-locked — it carries meaning, not identity, so it gets no --client-* tier.

light theme

--color-interactive

Fill — borders, rails, dots, selection edges

Insert token

--color-interactive-ink

Ink — link and tertiary-button labels

A link

dark theme

--color-interactive

Fill — borders, rails, dots, selection edges

Insert token

--color-interactive-ink

Ink — link and tertiary-button labels

A link

Why two values

Product named two blues, and they are a fill / ink pair — the same split the severity roles already use (--color-warning vs --color-warning-ink). The lighter value measures 4.45:1 on white: above the 3:1 floor for non-text, below the 4.5:1 floor for text. So it is the non-text value, and the darker 5.12:1 value is the text value. Getting this backwards is what shipped a serious axe violation on every validation message in the product (§15).

Measured contrast

Measured in Chromium against the surfaces each value actually sits on — not computed from a unit test, which cannot see Tailwind.

Measured WCAG contrast ratios for the interactive tokens, per theme and surface
ThemeTokenUseSurfaceRatio
Light--color-interactive #026FFFNon-textsurface #FFFFFF4.45:1 AA non-text
canvas #F8F8F94.19:1 AA non-text
surface-2 #F4F3F64.02:1 AA non-text
Light--color-interactive-ink #0266EBTextsurface #FFFFFF5.12:1 AA
canvas #F8F8F94.82:1 AA
surface-2 #F4F3F64.63:1 AA
Dark--color-interactive #3B82F6Non-textsurface #17151E4.91:1 AA non-text
canvas #0A09105.39:1 AA non-text
surface-2 #2623304.18:1 AA non-text
Dark--color-interactive-ink #60A5FATextsurface #17151E7.11:1 AA
canvas #0A09107.80:1 AA
surface-2 #2623306.05:1 AA

What it is not for

  • Not a brand color. It never replaces --color-primary, and it is never Client-overridable.
  • Not a solid button background. There is deliberately no --color-interactive-foreground: white on #026FFF measures 4.45:1, below AA for normal text, so a foreground pairing would ship a known-failing combination. Tertiary buttons are text-on-transparent; solid buttons stay --color-primary.
  • Not text on --color-surface-2 in light theme. The ink measures 4.33:1 on #EAECF0 — it clears AA on surface and canvas but not on the raised surface used by chips, kbd, and footers. Put interactive text on surface or canvas, or step it down locally the way --color-warning-ink was.
  • Not yet wired to anything. This change adds and documents the token. Converting existing components — links, tertiary buttons, --color-link — is separate work that follows.

Lifecycle stage identity (locked to Via)

One identity per canonical Task Workflow Stage category — applying · preparing · active · alumni · closed. Each column on the Task Workflows board reads as its own lifecycle moment, which is what the supplied Product Design does with its own stage palette.

Presentation only, and derived. No colour is stored, no Stage carries a colour column, and a tenant cannot pick one: the category is the Stage’s identity, so the colour is a pure function of canonical_category through an exhaustive Record — a sixth category is a compile error, not a colourless dot.

Why its own family. Not --color-secondary, which is Client-overridable per §8.3 — a canonical category must mean the same thing in every tenant, since it is the seam reporting reads. Not the severity roles: an amber dot on “Preparing” would read as a warning. Not the product accents: those denote GLOBAL / TRAVEL / COURSES. applying and preparing deliberately reuse Phoenix’s existing violet and orange values for coherence without inheriting their meaning.

light theme

--color-lifecycle-applying

--color-lifecycle-preparing

--color-lifecycle-active

--color-lifecycle-alumni

--color-lifecycle-closed

dark theme

--color-lifecycle-applying

--color-lifecycle-preparing

--color-lifecycle-active

--color-lifecycle-alumni

--color-lifecycle-closed

Measured contrast

A stage dot is a non-text indicator, so the floor is WCAG 2.2 SC 1.4.11’s 3:1 against the adjacent surface — not 4.5:1. Measured in Chromium against --color-surface: light 5.70 / 3.56 / 5.02 / 4.60 / 4.01, dark 5.56 / 6.69 / 8.68 / 5.71 / 7.11. These lighten on dark unlike the severity fills, because they sit on a surface rather than carrying their own foreground — #15803D on #17151E is ~2.2:1. (That green is --via-color-lifecycle-active, which the 2026-08-11 palette did not touch — lifecycle identity is its own namespace, deliberately not the severity roles.)

Turned off keeps its identity

A turned-off Stage keeps its hue and changes shape — filled becomes a ring (the second dot in each row above). Dropping it to a neutral grey would make its identity ambiguous with closed, whose identity grey genuinely is; and subduing it with opacity-* multiplies contrast against whatever is behind it, so a compliant token stops being compliant the moment it is applied (§8.1 / SC 1.4.3).

Typography

Two self-hosted variable fonts (no fonts.googleapis.com per CLAUDE.md §3.6.1). UI/UX may refine the family selection — swap by changing packages/ui/src/styles/fonts.css and the --font-* tokens.

Font families

--font-display

Plus Jakarta Sans Variable

The quick brown fox

Display / heading face. Geometric, friendly, broad weight range.

Regular — abcdefg ABCDEFG 0123456789

Medium — abcdefg ABCDEFG 0123456789

Semibold — abcdefg ABCDEFG 0123456789

Bold — abcdefg ABCDEFG 0123456789

--font-sans (default body)

Inter Variable

The quick brown fox

UI / body face. Optimized for small sizes, large x-height, broad weight range.

Regular — abcdefg ABCDEFG 0123456789

Medium — abcdefg ABCDEFG 0123456789

Semibold — abcdefg ABCDEFG 0123456789

Bold — abcdefg ABCDEFG 0123456789

Type scale

Tailwind v4 defaults applied to font-display for headings and font-sans for body. Final scale is for UI/UX to refine.

h1

Heading 1 — display

h2

Heading 2 — display

h3

Heading 3 — display

h4

Heading 4 — display

h5

Heading 5 — display

h6

Heading 6 — display

p

Body — paragraph text used for most prose in the product. Inter at the default size.

p

Small — caption or helper text often paired with form controls.

p

X-small — caption / metadata in dense layouts.

Layout primitives

Low-API building blocks. Pages compose them rather than asking each for variants. Every primitive is mobile-first, tokens-only (no ad-hoc spacing or color), works at 320px, and meets WCAG 2.2 AA. Density is tighter than typical SaaS marketing UI to suit data-and-workflow screens.

Page

Outermost wrapper for every route. Renders as <main> so each page has exactly one main landmark. Four widths gate the readable-line-length problem on wide screens while still letting dense surfaces own the viewport.

Widths

narrow (max-w-3xl) for forms · default (max-w-7xl) for most ops UI · wide (max-w-screen-2xl) for dense tables · full for app-shell pages that own the viewport.

width="narrow"

Page content (capped at narrow)

width="default"

Page content (capped at default)

width="wide"

Page content (capped at wide)

width="full"

Page content (capped at full)

Responsive padding

px-4 py-6 on mobile · sm:px-6 sm:py-8 · lg:px-8 lg:py-10. Resize the viewport to see the step-up at 640px and 1024px.

Page padding scales with viewport.
Accessibility & responsive notes
  • Renders <main> by default — one per route.
  • Override with as only when a route legitimately has no main (rare).
  • Mobile-first padding never drops below 16px so touch interaction stays comfortable.

PageHeader

The top of an index/list page. Always carries the page's single h1. The actions cluster reflows below the title on narrow viewports — the title is never truncated to make room for buttons.

Default — Experiences index

Eyebrow + title + description + primary action. Realistic admin landing for the Experiences module.

Travel · Global

Experiences

Reusable definitions of structured engagements your participants join.

With breadcrumb meta and tabs slot

Meta renders above the title (breadcrumbs, back-nav). Children render below the header — typically tabs or filter bars.

Sessions

Instances of an Experience with dates, capacity, and deadlines.

This block only demonstrates PageHeader's tabs/filter slot — selecting All would filter the session list below.

Accessibility & responsive notes
  • One h1 per page — pair with at most one PageHeader or DetailHeader.
  • Actions wrap to their own row at <640px so the title gets the full line.
  • Eyebrow is short, uppercase, neutral — use it for category, not status.

Section

Labelled region inside a Page. Always renders <section aria-labelledby>; the heading level is configurable so nested sections build a meaningful h1 → h2 → h3 outline.

Default (h2) with description and actions

The everyday section header. Title left, actions right; both wrap on narrow viewports.

Active experiences

Published and accepting applications.

12 experiences across 4 product lines.

Nested (h3)

Use headingLevel=3 inside an existing Section. Don't skip levels — h2 → h4 confuses screen-reader outlines.

Requirements

Configure the workflow for this Session.

Pre-departure

Completed before travel start.

3 requirements

In-program

Completed while abroad.

2 requirements

Accessibility & responsive notes
  • Outer section adds aria-labelledby automatically — no extra wiring at the call site.
  • headingLevel must respect outline order: h1 (PageHeader) → h2 (Section) → h3 (nested).
  • Actions row uses the same wrap discipline as PageHeader.

Stack

Vertical flex with a discrete spacing scale. Use Stack instead of ad-hoc space-y-* — the scale is the rhythm of the product.

Spacing scale

Six steps. md is the default; xs/sm for dense rows, xl/2xl for separating major regions.

gap="xs" (4px)

Row 1

Row 2

Row 3

gap="sm" (8px)

Row 1

Row 2

Row 3

gap="md" (12px)

Row 1

Row 2

Row 3

gap="lg" (16px)

Row 1

Row 2

Row 3

gap="xl" (24px)

Row 1

Row 2

Row 3

gap="2xl" (32px)

Row 1

Row 2

Row 3

As <ul> for nav / list semantics

Stack accepts an `as` prop. Use it for semantic lists instead of styling a generic div.

Accessibility & responsive notes
  • Scale is the only API — no arbitrary gap-[…]. Pick the closest step.
  • Use Stack for vertical rhythm; Inline for horizontal.
  • align controls cross-axis (items-start/center/end/stretch).

Inline

Horizontal flex with the same spacing scale as Stack. Wraps by default so dense rows of chips, metadata, and actions reflow gracefully at 320px.

Filter row

Status chips + sort selector. Wraps onto multiple rows at narrow viewports rather than overflowing horizontally.

Status: AllTerm: Fall 2026Owner: AnyoneType: Study abroadCapacity: AnyDeadline: Any

justify=between for header bars

A common pattern: a label on the left, actions on the right, wrapping cleanly when there's no room.

Showing24 of 184participants

wrap=false for fixed-width toolbars

Set wrap=false only when overflow is impossible by construction (icon-only toolbars in a known-width container).

Accessibility & responsive notes
  • Wraps by default; never produce horizontal scroll on a known viewport.
  • Default gap="sm" (8px) — tighter than Stack's default since horizontal rows read denser.
  • justify="between" is the workhorse for header bars.

Container

A bordered surface for grouping operational content. Deliberately not called "Card" — operational UI uses surfaces for structure, not for marketing-style content cards.

Variants

default for the everyday surface · subtle for nested/secondary blocks · flush for tables and lists that own their own internal padding.

variant="default"

White surface, subtle border, light shadow.

variant="subtle"

Neutral background, border, no shadow.

variant="flush"

Row 1
Row 2
Row 3

Padding scale

Discrete steps. sm for dense compact surfaces, md for the everyday, lg for prominent forms or detail panels.

padding="sm"

Content

padding="md"

Content

padding="lg"

Content

Composition: Stack of Containers

The everyday operational layout — a stack of subtle surfaces inside a Section.

Rome — Fall 2026

Open · 24 / 30 enrolled

Tokyo — Spring 2027

Open · 11 / 20 enrolled

Accessibility & responsive notes
  • Default padding is md; flush variants auto-drop to none.
  • Shadow is intentionally minimal — borders carry most of the visual hierarchy in dense layouts.
  • Pass as="article" for content that semantically stands alone (e.g., one item in a list of records).

EmptyState

What fills a list, table, or section when there is no data yet. Operational empty states describe the gap and offer the next step — they are not marketing slots.

Default (md) — whole-section emptiness

For an empty Experiences list, an empty Requirements panel, etc.

No experiences yet

Create your first Experience to start defining structured engagements for participants.

Small (sm) — inline empty

For empty inline lists inside a larger Section (e.g., a Session with no Requirements yet).

Requirements

No requirements configured

Add a requirement to define what participants need to complete.

Accessibility & responsive notes
  • No role="status" — empty state is static content, not a live region. Adding aria-live would cause unwanted announcements.
  • Title should be the noun, description should explain the gap, action should be the next step.
  • Icon is decorative and aria-hidden — never the sole signal.

SplitLayout

Main working surface + secondary <aside> for status, metadata, owners. Stacks below the main column on viewports under lg (1024px). DOM order is always main → aside, so the working content reads first.

Experience detail layout

Main column owns the description and sessions table; aside owns status, ownership, and key dates.

Overview

Visible to participants on the catalog page.

A semester abroad in Rome focused on classical art history and modern Italian culture. Includes a 10-day excursion to Florence and Naples.

Sessions

2 active · 1 draft

Fall 2026

12 / 30 enrolled · deadline May 30

Published

Spring 2027

12 / 30 enrolled · deadline May 30

Published

Fall 2027

12 / 30 enrolled · deadline May 30

Draft

asidePosition=left

Visual order swaps on lg+; DOM order stays main-first so screen readers and reading order are unaffected.

Main content

DOM order is still main → aside; only the visual position is swapped on lg+.

Accessibility & responsive notes
  • Aside is a real <aside> landmark — name it via its content (no ARIA needed).
  • DOM order is always main → aside. asidePosition only affects visual order at lg+.
  • asideWidth is sm (16rem) / md (20rem) / lg (24rem).

SidebarLayout

App-shell layout with a persistent left navigation. Desktop (lg+) shows the sidebar inline; mobile collapses it behind a toggle that opens an overlay drawer scoped to the layout's bounds.

Admin shell

Realistic admin nav with the page header and a small data surface inside. Resize the viewport below 1024px to see the mobile drawer behavior.

Experiences

Travel · Global

Experiences

Reusable definitions of structured engagements your participants join.

Active

12 published · 4 draft

Rome Study AbroadPublished
Tokyo Faculty-LedDraft
Accessibility & responsive notes
  • <nav aria-label> — required so it's distinguishable from page-internal nav (tabs, breadcrumbs).
  • Toggle reports aria-expanded / aria-controls; Esc closes the drawer; click on the overlay closes.
  • Drawer transitions are CSS-only and honor prefers-reduced-motion.
  • Hard focus trap is deferred to a future Dialog primitive; for now, navigate the drawer links with Tab and close with Esc.
  • Nav links should set aria-current="page" on the active route (this primitive doesn't own routing).

DetailHeader

Top of an entity detail page (one Experience, one Session, one Participant). Sibling to PageHeader; a page uses exactly one of the two.

Experience detail

Back link to the parent index, eyebrow, title with status, ownership and key-date metadata, primary actions.

Experiences

Experience · Study Abroad

Rome Study Abroad

Published

Semester program in classical art history and modern Italian culture.

Owner
C. Park
Term
Fall 2026
Capacity
30
Sessions
3
Open since
Apr 12, 2026

Without back link or metadata

Minimum form — title + actions. Useful for shallow detail screens (e.g., settings sub-pages).

Settings

Single sign-on

Not configured
Accessibility & responsive notes
  • Back link has a 44×44 touch target (min-h-11) — full mobile tap zone, normal visual size.
  • Status is a slot — the demo uses an inline pill since the Badge primitive ships later. Always include a text label; color is a redundant signal.
  • Metadata is a real <dl> with dt/dd pairs — screen readers announce term/value semantics.

Composition: Experience detail page

How the primitives compose into a realistic Phoenix workflow screen. Page → DetailHeader → SplitLayout → Section / Container / Stack / Inline.

Experiences

Experience · Study Abroad

Rome Study Abroad

Published
Owner
C. Park
Term
Fall 2026
Capacity
30

Sessions

Current and upcoming.

Fall 2026

12 / 30 enrolled · deadline May 30

Published

Spring 2027

12 / 30 enrolled · deadline May 30

Published

Requirements

No requirements configured

Add a requirement to define what participants need to complete.

Components

Each component PR adds an entry here showing variants, sizes, states (default, hover, focus, disabled, loading), and any accessibility-sensitive patterns (focus rings, ARIA, keyboard).

Button

Primary action element. Real <button>; never <div onClick>. Defaults to type="button" so it doesn't accidentally submit a form.

Variants

Six variants. Filled (primary / secondary / tertiary / destructive) use the resolved color tokens — they re-skin per Client and per product context automatically.

Sizes

Four sizes. sm / md / lg meet the 44×44px minimum touch target at every breakpoint per CLAUDE.md §8.1. icon is the one exception and it is deliberate — see the note below.

size="icon" — a square row action

For an icon-only control inside a data-dense table row (reorder, remove). 44×44 at the base breakpoint, 28×28 from sm: up — §8.1's touch target is a MOBILE rule, and 28px still clears WCAG 2.5.8 Target Size (Minimum, AA) at 24×24. Always pair with an sr-only label naming the row or column it acts on.

States

Default · disabled · loading. Hover and active states have subtle visual feedback (brightness shift on filled, neutral background on outline/ghost). Focus-visible ring is keyboard-only.

With icons

Leading and trailing icon slots. Icons are aria-hidden — the button's accessible name comes from its text children.

Full width

For modal footers, mobile CTAs, narrow form columns.

Accessibility notes
  • Real <button>; keyboard activation via Enter and Space comes free.
  • focus-visible outline (2px, 2px offset, neutral-900) — appears on keyboard focus, not mouse click.
  • loading=true sets aria-busy="true", swaps the leading icon for a spinner that honors prefers-reduced-motion, and disables interaction.
  • disabled drops opacity and disables interaction; the label remains in the accessibility tree.
  • Touch target ≥ 44×44px via min-h-11 min-w-11 on every size.
  • Icon-only buttons must wrap the icon's label in <span className="sr-only">.
  • Outline variant border uses border-neutral-400 (≈ 2.45:1) — the same documented WCAG 1.4.11 deviation noted on FormField. Revisit when the design system review picks up.

Badge

Inline label for status, category, or count. Every tone now takes its own tinted fill (Global Design Convergence ruling D2, Krystle 2026-08-20 — activated 2026-08-21 once the tone-mapping audit the ruling made a precondition was complete). Colour is always a redundant signal: the text label carries the meaning in every tone, which is what keeps the badge usable for colour-blind readers, in monochrome print, and in forced-colors mode. The design (Global Component library.pdf, p1) organizes Badge into five tiers, shown below in the design's own order and using its own captions.

1. Lifecycle & status — “The dot carries the state, the word carries the meaning”

Draft, Under review, Published, Action required, Rejected. Every tone here is tinted, and every one carries a dot.

DraftUnder reviewPublishedAction requiredRejected

draft's dot is structural, not optional — measured ~1.01:1 against surface-2 (hex-distinct, visually identical), so its tint alone cannot separate "Draft" from a plain tag. It is the one tone with no no-dot form: both renders below carry the same dot regardless of the prop.

Draft (dot prop omitted)Draft (dot explicitly false)

2. Triage — “How urgent something is”

Blocking, Needs reply, Ready. Phoenix has no separate triage tone — these are the same danger/warning/success tones as tier 1, read as urgency instead of lifecycle by their label wording alone.

BlockingNeeds replyReady

3. Category & count — “Category and count. No lifecycle, so no dot”

The plain tier: tone="neutral", untinted, never a dot. Operational lists fill with these — keep the surface calm.

Study abroadFaculty-ledTrainingEvent12 pending

neutral is the only tone that stays untinted, and it carries two jobs at once: this plain category/count tier, AND the honest treatment for a status whose mapping has not yet been ruled by Product — every ?? 'neutral' fallback across Phoenix's status maps renders here. "Tint what we know, neutralize what we don't" (Gary, 2026-08-21): an unresolved mapping is not painted with a meaning nobody chose. Reaching for a tinted tone here would misreport "nobody has decided yet" as a decision.

4. Category colour identity — “Always beside its name” (not built)

The design's own curated six-hue palette (purple, blue, pink, green, orange, teal) for identifying a GROUP rather than a state.

Phoenix does not implement this tier. BadgeTone has no members for it, and no curated category-identity palette exists in tokens.css. Reported as a gap rather than rendered — inventing six swatches here would claim a capability Phoenix does not have (CLAUDE.md §8.5).

5. Other tones

Broadcast, and the design's own unbuilt eighth tone.

Broadcast

broadcast is Via-locked, not tenant-tinted: the design specifies a 10%-tenant tint with a fixed dot, but no --client-* token is written for it at runtime, so every tenant renders Via's own pink rather than a per-organization colour the platform cannot keep a promise about. The design's eighth tone, planned, is annotated in the source "an unbuilt feature" and is deliberately absent from BadgeTone — a platform tone must not exist because a mockup demonstrated one, so it is not rendered anywhere on this page.

Inline with row content

Composes inside DetailHeader status slot, table cells, list items. Padding stays tight so dense rows don't expand.

Rome Study AbroadPublished

Measured contrast

Computed from the real hex values in tokens.css (WCAG 2.x sRGB relative luminance) — the same method tokens.contrast.test.ts runs, not transcribed from a comment. This page has published a wrong figure before (a claimed 4.65:1 for a real 3.56:1, in the product-accent section above); every number below was independently recomputed against the source hexes before being written here.

  • Label ink (--color-badge-ink) on its own tint clears AA (4.5:1) by a wide margin on every tone: 15.36–16.27:1 in light, 13.91–15.21:1 in dark.
  • Per-tone light figures (navy label on tint): draft 16.27:1, info 15.71:1, success 16.08:1, warning 16.02:1 (the design's "Attention" — CLAUDE.md §8.3's no-rename ruling), danger 15.36:1, broadcast 15.48:1.
  • The plain tier's ink is an improvement over the muted text it replaces: 16.19:1 light / 14.66:1 dark on surface-2, versus --color-fg-muted's 8.78:1 / 7.39:1.
  • Each tint's hairline is a deliberate non-boundary: ~1.3–1.8:1 against its own tint in both themes, below SC 1.4.11's 3:1 floor by construction (the same status --color-border already carries) — the label carries the state at ~16:1, the hairline is separation, not a signal.
  • draft's tint measures ~1.01:1 against surface-2 — hex-distinct, visually identical, which is the reason its dot cannot be optional.
Accessibility notes
  • Color is always a redundant signal — the text label carries the meaning. Forced-colors mode keeps the pill legible because the surface and label switch with the system theme.
  • The leading dot is aria-hidden — screen readers announce the label only, with no duplication.
  • Renders as an inline <span> — drop it inside paragraphs, table cells, or the DetailHeader status slot without breaking layout.
  • The tinted fill is per-tone; neutral is the one exception and stays untinted by design (tier 3 above).
  • Label ink and dot contrast are independently verified in tokens.contrast.test.ts, which recomputes ratios from the live stylesheet rather than trusting a comment.

ProgressBar

A status output (role="progressbar"), never a control. Determinate reports known progress (0–100, clamped) and exposes aria-valuenow. Indeterminate reports work of unknown duration and omits aria-valuenow — no fabricated percentage. First consumer: the ImageField upload lifecycle.

Determinate

Known progress. The fill width animates (and snaps under reduced motion). The required label is the accessible name; any visible caption is optional supporting text.

Indeterminate

Unknown duration (e.g. server-side processing). A segment travels the track; under prefers-reduced-motion it becomes a static striped fill that still reads as in progress.

Sizes

sm (6px) for compact/inline contexts, md (10px) default.

Tones

Two severity tones, always paired with caption text, never color alone. There is deliberately no `primary` tone — that would read the client-overridable brand colour in the same prop position these two use for meaning, so a tenant's brand change would silently alter what the bar communicates (removed in the Global Design Convergence correctness sweep; it had zero production consumers). `success` is also the default — a fill trends toward a good outcome unless a caller opts into `danger`. Danger is a general capability; ImageField failures transition into an actionable error state rather than lingering as a red bar.

Saved
Stalled

With caption — the honest upload lifecycle

Uploading (determinate) → Processing (indeterminate) → Saved (success). The bar only turns green after the backend confirms persistence, never merely because byte transfer hit 100%.

Saving…62%
Processing…
Saved
Accessibility notes
  • role="progressbar" with a required accessible name via the label prop.
  • Determinate exposes aria-valuenow / aria-valuemin=0 / aria-valuemax=100; indeterminate omits aria-valuenow so assistive tech announces busy, not a false number.
  • Motion respects prefers-reduced-motion — determinate width transitions are disabled; the indeterminate segment becomes a static striped fill.
  • Meaning is never color-only: the optional caption carries state as text.
  • No keyboard interaction — it is a status output, not a control.

ImageDropzone

A domain-blind image acquisition surface. One real <button> — click, Enter/Space, drag & drop, or paste (while focused) — that emits a candidate File and typed validation outcomes (unsupported-type, file-too-large, multiple-files, empty-file). It owns no preview, upload, or persistence — those belong to ImageField later. Supported formats and the size cap come from the shared ingest policy (PNG, JPEG, or WebP · up to 10 MB) and are shown in the surface. Try the live examples below; nothing uploads.

Default

Full-width acquisition surface. The whole box is the button (no nested link); 'browse' is emphasis only. Click / Enter / Space open the file picker; drag a file onto it; focus it and paste.

Waiting for an image…

Compact variant

Layout density only — identical behavior, validation, and events. For tighter contexts.

Waiting for an image…

Invalid (with FieldError)

Inherits FormField context like TextInput: danger border + aria-invalid, with the consumer-rendered FieldError below. The primitive emits typed reasons; the consumer owns the copy.

That image is too large — max is 10 MB.

Disabled

Disabled on both the button and the underlying file input; no hover or drag emphasis.

Replacement seam (children)

Non-interactive children fill the surface (the PR4 replace target). A drag-active overlay reads 'Drop image to replace' and dims the preview. Explicit Replace/Remove controls live adjacent in ImageField later — never nested inside this button.

Waiting for an image…

Accessibility notes
  • The surface is a real <button> — native keyboard (Enter/Space) and focus, no custom role. No nested interactive element, so there is no button-in-button.
  • Accessible name comes from the visible instructions (default) or aria-label (replacement seam). Instructions are text, never icon-only.
  • Drag & paste are supplementary; browse is the guaranteed keyboard path. Paste is scoped to the focused surface — no global listener, so it never interferes with other inputs.
  • Errors associate via FormField context (aria-invalid + aria-describedby). Client validation is UX only — the server ingest re-validates every byte.
  • Full-width, ≥44px target, wraps cleanly at 320px. Only subtle color transitions, disabled under prefers-reduced-motion.

ImageEditor

A domain-blind crop / zoom / rotate / reframe editor wrapping react-easy-crop (6.x, MIT) behind the Phoenix API. Desktop in a Dialog, mobile in a full-height Drawer (fixed header, scrollable body, sticky footer); presentation="auto" picks by viewport. It returns edit instructions on Apply — no canvas runs during interaction; the consumer prepares the Blob later via prepareEditedImage.

Editor — multiple aspect presets, zoom, rotate, reset, destination preview

Consumer-provided aspect presets (numeric + original); native-range zoom; explicit Rotate 90° and Reset; an optional live destination preview (nav chip). Apply returns pre-rotation source-space crop pixels + rotation/aspect/zoom.

Opens a Dialog (desktop) or full-height Drawer (mobile). Applying returns edit instructions; nothing is uploaded or persisted.

Constrained — single aspect, rotate unavailable

allowRotate=false removes the rotate control; a single preset locks the aspect. Zoom and reset remain. Density-only configuration — behavior is unchanged.

Source decode failure

When the source can't be decoded, the editor shows an actionable message and disables Apply. Recovery is Cancel — the editor never takes on acquisition responsibilities.

Accessibility notes
  • Honest limitation: fine crop repositioning is pointer/touch only (true of every canvas cropper). Task completion never depends on it — aspect presets (a radio group), the zoom range, Rotate, Reset, Apply, and Cancel are all keyboard operable, and the crop auto-centres, so a keyboard/screen-reader user reaches a valid result without dragging.
  • Desktop Dialog / mobile Drawer are native <dialog>: browser focus trap, Esc, and focus restoration. Esc / backdrop / Cancel discard — closing never silently applies edits.
  • The crop surface is a labelled group with an accessible description; the help text uses neutral guidance that never makes pointer interaction seem mandatory.
  • Targets ≥44px, 320px-safe, browser zoom enabled; motion is delegated to the overlay + cropper and respects reduced motion.

ImagePreserveEditor

The "preserve" sibling to ImageEditor above: where ImageEditor frames an image (crop/zoom/aspect to a target shape), this surface preserves it — the whole asset stays at its natural geometry, and the job is confidence, not composition. Preview the original on a transparency checkerboard (so alpha is visible), rotate if it isn't upright, and commit. There's deliberately no crop, no zoom, and no aspect selector. It is domain-blind and returns the same ImageEdit contract ImageEditor does. In the real product it never mounts standalone — ImageField mounts it internally for a preserve-intent ImagePurpose (see the "Preserve surface" example under ImageField below, which exercises the full upload path). The demos here open the editor directly against a fixed demo asset instead, so the surface is reviewable without an upload step.

Default — rotate, reset, destination preview

A wide, transparent-background demo mark, so the checkerboard is showing something real. Explicit Rotate 90° and Reset; an optional live destination preview. Apply returns a whole-image crop (source rect, pre-rotation) plus the chosen rotation.

Opens a Dialog (desktop) or full-height Drawer (mobile) — no crop, no zoom, no aspect selector. Applying returns edit instructions; nothing is uploaded or persisted.

Rotate unavailable

allowRotate=false removes the Rotate/Reset controls entirely — the surface becomes pure confirmation, nothing to adjust.

Source decode failure

When the source can't be decoded, the editor shows an actionable message and Apply is disabled. Recovery is Cancel — same posture as ImageEditor.

Accessibility notes
  • No pointer-only task: with rotate enabled, Rotate 90°, Reset, Apply, and Cancel are all keyboard-operable buttons — there is no drag/reposition affordance to route around (unlike ImageEditor's crop, this surface never repositions anything).
  • Desktop Dialog / mobile Drawer are the same native <dialog>-backed primitives as ImageEditor: browser focus trap, Esc, and focus restoration. Esc / backdrop / Cancel discard — closing never silently applies a rotation.
  • The checkerboard preview is presentational (transparency is visible to sighted users checking the mark); the help text under it states in words that the whole image is kept and nothing is cropped, so the same fact doesn't depend on seeing the pattern.
  • Targets ≥44px, 320px-safe, browser zoom enabled; rotation respects reduced motion.

ImagePreview

A domain-blind, non-interactive image preview — managed, external, or blob: URLs — that preserves aspect ratio and degrades gracefully when an image is broken. A pure renderer: it owns no object URLs (ImageField does) and knows nothing about storage.

Image

Renders any URL with its alt text.

Brand image

Aspect-ratio frame

Constrain the frame to a consumer-defined ratio; the image fits inside.

Brand image

Broken-image fallback

On load failure it shows a placeholder and keeps the accessible name — never a broken-image glyph.

Brand image

Empty

A null src shows a calm placeholder.

ImageField — the One Media Experience

The domain-blind composition of the image primitives — the full lifecycle: acquire → edit → prepare → upload → process → commit → change → remove → undo. Optimistic presentation, conservative persistence: the edited image appears immediately marked “Saving…”, and is only “Saved” once the backend confirms. Normal product code passes an ImagePurpose (Avatar / Preserve below) so the consumer declares an image’s role and ImageField resolves the editing surface, geometry, output, and destination preview — it does not hand-wire aspect ratios. The raw primitives example is retained only for platform work. The transports below are fakes — nothing is persisted.

The demos below cover the complete workflow end to end: one Add entry + source chooser, Image URL staged review (nothing commits until “Use image”), a committed managed image that is itself the Edit affordance (Edit primary · Change · quiet destructive Remove — never three equal peers), a committed external image that explains why Edit is unavailable, loading / pending and error → retry states, deliberate Remove (confirmation, never a one-click delete), and Frame / Preserve purposes. On narrow screens the secondary Change + Remove fold into a “More actions” menu while Edit stays visible — resize this page below 768px to see it. Verbs are consistent throughout: Add image · Edit image · Change image · Remove · Use image.

Media source chooser (One Media Experience — one way in)

The single Add-image entry. One chooser lists only the sources a field supports today (no 'coming soon' placeholders); it's a compact anchored popover on desktop and a bottom sheet on mobile. Keyboard: arrows/Home/End move, Enter selects, Esc closes; outside-click dismisses. New providers appear by being present in the options.

No source selected yet.

Image URL review (editing happens before commit)

An external link is entered, previewed, and only 'Use image' commits it — entering or submitting the field never changes the active image. Editing an externally-hosted image is a whole-image review (no crop/zoom/rotate/straighten); a copy line explains the image stays hosted at its original location. A failed preview blocks Use image.

Add image

Enter a link to an image online.

No image confirmed yet.

Avatar purpose (frame · circular · 1:1 · straighten)

`frame` intent with a circular crop mask, 1:1 square output, zoom + rotate + Straighten, and NO aspect selector — the admin frames a face and can nudge a crooked photo level, presented as a circular avatar. Source MIME preserved (no forced PNG); the saved raster stays a plain square.

Profile photo
Choose a photo; we’ll frame it.

Remove profile photo?

This removes the current image. You can add another image at any time.

Straighten (frame · original ratio)

`frame` locked to the source's natural ratio with Straighten enabled — an orientation correction for a slightly crooked image. The Straighten slider (−15°..+15°) tilts the preview live; the minimum zoom rises just enough to keep the crop frame covered (no empty corners), and Reset returns it to 0°. Straighten is deliberately quieter than crop/zoom/rotate — a fix, not a design tool.

Scan or document
Straighten a slightly crooked image.

Remove scan or document?

This removes the current image. You can add another image at any time.

Preserve surface (confirm, don't crop)

`preserve` intent keeps the whole mark at its natural geometry — confidence, not composition: preview the original on a transparency checkerboard, rotate if needed, Apply. No crop, zoom, or aspect selector. (Client Logo is now a `frame` purpose locked to the source's natural ratio — this generic preserve purpose keeps the surface demonstrated.)

Brand mark (preserve)
Kept whole — confirm and rotate, never cropped.

Remove brand mark (preserve)?

This removes the current image. You can add another image at any time.

Low-level primitives (platform only) — Upload + Image URL, raw aspects

The escape hatch: raw `aspectRatios` + two sources, for platform development and regression. Product code should use a purpose above instead. Upload → editor → optimistic → committed; Image URL → debounced preview → Save; Replace / Remove (with Undo).

Brand image
Shown across the workspace. PNG, JPEG, or WebP.

Add image

Enter a link to an image online.

Remove brand image?

This removes the current image. You can add another image at any time.

Failure → Retry

A failing upload restores the previous value and offers Retry (reuses the prepared image, no re-crop) or Choose another image.

Brand image

Deliberate removal — confirmation

Remove never happens from one click: it opens a confirmation (Cancel returns the current image unchanged; the destructive action runs the existing remove transport, shows a pending state, and clears the image only on success). Undo appears afterwards when the consumer supplies restore.

Brand image
Remove asks for confirmation first.

Uploaded · PNG

Remove brand image?

This removes the current image. You can add another image at any time.

Removal failure → retry (external image)

On an externally hosted image (same confirmation as a managed one): the first confirmation fails, keeping the image visible with an actionable error; retry succeeds. No Undo — the confirmation is the protection.

Linked image
First removal fails; retry succeeds.
Linked image

External URL

This image is hosted elsewhere. Change it to an uploaded image to edit it in Phoenix.

Add image

Enter a link to an image online.

Remove linked image?

This removes the current image. You can add another image at any time.

Accessibility notes
  • One coherent field label + description; the sub-controls (source selector, dropzone, editor, URL input, Retry, Replace, Remove, Undo) are all keyboard-accessible.
  • The source selector is a labelled toggle-button group (role="group" + aria-pressed); switching source is non-persistent and the committed image stays authoritative until a save succeeds. The URL input pairs its validation error via aria-describedby; a failed external preview is a text warning (not color alone) and never blocks Save.
  • Status changes announce through a polite live region; progress announcements are throttled to deciles (not every event).
  • The optimistic state is marked “Saving…” in text (not color alone); success/failure is announced, and failure restores the prior image.
  • Remove is reversible via inline Undo (no confirm modal, no “bytes deleted” claim); it is a field-local affordance, not a global toast — ImageField requires no ToastProvider.

ChooseImageDialog — the unified image picker

The one source-neutral picker, built to the frozen Design Lab prototype (docs/product/image-platform/design-lab-prototype.md). Four sources in a deliberate hierarchy: Your media and Stock photos are places you browse, so they are tabs; Upload is an action, so it is a button; Image URL is the rarest and weakest path, so it is a quiet link to its own focused step. A four-item rail gave a once-a-quarter task the same weight as the library you open daily.

Choosing never commits. Clicking a result — or pressing Enter on it — opens a staged review; only Apply commits, and that guarantee is structural rather than remembered: no source panel is given onApply, so there is no path from a panel to a commit. The component fetches nothing — the consumer supplies loadLibrary and searchStock, the same seam SubjectPicker's onSearch uses, so no shared component acquires a tenant-scoped read of its own. Stock searches only on an explicit search: the provider quota is small and a search-on-open burned it before the author had typed.

Upload is now offered by every image slot, and the dialog owns the transport (uploadImage, 2026-08-04). Bytes go up when the file is chosen — not at Apply — so the review step shows what was actually stored, an uploaded picture reaches the consumer already shaped like a library pick, and GalleryComposer's toItemImage stays synchronous. One consequence worth knowing: cancelling after choosing a file discards the choice, but the bytes are already stored as an unreferenced object, which is exactly what the Asset Platform's reaper collects. Without an uploadImage the Upload control is not rendered at all, even for a slot whose profile offers the source — a control that provably does nothing is worse than an absent one.

The picker, with every failure state reachable

Pick a simulated condition, then open the picker. The block below shows exactly what Apply hands the consuming screen — the point of the staged review is that this is data the consumer acts on, not a side effect that already happened. Every transport here is a fake and the imagery is a locally generated gradient, so nothing is uploaded, searched, or credited to a real photographer.

Simulated conditions
Nothing applied yet. Choosing a result opens review; only Apply commits.

A withheld source is explained, not absent

A logo is an organization's own mark, never a stock photograph — so the slot withholds that source and says why. The rule lives on the SLOT, not on whichever call site remembered. Note that Upload IS offered here: it was never withheld by a product rule, only by a missing route, and that route now exists.

Accessibility notes
  • The results grid is a WAI-ARIA single-select listbox with aria-activedescendant: one Tab stop for the whole grid, arrows to move, Home/End, Enter/Space to choose. Options are role="option" rather than buttons because that is what the pattern requires — the container owns the keyboard.
  • The active option carries a visible ring. Focus lives on the container, so without one a keyboard user cannot see where they are — a ring, never colour alone.
  • Click and Enter do the same thing. A grid where the mouse selects and the keyboard advances means the two disagree about what choosing is.
  • Dialog semantics (focus trap, Esc, backdrop) are inherited from the shared Dialog / Drawer primitives — Dialog above md, Drawer below. Focus RETURN is the consumer's: the picker is mounted only while open, so it unmounts at the moment it closes and the native restore has nothing to restore from.
  • Result counts and search state announce politely; failures use role="alert". Errors on the address step are associated through FormField, not merely adjacent.
  • Drop and paste work anywhere in the dialog, in every state — but they are enhancements: the Upload button is a real <button> and no operation is drag-only.
  • Touch targets are ≥ 44px on every source control, and the skeleton shimmer carries motion-reduce:animate-none.

GalleryComposer — membership, order and primary

The one gallery surface, built to the frozen design (docs/product/image-platform/gallery-decisions.md). It owns the collection — membership, order, an explicit primary, capacity, per-item description and per-item removal. It opens ChooseImageDialog's multi-select mode to add and AdjustImageDialog to frame, and owns neither. Production fuses all three into one ~400-line component, duplicated for Experience and Activity.

It never interprets custody. The origin word is a lookup shared with the editor, credit renders because the item carries credit rather than because of what kind of image it is, and the picker's source list comes straight off the placement profile — so there is no if (custody === …) here and a fourth custody is not a change to this component.

Primary is an id, and reordering is a keyboard operation

Capacity is 4 so the full state is reachable; nothing generic knows that number. Note that the primary is the THIRD photo — a state production's `sort_order === 0` inference cannot represent. Move it about: the designation follows the picture, and setting primary on another one does not reorder anything. Every change is logged below with what changed.

3 of 4

2 of 3 described
  1. 1

    Your media · 2400×1350

    Students walking along the riverfront

  2. 2

    Your media · 2400×1350

    Needs a description

  3. Primary3

    Stock photo · 2400×1350

    Photo by Alba Moretti on Unsplash

    A busy covered market

No changes yet. Every change reports WHAT changed, so a consumer can persist one row rather than diffing two collections.

The empty state explains what the collection is

Never “No photos”. It says what the collection does with the photos it gets — they appear in the order you arrange them, and the first one becomes the one that represents the gallery — and offers the first action.

0 of 4

No photos yet

Photos appear here in the order you arrange them. The first one you add becomes the one that represents this gallery.

Long alt text wraps — the reassignment panel never scrolls sideways

Open “More actions for Undescribed photo …” → “Remove from gallery”. The first photo is the primary and two remain, so the dialog also asks which one takes over — a radio list labelled with author-written alt text, which has no length limit. One of these labels is a single unbroken 100-character word. Nothing may run past the dialog edge and there must be no horizontal scrollbar, at any width down to 320px. A <fieldset> defaults to `min-inline-size: min-content` and so cannot be squeezed to fit its parent, which is why the panel needs an explicit `min-w-0` and the labels need `wrap-anywhere` rather than `truncate`. A unit test cannot check this — happy-dom resolves no Tailwind and reports every rect as zero — so this example is the check.

3 of 4

2 of 3 described
  1. Primary1

    Your media · 2400×1350

    Needs a description

  2. 2

    Your media · 2400×1350

    gray concrete pathway between concrete buildings during daytime, with students walking towards the library entrance under a clear sky

  3. 3

    Your media · 2400×1350

    Pneumonoultramicroscopicsilicovolcanoconiosissilicovolcanoconiosissilicovolcanoconiosissilicovolcanoconiosis

Accessibility notes
  • An ordered list, never a listbox. Production's gallery strip is role="listbox" / role="option" reordered by drag, which is wrong twice: listbox describes selection, not sequence — so a screen reader announces “selected” when a position changed — and drag is the only path. This is an <ol> of <article>s, each named “Riverfront campus, position 1 of 4, primary photo”, so a screen reader can enumerate them and jump between them.
  • Every item has a visible keyboard reorder path — Move earlier / Move later, always present, 44×44, named per item. Nine buttons all called “Remove” are an unnavigable list, so every per-item control carries the item's name.
  • A polite live region announces “Moved Lecture hall to position 1 of 4” — the item's name, because “moved to position 3” does not say what moved when three moves happen in quick succession.
  • Focus follows the moved item, and falls back to that item's other move control when the one used becomes disabled at the end of the list. A browser blurs a disabled element, which would dump the author at the top of the document mid-rearrange. happy-dom lets a disabled button hold focus, so the test asserts where focus landed rather than that it is not on the body — an assertion that can actually fail.
  • No drag. Deliberately absent rather than the only path: an enhancement that is missing is honest, one that is required is an exclusion.
  • Description completeness reads “3 of 4 described” with an icon, and a per-item flag says “Needs a description” in words. Never colour alone. A photo marked decorative counts as described — it is an answer, not an omission.
  • The card grid uses container queries (@container + @md / @4xl) rather than viewport breakpoints: a composer inside a narrow inspector panel must lay out for the panel. The container is declared on a wrapper — an element cannot query itself, and that failure is silent.
  • Removing a photo drops a membership, and the confirmation says so (“It stays in Your media”). Removing the primary asks which photo takes over, with the first remaining preselected — silently promoting whatever lands first makes an editorial decision inside a delete. Undo restores the whole previous collection, order and primary included.
  • At capacity the Add control is disabled and explained in the author's terms, and every item stays fully editable. The picker refuses the one-too-many at selection rather than accepting it and truncating at commit.

Image framing — FocalPointField + AdjustImageDialog

“Choose what stays in frame.” The author marks a point on the picture; every surface that crops the image honours it as CSS object-position over object-fit: cover. That is the whole mechanism — one declaration, no pixels, nothing decoded or re-encoded — which is exactly why it works for a stock photo Phoenix must never touch, and why one value survives every breakpoint. The derivation is focalPointObjectPosition in @viatrm/validation, shared by the brochure hero, the marketplace card, the admin thumbnails and the previews below.

The control set is derived, never chosen. presentationControlsFor(profile, custody) intersects what the placement can use with what the custody permits. A provider or external image therefore gets a shorter editor, not a disabled one: no greyed-out control advertises something that will never happen, and one sentence says why the set is short. Byte adjustments (crop / zoom / rotate / straighten) need re-encoding and are absent here for every custody until the ingest path exists.

Accessibility, stated honestly. Three routes reach the same value: click or drag, arrow keys (2%, 10% with Shift, Home to centre), and nine named presets. A 2D value has no correct ARIA role — slider is one-dimensional and application suppresses the reading cursor — so the marker is a real <button> whose accessible name carries the live position in words as well as percentages, with one polite live region on the dialog. Placing a point on a photograph is a visual judgement; the presets are the route that works without sight, and we do not claim parity.

FocalPointField, with the derived CSS beside it

Move the marker by pointer, arrow keys or preset. The two frames to the right are the same image at the widest and tallest crops the cover actually ships in — they are the outcome, not a drawing of it.

Click the picture, drag the marker, or use the arrow keys. Whatever you mark here stays in view as the image is cropped for different screens.

Or choose a position

21:9 — the widest crop

4:3 — the tallest crop

object-position: 50.0% 50.0%

Last announcement:

The crop guides, and saying when framing cannot reach. object-position only moves a picture on an axis where the picture overflows the frame, so a destination sharing the photo’s proportions cannot move at all. Measured in Chromium against a 3:2 source — which is every photo in the curated stock set (1200×800) — the 21:9 hero responds vertically, the 4:3 phone hero horizontally, and the 3:2 Browse card not at all. Each destination’s crop is now outlined on the photograph itself, so a move changes something under the cursor rather than in a list below the fold, and one sentence names the destinations the photo already fills exactly. Nothing is disabled: an axis inert at one destination is frequently live at another. The outlines are aria-hidden decoration — every fact they carry is repeated as words in the legend beneath them, which is not.

The whole editor, and what custody changes about it

Switch where the bytes live and reopen. The framing control is identical in all three — a focal point costs nothing and touches no bytes — but a stock or external image gains one plain sentence about what Phoenix cannot alter. Switch the photo's SHAPE to 3:2 and the Browse card guide reports 'Already fits — framing changes nothing here', because a 3:2 photo in a 3:2 frame has nothing to move. The safe-area band shows where the hero's headline sits, so an author can see before publishing that they have not framed the subject behind the title.

Where the bytes live
The shape of the photo

Applied framing: unframed (centred)

ImageAttribution

The ONE renderer for Unsplash-style "Photo by X on Unsplash" credit — Unsplash's guidelines require it wherever a credited photo is displayed. Renders a real <a href> for the photographer's name only when creditUrl parses as an absolute http(s) URL; any other scheme (or a missing value) renders the name as plain text instead — the render-side half of the write-side scheme defence. The "on Unsplash" link is a fixed Phoenix constant and always renders. Never truncates — the text wraps rather than clipping the provider half of the credit.

overlay — atop a photo

A small dark pill for placement on top of an image (hero cover, gallery grid thumbnails, accommodation image). The consumer positions it (absolute + corner offset); the component supplies the pill look.

caption — below or beside an image

Quiet text for the gallery lightbox, a review avatar, or a testimonial portrait — surfaces where the image is too small (or already framed) for an overlay badge.

“An unforgettable semester.”

Photo by David Köhler on Unsplash

Refused scheme → plain text

A creditUrl with a non-http(s) scheme (javascript:, data:, mailto:, a bare string) never becomes a link — only the name loses its link; the Unsplash link is unaffected because it never depends on caller-supplied data.

Photo by Theodor Vasile on Unsplash
Accessibility notes
  • Always a real <a href>, never a <div onClick> — both links carry target="_blank" + rel="noopener noreferrer" and a visible focus ring tuned per variant (white ring for the overlay atop a dark photo, the standard focus token for the caption).
  • Rendered inside an interactive tile (the gallery grid), the attribution is a SIBLING of the photo's button, never nested inside it — a link nested in a button is invalid interactive-in-interactive markup that traps keyboard/AT navigation.
  • Text wraps (whitespace-normal break-words) instead of truncating — a clipped credit satisfies no licence.

Notice

The shared inline message. StatusCard is a metric tile and ConsequenceCard states the consequence of a pending destructive action — neither is an inline message, and that absence is why production hand-rolls bordered alert blocks with raw amber-* / bg-danger/5 literals in a dozen places. Tone is reinforcement only: every notice carries a title in words, and there is no icon-only form.

Four tones, and a deliberate live-region choice

`live` is opt-in, never a default. `alert` for something the user must notice now, `status` for a non-urgent state change, and OMITTED for static explanatory copy — an always-announced notice turns a re-render into noise, and a silent error is one a screen-reader user never learns about.

Nothing to report

The quiet default — an explanatory block with no urgency and no live region.

Phoenix won’t hold a copy of this image

It stays on the other site, so if that site changes or removes it, it will stop appearing in Phoenix.

This image may look soft

It’s 800×450. The cover image looks best at 1600×900 or larger. You can still use it.

Form primitives

Foundational input layer. FormField owns the ARIA wiring — labels link via htmlFor, helper/error text via aria-describedby, invalid state via aria-invalid. The inputs read the context and stay simple at the call site.

One visual size for v1 (44px touch target on every interactive input). Density is tighter than typical SaaS marketing UI — operational forms scan top-to-bottom with clear rhythm.

FormField · FieldLabel · FieldDescription · FieldError

The unit of one labelled control. FormField generates stable IDs and exposes them through context; FieldLabel, FieldDescription, FieldError, and the inputs all read from it. Two layouts: stack (default — label above input) and inline (Checkbox / Switch).

Anatomy

A typical text field. Required marker on the label; helper text below; error reserved for validation.

Visible to participants in the catalog.

Optional marker

Mark optional fields explicitly when most fields are required. Use either required (*) or optional ((optional)) consistently within a form, not both.

Not shown to participants.

Invalid + error

Setting invalid on FormField shifts the input border to danger and pulls the error message into aria-describedby alongside the description.

Maximum number of participants who can enrol.

Capacity must be a positive whole number.

Long label wraps cleanly

Labels are not constrained to one line — the field stays operational with long, explicit copy.

The waitlist starts admitting participants automatically once a confirmed participant withdraws.

Narrow viewport

Fixed to 320px — the foundation operational width. Label and helper text wrap; input fills the column.

Shown in admin lists and on participant invitations.

Accessibility & responsive notes
  • FormField generates the input ID, description ID, and error ID and exposes them via context — the inputs and label components consume them automatically.
  • FieldLabel renders a real <label htmlFor>; clicking it focuses the input.
  • The input's aria-describedby always references the description ID and adds the error ID when invalid is true.
  • FieldError uses no role="alert" — validation errors travel with the input via aria-invalid + aria-describedby; the screen reader announces them on re-focus rather than interrupting the current read.
  • required and optional are mutually exclusive — the optional marker is suppressed when both are set.
  • Contrast — known deviation. Input borders use border-neutral-400 (≈ 2.45:1 against white) and placeholders use text-neutral-500 (≈ 4.57:1, meets WCAG 1.4.3 AA). Strict WCAG 2.2 SC 1.4.11 Non-text Contrast calls for 3:1 on UI-component identifying borders, which would push us to neutral-500 (≈ 4.6:1) — visually heavy on dense operational forms. We've logged this as a deliberate, documented deviation so it surfaces in the next audit; revisit during the Via UI/UX sync.

TextInput

Single-line input. Native type is honored — text, email, number, tel, url, password, date — so the browser keyboard and validation behave correctly per-platform.

States

Default · required · optional · disabled · read-only · invalid. All visually distinct without relying on color alone.

Default

Required

Optional

Disabled

Read-only

Invalid

Must be a positive whole number.

Density

comfortable (the default, 44px) · compact (44px at the base breakpoint, 32px from sm: up). Opt-in — an omitted prop is byte-identical to comfortable, so no existing consumer moves. PhoenixSelect takes the same prop and the two must always match.

comfortable — the default

compact

With helper text

Helper text sits below the input and is wired into aria-describedby. Use it for constraints (formats, character limits) — not for nice-to-have copy.

Participants can no longer submit applications after 23:59 in their local time on this date.

Long label

Multi-line labels stay readable. Avoid them when a short label + helper text would do.

Narrow viewport

320px column. Input fills width; label wraps.

Accessibility & responsive notes
  • Two densities, and comfortable is the default. This entry used to read "one size … compact variants are deferred until a real Phoenix workflow needs them". That workflow arrived: the Cost Table grid measured a 75px row against ~48px in the supplied design, because a 44px control plus 8px of cell padding is a 60px row and a table multiplies it by every line item.
  • Density never changes the default. An omitted prop emits the exact class list comfortable does, asserted by equality in input-density.test.tsx. Portal is mobile-first and stays at 44px; compact is for admin data tables only.
  • The touch-target rule wins where it applies. compact is 44px at the base breakpoint and steps down to 32px only from sm: up, so §8.1's mobile requirement is untouched. 32px still clears WCAG 2.5.8 Target Size (Minimum, AA) at 24×24.
  • Disabled state preserves the label in the accessibility tree; the input is removed from tab order and visually dimmed.
  • Read-only state keeps focus and selection — useful for displaying derived values that must be copyable but not editable (e.g., system-generated slugs).
  • aria-[invalid=true] Tailwind variant shifts the border to border-danger — driven by the FormField context.

PhoenixSelect

Phoenix's canonical dropdown — everywhere else in this file, and everywhere in Admin and Portal, a select control should be this, not a native <select>. A WAI-ARIA select-only combobox styled to match TextInput exactly (same border, surface, height, focus ring), because the browser's native option-list chrome doesn't follow the product's dark mode.

States

Default · disabled · invalid. Controlled only (value + onChange) — compose with FormField or pass label directly for a standalone aria-label.

Default

Disabled

Invalid

Option adornments and a listbox footer — the optional `adornment` and `footer` props

An adornment is a small DECORATIVE node on each option, repeated on the control so the chosen option's colour is visible without opening the list. It is wrapped aria-hidden: the label stays the whole accessible name, so an adornment must never be the only carrier of a distinction. The footer sits OUTSIDE the ul[role=listbox] — a button inside a listbox is announced as one more option to choose — and Tab from the open trigger reaches into it rather than dismissing the list. Used by Messages' inbox selector.

Density

comfortable (the default, 44px) · compact (44px at the base breakpoint, 32px from sm: up). TextInput takes the same prop and the two must always match — they sit side by side in a form row and in a data-table cell.

comfortable — the default

compact

compact TextInput, for comparison

Accessibility & responsive notes
  • Density is opt-in and never changes the default. compact exists for admin data tables, where a 44px control and 8px of cell padding make a 60px row — measured 75px on the Cost Table grid before it landed, against ~48px in the supplied design. Portal is mobile-first and keeps the 44px default; do not reach for compact on a participant surface.
  • The touch-target rule is not traded away. §8.1's 44×44px is a mobile requirement, so compact is 44px at the base breakpoint too and steps down only from sm: (640px) up, where a pointer is assumed. 32px still clears WCAG 2.5.8 Target Size (Minimum, AA) at 24×24. A compact control is never smaller than a comfortable one at any width where the rule applies.
  • The trigger label truncates rather than wrapping. It previously had no minimum-width override, so a label longer than the control grew the trigger taller — measured 50px against its declared 32px in a 128px table column, and being the tallest cell it then set the height of every row.
  • Where a truncated value remains readable, precisely. In the listbox, always. As a native title tooltip — mouse hover only, so a keyboard-only or touch user must open the listbox. Not in the trigger's accessible name: that comes from aria-label or an associated <label htmlFor>, and either source short-circuits name-from-content — so the value span has never contributed to it, truncated or not. That is pre-existing behaviour, not a truncation side effect, but it is not a mitigation either. A screen-reader user reads the current value by opening the listbox.
  • Keyboard: Enter/Space/Down opens the trigger; Up/Down moves the active option; Enter selects; Esc/Tab closes. Matches the ARIA APG "Select-Only Combobox" pattern exactly.
  • Originated in Activities admin forms and promoted to @viatrm/ui after 8 call sites across Activities, Settings, and Experiences forms had already adopted it independently — the de facto standard made official.
  • Do not reach for a native <select>, a hand-rolled dropdown, or a Popover-based single-select for this job. See the Browse filtering pattern below for the canonical way to compose several of these into a filter row.
  • An optional placeholder prop overrides the trigger's empty-state text (defaults to "Select…") — e.g. DropdownQuestion passes "Choose…" to match the Forms Studio widget Design.

Textarea

Multi-line input. Same border, focus, and state styling as TextInput. Vertical-only resize so users can grow the field for long content (advisor notes, submission review). rows controls the initial height.

States

Default · required · optional · disabled · read-only · invalid.

Default

Required

Optional

Disabled

Read-only

Invalid

Eligibility statement is required before the Experience can be published.

With helper text and larger rows

Use rows to give long-form fields room to start. Users can still drag the resize handle vertically.

Visible to all reviewers for this Requirement. Not shared with the participant.

Narrow viewport

320px column. Textarea fills width; resize handle still works.

Accessibility & responsive notes
  • Vertical-only resize (resize-y) — horizontal resize would break responsive columns.
  • min-h-24 (96px) keeps the input usable from first render even before the user resizes.
  • Read-only Textareas are the standard pattern for showing a Submission's prior content during review — selectable, copyable, but not editable.

RichText

The single read-only renderer for sanitized rich-text HTML — used by the Experience brochure fields and Activity descriptions so the admin preview and the participant view share one content path. Content is authored with the TipTap editor (Activities + Studio). RichText sanitizes on render against the shared allowlist (@viatrm/validation — bold, italic, links, lists, H2/H3, blockquote only), and the API sanitizes again on write. Inherits font size and color from the wrapper, so each field keeps its own typography via className.

Formatted content

Allowed marks render; the wrapper's className sets the base type scale.

Study in the heart of Rome with faculty-led seminars and immersive fieldwork.

What you get

  • Small cohort
  • Housing near campus

Questions? Read the guide.

Legacy plain text (backward-compatible)

Values saved before rich text shipped have no tags — they render as text with line breaks preserved, exactly as before. No migration.

A plain paragraph. With a second line preserved.

Malicious HTML is stripped

Scripts, images, iframes, inline styles/classes/handlers, and unsafe links never render — even if they somehow reached storage.

Only this safe text survives.

bad link

Accessibility & responsive notes
  • Two sanitization passes: on API write (services) and on render (this component) — defense-in-depth for participant-facing, admin-authored HTML.
  • Allowlist mirrors the editor toolbar exactly: p / br / strong / em / h2 / h3 / ul / ol / li / blockquote / a. Links get rel="noopener noreferrer"; unsafe schemes are dropped.
  • Empty, null, or whitespace-only input renders nothing (matches the old value ? … : null pattern at each call site).

Checkbox

Native <input type="checkbox"> with accent-primary styling — fully accessible by default (keyboard, screen reader, magnifier, forced-colors). Always compose with FormField layout="inline" so the FieldLabel wraps the touch zone via htmlFor.

States

Default · required · disabled · invalid. Native checkboxes don't support read-only; use disabled instead.

Default

Anyone can view the Experience details, but only authorized participants can apply.

Required + checked

Enforced before participants can submit.

Disabled

Locked while the Session is in draft.

Invalid

You must agree before publishing.

Stack of preferences

A typical participant-preferences pattern: a column of checkboxes inside a Container.

Itinerary changes, deadline reminders, and travel advisories.

Includes your bio, photo, and emergency contact information.

Advisors can read but not comment on private journal entries.

Long label

Multi-line labels wrap; the checkbox stays aligned with the first line of text via mt-0.5.

The application cannot be re-opened after submission without an advisor's approval.

Narrow viewport

320px column. Checkbox + label row wraps cleanly; the touch zone stays large.

Participants cannot complete pre-departure without this.

Accessibility & responsive notes
  • Native <input type="checkbox"> — the most accessible primitive available. accent-primary themes the check fill without custom rendering.
  • Touch target: the visible checkbox is 20×20. The combined touch zone for toggling is met by FormField layout="inline" (min-h-11 row) plus the FieldLabel's htmlFor link — tapping anywhere on the label toggles the input.
  • Native HTML doesn't support read-only checkboxes. Use disabled when the value cannot change in the current context.
  • For an indeterminate checkbox (e.g., header of a partially-selected list), set the input's indeterminate property via a ref — not yet surfaced as a prop; deferred until a real table-selection pattern needs it.

Switch

Binary on/off control. Implemented as a native checkbox with role="switch" — modern screen readers announce it as a switch (on/off) rather than checked/unchecked. Use a Switch for instant-effect settings (toggle a feature); use a Checkbox for choices that are committed on form submit.

States

Default · on · disabled · disabled+on.

Off

When off, the catalog shows the Session but the apply button is disabled.

On

Send an in-app notification when a participant submits this Requirement.

Disabled (off)

Locked — requires Provider Portal Phase 2.

Disabled (on)

Set automatically when the Experience is published.

Settings group

Switches read densely in vertical stacks — the affordance signals an instant-effect setting, no save button needed.

Visible at /catalog. Off keeps the Session unlisted but still accessible by direct link to authorized participants.

When capacity is reached, additional applicants join a waitlist instead of being rejected.

Participants must pass eligibility before they can submit application materials.

Long label

Switch stays anchored top-left while the label wraps.

No effect on weekends or on advisor accounts that have email notifications disabled at the account level.

Narrow viewport

320px column. Switch + label remain side-by-side; label wraps below.

In-app + email when a country risk level changes.

Accessibility & responsive notes
  • Native <input type="checkbox" role="switch"> — keyboard activation via Space comes free; AT announces as a switch on / off.
  • The visual track and knob are aria-hidden — they sit behind the input via peer-checked: modifiers and don't intercept clicks.
  • Touch target: the switch visual is 24×44 (44 wide, 24 tall). The combined row in FormField layout="inline" (min-h-11) plus the htmlFor-linked FieldLabel gives the 44×44 effective touch zone per CLAUDE.md §8.1.
  • Switch vs Checkbox: switches signal instant effect; checkboxes signal "committed on submit." Don't mix them in the same form for the same kind of decision.
  • Motion: knob and track transitions respect prefers-reduced-motion.

Composition: Create Session

A realistic admin workflow composed from the existing layout primitives plus the new form primitives. PageHeader → SplitLayout → Section / Container / Stack → FormField + inputs. Form actions sit in a sticky footer-style row at the bottom of the form column.

Experience · Rome Study Abroad

New session

An instance of this Experience with its own dates, capacity, and deadlines.

Basics

Shown in admin lists and on participant invitations.

Used in URLs and reporting. Auto-generated if left blank.

Inherits from the Experience description if left blank.

Dates and capacity

Participants cannot submit after 23:59 in their local time on this date.

Maximum number of participants who can enrol.

Capacity must be a positive whole number.

Settings

Visible at /catalog. Off keeps the Session unlisted but still accessible by direct link.

When capacity is reached, additional applicants join a waitlist instead of being rejected.

Send an in-app notification to each assigned advisor.

Copy the Experience's Requirements as the starting point. You can edit or remove them on this Session afterwards.

Internal notes

Use this to record coordination details, vendor contacts, or known constraints.

Form questions (Forms M1.5)

One field-level widget per Forms question type — a step up from the raw primitives above. Each takes label / help / required and owns its own a11y wiring. Short text through Date additionally take value / onChange / error — a runtime/builder consumer never hand-wires FormField for the common case.

FileUploadQuestion and SignatureQuestion are the exception: Studio-only AUTHORING STUBS (workstream #3S) — a disabled, non-interactive "available soon" placeholder, never a real dropzone/file input or signature-capture pad, and never avalue/onChange. Real participant file upload / signature capture is a DEFERRED, escalation-gated surface per the ratified Governed-tier threat-model (CLAUDE.md §15 "Forms & the Governed tier"). Excludes image, video, and file (the presentational/media content kinds) — those remain a later, unbuilt workstream.

Not wired to any API and no builder/runtime shell yet — that's a later slice (M1.4). These are pure, controlled inputs today.

ShortTextQuestion

The short_text question type. Composes FormField + TextInput — single-line, value_type: text.

Default + error

Help text describes meaning; the error replaces it in aria-describedby priority the same way FormField does.

Default

As it appears on your passport.

Invalid

This field is required.

Accessibility & responsive notes
  • Same value shape as paragraph — they differ in constraint + widget only (storage-identity-reporting-decision.md §3.4).
  • Renders <label htmlFor> via FormField; the asterisk and aria-required come from required.

ParagraphQuestion

The paragraph question type. Composes FormField + Textarea — multi-line, same value_type: text as short_text.

Default

rows defaults to 4; grows via the native resize handle.

A few sentences is plenty.

With a word-limit hint

An optional maxWords prop renders an "N words maximum" hint (combined with any help text into one description). No Forms config wires this yet — component-level readiness only.

150 words maximum

QuestionFieldset

The shared <fieldset><legend> layout behind every GROUP-style question widget below (MultipleChoiceQuestion, CheckboxQuestion, RatingQuestion, FileUploadQuestion, SignatureQuestion) — the group counterpart to FormField's single-<label htmlFor> wiring. It owns the legend, the required marker, and help/error text wired via aria-describedby on the fieldset itself — announced once per group rather than once per option. It takes a render prop and is deliberately option-row-agnostic; every widget below supplies its own row visuals (radios, checkboxes, a disabled card). This section demos the primitive alone, with plain rows, so the disabled/read-only distinction below isn't tangled up with any one consumer's styling.

Required, with help text

The render prop hands back describedBy (for each option's aria-describedby) and idPrefix (for stable option ids) — the same two values MultipleChoiceQuestion/CheckboxQuestion thread into their own rows.

Preferred campus

Choose the campus you'd attend.

Default, disabled, and read-only

disabled dims the whole group (opacity on the fieldset, cascading to every descendant) and natively disables every control with no per-option wiring. readOnly disables the same native way — there's no native readonly for a radio/checkbox group — but skips the dim, so a submitted answer under review (admin's read-only Forms response drawer) stays at full contrast instead of looking unavailable.

Default

Preferred campus

Choose the campus you'd attend.

Disabled

Preferred campus

Read-only

Preferred campus
Accessibility & responsive notes
  • disabled sets the native disabled attribute on the <fieldset>, which cascades to every descendant control — no per-option threading required.
  • readOnly is a distinct, non-interactive review-of-an-answer mode from disabled — same native block on interaction, full contrast instead of the dim.
  • The required asterisk is visible text but aria-hidden — the group's accessible name stays exactly the label, never "label *".
  • Option-row visuals (radio/checkbox styling, ≥44px touch target, focus ring) are each consumer's own concern — see MultipleChoiceQuestion/CheckboxQuestion below for the real row treatment.

MultipleChoiceQuestion

The multiple_choice question type — single-select. value_type: single_option_ref; the answer stores the option id, never its label, so relabeling an option never rewrites history. A <fieldset><legend> groups native radios — the same pattern already used by the login role picker — rather than FormField's single-<label htmlFor> wiring, which fits one control, not a group.

Default + error

Radio rows meet the 44px touch target; the group's accessible name comes from the legend.

Default

Preferred campus

Invalid

Preferred campus

Choose one campus.

Accessibility & responsive notes
  • Uses <fieldset><legend>, not FormField — screen readers announce the group name and option count when focus enters any radio.
  • Help/error text is referenced via aria-describedby on the fieldset itself, so it's announced once per group, not once per option.

CheckboxQuestion

The checkbox question type — multi-select. value_type: multi_option_ref, modelled as one answer with N selected option ids. Composes the Checkbox primitive per option inside the same fieldset/legend group as MultipleChoiceQuestion. Not to be confused with the single Checkbox primitive above (a yes/no control) — this is a group of checkboxes answering one question.

Multi-select

Toggling an option adds/removes it from the value array; unrelated options are unaffected.

Dietary restrictions

Select all that apply — shared with housing and catering.

The dropdown question type — same value_type: single_option_ref shape as multiple_choice, different widget. Composes PhoenixSelect (CLAUDE.md §7.10) — never a native <select>.

Default

Controlled combobox inside FormField.

RatingQuestion

The rating question type — value_type: number, a numeric-scale variant. Two display variants, both a fieldset/legend group of native radios (min/max inclusive, defaults to 1–5): Stars (the default — a visually-hidden radio per star, filled 1..value, each with its own "N star(s)" accessible name) and Numbers, which keeps the radio control visible rather than hiding it behind a custom "button" skin — same rationale as Checkbox: the native control is accessible by default.

Stars (default) — with low/high captions

Optional lowLabel/highLabel captions sit under the scale.

How would you rate your orientation session?
PoorExcellent

Numbers

display="numbers" — the original numbered-box style.

How would you rate your orientation session?
PoorExcellent

0–10 (NPS-style) — wraps at narrow widths

min/max are configurable. The row wraps (flex-wrap) rather than scrolling, so an 11-point scale stays usable at 320px.

How likely are you to recommend this program to a friend?
Not at all likelyExtremely likely

DateQuestion

The date question type — value_type: date. Composes FormField + TextInput type="date" — the platform-native date picker, keyboard-operable and screen-reader-supported without a custom calendar widget.

Default

Optional min/max native bounds, e.g. to block dates outside a valid passport window.

Must be valid for at least 6 months after your return date.

FileUploadQuestion Authoring stub

The file_upload question type — value_type: asset_ref. Studio-only: a disabled, non-interactive "available soon" card via QuestionFieldset. Real participant upload is DEFERRED/escalation-gated (Governed-tier threat-model G-4, CLAUDE.md §15) — this component must never grow a value/onChange/upload prop.

Default

Required marker + help text both wire through QuestionFieldset exactly like the group widgets above; the card itself is inert.

Passport photo page

Participants will upload a file here.

PDF, DOCX, or image — up to 10 MB

Available soon

A clear scan or photo of your passport's photo page.

Accessibility & responsive notes
  • Fieldset is natively disabled — perceivable to keyboard/AT as an inert group, not just visually dimmed.
  • No file input anywhere in the DOM — nothing to click, drag, or drop.

SignatureQuestion Authoring stub

The signature question type — value_type: signature_ref. Same Studio-only inert-card posture as FileUploadQuestion above. Real signature capture — and storing the drawn signature's content at all — is DEFERRED/escalation-gated (Governed-tier threat-model G-3: biometric-in-immutable-plane, Product ruling owed).

Default

Same disabled-fieldset posture as FileUploadQuestion — a group, not a single input.

Waiver signature
Participants will sign here.Available soon

By signing, you acknowledge the terms above.

Accessibility & responsive notes
  • No <canvas> anywhere in the DOM.

Form renderer (@viatrm/form-renderer)

The shared, placement→widget dispatch for a PublishedFormDocument — mirrors @viatrm/experience-brochure's <BrochureRenderer> contract: owns placement rendering + conditional visibility + the accent theme context; the consumer owns the page container, heading, and any editor/fill chrome. Form Studio's preview canvas is the first consumer (its Edit mode is unchanged, still opting into full per-placement control via renderPlacement); the participant fill surface (apps/portal) and Studio's own Participant-preview mode are the second, meeting CLAUDE.md §3.7's second-consumer promotion test.

When no renderPlacement is supplied (the DEFAULT — both demos below), placements fold into SECTION CARDS: everything before the first Section Heading renders in one card with no label above it; each Section Heading renders as a label above its own shared card, holding every placement up to the next Section Heading; a document with no Section Heading at all renders as a single card. A Form Heading, when present, never joins a section. A section with nothing visible left after conditional filtering (including an empty section — a heading immediately followed by another, or by nothing) hides entirely rather than showing an empty card.

Four render modes, chosen entirely by which props the caller passes: uncontrolled preview (Studio's "enabled but inert" mode — real interaction against local, uncommitted state), controlled (bound to values, calls onChange — the autosave wiring), disabled (bound to values but genuinely non-interactive, rendered dimmed — the participant fill surface's submitted-confirmation view), and read-only (same non-interactive binding, but at full contrast instead of dimmed — admin's Forms response drawer, so a reviewed answer stays legible).

Uncontrolled preview (Form Studio's mode)

No values/onChange passed — every widget is genuinely interactive against local, uncommitted state. applyConditionalVisibility={false} mirrors Studio's own posture: it never live-hides a question.

As it appears on your passport.

Travel details

Preferred campus

Only shown once "Rome" is selected above.

Disabled (the participant fill surface's mode)

Bound to saved values but read-only — nothing is editable yet (autosave is a later slice). Conditional visibility is ON by default: the follow-up question shows because the bound campus answer is 'rome'.

As it appears on your passport.

Travel details

Preferred campus

Only shown once "Rome" is selected above.

Read-only (admin's response review mode)

Genuinely non-interactive, like Disabled above — but each widget renders at full contrast instead of the dimmed disabled look (a11y contrast fix). Admin's Forms response drawer uses this mode; the unanswered 'Rome trip plans' question shows a muted 'Not answered' line instead of a blank box.

As it appears on your passport.

Travel details

Preferred campus

Not answered

Only shown once "Rome" is selected above.

Conditional visibility

Toggling the campus answer re-runs the shared evaluateConditional (@viatrm/validation) — the follow-up placement appears only once 'Rome' is chosen, matching the participant runtime's real behavior.

    Accessibility & responsive notes
    • Presentational/media content (Section Heading shown above, plus Paragraph Text / Divider / Image / Video / File Download) renders its REAL content — never a schema summary — via the same VideoEmbed/ResourceCard the participant-facing brochure uses.
    • file_upload/signature always render as the Studio-only AUTHORING STUBS shown above, in every mode — see FileUploadQuestion/SignatureQuestion's own entries.
    • The two demos above (Uncontrolled preview, Disabled) supply no renderPlacement, so they show the DEFAULT section-card layout: "Legal name" sits in the leading card above the "Travel details" label, which owns its own card holding "Preferred campus" and the conditional follow-up. The Conditional visibility demo below supplies its own renderPlacement instead (the same override Form Studio's Edit mode uses for its per-question selectable cards) — an explicit opt-out of the section-card grouping.

    Campaign email renderer (@viatrm/campaign-email)

    The shared, read-only render of a resolved CampaignEmailView — the same contract shape @viatrm/experience-brochure's <BrochureRenderer> established: the package owns the email DOCUMENT (masthead, subject, preview text, the ordered block body, footer), and the consumer owns the container, any device frame, the "Live preview" chrome, and every editor affordance.

    Two committed consumers, which is CLAUDE.md §3.7's second-consumer bar: the admin Campaign builder's live preview (today) and the send path (Campaigns slice 4), which must render the author's approved content rather than a second, independently-written version of it. The renderer takes a RESOLVED view rather than a CampaignDetail precisely so the package needs no data fetching and no tenant-scoping knowledge — a programs/events block stores bare ids, and turning one into a title is a permissioned read the renderer must never perform.

    Six block kinds, and deliberately no standalone image block: an author cannot drop an arbitrary picture into a message, because there is no publicly-servable asset URL to give one (only authenticated_cacheable assets are served, and an email client fetches images unauthenticated from outside the session — issue #542). Rich text goes through @viatrm/ui's <RichText>, which re-sanitizes on render — this package contains no dangerouslySetInnerHTML of its own.

    Email HTML is not web HTML, and the WHOLE document obeys that. The frame, the masthead, the subject treatment, every body block, the cards, and the footer are nested <table role="presentation">s and block elements carrying inline styles with literal hexes — no flexbox, no grid, no CSS custom properties, no @media, and no class names — because Outlook renders through Word and supports none of them. That is also why the portal's <ExperienceCard> is not reused here however similar the two look. The palette is transcribed from tokens.css's LIGHT theme, so the document renders fixed-light in both themes — that is what an email is, and a theme-following preview would misrepresent what the recipient receives. A card's cover image is resolved by the consumer, never by the renderer: only an absolute http(s) URL is loadable in an inbox, so a managed (uploaded) cover arrives as null and the card then renders no image slot at all — never a grey placeholder, which in an inbox reads as a broken image. When #542 lands, a managed cover resolves to a public URL and starts arriving in the same field with no change here.

    Three things the design draws that Outlook cannot render, recorded rather than papered over: the 600px column is a max-width (a width="600" attribute would force horizontal scroll at 320px, which §8.1 forbids), and Word ignores max-width — so Outlook reads the message full-width. The masthead's round brand dot and the button's filled pill both need border-radius, which Word also ignores — a square dot and an accent-coloured text link. A VML-conditional "bulletproof" button needs an HTML serializer to emit the conditional comment, which is the send path's job (slice 4), not this renderer's.

    Every block kind (desktop)

    Heading, rich text, the two-up Experience card grid (one with a cover image, one without — no placeholder block, just a tighter card), an Activities list drawn as rows with a calendar mark, a divider, and a button. The undated activity renders no date line at all rather than a 'Dates TBD' placeholder, and the unpublished one is marked in words — a recipient could not open it. 'Learn more →' is styled TEXT, not a link: there is no public marketplace, and the portal is behind auth, so a real href would send a student to a login wall.

    Via University

    Applications for spring are open

    Two weeks to apply — here is what is on offer

    Somewhere new next spring

    Applications for spring placements are open until 15 August. Pick a destination, talk to an advisor, and get your paperwork started.

    Featured experiences

    The Colosseum at dusk

    Rome Study Abroad

    2 Sep 2026 – 12 Dec 2026

    Learn more →

    Tokyo Design Intensive

    11 Jan 2027 – 19 Mar 2027

    Learn more →

    Come and ask questions

    Study abroad fair

    14 Aug 2026 10:00 · Student Union, Main Hall

    Advisor drop-in (not yet published)

    Not visible to participants — a recipient could not open this.


    Start an application

    Via University

    You are receiving this because you have an account. Every message will include a way to unsubscribe.

    Mobile width

    The same content in the narrow single-column reading. Narrower, never lossier: a device toggle must not become a second, more forgetful renderer.

    Via University

    Applications for spring are open

    Two weeks to apply — here is what is on offer

    Somewhere new next spring

    Applications for spring placements are open until 15 August. Pick a destination, talk to an advisor, and get your paperwork started.

    Featured experiences

    The Colosseum at dusk

    Rome Study Abroad

    2 Sep 2026 – 12 Dec 2026

    Learn more →

    Tokyo Design Intensive

    11 Jan 2027 – 19 Mar 2027

    Learn more →

    Come and ask questions

    Study abroad fair

    14 Aug 2026 10:00 · Student Union, Main Hall

    Advisor drop-in (not yet published)

    Not visible to participants — a recipient could not open this.


    Start an application

    Via University

    You are receiving this because you have an account. Every message will include a way to unsubscribe.

    A brand-new campaign

    No subject and no blocks yet. The subject slot says 'No subject line yet' as an explicit placeholder rather than rendering an empty heading, and the empty-body copy is supplied by the CONSUMER — what to do next differs between the builder (which points at its own block picker) and the send path (which would never render an empty body at all).

    Via University

    No subject line yet

    Nothing written yet. Add a heading or a paragraph on the Content step and it will appear here.

    Via University

    You are receiving this because you have an account. Every message will include a way to unsubscribe.

    With the consumer's device toggle

    The chrome the Campaign builder's preview pane wraps the renderer in — a radiogroup, because these are two mutually exclusive readings of one thing. The toggle lives in the consumer, not the package.

    Preview width

    Via University

    Applications for spring are open

    Two weeks to apply — here is what is on offer

    Somewhere new next spring

    Applications for spring placements are open until 15 August. Pick a destination, talk to an advisor, and get your paperwork started.

    Featured experiences

    The Colosseum at dusk

    Rome Study Abroad

    2 Sep 2026 – 12 Dec 2026

    Learn more →

    Tokyo Design Intensive

    11 Jan 2027 – 19 Mar 2027

    Learn more →

    Come and ask questions

    Study abroad fair

    14 Aug 2026 10:00 · Student Union, Main Hall

    Advisor drop-in (not yet published)

    Not visible to participants — a recipient could not open this.


    Start an application

    Via University

    You are receiving this because you have an account. Every message will include a way to unsubscribe.

    Accessibility & responsive notes
    • headingLevel (2 or 3) sets the level of the subject heading; block headings render one level below it. The renderer never assumes it owns the page's <h1> — a preview embedded in a builder must not compete with the page heading, and the demos above pass 3 because this page owns an h2.
    • The masthead's organization name is a <p>, not a heading: it is branding chrome and must not appear in a screen reader's heading outline. It comes from the Client's own displayName (§8.3's whitelabel surface) — never a Via brand string.
    • A button block renders as a real <a href> with rel="noopener noreferrer", because in a delivered email that is exactly what it is. A styled <span> would make the preview lie about what the recipient receives.
    • The footer's unsubscribe line is TEXT, not a link: no unsubscribe endpoint exists (consent and preferences are deferred wholesale), and a link that 404s is worse than a statement of intent. It becomes a real link when the preference centre does.
    • There is no metric, recipient, or delivery field anywhere in the component, and nowhere to put one: it describes a MESSAGE, never its outcome. A test asserts the rendered output matches none of delivered|opened|open rate|clicks|recipients.
    • smsSegmentStats() ships from the same package — the SMS bubble is consumer chrome (a handset draws a text natively; there is no document to render), but the GSM-7 segment arithmetic is shared so the count the author sees and the count the send path reports cannot diverge. It assumes GSM-7 and therefore UNDER-counts a message containing emoji or non-Latin script, which is documented as an approximate authoring hint rather than billing information.

    SubjectPicker

    The platform’s one way to let an operator name a governed subject. A WAI-ARIA combobox with an owned listbox, arrow/Enter/Escape handling, and virtual focus via aria-activedescendant.

    Try “jan”. The third result has no resolvable label.

    Data tables

    Native <table> semantics with opinionated dense defaults. Compose DataTable, TableHeader, TableBody, TableRow, and TableCell directly — no schema-driven column config, no plugin layer. Add a toolbar, filter bar, bulk-action bar, and pagination around the table as the workflow needs them.

    Density rhythm: px-3 py-2.5 per cell; divide-y rows; no vertical borders. Selected rows tint to bg-neutral-100; hover tints to bg-neutral-50. Mobile strategy: intentional horizontal scroll on the table container, plus low-priority columns hidden via hidden sm:table-cell / md: / lg:. For deeply mobile-critical lists, render a stacked Container list at narrow widths instead of the table — see the bottom of this section.

    DataTable · TableHeader · TableRow · TableCell

    Native semantics first. Header cells use scope="col"; cells inherit text-sm with neutral-700 body text; the scrollable container is the only wrapper DataTable adds.

    Defaults — Sessions roster

    A typical operational list. Columns wrap by content; long names truncate via the consumer's column width (or by adding the truncate prop to the cell).

    SessionTermEnrolledStatus
    Rome — Fall 2026Fall 202624 / 30Published
    Rome — Spring 2027Spring 202711 / 25Published
    Rome — Fall 2027Fall 20270 / 30Draft

    Dense roster — Participants management

    More columns; same rhythm. Right-aligned numerics; status badges stay inline with the row text.

    ParticipantEmailSessionRequirementsStatus
    Alex Chenalex@school.eduRome — Fall 20264 / 5accepted
    Jamie Lopezjamie@school.eduTokyo — Spring 20272 / 5under review
    Sam Patelsam@school.eduMadrid — Fall 20265 / 5accepted
    Riley Singhriley@school.eduLondon — Summer 20270 / 5draft
    Accessibility & responsive notes
    • DataTable renders a real <table> inside an overflow-x-auto wrapper — the only intentional horizontal-scroll containment per CLAUDE.md §8.
    • Header cells default to scope="col"; pass scope="row" on the leftmost body cell to make long-row tables more navigable for screen readers.
    • Required ARIA: set aria-label or aria-labelledby on every DataTable. For the SR-only equivalent, pass srOnlyCaption.
    • Hover state is bg-neutral-50; selected is bg-neutral-100. These don't collide because a selected row stays at neutral-100 even while hovered — visually consistent.

    Selection · Indeterminate · BulkActionBar

    Selection is a composition of an existing primitive (Checkbox) inside the leftmost cell of each row plus a "select all" checkbox in the header. The header checkbox supports indeterminate when some rows are selected. The BulkActionBar appears between the toolbar and the table once at least one row is selected.

    Selectable rows with bulk actions

    Tick rows to surface the BulkActionBar. The header checkbox toggles between (none), (indeterminate when some), and (all).

    NameOwnerSessionsStatus
    Rome Study AbroadC. Park3Published
    Tokyo Faculty-LedJ. Rivera1Draft
    Madrid OrientationA. Davis2Published
    London InternshipC. Park5Published
    Accessibility & responsive notes
    • The header checkbox carries aria-label="Select all…"; each row checkbox carries the row's noun ("Select Rome Study Abroad"). Without these labels, AT users hear only "checkbox."
    • The selected-row class is bg-neutral-100. Calm — no alarming highlight — and the checkbox state plus BulkActionBar carry the signal.
    • indeterminate on the header checkbox is the native DOM property, set via an effect inside Checkbox. The mixed state is announced by AT in modern browsers without any extra ARIA.
    • BulkActionBar renders nothing when count === 0 — no layout flicker.
    • Cross-page selection ("select all 184") is deferred until a real Phoenix workflow needs it.

    SortableHeader

    A <th> with a button inside. The button is the keyboard target; the <th> carries aria-sort (ascending / descending / none) so AT users can detect the sort direction without seeing the chevron.

    Two sortable columns

    Click a header to cycle asc → desc. Inactive columns show a subdued double-chevron; the active column shows a single chevron in the current direction.

    Owner
    London InternshipC. Parkyesterday
    Madrid OrientationA. Davis3 days ago
    Rome Study AbroadC. Park2 days ago
    Tokyo Faculty-LedJ. Rivera1w ago
    Accessibility & responsive notes
    • The sort cycle is consumer-defined — most tables alternate asc → desc; some include a third "unsorted" state. The primitive just renders whatever direction the consumer passes.
    • Keyboard: Tab to focus, Enter/Space to toggle. The button has a thick focus ring that sits inside the header background so it doesn't clip on dense tables.
    • Compose SortableHeader in the same <tr> as non-sortable TableHeadCells; they share alignment and padding.

    ActionsMenu

    The kebab-button popover used for per-row actions. Always provide a per-row accessible label ("Actions for {row name}") so AT users can disambiguate identical triggers across rows.

    Default — five items with a separator and a destructive action

    Activate with mouse or keyboard. Esc closes and returns focus to the trigger; outside click closes without focus return.

    Rome Study Abroad

    Labelled trigger — the optional `trigger` prop

    A menu whose trigger says what it does, instead of a kebab. The primitive still owns the ref, aria-haspopup/expanded/controls, the toggle click and the arrow keys; the caller owns how the control looks. It deliberately does NOT force `label` onto the button as an aria-label — the visible words are the accessible name (SC 2.5.3). Used by Messages' Create New.

    One-of-N view choice — the optional `checked` prop

    Items become role=menuitemradio reporting aria-checked, for a menu that picks a VIEW rather than firing an action. Product's reason: opening it must answer 'which one am I on' — with a plain menuitem, the current value lives only in the trigger the menu has just covered up. Omit `checked` entirely for an ordinary action item; a one-way action must never announce itself as a toggle that is currently off. Used by Messages' Show and Filter.

    Unavailable but still there — the optional `ariaDisabled` prop

    Marks an item that does not apply in the caller's current state WITHOUT removing it from the menu or from the accessible tree: aria-disabled, still arrow-key reachable, onSelect inert. Distinct from `disabled` on purpose — the native attribute drops the item out of the roving focus, so a screen-reader user never learns it exists. Use `disabled` for busy/temporary; use this when the state is already visible on screen. Used by Messages' Actions menu, whose design states the rule: a menu that changes shape is harder to learn than one that greys an item.

    Inside clipping ancestors — the case that broke (#699)

    A media card whose overflow-hidden rounds its cover image, inside a scroll region. The panel is portaled with fixed positioning, so neither ancestor clips it; open the LAST card's menu to see it flip above the trigger rather than run past the region.

    Scroll region (overflow-auto) — scroll to the last card and open its menu.

    Piazza Navona at dusk
    Studio courtyard
    Cohort dinner
    Accessibility & responsive notes
    • Trigger: aria-haspopup="menu" + aria-expanded. Menu container: role="menu"; items: role="menuitem".
    • Keyboard: Down on trigger opens + focuses first item. Up/Down inside cycle items. Esc closes and returns focus to trigger. Enter/Space activates the item and closes.
    • Touch target: the visible kebab is 36×36; an invisible before:-inset-1 pseudo-element extends the click zone to 44×44 to satisfy CLAUDE.md §8.1 on mobile.
    • Items pass index sequentially so the parent can roving-focus them. Without it, arrow-key navigation falls back to no-op.
    • The panel is portaled (to <body>, or into the nearest open <dialog> when the trigger is inside a Dialog/Drawer) and position: fixed, anchored to the trigger and repositioned on scroll/resize. That is what keeps it out of an overflow ancestor's clip — the same shape as Popover and PhoenixSelect, not a third pattern.
    • Placement: below the trigger, flipping above when it does not fit; height capped to the free space so a long menu scrolls inside itself; width and offsets clamped 8px inside the viewport so nothing lands off-screen or creates horizontal scroll at 320px.
    • Type-ahead search, submenus, and roving tabindex across multiple instances on the same page are deferred to a future generic Menu primitive.

    TableToolbar · TableSearch

    The toolbar slot above the table. Left cluster typically holds search and filter triggers; right cluster holds the primary action. Wraps below 640px so all controls stay reachable at 320px.

    Toolbar with live search

    Type to filter the rows below. Live filtering, no submit. The input is type=search so the browser's native clear (×) is available.

    NameOwnerStatus
    Rome Study AbroadC. ParkPublished
    Tokyo Faculty-LedJ. RiveraDraft
    Madrid OrientationA. DavisPublished
    London InternshipC. ParkPublished
    Berlin Summer IntensiveA. DavisArchived
    Seoul Language ImmersionJ. RiveraDraft
    Cape Town Field ResearchA. DavisPublished
    Accessibility & responsive notes
    • TableSearch is a real <input type="search"> — keyboard, screen-reader, browser clear button all work natively.
    • The toolbar's start / end slots are convenient defaults; pass children instead if you want full control of the row layout.
    • For dense workflows with many filter triggers (state, owner, date range), favour an "Add filter" button that opens a popover rather than crowding the toolbar with one button per dimension.

    FilterBar · FilterChip

    The row of active filters between the toolbar and the table. Each chip is one applied filter; clicking the chip removes the filter. The whole chip is the click target — there is no separate × tap zone.

    Active filters with remove and clear-all

    Click a chip to remove that one filter; Clear all removes everything. Filters wrap to multiple rows at narrow widths.

    Filters
    Accessibility & responsive notes
    • Each FilterChip is a single <button> with a 44×44 minimum touch zone via min-h-11 — strict compliance with CLAUDE.md §8.1 on mobile.
    • The chip's accessible name is auto-prefixed with "Remove filter" when children is a string; override via aria-label on the FilterChip for rich content.
    • For very dense lists with many filter dimensions, consider an inline filter-builder popover (deferred — not yet a primitive).

    Browse filtering (SearchField · PhoenixSelect · ClearFiltersButton)

    The canonical filtering row for browse/discovery surfaces — card grids the user scans and explores, as opposed to the dense data tables above. One wrapping row: SearchField, one PhoenixSelect per filter dimension, a ClearFiltersButton when anything's active, then Sort + a result count pushed right via ml-auto. Active filters (when tracked as removable, as opposed to the toggles shown here) surface below via FilterBar/FilterChip. This is the same row every browse page should use — Admin Activities' Browse view and Portal's Experiences marketplace both compose it this way.

    Search, filters, clear, sort, and count in one row

    Type in the search box or pick a filter to see Clear filters appear. Wraps at narrow widths rather than scrolling horizontally.

    Sort
    12 experiences
    Accessibility & responsive notes
    • Controls stay quiet on purpose — no per-filter icons, no pill-shaped inputs. The cards below this row are the surface's hero; the toolbar should fade into the background.
    • Per-dimension filters render conditionally (only when more than one distinct value exists in the current data) rather than always showing every possible filter.
    • Layout wraps (flex-wrap) rather than scrolling horizontally — a horizontally-scrolling filter row has been observed to leak into document.documentElement.scrollWidth when nested inside SidebarLayout, causing page-level horizontal scroll at narrow viewports.
    • Do not invent a second version of this row with different radii, spacing, or a Popover-based filter builder for a browse/card surface — that's the pattern for dense data tables (see FilterBar above), not discovery pages.

    Pagination

    Operational prev/next pattern with a position indicator ("Showing 1–25 of 184"). Page-number lists are deferred until a real Phoenix workflow needs them (deep links, saved views).

    Prev/Next with position indicator

    Click Next/Previous to page through. Buttons disable at the edges of the range.

    NameOwner
    Rome Study AbroadC. Park
    Tokyo Faculty-LedJ. Rivera
    Madrid OrientationA. Davis
    Accessibility & responsive notes
    • Wrapped in a <nav aria-label="Pagination"> so AT users can jump to it via landmark navigation.
    • Buttons use min-h-11 — full 44×44 touch target on mobile.
    • Page-size selectors, jump-to-page input, and page-number ranges are deferred. The current API is intentionally narrow so the operational workflow drives expansion later.

    EmptyState · LoadingState

    Empty states reuse the existing EmptyState primitive inside a single <td colSpan> row. Loading states render skeleton bars in place of cell content via LoadingRow, with aria-busy="true" on the DataTable so AT users know the data is refreshing.

    Empty — no rows yet

    Use the existing EmptyState primitive inside a colSpan row. Title states what's empty; description offers the next step.

    SessionTermStatus

    No sessions yet

    Create the first Session to start enrolling participants.

    Loading — skeleton rows

    The DataTable carries aria-busy=true while LoadingRow placeholders render. The bars are aria-hidden — the busy attribute is the AT announcement.

    SessionTermEnrolledStatus

    LoadingState — single bar

    The atomic placeholder. Use directly inside any cell, in cards, in form previews, etc.

    Accessibility & responsive notes
    • Empty rows render inside the table's structural <tbody> so they share the header's column context — no special "empty" container needed.
    • The skeleton bars are aria-hidden. The single ATR announcement comes from the table's aria-busy="true" while loading.
    • Animations honor prefers-reduced-motion — the pulse stops when the user requests reduced motion.

    Responsive · narrow · stacked alternative

    At 320px, the table's scrollable container keeps it usable as a horizontal-scroll surface. For lists that are mobile-critical (participant's own dashboard, traveler portal), render a stacked Container list at narrow widths instead. Same data, different affordance.

    Column priority — hide on narrow widths

    The Experiences composition below hides Type at <md, Owner at <sm, Sessions at <md, Updated at <lg via hidden sm:table-cell etc. The Name + Status + Actions trio stays at 320px.

    NameStatus
    Rome Study AbroadPublished
    Tokyo Faculty-LedDraft

    Stacked alternative at narrow widths

    For mobile-critical lists, render a stacked Container list instead of a table. Same data; different affordance — easier to scan and tap at 320px.

    • Rome Study Abroad

      Study abroad · C. Park · 3 sessions · updated 2 days ago

      Published
    • Tokyo Faculty-Led

      Faculty-led · J. Rivera · 1 sessions · updated 1w ago

      Draft
    • Madrid Orientation

      Orientation · A. Davis · 2 sessions · updated 3 days ago

      Published
    Accessibility & responsive notes
    • Default mobile pattern: keep the DataTable, hide low-priority columns via hidden sm:table-cell / md:table-cell / lg:table-cell on both the matching TableHeadCell and TableCell.
    • For lists that are primarily consumed on mobile (e.g., the traveler portal's requirements list), render a stacked Container list instead of a table at narrow widths. The DataTable is the desktop-first surface; stacked lists are the mobile-first surface.
    • When hiding a column, hide the matching header cell too — otherwise the column count mismatches and skews colspan-based empty / loading rows.

    Composition: Experiences management

    Toolbar with search + add filter + new experience; one active filter chip; selection with bulk archive/export; sortable Name and Updated; per-row actions; pagination. Column priorities reduce density from lg to sm.

    Filters
    Experiences — sortable, filterable, and selectable.
    StatusActions
    Cape Town Field ResearchPublished
    Madrid OrientationPublished
    Rome Study AbroadPublished
    London InternshipPublished
    Accessibility & responsive notes
    • Toolbar, FilterBar, BulkActionBar, DataTable, and Pagination all sit inside a single Container variant="flush" padding="none" — each piece carries its own bottom or top border, so they connect visually without double-stacked borders.
    • The Name column is a link (<a href>). Rows are not themselves clickable — the explicit link target is more accessible and avoids conflicting with the checkbox / kebab inside the row.
    • Selection persists across pagination changes in this demo's state. For real workflows, decide whether selection should clear on filter or page change.

    Composition: Submission review queue

    The reviewer's daily working surface. Same primitives, different shape — a single status filter is pre-applied; bulk actions are approve / request changes / assign reviewer; the "all caught up" empty state replaces the row list when no rows match.

    Filters
    ParticipantStatusActions
    Alex ChenUnder review
    Jamie LopezPending
    Dakota ReyesUnder review
    Accessibility & responsive notes
    • The status column communicates lifecycle via Badge tone+dot — neutral for Withdrawn, info for Pending / Under review, warning for Needs changes, success for Approved.
    • The Reviewer column shows "Unassigned" in a softer neutral colour rather than empty — the gap is operationally meaningful (a reviewer can act on it).
    • Per-row actions are tailored to the review workflow: Open review, Approve, Request changes, Reassign. Bulk actions mirror the most common review operations.

    Surface hierarchy

    Six layers from page background to critical attention. Each layer documents the shadows, borders, backdrops, z-index, and interaction ownership it carries. Picking the right surface is what keeps the platform calm — every primitive below points to one of these surfaces.

    Z-index philosophy: we use Tailwind's preset z-30 / z-40 / z-50 rungs for non-modal overlays. Modals are native <dialog> elements that participate in the browser's top layer and sit above every z-index — no arbitrary z-index values anywhere.

    Surface 0

    App background

    Body element, page padding, the canvas underneath everything.

    Background
    --color-bg / neutral-50
    Shadow
    none
    Border
    none
    Z-index
    default (0)
    Used by
    Body / root layoutPage padding around contentEmpty regions of dashboards
    Page bg — nothing else draws on this layer

    Surface 1

    Primary content

    The page's main content surface — table rows, list items, plain text bodies.

    Background
    white
    Shadow
    none
    Border
    none or single divider
    Z-index
    default (0)
    Used by
    TableBody rowsSection body contentForm rows inside a Container
    Primary text and rows

    Surface 2

    Grouped / elevated content

    Containers and toolbars that group related content. Borders carry most of the hierarchy; shadow stays minimal.

    Background
    white
    Shadow
    shadow-sm (very subtle)
    Border
    border-neutral-200
    Z-index
    default (0)
    Used by
    Container variant=defaultTableToolbarBulkActionBarPageHeader card sub-regions

    Container

    subtle border + shadow-sm

    Surface 3

    Floating interaction surfaces

    Non-modal overlays that float above content. Light-dismiss (outside click + Esc). Focus stays where the user put it (except DropdownMenu, which roving-focuses its items).

    Background
    white
    Shadow
    shadow-md
    Border
    border-neutral-200
    Z-index
    z-30 (z-50 for Toast)
    Used by
    DropdownMenu / ActionsMenuPopoverTooltipToast (z-50 — above other Surface 3)

    Floating menu / popover

    Surface 4

    Modal / blocking surfaces

    Blocking dialogs that require a decision before continuing. The browser owns focus trap, Esc, and inert-ing the rest of the page via native <dialog> + showModal().

    Background
    white
    Shadow
    shadow-lg + backdrop scrim
    Border
    border-neutral-200
    Z-index
    native top layer (above every z-index)
    Used by
    DialogDrawerCommandPalette

    Modal dialog

    native top layer + backdrop scrim

    Surface 5

    Critical / destructive attention

    Same physical layer as Surface 4 — distinguished by intent. Destructive actions use Button variant=destructive on the primary action; the dialog surface itself stays neutral so the danger signal lives where the action lives.

    Background
    white
    Shadow
    shadow-lg + backdrop scrim
    Border
    border-neutral-200 (danger styling on the primary action only)
    Z-index
    native top layer
    Used by
    Archive / delete confirmationIrreversible publish / revoke flowsAccount removal

    Overlays

    Seven primitives that map onto Surfaces 3, 4, and 5. Modals (Dialog, Drawer, CommandPalette) use the native <dialog> element so the browser owns focus trap, Esc, top-layer stacking, and focus return. Non-modal surfaces (DropdownMenu, Popover, Tooltip) are controlled-state primitives with outside-click + Esc dismissal. Toast lives between them — non-blocking, but stacked above Surface 3 so it remains visible alongside ephemeral menus.

    Motion is restrained: backdrop fade for modals, subtle slide for Drawer and Toast. Every animation honors prefers-reduced-motion; the CSS lives in packages/ui/src/styles/overlays.css.

    Dialog

    Surface 4. Modal blocking dialog built on the native <dialog> element. The browser handles focus trap, Esc-to-close, focus return, and inert background. Backdrop click closes via the click-on-dialog-element pattern. Use for decisions that must be answered before continuing — confirmations, short forms, important questions.

    Default — Save changes?

    A typical operational confirmation. Title + description + footer with Cancel + primary. Tab cycles inside; Esc closes; focus returns to the trigger when closed.

    Save changes?

    You've made unsaved changes to this Session. Save them before continuing?

    Destructive variant — Surface 5

    Same Surface 4 substrate; the danger lives on the primary action (Button variant=destructive). The dialog body stays neutral so the warning sits where the user will click.

    Archive Rome Study Abroad?

    Archived experiences are hidden from the catalog and stop accepting new applications. You can restore them from the archive list within 30 days.

    Long body — one scrollbar, and it is the body's

    Overflowing content plus a visually-hidden live region: the pair that used to render two vertical scrollbars side by side and a band of dead space under the footer. There must be exactly one scrollbar here, the header and footer must stay pinned while it scrolls, and Tab must be able to reach the scroll region itself.

    Review the eligibility checklist

    Long enough to overflow. The body scrolls; the header and footer do not.

    Checklist item 1 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 2 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 3 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 4 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 5 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 6 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 7 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 8 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 9 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 10 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 11 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 12 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 13 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 14 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 15 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 16 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 17 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 18 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 19 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 20 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 21 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 22 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 23 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 24 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 25 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 26 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 27 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 28 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 29 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Checklist item 30 — a paragraph of body copy, repeated until the content is taller than the panel so the body has something to scroll.

    Accessibility & responsive notes
    • Focus trap is the browser's. Tab and Shift+Tab cycle inside;Esc closes and fires the native close event which we wire to onClose.
    • Backdrop click closes by checking e.target === dialog in the onClick. Clicking inside the dialog body never closes by accident.
    • Initial focus goes to the first focusable element inside the dialog. Add autoFocus to a specific element to steer it (e.g., the Cancel button for destructive dialogs).
    • No enter / exit motion on the dialog body. The backdrop fades in via @starting-style; reduced-motion users get instant.
    • Sizes: xs / sm / md (default) / lg / xl. The dialog max-height is 85vh; longer body content scrolls inside.
    • The panel column owns the 85vh cap and the body owns the only scroller. The <dialog> itself is overflow-clip — a native <dialog> defaults to overflow: auto, which made it a second, undeclared scroll container that any absolutely-positioned descendant (an sr-only live region, for instance) would spill into. clip rather than hidden: hidden still scrolls programmatically. Portaled floating panels are position: fixed and are not clipped by it.

    Drawer

    Surface 4. Same native <dialog> foundation as Dialog, anchored to the right (or left) viewport edge. Use a Drawer when the user needs to inspect or edit something without losing context — the Submission review queue + its detail view is the canonical case.

    Participant detail drawer

    Status, contact, requirement checklist, advisor notes. Footer holds Close + primary. Resize the viewport below 640px to see how it collapses to full-width on mobile.

    Alex Chen

    Rome Study Abroad · Fall 2026

    Status

    AcceptedPre-departure

    Contact

    Email
    alex@school.edu
    Phone
    +1 555 0142

    Requirements (4 of 5 complete)

    Application formApproved
    Health questionnaireApproved
    Eligibility statementApproved
    Cancellation acknowledgementApproved
    Medical clearancePending

    Advisor notes

    Accessibility & responsive notes
    • Slide-in motion is 180ms ease-out via @starting-style. Honors prefers-reduced-motion — reduced-motion users get an instant appearance.
    • Width: sm / md (default) / lg / xl — caps the drawer width but w-full on narrow viewports means the drawer fills the screen at 320px.
    • DOM is rendered into the native top layer — sits above every z-index without arbitrary values.
    • Focus trap + focus return are handled by the browser via showModal() + the dialog's native close event.
    • Backdrop dismissal fires only when the press and release both land on the backdrop — a press that begins inside the panel (text-selection drag, a native <select>/date popup whose option-click retargets to the dialog) does not close the drawer.
    • Right is the default anchor (matches Western reading direction for "more detail"). Pass side="left" when the drawer is conceptually navigation rather than detail.

    Surface 3. Labelled menu button — for toolbar menus, view-options, bulk-action groupings. Pair with ActionsMenu when you need a kebab affordance (row-level actions), and with Popover when the content is richer than a list of items.

    Two menus — left-aligned and right-aligned

    The trigger is a built-in Button; the menu opens below. Tab from a menu item closes the menu and moves focus to the next focusable element on the page.

    Accessibility & responsive notes
    • ARIA: trigger gets aria-haspopup="menu" + aria-expanded; menu is role="menu"; items are role="menuitem".
    • Keyboard: / Enter / Space on trigger opens + focuses first; opens + focuses last; / cycle inside; Home/End jump to first/last; Esc closes + returns focus to trigger; Tab closes + lets focus flow naturally.
    • Pass index sequentially on items so the parent can roving-focus them — without it, arrow-key navigation is a no-op.
    • Type-ahead, submenus, and roving focus across multiple menus on a page are deferred to a future generic Menu primitive.

    Popover

    Surface 3. Non-modal floating surface for richer content — filter builders, inline edits, picker-style choices. For action lists prefer DropdownMenu; for ephemeral hints prefer Tooltip.

    Filter popover with checkboxes

    The trigger is the consumer's element (typically a Button). Popover clones it to attach onClick + ARIA. Esc closes; outside click closes; focus stays where the user put it. The panel below sits inside an overflow-hidden frame yet is not clipped — it renders in a portal.

    Accessibility & responsive notes
    • Trigger receives onClick, aria-haspopup, aria-expanded, and aria-controls via cloneElement.
    • Content carries role="dialog" + aria-modal="false" with an aria-label from the required label prop — focus is not trapped, and Tab can leave the popover (which closes it).
    • Use the initialFocusRef prop when the popover contains a form (e.g., focus the first input on open).
    • The panel renders in a portal to document.body with position: fixed anchored to the trigger, so it is never clipped by an ancestor's overflow (a SplitView pane, a scroll container). Position recomputes on scroll/resize while open.

    Tooltip

    Surface 3. Ephemeral hint that appears on hover and on focus — CLAUDE.md §8 forbids hover-only reveals. Use Tooltip only for short, non-essential clarification; never put critical information here.

    Inline tooltips on small actions

    Hover over each button to see the tip. Tab through them to verify the same tip appears on focus — that's the test for the CLAUDE.md hover-only rule.

    Accessibility & responsive notes
    • Trigger receives aria-describedby while the tooltip is open. Closed tooltips don't leave a dangling ID on the trigger.
    • Tooltip body has role="tooltip" and is pointer-events: none — there's no way to focus a tooltip or click anything inside it.
    • Never use Tooltip for content that the user needs to act on. The trigger must always carry its own visible label (or aria-label for icon-only buttons) — the tooltip supplements, never replaces.
    • No reveal delay in v1 — instant on hover/focus. Repositioning (flip / shift near viewport edges) is deferred until a real Phoenix workflow needs it.

    Toast

    Surface 3 (z-50). Transient confirmation anchored to the bottom-right of the viewport. Wrap your app shell in <ToastProvider> and call useToast().push(...) from anywhere downstream.

    Four tones + an actionable toast

    Each button triggers a toast. Danger uses role=alert (assertive). Other tones use role=status (polite). Hover or focus a toast to pause its auto-dismiss.

    Accessibility & responsive notes
    • Region: <div role="region" aria-label="Notifications"> at the bottom-right. AT users can navigate to it via the landmarks list.
    • Per-toast: role="status" + aria-live="polite" for neutral / success / info / warning; role="alert" + aria-live="assertive" for danger.
    • Auto-dismiss: 5000ms default; duration=0 for persistent (user must dismiss). Hover or focus pauses the timer.
    • Limit: limit=4 by default — older toasts drop off as new ones arrive. No queue overflow indicator (deferred).
    • Enter motion: via-toast-enter keyframe — 150ms slide-up + fade. Reduced-motion users get an instant appearance.
    • Toasts use z-50 — sit above other Surface 3 overlays. Modals (top layer) still sit above toasts; that's acceptable because most operational toasts arrive after a modal closes.

    CommandPalette

    Surface 4. Keyboard-first quick launcher — minimal v1. Substring search, grouped listing, arrow-key navigation, Enter to activate. Open with Cmd/Ctrl+K via the useCommandShortcut hook in your app shell.

    Default — Create / Navigate / Bulk / Account

    Type to filter, ↑/↓ to navigate, Enter to activate, Esc to close. The dialog focuses the search input automatically on open.

    In production, also wire Cmd/Ctrl+K via useCommandShortcut(() => setOpen(true)) in the app shell.

    Accessibility & responsive notes
    • Same native <dialog> foundation as Dialog — focus trap + Esc + top layer are all browser-owned.
    • Search input carries aria-controls + aria-activedescendant; items are role="option" with aria-selected on the active row.
    • Substring matching only in v1. Fuzzy / scored matching, async / debounced search, per-command shortcuts, and recently-used boosting are deferred.
    • Wire the global shortcut via useCommandShortcut(() => setOpen(true)) in the app shell — opt-in, not auto-installed.
    • Mobile: works exactly like Dialog — full-width below 640px, full focus management. The shortcut is only visible to keyboard users on desktop.

    Composition: Experience management workflow

    Row ActionsMenu → destructive Dialog (Surface 5) → success Toast with Undo. The whole flow stays in one keyboard journey: Tab to the row, Enter on the kebab, ↓ to Archive…, Enter to open the confirmation, Tab to Archive, Enter to confirm. Focus returns to the row when the toast appears.

    NameStatusActions
    Rome Study AbroadPublished
    Tokyo Faculty-LedDraft
    Madrid OrientationPublished

    Archive

    Archived experiences are hidden from the catalog and stop accepting new applications. You can restore them from the archive list within 30 days.

    Accessibility & responsive notes
    • The Dialog uses variant="destructive" on the primary action — that's the Surface 5 signal. The dialog body itself stays neutral.
    • Toast tone is success (the action completed) with an Undo action so the operator can recover from a mis-click within the auto-dismiss window.
    • Focus return: when the Dialog closes (Esc, Cancel, or Archive), the browser returns focus to the row's ActionsMenu trigger automatically.

    Composition: Submission review workflow

    Filter Popover (Surface 3) → click a row → Drawer (Surface 4) with contextual actions (Approve / Request changes / Close). Approve fires a success Toast; Request changes fires a warning. The Drawer keeps the queue in view on desktop, full-screens on mobile.

    Status:
    ParticipantStatusActions
    Under review
    Pending
    Needs changes

    Submission

    Submitted

    2 days ago by

    Response

    The participant confirmed their travel dates, uploaded an up-to-date passport scan, and selected the dietary preferences shown below. Internal eligibility checks all passed.

    Reviewer notes (visible to other reviewers)

    Preferences

    Participant gets a confirmation when this submission is approved.

    Accessibility & responsive notes
    • The status Popover holds Checkboxes the user can toggle without leaving the queue context. Esc closes the popover; the underlying queue stays interactive.
    • The Drawer is anchored right. Approve / Request changes fire Toast and close the Drawer; Close exits without changes. Tooltip on "Take queue" clarifies the bulk action without crowding the toolbar copy.
    • On mobile, the Drawer fills the viewport and the row content scrolls underneath when the Drawer is closed — no horizontal scroll required because low-priority columns (Requirement) are hidden below md.

    Worklist primitives

    Foundational primitives for the canonical Worklist archetype. These power Engagement first and every future Worklist (Inbox, Submissions, Risk). The architecture lives in docs/architecture/worklist-implementation.md; the decision notes for each primitive live alongside in docs/architecture/notes/.

    Phase 0 is complete: all three foundational primitives — KeyboardScope, SplitView, and WorklistTable — are shipped. Phase 1 composes them into the Worklist shell + Inspector + workspace state hooks.

    KeyboardScope + KeyboardCheatsheet

    Stack-based keyboard shortcut system. Wrap the app once in <KeyboardScopeProvider>, then mount <KeyboardScope> wherever a workspace, row, or overlay needs its own keys. The deepest scope in the render tree wins for nested cases; siblings at the same depth resolve by mount order — so opening a modal automatically intercepts shared keys without explicit wiring. Drop a single <KeyboardCheatsheet /> nearby and ? becomes a self-documenting help surface.

    Keyboard shortcuts

    Press ? to open the cheatsheet. Try j / k, the g e sequence, or ⌘K.

    Mount a row scope to see stacking in action.

    Recently fired

    No shortcuts fired yet.

    Try this

    · Press j with row focus off vs on — different actions fire.

    · Press g alone — nothing happens. Then e within a second — sequence fires.

    · Click in the search box below, then press j — nothing fires (input has focus). Then press ⌘K — still fires (allowInInput).

    SplitView

    Resizable two-pane layout with three modes (split, primary-only, secondary-only). Slot-based — pass <SplitView.Primary> and <SplitView.Secondary> as direct children. Mode and ratio are controlled-or-uncontrolled; persistence is the consumer's responsibility (a server-backed user-preferences slice lands in Phase 2 of the Worklist plan). The drag mechanics and ARIA separator come from react-resizable-panels; the snap/reset keybindings (Enter, Esc, Home, End) and the mode wrapping are ours.

    ratio: 40%

    Drag the divider to resize. With the divider focused, press / for arrow-key resize, Enter to cycle snap points, Esc to reset, and Home / End to snap to min / max.

    Primary

    In the Worklist, this is the list pane.

    Secondary

    In the Worklist, this is the inspector.

    Try this

    · Switch modes — the ratio is preserved across toggles, so going back to Split restores your last drag position.

    · Tab to the divider, then press Enter repeatedly to cycle through 5 snap points.

    · Try Esc anywhere on the divider — ratio resets to defaultRatio (40%).

    WorklistTable

    Virtualized ARIA grid for high-frequency operational lists. Built as a div-based role="grid" because virtualizing native <tr> breaks the layout flow that screen readers expect; the WAI-ARIA grid pattern is the accepted accessible alternative for this case. Fully controlled — focus, selection, sort, and density flow from props; the table doesn't own workspace state and doesn't register global keyboard listeners. Wire j / k / x / etc. via a <KeyboardScope> at the consumer, and call the table's imperative scrollToRow(id) when the focused row leaves the viewport.

    Keyboard shortcuts

    Density:1,500 rows · 0 selected

    Click the grid, then press j / k to move focus, x to toggle selection on the focused row, Esc to clear all selection, ? for the cheatsheet. Click any column header to sort.

    #

    Try this

    · 1,500 rows are rendered — DevTools will show only ~30 in the DOM at any time.

    · Switch density — virtualizer remeasures and the visible window adjusts.

    · Hold j — the grid scrolls to keep the focused row in view (only when off-screen).

    · Click a column header twice to flip direction; aria-sort updates accordingly.

    Empty state

    When items=[] and !isLoading, the body renders the emptyState slot — the header row stays so the column structure remains visible. Use a domain-aware empty state (EmptyState compositions live in @viatrm/ui).

    Title
    Category

    No items match

    Try a broader filter or clear the search to see the full list.

    Loading state

    When isLoading is true, aria-busy is set on the grid and loadingRowCount skeleton rows render in the body (default 8). The header is unchanged so the user keeps their column context.

    Title
    Category
    Priority

    Operational awareness

    The Mission Control Home leads with attention, not records. Severity drives weight and the eyebrow word; signal is never color-only (the severity word and “Impact” text carry it). Chrome stays calm — color is spent only on meaning.

    ConsequenceCard — hero emphasis (the lead concern)

    Filled, prominent. One per severity. The action is the single interactive element.

    Critical

    ImpactHigh

    Rome Orientation

    4 of 30 registered

    The room may be nearly empty when it starts.

    Starts in 9 days

    Review enrollment

    High

    ImpactHigh

    Barcelona Housing Session

    9 of 40 registered

    Enrollment is well behind with little time left.

    Starts in 12 days

    Review enrollment

    ConsequenceCard — compact emphasis (the watch list)

    A colored left accent + the severity word; neutral body. Used in “Also worth your attention”.

    High

    ImpactHigh

    Visa Approvals

    12 pending reviews

    Due in 5 days

    Review

    Medium

    ImpactMedium

    London Safety Briefing

    2 attendance discrepancies

    Happening in 3 days

    Review

    Low

    ImpactLow

    Passport Workshop

    3 no-shows of 18 registered

    Concluded yesterday

    Follow up

    Card — a discrete bounded content object

    One spec (24px padding, 16px radius, subtle hairline, ink-tinted shadow) plus one compact role. Is the thing inside the box the point? That is a Card. A neutral Card is valid — an accent is never what qualifies something as one.

    Rome Study Abroad

    A neutral card. No accent, and therefore no bloom — that is the correct rendering, not a missing feature.

    Compact density

    20px padding, 12px radius, lighter elevation. The second of two roles.

    Danger

    The bloom and the tile both derive from this card's own--card-accent. There is no way to hand one an arbitrary colour.

    Warning

    The bloom and the tile both derive from this card's own--card-accent. There is no way to hand one an arbitrary colour.

    Info

    The bloom and the tile both derive from this card's own--card-accent. There is no way to hand one an arbitrary colour.

    Container — structural grouping chrome

    Is the box the point — it groups, it lays out, it is chrome? That is a Container. Elevation and padding are now orthogonal props; the old variant still works unchanged and is deprecated.

    surface="raised"

    Structural chrome. It groups; it is not the point.

    surface="flat"

    Structural chrome. It groups; it is not the point.

    surface="subtle"

    Structural chrome. It groups; it is not the point.

    surface="recessed"

    Structural chrome. It groups; it is not the point.

    padding is now orthogonal to surface. The old variant prop still works unchanged and is @deprecated; variant="default" points at Card, because a white bordered shadowed surface holding a content object is a card.

    ContainerHeader / ContainerFooter

    Extracted from 12 bespoke implementations. The footer's contract: the hint takes the flexible space and wraps to its own line, and the actions hold the trailing edge at every width. Neither owns a heading level or any domain copy.

    Session terms

    The consumer owns its own heading level.

    Panel body.
    The hint takes the flexible space and wraps to its own line before the actions move.

    Row / List / ListFoot

    Selection, unread, hover and activation are four separate concerns, never one boolean — the selection prop names the ARIA claim it makes, and an inert row cannot take an onClick at the type level. Dividers sit on the top of every item after the first, which is what lets the disclosure footer never leave a dangling rule.

    Tabs — in-page view switching only

    Peer views of one section, exactly one selected. No href and panels are required, so navigation and wizard progress are inexpressible rather than merely discouraged.

    Automatic activation — selection follows focus.

    Manual activation.

    Avatar

    Identity presentation, and no identity semantics — the accent is a boolean precisely so it can never encode a role or a permission.

    20 / 24 / 28 / 32 / 36px, then the identity accent, then the neutral fallback. An avatar is decorative by default — passing accessibleName is what makes it identifying, because every current call site has a visible name beside it and announcing twice is worse than not announcing.

    Skeleton

    Product's artifact specifies no loading state at all; Phoenix stays the authority. Shapes are aria-hidden scaffolding and the announcement lives on SkeletonGroup, whose label is required — a skeleton must never invent what is loading.

    Loading participants

    StatTile — a measured zero is not an unmeasurable metric

    The figure is a discriminated union, so the confident zero Phoenix has shipped twice is unrepresentable rather than discouraged. Unavailable renders no 0 and no bare dash — it renders a marker and the reason, in words.

    7

    Total campaigns

    2

    Scheduled & recurring

    0

    Automations live

    A real, measured zero. Calm — no accent, no chip, and not a button.

    Not available

    Avg. open rate

    Phoenix does not observe delivery yet, so there is nothing to average.

    Button — asChild

    Added before any Button restyle, so the 14 files that hand-copy the primary-button class string onto a link have somewhere to go instead of drifting.

    A disabled Button rendered as a link keeps its href and takes aria-disabled plus pointer-events-none — stripping the href throws in next/link, and pointer-events-none is what also closes the middle-click and ctrl+click gap a plain preventDefault guard misses.

    OutcomeGauge — the cockpit instrument

    One gauge, one truth: current vs. target. Severity tints the fill, but the count is always text — colour is never the only signal. Uncapped targets show the count with no bar.

    Registration

    4 of 30 registered

    Starts in 2 days · 26 seats still open

    Registration

    12 of 20 registered

    Starts in 3 weeks · 8 seats still open

    Registration

    26 of 30 registered

    On pace · 4 seats left

    Registration

    18 registered

    No capacity limit

    OutcomeMovement — is this getting better or worse?

    A read-only instrument: a plain-language direction (word + glyph + colour, never colour alone), the net change, and a short count log — oldest→newest, current emphasised. No chart, no sparkline, no forecast.

    Movement

    Improving

    Up 18 registrations over the last 12 days.

    8142026

    Movement

    Unchanged

    No new registrations over the last 12 days.

    4444

    Movement

    Deteriorating

    Down 3 registrations over the last 10 days.

    201917

    AudienceGapSummary — who sits inside the gap

    Identification only: expected / registered / missing, led by the gap, plus a representative sample of opaque (PII-free) handles and a “+N more” remainder. No table, no checkboxes, no actions. The all-registered state reads honestly.

    Audience

    26 not registered

    30 expected · 4 registered

    A few of them

    • P-4827
    • P-1193
    • P-7740
    • P-2056
    • +22 more

    Audience

    Everyone expected has registered. 30 expected · 30 registered

    RecommendedActionCard — what is the move?

    Modeling + presentation only: the recommended move, its reason, the expected outcome impact, and the availability state. No button, no disabled button, no “Send” — when a move can’t run yet, the card says so in plain text.

    Recommended move

    Reach unregistered travelers

    26 expected travelers have not registered.

    This action is most likely to improve enrollment.

    Messaging capability not yet available.

    Recommended move

    Follow up with no-shows

    3 travelers were marked as a no-show.

    This closes out attendance and clears the record.

    Follow-up workflow not yet available.

    OverviewBackground — atmosphere by tone

    The recessive atmospheric layer behind the Home. Shown here are the gradient fallbacks (calm / active / critical) used until art-directed photographs are configured; a scrim keeps content AA over any image. Decorative, static, never competes with content.

    calm
    active
    critical

    OverviewShell — the Home scaffold

    Greeting → verdict (the page’s h1) → lead concern → “also worth your attention” → calm-remainder footer. Layout only; the page fills the slots.

    Good morning.

    Here’s what matters today.

    We have one issue that needs your attention and two things to watch.

    Critical

    ImpactHigh

    Rome Orientation

    4 of 30 registered

    The room may be nearly empty when it starts.

    Starts in 9 days

    Review enrollment

    Also worth your attention

    Medium

    ImpactMedium

    London Safety Briefing

    2 attendance discrepancies

    Happening in 3 days

    Review

    Low

    ImpactLow

    Passport Workshop

    3 no-shows of 18 registered

    Concluded yesterday

    Follow up
    Everything else is on track — 27 activities, none need you.

    StatusCard — Phoenix status card

    Vertical status card with a spine, origin dot, and atmospheric gradient. Color is tone-mapped to danger / warning / info / neutral — but the signal is always carried in pillLabel and statusText too (color is never the sole indicator). Cards with a count of 0 receive active=false — muted opacity, no tone color. First implementation: Activities Needs Attention.

    Active state — four tones

    Inactive state — count zero

    Review

    0

    Draft activities

    No drafts pending
    Upcoming

    0

    Registration closing soon

    No upcoming deadlines
    Action needed

    0

    Missing hosts

    All hosts assigned
    Action needed

    0

    Missing resources

    Resources complete

    Studio preview controls

    Device-size + light/dark preview frame for a live content-preview canvas — apps/admin/src/components/studio/preview-controls.tsx. First built for the Experience (Brochure) Studio; Form Studio is the second consumer, promoted per CLAUDE.md §3.7 rather than forked. An admin-local, cross-feature Studio primitive (not @viatrm/ui), included here because it is interactive and now shared by two Studios.

    DeviceToggle + PreviewThemeToggle

    Desktop is fluid; Tablet/Mobile frame the canvas to a fixed max-width (DEVICE_W). The theme toggle re-resolves every --color-* token inside the framed canvas via a nested data-theme island, independent of this page's own theme or the Studio chrome's theme.

    Preview canvas

    This box re-resolves every token-based class (bg-surface, text-fg, border-border, …) to light mode, independent of the styleguide page's own theme, and is capped at 100% wide for the desktop device.

    Accessibility & responsive notes
    • Both toggles are a labelled role="group" of aria-pressed buttons — Tab moves between options, Enter/Space selects. No native <select>, no color-only signal (the pressed option gets a filled background, not just a color change).
    • Each button is a 32px hit target inside a padded group — below the 44px touch-target guidance on its own, but the group as a whole sits in a toolbar cluster meant for precise pointer/keyboard use, matching the Brochure Studio's existing toolbar density; the surrounding toolbar row keeps a comfortable overall tap area.
    • usePersistedPreviewTheme(storageKey) hydrates from localStorage after mount (never blocks first paint on a synchronous read) and persists on change — each Studio passes its own storage key so Form Studio and the Brochure Studio don't clobber each other's preference.