Skip to content

feat: iOS and Material You platform themes (#288, #291)#321

Merged
hessius merged 2 commits intoversion/2.4.0from
feature/288-291-platform-themes
Mar 26, 2026
Merged

feat: iOS and Material You platform themes (#288, #291)#321
hessius merged 2 commits intoversion/2.4.0from
feature/288-291-platform-themes

Conversation

@hessius
Copy link
Copy Markdown
Owner

@hessius hessius commented Mar 25, 2026

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 persistence
  • ios-theme.css — SF Pro fonts, frosted glass cards, green toggles, scale-on-press, 0.5px borders
  • material-theme.css — Roboto fonts, elevation shadows, ripple state layers, pill buttons, teal toggles

Changes

  • Settings → Appearance: new "Platform theme" dropdown (Auto / iOS / Material / Default)
  • CSS themes override --radius-factor and --font-family CSS variables via class on <html>
  • i18n translations added for all 6 locales (en, sv, de, es, fr, it)

Closes #288
Closes #291

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>
@hessius hessius added this to the 2.4 milestone Mar 25, 2026
@hessius hessius requested a review from Copilot March 25, 2026 21:14
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 usePlatformTheme hook 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.

Comment on lines +22 to +23
.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; }
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 …).

Suggested change
.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; }

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

.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; }
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.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”.

Suggested change
.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; }

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +8 to +12
.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; }
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 …).

Suggested change
.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; }

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 8fe78c7. iOS dark mode selectors now include .ios-theme.dark (no space) to match both same-element and nested .dark class patterns.

Comment on lines +40 to +46
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])
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +2 to +9
.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); }
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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.

Suggested change
.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); }

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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); }
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 …).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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; }
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
.material-theme button { position: relative; overflow: hidden; }
.material-theme button,
.material-theme [role="button"] { position: relative; overflow: hidden; }

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@hessius
Copy link
Copy Markdown
Owner Author

hessius commented Mar 25, 2026

Review Findings Addressed

All 7 automated code review comments resolved in 8fe78c7:

  1. Dark mode selector (material switch) — Added .material-theme.dark selector for same-element matching
  2. border-t/border-b specificity — Split into border-top-width and border-bottom-width respectively
  3. Dark mode selector (iOS switch/cards) — Added .ios-theme.dark selector for same-element matching
  4. FOUC prevention — Changed useEffectuseLayoutEffect for synchronous theme application
  5. Elevation vars portal access — Moved from .material-theme #root to .material-theme so Radix portals inherit them
  6. Dark elevation selector — Removed #root scoping, added .dark same-element selector
  7. Ripple on role=button — Added position: relative; overflow: hidden to [role="button"]

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.

@hessius hessius merged commit de9eafc into version/2.4.0 Mar 26, 2026
6 checks passed
@hessius hessius deleted the feature/288-291-platform-themes branch March 26, 2026 05:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants