Skip to content

[Feature]: UI Facelift — Design System, Dark/Light Theme, Component Redesign #116

@scheilch

Description

@scheilch

Problem or Motivation

OpenCloudTouch currently ships a dark-only theme with hand-written CSS that has grown organically per component. The result is:

  • No light theme option — forces dark mode regardless of OS preference or user choice
  • Inconsistent visual language across pages: Presets, LocalControl, MultiRoom, Settings, Firmware, SetupWizard each have distinct "feels" with different spacings, border radii, and shadow depths
  • Design tokens exist in index.css (:root) but a parallel set of wizard-specific aliases (--primary-color, --surface-color, etc.) has diverged from the main system
  • No consistent elevation model — cards, modals, and sheets mix arbitrary box-shadow values
  • Typography is ad-hoc: no defined scale, no consistent line-height rhythm
  • Interactive states (hover, focus, active, disabled) are handled differently per component
  • No visual hierarchy on data-dense pages (LocalControl, RadioSearch)
  • NowPlaying, VolumeSlider, DeviceSwiper — three of the most-used components — have a utilitarian rather than pleasurable feel
  • The navigation bar has no active-state polish and does not scale for future nav items

The user experience should be calm, refined, and enjoyable — not just functional.


Desired Behaviour

Design System Foundation

1. Color System — Two Themes

Define a single canonical token set. Both themes implement every token. Zero hardcoded hex values outside the token file.

Token categories

Category Tokens
Background --bg-base, --bg-elevated, --bg-overlay, --bg-subtle, --bg-interactive
Surface --surface-1 (card), --surface-2 (inner card), --surface-3 (input), --surface-modal
Border --border-default, --border-subtle, --border-strong, --border-interactive, --border-focus
Text --text-primary, --text-secondary, --text-tertiary, --text-disabled, --text-on-accent, --text-on-danger
Accent --accent, --accent-hover, --accent-active, --accent-subtle (10% opacity bg)
Status --color-success, --color-success-subtle, --color-warning, --color-warning-subtle, --color-danger, --color-danger-subtle, --color-info, --color-info-subtle
Overlay --overlay-scrim (modal backdrop), --overlay-toast

Dark theme (default)

Inspired by high-quality audio software (Roon, Tidal). Warm neutrals, not cold greys.

--bg-base:          #111113   (near-black, warm tint)
--bg-elevated:      #1c1c1f   (cards, panels)
--bg-overlay:       #242428   (dropdowns, tooltips)
--bg-subtle:        #2a2a2e   (hover states, zebra rows)
--bg-interactive:   #313136   (input backgrounds)

--surface-1:        #1c1c1f
--surface-2:        #242428
--surface-3:        #2a2a2e

--border-default:   rgba(255,255,255,0.08)
--border-subtle:    rgba(255,255,255,0.05)
--border-strong:    rgba(255,255,255,0.16)
--border-interactive: rgba(255,255,255,0.12)
--border-focus:     #4a9eff

--text-primary:     #f0f0f2
--text-secondary:   #a0a0aa
--text-tertiary:    #6a6a72
--text-disabled:    #44444c

--accent:           #3b82f6   (blue — calm, not jarring)
--accent-hover:     #60a5fa
--accent-active:    #2563eb
--accent-subtle:    rgba(59,130,246,0.12)

--color-success:    #22c55e
--color-warning:    #f59e0b
--color-danger:     #ef4444
--color-info:       #38bdf8

Light theme

Warm off-white base, not stark white. Subtle depth through shadow rather than colour.

--bg-base:          #f5f5f7   (Apple-inspired warm off-white)
--bg-elevated:      #ffffff
--bg-overlay:       #ffffff
--bg-subtle:        #efefef
--bg-interactive:   #e8e8ec

--surface-1:        #ffffff
--surface-2:        #f5f5f7
--surface-3:        #ececf0

--border-default:   rgba(0,0,0,0.08)
--border-subtle:    rgba(0,0,0,0.05)
--border-strong:    rgba(0,0,0,0.16)
--border-focus:     #2563eb

--text-primary:     #111113
--text-secondary:   #56565e
--text-tertiary:    #8e8e96
--text-disabled:    #b8b8c0

--accent:           #2563eb
--accent-hover:     #1d4ed8
--accent-active:    #1e40af
--accent-subtle:    rgba(37,99,235,0.08)

