You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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
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
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
Problem or Motivation
OpenCloudTouch currently ships a dark-only theme with hand-written CSS that has grown organically per component. The result is:
index.css(:root) but a parallel set of wizard-specific aliases (--primary-color,--surface-color, etc.) has diverged from the main systembox-shadowvaluesThe 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
--bg-base,--bg-elevated,--bg-overlay,--bg-subtle,--bg-interactive--surface-1(card),--surface-2(inner card),--surface-3(input),--surface-modal--border-default,--border-subtle,--border-strong,--border-interactive,--border-focus--text-primary,--text-secondary,--text-tertiary,--text-disabled,--text-on-accent,--text-on-danger--accent,--accent-hover,--accent-active,--accent-subtle(10% opacity bg)--color-success,--color-success-subtle,--color-warning,--color-warning-subtle,--color-danger,--color-danger-subtle,--color-info,--color-info-subtle--overlay-scrim(modal backdrop),--overlay-toastDark theme (default)
Inspired by high-quality audio software (Roon, Tidal). Warm neutrals, not cold greys.
Light theme
Warm off-white base, not stark white. Subtle depth through shadow rather than colour.
2. Theme Application & Persistence
prefers-color-schemeOS settingThemeTogglebutton (sun/moon icon) in the navigation bar, persisted tolocalStoragekeyoct-themedata-theme="dark"/data-theme="light"on<html>, all tokens in:root[data-theme="dark"]and:root[data-theme="light"]useTheme()hook manages thedata-themeattribute and localStorage sync3. Typography Scale
Single font: system font stack (existing
--font-sans). Define a strict modular scale:--text-xs--text-sm--text-base--text-md--text-lg--text-xl--text-2xlLetter-spacing: headings use
letter-spacing: -0.02emfor refinement.4. Spacing & Layout Grid
Keep the existing 8px base grid. Extend to:
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.
--shadow-none--shadow-sm--shadow-md--shadow-lgDark:
Light:
6. Border Radius Scale
7. Motion & Animation
Principle: purposeful, not decorative. Transitions communicate state changes; they do not entertain.
Respect
prefers-reduced-motion: alltransition/animationset to0msin the media query.Component Redesign Inventory
Navigation Bar
Current: Three text links + LanguageSelector dumped in a row.
Redesigned:
ThemeToggle(Sun/Moon) +LanguageSelector--accentcolor text,--accent-subtlebackground pill,border-bottom: 2px solid var(--accent)on desktopbackdrop-filter: blur(16px)+ semi-transparent--bg-elevatedbackground for frosted-glass depthCards (global)
All card-like elements (preset buttons, station results, device cards, settings rows) share a base card style:
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):
object-fit: cover, graceful placeholder if missingline-clamp: 2, full title on long-press tooltipVolumeSlider
Current: Native
<input type="range">with minimal CSS.Redesigned:
8pxheight,--radius-full, background--bg-interactive--accent, right uses--border-default22pxcircle,--surface-1background,--accentborder2px,--shadow-sm26pxvia transform--ease-springbounceDeviceSwiper
Current: Horizontal swipe card carousel.
Redesigned:
--surface-1,--shadow-md,--radius-lg, top-border accent stripe3px solid var(--accent)opacity: 0.5,scale: 0.94--accent, inactive--border-strongopacity: 0.4, crossed-out device name,--color-danger"Offline" badgePresetButton
Current: Grid of numbered preset buttons.
Redesigned:
--surface-1card,--radius-md, station logo left (40×40px,--radius-sm), station name + source-type badge right--text-xspill top-right corner of cardborder-color: var(--accent),background: var(--accent-subtle), animated equaliser bars icon replacing the play icon--border-subtleborder, "+ Add preset" ghost stateRadioSearch Modal / Sheet
Current: Full-page overlay with a search input and results list.
Redesigned:
--radius-xltop corners, drag handle), centred modal on desktop (max 560px wide)--surface-3,--radius-md, magnifier icon left, clear (✕) icon rightSettings Page
Current: Manual IP list + About section in a flat layout.
Redesigned:
--text-tertiary,--text-xs, uppercase,--space-2bottom margin)--surface-1card style, label left, control right (toggle, dropdown, value+chevron)--color-dangertext, confirmation dialogManualIPModal
--color-dangerhelper text for invalid IP formatToast Notifications
Current: Unknown position/style.
Redesigned:
--space-4from top--surface-modalbackground,--shadow-lg,--radius-md, left-border4pxin status colourStatusBadge / SetupBadge / CloudBadge
Unify all badge components to use a single
<Badge>primitive:variant:success | warning | danger | info | neutralsize:sm | md--color-{variant}-subtle, text:--color-{variant}, border:--color-{variant}at 30% opacityEmptyState
Consistent empty state for all pages (no presets, no devices, no search results):
--text-tertiary) → heading (--text-md,--text-secondary) → body (--text-sm,--text-tertiary) → optional CTA buttonLoadingSkeleton
All skeleton loaders use a unified
shimmeranimation: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):
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:
--accentfilled, current =--accentoutlined, pending =--border-defaultfilled--surface-1,--shadow-md,--radius-lg--duration-slow--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
--bp-mobile--bp-tablet--bp-desktopAccessibility Requirements (WCAG 2.1 AA)
focus-visibleoutline:2px solid var(--border-focus),outline-offset: 2px— neveroutline: nonewithout a replacementaria-labelwhere text is absent)aria-label="Switch to dark theme"/aria-label="Switch to light theme"--touch-target-minprefers-reduced-motiondisables all transitions/animationsImplementation Approach
Phase 1 — Design System Tokens (no visual change)
index.css:rootinto a single, well-documented token fileuseTheme()hook +ThemeTogglecomponentPhase 2 — Global & Layout Styles
body,.app,.page,.app-header/.app-mainPhase 3 — Component Redesign
Phase 4 — Page Redesign
Phase 5 — QA & Polish
prefers-reduced-motionsmoke testnpm run test:ux)Alternatives Considered
Additional Context
apps/frontend/src/index.css(:rootblock, ~60 tokens, dark-only)index.cssaround line 50–80 — these must be eliminated in Phase 1lucide-react, MIT license, ~330 bytes/icon tree-shaken, consistent 24px stroke design) — confirm before implementationcypress.ux.config.tsalready presentAcceptance 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 CSSuseTheme()hook andThemeTogglecomponent exist; theme persists in localStorage; respectsprefers-color-schemeas defaultprefers-reduced-motionzeroes all transitions