#FFFFFF on
#E11D48
- Background
- --via-color-primary
- Foreground
- --via-color-primary-foreground
- Contrast
- 4.83:1 AA
via-quantum
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.
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).
Cascade source. Override at the Client layer; never edit directly.
#FFFFFF on
#E11D48
#FFFFFF on
#1E3A8A
#FFFFFF on
#0F766E
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
#FFFFFF on
#1E3A8A
#FFFFFF on
#0F766E
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
#FFFFFF on
#0F766E
#FFFFFF on
#7C3AED
Status meaning is consistent across all Clients — not whitelabeled.
#FFFFFF on
#15803D
#FFFFFF on
#B45309
#FFFFFF on
#B91C1C
#FFFFFF on
#1D4ED8
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-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
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.
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.
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.
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"
narrow)width="default"
default)width="wide"
wide)width="full"
full)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.
<main> by default — one per route.as only when a route legitimately has no main (rare).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.
Eyebrow + title + description + primary action. Realistic admin landing for the Experiences module.
Travel · Global
Reusable definitions of structured engagements your participants join.
Meta renders above the title (breadcrumbs, back-nav). Children render below the header — typically tabs or filter bars.
Instances of an Experience with dates, capacity, and deadlines.
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.
The everyday section header. Title left, actions right; both wrap on narrow viewports.
Published and accepting applications.
12 experiences across 4 product lines.
Use headingLevel=3 inside an existing Section. Don't skip levels — h2 → h4 confuses screen-reader outlines.
Configure the workflow for this Session.
Completed before travel start.
3 requirements
Completed while abroad.
2 requirements
aria-labelledby automatically — no extra wiring at the call site.Vertical flex with a discrete spacing scale. Use Stack instead of ad-hoc space-y-* — the scale is the rhythm of the product.
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
Stack accepts an `as` prop. Use it for semantic lists instead of styling a generic div.
gap-[…]. Pick the closest step.align controls cross-axis (items-start/center/end/stretch).Horizontal flex with the same spacing scale as Stack. Wraps by default so dense rows of chips, metadata, and actions reflow gracefully at 320px.
Status chips + sort selector. Wraps onto multiple rows at narrow viewports rather than overflowing horizontally.
A common pattern: a label on the left, actions on the right, wrapping cleanly when there's no room.
Set wrap=false only when overflow is impossible by construction (icon-only toolbars in a known-width container).
gap="sm" (8px) — tighter than Stack's default since horizontal rows read denser.justify="between" is the workhorse for header bars.A bordered surface for grouping operational content. Deliberately not called "Card" — operational UI uses surfaces for structure, not for marketing-style content cards.
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"
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
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
md; flush variants auto-drop to none.as="article" for content that semantically stands alone (e.g., one item in a list of records).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.
For an empty Experiences list, an empty Requirements panel, etc.
No experiences yet
Create your first Experience to start defining structured engagements for participants.
For empty inline lists inside a larger Section (e.g., a Session with no Requirements yet).
No requirements configured
Add a requirement to define what participants need to complete.
role="status" — empty state is static content, not a live region. Adding aria-live would cause unwanted announcements.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.
Main column owns the description and sessions table; aside owns status, ownership, and key dates.
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.
2 active · 1 draft
Fall 2026
12 / 30 enrolled · deadline May 30
Spring 2027
12 / 30 enrolled · deadline May 30
Fall 2027
12 / 30 enrolled · deadline May 30
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+.
<aside> landmark — name it via its content (no ARIA needed).asidePosition only affects visual order at lg+.asideWidth is sm (16rem) / md (20rem) / lg (24rem).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.
Realistic admin nav with the page header and a small data surface inside. Resize the viewport below 1024px to see the mobile drawer behavior.
Travel · Global
Reusable definitions of structured engagements your participants join.
12 published · 4 draft
<nav aria-label> — required so it's distinguishable from page-internal nav (tabs, breadcrumbs).aria-expanded / aria-controls; Esc closes the drawer; click on the overlay closes.prefers-reduced-motion.aria-current="page" on the active route (this primitive doesn't own routing).Top of an entity detail page (one Experience, one Session, one Participant). Sibling to PageHeader; a page uses exactly one of the two.
Back link to the parent index, eyebrow, title with status, ownership and key-date metadata, primary actions.
Experience · Study Abroad
Semester program in classical art history and modern Italian culture.
Minimum form — title + actions. Useful for shallow detail screens (e.g., settings sub-pages).
Settings
min-h-11) — full mobile tap zone, normal visual size.<dl> with dt/dd pairs — screen readers announce term/value semantics.How the primitives compose into a realistic Phoenix workflow screen. Page → DetailHeader → SplitLayout → Section / Container / Stack / Inline.
Experience · Study Abroad
Current and upcoming.
Fall 2026
12 / 30 enrolled · deadline May 30
Spring 2027
12 / 30 enrolled · deadline May 30
No requirements configured
Add a requirement to define what participants need to complete.
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).
Primary action element. Real <button>; never <div onClick>. Defaults to type="button" so it doesn't accidentally submit a form.
Six variants. Filled (primary / secondary / tertiary / destructive) use the resolved color tokens — they re-skin per Client and per product context automatically.
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.
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.
Leading and trailing icon slots. Icons are aria-hidden — the button's accessible name comes from its text children.
For modal footers, mobile CTAs, narrow form columns.
<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.min-h-11 min-w-11 on every size.<span className="sr-only">.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.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.
Status pills. Tone communicates lifecycle/state; the text label always carries meaning.
For category tags and counts where lifecycle isn't the meaning. Keep the surface calm — operational lists fill with these.
Composes inside DetailHeader status slot, table cells, list items. Padding stays tight so dense rows don't expand.
aria-hidden — screen readers announce the label only, with no duplication.<span> — drop it inside paragraphs, table cells, or the DetailHeader status slot without breaking layout.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.
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.
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.
sm (6px) for compact/inline contexts, md (10px) default.
Brand default plus semantic success / danger — always paired with caption text, never color alone. Danger is a general capability; ImageField failures transition into an actionable error state rather than lingering as a red bar.
Uploading (determinate) → Processing (indeterminate) → Saved (success). The bar only turns green after the backend confirms persistence, never merely because byte transfer hit 100%.
role="progressbar" with a required accessible name via the label prop.aria-valuenow / aria-valuemin=0 / aria-valuemax=100; indeterminate omits aria-valuenow so assistive tech announces busy, not a false number.prefers-reduced-motion — determinate width transitions are disabled; the indeterminate segment becomes a static striped fill.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.
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…
Layout density only — identical behavior, validation, and events. For tighter contexts.
Waiting for an image…
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 on both the button and the underlying file input; no hover or drag emphasis.
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…
<button> — native keyboard (Enter/Space) and focus, no custom role. No nested interactive element, so there is no button-in-button.aria-label (replacement seam). Instructions are text, never icon-only.FormField context (aria-invalid + aria-describedby). Client validation is UX only — the server ingest re-validates every byte.prefers-reduced-motion.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.
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.
allowRotate=false removes the rotate control; a single preset locks the aspect. Zoom and reset remain. Density-only configuration — behavior is unchanged.
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.
<dialog>: browser focus trap, Esc, and focus restoration. Esc / backdrop / Cancel discard — closing never silently applies edits.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.
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.
allowRotate=false removes the Rotate/Reset controls entirely — the surface becomes pure confirmation, nothing to adjust.
When the source can't be decoded, the editor shows an actionable message and Apply is disabled. Recovery is Cancel — same posture as ImageEditor.
<dialog>-backed primitives as ImageEditor: browser focus trap, Esc, and focus restoration. Esc / backdrop / Cancel discard — closing never silently applies a rotation.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.
Renders any URL with its alt text.
Constrain the frame to a consumer-defined ratio; the image fits inside.
On load failure it shows a placeholder and keeps the accessible name — never a broken-image glyph.

