OnRamp UI Conventions
How to compose a polished, professional OnRamp product page on top of the design system. This is the compositional layer — the OnRamp-specific opinions about how to assemble PrimeVue + Tailwind into pages that read as a real product.
What this document is — and is not
Three layers cooperate to produce OnRamp UI; each has exactly one home:
| Layer | Source of truth | What it owns |
|---|---|---|
| Tokens | design-system/DESIGN.md | Color ramps, typography base, spacing scale, radius. Single token source — never duplicated elsewhere. |
| Preset | app/ui-organization/theme/themeRegistry.js — the variant its DEFAULT_THEME_ID names | Active Aura overrides — page ground, severity → color mapping, button.text persistent backgrounds. Follow DEFAULT_THEME_ID to the variant that renders; the layer chain lives in the UI rules. |
| Compositional patterns | This document | OnRamp-specific opinions about how to assemble tokens + components into pages. |
| Component APIs | PrimeVue docs (via @primevue/mcp) | Component props, events, slots, accessibility. We don't maintain this — defer. |
If a value lives in DESIGN.md or preset.js, never duplicate it here. This document references concepts (the bg-surface-ground semantic utility, the {4, 8, 12, 16, 24, 32, 40, 48} spacing scale) but does not own them. If those files change, this document does not need to.
If a pattern can be expressed by a PrimeVue component in its native shape, use the PrimeVue component — don't reinvent or wrap it.
The opinionated mandate (read first)
When building UI — whether from a business requirement (a ticket, a feature description) or an external design (Claude Design bundle, Figma frame, screenshot, HTML mock):
Professional > faithful. Brand and architecture compliance > visual fidelity to the source.
A requirement and a design artifact are the same class of input: both carry the intent — what data is shown, what actions exist, what decisions the screen enables — and neither dictates how to render it in OnRamp. An artifact's inline colors, custom shells, and ad-hoc layouts are not load-bearing; they're a sketch. And when there is no artifact at all, that is not a gap to fill by improvising a layout: the rendering is already decided by this document, the golden references, and the decision hierarchy.
It is expected and desirable that the resulting UI looks materially different from the source design. The source's intent is what you preserve. The rendering follows this document, DESIGN.md, the preset, and PrimeVue's native conventions.
If you find yourself trying to match a source layout exactly while violating the patterns below, the answer is to follow the patterns. The source is wrong about OnRamp.
The compositional patterns
Each section names a pattern and its intent. Concrete examples use semantic utilities and PrimeVue components by name; specific token values are out of scope (those live in DESIGN.md / preset.js).
1. Page shell anatomy
Every product page has the same structural skeleton:
- A real
<header>element with the page title, optional scope chips, and primary actions on the right. The page title is the largest text on the page. - Top-level content wrapped in
<section aria-labelledby="…">regions. Each region has a section label. - A single
<h1>matching the header title (a11y landmark). - The page body sits on the surface ground (
bg-surface-ground), not on the same surface as its cards. The contrast between ground and card is what makes cards read as elevated.
Pages are not a single <div> wrapping a wall of widgets. Use structural landmarks.
PrimeVue-first pages opt into the .prime-page shell (class on the page's scroll root; rules in app/ui-organization/theme/page-shell.scss). It carries the theme's compositional layer: border-first hairline cards and the scheme-aware link-hover step on every variant, plus — only under the Brand theme — the DM Sans typeface scope (pages + teleported overlays; legacy surfaces keep the system stack) and the aurora ground (faint Core Purple / lavender washes over --p-surface-ground, deeper on Midnight). Both are gated on data-theme='brand' in page-shell.scss, so under the default Enterprise Slate they do not render, and a page built expecting them will not match what ships. A page that skips the class renders on the plain ground with Aura's default card elevation — fine for utility screens, but product dashboards should opt in.
Opt into the compact density scale. A PrimeVue page's scroll-root setup calls usePrimePageScale() (@/composables/usePrimePageScale) — unless a shared shell already owns that call for the page, as Settings' does (§15) — which marks the document so the compact rem down-scale in app/ui-organization/theme/density.scss applies — controls render at the app's enterprise-dense tier (14px root) instead of Aura's 16px default. Every org-admin golden reference opts in; an org PrimeVue page that skips it reads oversized next to them. (Legacy routes are never rem-scaled — the flag is set only while a PrimeVue page is mounted.) The customer-portal golden references are the deliberate exception — see the next paragraph; do not read "every golden reference" as including them.
The customer-facing portal is the one deliberate exception. The v3 customer portal (app/ui-customer/pages/v3/**, portalRenderer/**) renders breathable — Aura's 16px default — and must not call usePrimePageScale. The admin portal builder (app/ui-organization/pages/portalStudio/**) does opt into compact, like every org page. This density split is the only place the portal deviates; every other pattern in this document applies to the portal exactly as to org-admin. The canonical statement of which portal surface is unified vs. frozen lives in .claude/rules/ui-design-system.md ("Default component choice").
2. Surfaces and elevation
PrimeVue's Aura preset ships card elevation by default — use it. The single biggest reason generated pages look like wireframes is shadow-none on every card and identical backgrounds for ground and cards.
- Page ground:
bg-surface-ground(the semantic utility — the preset maps this; never hardcode a shade). - Card surface: prefer the preset-themed
<Card>(its background routes through--p-content-background, which is scheme-aware). A hand-setbg-surface-0works in light but is the lightest step in Midnight — pair it with adark:variant per the dark-ramp bullet below. Inside the.prime-pageshell, cards are border-first: 1px--p-content-border-colorhairline, no shadow, 12px radius (the shell applies this — don't re-declare per card). Outside the shell, cards keep PrimeVue's default shadow. - Inset / nested cards: should read as a sub-region, not a peer — on a shadow-elevation page that means
shadow-none; in the shell, use a sunken surface step instead. - Borders:
border-surfacefor any explicit border;rounded-borderfor radius. Both semantic. - Dark ramp direction: the Midnight
surfaceramp follows Aura's dark convention —surface.0is the lightest step (text chain),surface.900the card,surface.950form fields. Numberedbg-surface-*utilities therefore flip meaning across schemes; pair them with adark:variant or use the semantic tokens (--p-content-background, ground) instead.
Whichever model a page uses, a card must read as distinct from the page ground — via hairline + ground contrast in the shell, via shadow outside it. Elevation is non-negotiable for a professional dashboard.
3. Type hierarchy
A real product page has 5+ visible text roles. Roles are named by intent; sizes use Tailwind's scale on top of DESIGN.md's 14px base and three weight levels (400 / 500 / 600).
Required roles (every dashboard or detail page needs all of them):
| Role | Intent | Distinguishing trait |
|---|---|---|
| Page title | Largest text, <h1>, in the header | Largest size, semibold with display tracking (the .prime-page shell applies weight 600 / −0.02em), text-color |
| Section label | Above each <section>, all caps, muted | The .mono-eyebrow class (theme/components.scss): IBM Plex Mono micro-label, uppercase, positive tracking, text-muted-color |
| Card title | Inside <Card>, prominent within the card | Mid-large size, semibold, text-color |
| KPI value | The number being communicated | Large+ size, bold, text-color |
| Supporting label / metadata | Above or below the value, qualifies it | Smaller, medium weight, text-muted-color |
| Body | Normal reading text | Default, text-color |
| Tiny meta | Timestamps, fine print | Smallest, text-muted-color |
Failure mode: every text block is text-base and text-sm with no dramatic contrast. The page reads as a spreadsheet.
The exact class names are guidance only — what matters is the proportional contrast. Use Tailwind's scale; don't invent.
4. KPI / stat tiles
A KPI tile communicates a number and what it means. It is not just a value and a sparkline. A real KPI tile has three visible elements:
- Label row — a muted-color label on the left, a severity-tinted icon badge on the right. The badge is rendered as a PrimeVue
<Button>with:severity+rounded+text+ an icon. This is OnRamp's answer to Sakai'sbg-blue-100 text-blue-500raw-ramp pattern — same visual outcome, but severity-driven and theme-aware. - Value row — the large bold value, optional unit, optional delta indicator. The delta carries text and color, never color-only (see §8).
- Context row — a severity-tinted highlight followed by a muted qualifier (e.g.
<span style="color: var(--p-green-500)">+24</span><span class="text-muted-color"> since last visit</span>). There is notext-successutility in this build; seeforbidden-patterns.md§4.
Severity on the badge is chosen by meaning, not color: danger for problems, warn for caution, success for good, info for neutral signals, primary for brand-flavored metrics. Most KPI tiles use info or primary for the badge; danger/success are reserved for genuine status meaning.
The "tinted icon badge as <Button :severity rounded text>" pattern is the canonical answer to the question "how do I get a Sakai-style colored icon square without using raw ramp utilities?" Always use it.
5. Charts and data visualization
- Any chart → PrimeVue
<Chart>(Chart.js under the hood). No hand-rolled<svg><path d="M…"/>— this is a hard rule. Sparklines, trend lines, bar charts, donut charts: all<Chart>. - Import that
<Chart>fromcomposites/chart/GuardedChart.js, never fromprimevue/chart—no-restricted-importsenforces it. PrimeVue builds Chart.js inside an unguarded dynamic-import callback, so a chart unmounted mid-import registers a dead entry in Chart.js's module-global registry and the next one throws aTypeErrorout of an unhandled promise. The wrapper adds the missing canvas check and a chunk-load rejection handler, and is otherwise PrimeVue's Chart.@is org-only, so the first portal orapp/onrampchart needs the wrapper moved to a shared home rather than the rule silenced. - Severity drives chart color. Mock data carries severity codes (
'success','danger', etc.); thecomposites/chart/layer (ChartCard.vue+GuardedChart.js+useChartTheme.js) resolves a code to a literal CSS value at chart-render time, re-resolving on scheme flip — render through it rather than hand-rolling the resolution. Never storevar(--p-…)or hex literals in data (forbidden-patterns.md #3). - Progress / ratio visualizations →
<ProgressBar :severity>.
Hand-rolled charts (or worse, ASCII-grid pretend charts) read as a wireframe.
6. Tabular data
- Any tabular section →
<DataTable>. Sortable, filterable, paginated tabular data never lives in a<div>-grid of rows. This is a hard rule. - Column definitions live in a sibling
columnDefs.js:{ field, header, kind, sortable, style, locked? }. The template body dispatches onkind. - Use
state-storage="local"+ astate-key. PrimeVue persists order, width, sort and filter itself — and also page offset + rows, wheneverpaginatoris on.useDataTableState(below) zeroes the restored page on reconcile so the paginator matches the mount-time page-1 fetch a table's filter hydration always does; a page not built on that composable must zero it itself, or the two will disagree on first paint. - Column visibility is managed by a
useTableColumnscomposable feeding:hidden(PrimeVue does not persist column visibility natively).useDataTableStatebelow composes it with the rest of the table's state; reach foruseTableColumnsdirectly when visibility is all a surface needs. - A paginated table surfaces its total via the paginator's built-in
CurrentPageReport: prependCurrentPageReporttopaginator-templateand setcurrent-page-report-template="Showing {first}–{last} of {totalRecords} <entity>"(e.g.projects). The user should always be able to see how many records exist, not just the current page.PrimeProjects.vueis the reference. - Persistence, visibility, row actions and open-in-new-tab are available à la carte from
app/ui-organization/components/composites/dataTable/. Take the pieces a surface needs and leave the rest — none of them wrapsDataTable, and none of them requires another:useDataTableState.js— the table's whole persisted state in one call. Pass{ key, scope, columns }(plusleadingColumnKeys/trailingColumnKeysfor any<Column>rendered outside thev-for), then bind:state-key="stateKey",@state-save="onStateSave",:key="instanceKey", and:hidden="isColumnHidden(col)"on each column; callreset()behind the page's Reset action. It reconciles the persisted blob against the live column list at setup, which is why astate-keycarries no version integer — adding a column tocolumnDefs.jsno longer costs every user their saved layout.restoredStateis the reconciled blob, for seeding a fetch composable in the same tick.useTableColumns.js— column visibility alone, for a surface that wants the:hiddenbinding and nothing else.RowActionMenu.vue+RowActionMenuTrigger.vue— the per-row overflow menu. Render the trigger from akind: 'rowActions'column body (:row-label,:expanded="menuRef?.isRowActive(data)",@open→ the menu's exposedopen(event, row)) and declare one<RowActionMenu :actions :rowTo>as a sibling of theDataTable. Items are plain descriptors —{ id, label, icon, visible, disabled, confirm }, wherelabel/iconmay be functions of the row — and the page dispatches onaction.idfrom a single@select. Prefer this over a hand-rolledPopoverof Buttons: it carries the keyboard navigation, the disabled-reason tooltip, and the two-step destructive confirm. If the page also usesuseDataTableState, declare the rowActions column's key inleadingColumnKeys/trailingColumnKeys— it is rendered outside the<Column v-for>, and the reconciler needs it named or the persisted column widths misalign.useRowLink.js— resolves a row to anhref. Bind the same route to the identity cell's<router-link :to>and toRowActionMenu'srowToprop, so ⌘/ctrl-click, middle-click and the "Open in new tab" item can't disagree about where a row goes.
- Single sort, search, reorder/resize, the paginator, and a frozen column stay plain
DataTableprops, per the bullets above — none of them needs a piece from the à la carte toolkit.
A list of repeating data items that isn't sortable/filterable can be a simple list — but if there's structure (multiple columns, sort, filter, action menu), it's a DataTable.
7. Color use philosophy
The page must read as restrained but visible.
- Brand primary (
text-primary,bg-primary) appears in 2–3 places per page — header CTA, an Aero callout, a signature KPI badge. Not everywhere. Brand color spread thin reads as noise; concentrated, it reads as identity. - Severity colors (
danger,warn,success,info) appear where status is being communicated — Tags, ProgressBars, KPI deltas, status indicators, tinted icon badges. - Tinted icon badges are the primary mechanism for "a colored thing in the corner of a card." They carry the visual interest a Sakai-style raw ramp would, without the rule violation.
text-color,text-muted-color,border-surfaceare the default-everywhere choices. Most text is one of these two; most borders are this one.
Failure mode: monochromatic. Every text is text-color, every card is bg-surface-0, every border is border-surface. The page is technically correct and visually flat. The fix is not to add raw ramp utilities — it is to use more severity (more Tag, more ProgressBar, more tinted icon badge) and selectively more primary in the 2–3 anchor positions.
8. Color-only signals are forbidden (a11y)
A status communicated only by color or only by an arrow character is broken for users who can't perceive the signal. Every state needs either:
- Visible text accompanying the signal (
<span style="color: var(--p-danger-color)">▲ +5 vs last month</span>), or - An
aria-labelcarrying the meaning, or - An
<span class="sr-only">text fallback.
The most common offender: red/green up/down arrows with no text. Always add text. (And: red is danger = problem. An "up" trend that's good is success = green, regardless of which direction the arrow points.)
9. Severity vocabulary
severity is chosen by what a state means, not what color it should be. The canonical meaning→severity table is in .claude/rules/ui-design-system.md ("Severity is meaning, not intensity") — it auto-loads on UI edits, so it's the one home for the vocabulary. This section adds only the composition nuance the table doesn't carry.
Hardcoding severity="amber" or severity="red" is wrong twice: it bypasses the preset's meaning-to-color mapping, and it locks in the color in a way that won't theme correctly. The severity → color mapping (which ramp warn resolves to, etc.) is owned by the preset — not by this document. Don't memorize it; trust it.
10. Spacing rhythm
A real dashboard reads as having intentional rhythm: bigger gaps separate kinds of content; smaller gaps separate items within a kind.
Three levels of gap, all stepped from the DESIGN.md spacing scale ({4, 8, 12, 16, 24, 32, 40, 48}):
- Between major page sections (Portfolio / Trends / Engagement / etc.): the largest step on the page. Picks from the upper end of the scale.
- Within a section, between cards in a grid: a middle step. Smaller than the section gap, large enough to read as separation.
- Inside a card: PrimeVue Card's default padding. Don't compress with
:ptunless the design genuinely demands it.
Avoid arbitrary values when a scale step fits. Off-scale only via gap-[18px]-style arbitrary values for a genuine non-scale need.
Failure mode: gap-8 everywhere. Page reads dense and chartless.
11. Density: visible but not cramped
A real Aura dashboard packs 4–8 visible widgets above the fold without feeling crowded.
- Section headers are inline strips (a section label + optional toolbar), not full
Cardheaders. - List rows use dividers (
border-b border-surface) with reasonable row padding — not aCardper row. - Cards are sized tight to content, not stretched to fill arbitrary height. (A 4-tile KPI row should not have each tile a full screen tall.)
If a page is one giant card per section with one chart inside, density is wrong. Aura dashboards have signal-per-square-inch.
12. Component choice (when in doubt, native)
If PrimeVue ships a component for the need, use it. The decision goes:
- Does the OnRamp preset / this document already say which component to use?
- If not — does PrimeVue ship a component for the need? Use that, in its native shape (props/events/slots from PrimeVue docs).
- If PrimeVue does not — last resort, write a small composite. It belongs in the
components/composites/directory grouped by domain subfolder — check the generated index first (.agents/skills/frontend-development/references/design-system/composites.md: every composite with its summary, props, and importer count) — not at the page level. Inline-comment why no PrimeVue equivalent was used. Three rules for composites: scope a layout tweak with:deep()inside the composite's own.vue— never reach for the global app preset (it touches every component app-wide); extract inline composition that recurs verbatim; and always name it (anonymous composites become unsearchable).
Never write a custom card shell (<div class="card border rounded p-4">) when <Card> exists. Never write a custom segmented control with !important when <SelectButton> exists. Never write a custom dropdown when <Select> exists.
For confirm dialogs / context menus / overflow actions: use <Popover> consistently (not <ConfirmPopup> for some and <Popover> for others). This keeps outside-click and teleport behavior consistent.
13. Dark mode without dark: variants
Semantic primeui utilities (bg-surface-0, text-color, text-muted-color, border-surface) flip automatically with the color scheme. So dark: variants should be rare — only where a value genuinely can't be expressed through a scheme-aware token.
If a generated page is littered with dark:bg-… overrides, the author didn't trust the semantic utilities. Strip them and verify the semantic utility renders correctly in both modes.
Toggle dark mode (.or-dark on <html> for org) and verify the page reads correctly without dark: adjustments.
14. Empty, loading, and error states
Each state has a default treatment:
- Empty: a
<Card>containing centered icon + headline + supporting text- optional primary action. The icon uses
text-muted-color. Empty states are a chance to set expectation — short paragraph, one CTA, nothing else.
- optional primary action. The icon uses
- Loading:
<Skeleton>placeholders matching the eventual layout — same shapes, same spacing, no spinner. A spinner is acceptable for short blocking actions (button-level) but not for page-level loads. - Error: a
<Card>(or<Message severity="error">) with the error, what to do next, and a retry action. Never a bare console error or a silent blank state.
Generated pages without these states are not done; they're a happy-path demo.
15. Settings / form pages
A settings or configuration page is form-shaped, not signal-shaped — a sequence of labeled sections the user reads and edits, not a KPI/chart dashboard. It has its own composition vocabulary (model on the settings golden reference below):
The page header is invariant. It states what the page governs — the title, a static description, and any action whose scope is the whole page — and nothing in it changes while the user stays on the page. A description that varies by tab, or an action that applies to only one tab, is not page-level: it belongs below the tab strip in that panel's own header row, a muted description plus the action cluster, and carries no heading of its own since the active
Tabalready names the panel.pages/settings/pages/accessControl/is the reference. The review test is mechanical — open the page, click through every tab, and watch whether anything in the header moves. A page description is therefore never a computed keyed on the active tab or on component state.Section card. Each settings group is a
<Card>led by a small tinted icon tile (a decorative,aria-hidden<span>on a brand/severity tint — e.g.bg-primary-50 dark:bg-primary-950withtext-primary), a title + muted description, an optional dirty<Tag>on the right, and the controls in the slot body. The reusable shape iscomposites/settings/SettingsSectionCard.vue. Note the icon tile here is a decorative<span>— distinct from §4's interactive<Button>KPI badge, because a section header is not a focusable affordance.Setting row inside a section. One setting per row: a boolean leading — including a master toggle that gates the rows below it — a label + muted description, and any non-boolean control trailing. Rows self-separate; there is no per-row divider flag. The row shape is
app/ui-organization/components/composites/settings/SettingsConfigRow.vue— reach for it before hand-rolling a flex row per setting. (The email-settings row below is the grouped per-item variant, not this one.)Dirty-state editing → one sticky save bar. When the page holds unsaved edits, surface a single sticky action bar (
sticky bottom-16, hairline border,--p-content-background) with Discard (text severity="secondary") + Save (filled,:loading) — never a per-field save button. Pair it with anonBeforeRouteLeaveleave-guardDialogand abeforeunloadhandler so edits can't be lost on navigation.First-run / empty config →
Message severity="info"above the sections, explaining what the page configures — not a blank form with no orientation.Grouped config list. When the settings are many repeated items (per-type rows), group them in collapsible
<Panel>s, each with a tinted section-anchor icon and a count, and each row a divider-separated config row with its control (e.g. aToggleSwitch) on the right. Model on the grouped-config golden reference; the row shape iscomposites/emailSettings/EmailProgramRow.vue.Inline status affordance → content-status pill. A row whose state is "system default / customized / read-only" carries the state label and its action in one clickable pill (
composites/emailSettings/ContentStatusPill.vue), not a separate Tag plus Button.Density. Settings pages opt into the compact enterprise scale exactly like the dashboards, but a page on the shared shell does nothing to get it:
pages/settings/components/SettingsPage.vuecallsusePrimePageScale()on every page's behalf, and calling it again from the page is redundant. A settings surface that sits outside the shell calls it from its own scroll root (see §1).Routed tabs (multi-page settings). When a settings page's sections are distinct routes — not a same-page panel switch (that's
pages/settings/pages/accessControl/AccessControl.vue's query-param-syncedTabs/TabPanels) — driveTabsfrom the route instead of local state:Tabs :value="route.name", oneTabper child route renderedas-childwrapping aRouterLink(bind the slot'sclass/a11yAttrs/onClickso it keeps native Tab styling, active-state, and keyboard nav), and a bareRouterViewbelowTabList— noTabPanels/TabPanel, since content is route-driven. Also bind@update:valueonTabsto a real navigation:Tab's ownSpace/Enterkeydown handling callsTabs.updateValue()directly, independent of the:valuebinding, and a mouse click only "self-heals" because the wrappedRouterLinkalso navigates natively —Spacedoes not activate an anchor natively, so without@update:valuea keyboard-only Space press desyncs the highlighted tab from the routed content.pages/settings/pages/organization/OrganizationRoot.vueis the reference; copy its shape for the other legacyOTabsNavsettings roots (Api, Email, Widget, Webhooks, DataFields, Exports, Tags, Imports) as they migrate.html<Tabs :value="route.name" @update:value="(name) => router.push({ name })"> <TabList> <Tab v-for="tab in tabs" :key="tab.routeName" :value="tab.routeName" as-child> <template #default="{ class: tabClass, a11yAttrs, onClick }"> <RouterLink :to="{ name: tab.routeName }" :class="tabClass" v-bind="a11yAttrs" @click="onClick"> {{ tab.title }} </RouterLink> </template> </Tab> </TabList> </Tabs> <RouterView />
16. Interaction containers: page, Drawer, Dialog, Popover
Where an interaction lives is a design decision, and picking wrong is the most common structural mistake in new UI — a drill-down that becomes a route loses the user's place; a create form crammed into a popover can't hold its own validation. Decide by what the interaction is, not by how big it feels:
| The interaction… | Container | Why |
|---|---|---|
| is a destination the user navigates to, links to, or reloads | Page (route) | It needs a URL. If you'd want to share or bookmark it, it's a page. |
| inspects a row / tile / card in more depth, then returns | Drawer (right side) | Preserves the list underneath and the user's scroll position. Use the DetailDrawer composite — see the drawer row in "Golden references". |
| commits something — create, edit, confirm, destructive | Dialog | Modal focus is correct when the user must finish or cancel. Holds a form, its validation, and its action row. |
| is a menu, a two-step confirm, or a small contextual affordance | Popover | Lightweight, outside-click dismissal, consistent teleport behavior. |
Rules that fall out of this:
Popoveris the confirm mechanism, notConfirmPopup— used consistently so dismissal and teleport behave the same everywhere (§12).- Drawer for inspect, Dialog for commit is the line to hold. A drawer may contain a Dialog when a commit happens inside an inspection flow (a role editor drawer raising a confirm) — that nesting is correct, not a smell.
- Don't route a drill-down. If the target is "one row of the list I'm looking at", it's a Drawer. Promote it to a page only when it has independent identity worth a URL.
- One overflow-action pattern per page. Mixing an inline menu on one row with a dialog on another for the same class of action reads as two products.
Forbidden compositions
These are page-level antipatterns. The token-level forbidden patterns (raw ramp, hex, !important, v3 component names) live in .claude/rules/ui-design-system.md; this section lists the compositional failures.
- Every card has
shadow-none— kills elevation, makes cards invisible against the ground. - Identical
bg-surface-0background on page and cards — same effect. Usebg-surface-groundon the page body. - Hand-rolled SVG charts — replace with
<Chart>. - Custom card shells (
<div>with rounded border + padding) — replace with<Card>. - Tabular data in
<div>rows — replace with<DataTable>. - Color values stored in mock data (
{ color: 'var(--p-red-500)' }or{ color: '#ff0000' }) — replace with severity codes ({ status: 'danger' }) and resolve to color at render. - KPI tile that is just a value + sparkline — add the label row with a severity-tinted icon badge, and the context row with severity + muted qualifier. Three rows minimum.
- Monochromatic page — every text
text-color, every cardbg-surface-0, every borderborder-surface. Add Tags / ProgressBars / tinted icon badges /text-primaryin 2–3 anchor positions to break it up. severityset to a color name (severity="red",severity="amber") — replace with the meaning (severity="danger",severity="warn").- One giant card per section with one chart inside — kill the wrapper card or compose multiple sub-cards inside. Density is signal.
Golden references
This registry is the single home for which page to mimic per screen shape — every other doc and skill points here rather than re-listing pages. Read the row whose shape matches your screen before generating — its structure, elevation, type hierarchy, and composition are the worked example; the principles above are the prose behind them. Defer to PrimeVue Aura's defaults where both are silent, and don't invent a new pattern to fill a gap — flag it and ask.
Routing by intent. Match what the screen is for, not what it looks like:
| The screen… | Shape |
|---|---|
| communicates status, aggregates, or trends | Dashboard |
| is a work queue the user acts from | Operational dashboard |
| is a list the user filters, sorts, and acts on in bulk | Data-list |
| is labeled config the user reads and edits | Settings |
| is a drill-down from a row or card | Drawer — not a new page |
| is customer-facing | The portal rows |
Org-admin
| Category | Reference | When to reach for it |
|---|---|---|
| Dashboard (KPI strip + charts + tables) | app/ui-organization/pages/executive-dashboard/ | The screen communicates status, aggregates, or trends — signal-first. Stacked sections of KPI tiles, charts, and bounded preview tables, a page-level period filter, and drill-downs into detail. Caveat: its data still comes from executiveData.js mock fixtures — copy the composition, not the mock-data approach. |
| Operational dashboard / work queue | app/ui-organization/pages/working-dashboard/ | The screen is what the user does next — signal plus action. The same KPI band as the executive dashboard, then work-queue cards (WorkableByMeCard, CustomerTasksOverdueCard, RecentCustomerCommentsCard) each pairing a scoped preview table with its own drill-down drawer, an activity feed, and an Aero summary row. Reach for it when the user works from the page rather than only reading it. Caveat: CustomerTaskActionCell.vue still uses the legacy OMenu for its overflow menu — use a Popover in new work (§12). |
| Data-list / index page (filter + DataTable + bulk actions) | app/ui-organization/pages/projects/modules/projectsPage/ (PrimeProjects.vue) | The screen is a data-driven list the user filters, sorts, and acts on in bulk. FilterBar + stat-card filters + a stateful DataTable + header actions. It is also the first consumer of the composites/dataTable/ state toolkit (§6) — read its DataTable block for the wiring. |
| Settings / configuration page | Form/editor: app/ui-organization/pages/settings/pages/portalAgent/PortalAgentSettings.vue · Grouped config list: app/ui-organization/pages/settings/pages/emailTemplates/EmailTemplates.vue | The screen is form-shaped config, not a signal dashboard — labeled section cards the user reads and edits (Portal Agent), or grouped per-item config rows with toggles (Email Settings). See §15 for the patterns. Shell nuance: Portal Agent uses the .prime-page shell — the model for greenfield; Email Settings hosts PrimeVue inside the legacy OPage shell (transitional, do not copy OPage for new pages). |
| Modal / drawer flows | app/ui-organization/components/composites/dataTable/DetailDrawer.vue + its consumers in executive-dashboard/components/ and working-dashboard/components/ | A drill-down from a list row, KPI tile, or card — not a route. DetailDrawer is the canonical right-side shell (standard width, title + optional #filters slot sharing the header row, loading/error/table body); every drill-down drawer on both dashboards is a thin wrapper supplying only a title, filters, and a DataTable. Use Dialog instead when the interaction is a commit (create/confirm/destructive), and Popover for a two-step confirm (§12). |
| Detail / edit page | app/ui-organization/pages/users/groups/GroupDetail.vue | The screen is a single record's own page — header with identity + primary actions, a meta line of at-a-glance facts, then tabbed sub-content synced to ?tab=. Read its header (back button, title, status Tag, Edit/Delete) and its Tabs/TabPanels wiring — the same query-sync pattern as AccessControl.vue §15 — before building another one. |
Customer portal (v3)
The portal follows every pattern in this document except density — it is breathable (§1) — and it is composed from registry-driven widgets rather than page-level markup. New portal UI is a widget in portalRenderer/**; the pages/v3/** chassis is not where widgets go (see .claude/rules/ui-design-system.md "Default component choice").
| Category | Reference | When to reach for it |
|---|---|---|
| Portal widget | app/ui-customer/portalRenderer/widgets/ — WidgetNextStep.vue (single focused CTA), WidgetTaskChecklist.vue (grouped actionable list), WidgetTimeline.vue (phase rollup) | Any new customer-facing portal content. A widget is contract-bound: it needs a widgetRegistry.js entry derived from portal_studio_contract.json, a component in widgetComponents.js, and inspector props. Model on the widget whose interaction matches, and keep it responsive via the container queries in portal-chassis.css — never viewport media queries. |
| Portal project page | app/ui-customer/pages/v3/ project route + PortalPageRenderer.vue | The customer's view of one project: branded hero, stage stepper, next-step spotlight, task checklist. Composition is builder-authored — read it to understand how widgets assemble, not to hand-author a page. |
| Portal account home | The project_grid widget composition on the v3 account home | The customer's multi-project landing surface — a grid of project cards with progress, status Tag, and next step. |
A page earns a row only if it is demonstrably the right answer for its category — it passes /design-handoff-eval without "foundation rework", and is not aspirational or "we'll fix it later." When a new PrimeVue-native page clears that bar, add it here in the same PR so the registry never lags the app.
These references draw from the shared composite layer in app/ui-organization/components/composites/ (grouped into domain subfolders — enumerate the directory for the current set) — prefer an existing composite over re-authoring a pattern.
When to use this document
- Building new UI from a business requirement (
/design-build, the common case — a ticket or feature description, no mockup): required reading, and the primary input. The "Golden references" registry above is what supplies the screen's structure when no design artifact exists. - Building new UI from a design source (
/design-buildwith an artifact — Claude Design bundle, Figma frame, screenshot): required reading before generation. The artifact supplies intent; this document supplies rendering. - Reviewing generated UI (
/design-handoff-eval): Section 6 (visual polish) audits against this document. - Manually authoring or modifying UI in
app/ui-organization/**: this document is referenced by the auto-loaded.claude/rules/ui-design-system.md; it does not auto-load itself — read it when composing a page. - Authoring the v3 customer portal (
pages/v3/**,portalRenderer/**) or the admin portal builder (portalStudio/**): same patterns apply — with the breathable-density exception in §1. Legacypages/v2/**/pages/private/**are out of scope (frozen; see the rule).
When this document conflicts with an external design source: this document wins. When this document conflicts with DESIGN.md or the preset: those files win — but flag the conflict, because it means this document needs to update.