Theming Strategy
Purpose
This document defines how GreenGrass supports per-tenant visual branding, RTL layouts, dark mode, and accessibility themes. Every tenant runs the same application code — theming happens entirely through design token overrides, not code changes.
Per-Tenant Branding
What Tenants Can Customize
| Element | Customizable | How |
|---|---|---|
| Primary color | Yes | Maps to --color-primary and its derivatives |
| Logo | Yes | Uploaded in org settings; displayed in header bar and public pages |
| Favicon | Yes | Uploaded; replaces default in browser tab |
| Brand name | Yes | Text in header bar, document titles, email templates |
| Accent color | Optional | Secondary brand color for data visualization, charts |
| Font | No | System font stack. Tenants cannot change it. |
| Layout | No | Navigation structure, spacing, grid are fixed. |
| Component styles | No | Button shapes, card styles, input styles are fixed. |
| Icons | No | Lucide icon set is fixed. |
Why Font and Layout Are Fixed
Per-tenant font and layout customization would:
- Break spacing and alignment (different fonts have different metrics)
- Require per-tenant testing of every screen
- Increase bundle size (loading different fonts per tenant)
- Create maintenance burden that grows with tenant count
The trade-off is intentional: tenants get color/logo customization (which covers 90% of brand expression needs) without the engineering cost of deeper customization.
Theme Token Override
A tenant's theme is expressed as a set of CSS custom property overrides:
/* Default theme (GreenGrass defaults) */
:root {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-primary-active: #1e40af;
--color-primary-subtle: #eff6ff;
--color-on-primary: #ffffff;
}
/* Tenant override (e.g., Partido Verde - green brand) */
:root[data-theme="partido-verde"] {
--color-primary: #16a34a;
--color-primary-hover: #15803d;
--color-primary-active: #166534;
--color-primary-subtle: #f0fdf4;
--color-on-primary: #ffffff;
}
Automatic Derivative Colors
When a tenant sets their primary color, the system automatically generates:
- Hover state: 10% darker
- Active/pressed state: 20% darker
- Subtle background: 95% lighter (near-white tint)
- On-primary text: White or black, whichever meets 4.5:1 contrast against the primary color
This auto-generation runs server-side during theme configuration. The Org Admin sees a preview before saving.
Theme Configuration UX
In Settings > Org Profile & Branding:
┌──────────────────────────────────────────────────────┐
│ Branding │
├──────────────────────────────────────────────────────┤
│ │
│ Organization Name: [Partido Verde de Puerto Rico ] │
│ │
│ Logo: [Current logo] [Upload New] │
│ Favicon: [Current icon] [Upload New] │
│ │
│ Brand Color: │
│ [#16a34a] [Color picker ▾] │
│ │
│ Preview: │
│ ┌────────────────────────────────────────────────┐ │
│ │ [Logo] Partido Verde [●] [🔔] [👤] │ │
│ │ ┌──────┐ │ │
│ │ │ Nav │ [Primary Button] [Link Text] │ │
│ │ │ item │ [Active Tab] [Inactive Tab] │ │
│ │ └──────┘ │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ ⚠ If your brand color has low contrast against │
│ white, text will automatically switch to dark. │
│ │
│ [Cancel] [Save] │
└──────────────────────────────────────────────────────┘
Brand Color Constraints
The Org Admin can choose any brand color, but the system enforces accessibility:
- If the chosen color has insufficient contrast with white text,
--color-on-primaryswitches to dark text automatically. - If the chosen color is too light for use as link text against white backgrounds,
--color-text-linkuses a darkened variant. - A warning appears if the color is close to status colors (red, amber, green) — "This color is similar to error/warning/success indicators, which may cause confusion."
Public Page Theming
Public pages (donation forms, event pages, candidate profiles) inherit the tenant's theme more aggressively:
- Tenant logo prominent in header
- Primary color used for buttons, links, and accents
- Optional hero background color or image
- Compliance disclaimers remain in the standard system font and style (not branded — legal requirements)
RTL Support
Structural Approach
RTL is not a theme — it's a layout mode. The entire application flips when the UI language is RTL (Arabic, Hebrew).
/* LTR (default) */
html[dir="ltr"] {
--sidebar-position: inline-start; /* left */
--detail-panel-position: inline-end; /* right */
}
/* RTL */
html[dir="rtl"] {
--sidebar-position: inline-start; /* right */
--detail-panel-position: inline-end; /* left */
}
What Flips
| Element | LTR | RTL |
|---|---|---|
| Sidebar position | Left | Right |
| Detail panel position | Right | Left |
| Text alignment | Left-aligned | Right-aligned |
| Navigation arrows/chevrons | Points right (forward) | Points left (forward) |
| Breadcrumb direction | Left → Right | Right → Left |
| Tab bar order | Left-to-right | Right-to-left |
| Progress bars | Fill left-to-right | Fill right-to-left |
| Form labels | Left of input (or above) | Right of input (or above) |
| List item icons | Left side | Right side |
| Notification drawer | Slides from right | Slides from left |
| Number input spinners | Right side | Left side |
What Does NOT Flip
| Element | Behavior | Why |
|---|---|---|
| Phone numbers | Always LTR | International standard |
| Email addresses | Always LTR | Technical standard |
| URLs | Always LTR | Technical standard |
| Currency amounts | Always LTR | International standard |
| Code/technical text | Always LTR | Universal |
| Maps | No flip | Geographic north/south/east/west don't flip |
| Media playback controls | No flip | Universal convention |
| Checkmarks | No flip | Not directional |
| Close buttons (✗) | No flip | Not directional |
Implementation
RTL is handled entirely through CSS logical properties:
/* Never this: */
margin-left: var(--space-4);
padding-right: var(--space-2);
text-align: left;
/* Always this: */
margin-inline-start: var(--space-4);
padding-inline-end: var(--space-2);
text-align: start;
This means no duplicate stylesheets, no RTL-specific CSS files, no conditional classes. A single stylesheet works for both directions. The dir attribute on <html> triggers the correct behavior.
RTL Font Considerations
Arabic script requires specific typographic attention:
- Arabic text uses system fonts — the font stack falls through to Noto Sans Arabic, Arabic UI Text, or system Arabic fonts.
- Mixed-direction text — a paragraph in Arabic containing an English brand name or email address uses Unicode bidirectional algorithm (BiDi) for inline direction switching. No manual intervention needed in most cases.
- Numerals — Arabic-language UI uses Western Arabic numerals (0-9), not Eastern Arabic numerals (٠-٩), for consistency with data displays and international standards. This is configurable per tenant if needed.
RTL Testing
Every component and screen must be tested in RTL mode. The development environment includes a language toggle that switches between LTR and RTL instantly for visual verification.
Dark Mode
Approach
Dark mode is a user preference, not a tenant setting. It applies across the entire application.
Token Mapping
Dark mode remaps all surface, text, and border tokens:
| Token | Light | Dark |
|---|---|---|
--color-surface |
#ffffff |
#0f172a |
--color-surface-raised |
#ffffff |
#1e293b |
--color-surface-sunken |
#f8fafc |
#020617 |
--color-text |
#0f172a |
#f1f5f9 |
--color-text-secondary |
#475569 |
#94a3b8 |
--color-text-tertiary |
#94a3b8 |
#64748b |
--color-border |
#e2e8f0 |
#334155 |
--color-border-strong |
#cbd5e1 |
#475569 |
--color-input-border |
#cbd5e1 |
#475569 |
--color-disabled |
#f1f5f9 |
#1e293b |
Primary Color in Dark Mode
The tenant's primary color may need adjustment in dark mode:
- If the primary color is dark (e.g., dark blue), it may lack contrast against the dark background. The system auto-lightens it for dark mode.
- If the primary color is already light (e.g., yellow), it may be used as-is or slightly saturated.
--color-primary-subtleinverts — instead of a near-white tint, it becomes a near-black tint of the primary.
Status Colors in Dark Mode
Status colors (success, warning, error) shift to slightly lighter/more saturated variants in dark mode to maintain visibility against dark backgrounds.
Dark Mode Activation
- User setting: Toggle in personal profile preferences
- System preference: Respects
prefers-color-scheme: darkmedia query by default - Override: User can override system preference (force light, force dark, or follow system)
- Persistence: User's choice persisted to profile (server-side), applies across devices
Dark Mode Exclusions
Some elements don't change in dark mode:
- Public donation forms — always use the tenant's brand-configured theme (typically light). Dark mode applies to the platform, not public pages.
- Print views — always light.
- Map tiles — map providers have their own dark mode tiles (optional).
War Room Dark Mode
The war room dashboard defaults to dark mode during election night operations (override-able). Dark mode is easier on eyes during prolonged monitoring in dimly lit rooms, and large-screen war room displays look better with dark backgrounds.
Accessibility Themes
Beyond dark mode, the design system supports accessibility overrides:
High Contrast Mode
Activated in personal accessibility preferences. Overrides:
| Change | Effect |
|---|---|
| Border width | All borders 1px → 2px |
| Focus rings | 2px → 3px, with 2px offset |
| Text contrast | All text meets 7:1 ratio (WCAG AAA) |
| Color simplification | Subtle background colors become more distinct |
| Status indicators | Icons added to all color-only indicators |
| Link underlines | All links are underlined (not just on hover) |
Large Text Mode
For users who need larger text without zooming (which can break layouts):
| Change | Effect |
|---|---|
| Base font size | 16px → 20px |
| Minimum font size | 12px → 16px |
| Line heights | Proportionally increased |
| Touch targets | 44px → 52px minimum |
| Spacing | Proportionally increased |
Note: Large text mode may cause some desktop layouts to behave like tablet layouts (less content visible, more scrolling). This is acceptable — usability over information density.
Reduced Motion Mode
Respects prefers-reduced-motion: reduce:
- All transitions:
duration: 0ms - No animated loading indicators (static icon instead)
- No auto-scrolling in war room feed (manual scroll only)
- No skeleton loader pulsing (static grey blocks instead)
Theme Composition
Themes compose — multiple concerns are applied simultaneously:
Base tokens (foundations.md)
└── Tenant brand override (primary color, logo)
└── Color mode (light / dark)
└── Accessibility overrides (high contrast, large text)
└── Context overrides (field mode density, war room dark)
Each layer only overrides what it needs. A tenant's dark mode high-contrast theme = base tokens + tenant brand + dark palette + high contrast adjustments. The CSS cascade handles this naturally through specificity:
:root { /* base */ }
:root[data-theme="partido-verde"] { /* tenant */ }
:root[data-color-mode="dark"] { /* dark mode */ }
:root[data-high-contrast="true"] { /* accessibility */ }
:root[data-context="field-mode"] { /* context */ }
Open Questions
-
Tenant dark mode defaults. Should tenants be able to set a default color mode (light/dark) for new users? Some campaigns run in the evening; dark mode default might be appropriate.
-
Seasonal/campaign themes. Should tenants be able to configure time-limited theme overrides (election day theme, fundraising drive theme)? Adds visual dynamism but increases complexity.
-
White-label depth. Some tenants (particularly self-hosted) may want deeper white-labeling (custom app name, custom login screen, custom email templates). How deep does theming go? Currently limited to color + logo. Full white-label would require a different architecture.