A null src shows a calm placeholder.
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.
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.
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.
No image confirmed yet.
`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.
`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.
`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.)
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).
A failing upload restores the previous value and offers Retry (reuses the prepared image, no re-crop) or Choose another image.
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.
Uploaded · PNG
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.
External URL
This image is hosted elsewhere. Change it to an uploaded image to edit it in Phoenix.
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.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.
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).
A typical text field. Required marker on the label; helper text below; error reserved for validation.
Visible to participants in the catalog.
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.
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.
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.
Fixed to 320px — the foundation operational width. Label and helper text wrap; input fills the column.
Shown in admin lists and on participant invitations.
<label htmlFor>; clicking it focuses the input.aria-describedby always references the description ID and adds the error ID when invalid is true.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.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.Single-line input. Native type is honored — text, email, number, tel, url, password, date — so the browser keyboard and validation behave correctly per-platform.
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.
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.
Multi-line labels stay readable. Avoid them when a short label + helper text would do.
320px column. Input fills width; label wraps.
min-h-11 (44px touch target) at every breakpoint. Compact size variants are deferred until a real Phoenix workflow needs them.aria-[invalid=true] Tailwind variant shifts the border to border-danger — driven by the FormField context.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.
Default · disabled · invalid. Controlled only (value + onChange) — compose with FormField or pass label directly for a standalone aria-label.
Default
Disabled
Invalid
@viatrm/ui after 8 call sites across Activities, Settings, and Experiences forms had already adopted it independently — the de facto standard made official.<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.placeholder prop overrides the trigger's empty-state text (defaults to "Select…") — e.g. DropdownQuestion passes "Choose…" to match the Forms Studio widget Design.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.
Default · required · optional · disabled · read-only · invalid.
Default
Required
Optional
Disabled
Read-only
Invalid
Eligibility statement is required before the Experience can be published.
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.
320px column. Textarea fills width; resize handle still works.
resize-y) — horizontal resize would break responsive columns.min-h-24 (96px) keeps the input usable from first render even before the user resizes.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.
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.
Questions? Read the guide.
Values saved before rich text shipped have no tags — they render as text with line breaks preserved, exactly as before. No migration.
Scripts, images, iframes, inline styles/classes/handlers, and unsafe links never render — even if they somehow reached storage.
Only this safe text survives.
p / br / strong / em / h2 / h3 / ul / ol / li / blockquote / a. Links get rel="noopener noreferrer"; unsafe schemes are dropped.value ? … : null pattern at each call site).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.
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.
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.
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.
320px column. Checkbox + label row wraps cleanly; the touch zone stays large.
Participants cannot complete pre-departure without this.
<input type="checkbox"> — the most accessible primitive available. accent-primary themes the check fill without custom rendering.min-h-11 row) plus the FieldLabel's htmlFor link — tapping anywhere on the label toggles the input.disabled when the value cannot change in the current context.indeterminate property via a ref — not yet surfaced as a prop; deferred until a real table-selection pattern needs it.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.
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.
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.
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.
320px column. Switch + label remain side-by-side; label wraps below.
In-app + email when a country risk level changes.
<input type="checkbox" role="switch"> — keyboard activation via Space comes free; AT announces as a switch on / off.aria-hidden — they sit behind the input via peer-checked: modifiers and don't intercept clicks.min-h-11) plus the htmlFor-linked FieldLabel gives the 44×44 effective touch zone per CLAUDE.md §8.1.prefers-reduced-motion.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
An instance of this Experience with its own dates, capacity, and deadlines.
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.
The short_text question type. Composes FormField + TextInput — single-line, value_type: text.
Help text describes meaning; the error replaces it in aria-describedby priority the same way FormField does.
Default
Invalid
paragraph — they differ in constraint + widget only (storage-identity-reporting-decision.md §3.4).<label htmlFor> via FormField; the asterisk and aria-required come from required.The paragraph question type. Composes FormField + Textarea — multi-line, same value_type: text as short_text.
rows defaults to 4; grows via the native resize handle.
A few sentences is plenty.
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
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.
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.
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
Disabled
Read-only
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.aria-hidden — the group's accessible name stays exactly the label, never "label *".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.
Radio rows meet the 44px touch target; the group's accessible name comes from the legend.
Default
Invalid
<fieldset><legend>, not FormField — screen readers announce the group name and option count when focus enters any radio.aria-describedby on the fieldset itself, so it's announced once per group, not once per option.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.
Toggling an option adds/removes it from the value array; unrelated options are unaffected.
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>.
Controlled combobox inside FormField.
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.
Optional lowLabel/highLabel captions sit under the scale.
display="numbers" — the original numbered-box style.
min/max are configurable. The row wraps (flex-wrap) rather than scrolling, so an 11-point scale stays usable at 320px.
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.
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.
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.
Required marker + help text both wire through QuestionFieldset exactly like the group widgets above; the card itself is inert.
disabled — perceivable to keyboard/AT as an inert group, not just visually dimmed.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).
Same disabled-fieldset posture as FileUploadQuestion — a group, not a single input.
<canvas> anywhere in the DOM.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).
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.
Only shown once "Rome" is selected above.
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.
Only shown once "Rome" is selected above.
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.
Not answered
Only shown once "Rome" is selected above.
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.
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.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.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.
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.
| |||||||||||
Applications for spring are openTwo weeks to apply — here is what is on offer Somewhere new next springApplications for spring placements are open until 15 August. Pick a destination, talk to an advisor, and get your paperwork started. Featured experiences
Come and ask questions
| |||||||||||
Via University You are receiving this because you have an account. Every message will include a way to unsubscribe. |
The same content in the narrow single-column reading. Narrower, never lossier: a device toggle must not become a second, more forgetful renderer.
| |||||||||||
Applications for spring are openTwo weeks to apply — here is what is on offer Somewhere new next springApplications for spring placements are open until 15 August. Pick a destination, talk to an advisor, and get your paperwork started. Featured experiences
Come and ask questions
| |||||||||||
Via University You are receiving this because you have an account. Every message will include a way to unsubscribe. |
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).
| |
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. |
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.
| |||||||||||
Applications for spring are openTwo weeks to apply — here is what is on offer Somewhere new next springApplications for spring placements are open until 15 August. Pick a destination, talk to an advisor, and get your paperwork started. Featured experiences
Come and ask questions
| |||||||||||
Via University You are receiving this because you have an account. Every message will include a way to unsubscribe. |
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.<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 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.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.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.
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.
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).
| Session | Term | Enrolled | Status |
|---|---|---|---|
| Rome — Fall 2026 | Fall 2026 | 24 / 30 | Published |
| Rome — Spring 2027 | Spring 2027 | 11 / 25 | Published |
| Rome — Fall 2027 | Fall 2027 | 0 / 30 | Draft |
More columns; same rhythm. Right-aligned numerics; status badges stay inline with the row text.
| Participant | Session | Requirements | Status | |
|---|---|---|---|---|
| Alex Chen | alex@school.edu | Rome — Fall 2026 | 4 / 5 | accepted |
| Jamie Lopez | jamie@school.edu | Tokyo — Spring 2027 | 2 / 5 | under review |
| Sam Patel | sam@school.edu | Madrid — Fall 2026 | 5 / 5 | accepted |
| Riley Singh | riley@school.edu | London — Summer 2027 | 0 / 5 | draft |
DataTable renders a real <table> inside an overflow-x-auto wrapper — the only intentional horizontal-scroll containment per CLAUDE.md §8.scope="col"; pass scope="row" on the leftmost body cell to make long-row tables more navigable for screen readers.aria-label or aria-labelledby on every DataTable. For the SR-only equivalent, pass srOnlyCaption.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 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.
Tick rows to surface the BulkActionBar. The header checkbox toggles between (none), (indeterminate when some), and (all).
| Name | Owner | Sessions | Status | |
|---|---|---|---|---|
| Rome Study Abroad | C. Park | 3 | Published | |
| Tokyo Faculty-Led | J. Rivera | 1 | Draft | |
| Madrid Orientation | A. Davis | 2 | Published | |
| London Internship | C. Park | 5 | Published |
aria-label="Select all…"; each row checkbox carries the row's noun ("Select Rome Study Abroad"). Without these labels, AT users hear only "checkbox."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.count === 0 — no layout flicker.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.
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 Internship | C. Park | yesterday |
| Madrid Orientation | A. Davis | 3 days ago |
| Rome Study Abroad | C. Park | 2 days ago |
| Tokyo Faculty-Led | J. Rivera | 1w ago |
asc → desc; some include a third "unsorted" state. The primitive just renders whatever direction the consumer passes.SortableHeader in the same <tr> as non-sortable TableHeadCells; they share alignment and padding.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.
Activate with mouse or keyboard. Esc closes and returns focus to the trigger; outside click closes without focus return.
aria-haspopup="menu" + aria-expanded. Menu container: role="menu"; items: role="menuitem".before:-inset-1 pseudo-element extends the click zone to 44×44 to satisfy CLAUDE.md §8.1 on mobile.index sequentially so the parent can roving-focus them. Without it, arrow-key navigation falls back to no-op.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.
Type to filter the rows below. Live filtering, no submit. The input is type=search so the browser's native clear (×) is available.
| Name | Owner | Status |
|---|---|---|
| Rome Study Abroad | C. Park | Published |
| Tokyo Faculty-Led | J. Rivera | Draft |
| Madrid Orientation | A. Davis | Published |
| London Internship | C. Park | Published |
| Berlin Summer Intensive | A. Davis | Archived |
| Seoul Language Immersion | J. Rivera | Draft |
| Cape Town Field Research | A. Davis | Published |
TableSearch is a real <input type="search"> — keyboard, screen-reader, browser clear button all work natively.start / end slots are convenient defaults; pass children instead if you want full control of the row layout.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.
Click a chip to remove that one filter; Clear all removes everything. Filters wrap to multiple rows at narrow widths.
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.children is a string; override via aria-label on the FilterChip for rich content.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.
Type in the search box or pick a filter to see Clear filters appear. Wraps at narrow widths rather than scrolling horizontally.
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.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).
Click Next/Previous to page through. Buttons disable at the edges of the range.
| Name | Owner |
|---|---|
| Rome Study Abroad | C. Park |
| Tokyo Faculty-Led | J. Rivera |
| Madrid Orientation | A. Davis |
<nav aria-label="Pagination"> so AT users can jump to it via landmark navigation.min-h-11 — full 44×44 touch target on mobile.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.
Use the existing EmptyState primitive inside a colSpan row. Title states what's empty; description offers the next step.
| Session | Term | Status |
|---|---|---|
No sessions yet Create the first Session to start enrolling participants. | ||
The DataTable carries aria-busy=true while LoadingRow placeholders render. The bars are aria-hidden — the busy attribute is the AT announcement.
| Session | Term | Enrolled | Status |
|---|
The atomic placeholder. Use directly inside any cell, in cards, in form previews, etc.
<tbody> so they share the header's column context — no special "empty" container needed.aria-hidden. The single ATR announcement comes from the table's aria-busy="true" while loading.prefers-reduced-motion — the pulse stops when the user requests reduced motion.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.
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.
| Name | Status |
|---|---|
| Rome Study Abroad | Published |
| Tokyo Faculty-Led | Draft |
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
Tokyo Faculty-Led
Faculty-led · J. Rivera · 1 sessions · updated 1w ago
Madrid Orientation
Orientation · A. Davis · 2 sessions · updated 3 days ago
hidden sm:table-cell / md:table-cell / lg:table-cell on both the matching TableHeadCell and TableCell.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.
| Status | Actions | ||
|---|---|---|---|
| Cape Town Field Research | Published | ||
| Madrid Orientation | Published | ||
| Rome Study Abroad | Published | ||
| London Internship | Published |
Container variant="flush" padding="none" — each piece carries its own bottom or top border, so they connect visually without double-stacked borders.<a href>). Rows are not themselves clickable — the explicit link target is more accessible and avoids conflicting with the checkbox / kebab inside the row.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.
Badge tone+dot — neutral for Withdrawn, info for Pending / Under review, warning for Needs changes, success for Approved.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
Body element, page padding, the canvas underneath everything.
Surface 1
The page's main content surface — table rows, list items, plain text bodies.
Surface 2
Containers and toolbars that group related content. Borders carry most of the hierarchy; shadow stays minimal.
Container
subtle border + shadow-sm
Surface 3
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).
Floating menu / popover
Surface 4
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().
Modal dialog
native top layer + backdrop scrim
Surface 5
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.
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.
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.
A typical operational confirmation. Title + description + footer with Cancel + primary. Tab cycles inside; Esc closes; focus returns to the trigger when closed.
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.
close event which we wire to onClose.e.target === dialog in the onClick. Clicking inside the dialog body never closes by accident.autoFocus to a specific element to steer it (e.g., the Cancel button for destructive dialogs).@starting-style; reduced-motion users get instant.xs / sm / md (default) / lg / xl. The dialog max-height is 85vh; longer body content scrolls inside.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.
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.
180ms ease-out via @starting-style. Honors prefers-reduced-motion — reduced-motion users get an instant appearance.sm / md (default) / lg / xl — caps the drawer width but w-full on narrow viewports means the drawer fills the screen at 320px.showModal() + the dialog's native close event.<select>/date popup whose option-click retargets to the dialog) does not close the drawer.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.
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.
aria-haspopup="menu" + aria-expanded; menu is role="menu"; items are role="menuitem".index sequentially on items so the parent can roving-focus them — without it, arrow-key navigation is a no-op.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.
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.
onClick, aria-haspopup, aria-expanded, and aria-controls via cloneElement.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).initialFocusRef prop when the popover contains a form (e.g., focus the first input on open).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.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.
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.
aria-describedby while the tooltip is open. Closed tooltips don't leave a dangling ID on the trigger.role="tooltip" and is pointer-events: none — there's no way to focus a tooltip or click anything inside it.aria-label for icon-only buttons) — the tooltip supplements, never replaces.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.
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.
<div role="region" aria-label="Notifications"> at the bottom-right. AT users can navigate to it via the landmarks list.role="status" + aria-live="polite" for neutral / success / info / warning; role="alert" + aria-live="assertive" for danger.duration=0 for persistent (user must dismiss). Hover or focus pauses the timer.limit=4 by default — older toasts drop off as new ones arrive. No queue overflow indicator (deferred).via-toast-enter keyframe — 150ms slide-up + fade. Reduced-motion users get an instant appearance.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.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.
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.
<dialog> foundation as Dialog — focus trap + Esc + top layer are all browser-owned.aria-controls + aria-activedescendant; items are role="option" with aria-selected on the active row.useCommandShortcut(() => setOpen(true)) in the app shell — opt-in, not auto-installed.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.
| Name | Status | Actions |
|---|---|---|
| Rome Study Abroad | Published | |
| Tokyo Faculty-Led | Draft | |
| Madrid Orientation | Published |
variant="destructive" on the primary action — that's the Surface 5 signal. The dialog body itself stays neutral.success (the action completed) with an Undo action so the operator can recover from a mis-click within the auto-dismiss window.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.
| Participant | Status | Actions |
|---|---|---|
| Under review | ||
| Pending | ||
| Needs changes |
md.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.
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.
Press ? to open the cheatsheet. Try j / k, the g e sequence, or ⌘K.
No shortcuts fired yet.
· 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).
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.
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.
In the Worklist, this is the list pane.
In the Worklist, this is the inspector.
· 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%).
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.
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.
· 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.
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).
No items match
Try a broader filter or clear the search to see the full list.
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.
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.
Filled, prominent. One per severity. The action is the single interactive element.
Critical
Rome Orientation
4 of 30 registered
The room may be nearly empty when it starts.
Starts in 9 days
Review enrollmentHigh
Barcelona Housing Session
9 of 40 registered
Enrollment is well behind with little time left.
Starts in 12 days
Review enrollmentA colored left accent + the severity word; neutral body. Used in “Also worth your attention”.
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
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
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
Audience
Everyone expected has registered. 30 expected · 30 registered
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.
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.
Greeting → verdict (the page’s h1) → lead concern → “also worth your attention” → calm-remainder footer. Layout only; the page fills the slots.
Good morning.
We have one issue that needs your attention and two things to watch.
Critical
Rome Orientation
4 of 30 registered
The room may be nearly empty when it starts.
Starts in 9 days
Review enrollmentVertical 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
0
Draft activities
0
Registration closing soon
0
Missing hosts
0
Missing resources
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.
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.
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).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.