Roadless UI — Proposed Design System

Rollout complete — all 6 steps applied · Updated 2026-04-27

What this proposes

A consolidated theme that promotes the de-facto hardcoded values used across the codebase into named Vuetify tokens, switches the body font from the Segoe UI fallback to the Bunny-loaded Inter (and uses Merriweather Sans for display headings), and renames the three paths from Fast / Informed / Champion to General / Informed / Learn with a single shared accent color until per-path differentiation is designed.

Color palette

Tokens are grouped by role. Promoted badges mark values that move from hardcoded usage into the theme; Updated marks an existing token whose value or alias is changing.

Brand greens

Three working greens collapsed to two: primary as the main action color, primary-dark for hovers and headings on tinted blocks, plus secondary for chrome and callouts.

primary
#1e6814
Updated
Use for: All primary actions, links, focus rings, step "Next" buttons
Replaces: nuxt.config.ts #2a5c35, vuetify.ts #1B5E20, hardcoded #1e6814 (44 sites)
Consume as: color="primary"
primary-dark
#2d5a27
Promoted
Use for: Hover state for primary buttons, h3/h4 text on tinted blocks (sidebars, talking points)
Replaces: hardcoded #2d5a27 (6+ sites in Privacy, Research, About, TalkingPointsSidebar)
Consume as: color="primary-dark" or rgb(var(--v-theme-primary-dark))
secondary
#4a8c6e
Kept
Use for: Header chrome, "Why the rule matters" callout, mid-emphasis surfaces
Consume as: color="secondary"

Brand blues / chrome

Unchanged. Used for the dark navy site chrome, light-blue accent on dark backgrounds, and the teal accent.

header
#1a3a52
Kept
Use for: Dark navy chrome, page header strip background
Consume as: color="header"
header-light
#9cc8dc
Kept
Use for: Light-blue accent on top of dark navy backgrounds
Consume as: color="header-light"
accent
#5a9ab0
Kept
Use for: Teal accent for highlights, info icons
Consume as: color="accent"

Semantic

Cleaned up: success moves to a brighter green so it no longer reads as secondary; warning drops the alpha that was baked into the old hex.

success
#28a745
Updated
Use for: Success alerts, completion checkmarks
Replaces: old success #4a8c6e (identical to secondary)
Consume as: color="success" or type="success" on alerts
info
#5a9ab0
Kept
Use for: Info alerts, neutral notifications. Aliased to accent.
Consume as: type="info" on alerts
warning
#bd8908
Updated
Use for: Warning alerts, validation feedback
Replaces: old #bd8908D8 (alpha-blended hex)
Consume as: type="warning" on alerts
error
#dc3545
Kept
Use for: Error alerts, validation failures
Consume as: type="error" on alerts

Surfaces & structure

New tokens, promoted from hardcoded values that show up across many components. These primarily get consumed via CSS variables in scoped styles, not via the color prop.

panel
#f8f9fa
Promoted
Use for: Page background, inline panel backgrounds inside Engagement / KeyFacts
Replaces: hardcoded #f8f9fa (10+ sites)
Consume as: background: rgb(var(--v-theme-panel))
border
#dee2e6
Promoted
Use for: Default 1–2px borders on form fields, sidebars, mobile inputs
Replaces: hardcoded #dee2e6 (8+ sites)
Consume as: border: 1px solid rgb(var(--v-theme-border))
step-tint
#f0fff4
Promoted
Use for: Step header card background — the canonical pale-green wrapper for step titles
Replaces: hardcoded #f0fff4 (7 step components)
Consume as: color="step-tint" on v-card
sidebar-tint
#e8f5e8
Promoted
Use for: Sidebar / left-column background, talking-points gradient stop
Replaces: hardcoded #e8f5e8 (3 sites)
Consume as: color="sidebar-tint" or background: rgb(var(--v-theme-sidebar-tint))

Path accent

A single token shared by all three paths for now. When per-path differentiation is designed, this expands into path-general / path-informed / path-learn.

