Overview
This is the canonical reference for the visual language of the Roadless UI frontend. Every token, font, and component pattern described here is wired into the running app — the swatches and demos below render with the live theme.
Source of truth lives in nuxt.config.ts (theme tokens, font loader, Vuetify defaults) and components/inputs/ (the canonical input wrappers). Adding a new color, font, or input convention means updating those files first, then this page.
@nuxt/fonts (Bunny: Inter + Merriweather Sans) · @nuxt/icon + @mdi/font. Color tokens
Colors are organized in two layers: a primitive palette (the raw colors that exist in the app) and semantic tokens (what components actually consume, each one pointing at a primitive). The semantic layer is the source of intent — if you're building UI, you pick a semantic token, never a primitive.
Vuetify exposes every semantic token as a named option for the color prop and as a CSS variable --v-theme-{name}, consumable in scoped styles via rgb(var(--v-theme-{name})).
1 · Primitive palette
Greens
Three working greens in the brand family.
………Blues / navy
………Browns
Warm-earth family. Sits alongside the forest greens and navy blues — used for warm text, decorative highlights, and warm surface tints.
…………Status palette
Reserved hues for state communication.
………Gray scale
Five-step neutral scale, darkest → lightest. Replaces ~13 ad-hoc grays previously used across the app.
……………Surface tints
The pale background tints used for inline panels and step framing.
…………Accents
Raw colors that serve as high-contrast partners for the brand colors. Each is documented as a semantic token below and visualized with measured WCAG ratios in the Accent — contrast partners section.
…………2 · Semantic tokens
Actions
…color="primary" · rgb(var(--v-theme-primary))…rgb(var(--v-theme-primary-dark))Chrome & branding
…color="secondary"…color="header"…color="header-medium"…color="header-light"…color="header-dark"Status
…type="success" / color="success"…type="info" / color="info"…type="warning" / color="warning"…type="error" / color="error"Surfaces & structure
Consumed via the color prop on v-card or via CSS variables in scoped styles.
…background: rgb(var(--v-theme-panel))…border: 1px solid rgb(var(--v-theme-border))…color="step-tint"…color="sidebar-tint" · rgb(var(--v-theme-sidebar-tint))Text
Neutral text roles. Pick by emphasis level, not by color.
…rgb(var(--v-theme-neutral-900))…rgb(var(--v-theme-neutral-700))…rgb(var(--v-theme-neutral-600))…rgb(var(--v-theme-neutral-500))…rgb(var(--v-theme-neutral-300))Warm text & highlight
Brown-family roles for warm text, decorative highlights, and warm surface backgrounds.
…rgb(var(--v-theme-text-warm))…rgb(var(--v-theme-text-warm-strong))…color="highlight" · rgb(var(--v-theme-highlight))…color="tint-warm" · rgb(var(--v-theme-tint-warm))Path accent
A single token shared by all three paths. Splits later if per-path color differentiation is wanted.
…color="path"Contrast accents
High-contrast partners locked to a specific base color — each accent's ratio is only meaningful when paired with its base, so use it only in that pairing. Measured ratios shown in the Accent — contrast partners section below.
…color="primary-accent" · rgb(var(--v-theme-primary-accent))…rgb(var(--v-theme-primary-dark-accent))…rgb(var(--v-theme-secondary-accent))…rgb(var(--v-theme-header-accent))…rgb(var(--v-theme-header-light-accent))- Add the raw color to
nuxt.config.tsas a new theme entry with a descriptive (not role-based) name — e.g.amber-base, nothighlight. - Document it under Primitive palette above (which group it belongs to, what it looks like).
- Add a semantic token in
nuxt.config.tswith a role-based name that points at the primitive — e.g.highlight: amber-base. - Document the semantic token under Semantic tokens with its "Use for" and "Don't use for" guidance.
- Consume the semantic token in components — never the primitive.
Accent — contrast partners
A high-contrast partner for each brand color that can host one. Light accents pair with the dark bases; the one dark accent reads only on the very-light header-light. secondary is mid-tone, so its accent clears 3:1 for bold/large text only (not AA body); header-medium gets none — no accent reads on it. Each block renders the accent on its base with the measured WCAG ratio; colors are read live from the theme.
… on primary · 5.57:1 · AA… on primary-dark · 6.39:1 · AA… on secondary · 3.22:1 · bold/large only… on header · 7.39:1 · AAA… on header-light · 9.26:1 · AAA… on brown-base · 9.94:1 · AAA… on cream-light · 13.70:1 · AAATypography
Inter is the default for all body and UI text. Merriweather Sans is opt-in via the .display-heading utility for hero / page-title use. Both are loaded by @nuxt/fonts (Bunny provider).
Roles
| Role | Family | Applies via |
|---|---|---|
Body, UI, all Vuetify text-* utilities | Inter | Set on body; Vuetify's $body-font-family + $heading-font-family |
| Display / hero / page titles | Merriweather Sans | Add class="display-heading" to the element |
| Code, hex values | Monospace stack | <code> tag |
Live samples
.display-heading · Merriweather Sans 700 · 2.25rem
text-medium-emphasis.Common emphasis classes
font-weight-bold— strong emphasisfont-weight-medium— semi-emphasistext-medium-emphasis— secondary / muted body texttext-primary— primary-color text (links, headings on light surfaces)text-green-darken-3— used in step header copy on the green tint
Spacing
Vuetify's 4px-base scale (m{a,t,r,b,l,x,y}-{0..16}, pa-*) is the standard. Use it.
Container max-widths used
| Width | Used for |
|---|---|
max-width="600" | Single-column form steps (contact, statement) |
max-width="700" | Larger form steps (area picker, identity) |
max-width="800" | Standard modals (Help, Submission Guide, Path Selection) |
max-width="900" | Large dialog modals |
Iconography
Material Design Icons (MDI) via @mdi/font and @nuxt/icon. Vuetify default set is mdi. Use <v-icon> for inline icons; sizes small / default / large / 32 / 40 / 48 are all in use.
Cards & surfaces
Variants
Selectable card
Used in path selection, persona picker, concern picker, argument picker. Selected state flips to color="primary" + variant="flat".
Form input wrappers
Five wrappers in components/inputs/ codify field semantics — type, autocomplete, inputmode, format-validation rules — so browsers and phone keyboards get the right autofill and layout. The first four wrap <v-text-field>; RoadlessTextInput wraps <v-textarea> and adds automatic content sanitization.
What each wrapper locks
| Wrapper | type | autocomplete | inputmode | Other | Built-in rule |
|---|---|---|---|---|---|
RoadlessEmailField | email | email | email | — | RFC-loose x@y.z |
RoadlessNameField which="given" | text | given-name | — | — | Required-only |
RoadlessNameField which="family" | text | family-name | — | — | Required-only |
RoadlessNameField which="full" | text | name | — | — | Required-only |
RoadlessZipField | text | postal-code | numeric | maxlength=5, pattern=[0-9]{5}, digits-only sanitize-on-input | 5 digits |
RoadlessPhoneField | tel | tel | tel | — | ≥ 10 digits anywhere |
RoadlessTextInput | — | — | — | auto-grow, rows=5, sanitize-on-emit, trim-on-blur | Required-only |
API conventions
v-model—modelValue: string; always returns string (never undefined)variant="outlined"is locked — no escape hatchlabel/placeholderdefault per type but are overridable:rulescomposes additively after the built-in required + format rulesrequired/disabled/errorpass through- Anything else passes through to the inner Vuetify component via
v-bind="$attrs"
RoadlessTextInput sanitization
Every value emitted by RoadlessTextInput runs through this normalization before reaching the model:
| Cleanup | What & why |
|---|---|
| Control characters | Strips \x00–\x08, \x0B, \x0C, \x0E–\x1F, \x7F (preserves \t and \n). Pasted from Word/PDF/Outlook; breaks downstream submission encoding. |
| Zero-width & bidi controls | Strips U+200B–U+200D, U+2060, U+FEFF, U+202A–U+202E. Invisible chars from rich-text paste. |
| Line endings | \r\n / \r normalized to \n. |
| Excess blank lines | 3+ consecutive newlines collapse to 2 (one blank line). Preserves paragraph breaks. |
| Leading / trailing whitespace | Trimmed on blur only. |
| Smart quotes & em-dashes | Off by default. Pass normalize-quotes to convert to ASCII equivalents. |
Live demo
Other Vuetify inputs (no wrapper)
Use v-checkbox, v-select, etc. directly with color="primary" and outlined variants where applicable.
Alerts
variant="tonal" dominates; border="start" is used on prominent banners.
Prominent banner
Compact validation alert
- Email is required
- ZIP must be 5 digits
Modals
Six dialogs in components/modals/ share this skeleton: primary-tinted header bar, top-right close, scrollable body, action footer with primary button right-aligned.
<v-dialog v-model="open" max-width="800" scrollable>
<v-card>
<v-card-title class="bg-primary text-white">
<v-icon class="mr-2">mdi-info-outline</v-icon>
Modal title
<v-spacer />
<v-btn icon="mdi-close" variant="text" color="white" @click="close" />
</v-card-title>
<v-card-text class="pa-6"> ... </v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="elevated" @click="close">Done</v-btn>
</v-card-actions>
</v-card>
</v-dialog>Step pattern
Every step in components/steps/*Step.vue shares this three-piece structure: green-tinted header (color="step-tint") with icon + title, outlined content card with the form panel, and a card-actions row with back / next buttons. Centered with ma-auto at max-width="600" or "700".
Step 1: Contact Information
We'll start by collecting your contact information.
Layout shell
Page composition
HeaderComponent— full-bleed banner, rotating images, 200/150/100 px responsive heights, backgroundsecondary.v-container fluid— 3-column row: news sidebar (lg=3), main content (lg=6), optional resources sidebar.FooterComponentLightorFooterComponentDark.
Mobile / desktop branching
Single route per page. Each pages/*.vue imports both components/desktop/<Page>View.vue and components/mobile/<Page>View.vue and switches on useDevice().isDesktop.
<template>
<DesktopView v-if="isDesktop" ... />
<MobileView v-else ... />
</template>
<script setup>
const { isDesktop } = useDevice()
</script>Path naming
The comment flow has three paths. They currently share the single path color token (aliased to secondary); per-path color differentiation can be introduced later by splitting the token.
| Path | Status | Note |
|---|---|---|
| General | Live | Default fastest path. |
| Informed | Live | Adds a learning step before content selection. |
| Learn | Not yet developed | Education-first path (planned). |
File references
nuxt.config.ts— theme tokens, font loader, Vuetify defaults (incl.VBtn rounded='xl')assets/styles/global.css— body chrome,.display-heading, footer, briefcase indicator, accessibility media queriesassets/styles/mobile.css— mobile typography utilitiesassets/styles/vuetify.scss— Vuetify body/heading family override (Inter)components/inputs/Roadless{Email,Name,Zip,Phone}Field.vue— text-input wrapperscomponents/inputs/RoadlessTextInput.vue— textarea wrapper with sanitizationcomponents/layout/HeaderComponent.vue,FooterComponent{Light,Dark}.vue— page chromecomponents/steps/ContactInfoStep.vue— canonical step examplecomponents/modals/PathSelectionModal.vue— selectable-card pattern