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).

Via brand (locked)

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

#FFFFFF on

#E11D48

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

#FFFFFF on

#1E3A8A

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

#FFFFFF on

#0F766E

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

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

#E11D48

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

#FFFFFF on

#1E3A8A

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

#FFFFFF on

#0F766E

Background
--client-color-tertiary
Foreground
--client-color-tertiary-foreground
Contrast
4.55: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.

#FFFFFF on

#EA580C

Background
--product-global-accent
Foreground
--product-global-accent-foreground
Contrast
4.65:1 AA

#FFFFFF on

#0F766E

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

#FFFFFF on

#7C3AED

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

Semantic (locked to Via)

Status meaning is consistent across all Clients — not whitelabeled.

#FFFFFF on

#15803D

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

#FFFFFF on

#B45309

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

#FFFFFF on

#B91C1C

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

#FFFFFF on

#1D4ED8

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

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.

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

Three sizes. All sizes meet the 44×44px minimum touch target on mobile per CLAUDE.md §8.1 — sm is visually compact but still tappable.

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. One subtle pill style — color is always a redundant signal alongside the label, so the badge stays usable for color-blind readers and in forced-colors mode. Use a leading dot for at-a-glance scanning in lists.

Tones (with dot)

Status pills. Tone communicates lifecycle/state; the text label always carries meaning.

DraftUnder reviewPublishedAction requiredRejected

Tones (no dot)

For category tags and counts where lifecycle isn't the meaning. Keep the surface calm — operational lists fill with these.

Study abroadFaculty-ledTrainingEvent12 pending

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
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.

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.

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
  • One size: min-h-11 (44px touch target) at every breakpoint. Compact size variants are deferred until a real Phoenix workflow needs them.
  • 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.

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.

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.

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
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.
  • 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).

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.

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.

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

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