path
#4a8c6e
Promoted
Use for: Borders, icons, and accents on path-themed UI for General / Informed / Learn
Replaces: dead #8b4513 / #faf7f3 / #fd7e14 / #6f42c1 in global.css path rules
Consume as: color="path"

Migration map

What each currently-hardcoded value should become after the consolidation.

Current hardcoded→ New tokenSites affected
#2a5c35primarynuxt.config.ts (1) — replaced by #1e6814
#1B5E20primaryvuetify.ts (1) — entire theme block deleted
#1e6814primary44 sites — Engagement, InstructionSidebar, all step-api components, global.css, mobile.css
#2d5a27primary-dark8 sites — PrivacyPolicyView, RoadlessResearchView, AboutView, TalkingPointsSidebar, global.css
#f0fff4step-tint7 step components in components/steps/
#e8f5e8sidebar-tint3 sites — global.css, pages/comment.vue, TalkingPointsSidebar
#f8f9fapanel10+ sites — global.css body, Engagement, DemoContactInfo, PrivacyPolicy, KeyFacts
#dee2e6border8+ sites — global.css, mobile.css, NewsSidebar
#bd8908D8warningnuxt.config.ts — alpha stripped
#8b4513(deleted)global.css path rules — dead code, removed entirely
#faf7f3(deleted)global.css path rules — dead code, removed entirely
#684b30(deleted)global.css legacy .btn — replaced by v-btn migration

Live nuxt.config.ts theme block

As shipped in step 1. vuetify.ts is deleted; everything lives in nuxt.config.ts.

vuetify: {
  vuetifyOptions: {
    blueprint: md3,
    theme: {
      defaultTheme: 'light',
      themes: {
        light: {
          dark: false,
          colors: {
            // Brand greens
            primary:        '#1e6814',
            'primary-dark': '#2d5a27',
            secondary:      '#4a8c6e',

            // Brand blues / chrome
            header:         '#1a3a52',
            'header-light': '#9cc8dc',
            'header-dark':  '#1a3a52',
            accent:         '#5a9ab0',

            // Semantic
            success:        '#28a745',
            info:           '#5a9ab0',
            warning:        '#bd8908',
            error:          '#dc3545',

            // Surfaces & structure (promoted from hardcoded values)
            panel:          '#f8f9fa',
            border:         '#dee2e6',
            'step-tint':    '#f0fff4',
            'sidebar-tint': '#e8f5e8',

            // Path accent (single shared token; differentiate later)
            path:           '#4a8c6e'
          }
        }
      }
    },
    defaults: {
      VBtn: { rounded: 'xl' }
    },
    icons: { defaultSet: 'mdi' }
  },
  moduleOptions: { styles: true }
}

Path nomenclature & color

Renaming

Old name→ New nameStatus
FastGeneralExisting path, renamed
InformedInformedUnchanged
ChampionLearnRenamed; not yet developed

Color

