diff --git a/apps/web/public/locales/de/translation.json b/apps/web/public/locales/de/translation.json index c657605e..46f86061 100644 --- a/apps/web/public/locales/de/translation.json +++ b/apps/web/public/locales/de/translation.json @@ -860,7 +860,13 @@ "followSystemTheme": "Follow system theme", "followSystemDescription": "Automatically match your system's dark/light mode preference", "backgroundAnimations": "Background animations", - "animationsDescription": "Show animated background effects" + "animationsDescription": "Show animated background effects", + "platformTheme": "Plattform-Design", + "platformThemeDescription": "Visuellen Stil an Ihr Gerät anpassen", + "platformThemeAuto": "Automatisch", + "platformThemeIos": "iOS", + "platformThemeMaterial": "Material (Android)", + "platformThemeNone": "Standard" }, "advanced": { "detailedKnowledge": "Detailliertes KI-Wissen", diff --git a/apps/web/public/locales/en/translation.json b/apps/web/public/locales/en/translation.json index 0d720abf..aada0c26 100644 --- a/apps/web/public/locales/en/translation.json +++ b/apps/web/public/locales/en/translation.json @@ -865,7 +865,13 @@ "followSystemTheme": "Follow system theme", "followSystemDescription": "Automatically match your system's dark/light mode preference", "backgroundAnimations": "Background animations", - "animationsDescription": "Show animated background effects" + "animationsDescription": "Show animated background effects", + "platformTheme": "Platform theme", + "platformThemeDescription": "Adjust the visual style to match your device", + "platformThemeAuto": "Auto-detect", + "platformThemeIos": "iOS", + "platformThemeMaterial": "Material (Android)", + "platformThemeNone": "Default" }, "pourOver": { "title": "Pour-over", diff --git a/apps/web/public/locales/es/translation.json b/apps/web/public/locales/es/translation.json index cbaf03d9..bbb86a2f 100644 --- a/apps/web/public/locales/es/translation.json +++ b/apps/web/public/locales/es/translation.json @@ -860,7 +860,13 @@ "followSystemTheme": "Follow system theme", "followSystemDescription": "Automatically match your system's dark/light mode preference", "backgroundAnimations": "Background animations", - "animationsDescription": "Show animated background effects" + "animationsDescription": "Show animated background effects", + "platformTheme": "Tema de plataforma", + "platformThemeDescription": "Ajusta el estilo visual según tu dispositivo", + "platformThemeAuto": "Detectar automáticamente", + "platformThemeIos": "iOS", + "platformThemeMaterial": "Material (Android)", + "platformThemeNone": "Predeterminado" }, "advanced": { "detailedKnowledge": "Conocimiento detallado de IA", diff --git a/apps/web/public/locales/fr/translation.json b/apps/web/public/locales/fr/translation.json index 87d90de1..ee956756 100644 --- a/apps/web/public/locales/fr/translation.json +++ b/apps/web/public/locales/fr/translation.json @@ -860,7 +860,13 @@ "followSystemTheme": "Follow system theme", "followSystemDescription": "Automatically match your system's dark/light mode preference", "backgroundAnimations": "Background animations", - "animationsDescription": "Show animated background effects" + "animationsDescription": "Show animated background effects", + "platformTheme": "Thème de plateforme", + "platformThemeDescription": "Adapter le style visuel à votre appareil", + "platformThemeAuto": "Détection automatique", + "platformThemeIos": "iOS", + "platformThemeMaterial": "Material (Android)", + "platformThemeNone": "Par défaut" }, "advanced": { "detailedKnowledge": "Connaissances IA détaillées", diff --git a/apps/web/public/locales/it/translation.json b/apps/web/public/locales/it/translation.json index 535acc44..c48b4b1d 100644 --- a/apps/web/public/locales/it/translation.json +++ b/apps/web/public/locales/it/translation.json @@ -860,7 +860,13 @@ "followSystemTheme": "Follow system theme", "followSystemDescription": "Automatically match your system's dark/light mode preference", "backgroundAnimations": "Background animations", - "animationsDescription": "Show animated background effects" + "animationsDescription": "Show animated background effects", + "platformTheme": "Tema piattaforma", + "platformThemeDescription": "Adatta lo stile visivo al tuo dispositivo", + "platformThemeAuto": "Rilevamento automatico", + "platformThemeIos": "iOS", + "platformThemeMaterial": "Material (Android)", + "platformThemeNone": "Predefinito" }, "advanced": { "detailedKnowledge": "Conoscenza IA dettagliata", diff --git a/apps/web/public/locales/sv/translation.json b/apps/web/public/locales/sv/translation.json index 6f76e2a5..2427e063 100644 --- a/apps/web/public/locales/sv/translation.json +++ b/apps/web/public/locales/sv/translation.json @@ -865,7 +865,13 @@ "followSystemTheme": "Följ systemtema", "followSystemDescription": "Matcha automatiskt ditt systems mörkt-/ljust-läge", "backgroundAnimations": "Bakgrundsanimationer", - "animationsDescription": "Visa animerade bakgrundseffekter" + "animationsDescription": "Visa animerade bakgrundseffekter", + "platformTheme": "Plattformstema", + "platformThemeDescription": "Anpassa utseendet efter din enhet", + "platformThemeAuto": "Automatisk", + "platformThemeIos": "iOS", + "platformThemeMaterial": "Material (Android)", + "platformThemeNone": "Standard" }, "pourOver": { "title": "Pour Over", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index edf8dc32..39629005 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -37,6 +37,7 @@ import type { APIResponse, ViewState } from '@/types' import { AmbientBackground } from '@/components/AmbientBackground' import { useBackgroundBlobs } from '@/hooks/useBackgroundBlobs' import { useThemePreference } from '@/hooks/useThemePreference' +import { usePlatformTheme } from '@/hooks/usePlatformTheme' import { Sun, Moon } from '@phosphor-icons/react' import { AI_PREFS_CHANGED_EVENT, getAiEnabled, getHideAiWhenUnavailable, getAutoSync, getAutoSyncAiDescription, syncAutoSyncFromServer } from '@/lib/aiPreferences' @@ -252,6 +253,7 @@ function App() { // Theme preference (light/dark/system) const { mounted: themeMounted, isDark, isFollowSystem, toggleTheme, setFollowSystem } = useThemePreference() + const { theme: platformTheme, setTheme: setPlatformTheme } = usePlatformTheme() const isHome = viewState === 'start' @@ -1020,6 +1022,8 @@ function App() { isFollowSystem={isFollowSystem} onToggleTheme={toggleTheme} onSetFollowSystem={setFollowSystem} + platformTheme={platformTheme} + onSetPlatformTheme={setPlatformTheme} /> )} diff --git a/apps/web/src/components/SettingsView.tsx b/apps/web/src/components/SettingsView.tsx index 4e587588..b5945eeb 100644 --- a/apps/web/src/components/SettingsView.tsx +++ b/apps/web/src/components/SettingsView.tsx @@ -6,6 +6,7 @@ import { Card } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Alert, AlertDescription } from '@/components/ui/alert' import { Progress } from '@/components/ui/progress' import { hasFeature } from '@/lib/featureFlags' @@ -39,6 +40,7 @@ import { useUpdateStatus } from '@/hooks/useUpdateStatus' import { useUpdateTrigger } from '@/hooks/useUpdateTrigger' import { MarkdownText } from '@/components/MarkdownText' import { LanguageSelector } from '@/components/LanguageSelector' +import type { PlatformTheme } from '@/hooks/usePlatformTheme' interface SettingsViewProps { onBack: () => void @@ -48,6 +50,8 @@ interface SettingsViewProps { isFollowSystem?: boolean onToggleTheme?: () => void onSetFollowSystem?: (follow: boolean) => void + platformTheme?: PlatformTheme + onSetPlatformTheme?: (theme: PlatformTheme) => void } interface Settings { @@ -98,7 +102,7 @@ const PROGRESS_UPDATE_INTERVAL = 500 const METICULOUS_ADDON_INSTALL_SNIPPET = 'docker exec -it meticai bash -lc "cd /app/meticulous-addon && python3 -m pip install -r requirements.txt && python3 -m pip install ."' const METICULOUS_ADDON_UPDATE_SNIPPET = 'docker exec -it meticai bash -lc "cd /app/meticulous-addon && git pull --ff-only && python3 -m pip install ."' -export function SettingsView({ onBack, showBlobs, onToggleBlobs, isDark, isFollowSystem, onToggleTheme, onSetFollowSystem }: SettingsViewProps) { +export function SettingsView({ onBack, showBlobs, onToggleBlobs, isDark, isFollowSystem, onToggleTheme, onSetFollowSystem, platformTheme, onSetPlatformTheme }: SettingsViewProps) { const { t } = useTranslation() const [settings, setSettings] = useState({ @@ -1059,7 +1063,7 @@ export function SettingsView({ onBack, showBlobs, onToggleBlobs, isDark, isFollo } {/* Appearance */} - {(onToggleBlobs !== undefined || onToggleTheme !== undefined) && ( + {(onToggleBlobs !== undefined || onToggleTheme !== undefined || onSetPlatformTheme !== undefined) && (

{t('appearance.title')}

@@ -1110,6 +1114,30 @@ export function SettingsView({ onBack, showBlobs, onToggleBlobs, isDark, isFollo
)} + {/* Platform theme */} + {onSetPlatformTheme !== undefined && ( +
+
+ +

{t('appearance.platformThemeDescription')}

+
+ +
+ )} + )} diff --git a/apps/web/src/hooks/index.ts b/apps/web/src/hooks/index.ts index 27863469..d2da7722 100644 --- a/apps/web/src/hooks/index.ts +++ b/apps/web/src/hooks/index.ts @@ -27,5 +27,8 @@ export { useIsMobile } from './use-mobile'; export { useSwipeNavigation } from './use-swipe-navigation'; +export { usePlatformTheme } from './usePlatformTheme'; +export type { PlatformTheme, DetectedPlatform } from './usePlatformTheme'; + // Accessibility Hooks export * from './a11y'; diff --git a/apps/web/src/hooks/usePlatformTheme.ts b/apps/web/src/hooks/usePlatformTheme.ts new file mode 100644 index 00000000..5cfbdfe4 --- /dev/null +++ b/apps/web/src/hooks/usePlatformTheme.ts @@ -0,0 +1,49 @@ +import { useState, useLayoutEffect, useCallback } from 'react' +import { STORAGE_KEYS } from '@/lib/constants' + +export type PlatformTheme = 'auto' | 'ios' | 'material' | 'none' +export type DetectedPlatform = 'ios' | 'android' | 'desktop' + +function detectPlatform(): DetectedPlatform { + if (typeof navigator === 'undefined') return 'desktop' + const ua = navigator.userAgent + if (/iPhone|iPad|iPod/.test(ua)) return 'ios' + if (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1) return 'ios' + if (/Android/.test(ua)) return 'android' + return 'desktop' +} + +function resolveThemeClass(pref: PlatformTheme, detected: DetectedPlatform): string | null { + if (pref === 'none') return null + if (pref === 'ios') return 'ios-theme' + if (pref === 'material') return 'material-theme' + if (detected === 'ios') return 'ios-theme' + if (detected === 'android') return 'material-theme' + return null +} + +export function usePlatformTheme() { + const [platform] = useState(detectPlatform) + const [theme, setThemeState] = useState(() => { + try { + const stored = localStorage.getItem(STORAGE_KEYS.PLATFORM_THEME) + if (stored === 'ios' || stored === 'material' || stored === 'none') return stored + } catch { /* noop */ } + return 'auto' + }) + + const setTheme = useCallback((next: PlatformTheme) => { + setThemeState(next) + try { localStorage.setItem(STORAGE_KEYS.PLATFORM_THEME, next) } catch { /* noop */ } + }, []) + + useLayoutEffect(() => { + 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]) + + return { platform, theme, setTheme } as const +} diff --git a/apps/web/src/lib/constants.ts b/apps/web/src/lib/constants.ts index 6b3241f3..e04ffb03 100644 --- a/apps/web/src/lib/constants.ts +++ b/apps/web/src/lib/constants.ts @@ -15,4 +15,7 @@ export const STORAGE_KEYS = { PROFILE_LIST_CACHE: 'meticai-direct-profile-list', DESCRIPTION_CACHE: 'meticai-direct-desc-cache', POUR_OVER_PREFS: 'meticai-direct-pour-over-prefs', + + // -- Appearance -- + PLATFORM_THEME: 'meticai-platform-theme', } as const diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 5d335ab1..dabf7561 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -14,6 +14,8 @@ import './i18n/config' import "./main.css" import "./styles/theme.css" +import "./styles/ios-theme.css" +import "./styles/material-theme.css" import "./index.css" // In direct mode (PWA on machine), intercept MeticAI proxy API calls and either diff --git a/apps/web/src/styles/ios-theme.css b/apps/web/src/styles/ios-theme.css new file mode 100644 index 00000000..494024f7 --- /dev/null +++ b/apps/web/src/styles/ios-theme.css @@ -0,0 +1,17 @@ +/* iOS theme - activated via .ios-theme on */ +.ios-theme #root { + --font-family: -apple-system, "SF Pro Text", "SF Pro Display", system-ui, sans-serif; + --radius-factor: 1.75; +} +.ios-theme body { font-family: -apple-system, "SF Pro Text", "SF Pro Display", system-ui, sans-serif; -webkit-font-smoothing: antialiased; } +.ios-theme [data-slot="card"], .ios-theme .card { backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); background-color: rgba(255,255,255,0.72); } +.ios-theme.dark-theme [data-slot="card"], .ios-theme.dark [data-slot="card"], .ios-theme .dark [data-slot="card"], .ios-theme.dark-theme .card, .ios-theme.dark .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"], .ios-theme .dark [data-slot="switch"][data-state="checked"] { background-color: #30d158 !important; border-color: #30d158 !important; } +.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; } +.ios-theme ::selection { background-color: rgba(0,122,255,0.25); } +.ios-theme [data-slot="select-item"], .ios-theme [data-slot="dropdown-menu-item"] { transition: background-color 0.15s ease; } diff --git a/apps/web/src/styles/material-theme.css b/apps/web/src/styles/material-theme.css new file mode 100644 index 00000000..80c10be1 --- /dev/null +++ b/apps/web/src/styles/material-theme.css @@ -0,0 +1,28 @@ +/* Material You theme - activated via .material-theme on */ +.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, .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); } +.material-theme body { font-family: "Roboto", "Google Sans", system-ui, sans-serif; -webkit-font-smoothing: antialiased; } +.material-theme button, .material-theme [role="button"] { border-radius: 20px; transition: box-shadow 0.2s ease, background-color 0.2s ease; } +.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, .material-theme [role="button"] { position: relative; overflow: hidden; } +.material-theme button:hover::after, .material-theme [role="button"]:hover::after { opacity: 0.08; } +.material-theme button:focus-visible::after, .material-theme [role="button"]:focus-visible::after { opacity: 0.1; } +.material-theme button:active::after, .material-theme [role="button"]:active::after { opacity: 0.12; } +.material-theme [data-slot="card"], .material-theme .card { box-shadow: var(--elevation-1); border-radius: var(--radius-xl); transition: box-shadow 0.2s ease; } +.material-theme [data-slot="card"]:hover, .material-theme .card:hover { box-shadow: var(--elevation-2); } +.material-theme [data-slot="switch"][data-state="checked"] { background-color: #006b5e !important; border-color: #006b5e !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; } +.material-theme [data-slot="select-trigger"] { border-radius: 12px; } +.material-theme [data-slot="select-content"] { border-radius: 12px; box-shadow: var(--elevation-2); } +.material-theme [data-slot="select-item"] { border-radius: 8px; transition: background-color 0.15s ease; } +.material-theme input, .material-theme textarea { border-radius: 12px; } +.material-theme * { transition-timing-function: cubic-bezier(0.2, 0, 0, 1); }