Parcourir la source

Add five theme presets alongside light and dark (#243)

* Add five theme presets alongside light and dark

Themes are declared as CSS variable blocks on top of a light/dark mode class,
so the dark: variant and mode-scoped rules keep working for every preset. The
settings pages get a swatch picker, the sidebar a theme submenu, and public
portal/share pages still force light.

* icons
Bernt Christian Egeland il y a 1 mois
Parent
commit
e16bd6668b

+ 1 - 2
messages/de/navigation.json

@@ -102,8 +102,7 @@
     "companyPlaceholder": "z.B. Müllers Autowerkstatt",
     "cancel": "Abbrechen",
     "createCompany": "Unternehmen erstellen",
-    "lightMode": "Heller Modus",
-    "darkMode": "Dunkler Modus",
+    "theme": "Design",
     "signOut": "Abmelden",
     "more": "Mehr",
     "auditLog": "Aktivitätsprotokoll",

+ 12 - 2
messages/de/settings.json

@@ -856,8 +856,18 @@
   "appearance": {
     "title": "Darstellung",
     "description": "Passen Sie das Erscheinungsbild von Torqvoice an.",
-    "darkMode": "Dunkler Modus",
-    "darkModeHint": "Zwischen hellem und dunklem Design wechseln",
+    "themeLabel": "Design",
+    "themeHint": "Wählen Sie das Farbschema der App. Die Auswahl wird auf diesem Gerät gespeichert.",
+    "themes": {
+      "light": "Hell",
+      "dark": "Dunkel",
+      "graphite": "Graphit",
+      "ocean": "Ozean",
+      "forest": "Wald",
+      "midnight": "Mitternacht",
+      "carbon": "Karbon",
+      "system": "System"
+    },
     "dateTimeTitle": "Datum & Uhrzeit",
     "dateTimeDescription": "Konfigurieren Sie, wie Datum und Uhrzeit in der Anwendung angezeigt werden.",
     "dateFormat": "Datumsformat",

+ 1 - 2
messages/en/navigation.json

@@ -102,8 +102,7 @@
     "companyPlaceholder": "e.g. Joe's Auto Repair",
     "cancel": "Cancel",
     "createCompany": "Create Company",
-    "lightMode": "Light Mode",
-    "darkMode": "Dark Mode",
+    "theme": "Theme",
     "signOut": "Sign Out",
     "more": "More",
     "auditLog": "Audit Log",

+ 12 - 2
messages/en/settings.json

@@ -856,8 +856,18 @@
   "appearance": {
     "title": "Appearance",
     "description": "Customize how Torqvoice looks and feels.",
-    "darkMode": "Dark Mode",
-    "darkModeHint": "Switch between light and dark themes",
+    "themeLabel": "Theme",
+    "themeHint": "Choose the color scheme for the app. The choice is saved on this device.",
+    "themes": {
+      "light": "Light",
+      "dark": "Dark",
+      "graphite": "Graphite",
+      "ocean": "Ocean",
+      "forest": "Forest",
+      "midnight": "Midnight",
+      "carbon": "Carbon",
+      "system": "System"
+    },
     "dateTimeTitle": "Date & Time",
     "dateTimeDescription": "Configure how dates and times are displayed across the application.",
     "dateFormat": "Date Format",

+ 1 - 2
messages/es/navigation.json

@@ -102,8 +102,7 @@
     "companyPlaceholder": "ej. Taller Mecánico López",
     "cancel": "Cancelar",
     "createCompany": "Crear empresa",
-    "lightMode": "Modo claro",
-    "darkMode": "Modo oscuro",
+    "theme": "Tema",
     "signOut": "Cerrar sesión",
     "more": "Más",
     "auditLog": "Registro de auditoría",

+ 12 - 2
messages/es/settings.json

@@ -856,8 +856,18 @@
   "appearance": {
     "title": "Apariencia",
     "description": "Personalice como se ve y se siente Torqvoice.",
-    "darkMode": "Modo oscuro",
-    "darkModeHint": "Cambiar entre temas claro y oscuro",
+    "themeLabel": "Tema",
+    "themeHint": "Elige la combinación de colores de la aplicación. La elección se guarda en este dispositivo.",
+    "themes": {
+      "light": "Claro",
+      "dark": "Oscuro",
+      "graphite": "Grafito",
+      "ocean": "Océano",
+      "forest": "Bosque",
+      "midnight": "Medianoche",
+      "carbon": "Carbono",
+      "system": "Sistema"
+    },
     "dateTimeTitle": "Fecha y hora",
     "dateTimeDescription": "Configure como se muestran las fechas y horas en la aplicación.",
     "dateFormat": "Formato de fecha",

+ 1 - 2
messages/fr/navigation.json

@@ -102,8 +102,7 @@
     "companyPlaceholder": "ex. Garage Auto Dupont",
     "cancel": "Annuler",
     "createCompany": "Créer l'entreprise",
-    "lightMode": "Mode clair",
-    "darkMode": "Mode sombre",
+    "theme": "Thème",
     "signOut": "Se déconnecter",
     "more": "Plus",
     "auditLog": "Journal d'audit",

+ 12 - 2
messages/fr/settings.json

@@ -856,8 +856,18 @@
   "appearance": {
     "title": "Apparence",
     "description": "Personnalisez l'apparence de Torqvoice.",
-    "darkMode": "Mode sombre",
-    "darkModeHint": "Basculer entre les themes clair et sombre",
+    "themeLabel": "Thème",
+    "themeHint": "Choisissez la palette de couleurs de l'application. Le choix est enregistré sur cet appareil.",
+    "themes": {
+      "light": "Clair",
+      "dark": "Sombre",
+      "graphite": "Graphite",
+      "ocean": "Océan",
+      "forest": "Forêt",
+      "midnight": "Minuit",
+      "carbon": "Carbone",
+      "system": "Système"
+    },
     "dateTimeTitle": "Date et heure",
     "dateTimeDescription": "Configurez l'affichage des dates et heures dans l'application.",
     "dateFormat": "Format de date",

+ 1 - 2
messages/it/navigation.json

@@ -102,8 +102,7 @@
     "companyPlaceholder": "es. Officina Mario Rossi",
     "cancel": "Annulla",
     "createCompany": "Crea azienda",
-    "lightMode": "Modalità chiara",
-    "darkMode": "Modalità scura",
+    "theme": "Tema",
     "signOut": "Esci",
     "more": "Altro",
     "auditLog": "Registro attività",

+ 12 - 2
messages/it/settings.json

@@ -856,8 +856,18 @@
   "appearance": {
     "title": "Aspetto",
     "description": "Personalizza l'aspetto di Torqvoice.",
-    "darkMode": "Modalita scura",
-    "darkModeHint": "Passa tra tema chiaro e scuro",
+    "themeLabel": "Tema",
+    "themeHint": "Scegli la combinazione di colori dell'app. La scelta viene salvata su questo dispositivo.",
+    "themes": {
+      "light": "Chiaro",
+      "dark": "Scuro",
+      "graphite": "Grafite",
+      "ocean": "Oceano",
+      "forest": "Foresta",
+      "midnight": "Mezzanotte",
+      "carbon": "Carbonio",
+      "system": "Sistema"
+    },
     "dateTimeTitle": "Data e ora",
     "dateTimeDescription": "Configura come date e orari vengono visualizzati nell'applicazione.",
     "dateFormat": "Formato data",

+ 1 - 2
messages/lt/navigation.json

@@ -102,8 +102,7 @@
     "companyPlaceholder": "pvz., Jono autoservisas",
     "cancel": "Atšaukti",
     "createCompany": "Sukurti įmonę",
-    "lightMode": "Šviesus režimas",
-    "darkMode": "Tamsus režimas",
+    "theme": "Tema",
     "signOut": "Atsijungti",
     "more": "Daugiau",
     "auditLog": "Audito žurnalas",

+ 12 - 2
messages/lt/settings.json

@@ -856,8 +856,18 @@
   "appearance": {
     "title": "Išvaizda",
     "description": "Pritaikykite, kaip atrodo ir jaučiasi Torqvoice.",
-    "darkMode": "Tamsus režimas",
-    "darkModeHint": "Perjungti tarp šviesios ir tamsios temos",
+    "themeLabel": "Tema",
+    "themeHint": "Pasirinkite programos spalvų schemą. Pasirinkimas išsaugomas šiame įrenginyje.",
+    "themes": {
+      "light": "Šviesi",
+      "dark": "Tamsi",
+      "graphite": "Grafitas",
+      "ocean": "Vandenynas",
+      "forest": "Miškas",
+      "midnight": "Vidurnaktis",
+      "carbon": "Karbonas",
+      "system": "Sistemos"
+    },
     "dateTimeTitle": "Data ir laikas",
     "dateTimeDescription": "Konfigūruokite, kaip datos ir laikai rodomi visoje programoje.",
     "dateFormat": "Datos formatas",

+ 1 - 2
messages/nb/navigation.json

@@ -102,8 +102,7 @@
     "companyPlaceholder": "f.eks. Olas Bilverksted",
     "cancel": "Avbryt",
     "createCompany": "Opprett firma",
-    "lightMode": "Lyst modus",
-    "darkMode": "Mørkt modus",
+    "theme": "Tema",
     "signOut": "Logg ut",
     "more": "Mer",
     "auditLog": "Aktivitetslogg",

+ 12 - 2
messages/nb/settings.json

@@ -856,8 +856,18 @@
   "appearance": {
     "title": "Utseende",
     "description": "Tilpass hvordan Torqvoice ser ut og foler seg.",
-    "darkMode": "Mork modus",
-    "darkModeHint": "Bytt mellom lyse og morke temaer",
+    "themeLabel": "Tema",
+    "themeHint": "Velg fargetema for appen. Valget lagres på denne enheten.",
+    "themes": {
+      "light": "Lys",
+      "dark": "Mørk",
+      "graphite": "Grafitt",
+      "ocean": "Hav",
+      "forest": "Skog",
+      "midnight": "Midnatt",
+      "carbon": "Karbon",
+      "system": "System"
+    },
     "dateTimeTitle": "Dato og tid",
     "dateTimeDescription": "Konfigurer hvordan datoer og tidspunkter vises i applikasjonen.",
     "dateFormat": "Datoformat",

+ 1 - 2
messages/nl/navigation.json

@@ -102,8 +102,7 @@
     "companyPlaceholder": "bijv. Jans Autogarage",
     "cancel": "Annuleren",
     "createCompany": "Bedrijf aanmaken",
-    "lightMode": "Lichte modus",
-    "darkMode": "Donkere modus",
+    "theme": "Thema",
     "signOut": "Uitloggen",
     "more": "Meer",
     "auditLog": "Activiteitenlog",

+ 12 - 2
messages/nl/settings.json

@@ -856,8 +856,18 @@
   "appearance": {
     "title": "Weergave",
     "description": "Pas aan hoe Torqvoice eruitziet.",
-    "darkMode": "Donkere modus",
-    "darkModeHint": "Schakelen tussen licht en donker thema",
+    "themeLabel": "Thema",
+    "themeHint": "Kies het kleurenschema van de app. De keuze wordt op dit apparaat opgeslagen.",
+    "themes": {
+      "light": "Licht",
+      "dark": "Donker",
+      "graphite": "Grafiet",
+      "ocean": "Oceaan",
+      "forest": "Bos",
+      "midnight": "Middernacht",
+      "carbon": "Carbon",
+      "system": "Systeem"
+    },
     "dateTimeTitle": "Datum en tijd",
     "dateTimeDescription": "Configureer hoe datums en tijden worden weergegeven in de applicatie.",
     "dateFormat": "Datumformaat",

+ 1 - 2
messages/pl/navigation.json

@@ -102,8 +102,7 @@
     "companyPlaceholder": "np. Warsztat Kowalskiego",
     "cancel": "Anuluj",
     "createCompany": "Utwórz firmę",
-    "lightMode": "Tryb jasny",
-    "darkMode": "Tryb ciemny",
+    "theme": "Motyw",
     "signOut": "Wyloguj się",
     "more": "Więcej",
     "auditLog": "Dziennik audytu",

+ 12 - 2
messages/pl/settings.json

@@ -856,8 +856,18 @@
   "appearance": {
     "title": "Wygląd",
     "description": "Dostosuj wygląd i styl Torqvoice.",
-    "darkMode": "Tryb ciemny",
-    "darkModeHint": "Przelacz między jasnym a ciemnym motywem",
+    "themeLabel": "Motyw",
+    "themeHint": "Wybierz kolorystykę aplikacji. Wybór jest zapisywany na tym urządzeniu.",
+    "themes": {
+      "light": "Jasny",
+      "dark": "Ciemny",
+      "graphite": "Grafit",
+      "ocean": "Ocean",
+      "forest": "Las",
+      "midnight": "Północ",
+      "carbon": "Karbon",
+      "system": "Systemowy"
+    },
     "dateTimeTitle": "Data i czas",
     "dateTimeDescription": "Skonfiguruj sposób wyświetlania dat i godzin w aplikacji.",
     "dateFormat": "Format daty",

+ 1 - 2
messages/pt-BR/navigation.json

@@ -102,8 +102,7 @@
     "companyPlaceholder": "ex. Oficina do João",
     "cancel": "Cancelar",
     "createCompany": "Criar Empresa",
-    "lightMode": "Modo Claro",
-    "darkMode": "Modo Escuro",
+    "theme": "Tema",
     "signOut": "Sair",
     "more": "Mais",
     "auditLog": "Registro de auditoria",

+ 12 - 2
messages/pt-BR/settings.json

@@ -856,8 +856,18 @@
   "appearance": {
     "title": "Aparência",
     "description": "Personalize a aparência do Torqvoice.",
-    "darkMode": "Modo escuro",
-    "darkModeHint": "Alternar entre temas claro e escuro",
+    "themeLabel": "Tema",
+    "themeHint": "Escolha o esquema de cores do aplicativo. A escolha é salva neste dispositivo.",
+    "themes": {
+      "light": "Claro",
+      "dark": "Escuro",
+      "graphite": "Grafite",
+      "ocean": "Oceano",
+      "forest": "Floresta",
+      "midnight": "Meia-noite",
+      "carbon": "Carbono",
+      "system": "Sistema"
+    },
     "dateTimeTitle": "Data e hora",
     "dateTimeDescription": "Configure como datas e horários sao exibidos no aplicativo.",
     "dateFormat": "Formato de data",

+ 1 - 2
messages/ru/navigation.json

@@ -102,8 +102,7 @@
     "companyPlaceholder": "напр. Автосервис Ивана",
     "cancel": "Отмена",
     "createCompany": "Создать компанию",
-    "lightMode": "Светлая тема",
-    "darkMode": "Тёмная тема",
+    "theme": "Тема",
     "signOut": "Выйти",
     "more": "Ещё",
     "auditLog": "Журнал аудита",

+ 12 - 2
messages/ru/settings.json

@@ -856,8 +856,18 @@
   "appearance": {
     "title": "Внешний вид",
     "description": "Настройте внешний вид Torqvoice.",
-    "darkMode": "Тёмная тема",
-    "darkModeHint": "Переключение между светлой и тёмной темами",
+    "themeLabel": "Тема",
+    "themeHint": "Выберите цветовую схему приложения. Выбор сохраняется на этом устройстве.",
+    "themes": {
+      "light": "Светлая",
+      "dark": "Тёмная",
+      "graphite": "Графит",
+      "ocean": "Океан",
+      "forest": "Лес",
+      "midnight": "Полночь",
+      "carbon": "Карбон",
+      "system": "Системная"
+    },
     "dateTimeTitle": "Дата и время",
     "dateTimeDescription": "Настройте отображение даты и времени в приложении.",
     "dateFormat": "Формат даты",

+ 1 - 2
messages/tr/navigation.json

@@ -102,8 +102,7 @@
     "companyPlaceholder": "ör. Ali'nin Oto Tamircisi",
     "cancel": "İptal",
     "createCompany": "Şirket Oluştur",
-    "lightMode": "Açık Mod",
-    "darkMode": "Koyu Mod",
+    "theme": "Tema",
     "signOut": "Çıkış Yap",
     "more": "Diğer",
     "auditLog": "Denetim günlüğü",

+ 12 - 2
messages/tr/settings.json

@@ -856,8 +856,18 @@
   "appearance": {
     "title": "Görünüm",
     "description": "Torqvoice'un görünümünü ve hissini özelleştirin.",
-    "darkMode": "Karanlık Mod",
-    "darkModeHint": "Açık ve koyu temalar arasında geçiş yapın",
+    "themeLabel": "Tema",
+    "themeHint": "Uygulamanın renk şemasını seçin. Seçim bu cihazda kaydedilir.",
+    "themes": {
+      "light": "Açık",
+      "dark": "Koyu",
+      "graphite": "Grafit",
+      "ocean": "Okyanus",
+      "forest": "Orman",
+      "midnight": "Gece Yarısı",
+      "carbon": "Karbon",
+      "system": "Sistem"
+    },
     "dateTimeTitle": "Tarih & Saat",
     "dateTimeDescription": "Uygulama genelinde tarihlerin ve saatlerin nasıl görüntüleneceğini yapılandırın.",
     "dateFormat": "Tarih Formatı",

+ 7 - 23
src/app/(authenticated)/settings/appearance/appearance-settings.tsx

@@ -5,10 +5,9 @@ import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
 import { Label } from '@/components/ui/label'
-import { Switch } from '@/components/ui/switch'
 import { Button } from '@/components/ui/button'
-import { useTheme } from '@/components/theme-provider'
-import { Calendar, Clock, Loader2, Moon, Palette, Save, Sun } from 'lucide-react'
+import { ThemePicker } from '@/components/theme-picker'
+import { Calendar, Clock, Loader2, Palette, Save } from 'lucide-react'
 import {
   Select,
   SelectContent,
@@ -82,7 +81,6 @@ const TIMEZONE_OPTIONS = [
 
 export function AppearanceSettings({ settings }: { settings: Record<string, string> }) {
   const t = useTranslations('settings')
-  const { theme, setTheme } = useTheme()
   const router = useRouter()
   const [saving, setSaving] = useState(false)
 
@@ -109,28 +107,14 @@ export function AppearanceSettings({ settings }: { settings: Record<string, stri
           <Palette className="h-5 w-5 text-muted-foreground" />
           <CardTitle className="text-lg">{t('appearance.title')}</CardTitle>
         </CardHeader>
-        <CardContent className="space-y-6">
+        <CardContent className="space-y-4">
           <p className="text-sm text-muted-foreground">{t('appearance.description')}</p>
 
-          <div className="flex items-center justify-between rounded-lg border p-4">
-            <div className="flex items-center gap-3">
-              {theme === 'dark' ? (
-                <Moon className="h-5 w-5 text-muted-foreground" />
-              ) : (
-                <Sun className="h-5 w-5 text-muted-foreground" />
-              )}
-              <div>
-                <Label className="text-sm font-medium">{t('appearance.darkMode')}</Label>
-                <p className="text-xs text-muted-foreground">
-                  {t('appearance.darkModeHint')}
-                </p>
-              </div>
-            </div>
-            <Switch
-              checked={theme === 'dark'}
-              onCheckedChange={(checked) => setTheme(checked ? 'dark' : 'light')}
-            />
+          <div>
+            <Label className="text-sm font-medium">{t('appearance.themeLabel')}</Label>
+            <p className="text-xs text-muted-foreground">{t('appearance.themeHint')}</p>
           </div>
+          <ThemePicker />
         </CardContent>
       </Card>
 

+ 6 - 21
src/app/(authenticated)/settings/localization/localization-settings.tsx

@@ -37,14 +37,12 @@ import {
   Coins,
   Globe,
   Loader2,
-  Moon,
   Palette,
   Save,
-  Sun,
 } from 'lucide-react'
 import { cn } from '@/lib/utils'
 import { formatCurrency, formatDate, formatDateTime, DEFAULT_DATE_FORMAT } from '@/lib/format'
-import { useTheme } from '@/components/theme-provider'
+import { ThemePicker } from '@/components/theme-picker'
 import { setLocale } from '@/i18n/actions'
 import { locales, localeNames } from '@/i18n/config'
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
@@ -170,7 +168,6 @@ const TIMEZONE_OPTIONS = [
 export function LocalizationSettings({ settings }: { settings: Record<string, string> }) {
   const router = useRouter()
   const t = useTranslations('settings')
-  const { theme, setTheme } = useTheme()
   const currentLocale = useLocale()
   const [saving, setSaving] = useState(false)
   const [currencyOpen, setCurrencyOpen] = useState(false)
@@ -590,24 +587,12 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
           <Palette className="h-5 w-5 text-muted-foreground" />
           <CardTitle className="text-lg">{t('appearance.title')}</CardTitle>
         </CardHeader>
-        <CardContent className="space-y-6">
-          <div className="flex items-center justify-between rounded-lg border p-4">
-            <div className="flex items-center gap-3">
-              {mounted && theme === 'dark' ? (
-                <Moon className="h-5 w-5 text-muted-foreground" />
-              ) : (
-                <Sun className="h-5 w-5 text-muted-foreground" />
-              )}
-              <div>
-                <Label className="text-sm font-medium">{t('appearance.darkMode')}</Label>
-                <p className="text-xs text-muted-foreground">{t('appearance.darkModeHint')}</p>
-              </div>
-            </div>
-            <Switch
-              checked={mounted && theme === 'dark'}
-              onCheckedChange={(checked) => setTheme(checked ? 'dark' : 'light')}
-            />
+        <CardContent className="space-y-4">
+          <div>
+            <Label className="text-sm font-medium">{t('appearance.themeLabel')}</Label>
+            <p className="text-xs text-muted-foreground">{t('appearance.themeHint')}</p>
           </div>
+          <ThemePicker />
         </CardContent>
       </Card>
 

+ 2 - 13
src/app/(public)/portal/layout.tsx

@@ -1,20 +1,9 @@
 'use client'
 
-import { useLayoutEffect } from 'react'
+import { useForceLightTheme } from '@/hooks/use-force-light-theme'
 
 export default function PortalLayout({ children }: { children: React.ReactNode }) {
-  useLayoutEffect(() => {
-    const root = document.documentElement
-    const hadDark = root.classList.contains('dark')
-    root.setAttribute('data-force-theme', 'light')
-    root.classList.remove('dark')
-    root.classList.add('light')
-    return () => {
-      root.removeAttribute('data-force-theme')
-      root.classList.remove('light')
-      if (hadDark) root.classList.add('dark')
-    }
-  }, [])
+  useForceLightTheme()
 
   return <>{children}</>
 }

+ 2 - 13
src/app/(public)/share/layout.tsx

@@ -1,20 +1,9 @@
 'use client'
 
-import { useLayoutEffect } from 'react'
+import { useForceLightTheme } from '@/hooks/use-force-light-theme'
 
 export default function ShareLayout({ children }: { children: React.ReactNode }) {
-  useLayoutEffect(() => {
-    const root = document.documentElement
-    const hadDark = root.classList.contains('dark')
-    root.setAttribute('data-force-theme', 'light')
-    root.classList.remove('dark')
-    root.classList.add('light')
-    return () => {
-      root.removeAttribute('data-force-theme')
-      root.classList.remove('light')
-      if (hadDark) root.classList.add('dark')
-    }
-  }, [])
+  useForceLightTheme()
 
   return <>{children}</>
 }

+ 198 - 18
src/app/globals.css

@@ -117,6 +117,192 @@
   --sidebar-ring: oklch(0.78 0.16 80);
 }
 
+/* ── Theme presets ───────────────────────────────────────────────────────
+   Each preset is applied on top of its base mode class (`light`/`dark`), so
+   `dark:` utilities and the `.dark`-scoped rules further down keep working.
+   These blocks must stay after `:root` and `.dark`: they carry the same
+   specificity, so source order is what makes them win. ── */
+
+/* ── Graphite (light): neutral grey + steel blue ── */
+.theme-graphite {
+  --background: oklch(0.98 0.002 250);
+  --foreground: oklch(0.20 0.01 250);
+  --card: oklch(1 0 0);
+  --card-foreground: oklch(0.20 0.01 250);
+  --popover: oklch(1 0 0);
+  --popover-foreground: oklch(0.20 0.01 250);
+  --primary: oklch(0.52 0.13 250);
+  --primary-foreground: oklch(0.99 0 0);
+  --secondary: oklch(0.95 0.005 250);
+  --secondary-foreground: oklch(0.30 0.012 250);
+  --muted: oklch(0.96 0.004 250);
+  --muted-foreground: oklch(0.50 0.015 250);
+  --accent: oklch(0.93 0.02 250);
+  --accent-foreground: oklch(0.26 0.02 250);
+  --destructive: oklch(0.55 0.22 27);
+  --destructive-foreground: oklch(0.99 0 0);
+  --border: oklch(0.91 0.006 250);
+  --input: oklch(0.91 0.006 250);
+  --ring: oklch(0.52 0.13 250);
+  --chart-1: oklch(0.52 0.13 250);
+  --chart-2: oklch(0.62 0.12 190);
+  --chart-3: oklch(0.72 0.15 75);
+  --chart-4: oklch(0.55 0.14 300);
+  --chart-5: oklch(0.58 0.16 20);
+  --sidebar: oklch(0.965 0.004 250);
+  --sidebar-foreground: oklch(0.20 0.01 250);
+  --sidebar-primary: oklch(0.52 0.13 250);
+  --sidebar-primary-foreground: oklch(0.99 0 0);
+  --sidebar-accent: oklch(0.93 0.02 250);
+  --sidebar-accent-foreground: oklch(0.26 0.02 250);
+  --sidebar-border: oklch(0.91 0.006 250);
+  --sidebar-ring: oklch(0.52 0.13 250);
+}
+
+/* ── Ocean (light): cool blue paper + marine blue ── */
+.theme-ocean {
+  --background: oklch(0.98 0.012 220);
+  --foreground: oklch(0.20 0.03 235);
+  --card: oklch(0.995 0.006 220);
+  --card-foreground: oklch(0.20 0.03 235);
+  --popover: oklch(0.995 0.006 220);
+  --popover-foreground: oklch(0.20 0.03 235);
+  --primary: oklch(0.52 0.12 215);
+  --primary-foreground: oklch(0.99 0.005 220);
+  --secondary: oklch(0.93 0.025 215);
+  --secondary-foreground: oklch(0.28 0.04 230);
+  --muted: oklch(0.95 0.015 215);
+  --muted-foreground: oklch(0.50 0.035 225);
+  --accent: oklch(0.91 0.045 205);
+  --accent-foreground: oklch(0.26 0.05 230);
+  --destructive: oklch(0.55 0.22 27);
+  --destructive-foreground: oklch(0.99 0 0);
+  --border: oklch(0.90 0.025 215);
+  --input: oklch(0.90 0.025 215);
+  --ring: oklch(0.52 0.12 215);
+  --chart-1: oklch(0.52 0.12 215);
+  --chart-2: oklch(0.65 0.13 175);
+  --chart-3: oklch(0.60 0.14 260);
+  --chart-4: oklch(0.70 0.13 90);
+  --chart-5: oklch(0.58 0.16 340);
+  --sidebar: oklch(0.96 0.018 215);
+  --sidebar-foreground: oklch(0.20 0.03 235);
+  --sidebar-primary: oklch(0.52 0.12 215);
+  --sidebar-primary-foreground: oklch(0.99 0.005 220);
+  --sidebar-accent: oklch(0.91 0.045 205);
+  --sidebar-accent-foreground: oklch(0.26 0.05 230);
+  --sidebar-border: oklch(0.90 0.025 215);
+  --sidebar-ring: oklch(0.52 0.12 215);
+}
+
+/* ── Forest (light): soft sage + deep green ── */
+.theme-forest {
+  --background: oklch(0.98 0.008 140);
+  --foreground: oklch(0.20 0.02 150);
+  --card: oklch(0.995 0.004 140);
+  --card-foreground: oklch(0.20 0.02 150);
+  --popover: oklch(0.995 0.004 140);
+  --popover-foreground: oklch(0.20 0.02 150);
+  --primary: oklch(0.50 0.12 150);
+  --primary-foreground: oklch(0.98 0.01 140);
+  --secondary: oklch(0.93 0.02 145);
+  --secondary-foreground: oklch(0.28 0.03 150);
+  --muted: oklch(0.95 0.012 145);
+  --muted-foreground: oklch(0.50 0.025 150);
+  --accent: oklch(0.92 0.04 145);
+  --accent-foreground: oklch(0.26 0.04 150);
+  --destructive: oklch(0.55 0.22 27);
+  --destructive-foreground: oklch(0.99 0 0);
+  --border: oklch(0.90 0.02 145);
+  --input: oklch(0.90 0.02 145);
+  --ring: oklch(0.50 0.12 150);
+  --chart-1: oklch(0.50 0.12 150);
+  --chart-2: oklch(0.62 0.13 100);
+  --chart-3: oklch(0.60 0.14 200);
+  --chart-4: oklch(0.68 0.15 65);
+  --chart-5: oklch(0.55 0.15 25);
+  --sidebar: oklch(0.96 0.014 145);
+  --sidebar-foreground: oklch(0.20 0.02 150);
+  --sidebar-primary: oklch(0.50 0.12 150);
+  --sidebar-primary-foreground: oklch(0.98 0.01 140);
+  --sidebar-accent: oklch(0.92 0.04 145);
+  --sidebar-accent-foreground: oklch(0.26 0.04 150);
+  --sidebar-border: oklch(0.90 0.02 145);
+  --sidebar-ring: oklch(0.50 0.12 150);
+}
+
+/* ── Midnight (dark): deep navy + sky blue ── */
+.theme-midnight {
+  --background: oklch(0.16 0.03 260);
+  --foreground: oklch(0.93 0.015 250);
+  --card: oklch(0.20 0.035 260);
+  --card-foreground: oklch(0.93 0.015 250);
+  --popover: oklch(0.20 0.035 260);
+  --popover-foreground: oklch(0.93 0.015 250);
+  --primary: oklch(0.70 0.14 245);
+  --primary-foreground: oklch(0.15 0.04 260);
+  --secondary: oklch(0.26 0.035 260);
+  --secondary-foreground: oklch(0.90 0.015 250);
+  --muted: oklch(0.24 0.03 260);
+  --muted-foreground: oklch(0.65 0.03 250);
+  --accent: oklch(0.30 0.05 255);
+  --accent-foreground: oklch(0.92 0.02 250);
+  --destructive: oklch(0.55 0.22 27);
+  --destructive-foreground: oklch(0.98 0 0);
+  --border: oklch(0.30 0.04 260);
+  --input: oklch(0.30 0.04 260);
+  --ring: oklch(0.70 0.14 245);
+  --chart-1: oklch(0.70 0.14 245);
+  --chart-2: oklch(0.72 0.13 190);
+  --chart-3: oklch(0.70 0.14 300);
+  --chart-4: oklch(0.78 0.14 85);
+  --chart-5: oklch(0.68 0.16 350);
+  --sidebar: oklch(0.18 0.032 260);
+  --sidebar-foreground: oklch(0.93 0.015 250);
+  --sidebar-primary: oklch(0.70 0.14 245);
+  --sidebar-primary-foreground: oklch(0.15 0.04 260);
+  --sidebar-accent: oklch(0.30 0.05 255);
+  --sidebar-accent-foreground: oklch(0.92 0.02 250);
+  --sidebar-border: oklch(0.30 0.04 260);
+  --sidebar-ring: oklch(0.70 0.14 245);
+}
+
+/* ── Carbon (dark): true neutral black + mint ── */
+.theme-carbon {
+  --background: oklch(0.15 0 0);
+  --foreground: oklch(0.94 0 0);
+  --card: oklch(0.19 0 0);
+  --card-foreground: oklch(0.94 0 0);
+  --popover: oklch(0.19 0 0);
+  --popover-foreground: oklch(0.94 0 0);
+  --primary: oklch(0.72 0.15 165);
+  --primary-foreground: oklch(0.16 0.02 165);
+  --secondary: oklch(0.25 0 0);
+  --secondary-foreground: oklch(0.92 0 0);
+  --muted: oklch(0.22 0 0);
+  --muted-foreground: oklch(0.66 0 0);
+  --accent: oklch(0.28 0.02 165);
+  --accent-foreground: oklch(0.94 0 0);
+  --destructive: oklch(0.58 0.22 27);
+  --destructive-foreground: oklch(0.98 0 0);
+  --border: oklch(0.27 0 0);
+  --input: oklch(0.27 0 0);
+  --ring: oklch(0.72 0.15 165);
+  --chart-1: oklch(0.72 0.15 165);
+  --chart-2: oklch(0.70 0.13 220);
+  --chart-3: oklch(0.75 0.15 90);
+  --chart-4: oklch(0.65 0.15 300);
+  --chart-5: oklch(0.62 0.18 20);
+  --sidebar: oklch(0.17 0 0);
+  --sidebar-foreground: oklch(0.94 0 0);
+  --sidebar-primary: oklch(0.72 0.15 165);
+  --sidebar-primary-foreground: oklch(0.16 0.02 165);
+  --sidebar-accent: oklch(0.28 0.02 165);
+  --sidebar-accent-foreground: oklch(0.94 0 0);
+  --sidebar-border: oklch(0.27 0 0);
+  --sidebar-ring: oklch(0.72 0.15 165);
+}
+
 @layer base {
   * {
     @apply border-border outline-ring/50;
@@ -147,29 +333,29 @@
 ::-webkit-scrollbar-track {
   background: transparent;
 }
+/* Derived from the theme tokens so every preset gets a matching thumb */
 ::-webkit-scrollbar-thumb {
-  background: oklch(0.75 0.04 80);
+  background: color-mix(in oklab, var(--muted-foreground) 45%, transparent);
   border-radius: 3px;
 }
 .dark ::-webkit-scrollbar-thumb {
-  background: oklch(0.32 0.02 70);
+  background: color-mix(in oklab, var(--muted-foreground) 40%, transparent);
 }
 
 /* Glass effect utility */
 .glass {
-  background: oklch(0.995 0.004 85 / 0.7);
+  background: color-mix(in oklab, var(--card) 70%, transparent);
   backdrop-filter: blur(16px);
-  border: 1px solid oklch(0.88 0.03 80 / 0.5);
-}
-.dark .glass {
-  background: oklch(0.16 0.012 70 / 0.7);
-  backdrop-filter: blur(16px);
-  border: 1px solid oklch(0.28 0.02 75 / 0.5);
+  border: 1px solid color-mix(in oklab, var(--border) 60%, transparent);
 }
 
 /* Gradient text */
 .gradient-text {
-  background: linear-gradient(135deg, oklch(0.72 0.17 75), oklch(0.65 0.18 50));
+  background: linear-gradient(
+    135deg,
+    var(--primary),
+    color-mix(in oklab, var(--primary) 55%, var(--chart-3))
+  );
   -webkit-background-clip: text;
   -webkit-text-fill-color: transparent;
   background-clip: text;
@@ -178,14 +364,8 @@
 /* Subtle grid bg */
 .grid-bg {
   background-image:
-    linear-gradient(oklch(0.88 0.02 80 / 0.3) 1px, transparent 1px),
-    linear-gradient(90deg, oklch(0.88 0.02 80 / 0.3) 1px, transparent 1px);
-  background-size: 40px 40px;
-}
-.dark .grid-bg {
-  background-image:
-    linear-gradient(oklch(0.22 0.015 70 / 0.3) 1px, transparent 1px),
-    linear-gradient(90deg, oklch(0.22 0.015 70 / 0.3) 1px, transparent 1px);
+    linear-gradient(color-mix(in oklab, var(--border) 45%, transparent) 1px, transparent 1px),
+    linear-gradient(90deg, color-mix(in oklab, var(--border) 45%, transparent) 1px, transparent 1px);
   background-size: 40px 40px;
 }
 

+ 1 - 1
src/app/layout.tsx

@@ -95,7 +95,7 @@ export default async function RootLayout({
         <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
         <script
           dangerouslySetInnerHTML={{
-            __html: `(function(){try{var p=location.pathname;if(p.indexOf('/share/')===0||p.indexOf('/portal')===0){document.documentElement.classList.add('light');return}var t=localStorage.getItem('torqvoice-theme')||'dark';if(t==='system'){t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'}document.documentElement.classList.add(t)}catch(e){}})()`,
+            __html: `(function(){try{var c=document.documentElement.classList;var p=location.pathname;if(p.indexOf('/share/')===0||p.indexOf('/portal')===0){c.add('light');return}var M={light:'light',dark:'dark',graphite:'light',ocean:'light',forest:'light',midnight:'dark',carbon:'dark'};var t=localStorage.getItem('torqvoice-theme')||'dark';if(t==='system'){t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'}var m=M[t];if(!m){t='dark';m='dark'}c.add(m);if(t!==m){c.add('theme-'+t)}}catch(e){}})()`,
           }}
         />
       </head>

+ 35 - 10
src/components/app-sidebar.tsx

@@ -56,14 +56,14 @@ import {
   LayoutDashboard,
   Loader2,
   LogOut,
-  Moon,
+  Monitor,
   Package,
+  Palette,
   Plus,
   Receipt,
   Settings,
   Ship,
   ShieldCheck,
-  Sun,
   Users,
 } from 'lucide-react'
 import { useLocale, useTranslations } from 'next-intl'
@@ -73,6 +73,7 @@ import { locales, localeNames } from '@/i18n/config'
 import { createNewOrganization } from '@/features/team/Actions/createNewOrganization'
 import type { PlanFeatures } from '@/lib/features'
 import { useTheme } from '@/components/theme-provider'
+import { THEMES, type ThemePreference } from '@/lib/themes'
 import { useServiceType } from '@/components/service-type-context'
 import { NotificationBell, NotificationPanel } from '@/features/notifications/Components/NotificationPanel'
 import { SidebarInstallButton } from '@/components/pwa-install-prompt'
@@ -106,6 +107,7 @@ export function AppSidebar({
   const { setOpenMobile, isMobile } = useSidebar()
   const currentLocale = useLocale()
   const t = useTranslations('navigation')
+  const tSettings = useTranslations('settings')
   const [showCreateOrg, setShowCreateOrg] = React.useState(false)
   const [newOrgName, setNewOrgName] = React.useState('')
   const [creatingOrg, setCreatingOrg] = React.useState(false)
@@ -372,14 +374,37 @@ export function AppSidebar({
                     </Link>
                   </DropdownMenuItem>
                 )}
-                <DropdownMenuItem onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
-                  {theme === 'dark' ? (
-                    <Sun className="mr-2 size-4" />
-                  ) : (
-                    <Moon className="mr-2 size-4" />
-                  )}
-                  {theme === 'dark' ? t('sidebar.lightMode') : t('sidebar.darkMode')}
-                </DropdownMenuItem>
+                <DropdownMenuSub>
+                  <DropdownMenuSubTrigger>
+                    <Palette className="mr-2 size-4" />
+                    {t('sidebar.theme')}
+                  </DropdownMenuSubTrigger>
+                  <DropdownMenuSubContent>
+                    <DropdownMenuRadioGroup
+                      value={theme}
+                      onValueChange={(value) => setTheme(value as ThemePreference)}
+                    >
+                      {THEMES.map((definition) => (
+                        <DropdownMenuRadioItem key={definition.id} value={definition.id}>
+                          {/* Split dot: the theme's own background on one half,
+                              its primary on the other, so light and dark
+                              variants of the same hue stay tellable apart */}
+                          <span
+                            className="mr-2 size-3.5 shrink-0 rounded-full border"
+                            style={{
+                              background: `linear-gradient(135deg, ${definition.swatch[0]} 0 50%, ${definition.swatch[2]} 50% 100%)`,
+                            }}
+                          />
+                          {tSettings(`appearance.themes.${definition.id}`)}
+                        </DropdownMenuRadioItem>
+                      ))}
+                      <DropdownMenuRadioItem value="system">
+                        <Monitor className="mr-2 size-3.5 shrink-0" />
+                        {tSettings('appearance.themes.system')}
+                      </DropdownMenuRadioItem>
+                    </DropdownMenuRadioGroup>
+                  </DropdownMenuSubContent>
+                </DropdownMenuSub>
                 <DropdownMenuSub>
                   <DropdownMenuSubTrigger>
                     <Globe className="mr-2 size-4" />

+ 88 - 0
src/components/theme-picker.tsx

@@ -0,0 +1,88 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Check, Monitor } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { useTheme } from '@/components/theme-provider'
+import { THEMES, type ThemePreference } from '@/lib/themes'
+
+function ThemeSwatch({ colors }: { colors: [string, string, string] }) {
+  const [background, surface, primary] = colors
+  return (
+    <span
+      className="flex h-12 w-full items-center overflow-hidden rounded-md border"
+      style={{ background }}
+    >
+      <span className="h-full w-1/4 shrink-0" style={{ background: surface }} />
+      <span className="flex flex-1 items-center justify-center">
+        <span className="h-2.5 w-8 rounded-full" style={{ background: primary }} />
+      </span>
+    </span>
+  )
+}
+
+/**
+ * Grid of theme presets. Selecting one applies it immediately and stores it in
+ * the browser, so there is nothing to save on the server.
+ */
+export function ThemePicker() {
+  const t = useTranslations('settings')
+  const { theme, setTheme } = useTheme()
+
+  // The stored preference is only known on the client, so wait for mount
+  // before marking a card as selected (avoids a hydration mismatch).
+  const [mounted, setMounted] = useState(false)
+  useEffect(() => setMounted(true), [])
+
+  const options: Array<{
+    id: ThemePreference
+    label: string
+    swatch?: [string, string, string]
+  }> = [
+    ...THEMES.map((definition) => ({
+      id: definition.id as ThemePreference,
+      label: t(`appearance.themes.${definition.id}`),
+      swatch: definition.swatch,
+    })),
+    { id: 'system', label: t('appearance.themes.system') },
+  ]
+
+  return (
+    <div
+      className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"
+      role="radiogroup"
+      aria-label={t('appearance.themeLabel')}
+    >
+      {options.map((option) => {
+        const selected = mounted && theme === option.id
+        return (
+          <button
+            key={option.id}
+            type="button"
+            role="radio"
+            aria-checked={selected}
+            onClick={() => setTheme(option.id)}
+            className={cn(
+              'group relative rounded-lg border p-2 text-left transition-colors',
+              'hover:border-primary/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+              selected ? 'border-primary ring-2 ring-primary/40' : 'border-border'
+            )}
+          >
+            {option.swatch ? (
+              <ThemeSwatch colors={option.swatch} />
+            ) : (
+              <span className="flex h-12 w-full items-center justify-center rounded-md border bg-muted">
+                <Monitor className="h-5 w-5 text-muted-foreground" />
+              </span>
+            )}
+            <span className="mt-2 flex items-center justify-between gap-2">
+              <span className="text-sm font-medium">{option.label}</span>
+              {selected && <Check className="h-4 w-4 shrink-0 text-primary" />}
+            </span>
+          </button>
+        )
+      })}
+    </div>
+  )
+}

+ 52 - 21
src/components/theme-provider.tsx

@@ -1,55 +1,86 @@
 'use client'
 
 import { createContext, useContext, useEffect, useState } from 'react'
-
-type Theme = 'dark' | 'light' | 'system'
+import {
+  DEFAULT_THEME,
+  THEME_STORAGE_KEY,
+  type ThemeId,
+  type ThemePreference,
+  applyTheme,
+  getThemeMode,
+  isThemePreference,
+} from '@/lib/themes'
 
 interface ThemeContextType {
-  theme: Theme
-  setTheme: (theme: Theme) => void
+  /** What the user picked, which may be 'system'. */
+  theme: ThemePreference
+  /** The theme actually on screen ('system' already resolved). */
+  resolvedTheme: ThemeId
+  /** Whether the active theme is a light or a dark one. */
+  mode: 'light' | 'dark'
+  setTheme: (theme: ThemePreference) => void
 }
 
 const ThemeContext = createContext<ThemeContextType>({
-  theme: 'dark',
+  theme: DEFAULT_THEME,
+  resolvedTheme: DEFAULT_THEME,
+  mode: getThemeMode(DEFAULT_THEME),
   setTheme: () => {
     // Default empty implementation
   },
 })
 
+function resolvePreference(theme: ThemePreference): ThemeId {
+  if (theme !== 'system') return theme
+  if (typeof window === 'undefined') return DEFAULT_THEME
+  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
+}
+
 export function ThemeProvider({
   children,
-  defaultTheme = 'dark',
+  defaultTheme = DEFAULT_THEME,
 }: {
   children: React.ReactNode
-  defaultTheme?: Theme
+  defaultTheme?: ThemePreference
 }) {
-  const [theme, setTheme] = useState<Theme>(() => {
+  const [theme, setTheme] = useState<ThemePreference>(() => {
     if (typeof window !== 'undefined') {
-      const stored = localStorage.getItem('torqvoice-theme') as Theme | null
-      if (stored) return stored
+      const stored = localStorage.getItem(THEME_STORAGE_KEY)
+      if (isThemePreference(stored)) return stored
     }
     return defaultTheme
   })
 
+  const [resolvedTheme, setResolvedTheme] = useState<ThemeId>(() => resolvePreference(theme))
+
   useEffect(() => {
     const root = window.document.documentElement
 
-    // A child layout can set data-force-theme to override the user preference
-    if (root.hasAttribute('data-force-theme')) return
-
-    root.classList.remove('light', 'dark')
+    const paint = () => {
+      const active = resolvePreference(theme)
+      setResolvedTheme(active)
 
-    if (theme === 'system') {
-      const sys = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
-      root.classList.add(sys)
-    } else {
-      root.classList.add(theme)
+      // A child layout can set data-force-theme to override the user preference
+      if (root.hasAttribute('data-force-theme')) return
+      applyTheme(root, active)
     }
 
-    localStorage.setItem('torqvoice-theme', theme)
+    paint()
+    localStorage.setItem(THEME_STORAGE_KEY, theme)
+
+    if (theme !== 'system') return
+    const media = window.matchMedia('(prefers-color-scheme: dark)')
+    media.addEventListener('change', paint)
+    return () => media.removeEventListener('change', paint)
   }, [theme])
 
-  return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>
+  return (
+    <ThemeContext.Provider
+      value={{ theme, resolvedTheme, mode: getThemeMode(resolvedTheme), setTheme }}
+    >
+      {children}
+    </ThemeContext.Provider>
+  )
 }
 
 export const useTheme = () => useContext(ThemeContext)

+ 10 - 3
src/components/ui/sonner.tsx

@@ -7,15 +7,22 @@ import {
   OctagonXIcon,
   TriangleAlertIcon,
 } from "lucide-react"
-import { useTheme } from "next-themes"
 import { Toaster as Sonner, type ToasterProps } from "sonner"
+import { useEffect, useState } from "react"
+import { useTheme } from "@/components/theme-provider"
+import { DEFAULT_THEME, getThemeMode } from "@/lib/themes"
 
 const Toaster = ({ ...props }: ToasterProps) => {
-  const { theme = "system" } = useTheme()
+  // Presets resolve to a light or dark mode; sonner only understands those.
+  const { mode } = useTheme()
+
+  // The stored theme is client-only, so keep the server default until mount.
+  const [mounted, setMounted] = useState(false)
+  useEffect(() => setMounted(true), [])
 
   return (
     <Sonner
-      theme={theme as ToasterProps["theme"]}
+      theme={mounted ? mode : getThemeMode(DEFAULT_THEME)}
       className="toaster group"
       icons={{
         success: <CircleCheckIcon className="size-4" />,

+ 26 - 0
src/hooks/use-force-light-theme.ts

@@ -0,0 +1,26 @@
+'use client'
+
+import { useLayoutEffect } from 'react'
+import { ALL_THEME_CLASSES } from '@/lib/themes'
+
+/**
+ * Pins the document to the light theme for public pages (portal, share links),
+ * regardless of the workshop user's own theme. Any preset classes are stripped
+ * as well, then restored when the page unmounts.
+ */
+export function useForceLightTheme() {
+  useLayoutEffect(() => {
+    const root = document.documentElement
+    const previous = ALL_THEME_CLASSES.filter((cls) => root.classList.contains(cls))
+
+    root.setAttribute('data-force-theme', 'light')
+    root.classList.remove(...ALL_THEME_CLASSES)
+    root.classList.add('light')
+
+    return () => {
+      root.removeAttribute('data-force-theme')
+      root.classList.remove(...ALL_THEME_CLASSES)
+      root.classList.add(...previous)
+    }
+  }, [])
+}

+ 94 - 0
src/lib/themes.ts

@@ -0,0 +1,94 @@
+/**
+ * Theme registry.
+ *
+ * Every theme is a set of CSS custom properties declared in globals.css. The
+ * two base themes (`light`, `dark`) live on `:root` / `.dark`; the presets add
+ * a `theme-<id>` class on top of their base mode class, so a dark preset is
+ * applied as `class="dark theme-midnight"`. Keeping the mode class means the
+ * `dark:` Tailwind variant and the `.dark`-scoped utilities keep working for
+ * every preset.
+ */
+
+export type ThemeId = 'light' | 'dark' | 'graphite' | 'ocean' | 'forest' | 'midnight' | 'carbon'
+
+/** What the user picks: a theme, or "follow the OS". */
+export type ThemePreference = ThemeId | 'system'
+
+export interface ThemeDefinition {
+  id: ThemeId
+  mode: 'light' | 'dark'
+  /** Literal colors for the settings preview: [background, surface, primary]. */
+  swatch: [string, string, string]
+}
+
+export const THEMES: ThemeDefinition[] = [
+  {
+    id: 'light',
+    mode: 'light',
+    swatch: ['oklch(0.98 0.008 85)', 'oklch(0.93 0.04 80)', 'oklch(0.72 0.17 75)'],
+  },
+  {
+    id: 'dark',
+    mode: 'dark',
+    swatch: ['oklch(0.12 0.01 70)', 'oklch(0.24 0.03 75)', 'oklch(0.78 0.16 80)'],
+  },
+  {
+    id: 'graphite',
+    mode: 'light',
+    swatch: ['oklch(0.98 0.002 250)', 'oklch(0.93 0.02 250)', 'oklch(0.52 0.13 250)'],
+  },
+  {
+    id: 'ocean',
+    mode: 'light',
+    swatch: ['oklch(0.98 0.012 220)', 'oklch(0.91 0.045 205)', 'oklch(0.52 0.12 215)'],
+  },
+  {
+    id: 'forest',
+    mode: 'light',
+    swatch: ['oklch(0.98 0.008 140)', 'oklch(0.92 0.04 145)', 'oklch(0.5 0.12 150)'],
+  },
+  {
+    id: 'midnight',
+    mode: 'dark',
+    swatch: ['oklch(0.16 0.03 260)', 'oklch(0.3 0.05 255)', 'oklch(0.7 0.14 245)'],
+  },
+  {
+    id: 'carbon',
+    mode: 'dark',
+    swatch: ['oklch(0.15 0 0)', 'oklch(0.28 0.02 165)', 'oklch(0.72 0.15 165)'],
+  },
+]
+
+export const THEME_IDS = THEMES.map((t) => t.id)
+
+/** Storage key shared with the pre-hydration script in the root layout. */
+export const THEME_STORAGE_KEY = 'torqvoice-theme'
+
+export const DEFAULT_THEME: ThemeId = 'dark'
+
+/** Every class this module may put on <html>, so we can clear them in one go. */
+export const ALL_THEME_CLASSES = ['light', 'dark', ...THEMES.map((t) => `theme-${t.id}`)]
+
+export function isThemeId(value: string | null | undefined): value is ThemeId {
+  return !!value && (THEME_IDS as string[]).includes(value)
+}
+
+export function isThemePreference(value: string | null | undefined): value is ThemePreference {
+  return value === 'system' || isThemeId(value)
+}
+
+export function getThemeMode(id: ThemeId): 'light' | 'dark' {
+  return THEMES.find((t) => t.id === id)?.mode ?? 'dark'
+}
+
+/** The classes that make up a theme: its mode, plus a preset class when needed. */
+export function getThemeClasses(id: ThemeId): string[] {
+  const mode = getThemeMode(id)
+  return id === mode ? [mode] : [mode, `theme-${id}`]
+}
+
+/** Swap <html> over to `id`, removing whatever theme was there before. */
+export function applyTheme(root: HTMLElement, id: ThemeId) {
+  root.classList.remove(...ALL_THEME_CLASSES)
+  root.classList.add(...getThemeClasses(id))
+}