2. Theme Application & Persistence

  • Default: respect prefers-color-scheme OS setting
  • User override: ThemeToggle button (sun/moon icon) in the navigation bar, persisted to localStorage key oct-theme
  • Implementation: data-theme="dark" / data-theme="light" on <html>, all tokens in :root[data-theme="dark"] and :root[data-theme="light"]
  • No CSS-in-JS, no runtime injection — pure CSS custom properties
  • A useTheme() hook manages the data-theme attribute and localStorage sync

3. Typography Scale

Single font: system font stack (existing --font-sans). Define a strict modular scale:

Token Size Line Height Weight Usage
--text-xs 11px 1.4 400 Labels, badges, captions
--text-sm 13px 1.5 400 Secondary text, metadata
--text-base 15px 1.6 400 Body copy, list items
--text-md 17px 1.5 500 Subheadings, card titles
--text-lg 20px 1.4 600 Section headings
--text-xl 24px 1.3 700 Page headings
--text-2xl 30px 1.2 700 Hero text (NowPlaying title)

Letter-spacing: headings use letter-spacing: -0.02em for refinement.

4. Spacing & Layout Grid

Keep the existing 8px base grid. Extend to:

--space-1:  4px    --space-5: 20px
--space-2:  8px    --space-6: 24px
--space-3:  12px   --space-8: 32px
--space-4:  16px   --space-10: 40px
                   --space-12: 48px
                   --space-16: 64px

Page max-width: --page-max-width: 680px (single column, mobile-first, not wide desktop layout).

5. Elevation Model

Three elevation levels, implemented as box-shadow. Dark mode uses glow-less shadows. Light mode uses layered drop shadows.

Level Token Usage
0 --shadow-none Flat elements, inputs
1 --shadow-sm Cards, surface-1 elements
2 --shadow-md Dropdowns, popovers
3 --shadow-lg Modals, bottom sheets

Dark:

--shadow-sm: 0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3);
--shadow-md: 0 4px 12px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.3);
--shadow-lg: 0 16px 48px rgba(0,0,0,0.6), 0 4px 8px rgba(0,0,0,0.3);

Light:

--shadow-sm: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.10), 0 2px 4px rgba(0,0,0,0.06);
--shadow-lg: 0 16px 48px rgba(0,0,0,0.14), 0 4px 8px rgba(0,0,0,0.06);

6. Border Radius Scale

--radius-sm:   6px   (inputs, tags, small badges)
--radius-md:   10px  (cards, buttons)
--radius-lg:   16px  (large cards, modals)
--radius-xl:   24px  (bottom sheets, hero cards)
--radius-full: 9999px (pills, avatars, toggles)

7. Motion & Animation

Principle: purposeful, not decorative. Transitions communicate state changes; they do not entertain.

--duration-fast:   120ms  (hover, focus rings)
--duration-base:   200ms  (show/hide, slide)
--duration-slow:   320ms  (page transitions, modals)
--ease-out:        cubic-bezier(0.0, 0.0, 0.2, 1.0)
--ease-in-out:     cubic-bezier(0.4, 0.0, 0.2, 1.0)
--ease-spring:     cubic-bezier(0.34, 1.56, 0.64, 1.0)  (bounce: swiper, toggles)

Respect prefers-reduced-motion: all transition/animation set to 0ms in the media query.


Component Redesign Inventory

Navigation Bar

Current: Three text links + LanguageSelector dumped in a row.

Redesigned:

  • Sticky bottom bar on mobile (thumb-friendly), top bar on desktop (≥768px)
  • Nav items: icon + label, active state with accent-coloured underline pill
  • Icons: consistent icon set — recommend Lucide (MIT, tree-shakeable, already common in React ecosystems)
  • Items: Presets (Music icon), Zones (Layout icon), Settings (Sliders icon)
  • Right-side cluster: ThemeToggle (Sun/Moon) + LanguageSelector
  • Active item: --accent color text, --accent-subtle background pill, border-bottom: 2px solid var(--accent) on desktop
  • Height: 56px mobile, 52px desktop. backdrop-filter: blur(16px) + semi-transparent --bg-elevated background for frosted-glass depth

Cards (global)

All card-like elements (preset buttons, station results, device cards, settings rows) share a base card style:

background: var(--surface-1);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
transition: background var(--duration-fast) var(--ease-out),
            box-shadow var(--duration-fast) var(--ease-out);

