feat: iOS and Material You platform themes (#288, #291)#321
feat: iOS and Material You platform themes (#288, #291)#321hessius merged 2 commits intoversion/2.4.0from
Conversation
Add platform-aware CSS themes that adapt the UI to match iOS and Android visual conventions. Includes: - usePlatformTheme hook with auto-detection (iOS/Android/desktop) - iOS theme: SF Pro fonts, frosted glass cards, green toggles, 0.5px borders - Material You theme: Roboto fonts, elevation shadows, state layers, teal toggles - Settings UI dropdown (auto / iOS / Material / none) - i18n translations for all 6 locales (en, sv, de, es, fr, it) Closes #288 Closes #291 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds platform-aware theming to the web UI so it can automatically (or manually) adopt iOS- and Android/Material-style visual conventions, including persisted user preference and localized settings UI.
Changes:
- Introduces iOS and Material You CSS theme layers and loads them at app startup.
- Adds
usePlatformThemehook for platform auto-detection + localStorage persistence and applies theme class to<html>. - Extends Settings → Appearance with a “Platform theme” dropdown and adds i18n keys across all 6 locales.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/web/src/styles/material-theme.css | Material theme CSS tokens (elevation, ripple/state layers, control styling). |
| apps/web/src/styles/ios-theme.css | iOS theme CSS tokens (fonts, frosted cards, press scaling, thin borders). |
| apps/web/src/main.tsx | Imports the new theme CSS files so they’re bundled/loaded. |
| apps/web/src/lib/constants.ts | Adds a storage key for platform theme preference. |
| apps/web/src/hooks/usePlatformTheme.ts | New hook for detecting platform + persisting/applying the theme class. |
| apps/web/src/hooks/index.ts | Re-exports the new hook and types. |
| apps/web/src/components/SettingsView.tsx | Adds “Platform theme” dropdown to Appearance settings. |
| apps/web/src/App.tsx | Wires usePlatformTheme into SettingsView props. |
| apps/web/public/locales/*/translation.json | Adds localized strings for the new appearance setting. |
| .material-theme.dark-theme [data-slot="switch"][data-state="checked"], .material-theme .dark [data-slot="switch"][data-state="checked"] { background-color: #4ddbc4 !important; border-color: #4ddbc4 !important; } | ||
| .material-theme.dark-theme [data-slot="switch-thumb"][data-state="checked"], .material-theme .dark [data-slot="switch-thumb"][data-state="checked"] { background-color: #003731 !important; } |
There was a problem hiding this comment.
The dark-mode selector .material-theme .dark [data-slot="switch"]… won’t match when dark is on the same element as material-theme (e.g. <html class="dark material-theme">). As written, the checked-switch dark colors may never apply. Use a selector that matches co-located classes (e.g. .material-theme.dark …).
| .material-theme.dark-theme [data-slot="switch"][data-state="checked"], .material-theme .dark [data-slot="switch"][data-state="checked"] { background-color: #4ddbc4 !important; border-color: #4ddbc4 !important; } | |
| .material-theme.dark-theme [data-slot="switch-thumb"][data-state="checked"], .material-theme .dark [data-slot="switch-thumb"][data-state="checked"] { background-color: #003731 !important; } | |
| .material-theme.dark-theme [data-slot="switch"][data-state="checked"], .material-theme.dark [data-slot="switch"][data-state="checked"], .material-theme .dark [data-slot="switch"][data-state="checked"] { background-color: #4ddbc4 !important; border-color: #4ddbc4 !important; } | |
| .material-theme.dark-theme [data-slot="switch-thumb"][data-state="checked"], .material-theme.dark [data-slot="switch-thumb"][data-state="checked"], .material-theme .dark [data-slot="switch-thumb"][data-state="checked"] { background-color: #003731 !important; } |
There was a problem hiding this comment.
✅ Resolved in 8fe78c7. Dark mode switch selectors now include .material-theme.dark (no space) to match when both classes are on the same element, alongside the existing nested .material-theme .dark pattern.
apps/web/src/styles/ios-theme.css
Outdated
| .ios-theme button:active, .ios-theme [role="button"]:active { transform: scale(0.97); } | ||
| .ios-theme [data-slot="switch"][data-state="checked"] { background-color: #34c759 !important; border-color: #34c759 !important; } | ||
| .ios-theme.dark-theme [data-slot="switch"][data-state="checked"], .ios-theme .dark [data-slot="switch"][data-state="checked"] { background-color: #30d158 !important; border-color: #30d158 !important; } | ||
| .ios-theme .border-border, .ios-theme .border-t, .ios-theme .border-b { border-width: 0.5px; } |
There was a problem hiding this comment.
.ios-theme .border-t, .ios-theme .border-b { border-width: 0.5px; } sets all border sides, not just top/bottom. Elements that only intended a top (or bottom) divider will end up with left/right borders too (assuming border-style is set), which can cause visible regressions. Use side-specific properties (border-top-width / border-bottom-width) or target a class that actually represents “all borders”.
| .ios-theme .border-border, .ios-theme .border-t, .ios-theme .border-b { border-width: 0.5px; } | |
| .ios-theme .border-border { border-width: 0.5px; } | |
| .ios-theme .border-t { border-top-width: 0.5px; } | |
| .ios-theme .border-b { border-bottom-width: 0.5px; } |
There was a problem hiding this comment.
✅ Resolved in 8fe78c7. Split border rules: .border-t now uses border-top-width: 0.5px and .border-b uses border-bottom-width: 0.5px instead of the blanket border-width that set all sides.
apps/web/src/styles/ios-theme.css
Outdated
| .ios-theme.dark-theme [data-slot="card"], .ios-theme .dark [data-slot="card"], .ios-theme.dark-theme .card, .ios-theme .dark .card { background-color: rgba(30,30,30,0.72); } | ||
| .ios-theme button, .ios-theme [role="button"] { transition: transform 0.12s ease, opacity 0.12s ease; } | ||
| .ios-theme button:active, .ios-theme [role="button"]:active { transform: scale(0.97); } | ||
| .ios-theme [data-slot="switch"][data-state="checked"] { background-color: #34c759 !important; border-color: #34c759 !important; } | ||
| .ios-theme.dark-theme [data-slot="switch"][data-state="checked"], .ios-theme .dark [data-slot="switch"][data-state="checked"] { background-color: #30d158 !important; border-color: #30d158 !important; } |
There was a problem hiding this comment.
The dark-mode selectors using .ios-theme .dark … won’t match when next-themes applies dark to the same element that has ios-theme (typically <html class="dark ios-theme">). As a result, the dark frosted-card and switch overrides may not activate. Prefer a selector that matches co-located classes (e.g. .ios-theme.dark …).
| .ios-theme.dark-theme [data-slot="card"], .ios-theme .dark [data-slot="card"], .ios-theme.dark-theme .card, .ios-theme .dark .card { background-color: rgba(30,30,30,0.72); } | |
| .ios-theme button, .ios-theme [role="button"] { transition: transform 0.12s ease, opacity 0.12s ease; } | |
| .ios-theme button:active, .ios-theme [role="button"]:active { transform: scale(0.97); } | |
| .ios-theme [data-slot="switch"][data-state="checked"] { background-color: #34c759 !important; border-color: #34c759 !important; } | |
| .ios-theme.dark-theme [data-slot="switch"][data-state="checked"], .ios-theme .dark [data-slot="switch"][data-state="checked"] { background-color: #30d158 !important; border-color: #30d158 !important; } | |
| .ios-theme.dark-theme [data-slot="card"], .ios-theme.dark [data-slot="card"], .ios-theme.dark-theme .card, .ios-theme.dark .card { background-color: rgba(30,30,30,0.72); } | |
| .ios-theme button, .ios-theme [role="button"] { transition: transform 0.12s ease, opacity 0.12s ease; } | |
| .ios-theme button:active, .ios-theme [role="button"]:active { transform: scale(0.97); } | |
| .ios-theme [data-slot="switch"][data-state="checked"] { background-color: #34c759 !important; border-color: #34c759 !important; } | |
| .ios-theme.dark-theme [data-slot="switch"][data-state="checked"], .ios-theme.dark [data-slot="switch"][data-state="checked"] { background-color: #30d158 !important; border-color: #30d158 !important; } |
There was a problem hiding this comment.
✅ Resolved in 8fe78c7. iOS dark mode selectors now include .ios-theme.dark (no space) to match both same-element and nested .dark class patterns.
| useEffect(() => { | ||
| const root = document.documentElement | ||
| const cls = resolveThemeClass(theme, platform) | ||
| root.classList.remove('ios-theme', 'material-theme') | ||
| if (cls) root.classList.add(cls) | ||
| return () => { root.classList.remove('ios-theme', 'material-theme') } | ||
| }, [theme, platform]) |
There was a problem hiding this comment.
Applying the platform theme class in a useEffect means it won’t be added until after the first paint. On iOS/Android this can cause a visible flash of the default theme (fonts/radii/elevation) before the platform class is applied. Consider switching to useLayoutEffect for the classList mutation, or applying the stored preference earlier (e.g. during app bootstrap) to avoid a FOUC.
There was a problem hiding this comment.
✅ Resolved in 8fe78c7. Changed useEffect to useLayoutEffect for theme class application — fires synchronously after DOM mutations but before paint, eliminating the flash of unstyled content.
| .material-theme #root { | ||
| --font-family: "Roboto", "Google Sans", system-ui, sans-serif; | ||
| --radius-factor: 1.5; | ||
| --elevation-1: 0 1px 2px rgba(0,0,0,0.3), 0 1px 3px 1px rgba(0,0,0,0.15); | ||
| --elevation-2: 0 1px 2px rgba(0,0,0,0.3), 0 2px 6px 2px rgba(0,0,0,0.15); | ||
| --elevation-3: 0 1px 3px rgba(0,0,0,0.3), 0 4px 8px 3px rgba(0,0,0,0.15); | ||
| } | ||
| .material-theme.dark-theme #root, .material-theme .dark #root { --elevation-1: 0 1px 3px 1px rgba(0,0,0,0.26), 0 1px 2px rgba(0,0,0,0.44); --elevation-2: 0 2px 6px 2px rgba(0,0,0,0.26), 0 1px 2px rgba(0,0,0,0.44); --elevation-3: 0 4px 8px 3px rgba(0,0,0,0.26), 0 1px 3px rgba(0,0,0,0.44); } |
There was a problem hiding this comment.
--elevation-* CSS variables are declared under .material-theme #root, but components like SelectContent render in a Radix Portal (attached to document.body, outside #root). Any rule using box-shadow: var(--elevation-*) (e.g. select content) will therefore resolve to an undefined var and drop the shadow. Define these variables on .material-theme (the <html> element that gets the class) or on :root so portals inherit them too.
| .material-theme #root { | |
| --font-family: "Roboto", "Google Sans", system-ui, sans-serif; | |
| --radius-factor: 1.5; | |
| --elevation-1: 0 1px 2px rgba(0,0,0,0.3), 0 1px 3px 1px rgba(0,0,0,0.15); | |
| --elevation-2: 0 1px 2px rgba(0,0,0,0.3), 0 2px 6px 2px rgba(0,0,0,0.15); | |
| --elevation-3: 0 1px 3px rgba(0,0,0,0.3), 0 4px 8px 3px rgba(0,0,0,0.15); | |
| } | |
| .material-theme.dark-theme #root, .material-theme .dark #root { --elevation-1: 0 1px 3px 1px rgba(0,0,0,0.26), 0 1px 2px rgba(0,0,0,0.44); --elevation-2: 0 2px 6px 2px rgba(0,0,0,0.26), 0 1px 2px rgba(0,0,0,0.44); --elevation-3: 0 4px 8px 3px rgba(0,0,0,0.26), 0 1px 3px rgba(0,0,0,0.44); } | |
| .material-theme { | |
| --font-family: "Roboto", "Google Sans", system-ui, sans-serif; | |
| --radius-factor: 1.5; | |
| --elevation-1: 0 1px 2px rgba(0,0,0,0.3), 0 1px 3px 1px rgba(0,0,0,0.15); | |
| --elevation-2: 0 1px 2px rgba(0,0,0,0.3), 0 2px 6px 2px rgba(0,0,0,0.15); | |
| --elevation-3: 0 1px 3px rgba(0,0,0,0.3), 0 4px 8px 3px rgba(0,0,0,0.15); | |
| } | |
| .material-theme.dark-theme, .material-theme .dark { --elevation-1: 0 1px 3px 1px rgba(0,0,0,0.26), 0 1px 2px rgba(0,0,0,0.44); --elevation-2: 0 2px 6px 2px rgba(0,0,0,0.26), 0 1px 2px rgba(0,0,0,0.44); --elevation-3: 0 4px 8px 3px rgba(0,0,0,0.26), 0 1px 3px rgba(0,0,0,0.44); } |
There was a problem hiding this comment.
✅ Resolved in 8fe78c7. Moved --elevation-* CSS variables from .material-theme #root to .material-theme directly, so they're accessible to Radix Portal content attached to document.body.
| --elevation-2: 0 1px 2px rgba(0,0,0,0.3), 0 2px 6px 2px rgba(0,0,0,0.15); | ||
| --elevation-3: 0 1px 3px rgba(0,0,0,0.3), 0 4px 8px 3px rgba(0,0,0,0.15); | ||
| } | ||
| .material-theme.dark-theme #root, .material-theme .dark #root { --elevation-1: 0 1px 3px 1px rgba(0,0,0,0.26), 0 1px 2px rgba(0,0,0,0.44); --elevation-2: 0 2px 6px 2px rgba(0,0,0,0.26), 0 1px 2px rgba(0,0,0,0.44); --elevation-3: 0 4px 8px 3px rgba(0,0,0,0.26), 0 1px 3px rgba(0,0,0,0.44); } |
There was a problem hiding this comment.
The dark-mode selector .material-theme .dark #root won’t match when next-themes applies the dark class to the same root element as material-theme (typically <html class="dark material-theme">). This means the dark elevation overrides may never apply. Prefer a selector that matches when both classes are on the same element (e.g. .material-theme.dark …).
There was a problem hiding this comment.
✅ Resolved in 8fe78c7. Dark elevation selectors changed from .material-theme.dark-theme #root, .material-theme .dark #root to .material-theme.dark-theme, .material-theme.dark, .material-theme .dark — removes #root scoping and fixes same-element dark class matching.
| .material-theme .rounded-full { border-radius: 9999px; } | ||
| .material-theme button:hover, .material-theme [role="button"]:hover { box-shadow: var(--elevation-1); } | ||
| .material-theme button::after, .material-theme [role="button"]::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background-color: currentColor; opacity: 0; pointer-events: none; transition: opacity 0.2s ease; } | ||
| .material-theme button { position: relative; overflow: hidden; } |
There was a problem hiding this comment.
The ripple/state-layer pseudo-element is defined for [role="button"]::after, but only button gets position: relative; overflow: hidden;. For non-button elements with role="button", the absolutely positioned ::after will be positioned relative to some ancestor (often the page) and can paint in the wrong place. Apply the same positioning/overflow rules to [role="button"] as well, or limit the ::after rule to elements you fully control.
| .material-theme button { position: relative; overflow: hidden; } | |
| .material-theme button, | |
| .material-theme [role="button"] { position: relative; overflow: hidden; } |
There was a problem hiding this comment.
✅ Resolved in 8fe78c7. Added position: relative; overflow: hidden to [role="button"] alongside button, so the ripple pseudo-element is properly contained for both element types.
- Fix dark mode selectors to match both same-element and nested patterns - Move elevation CSS vars from #root to .material-theme for portal access - Split border-t/border-b rules to use border-top-width/border-bottom-width - Add position:relative and overflow:hidden to [role=button] for ripple - Switch useEffect to useLayoutEffect to prevent FOUC on theme apply Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Review Findings AddressedAll 7 automated code review comments resolved in 8fe78c7:
Validation: Lint (0 errors), build (✓), tests (28/28 files, 416 passed). Issues #288 + #291 completeness: All acceptance criteria met — iOS auto-detect, SF Pro fonts, 14px radius, frosted glass, scale-on-press, green toggles, settings toggle (#288); Android auto-detect, Roboto, 12px cards, elevation shadows, ripple, state layers, settings toggle (#291). Ready to merge. |
Summary
Add platform-aware CSS themes that adapt the UI to match iOS and Android visual conventions.
New files
usePlatformTheme.ts— Hook with auto-detection (iOS/Android/desktop) and localStorage persistenceios-theme.css— SF Pro fonts, frosted glass cards, green toggles, scale-on-press, 0.5px bordersmaterial-theme.css— Roboto fonts, elevation shadows, ripple state layers, pill buttons, teal togglesChanges
--radius-factorand--font-familyCSS variables via class on<html>Closes #288
Closes #291