All three paths share the single path token (currently aliased to secondary = #4a8c6e) until we design distinct treatments. This replaces the brown #8b4513 that the dead path-themed CSS was reaching for, and removes the need for the unwired body.fast-path / informed-path / champion-path selectors entirely.

General
path / #4a8c6e
Default path, fastest comment flow.
Informed
path / #4a8c6e
Adds a learning step before content selection.
Learn
path / #4a8c6e
Education-first path. Not yet developed.

Typography — Bunny fonts now live

Step 4 has shipped: vuetify.scss and global.css body now use Inter (loaded via @nuxt/fonts / Bunny). Merriweather Sans is opt-in via the .display-heading utility class for hero / page-title use — kept in the sans family for visual consistency with Inter body copy. The legacy Segoe UI references in .briefcase-indicator and the comment-preview block in Engagement.vue were also updated in this step.

Proposed assignments

RoleFamilyFallback chain
Body, all UI text, h2–h6Inter'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif
Display / hero h1, page titlesMerriweather Sans'Merriweather Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif
Code, hex valuesMonospace stackui-monospace, 'SF Mono', Menlo, monospace

Live samples (using the loaded Bunny fonts)

Defend the Roadless Rule

Display heading — Merriweather Sans 700, ~2.25rem

Why the Roadless Rule must be protected

Section heading — Inter 700, 1.5rem

Step 1: Contact information

Subsection — Inter 600, 1.25rem

The 2001 Roadless Area Conservation Rule — America's most successful forest-protection policy — faces its greatest threat in over two decades. Body copy in Inter 400 reads cleanly at 1rem with a 1.55 line-height.

Body — Inter 400, 1rem

CSS changes required

/* assets/styles/vuetify.scss — REMOVE these overrides */
- $body-font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
- $heading-font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;

/* assets/styles/global.css — UPDATE body */
body {
-  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+  font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}

/* New: display class for hero/h1 use only */
+ .display-heading {
+   font-family: 'Merriweather Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
+   font-weight: 700;
+ }

Form input wrappers

Field semantics — type, autocomplete, inputmode, format-validation rules — are codified in thin wrapper components rather than left to call sites. Browsers and phone keyboards rely on these attributes for autofill and the right keyboard layout, and the audit found that today they're either missing or inconsistent across 18 input sites.

What each wrapper locks

WrappertypeautocompleteinputmodeOtherFormat rule
RoadlessEmailFieldemailemailemailRFC-loose x@y.z
RoadlessNameField which="given"textgiven-nameRequired-only
RoadlessNameField which="family"textfamily-nameRequired-only
RoadlessNameField which="full"textnameRequired-only
RoadlessZipFieldtextpostal-codenumericmaxlength=5, pattern=[0-9]{5}, digits-only sanitize-on-input 5 digits
RoadlessPhoneFieldtelteltel≥ 10 digits anywhere
RoadlessTextInputauto-grow, rows=5, sanitize-on-emit, trim-on-blur Required-only

Sanitization — what RoadlessTextInput strips

Every value emitted by the textarea is run through a small normalization pass before reaching the model. Users can't paste content that quietly corrupts downstream submission to Regulations.gov or the email backends.

CleanupWhat & why
Control characters Strips \x00–\x08, \x0B, \x0C, \x0E–\x1F, \x7F (preserves \t and \n). These are never typed; they arrive via paste from Word / PDF / Outlook and break submission encoding.
Zero-width & bidi controls Strips U+200B–U+200D, U+2060, U+FEFF, U+202A–U+202E. Invisible characters that pasted-from-rich-text content carries; they create false-mismatch bugs in dedup and bot-detection.
Line endings\r\n and bare \r normalized to \n.
Excess blank lines Three or more consecutive newlines collapse to two (one blank line). Preserves paragraph breaks; kills copy-paste vertical cruft.
Leading / trailing whitespace Trimmed on blur only — not on every keystroke, so spaces the user is mid-typing don't disappear.
Smart quotes & em-dashesOff by default. Pass normalize-quotes to convert curly quotes ‘ ’ “ ” and em/en-dashes to ASCII equivalents. Off because Mac autocorrect generates them naturally and surprising the user is bad UX.

API conventions

  • v-model — every wrapper exposes modelValue: string and emits update:modelValue. Always returns a string (never undefined).
  • variant="outlined" is locked. There is no escape hatch. The previously inconsistent variant="filled" ZIP becomes outlined under the wrapper.
  • label and placeholder default to sensible values per field type but are overridable.
  • :rules is additive — caller-supplied rules run after the built-in required and format rules, so step-specific validation composes cleanly.
  • required, disabled, error pass through. Anything else lands on the inner v-text-field via v-bind="$attrs".

Live demo

Real wrappers, real behavior. Try invalid values to see the format rules fire; on a phone, watch the keyboard switch per field.

Live model value (post-sanitize, character-escaped):
""

Migration: ContactFormPanel.vue

Proof-of-concept diff. The canonical contact form drops ~50 lines of inline rules + sanitize logic and gains four autocomplete tokens it was missing.

- <v-text-field
-   :model-value="modelValue.firstName"
-   label="First Name"
-   placeholder="Enter your first name"
-   required
-   variant="outlined"
-   :rules="[v => !!v?.trim() || 'First name is required']"
-   @update:model-value="update('firstName', $event ?? '')"
- />
+ <RoadlessNameField
+   :model-value="modelValue.firstName"
+   which="given"
+   required
+   @update:model-value="update('firstName', $event)"
+ />

- <v-text-field
-   :model-value="modelValue.email"
-   label="Email Address"
-   placeholder="your.email@example.com"
-   type="email"
-   required
-   variant="outlined"
-   :rules="[ ... ]"
-   :error="!!(modelValue.email && !isValidEmail(modelValue.email))"
-   @update:model-value="update('email', $event ?? '')"
- />
+ <RoadlessEmailField
+   :model-value="modelValue.email"
+   required
+   @update:model-value="update('email', $event)"
+ />

- <v-text-field
-   :model-value="modelValue.zip"
-   label="ZIP Code"
-   placeholder="12345"
-   required
-   variant="filled"
-   maxlength="5"
-   :rules="[ ... ]"
-   :error="!!(modelValue.zip && !isValidZip(modelValue.zip))"
-   @input="onZipInput"
- />
+ <RoadlessZipField
+   :model-value="modelValue.zip"
+   required
+   @update:model-value="update('zip', $event)"
+ />

  // The local isValidEmail / isValidZip / onZipInput helpers and the
  // hasIncomplete computed can be deleted — rules now live in the wrappers,
  // and inline error checklist can read directly from validation state.

Migration status

Text fields — done where production-relevant:

  • components/steps/panels/ContactFormPanel.vue — 4 fields (canonical)
  • pages/contact-representatives.vue — ZIP migrated (datetime field unchanged; out of wrapper scope)
  • pages/mypostcard/contact.vue — phone + email migrated
  • pages/whats-coming.vue:140 — raw <input> got autocomplete="email" + inputmode="email" (kept as raw input — embedded in custom flex layout that the wrapper would break)
  • components/Engagement.vue — ZIP allows ZIP+4 (/^\d{5}(-\d{4})?$/), which RoadlessZipField doesn't support. Either extend the wrapper with an allowExtended prop or accept 5-digit-only behavior.
  • components/townhall/TownhallContactInfo.vue, components/demo/DemoContactInfo.vue — self-flagged "safe to delete" with the demo cleanup. Skipped.

Textareas — done:

  • components/steps/panels/PersonalStoryPanel.vue
  • components/steps/panels/PersonalConnectionPanel.vue
  • components/steps/panels/ConcernPickerPanel.vue
  • components/steps/panels/StatementPanel.vue
  • components/steps/panels/OpeningStatementsPanel.vue
  • components/steps/panels/CommentReviewPanel.vue
  • pages/townhall/admin/index.vue
  • pages/test-engagement.vue:33 — test page; skipped intentionally

Preview — step pattern with proposed tokens

Same canonical step layout as /design-system, but rendered with the proposed token values inline so we can sanity-check the look before migrating component files.

Step 1: Contact information

We'll start by collecting your contact information.

What's next

Once colors, fonts, and path naming land, the next sections to consolidate (in suggested order):

  1. Spacing & layout. Delete the legacy .mt-* / .mb-* utilities in global.css:519–529 that conflict with Vuetify's scale. Pick a canonical container width per page archetype.
  2. Buttons. Document and enforce the canonical Next/Back pair. Replace style="background:#1e6814;..." inline patterns in Engagement.vue with color="primary".
  3. Cards & panels. Move the step-header tint consumer to color="step-tint"; same for sidebars.
  4. Modals. Codify the header-bar / close-button / action-row skeleton; either ship a wrapper component or a docs snippet.
  5. Forms — migrate the 18 input sites. Wrappers ship in components/inputs/; the Form wrappers section above lists every site to swap. ContactFormPanel.vue is the canonical first migration.
  6. Path differentiation (later). When the time comes, replace the single path token with three: path-general, path-informed, path-learn.