Hover: background: var(--bg-subtle), box-shadow: var(--shadow-md)
Active: background: var(--bg-interactive), transform: scale(0.98)

Buttons

Two variants, not more:

Primary: background: var(--accent), color: var(--text-on-accent), border-radius: var(--radius-md), padding: var(--space-3) var(--space-5), font-size: var(--text-base), font-weight: 500. Hover: --accent-hover. Active: --accent-active. Disabled: opacity: 0.38, cursor: not-allowed.

Ghost: background: transparent, border: 1px solid var(--border-interactive), same padding/radius. Hover: background: var(--bg-subtle).

Icon button: 40×40px square, border-radius: var(--radius-md). Hover: background: var(--bg-subtle).

Minimum touch target: 44×44px via padding (preserved from existing --touch-target-min).

NowPlaying Component

The centrepiece of LocalControl. Currently functional but plain.

Redesigned layout (mobile-first, single column):

┌──────────────────────────────────────────┐
│  [Station artwork / device image]        │  — 200×200px, centered, border-radius: --radius-lg, shadow-md
│                                          │
│  Station/Track title     (--text-2xl)    │  — bold, truncate 2 lines
│  Source type badge        (--text-xs)    │  — pill badge: INTERNET RADIO / BLUETOOTH / AUX
│                                          │
│  ──── Volume Slider ────────────────     │  — custom track/thumb (see VolumeSlider below)
│  🔈  ────────────●──────────  🔊  42     │
│                                          │
│  [Source selector tabs: Radio | BT | AUX | AirPlay]
└──────────────────────────────────────────┘
  • Album art / station logo fills a square with object-fit: cover, graceful placeholder if missing
  • Title truncation with line-clamp: 2, full title on long-press tooltip
  • Source badge uses status color tokens (info for radio, success for BT, etc.)

VolumeSlider

Current: Native <input type="range"> with minimal CSS.

Redesigned:

  • Custom track: 8px height, --radius-full, background --bg-interactive
  • Fill: left of thumb uses --accent, right uses --border-default
  • Thumb: 22px circle, --surface-1 background, --accent border 2px, --shadow-sm
  • Hover: thumb scales to 26px via transform
  • Volume endpoints: mute (🔇) and max (🔊) tap targets each 44×44px
  • Snap feedback: at 0% and 100% a subtle --ease-spring bounce

DeviceSwiper

Current: Horizontal swipe card carousel.

Redesigned:

  • Active device card: full width, --surface-1, --shadow-md, --radius-lg, top-border accent stripe 3px solid var(--accent)
  • Inactive peek cards: visible at 15% on left/right edges, opacity: 0.5, scale: 0.94
  • Swipe indicator dots below: 6px circles, active --accent, inactive --border-strong
  • Offline device: opacity: 0.4, crossed-out device name, --color-danger "Offline" badge

PresetButton

Current: Grid of numbered preset buttons.

Redesigned:

  • 2-column grid on mobile, 3-column on desktop
  • Each button: --surface-1 card, --radius-md, station logo left (40×40px, --radius-sm), station name + source-type badge right
  • Numbered preset index: small --text-xs pill top-right corner of card
  • Active playing: border-color: var(--accent), background: var(--accent-subtle), animated equaliser bars icon replacing the play icon
  • Empty preset: dashed --border-subtle border, "+ Add preset" ghost state

RadioSearch Modal / Sheet

Current: Full-page overlay with a search input and results list.

Redesigned:

  • Bottom sheet on mobile (slides up, --radius-xl top corners, drag handle), centred modal on desktop (max 560px wide)
  • Search input: full-width, --surface-3, --radius-md, magnifier icon left, clear (✕) icon right
  • Results: virtualised list (if > 50 items), each row is a card (logo + name + country flag + bitrate badge)
  • Loading: skeleton rows matching item height (2 × lines per row)
  • Empty state: full illustration + "No stations found" + search tips

Settings Page

Current: Manual IP list + About section in a flat layout.

Redesigned:

  • Section grouping with labelled dividers (--text-tertiary, --text-xs, uppercase, --space-2 bottom margin)
  • Groups: Devices (manual IPs, discovery toggle), Appearance (theme toggle, language), About (version, links)
  • Each setting row: --surface-1 card style, label left, control right (toggle, dropdown, value+chevron)
  • Destructive actions (delete IP, factory reset if added later): --color-danger text, confirmation dialog

