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/preset.js | Active Aura overrides — page ground, severity → color mapping, button.text persistent backgrounds. |
| 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 generating or building UI from an external design (Claude Design bundle, Figma frame, screenshot, HTML mock):
Professional > faithful. Brand and architecture compliance > visual fidelity to the source.
The external design is a business-requirements doc: it tells you what data is shown, what actions exist, what decisions the screen enables. It does not tell you how to render that in OnRamp. The bundle's inline colors, custom shells, and ad-hoc layouts are not load-bearing — they're a sketch.
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: the DM Sans typeface scope (pages + teleported overlays — legacy surfaces keep the system stack), the aurora ground (faint Core Purple / lavender washes over --p-surface-ground, deeper on Midnight), border-first hairline cards, and the scheme-aware link-hover step. 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), 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 golden reference opts in; a 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.)
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 class="text-success">+24</span><span class="text-muted-color"> since last visit</span>).
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>. - Severity drives chart color. Mock data carries severity codes (
'success','danger', etc.); thecomposites/chart/layer (ChartCard.vue+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-keyso order, width, sort, filter, and page persist. - Column visibility is managed by a
useTableColumnscomposable feeding:hidden(PrimeVue does not persist column visibility natively). - 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.
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 class="text-danger">▲ +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 (kpi/,chart/,dataTable/,filterBar/,stats/,emailSettings/, … — enumerate the directory for the current set), 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):
- 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 ispages/settings/pages/portalAgent/components/PortalAgentSectionCard.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. - 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 — call
usePrimePageScale()from the page's scroll root (see §1).
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.
| 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. |
| 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. |
| 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 (section card, sticky save bar + leave guard, first-run Message, grouped config list, content-status pill). 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). |
| Detail / edit page | (none yet) | Add when one ships. |
| Modal / drawer flows | (none yet) | Add when one ships. |
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.
These references draw from the shared composite layer in app/ui-organization/components/composites/ (domain subfolders like kpi/, chart/, dataTable/, filterBar/, stats/, emailSettings/ — enumerate the directory for the current set rather than trusting this list) — prefer an existing composite over re-authoring a pattern.
When to use this document
- Generating new UI from a design source (
/design-handoff-build): required reading before generation. - 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.
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.