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 tokens — consolidate the three competing greens, promote hardcoded surface/border values into named tokens.
- Typography — actually use the Bunny fonts that are already loaded.
- Path nomenclature — rename to General / Informed / Learn, single shared color for now.
- Components, spacing, buttons, modal patterns — covered next.
nuxt.config.ts (full palette + md3 blueprint + VBtn rounded='xl'); orphaned vuetify.ts deleted. 2. Color migration — 64
#1e6814 callsites swapped to color="primary" / rgb(var(--v-theme-primary)). 3. Input wrappers — 5 components shipped to
components/inputs/; ContactFormPanel, contact-representatives, mypostcard/contact, and 7 step-panel textareas migrated. 4. Font swap — body and Vuetify utilities now Inter; 9 sites updated to Merriweather Sans for display headings.
5. Dead CSS deletion — ~90 lines of unreachable path-themed rules removed from
global.css; no-op classList mutations pruned from stores/app.ts. 6. Surface tokens — ~76 sites migrated to
step-tint, sidebar-tint, panel, border, primary-dark. Known follow-ups (not part of the 6-step rollout): the partially-live
.role-card / .topic-card / .fact-content rules in global.css:622+ still use the legacy brown #8b4513; the legacy .btn in global.css:198+ uses #684b30; the demo file pages/demo/comment.vue and its associated dead components are awaiting deletion. None of these block the design system; each is a discrete cleanup PR. 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.
#1e6814color="primary"#2d5a27color="primary-dark" or rgb(var(--v-theme-primary-dark))#4a8c6ecolor="secondary"Brand blues / chrome
Unchanged. Used for the dark navy site chrome, light-blue accent on dark backgrounds, and the teal accent.
#1a3a52color="header"#9cc8dccolor="header-light"#5a9ab0color="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.
#28a745color="success" or type="success" on alerts#5a9ab0type="info" on alerts#bd8908type="warning" on alerts#dc3545type="error" on alertsSurfaces & 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.
#f8f9fabackground: rgb(var(--v-theme-panel))#dee2e6border: 1px solid rgb(var(--v-theme-border))#f0fff4color="step-tint" on v-card#e8f5e8color="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.
#4a8c6ecolor="path"Migration map
What each currently-hardcoded value should become after the consolidation.
| Current hardcoded | → New token | Sites affected |
|---|---|---|
| #2a5c35 | primary | nuxt.config.ts (1) — replaced by #1e6814 |
| #1B5E20 | primary | vuetify.ts (1) — entire theme block deleted |
| #1e6814 | primary | 44 sites — Engagement, InstructionSidebar, all step-api components, global.css, mobile.css |
| #2d5a27 | primary-dark | 8 sites — PrivacyPolicyView, RoadlessResearchView, AboutView, TalkingPointsSidebar, global.css |
| #f0fff4 | step-tint | 7 step components in components/steps/ |
| #e8f5e8 | sidebar-tint | 3 sites — global.css, pages/comment.vue, TalkingPointsSidebar |
| #f8f9fa | panel | 10+ sites — global.css body, Engagement, DemoContactInfo, PrivacyPolicy, KeyFacts |
| #dee2e6 | border | 8+ sites — global.css, mobile.css, NewsSidebar |
| #bd8908D8 | warning | nuxt.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 }
}--v-theme-{name} automatically. Non-color uses (borders, backgrounds in scoped CSS) consume them as rgb(var(--v-theme-border)) rather than via the color prop. Path nomenclature & color
Renaming
| Old name | → New name | Status |
|---|---|---|
| Fast | General | Existing path, renamed |
| Informed | Informed | Unchanged |
| Champion | Learn | Renamed; 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.
path / #4a8c6epath / #4a8c6epath / #4a8c6eglobal.css:312–322 (dead body.path selectors), delete global.css:531–608 (dead path-header rules), remove the body.classList.remove('informed-path', 'general-path') calls in stores/app.ts:54,125 if they're now no-ops. 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
| Role | Family | Fallback chain |
|---|---|---|
| Body, all UI text, h2–h6 | Inter | 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif |
| Display / hero h1, page titles | Merriweather Sans | 'Merriweather Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif |
| Code, hex values | Monospace stack | ui-monospace, 'SF Mono', Menlo, monospace |
Live samples (using the loaded Bunny fonts)
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.
components/inputs/: RoadlessEmailField, RoadlessNameField, RoadlessZipField, RoadlessPhoneField, RoadlessTextInput. The first four wrap <v-text-field>; RoadlessTextInput wraps <v-textarea> and adds automatic content sanitization. Each wrapper bakes the canonical attribute set in and exposes a built-in format/required rule that composes additively with caller-supplied :rules. What each wrapper locks
| Wrapper | type | autocomplete | inputmode | Other | Format 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 |
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.
| Cleanup | What & 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-dashes | Off 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 exposesmodelValue: stringand emitsupdate:modelValue. Always returns a string (never undefined).variant="outlined"is locked. There is no escape hatch. The previously inconsistentvariant="filled"ZIP becomes outlined under the wrapper.labelandplaceholderdefault to sensible values per field type but are overridable.:rulesis additive — caller-supplied rules run after the built-in required and format rules, so step-specific validation composes cleanly.required,disabled,errorpass through. Anything else lands on the innerv-text-fieldviav-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.
""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>gotautocomplete="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})?$/), whichRoadlessZipFielddoesn't support. Either extend the wrapper with anallowExtendedprop 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):
- Spacing & layout. Delete the legacy
.mt-* / .mb-*utilities inglobal.css:519–529that conflict with Vuetify's scale. Pick a canonical container width per page archetype. - Buttons. Document and enforce the canonical Next/Back pair. Replace
style="background:#1e6814;..."inline patterns inEngagement.vuewithcolor="primary". - Cards & panels. Move the step-header tint consumer to
color="step-tint"; same for sidebars. - Modals. Codify the header-bar / close-button / action-row skeleton; either ship a wrapper component or a docs snippet.
- Forms — migrate the 18 input sites. Wrappers ship in
components/inputs/; the Form wrappers section above lists every site to swap.ContactFormPanel.vueis the canonical first migration. - Path differentiation (later). When the time comes, replace the single
pathtoken with three:path-general,path-informed,path-learn.