Add 'system' theme option that follows OS prefers-color-scheme

This commit is contained in:
Alex Gleason
2026-02-24 20:56:17 -06:00
parent fb2e2e6678
commit b42ca9434a
9 changed files with 136 additions and 27 deletions
+6 -2
View File
@@ -11,9 +11,13 @@
var theme = 'dark';
try {
var cfg = JSON.parse(localStorage.getItem('nostr:app-config') || '{}');
if (cfg.theme && themes[cfg.theme]) theme = cfg.theme;
if (cfg.theme && (themes[cfg.theme] || cfg.theme === 'system')) theme = cfg.theme;
} catch (e) {}
var t = themes[theme];
// Resolve "system" to light or dark based on OS preference
if (theme === 'system') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
var t = themes[theme] || themes.dark;
document.documentElement.className = theme;
document.body.style.background = t.bg;
var p = document.getElementById('preloader');
+26 -12
View File
@@ -2,7 +2,7 @@ import { ReactNode, useEffect } from 'react';
import { z } from 'zod';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext';
import { themes, buildThemeCss } from '@/themes';
import { themes, buildThemeCss, resolveTheme } from '@/themes';
import { ThemeSchema, FeedSettingsSchema, ContentWarningPolicySchema } from '@/lib/schemas';
interface AppProviderProps {
@@ -101,21 +101,35 @@ export function AppProvider(props: AppProviderProps) {
/**
* Hook to apply theme changes to the document root via an injected <style> tag.
* When theme is "system", resolves to "light" or "dark" based on OS preference
* and listens for changes to prefers-color-scheme.
*/
function useApplyTheme(theme: Theme) {
useEffect(() => {
const tokens = themes[theme] ?? themes.dark;
const css = buildThemeCss(tokens);
function apply() {
const resolved = resolveTheme(theme);
const tokens = themes[resolved] ?? themes.dark;
const css = buildThemeCss(tokens);
let el = document.getElementById('theme-vars') as HTMLStyleElement | null;
if (!el) {
el = document.createElement('style');
el.id = 'theme-vars';
document.head.appendChild(el);
let el = document.getElementById('theme-vars') as HTMLStyleElement | null;
if (!el) {
el = document.createElement('style');
el.id = 'theme-vars';
document.head.appendChild(el);
}
el.textContent = css;
// Now that CSS variables are set, the inline body background from
// theme.js is no longer needed — bg-background will resolve correctly.
document.body.removeAttribute('style');
}
apply();
// When theme is "system", listen for OS color scheme changes
if (theme === 'system') {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
}
el.textContent = css;
// Now that CSS variables are set, the inline body background from
// theme.js is no longer needed — bg-background will resolve correctly.
document.body.removeAttribute('style');
}, [theme]);
}
+15 -7
View File
@@ -209,7 +209,8 @@ function SyncScreen({ phase }: { phase: SyncPhase }) {
// Setup Questionnaire
// ---------------------------------------------------------------------------
const THEMES: { value: Theme; label: string; description: string; preview: string }[] = [
const THEMES: { value: Theme; label: string; description: string; preview: string; splitPreview?: boolean }[] = [
{ value: 'system', label: 'System', description: 'Matches your device', preview: '', splitPreview: true },
{ value: 'dark', label: 'Dark', description: 'Deep purple dark theme', preview: 'bg-[hsl(228,20%,10%)]' },
{ value: 'light', label: 'Light', description: 'Clean and bright', preview: 'bg-white border border-border' },
{ value: 'black', label: 'Black', description: 'True OLED black', preview: 'bg-black' },
@@ -963,12 +964,19 @@ function ThemeStep({
: 'ring-1 ring-border',
)}
>
<div
className={cn(
'w-14 h-14 rounded-full transition-transform duration-200 group-hover:scale-110',
theme.preview,
)}
/>
{theme.splitPreview ? (
<div className="w-14 h-14 rounded-full overflow-hidden transition-transform duration-200 group-hover:scale-110 flex">
<div className="w-1/2 h-full bg-white" />
<div className="w-1/2 h-full bg-[hsl(228,20%,10%)]" />
</div>
) : (
<div
className={cn(
'w-14 h-14 rounded-full transition-transform duration-200 group-hover:scale-110',
theme.preview,
)}
/>
)}
<div className="space-y-0.5 text-center">
<p className="text-sm font-medium">{theme.label}</p>
<p className="text-xs text-muted-foreground">{theme.description}</p>
+2 -1
View File
@@ -1,6 +1,6 @@
import { useState, useMemo, useCallback } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Home, Bell, Search, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText, User, Settings, Bookmark, UserPlus, LogOut, Check, Moon, Sun, Heart, ChevronDown, Plus } from 'lucide-react';
import { Home, Bell, Search, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText, User, Settings, Bookmark, UserPlus, LogOut, Check, Moon, Sun, Heart, Monitor, ChevronDown, Plus } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { ChestIcon } from '@/components/icons/ChestIcon';
import { CardsIcon } from '@/components/icons/CardsIcon';
@@ -151,6 +151,7 @@ export function LeftSidebar() {
};
const themes: { value: Theme; label: string; icon: React.ReactNode }[] = [
{ value: 'system', label: 'System', icon: <Monitor className="size-4" /> },
{ value: 'dark', label: 'Dark', icon: <Palette className="size-4" /> },
{ value: 'light', label: 'Light', icon: <Sun className="size-4" /> },
{ value: 'black', label: 'Black', icon: <Moon className="size-4" /> },
+2 -1
View File
@@ -1,5 +1,5 @@
import { Link, useNavigate } from 'react-router-dom';
import { User, Bookmark, Settings, LogOut, ChevronDown, ChevronUp, Sun, Moon, Heart, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText } from 'lucide-react';
import { User, Bookmark, Settings, LogOut, ChevronDown, ChevronUp, Sun, Moon, Heart, Monitor, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
@@ -78,6 +78,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
}, [feedSettings]);
const themes: { value: Theme; label: string; icon: React.ReactNode }[] = [
{ value: 'system', label: 'System', icon: <Monitor className="size-5" /> },
{ value: 'dark', label: 'Dark', icon: <Palette className="size-5" /> },
{ value: 'light', label: 'Light', icon: <Sun className="size-5" /> },
{ value: 'black', label: 'Black', icon: <Moon className="size-5" /> },
+69
View File
@@ -11,6 +11,7 @@ interface ThemeOption {
}
const themeOptions: ThemeOption[] = [
{ id: 'system', label: 'System' },
{ id: 'light', label: 'Light', tokens: themes.light },
{ id: 'dark', label: 'Dark', tokens: themes.dark },
{ id: 'black', label: 'Black', tokens: themes.black },
@@ -30,6 +31,74 @@ export function ThemeSelector() {
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{themeOptions.map((option) => {
if (option.id === 'system') {
const isActive = theme === 'system';
const lightTokens = themes.light;
const darkTokens = themes.dark;
return (
<button
key="system"
className={cn(
'relative group rounded-xl border-2 p-1 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive
? 'border-primary shadow-sm'
: 'border-border hover:border-primary/40',
)}
onClick={() => setTheme('system')}
>
{/* Split preview: left light, right dark */}
<div className="aspect-[4/3] rounded-lg overflow-hidden relative">
{/* Light half */}
<div
className="absolute inset-0 w-1/2"
style={{ backgroundColor: hsl(lightTokens.background) }}
>
<div className="h-2.5 w-full" style={{ backgroundColor: hsl(lightTokens.card) }} />
<div className="p-1.5 space-y-1">
<div className="h-1 w-3/4 rounded-full" style={{ backgroundColor: hsl(lightTokens.foreground), opacity: 0.6 }} />
<div className="h-1 w-1/2 rounded-full" style={{ backgroundColor: hsl(lightTokens.mutedForeground), opacity: 0.4 }} />
<div className="pt-0.5">
<div className="h-2 w-8 rounded-sm" style={{ backgroundColor: hsl(lightTokens.primary) }} />
</div>
</div>
</div>
{/* Dark half */}
<div
className="absolute inset-0 left-1/2"
style={{ backgroundColor: hsl(darkTokens.background) }}
>
<div className="h-2.5 w-full" style={{ backgroundColor: hsl(darkTokens.card) }} />
<div className="p-1.5 space-y-1">
<div className="h-1 w-3/4 rounded-full" style={{ backgroundColor: hsl(darkTokens.foreground), opacity: 0.6 }} />
<div className="h-1 w-1/2 rounded-full" style={{ backgroundColor: hsl(darkTokens.mutedForeground), opacity: 0.4 }} />
<div className="pt-0.5">
<div className="h-2 w-8 rounded-sm" style={{ backgroundColor: hsl(darkTokens.primary) }} />
</div>
</div>
</div>
{/* Active check mark */}
{isActive && (
<div className="absolute top-1 left-1 size-4 rounded-full flex items-center justify-center"
style={{ backgroundColor: hsl(lightTokens.primary) }}
>
<Check className="size-2.5" style={{ color: hsl(lightTokens.primaryForeground) }} />
</div>
)}
</div>
{/* Label */}
<p className={cn(
'mt-1.5 text-xs font-medium text-center transition-colors',
isActive ? 'text-foreground' : 'text-muted-foreground',
)}>
{option.label}
</p>
</button>
);
}
if (option.id === 'custom') {
return (
<button
+5 -1
View File
@@ -1,6 +1,10 @@
import { createContext } from "react";
export type Theme = "dark" | "light" | "black" | "pink";
/** A concrete theme with its own color palette. */
export type ConcreteTheme = "dark" | "light" | "black" | "pink";
/** User-facing theme preference. "system" resolves to "light" or "dark" based on OS preference. */
export type Theme = ConcreteTheme | "system";
/**
* How to handle events with a NIP-36 content-warning tag.
+1 -1
View File
@@ -3,7 +3,7 @@ import { z } from 'zod';
import type { Theme, ContentWarningPolicy } from '@/contexts/AppContext';
/** Zod schema for Theme validation */
export const ThemeSchema = z.enum(['dark', 'light', 'black', 'pink']) satisfies z.ZodType<Theme>;
export const ThemeSchema = z.enum(['dark', 'light', 'black', 'pink', 'system']) satisfies z.ZodType<Theme>;
/** Zod schema for ContentWarningPolicy validation */
export const ContentWarningPolicySchema = z.enum(['blur', 'hide', 'show']) satisfies z.ZodType<ContentWarningPolicy>;
+10 -2
View File
@@ -1,4 +1,4 @@
import type { Theme } from '@/contexts/AppContext';
import type { ConcreteTheme, Theme } from '@/contexts/AppContext';
export interface ThemeTokens {
background: string;
@@ -30,7 +30,7 @@ export interface ThemeTokens {
sidebarRing: string;
}
export const themes: Record<Theme, ThemeTokens> = {
export const themes: Record<ConcreteTheme, ThemeTokens> = {
light: {
background: '0 0% 100%',
foreground: '222.2 84% 4.9%',
@@ -164,3 +164,11 @@ export function buildThemeCss(tokens: ThemeTokens): string {
.join(' ');
return `:root { ${vars} }`;
}
/** Resolves a theme preference to a concrete theme. "system" maps to "light" or "dark" based on OS preference. */
export function resolveTheme(theme: Theme): ConcreteTheme {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return theme;
}