ManualIPModal

  • Upgrade to bottom sheet on mobile
  • Input validation inline: red border + --color-danger helper text for invalid IP format
  • Confirm button disabled until input is valid

Toast Notifications

Current: Unknown position/style.

Redesigned:

  • Position: top-centre, --space-4 from top
  • Style: --surface-modal background, --shadow-lg, --radius-md, left-border 4px in status colour
  • Types: success (green), error (red), info (blue), warning (amber)
  • Auto-dismiss: 4s, progress bar indicator
  • Stack: up to 3 simultaneous, LIFO order, each dismissible via ✕

StatusBadge / SetupBadge / CloudBadge

Unify all badge components to use a single <Badge> primitive:

  • variant: success | warning | danger | info | neutral
  • size: sm | md
  • Background: --color-{variant}-subtle, text: --color-{variant}, border: --color-{variant} at 30% opacity
  • Icon slot left, label text, optional count right

EmptyState

Consistent empty state for all pages (no presets, no devices, no search results):

  • Centered column layout: icon (64px, --text-tertiary) → heading (--text-md, --text-secondary) → body (--text-sm, --text-tertiary) → optional CTA button
  • No page-specific ad-hoc empty state markup

LoadingSkeleton

All skeleton loaders use a unified shimmer animation:

@keyframes shimmer {
  from { background-position: -200% 0; }
  to   { background-position:  200% 0; }
}
.skeleton {
  background: linear-gradient(
    90deg,
    var(--bg-subtle) 25%,
    var(--bg-interactive) 50%,
    var(--bg-subtle) 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.6s var(--ease-in-out) infinite;
  border-radius: var(--radius-sm);
}

Page-by-Page Redesign

RadioPresets (Home — /)

Layout: page heading "Presets" (--text-xl) + device selector header (DeviceSwiper or compact header bar) + preset grid.

Header bar shows: device name + connection status badge + NowPlaying mini-player (tap to expand to LocalControl).

Preset grid: 2 cols mobile / 3 cols tablet. Card height: 72px. Logo, name, source badge, preset number. Playing card animated.

LocalControl (/local)

Full-page player experience. No tab bar visible (or minimised). Back chevron to return to Presets.

Structure (top → bottom):

  1. Device selector (compact DeviceSwiper, one-line)
  2. NowPlaying (artwork, title, source badge)
  3. VolumeSlider
  4. Source tabs (Radio / Bluetooth / AUX / AirPlay)
  5. NowPlaying metadata (bitrate, station description, if available)

MultiRoom (/multiroom)

Groups and standalone devices in two sections:

Active Zones: Each zone as a card: zone name, member device count, master volume slider, member list (small chips).

Standalone Devices: Device cards: name, now-playing snippet, volume badge, "Add to zone" ghost button.

Zone creation: FAB (Floating Action Button, --accent) bottom-right.

SetupWizard (/setup-wizard)

Existing wizard steps need visual alignment with the new system:

  • ProgressTracker: dot-step indicator, completed = --accent filled, current = --accent outlined, pending = --border-default filled
  • Each step card: --surface-1, --shadow-md, --radius-lg
  • Step transitions: slide-in from right, slide-out to left, --duration-slow
  • CopyableCommand: monospace block, --surface-3, copy button with success feedback (icon swap ✓)

Settings (/settings)

As described in component section above. Add Appearance group with ThemeToggle row.

Firmware (/firmware)

Simple status card: current version, latest version, update button (primary). Progress indicator during update. Success/error state.

NotFound (*)

Centred layout: large 404 (--text-2xl, --text-tertiary), heading, back-to-home button.


Responsive Breakpoints

Breakpoint Width Layout change
--bp-mobile < 480px Single column, bottom nav, compact cards
--bp-tablet 480–767px 2-col preset grid, side nav hint
--bp-desktop ≥ 768px Top nav, 3-col preset grid, wider modals

Accessibility Requirements (WCAG 2.1 AA)

  • All colour pairs (text on background, text on surface, text on accent) must meet 4.5:1 contrast ratio — verified with a contrast checker for both themes before merge
  • focus-visible outline: 2px solid var(--border-focus), outline-offset: 2px — never outline: none without a replacement
  • All interactive elements have accessible names (aria-label where text is absent)
  • ThemeToggle: aria-label="Switch to dark theme" / aria-label="Switch to light theme"
  • No color as the sole means of conveying state (icons or labels always accompany color)
  • Touch targets ≥ 44×44px — already enforced by --touch-target-min
  • prefers-reduced-motion disables all transitions/animations

Implementation Approach

Phase 1 — Design System Tokens (no visual change)

  • Consolidate index.css :root into a single, well-documented token file
  • Add both dark and light theme token sets
  • Remove duplicate wizard-specific aliases; map to canonical tokens
  • Add useTheme() hook + ThemeToggle component
  • Verify all CI tests pass (no visual regressions in unit tests)

Phase 2 — Global & Layout Styles

  • Apply tokens to body, .app, .page, .app-header/.app-main
  • Redesign Navigation (icons, active state, bottom/top responsive position, ThemeToggle slot)
  • Redesign Cards, Buttons, Badges, Toasts, EmptyState, LoadingSkeleton as primitives

Phase 3 — Component Redesign

  • NowPlaying, VolumeSlider, DeviceSwiper, PresetButton
  • RadioSearch (bottom sheet)
  • ManualIPModal (bottom sheet)
  • ConfirmDialog, ErrorBoundary

Phase 4 — Page Redesign

  • RadioPresets, LocalControl, MultiRoom, Settings, SetupWizard, Firmware, NotFound

Phase 5 — QA & Polish

  • Cross-browser: Chrome, Firefox, Safari (iOS), Chrome (Android)
  • Both themes: contrast ratio audit for every text/bg pair
  • prefers-reduced-motion smoke test
  • Lighthouse accessibility score ≥ 90 in both themes
  • Cypress UX audit passes (npm run test:ux)
  • All 540+ unit tests green
  • Visual regression baseline snapshots (optional: Percy or Playwright screenshots)

Alternatives Considered

  • Adopt a component library (MUI, Chakra, shadcn/ui): Would accelerate development but introduces a large dependency, overrides the custom design language, and makes it harder to achieve the specific audio-app aesthetic. Rejected. The existing hand-written CSS approach is retained and refined.
  • Tailwind CSS: Good utility-first option but requires a build-time migration and changes the authoring model for every existing component. The CSS custom property approach achieves the same token-driven consistency without the migration cost. Rejected.
  • Single dark theme only: The OS split between dark and light users is roughly 50/50. Light theme is table stakes for a production app. Rejected.

Additional Context

  • Current design token state: apps/frontend/src/index.css (:root block, ~60 tokens, dark-only)
  • Wizard-specific alias tokens (diverged from main system) documented in index.css around line 50–80 — these must be eliminated in Phase 1
  • Icon set recommendation: Lucide React (lucide-react, MIT license, ~330 bytes/icon tree-shaken, consistent 24px stroke design) — confirm before implementation
  • The NowPlaying + VolumeSlider components are on the highest-traffic user journey (every Bose user opens the app to this) — prioritise polish there
  • Lighthouse UX audit config: cypress.ux.config.ts already present
  • No dedicated design file (Figma/Sketch) exists yet — this issue is the design specification. A companion Figma file is optional but not required before implementation begins

Acceptance Criteria

  • apps/frontend/src/styles/tokens.css (or equivalent) contains all design tokens; both dark and light themes defined; zero hardcoded hex/rgb values in component CSS
  • useTheme() hook and ThemeToggle component exist; theme persists in localStorage; respects prefers-color-scheme as default
  • Navigation: icons + labels, active state, ThemeToggle slot, responsive (bottom mobile / top desktop)
  • All text/background colour pairs pass WCAG 4.5:1 contrast in both themes (audit comment posted on PR)
  • prefers-reduced-motion zeroes all transitions
  • NowPlaying, VolumeSlider, DeviceSwiper, PresetButton visually redesigned per spec
  • RadioSearch implemented as bottom sheet (mobile) / modal (desktop)
  • All settings rows use unified card-row pattern; Appearance section includes ThemeToggle row
  • Badge, Button, EmptyState, LoadingSkeleton are unified primitives used consistently across all pages
  • Toast: top-centre position, status colour variants, auto-dismiss with progress bar
  • SetupWizard step transitions animate per spec
  • All 540+ unit tests remain green
  • Cypress UX audit passes in both themes
  • Lighthouse accessibility ≥ 90 in both themes (CI enforced)
  • No German text introduced in any new component or CSS comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions